XIAO ESP32S3 Sense で Video & Audio 配信
前回、Xiao ESP32S3 Senseで画像の配信をやって見ました。
このモジュールにはマイクも付いていたので、音声も画像と一緒に送れないかやって見ました。
マイクはI2Sで読み込むようです。
arduino-audio-toolsのライブラリーを使うことにしました。
音声の送信はどうやるのかを調べて見た結果。
読んだデータを送る方法は色々あって、一度WAVファイル形式にして送る方法だと、
遅延が10秒近くあり使えないと思い、色々検索していたら、YouTubeで良さそうなのを見つけました。
音声の送信
映像と音声の送信 どちらも音声はかなり遅延が少ないように見えました。 2つ目は映像と音声を同時に送信してるようです。(少しかくかくでエラーが発生してるようですが・・・)どちらも音声の再生にPCM-Playerと言う、Web Audio APIを利用したjavascriptを使用してるようです。
htmlファイルの中で
<script src="https://unpkg.com/pcm-player"></script>
pcm-player.jsを読み込んでいました。
これを使えば再生できそうです。
上記の方法だとオンラインでないと、使えないので、
オフラインでも使えるように、PCM Playerで検索した。以下の所から
samirkumardas/pcm-player
「pcm-player.js」をダウンロードして使いたいと思います。
これをダウンロードして置いて下さい。
今回は、映像も音声もWebsocketを使って送信します。
音声送信のサンプルがWebsocketだったので、映像も合わせることにしました。
Websocketだと双方向通信ができるので、後から色々追加するのも楽です。
例えば、HTMLにボタンを配置して、クライアント側からESP32に何かメッセージを送ってロボットを動かすとか簡単に出来ます。
プログラム
プログラムは、VSCode + PlatformIOで行います。
新規プロジェクトで、
Name: Cam_Audio_websocket
Board: Seeed Studio XIAO ESP32S3
Framework: Arduino
として下さい。(Nameは好きに付けていいです。)
platformio.iniを以下のようにして下さい。
[env:seeed_xiao_esp32s3]
platform = espressif32
board = seeed_xiao_esp32s3
framework = arduino
monitor_speed = 115200
board_build.filesystem = spiffs
board_build.partitions = huge_app.csv
build_flags =
-D ARDUINO_USB_CDC_ON_BOOT=1
-D ARDUINO_USB_MODE=1
-D BOARD_HAS_PSRAM=1
-D CONFIG_ASYNC_TCP_QUEUE_SIZE=65536
-D CONFIG_ASYNC_TCP_RUNNING_CORE=1
-D CONFIG_ASYNC_TCP_STACK_SIZE=4096
-D WS_MAX_QUEUED_MESSAGES=1
lib_deps =
esphome/ESPAsyncWebServer-esphome@^3.3.0
links2004/WebSockets@^2.6.1
https://github.com/pschatzmann/arduino-audio-tools.git
dataフォルダを以下の所に新たに作り、中に「index.html」ファイルを新たに作ってください。
また、先ほどダウンロードした「pcm-player.js」をドラック&ドロップして、dataフォルダに入れて下さい。

