AIと声で会話できるbotを、全部自分のマシンで、無料で動かしたかった。これが出発点でした。
外部のAPIを叩かない。月額課金もしない。クラウドにも上げない。手元のM1 Max一台で、マイクで喋れば文字起こしされて、ローカルのLLMが考えて、合成音声が声で返してくる。そういう「全ローカル・無料・所有」の音声会話botを作りたかったんです。名前は「kit」。マイクで喋ると、しばらくして声で返ってきて、そのまま自動で次を聞いてくれる、一人収録のための相方みたいなものです。
なぜ相方が欲しかったかというと、もとはと言えば Podcast をやりたかったからです。【Podcast 挑戦記 ①】40歳を超えて、Podcast をやってみたいのほうで「一人喋りが難しいから、まず喋りの相手を道具から作る」と書きました。これは、その続きの ② です。kit は、その道具の中身です。あっちが「なぜ作るか」の話なら、この記事は「どう作ったか」の実装記録、というつもりで読んでもらえると繋がります。
普通の音声チャットbotは、わりとシンプルな構造をしています。「人が喋り終わる → 文字起こしする → 考える → 喋り返す」という単線。一本道です。でも私は、これがどうしても人との会話に似てこないのが嫌でした。人と話しているとき、相手は私が喋り終わるのを黙って待ってなんていない。聞きながらうなずくし、相槌を打つし、こっちが言い淀めば先を促すし、頭の中では返事を準備している。全部が同時に走っている。だからkitは、単線をやめて並行システムとして組みました。
この記事は、その実装の中身を隅々まで正直に書いたものです。関数名も、config値も、実測した数字も、丸めずに出します。そしてきれいごとだけでなく、1枚のGPUの物理的な制約で「完全には実現できていない」部分も、技術記事の価値として正直に書きます。
何が普通と違うのか — 単線をやめた
kitは、人が喋っている「最中」に、複数のことを同時にやっています。
- 耳(whisper)は、喋っている途中の文字を滑走窓で起こし続ける
- DSP は、声の音量(RMS)と声の高さ(真F0)を 80ms ごとに測り続ける
- 「節の谷」を見つけたら、相手の声に被せて無色の相槌(「うん」「あー」)を打つ
- 沈黙が深まってきたら、段階的に質問や新しい話題を振る
- 裏では、大きいモデルが時間をかけて深い答えを煮込んでいる
これらが全部、人が一言喋っている間に並行で動いています。問題は、こういう「喋りたがり」が5系統もいると、同じ瞬間に複数が一斉に発声しようとして衝突することです。沈黙を埋めようとする声と、相手に被せる相槌と、割り込みの発話が、同じ一拍を奪い合う。
そこで kit は、発声する瞬間だけは必ず単一の Arbiter(制御スレッド)を1点通る設計にしました。並行で走る5系統がそれぞれ「喋りたい」と言ってきても、最終的に「今この瞬間どうするか」を決めるのは Arbiter ただ一つ。返す答えは {STAY_SILENT | BACKCHANNEL | FACILITATE | SPEAK} のどれか1つだけです。詳しくは後ろの「設計の心臓」で書きます。
全体像
頭の中で「並行で動いている」と言葉にするのは簡単ですが、実際に手を動かすと、どこが分かれてどこで合流するのかを一度ちゃんと絵にしないと、自分でも迷子になりました。だからまず、5系統がどう並行で回って、どこで1点に集約するのか。これを一枚で見てもらうのが早いです。
┌─────────────────────────────────────────────────────┐
│ 人(マイク入力) │
└───────────────────────┬─────────────────────────────┘
│ 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
kit でいちばん大事なのが、この Arbiter です。arbitrate() という関数が唯一の決定点で、並行で走る各系統からの「喋りたい」要求を受けて、1つの裁定だけを返します。
なぜここまで「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)の判断を待っていたら間に合いません。人が喋っている最中に被せるには、LLM を呼んでいる暇はない。だから kit は、フロント側に「脊髄反射」の層を置いています。
bcTick() という関数が 80ms ごと(bc_tick_ms=80)に回って、人が喋っている最中の「節の谷」に無色の相槌を被せます。「節の谷」をどう見つけるかというと、ここで真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秒のラグを資産化する — 二層思考
ここが kit のいちばん知的なギミックです。
ローカルの大きいモデルは、深い答えを返すのに時間がかかります。Gemma-4 26B は、本番環境だと1ターンに100秒以上かかる。普通に考えれば、これは致命的な弱点です。100秒も黙っていたら会話は死ぬ。
そこで kit は、速い浅い答えと遅い深い答えを二層で並走させました。人が喋り終わると、まず Swallow 8B が約8秒で「受けの一拍」を返します。「ちゃんと聞いてるよ」という即応です。これで会話のテンポは死なない。そしてその裏で、Gemma-4 26B が約110〜185秒かけて、もっと深い答えを煮込んでいる。煮込みが終わって、しかも「出していい瞬間」(人が喋っていない・言い淀んでいない・最新の話題に対する答えである)が来たら、深い答えを差し込みます。
これは情報工学的なギミックでもあります。先に「期待値の低い、軽い受け答え」を踏み台として置いておく。すると、後から深い答えが来たときの「お、ちゃんと考えてたんだ」という上振れ(報酬予測誤差)が最大化される。期待を低く設定しておいて、後で超える。
そして最重要なのが、新しい発話が来たら、深層の計算を即キャンセルして 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 は「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 一台が耳と脳と口を時分割しながら、人間に近いテンポで相槌を打ち、深く考え、番組として録画する。その全体を「単線でない並行システム」として組んだ記録でした。まだ進行中の部分も多いですが、ここまでの実装を隅々まで正直に書いてみました。


