「kit」は、外部APIを一切使わず、手元のM1 Max一台だけで無料で動く、声で会話できるAI botです。この記事は、そのkitを並行システムとして実装した技術記録です。聞く・考える・相槌を打つ・深く考える・喋るの5系統を並行で回し、単一のArbiterが1つの裁定に集約します。普通の音声botの「聞き終わってから考えて喋り返す」直列処理は採りませんでした。

なぜ作ったかというと、Podcastの喋り相手が欲しかったからです。【Podcast 挑戦記 ①】40歳を超えて、Podcast をやってみたいで「一人喋りが難しいから、まず喋りの相手を道具から作る」と書きました。この記事はその続きの②で、kitはその道具の中身です。①が「なぜ作るか」の話なら、②は「どう作ったか」の実装記録です。

出発点は「AIと声で会話できるbotを、全部自分のマシンで、無料で動かしたい」でした。外部のAPIを叩かない。月額課金もしない。クラウドにも上げない。マイクで喋ると文字起こしされ、ローカルのLLMが考え、合成音声が声で返してきます。返事が終わると自動で次を聞き始めるので、一人収録の相方として使えます。そういう「全ローカル・無料・所有」の音声会話botを作りたかったのです。

この記事では、関数名・config値・実測した数字を丸めずに出します。1枚のGPUという物理制約で「完全には実現できていない」部分も、隠さず書きます。技術記事の価値はそこにあると考えているからです。

普通の音声botと何が違うのか

一番の違いは、人が喋っている最中に、kitが複数の処理を同時に走らせている点です。普通の音声チャットbotは「人が喋り終わる → 文字起こしする → 考える → 喋り返す」の一本道で動きます。

私はこの一本道が、どうしても人との会話に似てこないのが嫌でした。人と話しているとき、相手は私が喋り終わるのを黙って待っていません。聞きながらうなずき、相槌を打ち、こちらが言い淀めば先を促します。頭の中では返事の準備も始めています。全部が同時に走っています。

だからkitは、直列処理をやめて並行システムとして組みました。人が一言喋っている間に、次の5つが並行で動きます。

  • 耳(whisper)は、喋っている途中の文字を滑走窓で起こし続ける
  • DSP は、声の音量(RMS)と声の高さ(真F0)を 80ms ごとに測り続ける
  • 「節の谷」を見つけたら、相手の声に被せて無色の相槌(「うん」「あー」)を打つ
  • 沈黙が深まってきたら、段階的に質問や新しい話題を振る
  • 裏では、大きいモデルが時間をかけて深い答えを煮込んでいる

ここで問題が起きます。「喋りたがり」が5系統もいると、同じ瞬間に複数が一斉に発声しようとして衝突します。沈黙を埋めようとする声と、相手に被せる相槌と、割り込みの発話が、同じ一拍を奪い合うのです。

そこでkitは、発声する瞬間だけは必ず単一のArbiter(制御スレッド)を通る設計にしました。5系統がそれぞれ「喋りたい」と言ってきても、「今この瞬間どうするか」を決めるのはArbiterただ一つです。返す答えは {STAY_SILENT | BACKCHANNEL | FACILITATE | SPEAK} のどれか1つだけです。詳しくは後の「なぜ発声の決定を Arbiter 一つに絞ったのか」で書きます。

kit の全体像はどうなっているか

kitは、知覚・理解・場(タイミング)・生成・出力の5系統が並行で回り、音を出す瞬間だけ単一のArbiterに合流する構造です。まず一枚の図で見てもらうのが早いです。

