技術約6分で読めます

M5Stack CoreS3に話しかけて返事をもらう。48kHz録音のmultipart POSTと同期版チャットの最小往復

いけさん目次

前回でマイク録音と再生の切り替えが単体で動いた。
今回はその録音をサーバーへ送って、返事をもらって喋らせる。

最終的には返答を文単位のチャンクで受け取る非同期方式にする。
今回は同期版(1リクエストで全部返る)で、48kHzの録音がそのままSTTに通るかと、multipart POSTの型が合っているかを確かめてみる。

構成

構成は仕様確認で決めたとおり、CoreS3からは工程1で経路を切り替えたVPS中継を叩き、Tailscale経由で自宅のRTX3050Ti 4GBの音声チャットサーバーまで転送させる。

同期版のAPIはこんな感じ。

項目内容
エンドポイントPOST /voice-relay.php?action=sync
送信multipart/form-data、フィールド名 audio、48kHz/16bit/mono WAV
返り値transcript(聞き取り)・reply(返答文)・audio_url(返答WAV)・timings をJSONで1回
特性STT→LLM→TTSが全部終わるまでブロック(ウォーム時 約12秒+中継分)

今回はSDもフィラーも使わず、工程3aの録音まわりに工程1/2のWiFi/HTTPを足しただけの構成にした。

flowchart TD
    A[タップ] --> B[5秒録音<br/>48kHz/16bit/mono]
    B --> C[RIFFヘッダ直書きでWAV化]
    C --> D[multipart POST<br/>action=sync]
    D --> E[transcript/reply受信<br/>画面表示]
    E --> F[返答WAVダウンロード<br/>約650KB]
    F --> G[再生+口パク]

RIFFヘッダを直書きしてWAVにする

録音バッファはただのPCMサンプル列なので、先頭に44バイトのRIFFヘッダを付ければWAVになる。ファイルシステムは経由しない。

// 44バイトのRIFFヘッダ直書き(PCM/16bit/モノラル)
static void write_wav_header(uint8_t* p, uint32_t samples, uint32_t rate) {
  uint32_t dataLen = samples * 2;
  memcpy(p, "RIFF", 4);
  put_u32(p + 4, 36 + dataLen);
  memcpy(p + 8, "WAVEfmt ", 8);
  put_u32(p + 16, 16);          // fmtチャンクサイズ
  put_u16(p + 20, 1);           // リニアPCM
  put_u16(p + 22, 1);           // モノラル
  put_u32(p + 24, rate);
  put_u32(p + 28, rate * 2);    // byte rate
  put_u16(p + 32, 2);           // block align
  put_u16(p + 34, 16);          // bit depth
  memcpy(p + 36, "data", 4);
  put_u32(p + 40, dataLen);
}

multipartボディはPSRAMに丸ごと組む

HTTPClientのPOSTはボディを1つのバッファで受け取るので、multipartの前置テキスト+WAV+後置テキストをPSRAMに連結して組み立てた。5秒録音でWAVが約480KB、全体でも481KB程度なのでPSRAM(8MB)には余裕で入る。

static const char* BOUND = "M5VoiceChatBoundary";
String prefix = String("--") + BOUND + "\r\n"
                "Content-Disposition: form-data; name=\"audio\"; filename=\"rec.wav\"\r\n"
                "Content-Type: audio/wav\r\n\r\n";
String suffix = String("\r\n--") + BOUND + "--\r\n";

size_t total = prefix.length() + 44 + pcmLen + suffix.length();
uint8_t* body = (uint8_t*)heap_caps_malloc(total, MALLOC_CAP_SPIRAM);
// prefix → RIFFヘッダ → PCM → suffix の順にmemcpy

http.addHeader("Content-Type", String("multipart/form-data; boundary=") + BOUND);
http.setTimeout(60000);  // LLM込み
int code = http.POST(body, total);

同期版だと、POSTの間はHTTPClientがブロックされる。
このデバイスの電源ボタンはソフト処理なので、応答が返るまでの十数秒は電源ボタンが効かない。待ちループでも電源処理を回すルールの例外がここだけ生じるが、非同期版(次回)は待ちがポーリングになり、その合間に電源処理を回すので、今回だけこれでOKとする。そもそもテスト中なので電源をオンオフ連打したりしない。

切り替えポップ対策も入れた

前回確認した、マイク→スピーカー切り替え時の「ブツッ」の対策をここで試した。方式は、begin()直後に音量0で300msの無音を再生し、その間に音量を10段階で戻すようにした。

static int16_t s_silence[14400];  // 300ms @48kHz、bssでゼロ初期化

static void speaker_restore() {
  M5.Speaker.begin();
  M5.Speaker.setVolume(0);
  M5.Speaker.playRaw(s_silence, 14400, 48000, false);
  for (int i = 1; i <= 10; i++) {
    M5.Speaker.setVolume(target * i / 10);  // 250msかけて階段状に復帰
    delay(25);
  }
}

シリアルコマンドsoft 0/1でON/OFFできるようにしておき、既定はONにした。

実機テスト

