1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

カメラサーバーに音声を追加しました。

Last updated at Posted at 2025-02-05

XIAO ESP32S3 Sense で Video & Audio 配信

前回、Xiao ESP32S3 Sense画像の配信をやって見ました。

このモジュールにはマイクも付いていたので、音声も画像と一緒に送れないかやって見ました。

マイクはI2Sで読み込むようです。
arduino-audio-toolsのライブラリーを使うことにしました。

音声の送信はどうやるのかを調べて見た結果。
読んだデータを送る方法は色々あって、一度WAVファイル形式にして送る方法だと、
遅延が10秒近くあり使えないと思い、色々検索していたら、YouTubeで良さそうなのを見つけました。

音声の送信

映像と音声の送信 どちらも音声はかなり遅延が少ないように見えました。 2つ目は映像と音声を同時に送信してるようです。(少しかくかくでエラーが発生してるようですが・・・)

どちらも音声の再生にPCM-Playerと言う、Web Audio APIを利用したjavascriptを使用してるようです。
htmlファイルの中で

index.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を以下のようにして下さい。

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の中身に以下をコピペして下さい。

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の中に、以下をコピペして下さい。

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モードの場合は、先頭の方のSSIDPASSWORDをルータの値に書き換えてください。
ルータのSSID2.4Ghzの方を選択して下さい。5Ghzには対応していません。

main.cpp
//#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にして下さい。

main.cpp
camera_config_t config = {
    :
    .fb_count = 20, // ここを大きくしてわざと遅延さてます。(普通は1か2です)
    :
}

 あと、スマホで再生する時に音声が自動で再生されなかったので、ネットで調べたら、
ボタンを押すとか、何かのアクションをした後でないと再生されない仕様の様なので、
HTMLにボタンを加え、そこを押したら音声が再生されるようにしました。

以上です。

uploading...0

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?