頭の中で「並行で動いている」と言うのは簡単でした。しかし実際に手を動かすと、どこが分かれてどこで合流するのかを一度ちゃんと絵にしないと、自分でも迷子になりました。

                    ┌─────────────────────────────────────────────────────┐
                    │                  人(マイク入力)                      │
                    └───────────────────────┬─────────────────────────────┘
                                            │ getUserMedia(1 stream・二重tap禁止)
                                            ▼
        ┌──────────── 単一 AnalyserNode(fftSize=2048)─────────────────┐
        │                                                                │
   ┌────┴─────┐   ┌──────────┐   ┌──────────────┐   ┌──────────┐   ┌────┴──────┐
   │ ① 知覚    │   │ ② 理解    │   │ ③ 場/タイミング  │   │ ④ 生成    │   │ ⑤ 出力     │
   │ P層       │   │ FSM       │   │ 反射/相槌     │   │ 二層思考  │   │ O層 録画   │
   │           │   │           │   │              │   │          │   │           │
   │ readRms   │   │ IDLE      │   │ bcTick 80ms  │   │ Swallow  │   │ 生声 mux  │
   │ readF0    │   │ ACQUIRING │   │ 被せ相槌      │   │ 即応 ~8s │   │ AI 声     │
   │ partial   │   │ LISTENING │   │ 沈黙 nudge    │   │ +        │   │ 相槌 mix  │
   │ STT       │   │ THINKING  │   │ stage1/2/3   │   │ Gemma-4  │   │ → wav     │
   │ (whisper) │   │ NUDGING   │   │ 考え中の埋め  │   │ 深層     │   │ → mp4     │
   │           │   │ SPEAKING  │   │              │   │ ~110-185s│   │           │
   └────┬─────┘   └────┬─────┘   └──────┬───────┘   └────┬─────┘   └───────────┘
        │              │                │                │
        ▼              ▼                ▼                ▼
   ┌────────────────────────────────────────────────────────────┐
   │   ★ 単一 Arbiter(制御スレッド) arbitrate(kind, stage)      │
   │   muteMode → floor → governor を見て 1 action だけ返す       │
   │   {STAY_SILENT | BACKCHANNEL | FACILITATE | SPEAK}          │
   └────────────────────────────────────────────────────────────┘
                                            │
                                            ▼
                サーバ(体: HTTP I/O・GPU を 1 枚に直列化)

ポイントは、①〜⑤が全部並行で回りながら、音を出す瞬間だけは必ずArbiterという1点を通ることです。「沈黙を埋めたい」「相槌を被せたい」「割り込みで喋りたい」の三つ巴が同じ瞬間を奪い合う衝突を、この構造で防いでいます。決定する場所が一箇所しかなければ、衝突はそもそも起きません。

ハードウェアと使っているモデルは次の構成です。全部ローカルで動かすので、ここが物理的な天井になります。

役割 実装
マシン M1 Max 64GB / Metal GPU 1 枚
耳(音声→文字) whisper.cpp(whisper-cli)+ ggml-small
脳(声会話の即応LLM) Llama-3.1-Swallow-8B-Instruct Q4_K_M
脳(深層・文字会話LLM) Gemma-4-26B Q4_K
口(文字→音声) AivisSpeech(VOICEVOX互換のローカルHTTP)

数値はすべて実際の設定ファイル(data/chat_config.json・約400キー)から確認した実値で、推測ではありません。

なぜ発声の決定を Arbiter 一つに絞ったのか

最初は各系統が自分の判断で勝手に喋る作りにしていて、何度も声が衝突したからです。判断する場所を arbitrate() という関数一つに集め、そこだけを正しくする方針に切り替えました。

arbitrate() はkitの唯一の決定点です。並行で走る各系統からの「喋りたい」要求を受けて、1つの裁定だけを返します。あちこちにif文で「喋っていいか」の判断を散らすと、必ずどこかに判定漏れができます。実際の優先順位は次のとおりです。

arbitrate(要求):
  1. Arbiter 無効 → 旧経路(回帰防止のため温存)
  2. muteMode(本人が黙らせている)→ SILENT(第一級)
  3. overlap 以外で「床」が AI に開いていない → SILENT
       (被せ相槌は床を奪わない信号なので床ゲートを免除)
  4. overlap 以外で stage が FACILITATE 以上 → 旧経路へ
       (段階発火は予算で切らない=責務分離)
  5. overlap かつ governor 免除設定 → 旧経路へ
  6. governor 有効 かつ この谷の AI 発話が予算超 → SILENT
  7. 旧経路(temperature を 1 action に写す)

ここには「同じ形をした責務分離」が2つ効いています。

1つめがfloor(床権)です。会話の「床(喋る権利)」は、既定で人が握っています。人がしばらく黙ったとき(floor_open_silence_ms=2200 以上)に、初めて床がAIに開きます。床が閉じていれば、AIは喋れません。ただし被せ相槌だけは例外で、床ゲートを免除しています。被せ相槌は「人が床を握っている最中でも鳴っていい、聞いていますよという信号」だからです。床を奪う発話と、床を奪わない相槌を、はっきり別扱いにしています。

