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

# XIAO ESP32S3 Sense で Video & Audio 配信
前回、**Xiao ESP32S3 Sense**で**画像**の配信をやって見ました。
 
https://qiita.com/kobatan/items/885dd9f8e3584bff71ba
 
このモジュールには**マイク**も付いていたので、音声も画像と一緒に送れないかやって見ました。
<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2520758/a711d561-5077-c957-d25f-5959e1ffb212.jpeg">

マイクはI2Sで読み込むようです。
[arduino-audio-tools](https://github.com/pschatzmann/arduino-audio-tools)のライブラリーを使うことにしました。

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

音声の送信
<iframe width="200" height="100" src="https://www.youtube.com/embed/qq2FRv0lCPw" title="Broadcasting Your Voice with ESP32-S3 &amp; INMP441" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
映像と音声の送信
<iframe width="200" height="100" src="https://www.youtube.com/embed/Hi-6Y12Jhgs" title="ESP32-S3 USB Camera &amp; Audio WebSocket Test: UVC + UAC Data Streaming Demo (ft. WebSocket)" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" referrerpolicy="strict-origin-when-cross-origin" allowfullscreen></iframe>
どちらも音声はかなり遅延が少ないように見えました。
２つ目は映像と音声を同時に送信してるようです。（少しかくかくでエラーが発生してるようですが・・・）

どちらも音声の再生に**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](https://github.com/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フォルダに入れて下さい。

<img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2520758/1359260b-caf0-b4fc-ced0-4b1a2db5fe46.jpeg">

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のパスワード（８文字以上）
	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は１個のみ
#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, 			    // フレームバッファ数(多くすると滑らかになるが、遅延が増える。音声に合わせて、わざと遅延させてます。普通は１か２）
	.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);   // 画像データを送信（これだと１つのクライアントにしか送れない）
      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で音声送信（これだと１つのクライアントにしか送れない）
    ws_audio.broadcastBIN((uint8_t*)audioBuffer, bytesIn); // 複数のクライアントに音声送信
  }
}
```
WiFiは**APモード**でも**STAモード**でも出来るようにしました。
APモードなら直接スマホと通信できます。

プログラムの先頭の方の //#define WIFI_DIRECT の所のコメントアウトを外すとAPモードに成ります。
STAモードの場合は、先頭の方の**SSID**と**PASSWORD**をルータの値に書き換えてください。
ルータの**SSID**は**2.4Ghz**の方を選択して下さい。5Ghzには対応していません。
```main.cpp
//#define WIFI_DIRECT	// ＜＜---　APモードにするなら、ここのコメントアウトを外して下さい。

#ifdef WIFI_DIRECT
	#define SSID	"ESP32_CAM"				// APの名前
	#define PASSWORD "12345678"				// APのパスワード（８文字以上）
	const IPAddress ip(192,168,7,1);	   	// IPアドレス
	const IPAddress subnet(255,255,255,0); 	// サブネットマスク
#else
	#define SSID "********"			// WiFiルータのSSID
	#define PASSWORD "********"		// WiFiルータのパスワード
#endif
```
マルチコアを利用し画像はコア０、音声はコア１で処理しています。

## プログラムの書き込み
　Xiao ESP32S3 SenseをPCに接続し、bootボタンを押しながら、リセットボタンを押して、両方放してください。これで書き込みモードに成ります。これをやらなくても書き込める場合もあります。
 
 先ずは、dataフォルダーの中身をSPIFFSに書き込みます。
 以下のようにクリックして下さい。
 コンソールに　success　と出れば成功です。
 <img width="200" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2520758/3dde6d83-8620-7908-7225-0897288e9e94.jpeg">

 プログラムの書き込みは一番下のアイコンの[➡]矢印をクリックするだけです。
 
 書き込みが成功した後、bootモードで書き込みした場合は、リセットする必要があります。
 
 ## 使い方
 **STAモードの時**
　起動時にVScodeのシリアルモニターに表示された**IPアドレス**をメモして置いて下さい。
PCでブラウザを起動し、アドレスにメモしたIPアドレスを書き込むと、カメラの画像がストリーミングで表示されます。

**APモードの時**
　APモードにした時は、スマホのWiFi接続先を[**ESP32_CAM**]にし、パスワードに[**12345678**]と入力し接続して下さい。
 （「ネットに繋がってないので、ほかのWiFiに繋げていいですか？」見たいなメッセージが出る場合がありますが、必ず[**拒否**]して下さい。）
その後、ブラウザを起動し、URLに[**192.168.7.1**]と入力するとカメラの画像がストリーミングで表示されます。

 ## 遅延はどうなの？
 　映像の方の遅延は殆どなくスムーズに再生されるのですが、
  音声の方が約１秒ぐらい遅延します。10秒の遅延よりは、ましになりました。
  
  映像と音声を合わせたくて、わざと映像を遅延させてます。
  カメラの設定の所で、.fb_conty の値を大きくして、音声と合わせるようにして見ました。
  適当な数値ですので、色々試してみてください。
  あまり大きくするとメモリーオーバーでクラッシュしますので、気をつけてください。
  画像を遅延させたくない場合は、.fb_countの値を1か2にして下さい。
```main.cpp
camera_config_t config = {
    :
    .fb_count = 20, // ここを大きくしてわざと遅延さてます。（普通は1か2です）
    :
}
```
　あと、スマホで再生する時に音声が自動で再生されなかったので、ネットで調べたら、
 ボタンを押すとか、何かのアクションをした後でないと再生されない仕様の様なので、
 HTMLにボタンを加え、そこを押したら音声が再生されるようにしました。


以上です。 

![uploading...0]()