index.htmlの中身に以下をコピペして下さい。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Video and Audio Streamer</title>
</head>
<style>
.button {
padding: 15px 32px;
text-align: center;
background-color: orange;
font-size: 30px
}
img { transform : scale(1,1); max-width: 100%; height: auto;}
</style>
<body onload="init()">
<div style="text-align:center;">
<img id="img" alt="" src="/"/>
<br>
<div id="msg">FPS</div>
<br>
<button id="btn" class="button" onclick="ws_audioStart()">Audio Start</button>
</div>
<script src="pcm-player.js"></script> <!-- PCMPlayerライブラリの読み込み(Audio用) -->
<script language="javascript" type="text/javascript">
const img = document.getElementById('img');
const btn = document.getElementById('btn');
const msg = document.getElementById('msg');
var fps = 0;
var st, et;
var player = new PCMPlayer({encoding:'16bitInt', channels:1, sampleRate: 16000}); // PCMPlayerのインスタンス生成
player.volume(1.0); // ボリュームの設定(0.0~1.0)
function init(){ // ページ読み込み時に実行
ws_videoStart(); // Video用Websocket 開始
st = performance.now();
}
function ws_audioStart() { // ボタンクリック時の処理(スマホだとボタン入力などの行為の後で無いと、音声再生されない為の処理)
var ws_audio = new WebSocket('ws://' + window.location.hostname + ':81/'); //Audio用 WebSocket接続
ws_audio.binaryType = 'arraybuffer'; // バイナリデータの受信形式をarraybufferに設定
ws_audio.onmessage = onAudioMessage; // メッセージを受信した時の処理を設定
btn.style.visibility = 'hidden'; // ボタンを非表示にする
}
function onAudioMessage(event){ // Audioデータを受信したら
var data = new Uint16Array(event.data); // Audio受信データをUint16Arrayに変換
player.feed(data); // PCMPlayerにデータをフィード
}
function ws_videoStart(){ // Video用 Websocketを開始する
var ws_video = new WebSocket('ws://' + window.location.hostname + ':82/');
ws_video.binaryType = 'blob'; // バイナリーデータに Blob オブジェクトを使用します。これが既定値です。(非同期に処理される)
ws_video.onmessage = onVideoMessage; // メッセージを受信した時
}
function onVideoMessage(event){ // Videoデータを受信したら
let data = event.data;
if(data instanceof Blob){ // Blob なら
drawBlob(data); // Blob画像描画
disp_fps(); // FPS表示
}
}
var urlObject = null;
function drawBlob(data){ // Blobデータ画像を描画
let blobObj = new Blob([data]);
if (urlObject) {
URL.revokeObjectURL(urlObject); // URLを破棄
}
urlObject = URL.createObjectURL(blobObj);
img.src = urlObject; // 画像表示
}
function disp_fps(){
fps++;
et = performance.now();
if(et - st >= 1000){
msg.innerHTML = fps +" FPS";
fps = 0;
st = et;
}
}
</script>
</body>
</html>
次に、main.cppの中に、以下をコピペして下さい。
#include <Arduino.h>
#include <WiFi.h>
#include <ESPAsyncWebServer.h>
#include <SPIFFS.h>
#include "AudioTools.h"
#include <WebSocketsServer.h>
#include <esp_camera.h>
//#define WIFI_DIRECT // <<--- APモードにするなら、ここのコメントアウトを外して下さい。
// 接続最大クライアント数
#define CLIENT_MAX 4
#ifdef WIFI_DIRECT
#define SSID "ESP32_CAM" // APの名前
#define PASSWORD "12345678" // APのパスワード(8文字以上)
const IPAddress ip(192,168,7,1); // IPアドレス
const IPAddress subnet(255,255,255,0); // サブネットマスク
#else
#define SSID "********" // WiFiルータのSSID
#define PASSWORD "********" // WiFiルータのパスワード
#endif
// I2S pins
#define I2S_WS 42
#define I2S_SCK -1
#define I2S_SD 41
#define I2S_PORT I2S_NUM_0 // XIAO ESP32S3 SenceのI2Sは1個のみ
#define SAMPLE_RATE 16000 // Sample rate of the audio
#define SAMPLE_BITS 16
#define DMA_BUF_COUNT 2
#define DMA_BUF_LEN 1024
#define VOLUME_GAIN 8
AsyncWebServer server(80);
WebSocketsServer ws_audio = WebSocketsServer(81);
WebSocketsServer ws_video = WebSocketsServer(82);
I2SStream i2sStream; // Access I2S as stream
camera_fb_t *fb = NULL; // Cameraデータ
bool isWebSocketAudioConnected[CLIENT_MAX];
bool isWebSocketVideoConnected[CLIENT_MAX];
const size_t bufferSize = DMA_BUF_LEN/2; // samples of int16_t
uint16_t audioBuffer[bufferSize]; // 16bit PCM buffer
void cam_init(){ // カメラ初期化 [XIAO ESP32S3 Sence 専用]
camera_config_t config = {
.pin_pwdn = -1,
.pin_reset = -1,
.pin_xclk = 10,
.pin_sscb_sda = 40,
.pin_sscb_scl = 39,
.pin_d7 = 48,
.pin_d6 = 11,
.pin_d5 = 12,
.pin_d4 = 14,
.pin_d3 = 16,
.pin_d2 = 18,
.pin_d1 = 17,
.pin_d0 = 15,
.pin_vsync = 38,
.pin_href = 47,
.pin_pclk = 13,
.xclk_freq_hz = 20000000, // 20MHz
.ledc_timer = LEDC_TIMER_1, // 1番のタイマー使用
.ledc_channel = LEDC_CHANNEL_0, // 0番のチャネル使用
.pixel_format = PIXFORMAT_JPEG, // JPEG
.frame_size = FRAMESIZE_SVGA, // 解像度 (800x600)
.jpeg_quality = 12, // JPGE画質(小さいほど高画質)
.fb_count = 20, // フレームバッファ数(多くすると滑らかになるが、遅延が増える。音声に合わせて、わざと遅延させてます。普通は1か2)
.fb_location = CAMERA_FB_IN_PSRAM,
.grab_mode = CAMERA_GRAB_LATEST // CAMERA_GRAB_WHEN_EMPTY
};
esp_err_t result = esp_camera_init(&config);
if (result != ESP_OK) {
Serial.printf("esp_camera_init error 0x%x\n", result);
}
sensor_t *s = esp_camera_sensor_get(); // 鮮度やコントラストなど変更する場合
s->set_vflip(s, 0); // 垂直flip 0 or 1
s->set_hmirror(s, 0); // 左右反転 0 or 1
// s->set_brightness(s, -1); // 輝度 -2 - 2
// s->set_contrast(s, 1); // コントラスト -2 - 2
// s->set_saturation(s, 2); // 彩度 -2 - 2
s->set_denoise(s, 1); // ノイズ除去
}
void wifi_start() // WiFiを [APモード] または [STAモード] で開始する
{
#ifdef WIFI_DIRECT // WIFI_DIRECTが定義されていたらAPモード
WiFi.mode(WIFI_AP); // Wifi AP モード
if(PASSWORD != "")
WiFi.softAP(SSID,PASSWORD);
else
WiFi.softAP(SSID);
WiFi.softAPConfig(ip,ip,subnet);
#else
delay(3000);
WiFi.mode(WIFI_STA); // Wifi STA モード
WiFi.begin(SSID, PASSWORD);
Serial.print("\nWifi Connecting");
while (WiFi.status() != WL_CONNECTED) {
delay(500);
Serial.print(".");
}
Serial.println("\n WiFi connected");
Serial.printf("Go to: http://%s\n", WiFi.localIP().toString().c_str()); // IPアドレスを表示
#endif
WiFi.setTxPower(WIFI_POWER_19_5dBm); // 出力パワー設定
}
int countVideoClient(){ // Videoに接続されてるクライアント数を数える
int i,num = 0;
for(i = 0; i < CLIENT_MAX; i++){
if(isWebSocketVideoConnected[i]){
num++;
}
}
return num;
}
int countAudioClient(){ // Audioに接続されてるクライアント数を数える
int i,num = 0;
for(i = 0; i < CLIENT_MAX; i++){
if(isWebSocketAudioConnected[i]){
num++;
}
}
return num;
}
// WebSocket Event Handler 音声用
void webSocketAudioEvent(uint8_t client_num, WStype_t type, uint8_t * payload, size_t length) {
switch (type) {
case WStype_DISCONNECTED:
Serial.printf("[%u] WebSocket Audio Disconnected!\n", client_num);
isWebSocketAudioConnected[client_num] = false;
break;
case WStype_CONNECTED:
Serial.printf("[%u] WebSocket Audio Connected!\n", client_num);
isWebSocketAudioConnected[client_num] = true;
break;
default:
break;
}
}
// WebSocket Event Handler ビデオ用
void webSocketVideoEvent(uint8_t client_num, WStype_t type, uint8_t * payload, size_t length) {
switch (type) {
case WStype_DISCONNECTED:
Serial.printf("[%u] WebSocket Video Disconnected!\n", client_num);
isWebSocketVideoConnected[client_num] = false;
if(countVideoClient() == 0) digitalWrite(LED_BUILTIN, HIGH); // 誰も接続されてないなら LED Off
break;
case WStype_CONNECTED:
Serial.printf("[%u] WebSocket Video Connected!\n", client_num);
isWebSocketVideoConnected[client_num] = true;
digitalWrite(LED_BUILTIN, LOW); // LED On
break;
default:
break;
}
}
void mic_i2s_init() { // マイクの初期化
const i2s_config_t i2sConfig = {
.mode = (i2s_mode_t)(I2S_MODE_MASTER | I2S_MODE_RX | I2S_MODE_PDM),
.sample_rate = SAMPLE_RATE ,
.bits_per_sample = i2s_bits_per_sample_t(SAMPLE_BITS),
.channel_format = I2S_CHANNEL_FMT_ONLY_LEFT,
.communication_format = I2S_COMM_FORMAT_STAND_I2S,
.intr_alloc_flags = 0,
.dma_buf_count = DMA_BUF_COUNT,
.dma_buf_len = DMA_BUF_LEN,
.use_apll = true
};
i2s_driver_install(I2S_PORT, &i2sConfig, 0, NULL);
i2s_pin_config_t pinConfig = {
.bck_io_num = I2S_SCK,
.ws_io_num = I2S_WS ,
.data_out_num = I2S_PIN_NO_CHANGE,
.data_in_num = I2S_SD
};
i2s_set_pin(I2S_PORT, &pinConfig);
}
void loop2(){ // カメラ画像をWebSocketで送信
ws_video.loop();
if (countVideoClient() > 0) { // WebSocket接続中なら
fb = esp_camera_fb_get(); // カメラ画像データ取得
if(fb){
// ws_video.sendBIN(0, (uint8_t*)fb->buf, fb->len); // 画像データを送信(これだと1つのクライアントにしか送れない)
ws_video.broadcastBIN((uint8_t*)fb->buf, fb->len); // 複数のクライアントに画像送信
esp_camera_fb_return(fb); // カメラのフレームバッファ解放
fb = NULL;
}else{
Serial.println("Camera error!");
}
}
}
void setup2(void *arg){ // カメラ画像送信用のタスク
cam_init(); // カメラ初期化
ws_video.begin(); // WebSocket Video Server
ws_video.onEvent(webSocketVideoEvent);
while (1){
loop2();
delay(1);
}
}
void setup(){
Serial.begin(115200);
pinMode(LED_BUILTIN, OUTPUT);
digitalWrite(LED_BUILTIN, HIGH); // LED Off
if(!SPIFFS.begin(true)){ // SPIFFSのセットアップ
SPIFFS.format();
SPIFFS.begin(true);
}
mic_i2s_init(); // マイクの初期化
wifi_start(); // Wi-Fiに接続
server.on("/", HTTP_GET, [](AsyncWebServerRequest *request){
IPAddress remote_ip = request->client()->remoteIP();
Serial.println("[" + remote_ip.toString() + "] HTTP GET request of " + request->url());
request->send(SPIFFS, "/index.html");
});
#define PCM_PLAYER_JS "/pcm-player.js"
server.on(PCM_PLAYER_JS, HTTP_GET, [](AsyncWebServerRequest *request){
IPAddress remote_ip = request->client()->remoteIP();
Serial.println("[" + remote_ip.toString() + "] HTTP GET request of " + request->url());
request->send(SPIFFS, PCM_PLAYER_JS);
});
for(int i = 0; i < CLIENT_MAX; i++){
isWebSocketAudioConnected[i] = false;
isWebSocketVideoConnected[i] = false;
}
server.begin();
ws_audio.begin(); // WebSocket Audio Server
ws_audio.onEvent(webSocketAudioEvent);
Serial.println("starting stream...");
xTaskCreatePinnedToCore(setup2, "Core0", 4096, NULL, 3, NULL, 0); // カメラ画像送信用のタスクを作成(Core0)
}
void loop() {
ws_audio.loop();
if (countAudioClient() > 0) {
size_t bytesIn = 0;
esp_err_t result = i2s_read(I2S_PORT, (uint8_t*)audioBuffer, sizeof(audioBuffer), &bytesIn, portMAX_DELAY); // I2S経由で録音
for(int i = 0; i < bufferSize; i++) {
audioBuffer[i] *= VOLUME_GAIN; // Volume UP
}
// ws_audio.sendBIN(0, (uint8_t*)audioBuffer, bytesIn); // WebSocketで音声送信(これだと1つのクライアントにしか送れない)
ws_audio.broadcastBIN((uint8_t*)audioBuffer, bytesIn); // 複数のクライアントに音声送信
}
}
WiFiはAPモードでもSTAモードでも出来るようにしました。
APモードなら直接スマホと通信できます。
プログラムの先頭の方の //#define WIFI_DIRECT の所のコメントアウトを外すとAPモードに成ります。
STAモードの場合は、先頭の方のSSIDとPASSWORDをルータの値に書き換えてください。
ルータのSSIDは2.4Ghzの方を選択して下さい。5Ghzには対応していません。
//#define WIFI_DIRECT // <<--- APモードにするなら、ここのコメントアウトを外して下さい。
#ifdef WIFI_DIRECT
#define SSID "ESP32_CAM" // APの名前
#define PASSWORD "12345678" // APのパスワード(8文字以上)
const IPAddress ip(192,168,7,1); // IPアドレス
const IPAddress subnet(255,255,255,0); // サブネットマスク
#else
#define SSID "********" // WiFiルータのSSID
#define PASSWORD "********" // WiFiルータのパスワード
#endif
マルチコアを利用し画像はコア0、音声はコア1で処理しています。
プログラムの書き込み
Xiao ESP32S3 SenseをPCに接続し、bootボタンを押しながら、リセットボタンを押して、両方放してください。これで書き込みモードに成ります。これをやらなくても書き込める場合もあります。
先ずは、dataフォルダーの中身をSPIFFSに書き込みます。
以下のようにクリックして下さい。
コンソールに success と出れば成功です。
プログラムの書き込みは一番下のアイコンの[➡]矢印をクリックするだけです。
書き込みが成功した後、bootモードで書き込みした場合は、リセットする必要があります。
使い方
STAモードの時
起動時にVScodeのシリアルモニターに表示されたIPアドレスをメモして置いて下さい。
PCでブラウザを起動し、アドレスにメモしたIPアドレスを書き込むと、カメラの画像がストリーミングで表示されます。
APモードの時
APモードにした時は、スマホのWiFi接続先を[ESP32_CAM]にし、パスワードに[12345678]と入力し接続して下さい。
(「ネットに繋がってないので、ほかのWiFiに繋げていいですか?」見たいなメッセージが出る場合がありますが、必ず[拒否]して下さい。)
その後、ブラウザを起動し、URLに[192.168.7.1]と入力するとカメラの画像がストリーミングで表示されます。
遅延はどうなの?
映像の方の遅延は殆どなくスムーズに再生されるのですが、
音声の方が約1秒ぐらい遅延します。10秒の遅延よりは、ましになりました。
映像と音声を合わせたくて、わざと映像を遅延させてます。
カメラの設定の所で、.fb_conty の値を大きくして、音声と合わせるようにして見ました。
適当な数値ですので、色々試してみてください。
あまり大きくするとメモリーオーバーでクラッシュしますので、気をつけてください。
画像を遅延させたくない場合は、.fb_countの値を1か2にして下さい。
camera_config_t config = {
:
.fb_count = 20, // ここを大きくしてわざと遅延さてます。(普通は1か2です)
:
}
あと、スマホで再生する時に音声が自動で再生されなかったので、ネットで調べたら、
ボタンを押すとか、何かのアクションをした後でないと再生されない仕様の様なので、
HTMLにボタンを加え、そこを押したら音声が再生されるようにしました。
以上です。