2つめがgovernor(谷予算)です。人がひとしきり喋ってから次に喋り出すまでの間を「谷」と呼びます。この谷でAIが場を埋める連打をしないように、1つの谷でAIが喋れる回数に予算(governor_valley_budget=2)を置いています。ただし、深い沈黙への段階発火(stage2/3)は予算で数えません。数えてしまうと「軽い反射の相槌1回で予算を使い切り、本当に必要な深い問いかけが永遠に発火しない」という二次バグになるからです。

そして大事なのが、沈黙を選ぶこと(SILENT)も、無視ではなくアクティブな決定だという点です。kitにとって黙ることは、Arbiterが「今は黙るのが正しい」と積極的に選んだ呼吸です。「何もしない」とは違います。

聞きながら相槌を打つ仕組みはどう作ったか

相槌は、LLMの判断を待たずに、フロント側の「脊髄反射」の層で打ちます。bcTick() という関数が80msごと(bc_tick_ms=80)に回り、人が喋っている最中の「節の谷」に無色の相槌を被せます。

LLMを待っていては間に合いません。人が喋っている最中に被せるには、LLMを呼んでいる暇がないからです。

「節の谷」をどう見つけるか。ここで真F0(声の高さ)を使うのがミソです。最初は声の音量(RMS)だけで谷を探していましたが、それでは足りませんでした。相槌を打つべき瞬間は、単なる音量の谷ではなく、声が一区切りに向かって下がった瞬間(low-pitch settling)です。人は文を締めにいくとき、自然と声の高さが下がります。これは音量の谷とは違うタイミングで来ます。

だから声の高さをHz空間で測り、直前の基準より下がったか(f0_settle_ratio=0.92)を見るようにしました。声の高さが不確かなとき(明瞭度が f0_clarity_min=0.45 未満)は、いいかげんな値をでっち上げず0として扱います。測れないものを測れたふりはしません。

面白いのが、相槌を確率で打つ点です。条件が揃っても bc_fire_prob=0.12 の確率でしか発火させず、それ以外は見送ります。固定メトロノームにしないためです。一定間隔で「うん、うん、うん」と打つと機械っぽくなり、むしろ聞いていない感じが出ます。予測できないタイミングで来るからこそ、相槌が生きます。

さらに bc_suppress_window_ms=4500 の休止時間と、60秒あたり最大8回のレート制限を重ねています。これで実効レートは「1分あたり7〜8本」くらいの疎な水準に収まります。火力を相対的に下げて、聞き手に降りた姿勢を作るためです。

この反射層を支えているのが、状態を1つしか持たないFSM(有限状態機械)です。kitの状態は次の一本道で、必ず setState() 経由でしか遷移しません。

IDLE → ACQUIRING → LISTENING → THINKING → NUDGING → SPEAKING

状態が同時に2つになることは絶対にありません(isCharging && isStunned のようなフラグの組み合わせを禁止しています)。状態が一意なら「この状態のとき何が起きるか」が完全に予測でき、判定漏れもDead Zoneも生まれません。たとえば被せ相槌が回るのはLISTENINGのときだけ、と決まっています。

再生処理には「世代カウンタ + watchdog」の三重ガードもかけています。マイク取得・再生終了・fetch解決といった外部の非同期処理が割り込んでも、「ちょうど1回だけ」再生されることを物理的に保証するためです。

100秒のラグをどう会話の価値に変えるか(二層思考)

速い浅い答え(Swallow 8B・約8秒)と、遅い深い答え(Gemma-4 26B・約110〜185秒)を二層で並走させます。先に軽い受け答えでテンポを保ち、深い答えは出せる瞬間が来たら差し込みます。ここがkitのいちばん知的なギミックです。

ローカルの大きいモデルは、深い答えを返すのに時間がかかります。Gemma-4 26Bは、本番環境だと1ターンに100秒以上かかります。普通に考えれば致命的な弱点です。100秒も黙っていたら会話は死にます。

