音声を送る(Ingest)
Ingest は、Discord 以外の音声(マイク・録音ファイル・別の通話アプリの参加者ごとの音声など)を文字おこしくんに送るための API です。文字おこしくんは、あなたが送った音声を、話者ごとに文字にします。あなたは文字起こしの結果を、公式の表示ページ・配信用オーバーレイで見ることも、Subscribe の API で受け取ることもできます。
Discord のボイスチャンネルの文字起こしには、公式のボットを使ってください。
前提
- 1 つのセッションに Ingest の接続は 1 本だけです。同じセッションに新しい接続が来ると、文字おこしくんは古い方の接続を
supersededで切ります。公式のボットが使っているセッションに音声を送ると、ボットが止まります。自分のセッションを作って、音声を送ってください。 - 複数の話者の音声は、1 本の接続の中で、複数のトラックとして送ります。
- 音声を送るには、
ingest:writeのセッショントークンが要ります。スコープingest:writeの API キー(作り方はクイックスタート)でセッションを作ると、応答にトークンが入っています(HTTP API リファレンス)。
# セッションを作る(セッションは API キーのテナントに属する。文字おこしくんは利用上限を確かめる)
curl -s -X POST "$BASE/v1/sessions" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
-d '{"label":"会議室マイク","externalRef":"my-app:room-42","reuseActive":true}'
# → { "sessionId": "…", "ingest": { "url": "wss://…/v1/ingest", "token": "…" }, "subscribe": { … }, "viewerUrl": "…", "overlayUrl": "…" }
# 文字起こしが終わったら、セッションを終える
curl -s -X POST "$BASE/v1/sessions/$SID/end" -H "Authorization: Bearer $KEY"
接続の流れ
- 接続したら、最初に
helloを送り、welcomeを受け取るまでほかのメッセージを送らない。 welcomeを受け取ったら、現在の状態をすべて送る(全話者のspeaker_upsert、全トラックのtrack_open)。state_syncedを送る。音声はstate_syncedの後に送る(文字おこしくんは、state_syncedの前に届いた音声を捨てることがある)。state_syncedを送った後は、クライアントは、話者・トラックの変化を差分として随時送る。
| メッセージ | 送る側 | 送る時期 |
|---|---|---|
hello |
クライアント | 接続した直後。protocolVersion: 1 と token を入れる |
welcome |
文字おこしくん | hello を受け付けたとき。sessionId が入っている。受け付けなかったときは、文字おこしくんが fatal な error を送ってから接続を切る |
speaker_upsert |
クライアント | welcome の後に、話者の数だけ |
track_open |
クライアント | speaker_upsert の後に、開いているトラックの数だけ |
state_synced |
クライアント | 今の状態を送り終えたとき。文字おこしくんは、state_synced を受け取るまでに宣言がなかったトラックを閉じる |
| 音声フレーム(バイナリ) | クライアント | state_synced の後に、20ms ごと |
speech_start / speech_end |
クライアント | 発話区間の始まりと終わりで |
ping |
クライアント | 15 秒ごと |
pong |
文字おこしくん | ping を受け取ったとき |
error |
文字おこしくん | エラーが起きたとき。error が入っている。特定のトラックに関するエラーでは、trackId も入っている。非 fatal なら、クライアントはそのまま続けてよい |
順序の決まり:
singleトラックの話者は、そのトラックのtrack_openより前に、speaker_upsertで宣言する(宣言していなければ、文字おこしくんはunknown_speakerを返す)。speaker_removeの前に、その話者のsingleトラックをtrack_closeする。channelは、接続の中で一意にする(使用中の番号には、文字おこしくんがchannel_in_useを返す)。track_closeの後は、同じ番号を再利用してよい。
トラックと音声の形式
| 項目 | 決まり |
|---|---|
| トラックの種類 | トラックの種類は single か mixed にする。single では、1 トラックで 1 人の話者の音声を送る(話者は、先に speaker_upsert で宣言する)。mixed では、複数人の声をまとめた音声を送る。diarize: true なら、文字おこしくんが話者を分離して「話者 1」「話者 2」…として表示し、false ならトラック単位で表示する |
opus |
opus では、48000 Hz だけを使い、1 か 2 ch の音声を送る。生の Opus パケット 1 個を、1 フレームで送る(Ogg・WebM などのコンテナに入れない) |
pcm_s16le |
pcm_s16le では、8000〜48000 Hz で、1 か 2 ch の音声を送る。音声は符号付き 16 bit のリトルエンディアンで送り、2 ch はインターリーブする(L R L R …) |
| 送り方 | 音声は 20ms 程度ずつ、実時間の速さで 1 フレームずつすぐ送る(まとめて送らない。まとめると遅延が増え、認識も不安定になる) |
| 上限 | 1 つの接続で、トラックは 64 本まで開け、話者は 256 人まで宣言できる。文字おこしくんは、API キーで作ったセッションで開いているトラックを、全部の API キー(ほかの利用者のキーを含む)で合わせて数え、同時に 20 本までにする。1 メッセージは 64 KiB までにする(数はレート制限と上限にある) |
| 話者の表示名 | 表示名は 100 文字までにする |
| 発話区間 | 発話区間が分かるなら、発話区間の始まりと終わりを speech_start / speech_end で知らせる。知らせると、確定(final)が早く出る |
扱えない形式には、文字おこしくんが非 fatal な unsupported_format(trackId 付き)を返します。
音声バイナリフレーム
クライアントは、WebSocket のバイナリフレーム 1 個で、音声フレームを 1 個送ります。7 バイトのヘッダ(ビッグエンディアン)の後に、payload が続きます。
| フィールド(位置と型) | 内容 |
|---|---|
| frame type(offset 0 から 1 バイト、u8) | 常に 0x01(audio)にする |
| channel(offset 1 から 2 バイト、u16 BE) | track_open で割り当てた番号(0〜65535)を入れる |
| sequence(offset 3 から 4 バイト、u32 BE) | トラックごとに 1 ずつ増やす。2^32 で 0 に折り返す |
| payload(offset 7 から最後まで、bytes) | opus では Opus のパケットを 1 個、pcm_s16le では PCM(リトルエンディアン)を入れる |
例: channel = 1、sequence = 42、payload が Opus の無音フレーム f8 ff fe の 3 バイトのとき、10 バイトのフレームになります。
01 00 01 00 00 00 2a f8 ff fe
| バイト(16 進) | 内容 |
|---|---|
01 |
frame type(0x01 = audio) |
00 01 |
channel(1) |
00 00 00 2a |
sequence(42) |
f8 ff fe |
payload(Opus の無音フレーム) |
- ヘッダはビッグエンディアンで、PCM の payload はリトルエンディアンです。2 つを間違えないように注意してください。
frame typeが0x01以外、または 7 バイト未満のフレームは不正です。文字おこしくんは、開いていないchannelのフレームを捨てます(unknown_trackを返すことがあります)。
// JavaScript(ブラウザ / Node): フレームを作る
function audioFrame(channel, sequence, payload /* Uint8Array */) {
const frame = new Uint8Array(7 + payload.byteLength);
const view = new DataView(frame.buffer);
view.setUint8(0, 0x01);
view.setUint16(1, channel); // ビッグエンディアン(DataView の既定)
view.setUint32(3, sequence >>> 0); // 2^32 で折り返す
frame.set(payload, 7);
return frame;
}
# Python: フレームを作る
import struct
def audio_frame(channel: int, sequence: int, payload: bytes) -> bytes:
return struct.pack(">BHI", 0x01, channel, sequence & 0xFFFFFFFF) + payload
sequence(通し番号)
sequenceは、トラックごとに 0 から始めて、フレームごとに 1 ずつ増やします。sequenceは 2^32 で 0 に戻ります。- 文字おこしくんは欠番を検出して、少しの欠け(5 フレームまで)を補間(Opus は PLC、PCM は無音)で埋めます。5 フレームより長い欠けは埋めずに、文字おこしくんは欠けの後に届いた番号から数え直します。
- 文字おこしくんは、少しだけ戻った番号(64 以内)を、重複か遅延として捨てます。番号が大きく戻ったら、文字おこしくんは、送信側が数え直したとみなして受け入れます。
- 再接続の後は、0 から数え直して構いません(文字おこしくんは、接続をまたいだ番号の連続性を前提にしていません)。
state_synced と再接続
state_synced は「接続直後の状態の送り直しが終わった」という合図です。文字おこしくんは、state_synced を受け取った時点で、クライアントが宣言しなかったトラックを閉じます(再接続の間に消えたトラックの後始末のため)。
再接続では:
helloからやり直し、welcomeの後に今の全話者・全トラックを送り、state_syncedを送る。- 同じトラックには同じ
trackIdを使う(channelは振り直してよい)。 - 切断中の音声は上限付き(例: 1 トラック 2 秒)で溜め、
state_syncedの後に送る。溢れたら古いものから捨てる。 - 切断中の
speech_start/speech_endも、溜めた音声との前後を保ってstate_syncedの後に送る。今話しているトラックは、track_openとstate_syncedの間にspeech_startを送ってもよい。 - 文字おこしくんは、送り直さなかった話者を消さずに残す。クライアントは、切断中に消した話者を、再接続の後に
speaker_upsert→speaker_removeで消す。
再接続せずに終えるべきエラー(unauthorized / session_ended / superseded / quota_exceeded など)と、バックオフについては再接続とエラーの扱いを見てください。
例: 1 人の話者の PCM を送る(Python)
WAV(リニア PCM 16 bit)を実時間の速さで送る最小の例です(この例は再接続を省いています)。
import asyncio, json, struct, sys, wave
import websockets
async def main(url: str, token: str, path: str) -> None:
with wave.open(path, "rb") as wav:
assert wav.getsampwidth() == 2 and wav.getnchannels() in (1, 2)
rate, channels = wav.getframerate(), wav.getnchannels()
pcm = wav.readframes(wav.getnframes())
frame_bytes = rate // 50 * channels * 2 # 20ms
async with websockets.connect(url) as ws:
await ws.send(json.dumps({"type": "hello", "protocolVersion": 1, "token": token, "clientName": "wav-sender/1.0"}))
welcome = json.loads(await ws.recv())
if welcome["type"] != "welcome":
raise SystemExit(welcome)
await ws.send(json.dumps({"type": "speaker_upsert", "speaker": {"speakerId": "me", "displayName": "わたし"}}))
await ws.send(json.dumps({"type": "track_open", "trackId": "mic", "channel": 0,
"format": {"codec": "pcm_s16le", "sampleRate": rate, "channels": channels},
"track": {"mode": "single", "speakerId": "me"}}))
await ws.send(json.dumps({"type": "state_synced"}))
await ws.send(json.dumps({"type": "speech_start", "trackId": "mic"}))
loop = asyncio.get_running_loop()
started = loop.time()
for seq, offset in enumerate(range(0, len(pcm), frame_bytes)):
await ws.send(struct.pack(">BHI", 1, 0, seq & 0xFFFFFFFF) + pcm[offset:offset + frame_bytes])
await asyncio.sleep(max(0.0, started + (seq + 1) * 0.02 - loop.time())) # 実時間の速さで
await ws.send(json.dumps({"type": "speech_end", "trackId": "mic"}))
await asyncio.sleep(3) # 最後の final が出るのを待つ
asyncio.run(main(*sys.argv[1:4]))
音声ファイルの変換の例: ffmpeg -i input.mp3 -ac 1 -ar 16000 -c:a pcm_s16le input.wav
コーディングエージェント向けのプロンプト
文字おこしくんの Ingest WebSocket API(/v1/ingest)に音声を送るクライアントを書いてください。
仕様: https://moji.kaitoranma.com/docs/ingest.md と asyncapi.yaml、
JSON Schema(https://moji.kaitoranma.com/docs/schemas/ingest-client-message.json)。
- 接続後 hello {protocolVersion:1, token} → welcome を待つ → 全話者の speaker_upsert → 全トラックの track_open → state_synced の順に送る。再接続のたびにこれを最初からやり直す。
- 音声は 7 バイトのヘッダ(u8 0x01、u16 BE channel、u32 BE sequence)+ payload のバイナリフレームで、20ms ごとに 1 個ずつ実時間の速さで送る。PCM は s16le(リトルエンディアン)、Opus は 48kHz の生パケット 1 個。
- sequence はトラックごとに 1 ずつ増やし 2^32 で折り返す。
- speech_start / speech_end を送る。切断中の音声は 1 トラック 2 秒まで溜め、state_synced の後に区切りと前後を保って送る。
- 1 セッションに Ingest 接続は 1 本だけ(superseded で止まる)。unauthorized / session_ended / superseded / quota_exceeded / fatal な invalid_message では再接続しない。
- トークンは ingest:write のセッショントークンを環境変数から読む。送るメッセージは JSON Schema で検証するテストを書く。