0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

日本語の音声コーディング向けにリアルタイムSTT 3社(Speechmatics / Soniox / Modulate)のpartialと確定タイミングを実測した

0
Last updated at Posted at 2026-10-02

はじめに

「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 回で済み、設定を変えて何度でも測り直せる。
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?