技術約16分で読めます

StackChan Bodyを音声チャットにつないだら、触っていないのに録音していた

いけさん目次

頭のタッチとかなちゃんの顔がそれぞれ単体で動くようになったので、Keyユニットで動かしていた非同期の音声チャットに組み込んでみた。
話しかけボタンを頭のタッチに置き換え、顔をかなちゃんの絵にして、返事の間は口パクさせるようにした。

書き込んで電源を入れたら、何も触っていないのに音声を取りに行っているような動きをした。
そのまま電源を切って、シリアルのログとサーバー側の記録をさかのぼった。

検証環境

項目内容
本体M5Stack CoreS3 + StackChan Body
開発環境Windows 11 + arduino-cli + ESP32コア 3.3.10
ボード指定(FQBN)esp32:esp32:m5stack_cores3:PartitionScheme=custom
ライブラリStackChan-BSP、M5Unified
音声サーバー自宅のRTX 3050 Ti Laptop 4GBノート。Qwen3-ASR-0.6B(CPU)で文字起こし、Qwen3.7-Plus(ModelScope API)で返事、Irodori-TTSで読み上げ
経路CoreS3 → VPSのPHP中継 → Tailscale → 音声サーバー

音声サーバーはSTTとLLMとTTSを4GBのGPUに同居させた構成のまま。
Omni-Flashに切り替えた記事のあと、文字起こしを先にやる構成へ戻した。

flowchart TD
    A[頭を叩いて録音開始] --> B[もう一度叩いて送信]
    B --> C[VPSの中継]
    C --> D[音声サーバー]
    D --> E[Qwen3-ASRで文字起こし]
    E --> F[Qwen3.7-Plusで返事]
    F --> G[Irodori-TTSで文ごとに音声化]
    G --> H[CoreS3が順に取得して再生]

プログラムの変更点

ベースにしたのは非同期チャンク方式の音声チャットで、送信するとすぐ受付番号(job_id)が返り、返事の音声は1文ずつできた順に取りに行く。
待っている間は起動時に取っておいたフィラー(「えーっと、ちょっと考えますね」などのつなぎの音声)を流す。

変えたところは次の3点。

項目Keyユニット版StackChan版
話しかけKeyユニットを押す頭を叩く(0.8秒以内に離す、撫でたら無視)
顔128x128のドット絵320x240のかなちゃんの絵。目ぱちは常時、口パクは再生中
LEDKeyユニットのLEDボディのRGB LED(赤=録音中、青=考え中、緑=再生中)

叩いて録音開始、もう一度叩いて送信、という押して・押しての操作はKeyユニット版と同じにした。
録音には上限10秒を付けていて、叩かずに10秒たったらそこまでを送る作りだった。

触っていないのに録音が走る

電源を入れたときのシリアルログ。

VoiceChat Step7: StackChan Body + head touch + kana face
rec buffer: OK (937 KB PSRAM)
face sprites: OK (decode 394 ms) PSRAM free 6665 KB
volume: 3/10 (NVS)
WiFi接続中...
filler[0]: 433964 bytes
filler[1]: 353324 bytes
filler[2]: 403244 bytes
fillers: 3本をPSRAMへ (6530 ms)
head: dur=2269 swipe=0 -> ignore
head: stuck -> recalibrate
head: dur=99 swipe=0 -> tap
rec: Speaker.stop/end
rec: Mic.begin -> OK
録音開始: 最大10秒 48000Hz/16bit/mono gain=16 mode=1
rec: Mic.end
録音完了: 480000 samples (10526 ms)
async: POST 960187 bytes (WAV 960044 bytes)
async: HTTP 200 (5009 ms) {"job_id":"3274df078cbf","poll":"/job/3274df078cbf"}
filler再生: 433964 bytes
fillerつなぎ2本目 [4639 ms]
head: dur=289 swipe=0 -> tap
停止(頭タッチ)

フィラーを3本取ってくるところまでは、起動時の通常の動きだ。
そのあと、タッチの判定の流れはこんな感じ。

