技術約9分で読めます

M5Stack CoreS3の音声チャットを非同期チャンク方式にする。フィラーで間を埋めてKeyユニット(U144)の物理キーで話しかける

いけさん目次

前回で録音→送信→返答再生の往復が同期版で動いた。
ただし返答が鳴り始めるまで20秒強、ずっと無言。フィラーもなく、POST中は電源ボタンも効かない。

今回はこれを非同期チャンク方式に組み替えていく。
あわせて、録音操作を画面タップから物理キー(Keyユニット U144)に変える。

Keyユニット(U144)を買ってきた

マルツで購入。M5Stackのメカニカルキーボタンユニットで、Groveケーブルで挿すだけの部品。

Keyユニット(U144)のパッケージ

中身はキー本体とGroveケーブル。キースイッチの下にRGB LED(SK6812)が入っていて、キーキャップごと光る。

開封したKeyユニットとGroveケーブル

項目内容
型番M5Stack Unit Key (U144)
スイッチメカニカルキースイッチ(押した感触がはっきりある)
LEDSK6812(RGB、キーキャップの下で光る)
接続Grove 4ピン(GND / 5V / 信号2本)
役割話しかけボタン。押した=録音、LED点灯=録音中の合図が1個で完結する

3aから持ち越していた、録音開始の合図がない・5秒固定に発話を収めるのが難しいという問題を、このキーで解決するのが今回の狙いのひとつ。

どのポートに挿しても動くようにする

DIN Baseの背面にはPort BとPort Cが並んでいる。Port Aは将来CO2センサー(SCD41)を戻す分で確保してあるので、KeyユニットはB/Cのどちらかに挿す。

DIN Base背面のポートにGroveケーブルを接続

ここでピン番号をハードコードすると、後で挿し替えたときや工程4の統合で困る。
なのでピン番号は M5.getPin() で実行時に取得して、どのポートのどっちのピンにキーが繋がっているかは「最初に押された瞬間」に自動判定することにした。

// Port B/Cの計4ピンをINPUT_PULLUPで監視
struct { m5::pin_name_t p1; m5::pin_name_t p2; char port; } ports[2] = {
  {m5::pin_name_t::port_b_pin1, m5::pin_name_t::port_b_pin2, 'B'},
  {m5::pin_name_t::port_c_pin1, m5::pin_name_t::port_c_pin2, 'C'},
};

4ピン全部をプルアップ入力にしておき、30ms続けてLOWになったピンをキーと確定する。
Groveの信号2本のうち片方がキー、もう片方がLEDなので、キーが確定すれば同ポートの相方ピンが自動的にLED(SK6812)になる。LEDはESP32 Arduinoコア組み込みの rgbLedWrite() で点けられるので、ライブラリの追加もいらない。

CoreS3だとPort BがG8/G9、Port CがG18/G17に割り当たっている(M5Unifiedのピンテーブルより)。ログはこんな感じ。

key: Port B 候補 G8/G9
key: Port C 候補 G18/G17

CoreS3(DIN Base)にKeyユニットを接続した全体

LEDの色はこんな感じで状態表示に割り当てた。

状態
録音中(話してよいの合図)
考え中(送信〜返答待ち)
再生中
消灯待機

5秒固定をやめて可変長録音にする

録音は5秒固定をやめて可変長にした。止め方は2方式を実装して、シリアルコマンドkeymodeで切り替えられるようにした。

方式動作
ホールド式押している間だけ録音。離したら送信
トグル式押して録音開始、もう一度押して送信(上限10秒で自動送信)

どちらが使いやすいかは操作の感覚がだいぶ違うはずなので、どちらを既定にするかは実際に試してみてから決める。

録音ループは工程3aと同じ100msチャンク方式のまま、各チャンクの合間にキーの状態を見て止めるだけ。
0.5秒未満で離した場合は誤爆扱いでキャンセルする。スピーカー→マイクのI2S切り替えに約0.4秒かかるので、押した瞬間ではなく「LEDが赤くなってから」が録音開始。ここは物理キーでも変わらない制約で、赤くなるのを見てから話す仕様になる。

非同期の組み立て

APIは仕様確認で決めた非同期エンドポイントをそのまま使う。

項目内容
POST action=async録音WAVをmultipartで送るとjob_idが即返る(処理は裏で進む)
GET action=job0.7秒間隔でポーリング。chunks(文単位の完成WAV URL)が完成した順に増える
done: true全チャンク出揃い

同期版と違って、返答待ちの間もこちらの処理が止まらない。ポーリングの合間に電源処理・口パク・タッチ操作が全部回せる。

