技術約7分で読めます

M5Stack CoreS3で自分の声を録って鳴らす。マイクとスピーカーが同じI2Sバスを取り合う実機の切り替え確認

いけさん目次

2026-07-22 追記: 続きの工程3b。この録音にRIFFヘッダを直書きしてサーバーへmultipart POSTし、返答WAVを再生する同期版の往復(実測12.4〜13.8秒)まで動いた → M5Stack CoreS3に話しかけて返事をもらう。48kHz録音のmultipart POSTと同期版チャットの最小往復

前回で顔と口パクとジェスチャー操作まで動いた。
残るは「話しかける」本体だが、ここには録音・通信・非同期の組み立てと山場が3つある。一気にやると詰まったときに切り分けられなくなるので、1山場=1工程に分割した。

今回はその1つ目、録音だけを確かめてみる。

サーバーもSDも外したボイスレコーダーにする

今回はWiFiもSDも使わない。工程1・2で作ったフィラー取得やSDキャッシュのコードは全部外した。
録音がおかしいとき、原因がネットワークなのかSDなのかマイクなのかを疑う状況を最初から作らないためで、この構成なら問題が出たらマイク周りしかない。

要するに、タップで固定秒数録音して、その場で自分の声を再生するだけのボイスレコーダーを作ってみる。

顔・口パク・ジェスチャー操作・電源処理は工程2からそのまま持ってきた。再生するのがフィラーではなく自分の声になるだけで、口パクも同じコードで動くはず。

録音形式は48kHz/16bit/モノラル

仕様確認のとおり、音声チャットサーバーが受けるWAVは48kHz・16bit・モノラル。最初からこの形式で録る。

1秒あたり94KB、最大10秒分で937KB。内部RAMには載らないのでPSRAM(8MB)に起動時に確保する。

static constexpr uint32_t REC_RATE = 48000;
static constexpr int REC_SEC_MAX = 10;

g_recBuf = (int16_t*)heap_caps_malloc(
    (size_t)REC_RATE * REC_SEC_MAX * sizeof(int16_t), MALLOC_CAP_SPIRAM);

マイクとスピーカーは同じI2Sバスを取り合う

CoreS3のマイク(ES7210)とスピーカー(AW88298)は同じI2Sバス(I2S_NUM_1、BCK=GPIO34/WS=GPIO33)につながっていて、同時には使えない。
M5Unifiedではend()begin()で明示的に切り替える。

flowchart TD
    A[待機<br/>Speaker on] -->|タップ| B[Speaker.end<br/>200ms待機]
    B --> C[Mic.begin<br/>録音 5秒]
    C --> D[Mic.end<br/>200ms待機]
    D --> E[Speaker.begin<br/>録音した声を再生]
    E -->|再生完了| A

切り替えはこんな感じ。実装例では切り替え前後に200〜500msの待機を挟んでいるので、まず200msで試してみる。

// --- Speaker → Mic ---
M5.Speaker.stop();
M5.Speaker.end();
delay(200);
M5.Mic.begin();

// (録音)

// --- Mic → Speaker ---
M5.Mic.end();
delay(200);
M5.Speaker.begin();
M5.Speaker.setVolume(vol);  // begin後に音量を再適用

実機で確かめたいのはこの切り替えの挙動そのもの。切り替え時に「ブツッ」というノイズが乗らないか、録音や再生の頭が欠けないか。仕様確認の段階では、200〜500ms待てば大丈夫らしいという以上のことは分からなかった。

録音ループの中でも電源ボタンを殺さない

M5Unifiedのrecord()は非同期で、渡したバッファを裏のタスクが埋めていく。キューが埋まっていると録音が終わるまでブロックするので、5秒分を一度に渡すと5秒間ループが止まる。

このデバイスの電源ボタンはソフト処理(M5.BtnPWRを読んでpowerOff())なので、ループが止まると電源が切れなくなる。工程1から続くルールで、録音中も待ちループの中で電源処理を回す必要がある。

そこで100ms分(4800サンプル)ずつキューに積み、合間に電源処理と残り秒数の表示を入れている。

size_t off = 0;
while (off < total) {
  size_t n = min(REC_CHUNK, total - off);   // 100ms分ずつ
  M5.Mic.record(g_recBuf + off, n, REC_RATE);
  off += n;
  power_tick();                             // 電源ボタン+電源表示の維持
  show_rec_countdown();                     // 残り秒数のオーバーレイ
}
while (M5.Mic.isRecording()) { power_tick(); delay(10); }

マイクゲインはシリアルから調整できるようにした

M5Unifiedのマイク設定にはmagnification(既定16)というソフトウェアゲインがある。適正値は録ってみないと分からないので、シリアルコマンドgain <n>で次の録音から変えられるようにした。

録音秒数(rec <sec>)、再生し直し(play)、音量(vol <n>)も同様にシリアルから変更できる。ゲインを変えて録り直して聴き比べる、を書き込みなしで回すため。

操作

工程2で確定したジェスチャー方式をそのまま使った。タップの役割が「疑似応答」から「録音」に変わった。

操作動作
タップ(どこでも)録音開始(既定5秒)。終了後に自動で自分の声を再生(口パク付き)
再生中のタップ停止
長押し(0.5秒)顔画面⇔UI画面の切り替え
上下フリック音量±(1秒だけオーバーレイ表示)
電源ボタン電源OFF(従来どおり)