ログ起きたこと
dur=2269 -> ignore誰も触っていないのに2.3秒の接触が出た。0.8秒を超えているので叩きではない
stuck -> recalibrate次の接触が10秒続いたので、張り付きとみなしてタッチの基準値を取り直した
dur=99 -> tap取り直した直後の0.1秒の揺れを叩きと判定した
録音 10526ms叩いて止める人がいないので、上限の10秒まで部屋の音を録った
POST 960187 bytes10秒分をそのまま送信した。フィラーが鳴り始めた

タッチを試した回で、首が動いたあとにタッチが触れっぱなしのまま戻らないことは分かっていた。
起動時に首を正面へ戻す動き(goHome())が入っていて、このときのプログラムにはそのあとの取り直しがまだなかった。
張り付き対策の取り直しそのものが、叩きの誤検出を1回作っていた。
最後の dur=289 -> tap で会話が止まっているが、電源を切ろうとしたときの接触かもしれない。

無音からヒント語が誤認識される

送られた10秒は音声サーバーで最後まで処理されていて、受付番号で結果を取得できた。

{"transcript":"立春、雨水、啓蟄、春分、清明、穀雨、立夏、小満、芒種、夏至、小暑、大暑、立秋、処暑、白露、秋分、寒露、霜降、立冬、小雪、大雪、冬至、小寒、大寒、元日、成人の日、建国記念の日、天皇誕生日、春分の日、昭和の日、憲法記念日、みどりの日、こどもの日、海の日、山の日、敬老の日、秋分の日、スポーツの日、文化の日、勤労感謝の日、正月、節分、ひな祭り、お彼岸、お盆、七夕、お月見、十五夜、お中元、お歳暮、ハロウィン、クリスマス、大晦日、かな、かなちゃん、StackChan、スタックチャン","reply":"わあ、日本の行事や二十四節季がずらっと並んでるね。全部覚えるの大変そうだけど、季節の移り変わりを感じられて素敵だな。","done":true,"error":null}

誰も喋っていない録音の文字起こしが、二十四節気と祝日と年中行事の一覧になっている。
音声サーバーのQwen3-ASRには、季節の行事名や「かな」「StackChan」を聞き取りやすくするためのヒント語を context に設定してある。
人の声がない入力に対して、そのヒント語をそのまま書き起こしとして返したようだ。
返事のLLMはそれを受けて「二十四節季がずらっと並んでるね」と答え、読み上げの音声も2本できていた。端末は途中で止めたので、この返事は再生していない。

端末側の誤検出を直しても、何かの拍子に無音が送られれば同じことが起きる。
そこで、端末・音声サーバー・中継のそれぞれに手を入れた。
音声サーバーはこのPCとは別のノートにあるため、改修はそのノートのClaude Codeに直してもらい、中継はVPSに入る環境から修正した。

音声サーバーで無音を打ち切る

文字起こしの前と後に1回ずつ判定を入れた。

位置判定打ち切る条件
文字起こしの前30ミリ秒ごとの音量が、-50dBFS(最大音量を0dBとするデジタル音量単位)と「その録音の静かな部分+10dB」の両方を超えた区間を発話とみなす発話が合計0.25秒未満
文字起こしの後書き起こしに含まれるヒント語を数えるヒント語が3語以上で、残りの文字が全体の30%以下。または書き起こしが空

打ち切ったときはLLMもTTSも呼ばずに、結果を error: "no_speech" にしてすぐ終えるようにした。
音声サーバー側で試した結果(合成音声で、48kHzと16kHzの両方)はこんな感じ。

入力結果完了まで
無音 10秒no_speech(発話0.00秒)0.04秒
雑音 10秒no_speech(発話0.00秒)0.02秒
「最近ちょっと疲れてるんだよね。」普通に返事15.4秒
「お中元って何を送ればいいの。」普通に返事10.3秒

