はじめに
「FizzBuzz を作って」と話しかけるとコードが出てくる、音声コーディングツール(VS Code 拡張)を作ろうとしています。
応答を速くするために考えたのが 先読み実行 です。ユーザーが言い終わるのを待たず、途中の文字起こし(partial)で意図が読めた時点で LLM を走らせてしまう、という設計です。
これが成り立つかどうかは、STT が partial・確定・ターン終了をどのタイミングで返すかで決まります。そこで実測しました。
- 前半: Speechmatics Agent STT(
linden-1)にマイクで話しかけて、イベントのタイミングを測る - 後半: 同じ録音を Speechmatics / Soniox / Modulate の 3 社に流して比較する
- おまけ: Soniox の終了判定パラメータを変えて試す
先に結論
- 日本語では、言い終わる前に partial から意図を読み取れない。 「作って」「説明して」は文末に来るので、意図が読める partial が届くのは、どのサービスでも発話終了の 0.3〜0.5 秒 後 だった。言い終わる前に読めたケースは 0 件。
- 速さなら Speechmatics、精度と言い直しの扱いなら Soniox。 Modulate は今回の用途では厳しかった。
- 先読み実行が効くのは「確定が遅い Soniox で partial を使う」組み合わせ(約 0.9 秒早く動き出せる)。Speechmatics では 0.4 秒ほどしか稼げない。
3 社の特徴をひとことで言うと、こうなります。
| サービス | ひとことで | 確定までの中央値 | 向いている使い方 |
|---|---|---|---|
| Speechmatics | 速いが、こまめに切る。 0.4 秒前後の間でターンを閉じるので、言い直しは必ず別ターンになる | 0.8 秒 | 確定を待って即起動。分割されたターンの結合はアプリ側で書く |
| Soniox | 正確で、文脈を見て待つ。 語彙を渡せばコード用語は全問正解。言い直しも短い間なら 1 つの発話で返す。そのぶん確定が遅い | 1.3 秒(最大 2.9 秒) | partial で先に動き出し、確定で突き合わせる |
| Modulate | partial は細かいが、確定が遅く不安定。 コード用語に弱い。感情や合成音声の判定など、文字起こし以外の信号も返せるのが特色 | 2.1 秒 | 今回の用途には向かない |
話者 1 人・各条件 3 回程度の小さな計測です。傾向の把握が目的で、1 回ぶんの差で優劣は断定できません。料金も比較していません。
用語
| 用語 | 意味 |
|---|---|
| partial | 話している途中に返る 未確定 の文字起こし。音声が進むと更新され、書き換わることがある |
| final | 確定 した文字起こし。以後変わらない |
| ターン終了 | 「この発話は終わった」という合図。Speechmatics は EndOfTurn、Soniox は <end> トークン。Modulate には無い |
| 先読み実行 | final を待たず、partial で意図が読めた時点で LLM を起動すること |
環境
| 項目 | 値 |
|---|---|
| 計測日 | 2026-10-01 |
| OS / Python | Windows 11 / Python 3.11.9 |
| Speechmatics | Agent STT linden-1、speechmatics-agent-stt 0.1.0、EU エンドポイント |
| Soniox |
stt-rt-v5、enable_endpoint_detection: true
|
| Modulate |
velma-2-stt-streaming、partial_results=true
|
| 音声 | 16 kHz / モノラル / 16-bit PCM、32 ms ごとに送信 |
シナリオ
| # | 発話 | 見たいこと |
|---|---|---|
| A | FizzBuzz を作って | コード用語、短い命令の遅延 |
| B | 10 行目から 20 行目の処理を説明して | 数字、「から」の構造 |
| C | この関数を async にリファクタして | コード用語(async、リファクタ) |
| D | えーっと…(沈黙)…FizzBuzz を作って | 沈黙でターンが切れるか |
| E | FizzBuzz を作って、あ、やっぱり説明して | 言い直しの扱い |
| F | 真壁さんが書いた関数を説明して | 人名(後半の比較のみ) |
F の人名は仮名です。実際の計測では別の名字を使っており、記事中の表記だけ置き換えています。
前半: Speechmatics Agent STT をマイクで測る
計測スクリプト
公式 SDK はイベントごとにハンドラを登録する形です。
from speechmatics.agent_stt import (
AgentSttAsyncClient, Microphone, Model, ServerMessageType, TranscriptionConfig,
)
client = AgentSttAsyncClient( # SPEECHMATICS_API_KEY を環境変数から読む
transcription_config=TranscriptionConfig(
model=Model.LINDEN_1,
language="ja",
enable_partials=True,
additional_vocab=[
{"content": "FizzBuzz", "sounds_like": ["フィズバズ"]},
{"content": "async", "sounds_like": ["エイシンク", "アシンク"]},
],
)
)
@client.on(ServerMessageType.ADD_PARTIAL_SEGMENT)
def on_partial(message):
print("partial", message["segment"]["transcript"])
@client.on(ServerMessageType.ADD_SEGMENT)
def on_final(message):
print("final ", message["segment"]["transcript"])
@client.on(ServerMessageType.END_OF_TURN)
def on_end_of_turn(message):
print("turn end at", message["metadata"]["end_time"])
async def main():
async with client: # 接続して、音声を受け付ける状態になるまで待つ
mic = Microphone(sample_rate=16000, chunk_size=1024)
mic.start()
while True:
await client.send_audio(await mic.read(1024))
サーバーは SpeechStarted / SpeechEnded で「音声の何秒目で発話が始まり、終わったか」を返します。この音声上の時刻を、送った音声量をもとに実際の時刻に対応づけて、「発話終了から final まで何 ms か」を出しました。
遅延(A〜E、47 ターン)
| 指標 | 中央値 | 最小 | 最大 |
|---|---|---|---|
| 発話終了 → final | 624 ms | 499 | 947 |
| 発話終了 → ターン終了 | 625 ms | 499 | 947 |
| 発話開始 → 最初の partial | 940 ms | 501 | 1858 |
| partial の遅れ(話した時点 → 届いた時点) | 591 ms | 368 | 1074 |
| partial の更新間隔 | 361 ms | 214 | 878 |
| 発話終了 → 意図が読める partial | 303 ms | 27 | 740 |
| 意図が読める partial → final | 351 ms | 0 | 742 |
final とターン終了はほぼ同時(差 0〜2 ms)に届きます。発話終了から 0.6 秒で確定するので、これ自体がかなり速いです。
意図は言い終わる前には読めない
「意図が読める」を、partial に 作っ / リファクタ(generate)か 説明(explain)が初めて現れた時点と定義しました。
結果は、39 ターンすべてで発話終了より後。日本語は動詞が文末に来るうえ、partial は話した時点から約 0.6 秒遅れて届くためです。partial を使って稼げるのは中央値 0.35 秒で、39 ターン中 4 ターンは partial に一度も意図が出ないまま final になりました。
一方で 対象 は早く取れます。"FizzBuzz" は発話開始から約 0.8 秒、「10行目から20行目」は final の 1.0〜1.6 秒前に partial に出ました。ファイルや該当行の読み込みは発話中に始められます。
ターンが早く切れすぎる
設計上の問題はこちらでした。
- D(フィラー + 沈黙): 6 回中 6 回、沈黙中にターン終了が出た。「えーっと」だけで 1 ターンになり、うち 4 回は「と。」と認識された。
- E(言い直し): 6 回中 6 回、「FizzBuzzを作って。」で確定してから「やっぱり説明して。」が別ターンで届いた。partial が置き換わるわけではない。
- 言葉が出たあとは、0.36 秒の間 でもターンが切れた。意図的でない言いよどみ(「この関数を」のあとの 0.42 秒)でも切れている。
VAD のしきい値は変更できません(公式ドキュメントに "Voice activity detection is not currently configurable." とあります)。自前で区切るなら TurnDetectionMode.EXTERNAL にして client.finalize() を呼ぶ形になります。
カスタム語彙の効果
| 語 | 語彙なし | 語彙あり |
|---|---|---|
| async | 0/3(「シンク」) | 3/3 |
| FizzBuzz | 11/12 | 9/9 |
async には明確に効きました。FizzBuzz は語彙なしでもほぼ出ます。
後半: 3 社に同じ録音を流して比較する
比較の方法
マイクに毎回話すと、発話ごとの差が混ざります。そこで 一度録音した WAV を、実時間のペースで 3 社へ同時に送る 形にしました。
async def paced_chunks(pcm: bytes, recorder):
"""各チャンクを、その最後のサンプルが録音されるはずの時刻に送り出す"""
recorder.start()
for offset in range(0, len(pcm), CHUNK_SIZE):
chunk = pcm[offset : offset + CHUNK_SIZE]
due = recorder.started + (offset + len(chunk)) / BYTES_PER_SECOND
await asyncio.sleep(max(0.0, due - time.perf_counter()))
yield chunk
こうすると「送信開始からの経過時間」がそのまま「音声上の位置」になり、どのサービスのイベントも同じ時間軸で比べられます。発話の開始・終了は WAV の音量(20 ms フレームの RMS)から求め、3 社とも同じ基準で測りました。
Soniox は最初に設定を JSON で送り、あとは音声をバイナリで流します。発話の区切りは <end> という特別なトークンで届きます。
config = {
"api_key": api_key,
"model": "stt-rt-v5",
"audio_format": "pcm_s16le",
"sample_rate": 16000,
"num_channels": 1,
"language_hints": ["ja"],
"enable_endpoint_detection": True,
"context": {"terms": ["FizzBuzz", "async", "await", "リファクタ", "真壁"]},
}
# 応答 1 件ぶんの処理
for token in response["tokens"]:
if token["text"] == "<end>" and token["is_final"]:
ended = True # ここが発話の区切り
elif token["is_final"]:
final_text += token["text"] # 確定トークンは 1 回だけ届く
else:
non_final += token["text"] # 未確定トークンは毎回入れ替わる
Modulate は設定をクエリ文字列で渡します。語彙は最初のテキストフレームで送ります。
query = urlencode({
"api_key": api_key, # このエンドポイントはクエリでしか受け取らない
"audio_format": "s16le",
"sample_rate": 16000,
"num_channels": 1,
"speaker_diarization": "false",
"partial_results": "true",
"language": "ja",
})
url = f"wss://platform.modulate.ai/api/velma-2-stt-streaming?{query}"
# 応答は {"type": "partial_utterance" | "utterance" | "done" | "error", ...}
Modulate は API キーが URL に入ります。例外メッセージやログに URL を出さないよう注意してください。
録音は 6 シナリオ × 3 テイクの 18 本です(ピーク −6 dBFS 前後、音割れなし)。語彙あり・なしの両方を流しました。録音の末尾には 5 秒の無音を足し、各社が自力で発話を閉じるのを待っています。
遅延(語彙あり、18 テイク、ms)
| サービス | 発話終了 → 確定(中央値) | 範囲 | 発話開始 → 最初の partial | 発話終了 → 意図が読める |
|---|---|---|---|---|
| Speechmatics | 781 | 694〜1283 | 776 | 375 |
| Soniox | 1290 | 738〜2912 | 897 | 433 |
| Modulate | 2110 | 1264〜3703 | 954 | 518 |
前半の 624 ms と数字が違うのは、基準が違うためです。前半は Speechmatics 自身の VAD が判定した発話終了、後半は WAV の音量から求めた発話終了を使っています。後者のほうが約 0.16 秒早い位置になるので、前半と後半の数字は直接比べられません。
- partial の速さは 3 社で大きく変わらない。 意図が読めるのは、どこも発話終了の 0.4〜0.5 秒後。
- 差が出るのは確定までの時間。partial から確定までの差(先読み実行で稼げる時間の目安)は、Speechmatics 約 0.4 秒、Soniox 約 0.9 秒、Modulate 約 1.6 秒。
- Soniox の確定は文末の判定で変わる。文が完結したと判断すると 0.7〜2.4 秒、続きがあると判断すると約 2.9 秒(18 本中 4 本)。後者では文末が「、」になる。「〜して」を文の途中とみなしている ように見える。
partial の出方
B「10 行目から 20 行目の処理を説明して」を Soniox に流したときの流れです。時刻は発話終了を 0 とした秒。
| 時刻 | 種類 | 内容 |
|---|---|---|
| −1.78 | partial | 10 |
| −1.52 | partial | 10行目 |
| −1.44 | partial | 10行目から |
| −0.70 | partial | 10行目から20 |
| −0.50 | partial | 10行目から20行目 |
| +0.14 | partial | 10行目から20行目の処理を |
| +0.25 | partial | 10行目から20行目の処理を説明 ← 意図が読める |
| +0.74 | final + ターン終了 | 10行目から20行目の処理を説明して。 |
行範囲は言い終わる 0.5 秒前に出そろい、意図は言い終わった 0.25 秒後に読めます。この例で先読み実行が稼げるのは、+0.25 → +0.74 の約 0.5 秒です。
partial は書き換わります。同じ文の別のテイクでは、Soniox の partial が「重量目から20」まで進んだあと「10行目から20行」に直りました。
更新の細かさはサービスで違います。
| サービス | 更新間隔(中央値) | 1 テイクあたり | 単位 |
|---|---|---|---|
| Speechmatics | 約 380 ms | 5〜6 回 | 語のまとまり |
| Soniox | 約 130 ms | 13〜14 回 | トークン |
| Modulate | 約 150 ms | 14〜15 回 | ほぼ 1 文字ずつ |
認識(その語を含んでいたテイク数 / 含むべきテイク数)
| サービス | 語彙 | FizzBuzz | async | リファクタ | 10行目 | 20行目 | 人名(F) |
|---|---|---|---|---|---|---|---|
| Speechmatics | あり | 9/9 | 3/3 | 3/3 | 1/3 | 1/3 | 2/3 |
| Speechmatics | なし | 9/9 | 0/3 | 3/3 | 1/3 | 1/3 | 2/3 |
| Soniox | あり | 9/9 | 3/3 | 3/3 | 3/3 | 3/3 | 2/3 |
| Soniox | なし | 0/9 | 1/3 | 2/3 | 3/3 | 3/3 | 2/3 |
| Modulate | あり | 1/9 | 3/3 | 2/3 | 2/3 | 2/3 | 3/3 |
| Modulate | なし | 0/9 | 2/3 | 2/3 | 2/3 | 2/3 | 3/3 |
表記は厳密に判定しています(大文字小文字・全半角・句読点・空白は無視)。
- Soniox は語彙が必須。 語彙なしだと FizzBuzz は「フィズバズ」などカタカナで返る(音は合っている)。語彙を渡すと 9/9 で、コード用語と行番号は全問正解だった。
- Speechmatics は語彙なしでも FizzBuzz を綴る。async は語彙なしだと 3 回とも「シンク」で、語彙を渡すと 3/3。行番号は 3 本中 2 本が「10両目から20両目」になった。「FizzBuzz。を作って」のように、文の途中に「。」が入るケースが 36 回中 5 回あった(Soniox と Modulate は 0 回)。
- Modulate は FizzBuzz が「ビズバズ」「クイズ画像」「フェーズバズ」などになり、語彙を渡してもほぼ直らなかった。行番号が「十両目」になることもある。
- 人名 は Speechmatics と Soniox が語彙の有無によらず 2/3、Modulate が 3/3。誤認識は、音の近い別の語や、別の名字に置き換わる形だった。語彙に登録しても直らなかった。
Modulate で語彙がそのまま出力された
Modulate に語彙(custom_terms)を渡したテイクで、次のことが起きました。
partial: この関数を async にリファクタして
final: FizzBuzz、 async await。この関数をasyncにリファクタして。
partial は正しいのに、確定結果の文頭に、渡した語彙の並びが付いています。語彙ありの 18 本中 1 本で発生しました。partial と final で別の処理が走っているようです。語彙機能を使うなら、final をそのまま信用しない作りが要ります。
ターンの切れ方(語彙あり)
| サービス | E(言い直し)が分割 | D(フィラー + 沈黙)が分割 | 発話前の雑音が 1 ターンに |
|---|---|---|---|
| Speechmatics | 3/3 | 3/3 | 3/18 |
| Soniox | 1/3 | 2/3 | 0/18 |
| Modulate | 1/3 | 2/3 | 0/18 |
言い直しまでの間は、3 本で 0.80 秒、0.82 秒、1.58 秒でした。Speechmatics はすべて分割します。Soniox と Modulate は 0.8 秒の 2 本を「FizzBuzzを作って、あ、やっぱり説明して。」と 1 つで返し、1.58 秒空いた 1 本だけ分割しました。D も同じ傾向で、沈黙が 0.92 秒の 1 本は分割されず、1.5 秒前後の 2 本は分割されています。
音声コーディングでは、この差が大きいと感じました。短い言い直しのたびに LLM を取り消す処理が、要らなくなるからです。
Speechmatics は発話前の雑音(録音開始のキー音など)を「と。」「各。」という 1 ターンにすることもありました。
おまけ: Soniox の終了判定を調整する
Soniox には終了判定のパラメータが 3 つあります。
-
max_endpoint_delay_ms: 発話終了後に待つ上限(500〜3000、既定 2000) -
endpoint_latency_adjustment_level: 遅延短縮の強さ(0〜3、既定 0) -
endpoint_sensitivity: 終了と判定しやすさ(−1.0〜1.0、既定 0.0)
同じ 18 本を 5 通りの設定で流しました(語彙あり)。
| 設定 | 確定までの中央値 | 最大 | E が分割 |
|---|---|---|---|
| 既定(上限 2000 ms) | 1290 ms | 2912 | 1/3 |
| 上限 1000 ms | 1303 ms | 2066 | 1/3 |
| レベル 3 | 1050 ms | 2933 | 2/3 |
| レベル 2・感度 0.3・上限 1500 ms(公式が勧める出発点) | 920 ms | 2497 | 3/3 |
| 上限 500 ms | 1188 ms | 1531 | 3/3 |
| レベル 3・感度 0.5・上限 500 ms | 676 ms | 1594 | 3/3 |
| 参考: Speechmatics | 781 ms | 1283 | 3/3 |
- 最悪値を決めるのは上限。 実際の確定は最大で「上限 + 約 1 秒」になる。「〜リファクタして」で終わる C は、レベルや感度を上げても上限いっぱいまで待った。
- レベルと感度を上げると中央値は縮むが、言い直しの分割が増える。 既定 1/3 → レベル 3 で 2/3 → 推奨設定で 3/3。
- 最も攻めた設定なら Speechmatics 並みの速さになる(中央値 0.68 秒)。ただし言い直しは 3/3 で分割され、ターンの切れ方も Speechmatics と同じになる。入力レベルが小さい別の録音では同じ設定で 1.1 秒だったので、この数字は録音によって振れる。
- 上限 1000 ms だけが、分割を増やさなかった。 最悪値が 2.9 秒 → 2.1 秒に縮み、中央値は変わらない。
- どの設定でも、語の認識結果はほぼ変わらなかった。
「速くて、言い直しを分割しない」設定は見つかりませんでした。速さと分割しにくさはトレードオフで、Soniox はそのどこに置くかを選べる、という位置づけです。
3 社の特徴まとめ
| 項目 | Speechmatics | Soniox | Modulate |
|---|---|---|---|
| 位置づけ | 音声エージェント向けの Agent STT。発話開始・終了とターンのイベントを返す | トークン単位のストリーミング STT。文脈を見て発話の終わりを判定する | 音声解析の一機能としての STT。感情・アクセント・合成音声の判定を同じ接続で返せる |
| 確定の速さ | ◎ 0.8 秒で安定 | △ 1.3 秒、最大 2.9 秒(設定で調整可) | × 2.1 秒、最大 3.7 秒 |
| partial | 約 0.4 秒ごと、語のまとまり | 約 0.13 秒ごと、トークン | 約 0.15 秒ごと、ほぼ 1 文字 |
| コード用語 | ○ FizzBuzz は語彙なしで出る。async は語彙が必要 | ◎ 語彙ありで全問正解。語彙なしはカタカナになる | × FizzBuzz がほぼ出ない |
| 行番号 | △「10両目」になりやすい | ◎ | ○ |
| 言い直し | 必ず別ターンに分割 | 間が 0.8 秒程度なら 1 つで返す | 間が 0.8 秒程度なら 1 つで返す |
| ターン終了の通知 | あり(EndOfTurn) |
あり(<end> トークン) |
なし(発話単位の確定のみ) |
| 終了判定の調整 | 不可。自前で区切るモードはある | 3 つのパラメータで可 | 今回のエンドポイントには見当たらない |
| 語彙機能 | 読みを添えて登録できる | 語のリストを渡す | 語のリストを渡す。確定結果に語彙が混ざる副作用があった |
| 注意点 | 雑音やフィラーが 1 ターンになる。文中に「。」が入る | 「〜して」で終わる文は確定が遅い | partial と final が食い違う |
記号は今回の計測での相対評価です。Modulate の感情・合成音声の判定は、今回は使っていません。
設計への示唆
最初の問い「partial は先読み実行のトリガーに使えるか」への答えは、サービスによる でした。
Soniox を使う場合
- 上限を 1000 ms にする。
- partial で意図が読めた時点(発話終了の約 0.4 秒後)に LLM を先に起動し、
<end>で確定と突き合わせる。確定を待つより 0.9 秒ほど早い。 - 言い直しは、間が短ければ 1 つの発話として届くので、取り消し処理は軽くて済む。
- 語彙の登録は必須。
Speechmatics を使う場合
- 確定が速いので、partial で先走る効果は小さい(約 0.4 秒)。ターン終了で即起動すればよい。
- 代わりに、ターン終了を「仮確定」として扱う。直後に次の発話が始まったら LLM を取り消して前のターンと結合し、フィラーや雑音だけのターンは捨てる。
- つまり先読み実行の形が変わる。「partial で先走る」ではなく「ターン終了で起動し、続きが来たら取り消す」。
どちらでも共通
- 対象(ファイル名、行番号)は発話中に partial で取れるので、コンテキストの準備は先に始められる。
- partial は書き換わる。partial で始めた処理は、final と突き合わせて捨てられるようにしておく。
- 人名は語彙に入れても安定しない。名前で対象を指定する操作は、候補から選ばせるなど別の手段を用意したほうがよい。
この計測の限界
- 話者 1 人、各シナリオ 3 テイク。1 テイクぶんの差(2/3 と 3/3 など)では優劣を断定できない。
- 前半のマイク計測は、入力レベルが小さい状態(ピーク −29 dBFS 前後)で行った。後半は適正なレベルで録音している。
- 発話の開始・終了は音量からの推定。発話前の雑音を開始と誤判定したテイクがあり、「最初の partial」の数値は参考程度。
- 接続先は Speechmatics が EU、他は各社の既定。日本からのネットワーク往復を含む。
- Soniox の設定違いは各 1 回の計測。Speechmatics の
EXTERNALモードと英語は未計測。 - 料金は比較していない。
まとめ
- 日本語は動詞が文末に来るので、partial で「言い終わる前に意図を知る」ことはできなかった。
- 速さなら Speechmatics、精度と言い直しの扱いなら Soniox。Soniox は設定で Speechmatics 並みに速くできるが、そのときはターンの切れ方も同じになる。
- 先読み実行の価値は、STT の確定がどれだけ遅いかで決まる。確定の速い Speechmatics では小さく、遅い Soniox では大きい。
- ターンの切れ方(言い直し・フィラーの扱い)は、遅延の数字と同じくらい設計に効く。数字だけでなく、実際の発話パターンで確かめる価値がある。
- 比較するなら、録音を実時間で流す方式が楽。話すのは 1 回で済み、設定を変えて何度でも測り直せる。