そこで人が喋り終わると、まずSwallow 8Bが約8秒で「受けの一拍」を返します。「ちゃんと聞いてるよ」という即応です。これで会話のテンポは死にません。その裏で、Gemma-4 26Bが約110〜185秒かけて、もっと深い答えを煮込みます。煮込みが終わり、しかも「出していい瞬間」が来たら、深い答えを差し込みます。「出していい瞬間」とは、人が喋っていない・言い淀んでいない・最新の話題に対する答えである、の3条件が揃った時です。

これは情報工学的なギミックでもあります。先に「期待値の低い、軽い受け答え」を踏み台として置いておきます。すると、後から深い答えが来たときの「お、ちゃんと考えてたんだ」という上振れ(報酬予測誤差)が最大化されます。期待を低く設定しておいて、後で超える設計です。

最重要なのが、新しい発話が来たら、深層の計算を即キャンセルしてGPUを譲ることです。煮込んでいる途中でも、人が次を喋り出したら deep_cancel で容赦なく止めます。kitのNorth Starの一つが「人が常に勝つ」です。AIがどんなに良い答えを考えている途中でも、人の声・人の割り込み・人の新発話は、AIのどの発話よりも即座に優先されます。GPUを握ったまま離さないAIではなく、人が喋った瞬間に身を引くAIを作りました。

深層を管理するワーカーは1スロットです。新しい要求が来ると世代カウンタを上げて古い計算を捨て、最新世代の結果しか採用しません。これで「古い話題への答えが今ごろ出てくる」事故を防いでいます。

全部ローカルで動かす限界はどこにあるか

M1 MaxのMetal GPUは1枚しかありません。耳(whisper)も脳(LLM)も口(AivisSpeech)も同じ1枚を使うので、これらは時間的に排他です。「聞きながら、同時に、深く考える」完全な並行は、原理的にできていません。ここが、いちばん正直に書くべきセクションです。

並行に見えるのは、1枚のGPUを細かく切り替えているからです。本当の意味で同時には走っていません。物理的に時分割するしかないのです。

これを成立させるために、GPUへのアクセスは1本のロック(_GPU_SPAWN_LOCK)で直列化しています。声会話の本筋は、ロックが空くまで待ちます(深層が走っていれば待たされます)。沈黙埋めのnudgeはbest-effortで、GPUが忙しければ即座に「GPU 忙しい」エラーを返し、空の声で穏やかに諦めます(ここでも人が勝ちます)。深層は、どんな出口を通っても必ずfinallyでロックを解放するので、GPUが永久に詰まることはありません。

そのうえで、正直な限界を書きます。

限界 詳細
深層が毎ターン cancel されて届きにくい 人が喋り続ける長いターンでは、新発話のたびに深層を捨てて GPU を譲るので、深い答えが完走しないことが多い。「100秒のラグを資産化」は構想であって、間欠 cancel の多い実運用ではまだ届きにくい。content-reply(深い答えの出し方)の調整は現在進行中です。
メモリ圧で深層が遅い 後述しますが、単独なら速いモデルが、常駐物の同居で大きく減速します。
被せ割り込みは録音中だけ AI が喋っている最中(SPEAKING)は、声を測るための解析器を畳んでいるので、声での割り込みは効きません。ボタンでの割り込みのみ。声で割り込めるのは録音している窓の中だけです。

特に正直に書きたいのが、実測で自分の仮説を否定した話です。

最初は「深層LLMが遅いのは、Gemma-4のMoEというアーキテクチャのせいだ」と推測していました。アーキテクチャが悪いなら、もっと素直な小さいモデルに替えれば速くなるはずだ、と。ところが、モデルを単独で計測するとGemma-4は約28 tok/s出ます。十分速いのです。

本番環境(SwallowとAivisSpeechが常駐している状態)で測り直すと、約4.7 tok/s。6倍に減速していました。律速はアーキテクチャではなく、同居しているプロセスによるメモリ圧でした。

アーキテクチャを疑ってモデルを下げる前に、計測がそれを否定してくれました。仮説を測定で殺すこの一手が、無駄なモデル交換を防ぎました。推測で動かず、まず測る。これは技術記事として残しておきたい教訓です。

実機テストで何を直したか