判定を入れる前の文字起こしに無音と雑音を送ると、ヒント語の一覧が返ってくる現象も再現できた。
ただし今回の10秒の録音そのものは、音声サーバーが文字起こしのあとに入力を消していて残っておらず、同じ音では試せていない。
音量の判定も合成音声での確認にとどまる。

録音と返事を16kHzにする

音声サーバーのQwen3-ASRは、受け取った音声を内部で16kHzに変換してから認識している。
48kHzで録っても16kHzに落とされるなら、最初から16kHzで送れば10秒の録音が960KBから320KBになる。
返事の音声(Irodori-TTSの出力は48kHz)も、sr=16000 を付けると16kHzで返すように変更した。

ところが、中継経由でフィラーを sr=16000 付きで取得しても、48kHzのまま同じ433,964バイトが返ってきた。
中継のPHPが sr を音声サーバーへ送っていなかった。
中継を直すついでに、音声サーバーのエラーをそのまま端末へ返すようにもした。直す前は、フィラーと返事の音声の取得で失敗すると502と空の本文に置き換え、ほかの操作では常に200を返していた。

取得したものsr指定なしsr=16000
フィラー1本目433,964バイト(48kHz)144,684バイト(16kHz)
返事の1文目(フィラー音声を入力にした試験)-79,404バイト(16kHz)

TailscaleでCoreS3から音声サーバーへ直接つなぐことも考えたが、今の実装は東京のDERP(Tailscaleの中継サーバー)を1本だけ使う形で、受信は47KiB/s前後だった。
送信も同じ速さなら、960KBの録音を上げるだけで20秒近くかかる。中継のPHPのときは5秒だったので、中継を残してデータ量を減らすほうを選んだ。

端末側の誤作動対策

対策内容
取り直し直後の無視タッチの基準値を取り直してから1.5秒は叩きを受け付けない。取り直しは首を戻したあと、WiFi接続のあと、張り付き時の3か所
上限まで録ったら送らない叩いて止めずに10秒に達した録音は捨てて、「10秒→送らない」と出す
no_speechの表示鳴っているフィラーを止めて「聞き取れなかった」と出す。赤字のエラーにはしない

誤検出で始まった録音が止める人がいないまま上限に達するため、上限の10秒に達したら送らないようにした。
その代わり、10秒を超えて喋ると何も送られない。

表情を返事に合わせる

かなちゃんの顔は微笑・喜・怒・哀・楽の5表情を作ったので、返事に合わせて切り替えるようにした。
表情を決めるためだけにLLMをもう1回呼ぶと、そのぶん返事が遅れる。
そこで、返事の先頭に [joy] のようなタグを付けさせ、音声サーバーで取り除いてから読み上げに回すようにした。
取り除いたタグは、返事の状態を問い合わせたときのJSONに emotion として返る。

端末は emotion を受け取っておき、返事の1文目を喋り始めるところで顔を切り替えて、会話が終わったら微笑に戻すようにした。

最初はプロンプトに5つのタグを並べただけで、「財布をなくしちゃった」にも哀ではなく微笑が返ってきた。
タグは「話しかけた側の気持ちではなく、返事を話すときのかなの表情」だと書き、タグごとに使う場面を足した。

タグ使う場面
smileふつうの受け答え、知識の答え、あいさつ
joy相手の嬉しい話を一緒に喜ぶ、褒める
sorrow残念な話、困った話、体調が悪い話で心配・同情する
anger夜更かしや無茶、かなへの悪口に軽く拗ねる・叱る。本気では怒らない
fun遊びの誘い、冗談、わくわくする予定

12の文をテキストでLLMに3回ずつ入れて、返ってきたタグを数えた。本番と同じくWeb検索あり、thinkingなし、日時入りの条件で、音声は通していない。

