音声を送る(Ingest)

Ingest は、Discord 以外の音声(マイク・録音ファイル・別の通話アプリの参加者ごとの音声など)を文字おこしくんに送るための API です。文字おこしくんは、あなたが送った音声を、話者ごとに文字にします。あなたは文字起こしの結果を、公式の表示ページ・配信用オーバーレイで見ることも、Subscribe の API で受け取ることもできます。

Discord のボイスチャンネルの文字起こしには、公式のボットを使ってください。

前提

# セッションを作る(セッションは 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"

接続の流れ

  1. 接続したら、最初に hello を送り、welcome を受け取るまでほかのメッセージを送らない。
  2. welcome を受け取ったら、現在の状態をすべて送る(全話者の speaker_upsert、全トラックの track_open)。
  3. state_synced を送る。音声は state_synced の後に送る(文字おこしくんは、state_synced の前に届いた音声を捨てることがある)。
  4. 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 か 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 の無音フレーム)
// 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(通し番号)

state_synced と再接続

state_synced は「接続直後の状態の送り直しが終わった」という合図です。文字おこしくんは、state_synced を受け取った時点で、クライアントが宣言しなかったトラックを閉じます(再接続の間に消えたトラックの後始末のため)。

再接続では:

  1. hello からやり直し、welcome の後に今の全話者・全トラックを送り、state_synced を送る。
  2. 同じトラックには同じ trackId を使う(channel は振り直してよい)。
  3. 切断中の音声は上限付き(例: 1 トラック 2 秒)で溜め、state_synced の後に送る。溢れたら古いものから捨てる。
  4. 切断中の speech_start / speech_end も、溜めた音声との前後を保って state_synced の後に送る。今話しているトラックは、track_open と state_synced の間に speech_start を送ってもよい。
  5. 文字おこしくんは、送り直さなかった話者を消さずに残す。クライアントは、切断中に消した話者を、再接続の後に 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 で検証するテストを書く。