kitは「設計書を書いて一気に実装した」作られ方をしていません。本人が実機でlive testし、体感で「ここが変」と指摘し、診断して直す。このループの積み重ねで育ちました。泥臭い直しの代表例を挙げます。

症状(実機で感じたこと) 診断 直し
番組のmp4に本人の生声ターンが入っていない 高遅延中の割り込みで、録音イベントが取りこぼされていた サーバ側でターン応答の前に生声をディスクに保存し、後から mtime を頼りに拾い直す fallback を追加。人の声は必ず mp4 に残るようにした
相槌が連発して、めちゃくちゃ長い相槌になる(本人激怒) 考え中フィラーの連鎖が無制限だった 連鎖を1発に制限し、沈黙窓を置いて「filler→沈黙→1発だけ生存信号→応答合流」という呼吸に変えた
224秒の一人語りでターンが切れない 長い録音の途中でフィラーを跨ぐと、ターンの上限タイマーの起点がリセットされて巻き戻っていた 上限の起点をターン級の起点に固定し、フィラーを跨いでも据え置くようにした
声が完全に止まる(GPU 永久ブロック) nudge とターンのロック競合で、プロセスを kill したときロックが宙吊りになっていた finally で必ず子プロセスを回収してからロックを解放。どの経路を通っても1回も漏らさない

whisperが「めめめめ」のような幻覚を出す問題も直しました。単一文字のチェックでは複数文字のループを見逃していたので、n-gramの反復検出に変えました。ただし「そうそう」のような正当な2回反復の相槌は守るため、最低3反復から幻覚と見なします。検出は本番ビルドと独立した検証で行い、結果は約70KBの検証レポートに記録しています。

まとめ:kit の技術的な要点

kitは「AIと声で話す」を、単線のリクエスト/レスポンスとしては実装しませんでした。知覚・理解・場・生成・出力の5系統が並行で回り、単一Arbiterが1裁定に集約するシステムとして組みました。技術的な要点は次の3つです。

  1. FSM 排他 state:状態を1つしか持たないことで、判定漏れとDead Zoneをゼロにする
  2. 単一 Arbiter:発声する瞬間だけ1点を通すことで、三つ巴の衝突を構造的に防ぐ
  3. 二層思考:速い浅い答えと遅い深い答えを並走させ、本来不利な100秒のラグを資産に変えようとする

正直に言えば、1枚のGPUの物理制約で「聞きながら深く考える完全並行」は、まだ実現できていません。深層は毎ターンcancelされがちで、メモリ圧で遅く、content-replyの調整は現在進行中です。それでも「会話を死なせない」「人が常に勝つ」という2つのNorth Starは守れています。best-effortで品質を落としながらでもturnを完結させる設計と、GPUロックの直列化があるからです。

全ローカル・無料・所有で、M1 Max一台が耳と脳と口を時分割しながら、人間に近いテンポで相槌を打ち、深く考え、番組として録画する。その全体を「単線でない並行システム」として組んだ記録でした。まだ進行中の部分も多いですが、ここまでの実装を隅々まで正直に書きました。

よくある質問

Q. kit は外部の API やクラウドを使いますか? A. 使いません。M1 Max 64GB 一台の上で全部ローカルで動かします。耳は whisper.cpp + ggml-small、脳は Llama-3.1-Swallow-8B-Instruct と Gemma-4-26B、口は AivisSpeech です。月額課金もありません。

Q. 5系統が同時に喋ろうとしたらどうなりますか? A. 発声の決定は arbitrate() 一箇所だけが行い、{STAY_SILENT | BACKCHANNEL | FACILITATE | SPEAK} のどれか1つを返します。決定する場所が一つなので、声の衝突は構造的に起きません。

Q. 深い答えに100秒以上かかるのに、会話は途切れませんか? A. 先に Swallow 8B が約8秒で軽い受け答えを返し、その裏で Gemma-4 26B が約110〜185秒かけて深い答えを作ります。ただし人が新しく喋り出すと深層は deep_cancel で止まるので、長いターンが続く実運用では深い答えが届きにくいのが現状です。

Q. AI が喋っている最中に声で割り込めますか? A. できません。SPEAKING 中は声を測る解析器を畳んでいるので、割り込みはボタンのみです。声で割り込めるのは録音している窓の中だけです。