文想定調整前調整後
財布なくしちゃったsorrowsmile ×3sorrow ×1、smile ×2
テストで満点取った!joyjoy ×3joy ×3
明日遊園地に行くんだfunfun ×2、joy ×1fun ×3
日本で一番高い山は?smilesmile・joy・fun 各1smile ×3
今日って何曜日?smilesmile ×3smile ×3
かなってポンコツだよねangersmile ×1、fun ×2anger ×3
風邪ひいて熱があるんだsorrowsmile ×3sorrow ×3
宝くじが当たった!joyjoy ×3joy ×3
しりとりしようfunfun ×3fun ×3
また朝の3時までゲームしてたangersmile ×2、fun ×1anger ×3
お中元って何を送ればいいのsmilesmile ×2、fun ×1smile ×3
飼ってた猫が死んじゃったsorrowsorrow ×3sorrow ×3
想定どおり20/3634/36

調整後に外れた2回はどちらも財布で、返事が「えっ、大丈夫?まずは警察に届けてみて。」のような対処の案内になっていた。案内する返事なので、知識の答えとして微笑を選んだのだと思う。
怒りの返事は「またそんな時間まで起きてたの。体壊すから早く寝なさいよ」くらいで、軽く叱る程度だった。
返事の長さは平均27.3字から25.4字で、「(悲しそうに)」のような書き添えは36回とも出ていない。

5表情ぶんの顔データを入れたら、プログラムが4,069,283バイトになった。
CoreS3のボード設定の既定では、プログラムを置く領域(アプリ領域)が3MBしかない。
フラッシュメモリは16MBあるが、ボード設定の選択肢に3MBより大きいアプリ領域が無かったので、プログラムのフォルダーに partitions.csv を置いてアプリ領域を6MBにし、ボード指定に PartitionScheme=custom を付けた。
設定値の保存場所(NVS)の位置は既定と同じにしたので、保存済みの音量はそのまま残った。

# Name,   Type, SubType, Offset,   Size,     Flags
nvs,      data, nvs,     0x9000,   0x5000,
otadata,  data, ota,     0xe000,   0x2000,
app0,     app,  ota_0,   0x10000,  0x600000,
app1,     app,  ota_1,   0x610000, 0x600000,
ffat,     data, fat,     0xC10000, 0x3E0000,
coredump, data, coredump,0xFF0000, 0x10000,

実機で話しかけた

書き込んで、頭を叩いて「今日って何曜日?」と話し、もう一度叩いて送った。
返事は「今日は木曜日だよ。もうすぐ週末だね。」で、送信から1文目を喋り始めるまで12.5秒かかった。表情は微笑のままだった。

次に「財布なくしちゃった」と話した。

送信からの経過起きたこと
0秒フィラー1本目
3.8秒フィラー2本目
5.6秒聞き取り「財布なくしちゃった。」、返事「えー、それは大変!警察に届け出た?」、表情 sorrow が届く
8.0秒フィラー3本目
9.0秒返事の1文目の音声を受け取り終わる
12.5秒1文目を喋り始め、顔が悲しい顔に変わる
17.0秒喋り終わって微笑に戻る

表情は切り替わった。
ただ、「財布なくしちゃった」と言われて「少々お待ちください」系のフィラーで待たせるのは不自然だった。
それに、返事の文面は5.6秒、1文目の音声も9.0秒には受信できていたのに、8.0秒から鳴らした3本目のフィラーが終わるのを待って、喋り始めが3.4秒遅れていた。

フィラーを作り直した

フィラーは音声サーバーのIrodori-TTSで作ったもので、どれも質問への返事を前提にした言葉だった。
1本目を「ふむふむ」、2本目を「えーっと」にすれば、何を言われてもつながる。
音声サーバーのTTSを直接呼んで、「ふむふむ」「えーっと。」「うーん。」をシード違いで3本ずつ作り、聞き比べて1本ずつ選んだ。
16kHzに変換して前後の無音を詰め、端末のプログラムに埋め込んだので、起動時にサーバーから取ってくる処理はなくなった。

順フィラー長さ
1本目ふむふむ0.78秒
2本目えーっと1.34秒
3本目うーん1.35秒

