はじめに
前回の記事 では、音声コーディングツールを作る前の下調べとして、リアルタイム STT 3 社(Speechmatics / Soniox / Modulate)の partial(途中の文字起こし)と確定のタイミングを測りました。
その結論を受けて実際に作ったのが、VS Code 拡張 Voice Coder です。Marketplace で公開しています。
- Marketplace: https://marketplace.visualstudio.com/items?itemName=tsekino62.voice-coder
- ソースコード(MIT): https://github.com/tsekino62/voice-coder
Ctrl+Shift+Space を押して「FizzBuzz を作って」と話すと、カーソル位置にコードが入ります。「10 行目から 20 行目を説明して」なら説明が出力パネルに流れ、「デバッグして」なら修正案が差分で出ます。
この記事では、どういう考えで作ったか、前回の計測結果を設計にどう落とし込んだか、作ってみて何が分かったかを書きます。
先に結論
- コンセプトは 「AI にまとめて作らせる」のではなく「人間が 1 つずつ確かめながら、横で手伝ってもらう」。得意なのは、関数を 1 つ作る、ここを説明してもらう、といった小さな作業。
- 前回の示唆どおり、STT は Soniox、partial で意図が読めた時点で LLM を走らせ、確定(final)で答え合わせする 構成にした。合成音声 15 本の計測では、発話終了から LLM の起動まで中央値 0.18 秒、確定まで待つと 0.78 秒 だった。約 0.6 秒早く動き出せる。
-
先読みで走らせた結果は、確定するまでエディタに書かない。 外れたら
AbortSignalで止めるだけで、取り消し処理は要らない。 - 意図の読み取りは 動詞の語幹の正規表現 が主役。「作っ」まで出れば generate と読める。キーワードの無い言い回し(「FizzBuzz がほしい」)だけ、外部の分類器(jev)に回す。
- VS Code 標準の音声入力 + Copilot のエージェントモードと比べると、話し終わりからコードが入るまで 14.8 秒 → 3.5 秒(同じ音声・同じ Copilot のモデル、各 1 回)。差の大部分は先読みではなく、エージェントを通さないことによるものです。エージェントは、音声入力のあとに送信の操作が 1 回要るうえ、依頼の意図をつかんで作業を組み立てるのに時間がかかります(後述)。
計測の多くは ElevenLabs で合成した音声(話者 1 人ぶん × 15〜30 本)で行っています。人の声・マイク・部屋の雑音を含む条件とは数字が変わります。傾向の把握が目的です。
コンセプト: 人間が主導して、AI は横で手伝う
最近のコーディング支援は、エージェントに依頼して「まとめて作ってもらう」方向に進んでいます。便利ですが、大量のコードが一度に出てくると、何が書かれたのかを後から読み解く必要があります。
Voice Coder はその逆を狙いました。コードを書くのは人間が主導し、AI には小さな単位で手伝ってもらう という考え方です。
- 「この関数を作って」と頼んで、入ったコードをその場で確かめる。気に入らなければ
Ctrl+Z1 回で戻す。 - 読んでいて分からないところがあれば、手を止めずに「ここ説明して」と聞く。説明は出力パネルに流れ、コードは変わらない。
- 直す・まとめるといった変更は差分で見せ、承認するまで書き込まない。
キーボードから手を離さずに、隣の人に声をかける感覚で頼める、というのが狙いです。特に 説明を聞く のは、チャット欄を開いて質問を打ち込むより気軽で、作っていて便利だと感じたところです。
この使い方では、1 回の依頼が小さい代わりに回数が多くなります。だから 1 回ごとの待ち時間が効いてきます。以降で説明する先読み実行は、そのための仕組みです。
前回の結論のおさらい
前回分かったことのうち、設計に効いたのは次の 3 点です。
| 前回の結果 | 設計への反映 |
|---|---|
| 日本語は動詞が文末に来るので、言い終わる 前 に意図は読めない。読めるのは発話終了の 0.3〜0.5 秒後 | 「言い終わる前に動く」はあきらめる。そのかわり 確定を待たずに動く |
| Soniox は確定が遅い(中央値 1.3 秒)ぶん、partial で先に動く価値が大きい。語彙を渡せばコード用語は全問正解 | STT は Soniox。語彙(FizzBuzz / async / リファクタ など)を必ず渡す |
Soniox の終了判定の上限 max_endpoint_delay_ms を 1000 ms にすると、言い直しの分割を増やさずに最悪値だけ縮む |
既定値を 1000 ms にした(設定 voiceCoder.maxEndpointDelayMs) |
できること
話した内容から「意図」を読み、意図ごとに決まった処理を走らせます。チャット欄に文字を入れるだけのツールとの違いはここです。
| 意図 | 話し方の例 | 動作 |
|---|---|---|
| generate | 「FizzBuzz を作って」 | カーソル位置に挿入。1 回の WorkspaceEdit なので Ctrl+Z 1 回で戻る |
| explain | 「10 行目から 20 行目を説明して」 | 出力パネルに説明を流す。ファイルは変えない |
| debug | 「デバッグして」「テストが通らないんだけど」 | エラー(getDiagnostics)とコードから修正案を作り、差分で表示。承認するまで書き込まない |
| refactor | 「似たクラスを抽象クラスにまとめて」 | 開いているファイルを LLM に渡し、複数ファイルの差分で表示 |
| create | 「User クラスのファイルを作って」 | 新しいファイルの案を差分で表示 |
| run | 「実行して」「テストを実行して」 | 言語に合ったコマンド(python / npx tsx / npm test など)をターミナルで実行 |
日本語のほか英語("Create FizzBuzz" / "Explain lines 10 to 20")でも話せます。
run のコマンドは拡張側が決まった形で組み立て、LLM の返答は実行しません。音声の聞き違いや LLM の出力で任意のコマンドが走るのを避けるためです。
全体の構成
マイク (pvrecorder) ─▶ Soniox (stt-rt-v5) ─partial/final─▶ IntentSpeculator ─▶ LLM (Copilot / OpenAI / Claude)
│ │
ステータスバー final で確定した結果だけをエディタへ
-
マイク:
@picovoice/pvrecorder-nodeで 16 kHz モノラルを Node から直接録る。最初は Python のサイドカー(PyAudio)で録っていたが、利用者に Python を要求したくないので置き換えた。 - STT: Soniox のリアルタイム API に WebSocket で流す。
-
意図の読み取りと先読み:
IntentSpeculator(後述)。 -
LLM: 既定は GitHub Copilot のモデル(VS Code の Language Model API)。Copilot に入っていれば、必要な API キーは
SONIOX_API_KEYだけで済む。Copilot が無ければ OpenAI、設定で Claude(Claude Agent SDK)も選べる。
先読み実行の実装
partial で起動し、final で答え合わせする
中心になるのは IntentSpeculator です。やっていることは単純で、partial が届くたびに意図を読み、意図が変わったら前の実行を止めて新しく起動します。final が届いたら、走っている実行の意図と final の意図を比べ、同じならそのまま採用、違えば止めて final の意図でやり直します。
private partial(text: string, atMs: number): void {
const intent = this.read(text);
const state = this.state;
// まだ意図語が出ていない partial は、走っている実行を止める理由にならない
if (!intent || (state.current && sameIntent(state.current.intent, intent))) return;
this.abort(state); // 前の先読みを止める
this.dispatch(state, intent, text, atMs, /* speculative */ true);
}
private resolve(pending: Pending, text: string, finalAtMs: number, intent: Intent | null): void {
// final の意図が、先読みで走らせたものと違えば止めてやり直す
if (!pending.current || !sameIntent(pending.current.intent, intent)) {
this.abort(pending);
if (intent) this.dispatch(pending, intent, text, finalAtMs, false);
}
this.handlers.onResolved?.({ text, intent, dispatch: pending.current, aborted: pending.aborted /* ほか */ });
}
(実際のコードから、非同期の分類器を待つ処理などを省いて抜粋しています。全体は src/intent/speculator.ts)
「同じ意図か」は、意図の種類だけでなく 行範囲と識別子 まで比べます。「10 行目から」の段階で explain が起動し、そのあと「20 行目」が出て範囲が変わったら、やり直しになります。
確定するまでエディタに書かない
先読みで一番気をつけたのは、外れた実行が何も残さない ことです。
LLM の出力は、実行ごとのバッファにためるだけにしています。エディタに書き込む(generate の挿入、debug の差分表示など)のは、final で採用が決まった実行だけです。外れた実行は AbortController.abort() で止めます。Copilot のモデルなら CancellationToken に、OpenAI なら signal に、そのままつながっています。
/** Start the agent for a dispatch (possibly speculative). Touches nothing in the editor. */
begin(dispatch: Dispatch): void {
// ...
run.done = (async () => {
for await (const text of this.agent().run(dispatch.intent, target, context)) {
if (dispatch.signal.aborted) return;
run.output += text; // ためるだけ。エディタには書かない
}
})();
}
こうしておくと、「書いたものを取り消す」処理が要りません。前回の記事で心配していた「言い直しのたびに LLM を取り消す処理」は、止めて捨てるだけになりました。
なお、explain だけは、採用が決まった時点でそれまでにたまった出力をまとめて出し、以降はストリーミングで流します。先読みが当たっていれば、確定した瞬間に説明の冒頭がすでに表示されます。
外れたときのコスト
外れた実行も、止めるまでに使った LLM のトークンは消費します。とはいえ partial から final までは 1 秒未満なので、止まるまでに出力されるのは数十トークン程度だと見込んでいます(推測で、実測はしていません)。合成音声 15 本の計測では、partial で起動した実行がそのまま final で採用されたのが 15/15 で、final でやり直しになったものはありませんでした(partial の途中で意図や範囲が変わって起動し直した回数は集計していません)。
意図の読み取り
動詞の「語幹」を正規表現で読む
partial は「FizzBuzz を作っ」のように、動詞の途中で切れて届きます。そこで、動詞全体ではなく 語幹 で照合しています。
const INTENT_WORDS: Array<[IntentKind, RegExp]> = [
["debug", /デバッグ|バグ|不具合|エラー|動かない|落ちる/g],
["explain", /説明|解説|教えて|どういう(?:意味|こと)|何をして|なにをして/g],
// 作 but not 動作/操作/工作/制作 (動作しない is a bug report, not a request to build)
["generate", /(?<![動操工制])作[っるりれ成]|実装|書い|書く|書き|生成/g],
// ...
];
日本語は動詞が最後に来るので、語幹が出た時点で、目的語(FizzBuzz)や行範囲(10 行目から 20 行目)はすでに partial にそろっています。前回の記事で「対象は早く取れる、意図は最後」と書いたのを、そのまま利用した形です。
ほかに、次のような日本語ならではの処理を入れています。
- 言い直し: 「作って、あ、やっぱり説明して」は、「やっぱ / いや / じゃなく」などがあれば 最後の意図 を採る。
- 打ち消し: 「作るんじゃなくて」「説明はいらない」の直後の打ち消しを見て、その意図を除く。
- 優先順位: 「抽象クラスを作って」は generate ではなく refactor、「ファイルを作って」は create。大きい要求を優先する。
- バグ報告と命令の区別: 「実行して」は run だが、「実行すると固まる」は debug。
STT の聞き違いを吸収する
実際に流してみると、Soniox は「十行目」をときどき 「従業目」(じゅうぎょうめ)と書き起こしました。前回の記事で Speechmatics が「10 両目」と返していたのと同じ種類の問題です。行範囲を読む前に、正規化で直しています。
// STT homophones of 行目 and of じゅう before it: 従業目 (じゅうぎょうめ) is 十行目
.replace(/([\d〇零一二三四五六七八九十百千従重住充])業目/g, "$1行目")
.replace(/[従重住充](?=行目)/g, "十")
マイクで試したときには「21 行目 か 25 行目」(「から」の聞き違い)も出たので、両側に「行目」があるときに限り範囲として読むようにしました。「10 秒目から 20 行目」のように片方だけ聞き違えたケースも拾います。
キーワードが無い言い回しは分類器に回す
「FizzBuzz がほしい」「テストが通らないんだけど」のように、意図語を含まない言い回しは正規表現では読めません。ここだけ、自然文を選択肢に分類する API(typesafe.ai の jev)に回しています。正規表現で読めれば待ち時間ゼロ、読めなければ 1 回 0.2 秒ほどかけて分類器に聞く、というハイブリッドです。
export function hybridReader(remote: IntentReader): IntentReader {
return (text) => parseIntent(text) ?? remote(text);
}
分類器は任意です(TYPESAFE_API_KEY が無ければ正規表現だけで動きます)。
同じ partial / final を 3 方式に同時に流して比べた結果です(Soniox に合成音声 30 本 × 2 周。出典: リポジトリの docs/JEV.md、計測日 2026-10-02)。
| 方式 | 意図語あり 30 発話 | 意図語なし 30 発話 | 発話終了 → 起動(中央値、意図語あり / なし) |
|---|---|---|---|
| 正規表現のみ | 30/30 | 0/30 | 181 ms / − |
| 分類器のみ | 30/30 | 30/30 | 272 ms / 90 ms |
| ハイブリッド(既定) | 30/30 | 30/30 | 130 ms / 86 ms |
意図語なしの言い回しは、partial の段階で文脈から読めることが多く、むしろ起動が早くなりました。
実測
発話終了から起動・確定まで
Soniox(max_endpoint_delay_ms=1000、語彙あり)に合成音声 15 本を実時間で流した結果です(出典: docs/LATENCY.md、計測日 2026-10-02)。
| 指標 | 中央値 | 最大 |
|---|---|---|
| 発話終了 → 意図に応じた処理の起動 | 180 ms | 657 ms |
| 発話終了 → final | 776 ms | 1958 ms |
意図の正解は 15/15、partial で起動したものがそのまま採用されたのも 15/15 でした。15 本中 5 本は、発話終了 前 に起動しています(「エラーの原因を調べて」は −538 ms)。前回「言い終わる前には読めない」と書きましたが、それは「作って」「説明して」のように意図語が文末に来る場合の話で、「エラーの原因を調べて」「デバッグをお願いします」のように意図語が先に来る言い方なら、言い終わる前に動き出せます。
前回の記事では Soniox(上限 1000 ms)の確定までの中央値が 1303 ms でした。今回の 776 ms と違うのは、音声が違うためです(前回は人の声のマイク録音、今回は雑音の無い合成音声)。前回の結果にもあったとおり、「〜して」で終わる文は確定が遅れやすく、今回も「書いて、。」「解説して、—」のように文末が「、」になったテイクは 1.8〜2.0 秒かかっています。
OpenAI の音声認識との比較
競合ツールのうち Codex のディクテーションは OpenAI の音声認識を使っていると思われるので、OpenAI の API とも比べました。Codex のディクテーションそのものは外から操作できないため、「Codex 相当」ではなく「OpenAI の音声認識 API」との比較 です。押して話して離す使い方を想定し、発話終了の 300 ms 後にキーを離した扱いで音声を止めています(出典: docs/STT_COMPARE.md、計測日 2026-10-02、30 本 × 2 周)。
| サービス | 意図の正解 | 行範囲 | 発話終了 → 起動(中央値 / 最大) | 発話終了 → final(中央値 / 最大) |
|---|---|---|---|---|
Soniox stt-rt-v5(ストリーミング) |
60/60 | 20/20 | 122 / 756 ms | 517 / 594 ms |
OpenAI gpt-live-transcribe(ストリーミング) |
59/60 | 19/20 | 546 / 5806 ms | 915 / 6496 ms |
OpenAI gpt-transcribe(録音後に一括) |
60/60 | 20/20 | 1080 / 2410 ms | 1005 / 2531 ms |
- OpenAI のストリーミングは中央値では悪くないものの、ときどき final が 5〜6 秒遅れた。
- 録音後に一括で送る方式は partial が無いので、先読みのしようがない。
- 一括方式は語彙を渡しても FizzBuzz を「フィズバズ」と返すことがあった(20 本中 6 本)。
この結果から、Soniox のままにしました。
VS Code 標準の音声入力 + Copilot との比較
最も身近な比較対象は、VS Code 本体の音声入力で Copilot Chat に話しかけるやり方です。同じ録音を仮想オーディオデバイス(VB-CABLE)経由で流し、話し終わりから最初のコードがエディタに入るまでを録画して測りました。LLM はどちらも Copilot の Auto モデルです。
| VS Code の音声入力 + Copilot(エージェントモード) | Voice Coder | |
|---|---|---|
| 日本語「FizzBuzz を作って」 | 14.8 秒 | 3.5 秒 |
| 英語 "Create FizzBuzz" | 23.9 秒 | 3.2 秒 |
各 1 回ずつの計測です。また、この差は先読みだけによるものではありません。先読みで稼げるのは 1 秒未満です。
差の大部分は、エージェントを通すかどうかから来ています。
- 操作が 1 回多い。 VS Code の音声入力は文字をチャット欄に入れるところまでなので、送信の操作が 1 回要る。Voice Coder は話し終われば、そのまま動き出す。
- 意図をつかむのに時間がかかる。 エージェントは依頼を読んで、何をするかを考え、ファイルを読み、ツールを呼んでから編集する。Voice Coder は意図を先に決めてしまい、意図ごとに決まったプロンプトを 1 回送るだけ。
逆にいえば、複数のファイルにまたがる調査や大きな機能の実装のように「考えてもらう」ことに価値がある作業では、エージェントのほうが向いています。Voice Coder が効くのは、関数を 1 つ作る、この部分を説明してもらう、といった単純で小さな作業 です。
実際にマイクで使って直したこと
合成音声のテストが全部通ってから実機で試すと、いくつも引っかかりました。
| 起きたこと | 原因と対処 |
|---|---|
| 結果が出ているのに、ステータスバーの「待機中」のくるくるが止まらない | final は無音の時点で届くので、キーをもう一度押す前に処理が終わっていた。停止時に結果の表示を上書きしないようにした。あわせて、指示が確定したら自動で聞き取りを止める 動作を既定にした(押す → 話す → 自動で止まる) |
| 出力パネルにフォーカスがあると、出力パネルに対して実行しようとする |
activeTextEditor ではなく、最後にフォーカスのあったコードのエディタを対象にした |
| ファイルを開いていないと何も起きない | generate は新しい無題ファイルに書く。explain / debug は「ファイルを開いてください」と出す |
| 「。」だけの final が届き、「指示を読めませんでした」と出る | Soniox が句点だけで 1 つの発話を閉じることがある。文字も数字も無い final は無視する |
Ctrl+Alt+V が効かない |
VS Code 1.131 以降、本体の「エディタで音声入力を開始」に既定で割り当てられていた。Ctrl+Shift+Space に変えた |
最後のキー割り当ては、競合調査をしていて気づきました。VS Code 本体の音声入力は 2026 年 7〜8 月のアップデートで、途中経過の表示や日本語対応モデル、ハンズフリーまで強化されています。
使ってみるには
- Marketplace から Voice Coder をインストールする。
-
Soniox の API キーを取得し、環境変数
SONIOX_API_KEYに設定してから VS Code を起動し直す(.envのパスを設定voiceCoder.envFileで指定してもよい)。 - GitHub Copilot に入っていれば、LLM はそれを使う。初回に「この拡張が Copilot のモデルを使ってよいか」を VS Code が確認する。Copilot が無い場合は
OPENAI_API_KEYを設定する。 -
Ctrl+Shift+Space(macOS はCmd+Shift+Space)を押して話す。
音声は Soniox のクラウドに送られ、コードは選んだ LLM(Copilot / OpenAI / Anthropic)に送られます。社外に出せないコードを扱う環境では、所属組織のルールを確認してから使ってください。Soniox は従量課金です(料金は Soniox の公式サイトで確認してください。この記事では比較していません)。
限界と課題
- 計測は合成音声が中心。 話者 1 人ぶんの合成音声で、雑音も無い。人の声では前回の記事のとおり確定が遅くなる方向に振れる。
- 音声認識がクラウドで有料。 VS Code 本体の音声入力は端末内で動いて無料。プライバシーと費用の面では不利。
- VS Code 本体に取り込まれるおそれ。 本体の音声入力が「partial でエージェントを先に動かす」機能を持てば、主な差別化点がなくなる。
- 大きな作業には向かない。 意図ごとに 1 回プロンプトを送るだけなので、複数ステップの調査や、ツールを使う作業は既存のエージェント(Copilot / Claude Code など)にかなわない。これはコンセプト上の割り切りでもある。読み取った意図を付けて既存エージェントに渡すモードは、今後の検討事項。
- 外れたときのトークン消費は未計測。
まとめ
- 前回の計測から「言い終わる前は無理でも、確定を待つ必要はない」という方針を立て、partial で起動して final で答え合わせする VS Code 拡張を作った。発話終了から起動まで中央値 0.18 秒、確定まで待つより約 0.6 秒早い。
- 先読みを安全にする鍵は、確定するまでエディタに書かない こと。外れた実行は止めて捨てるだけで済み、取り消し処理が要らない。
- 日本語の意図は 動詞の語幹 で partial から読める。キーワードの無い言い回しだけ分類器に回すハイブリッドで、合成音声 60 発話を全問正解した。
- 体感の速さには、STT の先読みよりも「エージェントを通さない」ことのほうが効いた。送信の操作が要らず、意図をつかむ時間もかからない。
- 向いているのは、関数を 1 つ作る、ここを説明してもらう、といった小さな作業。AI にまとめて作らせるのではなく、人間が 1 つずつ確かめながら作っていく ための道具として作った。
- 合成音声のテストが全部通っても、実機ではフォーカスや終了タイミングの問題がいくつも出た。音声 UI は実際に話して確かめるのが欠かせない。