まずはシリアルのrecnow(タッチ不要で録音→送信を起動するデバッグコマンド)で、深夜の部屋の環境音のまま往復させた。

録音完了: 240000 samples
sync: POST 480187 bytes (WAV 480044 bytes)
sync: HTTP 200 (12401 ms)
聞: はい。
答: はい、準備できました。何かお手伝いできることはありますか。
返答WAV取得中...
返答WAV: 649004 bytes
返答再生中

ヘッダを付けただけの48kHz/16bit/mono録音をSTTはそのまま受け付け、multipartの組み立ても手直しなしの一発で通った。
環境音しか入っていない録音をSTTが「はい。」と聞き取り、LLMが律儀に「はい、準備できました。何かお手伝いできることはありますか。」と返してきた。何に反応したのか一瞬分からず、ちょっと怖かった。

サーバーが返すtimingsで内訳も見える。

区間実測
STT(Qwen3-ASR)2.2秒
LLM(初トークンまで)1.8秒
LLM(全文)2.1秒
TTS待ち5.4秒
サーバー内合計9.7秒
CoreS3から見た往復(480KBアップロード+中継込み)12.4秒
返答WAV(649KB)ダウンロード約3秒

実声で聞いてみる

サーバー構築時から使っているテストデータ「日本で一番高い山は何ですか」をタップして話した。

聞: 日本で一番高い山は何ですか。
答: 日本で一番高い山は富士山です。標高は三千七百七十六メートルあります。

聞き取りは句点まで原文どおりで、48kHz録音の品質でSTTに問題は出なかった。返答WAV(791KB、約8秒)が再生されて、かなちゃんが口パクしながら富士山の標高を教えてくれた。

テストデータ以外も試した。「おすすめの朝ごはんを教えてください」には「納豆ご飯と味噌汁が栄養バランスも良くおすすめです。手軽に済ませたいならバナナとヨーグルトも良いですね。」と返ってきた。定型文でない質問も一往復で成立した。

この往復は13.8秒(サーバー内はSTT 2.0秒 / LLM 2.8秒 / TTS待ち6.6秒)、ダウンロードが約4秒。
タップから返答が鳴り始めるまで、録音5秒を含めて20秒強。同期版はフィラーも無いので、この20秒をそのまま無言で待った。

前回課題に挙げた「そもそも5秒で足りるのか」は、質問文1つならぎりぎり収まった。ただし待ちの20秒もあるので、録音開始の合図と秒数は本組みで調整する。

もう1つの持ち越しだった切り替え時のブツッは、無音先行+段階的な音量復帰を入れた今回は聞こえなくなった。

録音の操作には物理ボタンを買ってくる

往復が動いたところで、固定5秒録音の「合図がない・秒数が固定」問題をどう解決するかを検討した。

無音検知で発話終了を自動判定する案は、マイコンだと環境ごとに閾値を調整し直す手間が増えそうなので最初に却下した。
タップで録音開始→もう一度タップで停止のトグル式も候補だった。録音長が可変になって尻切れも消えるし、今の操作体系(長押し=画面切替、フリック=音量)を変えずに済むから。

ただ、この機体はDIN BASEのPort B/Cが空いている。Port Aは将来CO2センサー(SCD41)を戻す分で埋まっているが、GPIOのポートが2口遊んでいるので、ここに物理ボタンを挿すことにした。

買ってくるのはM5StackのメカニカルキーボタンユニットKey(U144)。マルツで在庫があるのを確認済み。

部品役割
Keyユニット(U144)話しかけボタン。メカニカルキー+RGB LED内蔵で、押した=録音・LED点灯=録音中の合図が1個で完結する
ExtIO2ユニット(将来)I2CのGPIOエキスパンダ。空きポートをI2C化してぶら下げ、タクトスイッチを追加して「プリセット質問ボタンパネル」に拡張する構想

3aから保留にしてた録音開始のタイミング問題は、押した瞬間に光る物理キーで解決することにした。もうちょっといい方法が思いついたり予算があったら別の方法にするかもしれない。
次工程(非同期チャンク方式)はボタンを買ってきてから、最初から物理ボタン前提で組む。

今回の作業進捗

  • RIFFヘッダ直書きのWAV化がSTTに通る
  • multipart POST(フィールド名 audio、PSRAMでボディ組み立て)
  • sync往復の実測(HTTP 200、12.4〜13.8秒、timings取得)
  • transcript/replyのJSON取り出しと画面表示
  • 返答WAV(649〜791KB)のダウンロードと再生+口パク
  • 実声での聞き取り精度 → 「日本で一番高い山は何ですか。」句点まで一致
  • 録音5秒 → 質問文1つならぎりぎり収まる(合図と秒数は本組みで調整)
  • 切り替え時のブツッ → 対策(無音先行+段階的な音量復帰)ありで聞こえなくなった

次回の作業予定

  • action=asyncjob_id受領と同時にフィラー再生+口パク
  • action=jobの0.5〜1秒ポーリング
  • chunksの順次ダウンロードと継ぎ目のない再生
  • POST中も電源ボタンが効く構造に直す
  • 録音操作をKeyユニット(U144)の物理ボタン+録音中LEDに変更