会話ごとに1本目から鳴らし、返事の文面を受信したら次のフィラーは始めず、1文目の音声を受け取り終わったら、鳴っているフィラーを止めて返事に入るようにした。

最初はフィラーが鳴り終わったらすぐ次を鳴らしていたので、0秒、0.95秒、2.26秒と3本が続けて鳴り、そのあと返事まで4秒以上無音になった。
前のフィラーは1本4秒前後あったので、鳴り終わってから次、でちょうどいい間になっていた。
鳴り終わってから2秒空けて次を鳴らすようにした。

条件1文目を喋り始めるまで
作り直す前(財布)12.5秒
作り直して、間を空ける前8.0秒
間を空けたあと(「今日は暑いね。」「今何してんの。」)8.8秒、8.2秒

頭を叩いても反応が悪い

会話はつながったが、頭を叩いても反応が悪かった。
強く叩いても弱く叩いても、なかなか録音が始まらなかった。

録音停止用の2回目の叩きを拾えない

1回目の叩きで録音は始まるのに、止めるための叩きを拾えず、10秒の上限で捨てられる、ということがあった。
録音中はマイクから50ms分ずつ取得する合間にしかタッチを検知していなかった。
マイクの読み取りを待つ間も5msおきにタッチを検知するようにして、検知間隔の最大は86msから37msになった。

スピーカー再生後にタッチが効かなくなる

「財布なくしちゃった」の返事を喋り終えたあと、何度叩いても接触が1回も記録されなかった。強さのログ(0〜3)を毎回出す状態にしていたので、強さ1の接触でも出るはずだった。
シリアルからタッチの基準値を取り直させたら、その直後の叩きですぐ録音が始まった。
スピーカーを鳴らしたあとに基準値がずれて、叩いても接触と判定されなくなっていた。
会話が終わるたびに取り直すようにした。手が触れている間に取り直すと、触れた状態を基準にしてしまうので、触れていたら離れるのを待つようにした。

タッチ強度の値が約200ms刻みでしか変わらない

強さのログを確認すると、値が変わる時刻が約200ms刻みになっていた。
軽く叩くと200msの間に落ちて拾えず、続けて叩くと離れた判定が間に合わずに、何回かの叩きが1〜5秒ほどの長い接触にまとまって、叩きの判定(0.8秒以内)から外れていた。

最初はタッチのチップの省電力スキャンを疑って setSleep(false) にしたが、刻みは変わらなかった。
次に疑ったのがタッチ応答サイクル(RTC)で、何回続けて触れていたら接触とみなすかの設定。BSPの既定は2で、実際には4回連続になる。
シリアルから0(2回連続)に変えると、0.6〜1.2秒おきの叩きが毎回別々に拾えた。
そのまま11分放置しても、触っていない接触は1件も出なかった。

RTC=0 で固定して会話したら、返事の2文目が鳴り始めた瞬間に、触っていない接触(強さ1、40ms)が出て叩きと判定され、返事が止まった。
その直後の取り直しが乱れている最中に実行され、顔側と背中側のゾーンで接触が出続けた。
約1分の間に録音が25回始まり、そのうち4回は送信まで進んだ。4回ともサーバーが発話0.03〜0.24秒の no_speech で打ち切り、返事は作られなかった。
シリアルからRTCを2に戻して止めた。
放置で誤接触が出なかったのはスピーカーを鳴らしていなかっただけのようで、RTC=0 は使わないことにした。

叩くのは開始時のみに変更した

RTCが2のままだと、叩きを取りこぼしたり、2回と誤判定されることが残る。
叩いて開始して叩いて止める方式では、止めるための叩きが2回と誤判定されると、止めた直後に次の録音が始まって次の声を拾ってしまう。
叩くのは録音の開始だけにして、終わりは声が途切れたことで判定するようにした。

場面動き
待機中叩き1回で録音開始。続けて叩いてまとまった接触も、2秒以内なら1回の叩きとして扱う
録音中叩きは無視。話し始めてから声が1.5秒途切れたら送信
話し始めない5秒で送らずに捨てて「聞き取れなかった」
会話中・再生中叩きは無視

