低遅延なリアルタイム文字起こしモデル「Soniox v3」を試す ②リアルタイムAPI
以下の続き。長くなったので分けた。
今回はリアルタイムAPI。
リアルタイムAPIでの文字起こし
リアルタイムAPIを使うと、ストリーミングでのリアルタイムな文字起こしが可能となる。リアルタイムAPIの特徴は以下のように書かれている。
- 低遅延
- 高精度
- 60以上の言語に対応
- リアルタイム文字起こしに最適なユースケース
- ライブ字幕
- 音声アシスタント
- ストリーミング分析
- 対話型AI
リアルタイムAPIはWebSocket APIで接続し、送信される音声データの進行状況にあわせて「トークン」単位で結果が返される。このあたりの挙動は以下に記載がある。
Sonioxに限ったことではないが、多くのリアルタイム文字起こしは、トークンごとに「ステータス」が付与される。このステータスには以下の2つがある。
-
non-final token(未確定トークン。中間認識結果などと呼ばれる場合もある)
- 暫定的な認識結果のテキスト。音声入力と同時に即表示される。
-
is_final: falseが付与されている。 - ただし、追加の音声が入力されるに従って、内容が変更されたり、表示されなくなったり、別のトークンに置き換えられたり、する可能性がある
-
final token(確定トークン。歳入認識結果などと呼ばれる場合もある)
- 認識結果が確定済みのテキスト。
-
is_final: trueが付与されている。 - 一度確定されると、以後の応答で内容が変更されることはない。
トークンがどのように認識されるかの実例も記載されているので順に見てみる。"How are you doing?"という音声がどのように認識が変わっていくかの例になっている。
まず最初のところ。"How are" までが入力されて認識が行われているが、この時点では、暫定的(is_final: false)な認識であり、間違った認識なども含まれている。
{
"tokens": [
{"text": "How", "is_final": false},
{"text": "'re", "is_final": false}
]
}
続けて音声が入力されていくうちに、暫定認識結果は修正されていく。ただしまだ確定はしていない。
{
"tokens": [
{"text": "How", "is_final": false},
{"text": " ", "is_final": false},
{"text": "are", "is_final": false}
]
}
さらに音声が入力されて "How are you"まで進んだ状態で、確定済み("is_final": true)のトークンが含まれている。これらはもう変更されることはないが、それ以降はまだ未確定となっている。
{
"tokens": [
{"text": "How", "is_final": true},
{"text": " ", "is_final": true},
{"text": "are", "is_final": false},
{"text": " ", "is_final": false},
{"text": "you", "is_final": false}
]
}
さらに "How are you doing?" まで進んだ状態。1つ前に確定済みになったものはもうトークンには含まれておらず、今度は "are you" までが認識確定となっている。
{
"tokens": [
{"text": "are", "is_final": true},
{"text": " ", "is_final": true},
{"text": "you", "is_final": true},
{"text": " ", "is_final": false},
{"text": "do", "is_final": false},
{"text": "ing", "is_final": false},
{"text": "?", "is_final": false}
]
}
最後に全て確定した状態。これで全ての文字起こしが完了。
{
"tokens": [
{"text": " ", "is_final": true},
{"text": "do", "is_final": true},
{"text": "ing", "is_final": true},
{"text": "?", "is_final": true}
]
}
ただし、実際にやってみると、さすがにこれぐらいの短い文章だとこうはならないと思う。長い文章だとおそらくこういう感じになるのだろうと思う。
自分はこのトークン単位で is_false に true / false が混在するってのが、ちょっと特徴的かなと感じていて、Google Cloud Speech-to-Text とか Amazon Transcribe などではもう少し大きい単位だったような印象がある、もう覚えてないけども。
とりあえずこれを踏まえて、実際にうごかしてみる。リアルタイムのサンプルコードは soniox_realtime.py が用意されている。
これ、非同期のサンプルコードとほぼ同様のインタフェースになっていて、いろいろな機能が使えるのはいいんだけども、リアルタイムAPIの生レスポンスを見るにはちょっと仰々しい。Codexに最低限かつマイクからの入力音声を文字起こしするコードにしてもらった。
import argparse
import json
import os
import queue
import sys
import threading
import time
import sounddevice as sd
from websockets import ConnectionClosedOK
from websockets.sync.client import connect
SONIOX_WEBSOCKET_URL = "wss://stt-rt.soniox.com/transcribe-websocket"
def stream_microphone(
ws,
sample_rate: int,
block_duration_ms: int,
stop_event: threading.Event,
) -> None:
"""マイクからの音声を一定間隔で送信する"""
block_size = max(int(sample_rate * block_duration_ms / 1000), 1)
audio_queue: queue.Queue[bytes] = queue.Queue()
def callback(indata, frames, time_info, status):
if status:
print(f"Audio callback status: {status}", file=sys.stderr)
audio_queue.put(bytes(indata))
try:
with sd.RawInputStream(
samplerate=sample_rate,
blocksize=block_size,
dtype="int16",
channels=1,
callback=callback,
):
while not stop_event.is_set():
try:
data = audio_queue.get(timeout=0.1)
except queue.Empty:
continue
ws.send(data)
finally:
try:
ws.send("")
except Exception:
pass
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--sample_rate", type=int, default=16000)
parser.add_argument("--block_duration_ms", type=int, default=120)
parser.add_argument("--language")
args = parser.parse_args()
api_key = os.environ.get("SONIOX_API_KEY")
if api_key is None:
raise RuntimeError("Missing SONIOX_API_KEY")
config: dict = {
"api_key": api_key,
"model": "stt-rt-v3",
"audio_format": "pcm_s16le",
"sample_rate": args.sample_rate,
"num_channels": 1,
# 言語識別を有効化
"enable_language_identification": True,
# 話者ダイアライぜーションを有効化
"enable_speaker_diarization": True,
# 発話停止を検出するエンドポイント検出を有効化
"enable_endpoint_detection": True,
}
if args.language:
config["language_hints"] = [args.language]
print("Connecting to Soniox...")
with connect(SONIOX_WEBSOCKET_URL) as ws:
ws.send(json.dumps(config))
stop_event = threading.Event()
sender_thread = threading.Thread(
target=stream_microphone,
args=(ws, args.sample_rate, args.block_duration_ms, stop_event),
)
sender_thread.start()
print("Session started. Press Ctrl+C to stop.")
try:
last_payload: str | None = None
while True:
message = ws.recv()
payload = json.loads(message)
# 普通にやると一定間隔でレスポンスが返され、出力量が非常に多くなるため、
# ここでは、トークンを保持しておいて、前回のトークンと比較して、内容が同じ
# なら出力を抑制するようにしている
current_dump = json.dumps(payload.get("tokens", []), sort_keys=True)
if current_dump != last_payload:
print("-" * 20)
print(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()))
print(json.dumps(payload, indent=2, ensure_ascii=False))
last_payload = current_dump
if payload.get("finished"):
print("-" * 20)
print("Session finished.")
break
except ConnectionClosedOK:
pass
except KeyboardInterrupt:
print("\nInterrupted by user.")
except Exception as exc:
print(f"Error: {exc}")
finally:
stop_event.set()
sender_thread.join(timeout=1.0)
if __name__ == "__main__":
main()
では実行してみる。オプションで言語ヒントを渡せるようにしてあるので、まずは英語で。"How are you? I'm fine." と発話してCtrl+Cで止める。
uv run soniox_minimal_realtime_mic.py --language en
出力は以下。ちょっと長いので折りたたんでいる。
結果出力
Connecting to Soniox...
Session started. Press Ctrl+C to stop.
--------------------
2025-10-24 01:30:23
{
"tokens": [],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 0
}
--------------------
2025-10-24 01:30:25
{
"tokens": [
{
"text": "How",
"start_ms": 1860,
"end_ms": 1860,
"confidence": 0.992,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2520
}
--------------------
2025-10-24 01:30:26
{
"tokens": [
{
"text": "How",
"start_ms": 1860,
"end_ms": 1860,
"confidence": 0.992,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": " are",
"start_ms": 2040,
"end_ms": 2100,
"confidence": 0.996,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2760
}
--------------------
2025-10-24 01:30:26
{
"tokens": [
{
"text": "How",
"start_ms": 1860,
"end_ms": 1860,
"confidence": 0.992,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": " are",
"start_ms": 2040,
"end_ms": 2100,
"confidence": 0.996,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": " y",
"start_ms": 2220,
"end_ms": 2280,
"confidence": 0.999,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 3000
}
--------------------
2025-10-24 01:30:26
{
"tokens": [
{
"text": "How",
"start_ms": 1860,
"end_ms": 1860,
"confidence": 0.992,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": " are",
"start_ms": 2040,
"end_ms": 2100,
"confidence": 0.996,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": " y",
"start_ms": 2220,
"end_ms": 2280,
"confidence": 0.999,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": "ou",
"start_ms": 2340,
"end_ms": 2400,
"confidence": 0.901,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 3120
}
--------------------
2025-10-24 01:30:26
{
"tokens": [
{
"text": "How",
"start_ms": 1860,
"end_ms": 1860,
"confidence": 0.992,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": " are",
"start_ms": 2040,
"end_ms": 2100,
"confidence": 0.996,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": " y",
"start_ms": 2160,
"end_ms": 2220,
"confidence": 0.999,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "ou",
"start_ms": 2280,
"end_ms": 2340,
"confidence": 0.918,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "?",
"start_ms": 2400,
"end_ms": 2460,
"confidence": 0.991,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "<end>",
"start_ms": 0,
"end_ms": 0,
"confidence": 0.999,
"is_final": true
}
],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 3240
}
--------------------
2025-10-24 01:30:27
{
"tokens": [],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 4320
}
--------------------
2025-10-24 01:30:27
{
"tokens": [
{
"text": " I",
"start_ms": 3720,
"end_ms": 3780,
"confidence": 0.99,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 4440
}
--------------------
2025-10-24 01:30:27
{
"tokens": [
{
"text": " I",
"start_ms": 3720,
"end_ms": 3780,
"confidence": 0.99,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": "'",
"start_ms": 3780,
"end_ms": 3840,
"confidence": 0.987,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 4560
}
--------------------
2025-10-24 01:30:27
{
"tokens": [
{
"text": " I",
"start_ms": 3720,
"end_ms": 3780,
"confidence": 0.99,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": "'",
"start_ms": 3780,
"end_ms": 3840,
"confidence": 0.987,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": "m",
"start_ms": 3900,
"end_ms": 3960,
"confidence": 1,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 4680
}
--------------------
2025-10-24 01:30:28
{
"tokens": [
{
"text": " I",
"start_ms": 3720,
"end_ms": 3780,
"confidence": 0.99,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": "'",
"start_ms": 3780,
"end_ms": 3840,
"confidence": 0.987,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": "m",
"start_ms": 3900,
"end_ms": 3960,
"confidence": 1,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": " fin",
"start_ms": 4140,
"end_ms": 4200,
"confidence": 0.95,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 4920
}
--------------------
2025-10-24 01:30:28
{
"tokens": [
{
"text": " I",
"start_ms": 3720,
"end_ms": 3780,
"confidence": 0.99,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "'",
"start_ms": 3780,
"end_ms": 3840,
"confidence": 0.987,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "m",
"start_ms": 3900,
"end_ms": 3960,
"confidence": 1,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": " fin",
"start_ms": 4140,
"end_ms": 4200,
"confidence": 0.95,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "e.",
"start_ms": 4380,
"end_ms": 4440,
"confidence": 0.766,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "<end>",
"start_ms": 0,
"end_ms": 0,
"confidence": 0.999,
"is_final": true
}
],
"final_audio_proc_ms": 5160,
"total_audio_proc_ms": 5160
}
--------------------
2025-10-24 01:30:29
{
"tokens": [],
"final_audio_proc_ms": 5160,
"total_audio_proc_ms": 6240
}
^C
Interrupted by user.
上から結果を見ていく。起動直後は何も処理していないので 認識結果を含む tokens は空になっている。それ以外にレスポンスにはfinal_audio_proc_msとtotal_audio_proc_msが付与されているのがわかる。
2025-10-24 01:30:23
{
"tokens": [],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 0
}
これは処理した音声データの長さを示していて、これは毎レスポンスに付与される。
-
final_audio_proc_ms: 確定した音声の長さ -
total_audio_proc_ms: 未確定+確定した音声の長さ
最初なのでどちらも0になっている。続きを見ていけばこれが変化するのがわかる。
続き。
--------------------
2025-10-24 01:30:25
{
"tokens": [
{
"text": "How",
"start_ms": 1860,
"end_ms": 1860,
"confidence": 0.992,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2520
}
--------------------
2025-10-24 01:30:26
{
"tokens": [
{
"text": "How",
"start_ms": 1860,
"end_ms": 1860,
"confidence": 0.992,
"is_final": false,
"speaker": "1",
"language": "en"
},
{
"text": " are",
"start_ms": 2040,
"end_ms": 2100,
"confidence": 0.996,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2760
}
"How are"まで処理されているが、これらはまだ未確定("is_final": "false")。それにあわせてtotal_audio_proc_ms が増価している。で、今回のコードでは言語識別と話者識別を有効化しているので、それぞれのトークンに speakerとlanguageも付与されている。
こういう感じでどんどん繰り返されて、次のところでちょっと変化がある。
2025-10-24 01:30:26
{
"tokens": [
{
"text": "How",
"start_ms": 1860,
"end_ms": 1860,
"confidence": 0.992,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": " are",
"start_ms": 2040,
"end_ms": 2100,
"confidence": 0.996,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": " y",
"start_ms": 2160,
"end_ms": 2220,
"confidence": 0.999,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "ou",
"start_ms": 2280,
"end_ms": 2340,
"confidence": 0.918,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "?",
"start_ms": 2400,
"end_ms": 2460,
"confidence": 0.991,
"is_final": true,
"speaker": "1",
"language": "en"
},
{
"text": "<end>",
"start_ms": 0,
"end_ms": 0,
"confidence": 0.999,
"is_final": true
}
],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 3240
}
"How are you doing?" までのトークンはここで確定("is_final": "true")したので、final_audio_proc_msが増加している。で、いちばん最後に<end>というトークンがあるが、これは発話の終端をサーバが検出したことを示している。これは "enable_endpoint_detection": True を有効にすることで利用できて、発話終端時にすぐに確定するようになるらしい。逆にサーバ側で検出させずにクライアント側で"type": "finalize"メッセージを送っても強制的に確定することができるらしい(クライアント側でVADで終端判定する場合なんかはこちらになると思う。)
全てのトークンが確定した次のレスポンスでは、以下のように認識されたトークンがなく、次の音声の認識がまた同じように繰り返されていた。
2025-10-24 01:30:27
{
"tokens": [],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 4320
}
--------------------
2025-10-24 01:30:27
{
"tokens": [
{
"text": " I",
"start_ms": 3720,
"end_ms": 3780,
"confidence": 0.99,
"is_final": false,
"speaker": "1",
"language": "en"
}
],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 4440
}
これだけ見てると、終端を検出したタイミングですべて確定しているように見えるが、その逆として、終端を検出しなければ全てのトークンが未確定のまま、ということにはならない。例えば、終端が来るまでの途中でも、ドキュメントに書いてあるような「未確定・確定が混在」するような状態になることはいろいろな発話を試してたら実際にあった。
{ "tokens": [ {"text": "How", "is_final": true}, {"text": " ", "is_final": true}, {"text": "are", "is_final": false}, {"text": " ", "is_final": false}, {"text": "you", "is_final": false} ] }
このあたりは、自分が過去にGoogleやAmazonのASRを試した時の記憶からすると、振る舞いが違うように思える。よって、返ってきたレスポンスの処理は、終端の自動・手動にかかわらず、注意が必要かなという気がする。
日本語で試した結果も以下に貼っておく。日本語の場合はトークンがどうやら文字単位になるので非常に出力が長くなる、よって抜粋&折りたたみ。
出力結果
uv run soniox_minimal_realtime_mic.py --language ja
Connecting to Soniox...
Session started. Press Ctrl+C to stop.
--------------------
2025-10-24 02:58:56
{
"tokens": [],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 0
}
--------------------
2025-10-24 02:58:58
{
"tokens": [
{
"text": "ご",
"start_ms": 1500,
"end_ms": 1560,
"confidence": 0.559,
"is_final": false,
"speaker": "1",
"language": "ja"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2280
}
--------------------
2025-10-24 02:58:58
{
"tokens": [
{
"text": "ご",
"start_ms": 1500,
"end_ms": 1560,
"confidence": 0.559,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "機",
"start_ms": 1620,
"end_ms": 1680,
"confidence": 0.78,
"is_final": false,
"speaker": "1",
"language": "ja"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2400
}
--------------------
2025-10-24 02:58:59
{
"tokens": [
{
"text": "ご",
"start_ms": 1500,
"end_ms": 1560,
"confidence": 0.559,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "機",
"start_ms": 1620,
"end_ms": 1680,
"confidence": 0.78,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "嫌",
"start_ms": 1740,
"end_ms": 1800,
"confidence": 0.985,
"is_final": false,
"speaker": "1",
"language": "ja"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2520
}
--------------------
2025-10-24 02:58:59
{
"tokens": [
{
"text": "ご",
"start_ms": 1500,
"end_ms": 1560,
"confidence": 0.559,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "機",
"start_ms": 1620,
"end_ms": 1680,
"confidence": 0.78,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "嫌",
"start_ms": 1740,
"end_ms": 1800,
"confidence": 0.985,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "い",
"start_ms": 1860,
"end_ms": 1920,
"confidence": 0.992,
"is_final": false,
"speaker": "1",
"language": "ja"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2640
}
--------------------
2025-10-24 02:58:59
{
"tokens": [
{
"text": "ご",
"start_ms": 1500,
"end_ms": 1560,
"confidence": 0.559,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "機",
"start_ms": 1620,
"end_ms": 1680,
"confidence": 0.78,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "嫌",
"start_ms": 1740,
"end_ms": 1800,
"confidence": 0.985,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "い",
"start_ms": 1860,
"end_ms": 1920,
"confidence": 0.992,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "か",
"start_ms": 1920,
"end_ms": 1980,
"confidence": 0.998,
"is_final": false,
"speaker": "1",
"language": "ja"
},
{
"text": "が",
"start_ms": 2040,
"end_ms": 2100,
"confidence": 0.999,
"is_final": false,
"speaker": "1",
"language": "ja"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2760
}
--------------------
(snip)
確定トークンを高速に取得する
未確定トークンはあくまでも暫定の認識結果であり、誤判定や途中で変更されることがありうる。信頼できる認識結果を得るには確定トークンを使う。
となれば、未確定→確定になるまでのタイムラグはなるだけ短いほうがいい。(未確定をうまく使うってのも手ではあるけども)
これを短くする方法として、上でも書いたけど、発話の終端の検出をうまく使う。終端の検出はサーバ側・クライアント側の両方で行える。
- サーバ側
-
enable_endpoint_detectionを有効にする - モデルが終端を検出するとトークンの確定化が行われる
-
- クライアント側
-
"type": "finalize"メッセージを送る - 全ての未確定のトークンを強制的に確定化する
-
これらについては別のドキュメントが用意されているようなので、のちほど再度確認してみようと思う。
サポートされる音声のフォーマット
リアルタイムの場合も基本的には自動検出({"audio_fomat": "auto"})が使える。対応しているコンテナは以下。
- aac
- aiff
- amr
- asf
- flac
- mp3
- ogg
- wav
- webm
自動の場合ははストリームヘッダーからコーデックやサンプルレートを自動で読んでくれる。
ヘッダーがないRAWオーディオの場合はフォーマットの指定が必要になる。
-
audio_format: エンコーディング -
sample_rate: サンプリングレート(Hz) -
num_channels: チャネル数(1: モノラル、2: ステレオ)
対応しているエンコーディングは以下。
- PCM(符号付き):
pcm_s8/pcm_s16/pcm_s24/pcm_s32(le/be) - PCM(符号なし):
pcm_u8/pcm_u16/pcm_u24/pcm_u32(le/be) - 浮動小数点PCM:
pcm_f32/pcm_f64(le/be) - コンパンデッド:
mulaw/alaw※主にSIP/VoIPなどで使う音声圧縮方式らしい。
例えば上のサンプルコードだと、RAW PCM(符号付き・16ビット)・16kHz・モノラルにしていた。
{
"audio_format": "pcm_s16le",
"sample_rate": 16000,
"num_channels": 1,
}
Get Startedで用意されているリアルタイムAPIのサンプルコードは soniox_realtime.py。
コメントを日本語化したものはこちら。
長いので折りたたみ
import json
import os
import threading
import time
import argparse
from typing import Optional
from websockets import ConnectionClosedOK
from websockets.sync.client import connect
SONIOX_WEBSOCKET_URL = "wss://stt-rt.soniox.com/transcribe-websocket"
# Soniox STT の設定を取得
def get_config(api_key: str, audio_format: str, translation: str) -> dict:
config = {
# console.soniox.com で API キーを取得し、export SONIOX_API_KEY=<YOUR_API_KEY> を実行してください
"api_key": api_key,
#
# 使用するモデルを選択
# 参考: soniox.com/docs/stt/models
"model": "stt-rt-v3",
#
# あらかじめ言語が分かっている場合は言語ヒントを設定し、精度を大幅に向上させる
# 参考: soniox.com/docs/stt/concepts/language-hints
"language_hints": ["en", "es"],
#
# 言語識別を有効にすると、各トークンに "language" フィールドが含まれる
# 参考: soniox.com/docs/stt/concepts/language-identification
"enable_language_identification": True,
#
# 話者ダイアライゼーションを有効にすると、各トークンに "speaker" フィールドが含まれる
# 参考: soniox.com/docs/stt/concepts/speaker-diarization
"enable_speaker_diarization": True,
#
# コンテキストを設定して、モデルがドメイン理解・重要語句の認識・カスタム語彙や翻訳設定の適用を行えるようにする
# 参考: soniox.com/docs/stt/concepts/context
"context": {
"general": [
{"key": "domain", "value": "Healthcare"},
{"key": "topic", "value": "Diabetes management consultation"},
{"key": "doctor", "value": "Dr. Martha Smith"},
{"key": "patient", "value": "Mr. David Miller"},
{"key": "organization", "value": "St John's Hospital"},
],
"text": "Mr. David Miller visited his healthcare provider last month for a routine follow-up related to diabetes care. The clinician reviewed his recent test results, noted improved glucose levels, and adjusted his medication schedule accordingly. They also discussed meal planning strategies and scheduled the next check-up for early spring.",
"terms": [
"Celebrex",
"Zyrtec",
"Xanax",
"Prilosec",
"Amoxicillin Clavulanate Potassium",
],
"translation_terms": [
{"source": "Mr. Smith", "target": "Sr. Smith"},
{"source": "St John's", "target": "St John's"},
{"source": "stroke", "target": "ictus"},
],
},
#
# 話者が停止したタイミングを検出するためにエンドポイント検出を使用
# 非確定トークンを即時確定させることで待ち時間を最小化
# 参考: soniox.com/docs/stt/rt/endpoint-detection
"enable_endpoint_detection": True,
}
# 音声フォーマット
# 参考: soniox.com/docs/stt/rt/real-time-transcription#audio-formats
if audio_format == "auto":
# "auto" を指定すると Soniox が自動で音声フォーマットを判定する
config["audio_format"] = "auto"
elif audio_format == "pcm_s16le":
# 生の音声フォーマットの例。Soniox は他にも多くの形式をサポートしている
config["audio_format"] = "pcm_s16le"
config["sample_rate"] = 16000
config["num_channels"] = 1
else:
raise ValueError(f"Unsupported audio_format: {audio_format}")
# 翻訳オプション
# 参考: soniox.com/docs/stt/rt/real-time-translation#translation-modes
if translation == "none":
pass
elif translation == "one_way":
# すべての言語をターゲット言語に翻訳する
config["translation"] = {
"type": "one_way",
"target_language": "es",
}
elif translation == "two_way":
# language_a から language_b に翻訳し、language_b から language_a に戻す
config["translation"] = {
"type": "two_way",
"language_a": "en",
"language_b": "es",
}
else:
raise ValueError(f"Unsupported translation: {translation}")
return config
# 音声ファイルを読み込み、バイト列を WebSocket に送信
def stream_audio(audio_path: str, ws) -> None:
with open(audio_path, "rb") as fh:
while True:
data = fh.read(3840)
if len(data) == 0:
break
ws.send(data)
# リアルタイムストリーミングを模倣するため 120ms 待機
time.sleep(0.120)
# 空文字列を送るとサーバーに音声終了を通知
ws.send("")
# トークンを読みやすい書き起こしに変換
def render_tokens(final_tokens: list[dict], non_final_tokens: list[dict]) -> str:
text_parts: list[str] = []
current_speaker: Optional[str] = None
current_language: Optional[str] = None
# トークンを順番に処理
for token in final_tokens + non_final_tokens:
text = token["text"]
speaker = token.get("speaker")
language = token.get("language")
is_translation = token.get("translation_status") == "translation"
# 話者が変わったら話者タグを追加
if speaker is not None and speaker != current_speaker:
if current_speaker is not None:
text_parts.append("\n\n")
current_speaker = speaker
current_language = None # 話者が変わったら言語をリセット
text_parts.append(f"Speaker {current_speaker}:")
# 言語が変わったら言語または翻訳タグを追加
if language is not None and language != current_language:
current_language = language
prefix = "[Translation] " if is_translation else ""
text_parts.append(f"\n{prefix}[{current_language}] ")
text = text.lstrip()
text_parts.append(text)
text_parts.append("\n===============================")
return "".join(text_parts)
def run_session(
api_key: str,
audio_path: str,
audio_format: str,
translation: str,
) -> None:
config = get_config(api_key, audio_format, translation)
print("Connecting to Soniox...")
with connect(SONIOX_WEBSOCKET_URL) as ws:
# まず設定を含むリクエストを送信
ws.send(json.dumps(config))
# バックグラウンドで音声ストリーミングを開始
threading.Thread(
target=stream_audio,
args=(audio_path, ws),
daemon=True,
).start()
print("Session started.")
final_tokens: list[dict] = []
try:
while True:
message = ws.recv()
res = json.loads(message)
# サーバーからのエラー応答
# 参考: https://soniox.com/docs/stt/api-reference/websocket-api#error-response
if res.get("error_code") is not None:
print(f"Error: {res['error_code']} - {res['error_message']}")
break
# 現在のレスポンスからトークンを抽出
non_final_tokens: list[dict] = []
for token in res.get("tokens", []):
if token.get("text"):
if token.get("is_final"):
# 確定トークンは一度だけ返されるため final_tokens に追加
final_tokens.append(token)
else:
# 非確定トークンは音声到着に応じて更新されるため、レスポンスごとにリセット
non_final_tokens.append(token)
# トークンを整形
text = render_tokens(final_tokens, non_final_tokens)
print(text)
# セッションが終了した場合
if res.get("finished"):
print("Session finished.")
except ConnectionClosedOK:
# 正常ケース: 完了後にサーバーが接続を閉じた
pass
except KeyboardInterrupt:
print("\nInterrupted by user.")
except Exception as e:
print(f"Error: {e}")
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--audio_path", type=str)
parser.add_argument("--audio_format", default="auto")
parser.add_argument("--translation", default="none")
args = parser.parse_args()
api_key = os.environ.get("SONIOX_API_KEY")
if api_key is None:
raise RuntimeError("Missing SONIOX_API_KEY.")
run_session(api_key, args.audio_path, args.audio_format, args.translation)
if __name__ == "__main__":
main()
Usageを見てみる。
uv run soniox_realtime.py --help
usage: soniox_realtime.py [-h] [--audio_path AUDIO_PATH] [--audio_format AUDIO_FORMAT] [--translation TRANSLATION]
options:
-h, --help show this help message and exit
--audio_path AUDIO_PATH
--audio_format AUDIO_FORMAT
--translation TRANSLATION
こちらも非同期と同じように、ファイルを使ったリアルタイム文字起こしとなっている。
あらかじめ用意されているコーヒーショップの会話のサンプルを使ってみる。
uv run soniox_realtime.py --audio_path ../assets/coffee_shop.mp3
Connecting to Soniox...
Session started.
===============================
Speaker 1:
[en] Wh
===============================
Speaker 1:
[en] What
===============================
Speaker 1:
[en] What is
===============================
Speaker 1:
[en] What is your
===============================
Speaker 1:
[en] What is your best
===============================
Speaker 1:
[en] What is your best s
===============================
Speaker 1:
[en] What is your best seller
===============================
Speaker 1:
[en] What is your best seller h
===============================
Speaker 1:
[en] What is your best seller here
===============================
Speaker 1:
[en] What is your best seller here?<end>
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] O
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best s
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller h
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is C
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Bre
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew I
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced Co
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced Coff
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced Coffe
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced Coffee,
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced Coffee, and
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced Coffee, and l
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced Coffee, and latt
===============================
Speaker 1:
[en] What is your best seller here?<end>
Speaker 2:
[en] Our best seller here is Cold Brew Iced Coffee, and lattes.<end>
===============================
(snip)
話者識別しつつ、認識結果が順次表示されているのがわかる。<end>が発話の終端部分になっている。
日本語の場合。非同期APIでも使った、自分が過去に開催した勉強会の冒頭部分だけ。
設定等も変えたほうがいいのだろうけど、そのままでも認識してくれる。
Connecting to Soniox...
Session started.
===============================
Speaker 1:
[ja] は
===============================
Speaker 1:
[ja] はい、
===============================
Speaker 1:
[ja] はい、じゃ
===============================
Speaker 1:
[ja] はい、じゃあ
===============================
Speaker 1:
[ja] はい、じゃあ
===============================
Speaker 1:
[ja] はい、じゃあ始
===============================
Speaker 1:
[ja] はい、じゃあ始め
===============================
Speaker 1:
[ja] はい、じゃあ始めま
===============================
Speaker 1:
[ja] はい、じゃあ始めます。
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ち
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっ
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっと
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとま
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られ
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られて
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方も
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もい
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいら
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっ
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっし
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃ
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃる
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるん
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんで
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんです
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんです
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけ
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボ
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイス
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスラン
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチ
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチで
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチ、JP
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始め
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めま
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end>
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end>
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、は
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はー
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。は
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。はい、
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。はい、日
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。はい、日曜
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。はい、日曜日に
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。はい、日曜日にお
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。はい、日曜日にお集
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。はい、日曜日にお集ま
===============================
Speaker 1:
[ja] はい、じゃあ始めます。ちょっとまだ来られてない方もいらっしゃるんですけど、ボイスランチJP始めます。<end> 皆さん、日曜日、はーい。はい、日曜日にお集まり
===============================
(snip)
リアルタイムAPIでの文字起こし+翻訳
基本的には非同期と同じで、指定した特定1言語への翻訳(one_way)と、音声に含まれる2言語をお互いにスイッチして翻訳、の2つのモード(two_way)がある。
リアルタイムAPIの場合はトークン単位で出力されるが、翻訳された内容もここに含まれることになる。実際にどのような出力が行われるかを確認するため、冒頭で使ったマイクからの音声を文字起こしするミニマルなサンプルを少し修正した。
import argparse
import json
import os
import queue
import sys
import threading
import time
import sounddevice as sd
from websockets import ConnectionClosedOK
from websockets.sync.client import connect
SONIOX_WEBSOCKET_URL = "wss://stt-rt.soniox.com/transcribe-websocket"
def stream_microphone(
ws,
sample_rate: int,
block_duration_ms: int,
stop_event: threading.Event,
) -> None:
"""マイクからの音声を一定間隔で送信する"""
block_size = max(int(sample_rate * block_duration_ms / 1000), 1)
audio_queue: queue.Queue[bytes] = queue.Queue()
def callback(indata, frames, time_info, status):
if status:
print(f"Audio callback status: {status}", file=sys.stderr)
audio_queue.put(bytes(indata))
try:
with sd.RawInputStream(
samplerate=sample_rate,
blocksize=block_size,
dtype="int16",
channels=1,
callback=callback,
):
while not stop_event.is_set():
try:
data = audio_queue.get(timeout=0.1)
except queue.Empty:
continue
ws.send(data)
finally:
try:
ws.send("")
except Exception:
pass
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--sample_rate", type=int, default=16000)
parser.add_argument("--block_duration_ms", type=int, default=120)
parser.add_argument("--language")
parser.add_argument("--to_language")
args = parser.parse_args()
api_key = os.environ.get("SONIOX_API_KEY")
if api_key is None:
raise RuntimeError("Missing SONIOX_API_KEY")
config: dict = {
"api_key": api_key,
"model": "stt-rt-v3",
"audio_format": "pcm_s16le",
"sample_rate": args.sample_rate,
"num_channels": 1,
"enable_language_identification": True,
"enable_speaker_diarization": True,
"enable_endpoint_detection": True,
}
if args.language:
config["language_hints"] = [args.language]
if args.to_language:
# 翻訳を有効化(one-way)
config["translation"] = {
"type": "one_way",
"target_language": args.to_language,
}
print("Connecting to Soniox...")
with connect(SONIOX_WEBSOCKET_URL) as ws:
ws.send(json.dumps(config))
stop_event = threading.Event()
sender_thread = threading.Thread(
target=stream_microphone,
args=(ws, args.sample_rate, args.block_duration_ms, stop_event),
)
sender_thread.start()
print("Session started. Press Ctrl+C to stop.")
try:
last_payload: str | None = None
while True:
message = ws.recv()
payload = json.loads(message)
# 普通にやると一定間隔でレスポンスが返され、出力量が非常に多くなるため、
# ここでは、トークンを保持しておいて、前回のトークンと比較して、内容が同じ
# なら出力を抑制するようにしている
current_dump = json.dumps(payload.get("tokens", []), sort_keys=True)
if current_dump != last_payload:
print("-" * 20)
print(time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()))
print(json.dumps(payload, indent=2, ensure_ascii=False))
last_payload = current_dump
if payload.get("finished"):
print("-" * 20)
print("Session finished.")
break
except ConnectionClosedOK:
pass
except KeyboardInterrupt:
print("\nInterrupted by user.")
except Exception as exc:
print(f"Error: {exc}")
finally:
stop_event.set()
sender_thread.join(timeout=1.0)
if __name__ == "__main__":
main()
--language は入力言語のヒント。--to_language が指定されていれば、リアルタイム翻訳を有効にして指定した言語への翻訳を同時に行うようにした。話者1なので one-way にしてある。
uv run soniox_minimal_realtime_mic_translate --language ja --to_language en
「おはようございます。今日はいいお天気ですね。」と発話してみた結果を以下に記載。長いので抜粋。
まず、普通に日本語で認識している。
--------------------
2025-10-27 23:00:57
{
"tokens": [],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 0
}
--------------------
2025-10-27 23:00:59
{
"tokens": [
{
"text": "お",
"start_ms": 1560,
"end_ms": 1620,
"confidence": 1,
"is_final": false,
"speaker": "1",
"language": "ja",
"translation_status": "original"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2160
}
--------------------
2025-10-27 23:00:59
{
"tokens": [
{
"text": "お",
"start_ms": 1560,
"end_ms": 1620,
"confidence": 1,
"is_final": false,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "は",
"start_ms": 1680,
"end_ms": 1740,
"confidence": 0.998,
"is_final": false,
"speaker": "1",
"language": "ja",
"translation_status": "original"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2280
}
--------------------
2025-10-27 23:00:59
{
"tokens": [
{
"text": "お",
"start_ms": 1560,
"end_ms": 1620,
"confidence": 1,
"is_final": false,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "は",
"start_ms": 1680,
"end_ms": 1740,
"confidence": 0.998,
"is_final": false,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "よう",
"start_ms": 1800,
"end_ms": 1860,
"confidence": 0.999,
"is_final": false,
"speaker": "1",
"language": "ja",
"translation_status": "original"
}
],
"final_audio_proc_ms": 0,
"total_audio_proc_ms": 2400
}
--------------------
このとき、translation_status というのが付与されている。ここは以下の通りとなる。
-
none: 翻訳なしの場合 -
original: 翻訳前の元のテキスト -
translation: 翻訳後のテキスト
上記の場合はまず元の日本語での文字起こしなので、originalとなっている。
で「おはようございます。」まで文字起こしができたタイミングで、英語に翻訳されたトークンが出力されている。
--------------------
2025-10-27 23:01:00
{
"tokens": [
{
"text": "お",
"start_ms": 1560,
"end_ms": 1620,
"confidence": 1,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "は",
"start_ms": 1680,
"end_ms": 1740,
"confidence": 0.998,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "よう",
"start_ms": 1800,
"end_ms": 1860,
"confidence": 0.999,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "ご",
"start_ms": 1980,
"end_ms": 2040,
"confidence": 0.999,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "ざ",
"start_ms": 2100,
"end_ms": 2160,
"confidence": 0.999,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "い",
"start_ms": 2220,
"end_ms": 2280,
"confidence": 1,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "ま",
"start_ms": 2280,
"end_ms": 2340,
"confidence": 1,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "す",
"start_ms": 2460,
"end_ms": 2520,
"confidence": 1,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
},
{
"text": "。",
"start_ms": 2700,
"end_ms": 2760,
"confidence": 0.97,
"is_final": true,
"speaker": "1",
"language": "ja",
"translation_status": "original"
}
],
"final_audio_proc_ms": 3120,
"total_audio_proc_ms": 3240
}
--------------------
2025-10-27 23:01:00
{
"tokens": [
{
"text": "Good",
"confidence": 1,
"is_final": true,
"speaker": "1",
"language": "en",
"translation_status": "translation",
"source_language": "ja"
},
{
"text": " mor",
"confidence": 1,
"is_final": true,
"speaker": "1",
"language": "en",
"translation_status": "translation",
"source_language": "ja"
},
{
"text": "ning.",
"confidence": 1,
"is_final": true,
"speaker": "1",
"language": "en",
"translation_status": "translation",
"source_language": "ja"
}
],
"final_audio_proc_ms": 3240,
"total_audio_proc_ms": 3240
}
--------------------
2025-10-27 23:01:01
{
"tokens": [],
"final_audio_proc_ms": 3240,
"total_audio_proc_ms": 4320
}
--------------------
2025-10-27 23:01:02
{
"tokens": [
{
"text": "<end>",
"confidence": 1,
"is_final": true,
"translation_status": "none"
}
],
"final_audio_proc_ms": 5280,
"total_audio_proc_ms": 5280
}
--------------------
languageがen、translation_statusがtranslationになっていて、これが翻訳されたトークンであり、また source_languageにjaが指定されていて、翻訳元言語が日本語であることもわかる。
このあとのトークンを見ていても、翻訳されたトークンは、元の言語の文字起こしのトークンとは別に返ってくるように見えるので、翻訳されたものを使いたい場合はtranslation_statusで区別すれば良さそう。
翻訳のスピード的には、元の言語の文字起こしの確定のタイミングでほとんど遅延なく返って来ているように見えるので、レスポンス的には、元の言語の未確定トークンよりは遅いけども、それでもかなり速いと思う。
タイムスタンプ
また、元の言語のトークンには 各トークンごとに start_ms / end_ms でタイムスタンプが付与されているので、例えば音声や映像に合わせるといったことができるが、これは翻訳後のトークンには存在しない点に注意。
サポートされている言語
以下に記載されている言語リスト間で翻訳がサポートされている。
発話終了検出
発話終了の検出は2つある。
- サーバ側で
enable_endpoint_detectionを有効にすると自動で検出する。 - クライアント側から
{"type": "finalize"}メッセージを送信する。
これにより、まだ処理中の未確定トークンが処理され、その時点での音声の認識が確定する。で、この章は前者の方。
一見サーバサイドVADかと思うのだが、ドキュメントによると(以下はPLaMo翻訳)
従来の音声活動検出(VAD)に基づくエンドポイント検出とは異なり、Sonioxは独自の音声モデルを使用して抑揚や間、会話の文脈を分析し、発話が終了したタイミングを正確に判断します。これにより、 はるかに高度な技術を採用しており、遅延の低減、誤検知の減少、そして 明らかにスムーズな製品体験を実現します。
とあり、VADよりはターン検出寄りの技術が使用されているのではないか?と思われる。
enable_endpoint_detectionを有効にした場合
- 無音区間を監視し、発話の終了を判定
- 発話終了を検出すると、
- それまでの先行トークン(つまり未確定のまま保留されているトークン)が確定トークンとなる
-
<end>トークンが返される。- 発話区間の最後に必ず1回だけ表示される。
- 常に最終トークンとなる。
という挙動になるため、これをもって後続の処理(例えば音声パイプラインでLLMに渡すなど)のトリガーとすることができる。
冒頭のミニマルなサンプルでもenable_endpoint_detectionは有効にしてあった。"How are you? I'm fine."と発話した場合の際の出力をを振り返ってみる。
まず"How are"までが未確定トークン("is_final": false)として認識されている
出力2025-10-24 01:30:26 { "tokens": [ { "text": "How", "start_ms": 1860, "end_ms": 1860, "confidence": 0.992, "is_final": false, "speaker": "1", "language": "en" }, { "text": " are", "start_ms": 2040, "end_ms": 2100, "confidence": 0.996, "is_final": false, "speaker": "1", "language": "en" } ], "final_audio_proc_ms": 0, "total_audio_proc_ms": 2760 }
これが進んでいき、"How are you?" まで進んだときに、<end>トークンが付与され、全てのトークンが確定トークン("is_final": true)になっている。おそらくここで発話の終了が検出されたのだと思われる。
出力2025-10-24 01:30:26 { "tokens": [ { "text": "How", "start_ms": 1860, "end_ms": 1860, "confidence": 0.992, "is_final": true, "speaker": "1", "language": "en" }, { "text": " are", "start_ms": 2040, "end_ms": 2100, "confidence": 0.996, "is_final": true, "speaker": "1", "language": "en" }, { "text": " y", "start_ms": 2160, "end_ms": 2220, "confidence": 0.999, "is_final": true, "speaker": "1", "language": "en" }, { "text": "ou", "start_ms": 2280, "end_ms": 2340, "confidence": 0.918, "is_final": true, "speaker": "1", "language": "en" }, { "text": "?", "start_ms": 2400, "end_ms": 2460, "confidence": 0.991, "is_final": true, "speaker": "1", "language": "en" }, { "text": "<end>", "start_ms": 0, "end_ms": 0, "confidence": 0.999, "is_final": true } ], "final_audio_proc_ms": 3120, "total_audio_proc_ms": 3240 }
手動終了処理
上の続きで、サーバサイドではなく、クライアント側で手動で発話終端を制御することもできる。こちらのほうが開発者側でより厳密に制御することができるため、ユースケースに合わせてどちらを使うかを選択することになると思うが、想定されるユースケースとして以下が記載されている。
- プッシュ・トゥ・トーク方式
- クライアント側でVADを使用する場愛
- セグメントベースで文字起こしする場合
- 自動(サーバサイド)での発話終了検出が要件に合わない場合。
クライアント側で発話終了を制御する場合、{"type": "finalize"} メッセージを送信する。これにより
- その時点での音声データ(つまり未確定のまま保留されているトークン)が確定(
finalize)される。 - 上記のトークンが
"is_final": trueで返される - 自動(サーバサイド)検出の場合の
<end>トークンと同様に<fin>トークン({"text": "<fin>", "is_final": true})が出力される。
重要なポイント
手動での終了処理を行う場合の注意点が記載されている。
- セッション中に
finalizeメッセージは複数回送信できるが、頻繁に送信しすぎると接続が切断される場合がある。ドキュメントの目安では数秒に1回程度らしい。 -
finalizeメッセージを送信したあとも、音声ストリームを継続して送信できる。 -
<fin>トークンは常に最終トークンとなるので、これをもって後続の処理(例えば音声パイプラインでLLMに渡すなど)のトリガーとすることができる。 - 課金対象となるのは 処理された音声データの時間ではなく、ストリーム全体の再生時間となる
- つまり、接続しっぱなしでずっと音声を送り続けると、ほぼ接続時間が課金対象になりそう。
- その場合は、おそらく無音であっても課金対象となる気がするので、クライアントVADなどで無音時は送信しない、といった対応が望ましいと思う。
末尾の無音部分
finalizeを送信する前の音声データの最後に無音が含まれる場合、finalizeメッセージにその長さを trailing_silence_ms で指定することで、その部分のパディングが削減され、遅延が改善できる。
{
"type": "finalize",
"trailing_silence_ms": 300
}
んー、とはいえ、finalizeメッセージのタイミングで確定するわけだから、そこまで遅延の改善があるかなぁ?という気はする。もちろん処理自体は多少なりとも速くなるとは思うんだけど。いまいち理屈がわからない。
接続キープアライブ
音声送信が行われていない、長時間の無音などがあると、タイムアウトする場合がある。これを防ぐにはキープアライブメッセージを使用すると良いらしい。これは次の章で見ていく。