flowchart TD
    A[キーを離す] --> B[WAV化してmultipart POST<br/>action=async]
    B --> C[job_id即受領<br/>フィラー再生開始]
    C --> D[action=jobを0.7秒ポーリング]
    D --> E[chunksに新URL出現]
    E --> F[チャンクを先読みダウンロード]
    F --> G[前の音声が終わり次第<br/>継ぎ目なく再生]
    G --> H{done かつ全部再生?}
    H -->|No| D
    H -->|Yes| I[待機に戻る]

loop()内の組み立ては「1周につき1仕事」の優先順で書いた。

static void chat_tick() {
  // 1. 再生の継ぎ足し: 鳴り終わっていて次のチャンクがあれば続ける
  // 2. 先読みダウンロード: 未取得のチャンクURLがあれば1本取る
  // 3. ポーリング: 0.7秒経っていればaction=jobを1回
}

ダウンロードとポーリングの間はHTTP待ちでブロックされるが、1周で1仕事しかしないので、仕事の合間に必ずloop()の先頭へ戻って電源処理とタッチが回る。同期版で例外扱いにしていた、電源ボタンが効かない時間もアップロードの数秒だけに縮んだ。

口パクを止めない先読みダウンロード

チャンクのダウンロードは再生中に走る(先読み)。ここで丸ごとブロックすると、返答を喋っている数秒間、口パクが止まって顔が固まる。

中継サーバーのレスポンスはHTTPのチャンク転送なので、生ソケットを自前で読むとチャンクのデコードも自前になる。色々めんどくさいんでそれは避けたい。
HTTPClientのwriteToStream()はデコード込みで書き込み先Streamに流してくれるので、書き込み先のwrite()が呼ばれるたびにUI処理を回すStreamを作った。

// writeToStream(チャンクデコード込み)のコールバックでUIを回す版
class PumpingPsramStream : public PsramBufferStream {
 public:
  size_t write(const uint8_t* data, size_t size) override {
    size_t r = PsramBufferStream::write(data, size);
    ui_pump();  // 電源処理+口パク+オーバーレイ消灯
    return r;
  }
};

ダウンロード自体は同期のまま、数KBごとに口パクが進む。RTOSタスクを立てて共有バッファの排他を考える構成は避けて、この作りにした。

フィラーの継ぎ目

最初のテストで、ロードマップに山場として書いていた「フィラー→1文目の継ぎ目」がそのまま出て、ログはこんな感じだった。

async: HTTP 200 (1862 ms) {"job_id":"65292e89d259",...}
filler再生: 433964 bytes        ← 4.5秒で鳴り終わる
poll: chunks 1本 (+1) [8503 ms]
chunk[0]再生開始 [9758 ms]      ← 約5秒の無音