録音中は画面下端に「録音中 n」の赤いオーバーレイを出し、残り秒数をカウントダウンする。

SDを使わないので、工程1・2で必須だった起動直後の「描画禁止フェーズ」も、書き込み後に完全に電源を切る手順も今回は不要。

実機テスト

書き込み直後の起動ログ。ソフトリセットだけで一瞬で顔が出た。

VoiceChat Step3a: voice recorder
rec buffer: OK (937 KB PSRAM)
face sprites: OK (decode 86 ms)
volume: 1/10 (NVS)

PSRAMの937KB確保、顔スプライトのデコード86ms(工程2と同水準)、NVSの音量引き継ぎまでは順調だった。

シリアルモニタが引き起こす画面フリーズ

最初のテストで画面が固まった。タップしても何も起きず、USBを抜いても電源表示が「USB給電」のまま変わらない。

I2S切り替えのどこかでハングしたと疑い、切り替えの各段階にログを仕込んで、シリアルから録音を起動するrecnowコマンドを足して書き込み直した。ところがシリアル経由だと録音→切り替え→再生まで全部通った。何度やっても再現しなかった。

原因はファームではなく、監視に使っていたシリアルポートの扱いだった。
CoreS3のUSBはESP32-S3内蔵のUSB-Serial-JTAGで、ホスト側のDTR/RTS信号がチップのリセット制御に直結している。書き込みツールが自動リセットに使っている経路で、モニタ用に開いたポートを閉じたときにこの信号がリセット保持側に残った。チップは止まるが、LCDコントローラは自前のRAMで最後のフレームを表示し続けるので、「最後の画面のまま固まった」ように見える。

デバイスもファームも無事で、リセットをかけ直したらそのまま動いた。以後のモニタはテスト中ずっとポートを開きっぱなしにして回避した。

もう1つ「再生されているはずなのに聞こえない」も起きたが、こちらはNVSに残っていた音量1/10が原因。前の工程のテストで下げた値がそのまま引き継がれていた。

録音→再生

テストの発話は、音声チャットサーバーの動作確認で使っている「日本で一番高い山は何ですか」。この録音は次の工程でそのままサーバーへ飛ぶので、テストデータも合わせた。

数字と聞こえ方はこんな感じ。

5秒録音(240,000サンプル)の実測は5007〜5010msで、100msチャンクを積み上げてもほぼ5秒ぴったりだった。
Mic.begin()は毎回一発でOK。録音→再生サイクルを繰り返しても失敗はなかった。
ゲインはmagnification=16(既定)のままで、机上のCoreS3に向かって話す距離なら聞き取れる音量で録れた。

切り替えの瞬間には「ブツッ」というノイズが乗った。200ms待機でも消えていない。
一方で録音の頭・再生の頭の欠けは気にならず、再生の終わりも特に何もなかった。
録音中も残り秒数のカウントダウンと電源表示の更新が止まらずに動いた。

操作の課題もあった。タップしてから録音が始まるまでの間がつかみにくく、話し始めが遅れるとケツが切れる。
固定秒数録音ならではの課題で、録音開始の合図(音や表情の変化)と、そもそも5秒で足りるのかは本組みまでに考える。

切り替えのブツッをどうするか

ブツッの正体は、Speaker.begin()でアンプ(AW88298)とI2Sを再初期化する瞬間のDCオフセットの段差だと考えている。
出力が無音の電位に飛びつくときの段差がそのままクリック音になるので、切り替えの待機時間を伸ばしても原理的には消えない。200msの待機で消えていないのもこれで説明がつく。

対策の候補はいくつかある。

対策見立て
begin()直後に無音を数百ms再生してから本編を鳴らすポップは出るが無音区間の頭で鳴り、発話の頭と分離される。実装数行で一番軽い
音量ランプ(begin直後は音量0、無音再生中に戻す)アンプ由来のポップには効かないが、無音再生と組み合わせる保険
AW88298のソフトミュートレジスタを直叩きM5Unifiedの管理外に踏み込むので最終手段
何もしない最終形は録音終了→即フィラー再生なので、ポップがフィラーの頭に重なって目立たなくなる可能性がある

どれを採るにしても、次の工程(録音をサーバーへ送る)でも切り替えは毎回発生する。対策の実証はそこでやる。

今回の作業進捗

  • 録音バッファ937KBのPSRAM確保
  • SD・WiFiなし構成での起動(描画禁止フェーズ不要、起動は一瞬)
  • タップ→5秒録音→自動再生の一連の流れ(繰り返しでも失敗なし)
  • Mic/Speaker切り替え前後のノイズ・頭欠け → 切り替え時にブツッあり(対策は今後)。頭欠けはなし
  • マイクゲイン(magnification)は既定16のままでOK
  • 録音中も残り秒数表示・電源表示が動き続けること
  • 音量フリック・画面切替が引き続き動くこと

次回の作業予定

録音が確認できたら、次は録音を実際にサーバーへ送って返事をもらう(同期版・最小往復)。

  • 録音にRIFFヘッダを直書きしてWAV化
  • action=syncへのmultipart POST(フィールド名 audio)
  • 文字起こし(transcript)と返答(reply)の画面表示
  • 返答WAV(全文連結1ファイル)のダウンロードと再生