声かどうかは、音声サーバーの無音判定に合わせて、50msごとの音量が「この録音の静かな部分+10dB」と-50dBFSの大きいほうを超えたら声とみなすようにした。
途切れの長さは最初1秒で試したが、話の合間で切れてしまうことがあったため1.5秒にした。
「こんばんは」と話したときの判定はこんな感じ。

録音開始から音量判定
0.20〜1.20秒-27.5〜-35.5dB声ではない(境目は-25.5dB前後)
1.25〜1.75秒-11.8〜-21.8dB声
1.80〜3.25秒-26〜-36dB声ではない
3.25秒-声が1.5秒途切れたので送信

送る録音は最後の声から0.3秒のところで切るので、3.8秒分・66KBに抑えられた。
10秒の上限まで録ったら送らない、という最初の対策から、話し始めなければ5秒で捨てる形に置き換えた。
誤検出で録音が始まっても、声が入らなければ音声サーバーまで行かない。
実機のマイクで普通に話して、音声サーバーの無音判定に弾かれることはこの日の会話では一度もなかった。

触っていない接触を信号の強さで除外した

叩き方を変えたあと、起動直後に触っていない接触が出た。
WiFiにつないで基準値を取り直した直後から、12〜18秒ほど、強さ1〜2の接触が断続的に出て、録音が2回始まった。どちらも声が入らず、5.6秒で捨てている。
以前はWiFiにつないだあとフィラーを3秒ほどダウンロードしてから取り直していたので、埋め込みにしたことで取り直しのタイミングが早まった可能性はある。

起動後と会話・録音のあとは、接触が3秒続けて出なくなるまで待ってから取り直し、それまで叩きを受け付けないようにした。15秒たっても静かにならなければ、その場で取り直して待ち直すようにした。
これで2往復試したら、2往復目が始まらなかった。
返事のあとに叩くたびに「まだ触れている」と判定されて待ちが延び、15秒たって強制的に取り直すまで、叩きを受け付けていなかった。

触っていない接触と本物の叩きで、ログの強さ(0〜3)の最大値を比べた。

種類強さの最大値
起動直後・返事のあとの触っていない接触(RTC=2)ほとんど1、ときどき2
叩いたときほとんど3

強さが3未満の接触は叩きにしないようにして、安定を待つ間も強さ3の叩きは受け付けるようにした。
軽すぎて3に届かない叩きは反応しないので、少し強めに叩く運用にした。

2往復の会話で確かめた

書き込み直したら、起動直後にまた触っていない接触が出たが、強さの最大値は2どまりで、全部無視された。

時刻起きたこと
23:21:05強さ3の叩きで録音開始
23:21:09声が途切れて送信。聞き取り「今何してんの。」、返事「もうこんな時間だね。そろそろお布団に入ろうかな。」
23:21:33喋り終わる。直後の触っていない接触(強さ1)は無視
23:21:36喋り終わって3.6秒後の叩き(強さ3)で2往復目の録音開始
23:21:41送信。「もう寝るの?」が「もうねんの。」と聞き取られ、返事は「おやすみなさい。いい夢見てね。」
23:21:54以降触っていない接触はどれも強さ1で無視

1往復目は、聞き取り結果が届くまでに13.2秒かかった。ほかの会話では4.6〜6.7秒だった。
この前の会話で「今何時?」と聞いたときは「今は23時5分だよ。そろそろ眠くなってくる時間だね。」と正しく答えた。
続けて「今の天気は。」と聞いたら、「ごめんね、私は外の様子が分からないから答えられないんだ。窓の外を見て教えてくれる?」と返ってきた。

時刻に答えられたのは、音声サーバーがプロンプトに書き込んでいるからで、Qwen単体で調べたわけではない。
音声サーバーはModelScopeのAPIでQwenを呼んでいるだけで、Qwen Codeのようなエージェントの仕組みは入れていないので、天気や最近の出来事は調べようがない。