フィラーは1本4秒前後で、chunk[0]が再生できるのはjob_id受領から約10秒後。だから、フィラー1本では埋めきれずに5秒黙ってしまう。
考えるふりをして黙るくらいなら喋り続けてほしいので、「フィラーが鳴り終わってもまだchunksの気配がなければ、つなぎのフィラーをもう1本」(上限3本)を足した。

} else if (g_chat.playIdx == 0 && g_chat.urlCount == 0
           && g_fillerCount > 0 && g_chat.fillersPlayed < 3) {
  // フィラーが鳴り終わってもまだチャンクの気配がない → つなぎでもう1本

改良後のログはこれ。

async: HTTP 200 (1632 ms)
filler再生: 433964 bytes
聞: はい。 [1847 ms]
答: はい、どうしましたか。何かお手伝いできることはありますか。 [3686 ms]
fillerつなぎ2本目 [4626 ms]
poll: chunks 1本 (+1) [6456 ms]
chunk[0]: 334124 bytes (DL 1818 ms)
chunk[0]再生開始 [8303 ms]
chunk[1]再生開始 [11788 ms]
チャット完了: 2 chunks (15082 ms)

フィラー1本目(4.6秒)→つなぎ2本目(3.7秒、終了8.3秒)→chunk0が、狙ったわけでもないのにほぼゼロギャップで繋がった。chunk[0]→chunk[1]も、再生中にchunk[1]の先読みが終わっているので継ぎ目なし。偶然とはいえいい感じである。

job_id受領からのタイムラインはこんな感じ。

経過イベント
0.0秒job_id受領、フィラー1本目再生開始
1.8秒transcript確定(聞き取り)
3.7秒reply確定(返答文)
4.6秒フィラー1本目終了、つなぎ2本目開始
6.5秒chunks[0]のURL出現
8.3秒フィラー2本目終了、chunk[0]再生開始(無音ギャップなし)
8.5秒done(チャンク全2本)
11.8秒chunk[1]再生開始(継ぎ目なし)
15.1秒再生完了、待機へ

録音を終えてから返答が鳴り始めるまで約10秒(アップロード1.6秒+8.3秒)。同期版は同じ区間が15〜17秒の完全な無言だったが、非同期版は無言がアップロードの1.6秒だけになり、残りはフィラーが喋っている。

なお起動時にフィラー3本(計1.2MB)をVPS中継からPSRAMへダウンロードしておく方式にした。工程1のSDキャッシュは統合時に組み込む予定で、今回のコードはSDなし構成のまま。

物理キーを押す

キーを初めて押した瞬間のログはこれ。

Key検出: Port C キー=G18 LED=G17
録音開始: 最大10秒 48000Hz/16bit/mono gain=16 mode=0

自動判定はPort Cだった。Groveの信号2本のうち1本目(G18)がキー、2本目(G17)がLED。挿した場所を一切教えていないのに、押しただけで配線が確定して、そのまま録音に入れた。

最初の押下は試しに一瞬で離したら、0.3秒の録音になって「録音が短すぎ(0.5秒未満)→キャンセル」が作動した。超単発の場合は間違って押してる可能性もあるので、この動作は想定通り。

次に、何も喋らずに3秒ホールドしてみた。

録音完了: 124800 samples (3113 ms)
async: HTTP 200 (1629 ms) {"job_id":"e6129af47c72",...}
filler再生: 403244 bytes
聞: ああ。 [1836 ms]
答: はい、何かお手伝いできることはありますか。いつでもお気軽にご質問ください。 [2855 ms]
fillerつなぎ2本目 [4166 ms]
chunk[0]再生開始 [8724 ms]
chunk[1]再生開始 [12617 ms]
チャット完了: 2 chunks (16861 ms)

深夜の環境音をSTTが「ああ。」と聞き取り、律儀な返答が一式返ってきた。前回の「はい。」の再現で、無言でも往復が成立してしまうのは相変わらず。フィラー2本→チャンク2本の流れはここでも無音ギャップなしだった。ちなみに「ああ」となった理由は、多分エアコンの直下なので送風の音の低いゴーというのが「あ」に変換されたのかと思う。全然関係なく「ああ」をとってたらちょっと怖い。

タップ停止の誤爆と表示の作り直し

質問音声を流して話しかけるテストで、返答を待っている途中に止まったように見えて、ログを確認したところセッションが何も言わずに死んでいた。
待ち時間に音量をフリックで調整したのだが、そのタッチの1回がタップ判定になり、「タップ=停止」にしていた仕様が発動していた。

fillerつなぎ3本目 [8691 ms]
音量 2/10      ← 音量調整のフリック
停止           ← 直後のタッチがタップ判定→セッション中断
音量 3/10

しかも止まったことが画面に出ない。状態表示(考え中…)は音量表示に上書きされて1秒後に消える作りだったので、フィラーが鳴り終わった後の無音待ちでは、動いているのか死んだのかの区別もつかなかった。まとめて直した。

変更内容
停止操作タップ停止を廃止して物理キーのみに。音量フリックがタップに化けても何も起きない
状態表示オーバーレイを状態表示(録音中・送信中・考え中)と一時表示(音量)の2層に分離。音量表示は1秒で状態表示に復帰し、「考え中…」はchunk再生開始まで出続ける
エラー表示停止・サーバーエラー・タイムアウトは顔画面にも理由を3〜4秒表示(それまではシリアルとUI画面のみ)

TTSに作らせた質問音声で話しかける

撮影用の質問「日本ではお中元の時期はいつからいつまでですか」は、自分の声ではなくサーバーのTTSに作らせた。撮影の時に自分の声入れたくないので。
同期版のAPIに「次の文をそのまま復唱してください」という指示の音声を投げると、返答WAVとして質問文をかなちゃんの声で読んだWAVが返ってくる。質問も返答も同じ声になる。

これをスピーカーから鳴らして実機に聞かせたら、STTの聞き間違いが2連発した。

化け方状況
御中元 → 「中間」単語単体では文脈の手がかりがなく化けた。質問文を「お中元を贈る時期」に言い換えて解消
いつからいつまでですか → 「1から10までです」スピーカー→空気→マイクの経路劣化。同じWAVをPCから直叩きした場合は完璧に通った

2つ目の化け方のときは質問自体が「中元の時期は1から10までです」という断定文になってしまい、LLMが「中元の時期は一般的に7月初旬から15日までです。1日から10日というわけではありません。」と律儀に訂正を返してきた。合ってるだけに悔しい。

取り直したテイクはこれ。聞き取りは句読点まで原文どおりに通った。

聞: 日本では、お中元の時期はいつからいつまでですか? [3679 ms]
答: お中元は一般的に7月初めから7月15日までに贈ります。地域によっては
    8月に送る場合もあるので、相手の地域の習慣を確認すると安心ですよ。 [6445 ms]
poll: chunks 1本 (+1) [13099 ms]
chunk[0]再生開始 [16571 ms]
chunk[1]再生開始 [25731 ms]
チャット完了: 2 chunks (33456 ms)

この往復は回答が2文とも長めで、chunk[0]の再生開始はjob_id受領から16.6秒。フィラー3本が先に喋って間を持たせた。
残った継ぎ目もある。chunk[0]と[1]の間に約2.6秒の無音が空いた。長い2文目のTTS完成が再生に追いつかないケースで、フィラー→1文目ほどの体裁は整えていない。文間の無音は本組みでつなぎ音を検討する。

ホールドとトグルを触り比べる

最後に、保留にしていた録音方式を両方触った感触の結果を。あくまで個人の感覚で。

ホールド式は押した実感どおりに録れて直感的だが、長めの質問を話している途中で指がずれた瞬間に録音が切れて、そのまま途中送信になる。喋りながら押し続ける姿勢は案外気を使う。特に撮影中はいろんなとこ見るので動作として押しっぱなしが結構苦痛。
トグル式は押して、話して、もう一度押すだけ。指を離して話せるので事故がない。既定はトグル式にした。

このテストで実声で話した「お歳暮」の質問は、STTに「お正月」と聞き取られた。TTS音声のときの「中間」と合わせて、時候の単語の聞き間違いが2回続いた。対処(言い直しの促しなど)は未着手のまま。とはいえ音量かハキハキ喋るか、しかない気もしなくもない。

次に向けて考え中のこと

統合(CO2モニターとのがっちゃんこ)に向けて、外付けディスプレイを検討している。
メインの液晶は顔とチャットに専念させて、CO2濃度などの常時表示は外付けの小型ディスプレイに表示する構成にしようかと。I2CのOLEDユニットならPort AのI2Cバスに相乗りできるので(SCD41=0x62、OLED=0x3C系でアドレス衝突なし)、空きポートを消費しない。

部品役割マルツ税込(在庫確認済み)
OLEDユニット 1.3インチ(U119、128×64)CO2濃度などの常時表示2,088円
拡張ハブユニット(U006、1to3分岐)Port AをSCD41と分岐691円

計2,779円でメイン画面がわちゃわちゃせずに常時表示が手に入る計算だが、買うかどうかはもうちょっと考える。

細かいところだと、つなぎフィラーの2本目以降はフルセンテンスを繰り返すより「うーん」のような短い考え中の音のほうが自然っぽい(サーバー側に短フィラーの追加が必要)。返答の再生中はキーのLEDが緑に光るだけなので、画面にも「返答中」の表記があったほうがいい。

後あまり本筋ではないが、顔の絵をもうちょっと大きく表示したいなあと。ちょっと小さいのでアイコンに話しかけてる感じしかしない。Stackちゃんは画面いっぱいだしこっちも画面いっぱいにしたいっちゃしたい。

今回の作業進捗

  • action=asyncjob_id受領と同時にフィラー再生+口パク
  • action=jobの0.7秒ポーリング(transcript/reply/chunks/doneの順次取得)
  • chunksの順次ダウンロードと継ぎ目のない再生(先読み中も口パク継続)
  • フィラー→1文目の継ぎ目: つなぎフィラー(上限3本)で無音ギャップ解消
  • POST中の電源ボタン問題: ブロックがアップロード数秒だけに縮小
  • Keyユニットのポート自動判定(実機はPort C、キー=G18/LED=G17を一発判定)
  • ホールド式での録音→往復→撮影(質問はTTS音声、聞き取りは原文どおり)
  • タップ停止の誤爆対策と状態・エラー表示の作り直し
  • ホールド/トグルの触り比べ → トグル式を既定に(指ずれによる途中送信がない)

次回の作業予定

  • 工程4: CO2モニター本体との統合(SCD41をPort Aに復帰、SDフィラーキャッシュ組み込み)
  • CO2表示をどこに出すか(UI画面?顔画面に小さく?)の設計判断
  • サーバーPCスリープ時のエラーハンドリング
  • 文間の無音(長い文のTTS完成待ち)のつなぎ検討
  • STTの聞き間違い(お歳暮→お正月など)への対処
  • つなぎフィラーの後半は「うーん」系の短い考え中音に(サーバー側に短フィラー追加)
  • 返答再生中の画面表記(いまはLED緑と口パクだけなので「返答中」を出す)
  • 外付けディスプレイ(OLEDユニットU119+ハブU006、計2,779円)の購入判断