# 音声を送る（Ingest）

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

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

## 前提

- **1 つのセッションに Ingest の接続は 1 本だけ**です。同じセッションに新しい接続が来ると、文字おこしくんは古い方の接続を `superseded` で切ります。公式のボットが使っているセッションに音声を送ると、ボットが止まります。**自分のセッションを作って**、音声を送ってください。
- 複数の話者の音声は、1 本の接続の中で、複数の**トラック**として送ります。
- 音声を送るには、`ingest:write` のセッショントークンが要ります。スコープ `ingest:write` の API キー（作り方は[クイックスタート](quickstart.md#from-api-key)）でセッションを作ると、応答にトークンが入っています（[HTTP API リファレンス](http.md#createSession)）。

```sh
# セッションを作る（セッションは 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` トラックの話者は、そのトラックの `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 までにする（数は[レート制限と上限](limits.md)にある） |
| 話者の表示名 | 表示名は 100 文字までにする |
| 発話区間 | 発話区間が分かるなら、発話区間の始まりと終わりを `speech_start` / `speech_end` で知らせる。知らせると、確定（`final`）が早く出る |

扱えない形式には、文字おこしくんが非 fatal な `unsupported_format`（`trackId` 付き）を返します。

## 音声バイナリフレーム {#binary-frame}

クライアントは、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 バイトのフレームになります。

```text
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` を返すことがあります）。

```js
// 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
# 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` を受け取った時点で、クライアントが宣言しなかったトラックを閉じます（再接続の間に消えたトラックの後始末のため）。

再接続では:

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` など）と、バックオフについては[再接続とエラーの扱い](reconnection.md)を見てください。

## 例: 1 人の話者の PCM を送る（Python）

WAV（リニア PCM 16 bit）を実時間の速さで送る最小の例です（この例は再接続を省いています）。

```python
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`

## コーディングエージェント向けのプロンプト

```text
文字おこしくんの 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 で検証するテストを書く。
```
