「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つです。
- FSM 排他 state:状態を1つしか持たないことで、判定漏れとDead Zoneをゼロにする
- 単一 Arbiter:発声する瞬間だけ1点を通すことで、三つ巴の衝突を構造的に防ぐ
- 二層思考:速い浅い答えと遅い深い答えを並走させ、本来不利な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 中は声を測る解析器を畳んでいるので、割り込みはボタンのみです。声で割り込めるのは録音している窓の中だけです。


