{"openapi":"3.1.0","info":{"title":"Canto-Gen API","version":"1.0.0","description":"The Canto-Gen public REST API for text-to-speech and audio transcription.\n\nCanto-Gen (港嘢) turns Cantonese text into natural, native-sounding speech.\nPreset voices are **Cantonese-optimized** (廣東話為本).\n\n## Getting started\n\n1. Create an API key in the playground (「API 金鑰」).\n2. List available voices with `GET /v1/voices`.\n\n**Sync generation — one call returns the audio directly (short text, ≤300 billable chars):**\n\n```bash\ncurl -X POST https://canto-gen.com/v1/audio/speech \\\n  -H \"Authorization: Bearer sk_live_…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"model\": \"cantogen-tts\",\n    \"voice\": \"sum\",\n    \"text\": \"The weather is lovely today, let us go for a hike.\",\n    \"response_format\": \"mp3\"\n  }'\n```\n\n**Async generation — submit a job and poll (any size, bulk ≤25):**\n\n```bash\n# 1) Submit a job — returns 202 Accepted with a job_id\ncurl -X POST https://canto-gen.com/v1/audio/jobs \\\n  -H \"Authorization: Bearer sk_live_…\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\"model\":\"cantogen-tts\",\"voice\":\"sum\",\"text\":\"The weather is lovely today, let us go for a hike.\"}'\n\n# 2) Poll every 2–5 s until status becomes completed\ncurl -H \"Authorization: Bearer sk_live_…\" \\\n  https://canto-gen.com/v1/audio/jobs/{job_id}\n\n# 3) Download the audio from the response's audio_url\ncurl -o speech.mp3 \"$AUDIO_URL\"\n```\n\nFor the full step-by-step guide (keys, pricing, bulk, SRT subtitles), see the guide: **[/v1/docs/en](/v1/docs/en)**.\n\nAll endpoints require an API key passed as a Bearer token, except `GET /v1/voices`\n(the public voice catalog, anonymous access allowed):\n\n```\nAuthorization: Bearer sk_live_…\n```\n\nCreate and manage keys in the playground under 「API 金鑰」 (API Keys).\nAll keys share the `sk_live_…` prefix; free-plan keys are subject to a daily\ngeneration limit, while paid-plan usage counts against the key's monthly character cap.\nTwo complementary patterns:\n\n1. **Sync (short text)** — `POST /v1/audio/speech` returns **200 OK** with the\n   audio bytes directly in the response body while the sync queue is fully idle. Limited to 300 billable characters,\n   `voice`/`design` modes only (no `clone`, no `srt`). While the sync queue is busy,\n   requests honestly fail closed with `503 {\"error\":\"pool_cold\"}` (nothing billed,\n   no silent async fallback) — use the async endpoint instead; if a request was accepted\n   but the wait exceeds ~45 s, it returns `202 {job_id, poll_url}` — the work keeps\n   running and you just switch to polling.\n\n2. **Async (any size)** — `POST /v1/audio/jobs` returns **202 Accepted** with a\n  `job_id`; poll `GET /v1/audio/jobs/{id}` every 2–5 s until `completed`, then\n  download the pre-signed `audio_url`. Also accepts bulk submissions\n  (`{\"items\":[…]}`, up to 25).\n## Errors\n\nErrors use a stable `error` code with an optional `message`:\n\n```json\n{ \"error\": \"text_too_long\", \"message\": \"text exceeds 500 characters.\" }\n```\n\nRate-limited requests return `429` with a `Retry-After` header (seconds).\n\n## Argument limits (REST)\n\n- `text`: async ≤ 500 characters; sync `POST /v1/audio/speech` ≤ 300 billable characters.\n- `instruction` ≤ 500 characters; synthesis order style → emotion → instruction (instruction has the final say).\n- `style`: English slugs (Chinese presets accepted as aliases); `emotion`: closed 9-value enum.\n- `clone` needs only `reference_audio` (base64; one request is capped at 4.4 MB ≈ 3 MB of audio — samples of at least 8 s) — `prompt_text` is retired (ignored); `design` uses `gender` + `age`; `speed` was removed (ignored).\n\n## Realtime WebSocket\n\nwss://api.canto-gen.com/v1/audio/realtime\n\nCanto-Gen also exposes a WebSocket realtime endpoint for text-in → streamed PCM audio-out. The protocol is based on OpenAI Realtime; Canto-Gen extension events/fields are marked as such.\n\n```js\n// npm i ws\nconst WebSocket = require(\"ws\");\nconst ws = new WebSocket(\"wss://api.canto-gen.com/v1/audio/realtime\", {\n  headers: { Authorization: `Bearer ${process.env.CANTOGEN_API_KEY}` },\n});\nws.on(\"open\", () => {\n  ws.send(JSON.stringify({ type: \"session.update\", session: { voice: \"sum\", max_session_chars: 500 } }));\n  ws.send(JSON.stringify({ type: \"conversation.item.create\",\n    item: { type: \"message\", content: [{ type: \"input_text\", text: \"你好，歡迎使用港嘢！\" }] } }));\n});\nws.on(\"message\", (raw) => {\n  const ev = JSON.parse(raw.toString());\n  if (ev.type === \"response.output_audio.delta\") play(Buffer.from(ev.delta, \"base64\")); // PCM16 @ 48 kHz\n  if (ev.type === \"response.done\") ws.close();\n});\n```\n\n### Realtime limits\n\n- `idle_timeout_seconds`: 120\n- `max_session_duration_seconds`: 300 (free) / unlimited (paid)\n- `max_sessions_per_user`: free 1 / lite 2 / starter 3 / pro 4 / studio 5 / enterprise 5\n- `max_sessions_global`: 8\n- `max_session_chars_default`: 2000\n- `max_in_flight_utterances`: 3\n- `raw_chars_per_session`: 20000\n- `raw_chars_per_minute`: 5000\n- `utterance_lease_seconds`: 60\n- `max_frame_bytes`: 65536\n\nBeyond REST, a realtime WebSocket endpoint (OpenAI Realtime base + Canto-Gen extensions) is available: `wss://api.canto-gen.com/v1/audio/realtime` — see the `x-realtime-websocket` extension and the Realtime WebSocket section of `/v1/docs/en`."},"servers":[{"url":"https://canto-gen.com","description":"Production"}],"tags":[{"name":"Speech","description":"Text-to-speech generation"},{"name":"Jobs","description":"Speech job status"},{"name":"Voices","description":"Voice catalog"},{"name":"Transcription","description":"Audio transcription"},{"name":"Balance","description":"Account balance"}],"paths":{"/v1/audio/speech":{"post":{"operationId":"createSpeech","summary":"Generate speech (sync, short text)","description":"Generate speech synchronously (**fast lane**). Returns `200 OK` with audio bytes.\n\nSync limits: text up to 300 billable characters; `voice` (preset) or\n`design` mode only — `clone` returns `400 clone_not_supported_sync` and\n`srt: true` returns `400 srt_not_supported_sync` (both: use the async\n`POST /v1/audio/jobs`).\n\n**Fast-lane semantics (first come, first served)**: the request is only accepted\nwhen the generation pool is completely idle — it then waits inside the same\nrequest for completion (typically ~5–15 s) and returns the bytes directly.\n**When the pool is busy the endpoint fails closed with `503 {\"error\":\"pool_cold\"}`**\n(nothing is billed) — use the async path. If the wait exceeds ~45 s (a sudden\nqueue surge) the endpoint returns `202 {job_id, poll_url}`; poll that job as\nusual — the work is kept and never double-billed.\n\nChoose exactly **one** voice mode — they are mutually exclusive:\n\n- `voice` — a preset voice slug from `GET /v1/voices` (recommended; all preset\n  voices are Cantonese-optimized 香港聲線). Requires the `tts:generate` scope.\n- `design` — describe a synthetic voice by `gender` + `age`. Accepted with the\n  `tts:generate` scope **or** the finer-grained `tts:design` scope (either one\n  suffices, so existing `tts:generate` keys keep working unchanged).\n- `clone` — clone a voice from a base64 `reference_audio` sample (requires the\n  `tts:clone` key scope). Only `reference_audio` is needed; the legacy\n  `prompt_text` field is retired (any value is ignored).\n\nSending more than one of `voice` / `design` / `clone` returns `400 voice_mode_conflict`.","tags":["Speech"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpeechRequest"},"examples":{"PresetVoice":{"summary":"Preset voice (simplest usage)","value":{"model":"cantogen-tts","voice":"sum","text":"你好，歡迎使用港嘢！","response_format":"mp3"}},"DesignedVoice":{"summary":"Designed voice by gender + age (tts:generate or tts:design scope)","value":{"model":"cantogen-tts","design":{"gender":"female","age":"young"},"text":"今日天氣幾好，一齊去行山啦。","style":"brisk","emotion":"happy"}},"DirectedDelivery":{"summary":"Free-text delivery direction","value":{"model":"cantogen-tts","voice":"sum","text":"生日快樂！祝你心想事成！","instruction":"用興奮嘅語氣講，好似慶祝生日咁"}}}}}},"responses":{"200":{"description":"Audio generated successfully. Body contains raw audio bytes.","content":{"audio/mpeg":{"schema":{"type":"string","format":"binary"}}},"headers":{"current-concurrent-requests":{"description":"Current number of in-flight generation requests on the /v1 API lane.","schema":{"type":"string"}},"maximum-concurrent-requests":{"description":"Global concurrency cap for the /v1 API lane (exceeding it returns 429 capacity_exceeded).","schema":{"type":"string"}},"X-Usage-Remaining":{"description":"Characters remaining in the current billing period.","schema":{"type":"string"}},"X-Audio-Duration":{"description":"Audio duration in seconds (when the server can probe the container).","schema":{"type":"string"}},"X-Credits-Charged":{"description":"Billable characters charged for this request.","schema":{"type":"string"}}}},"202":{"description":"The fast-lane wait exceeded ~45 s (a sudden queue surge) — the job was submitted normally (billed as usual); poll `GET {poll_url}` instead. The work is kept and never double-billed.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpeechJob"},"example":{"job_id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","status":"queued","poll_url":"/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","hint":"sync wait exceeded 45s — poll this job with the same Bearer key"}}}},"400":{"description":"Invalid request. See the `error` code for the reason.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"invalid_model":{"value":{"error":"invalid_model","message":"model must be \"cantogen-tts\"."}},"voice_mode_conflict":{"value":{"error":"voice_mode_conflict","message":"voice is mutually exclusive with design and clone."}},"text_required":{"value":{"error":"text_required","message":"text is required."}},"text_too_long":{"value":{"error":"text_too_long","message":"text exceeds 500 characters."}},"unsupported_format":{"value":{"error":"unsupported_format","message":"response_format supports mp3 and wav (MVP)."}},"invalid_emotion":{"value":{"error":"invalid_emotion","message":"emotion must be one of: happy/sad/angry/fearful/disgusted/surprised/neutral/excited/gentle."}},"invalid_instruction":{"value":{"error":"invalid_instruction","message":"instruction exceeds 500 characters."}},"reference_audio_required":{"value":{"error":"reference_audio_required","message":"clone.reference_audio is required."}},"design_gender_and_age_required":{"value":{"error":"design_gender_and_age_required","message":"design.gender and design.age are required."}},"invalid_srt":{"value":{"error":"invalid_srt","message":"srt must be a boolean."}},"clone_not_supported_sync":{"value":{"error":"clone_not_supported_sync","hint":"use POST /v1/audio/jobs (async)"}},"srt_not_supported_sync":{"value":{"error":"srt_not_supported_sync","hint":"use POST /v1/audio/jobs (async)"}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"403":{"description":"Forbidden — the key lacks the required scope (clone requests need `tts:clone`; design requests accept `tts:generate` or `tts:design`), or the key's monthly character cap is reached.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"insufficient_scope":{"value":{"error":"insufficient_scope","scope":"tts:clone"}},"monthly_cap_reached":{"value":{"error":"monthly_cap_reached","used":100000}}}}}},"429":{"description":"Rate limit exceeded (per-key, per-minute). Wait `Retry-After` seconds and retry.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"rate_limit_exceeded","retry_after":42}}}},"503":{"description":"The sync fast lane is unavailable, by `error` code: `pool_cold` = queue busy, request not accepted (nothing billed, no silent async fallback); `sync_generation_failed` = the job failed or was cancelled during the sync wait (already refunded); `jobs_unavailable` = transient enqueue outage. Sync waits over ~45 s return 202 instead — switch to polling. Submit via `POST /v1/audio/jobs` instead.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"pool_cold","hint":"use POST /v1/audio/jobs (async)"}}}}}}},"/v1/audio/jobs":{"post":{"operationId":"submitSpeechJob","summary":"Submit a speech job (async)","description":"Submit a speech job for asynchronous generation. Returns `202 Accepted` with a\n`job_id` and `poll_url`; poll `GET /v1/audio/jobs/{id}` until the job completes.\n\n**Bulk**: send `{\"items\": [ …up to 25 request bodies… ]}` to submit a batch —\nvalidation is all-or-nothing (any invalid item rejects the whole request with\n`400 invalid_items`); after enqueue, failures are per-item and the `202` body\nreports each entry (`{index, job_id, …}` or `{index, status: \"failed\", error}`).\n\nChoose exactly **one** voice mode — they are mutually exclusive:\n\n- `voice` — a preset voice slug from `GET /v1/voices` (recommended; all preset\n  voices are Cantonese-optimized 香港聲線). Requires the `tts:generate` scope.\n- `design` — describe a synthetic voice by `gender` + `age`. Accepted with the\n  `tts:generate` scope **or** the finer-grained `tts:design` scope (either one\n  suffices, so existing `tts:generate` keys keep working unchanged).\n- `clone` — clone a voice from a base64 `reference_audio` sample (requires the\n  `tts:clone` key scope). Only `reference_audio` is needed; the legacy\n  `prompt_text` field is retired (any value is ignored).\n\nSending more than one of `voice` / `design` / `clone` returns `400 voice_mode_conflict`.","tags":["Jobs"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"Idempotency-Key","in":"header","required":false,"description":"Retry-safety key. Replaying the same value returns `409 duplicate_request` (nothing resubmitted, nothing re-billed); any string works (UUIDs are used as the job id, other strings are mapped deterministically via UUIDv5). Omit it and every request is a fresh submission.","schema":{"type":"string"},"example":"order-2026-10-15-0001"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SpeechRequest"},"examples":{"PresetVoice":{"summary":"Preset voice (simplest usage)","value":{"model":"cantogen-tts","voice":"sum","text":"你好，歡迎使用港嘢！","response_format":"mp3"}},"VoiceClone":{"summary":"Voice clone (requires tts:clone scope)","value":{"model":"cantogen-tts","clone":{"reference_audio":"data:audio/wav;base64,UklGRiQAAABXQVZFZm10IBAAAAABAAEAQB8AAEAfAAABAAgAZGF0YQAAAAA="},"text":"用我把聲講呢句嘢。"}},"BulkBatch":{"summary":"Bulk submission (up to 25 items)","value":{"items":[{"model":"cantogen-tts","voice":"sum","text":"第一句。"},{"model":"cantogen-tts","design":{"gender":"male","age":"adult"},"text":"第二句。"}]}}}}}},"responses":{"202":{"description":"Job accepted. Single request → `{job_id, status, poll_url, srt_requested}`; bulk → `{jobs: [{index, job_id, status, poll_url, srt_requested} | {index, status: \"failed\", error}]}`.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatus"},"example":{"job_id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","status":"queued","poll_url":"/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","srt_requested":false}}},"headers":{"X-RateLimit-Limit":{"description":"Per-minute request cap for this key.","schema":{"type":"string"}},"current-concurrent-requests":{"description":"Current number of in-flight generation requests on the /v1 API lane.","schema":{"type":"string"}},"maximum-concurrent-requests":{"description":"Global concurrency cap for the /v1 API lane (exceeding it returns 429 capacity_exceeded).","schema":{"type":"string"}},"X-RateLimit-Remaining":{"description":"Requests left in the current 60-second window.","schema":{"type":"string"}}}},"400":{"description":"Invalid request — translation-layer codes, or `too_many_items` (>25) / `invalid_items` (bulk all-or-nothing validation).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"too_many_items":{"value":{"error":"too_many_items","max":25,"got":26}},"invalid_items":{"value":{"error":"invalid_items","items":[{"index":1,"error":"text_required"}]}},"voice_mode_conflict":{"value":{"error":"voice_mode_conflict","message":"voice is mutually exclusive with design and clone."}},"text_too_long":{"value":{"error":"text_too_long","message":"text exceeds 500 characters."}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"402":{"description":"The account's character pool is exhausted.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"character_limit_reached","remaining":0}}}},"403":{"description":"Forbidden — the key lacks the required scope (clone requests need `tts:clone`; design requests accept `tts:generate` or `tts:design`), or the key's monthly character cap is reached.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"insufficient_scope":{"value":{"error":"insufficient_scope","scope":"tts:clone"}},"monthly_cap_reached":{"value":{"error":"monthly_cap_reached","used":100000}}}}}},"429":{"description":"Rate limit exceeded (per-key, per-minute). Wait `Retry-After` seconds and retry.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"rate_limit_exceeded","retry_after":42}}}},"503":{"description":"Transient backend issue (queue/billing infrastructure). Retry with backoff.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"jobs_unavailable"}}}}}}},"/v1/audio/jobs/{id}":{"get":{"operationId":"getJob","summary":"Get job status","description":"Fetch the status of a speech job created with `POST /v1/audio/jobs`.\n\nPoll until `status` becomes `completed` or `failed`. When the job is\n`completed`, `audio_url` carries a pre-signed download URL for the audio.\nWhen subtitles were requested (`srt_requested: true`) and the job completes,\nthe `srt` field carries the SRT subtitle content.","tags":["Jobs"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The `job_id` returned by `POST /v1/audio/jobs`.","schema":{"type":"string"},"example":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b"}],"responses":{"200":{"description":"Current job state. `srt` is attached once subtitles are ready.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/JobStatus"},"example":{"job_id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","status":"completed","poll_url":"/v1/audio/jobs/b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","srt_requested":true,"srt":"1\n00:00:00,000 --> 00:00:02,400\n你好，歡迎使用港嘢！"}}}},"400":{"description":"Malformed job id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_job_id"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"404":{"description":"No job exists with this id (or it belongs to another key).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"job_not_found"}}}}}},"delete":{"operationId":"cancelJob","summary":"Cancel a job","description":"Cancel a speech job created with `POST /v1/audio/jobs`.\n\nJob not yet started (`queued`) → `200`: cancelled immediately and **fully\nrefunded** (`refunded` is the actual number of characters refunded).\nIn flight (`processing`) → `202`: the cancellation was accepted and the job\nwinds down — polling then shows `cancelling` before it settles as `cancelled`.\nAlready settled (`completed`/`failed`/`cancelled`) → `409 already_settled`: the\njob can no longer be cancelled and no refund applies.","tags":["Jobs"],"security":[{"ApiKeyAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"description":"The `job_id` returned by `POST /v1/audio/jobs`.","schema":{"type":"string"},"example":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b"}],"responses":{"200":{"description":"The job had not started — cancelled immediately and fully refunded.","content":{"application/json":{"schema":{"type":"object","description":"Immediate-cancel body — the job had not started and was fully refunded.","properties":{"ok":{"type":"boolean","description":"Always true on a successful cancel."},"status":{"type":"string","enum":["cancelled"],"description":"Terminal cancel state."},"refunded":{"type":"integer","description":"Characters actually refunded (per usage_events; 0 if the figure could not be read — the refund itself always happens)."}},"required":["ok","status"]},"example":{"ok":true,"status":"cancelled","refunded":1234}}}},"202":{"description":"Cancellation accepted — the in-flight job winds down; polling shows `cancelling` before it settles as `cancelled`.","content":{"application/json":{"schema":{"type":"object","description":"Cancel-intent body — the in-flight job winds down cooperatively; poll until it settles as `cancelled`.","properties":{"ok":{"type":"boolean","description":"Always true when the cancel intent was accepted."},"status":{"type":"string","enum":["cancelling"],"description":"Transient state — poll until it settles as `cancelled`."}},"required":["ok","status"]},"example":{"ok":true,"status":"cancelling"}}}},"400":{"description":"Malformed job id.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_job_id"}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"404":{"description":"No job exists with this id (or it belongs to another key).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"job_not_found"}}}},"409":{"description":"The job already settled (completed/failed/cancelled) — it can no longer be cancelled and no refund applies.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"already_settled"}}}}}}},"/v1/voices":{"get":{"operationId":"listVoices","summary":"List voices","description":"List the official voice catalog. All preset voices are\n**Cantonese-optimized** (廣東話優化) — use each voice's `slug` as the\n`voice` parameter of `POST /v1/audio/speech` (sync) or `POST /v1/audio/jobs` (async).\n\n`preview_url` streams a short sample without extra auth.","tags":["Voices"],"security":[],"responses":{"200":{"description":"The official voice catalog.","content":{"application/json":{"schema":{"type":"object","properties":{"voices":{"type":"array","items":{"$ref":"#/components/schemas/Voice"}}},"required":["voices"]},"example":{"voices":[{"slug":"sum","name":"心妍","gender":"female","age":"adult","styles":[],"preview_url":"https://canto-gen.com/api/voice-clip?slug=sum"}]}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"503":{"description":"Voice catalog temporarily unavailable.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"voice_gallery_unavailable"}}}}}}},"/v1/asr/transcribe":{"post":{"operationId":"createTranscription","summary":"Transcribe audio to text","description":"Upload an audio file and receive the transcript synchronously in the same request.\nCanonical path: `/v1/asr/transcribe` — OpenAI-compatible clients may keep using\n\n**Fields**: `file` (required, the audio file), `model` (required), `language`\n(optional, ISO-639-1), `response_format` (`json` default / `text`). `srt` and\n`verbose_json` need segment-level timestamps and are not yet supported — they\nreturn `400 unsupported_response_format`. Transcription is currently\nCantonese-optimized.\n\n**Billing (prepaid HK$ balance)**: billed by audio duration — HK$1.40 per hour\nfor file transcription, charged per second with no rounding up. The estimated\namount is **held** before processing and settled on the actual duration when\ndone; failures, timeouts and outages are refunded in full (success-only billing).\nAn insufficient balance returns `402 insufficient_balance` — top up at\n[/account/billing](/account/billing) and retry. `GET /v1/balance` returns your\nbalance and the current rates.\n\n**Limits**: files up to 20 MB; audio is capped at **≈9 minutes per request on\nthe synchronous endpoint** (see the transition note — the 4-hour cap is planned,\nnot live yet); 24 hours of\nbillable audio per day (Hong Kong day boundary, over it returns\n`429 daily_limit_exceeded`).\n\n**Transition note**: delivery is synchronous — short and medium files finish\nin-request; audio whose processing exceeds the synchronous wait window (≈9 minutes\nof audio) is refunded in full and returns `503 engine_unavailable` (the worker still\nfinishes; there is no retrieval surface yet). The 4-hour claim returns once the\nasync retrieval API ships.","tags":["Transcription"],"security":[{"ApiKeyAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/TranscriptionRequest"},"examples":{"AudioFile":{"summary":"Transcribe an audio file","value":{"file":"(binary audio file)","model":"cantogen-asr","response_format":"json"}}}}}},"responses":{"200":{"description":"Transcription succeeded.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Transcription"},"example":{"id":"b7e6f2a1-4c3d-4e5f-8a9b-0c1d2e3f4a5b","object":"transcription","text":"今日天氣幾好，一齊去行山啦。","duration_seconds":1830,"charged_hkd":0.7117}}}},"400":{"description":"Invalid request — `invalid_request` (multipart missing `file`/`model`, an empty file, or an unsupported audio type) or `unsupported_response_format` (`response_format` currently supports `json`/`text` only).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"invalid_request":{"value":{"error":"invalid_request","message":"file field is required."}},"unsupported_response_format":{"value":{"error":"unsupported_response_format","message":"Supported formats: json, text. Segment-timestamp formats are not yet supported."}}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"402":{"description":"The prepaid balance cannot cover the hold for this transcription (nothing was processed, nothing billed). Top up at /account/billing and retry.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"insufficient_balance","message":"Top up your prepaid balance and retry."}}}},"413":{"description":"The audio file exceeds 20 MB, or the audio is longer than the limit (≈9 minutes in practice on the synchronous endpoint; 4 hours is planned).","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"payload_too_large","message":"File exceeds 20 MB limit."}}}},"429":{"description":"The daily billable-audio cap (24 hours/day, Hong Kong day boundary) is reached. Retry tomorrow.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"daily_limit_exceeded","message":"Daily audio-seconds cap reached."}}}},"503":{"description":"The transcription service is temporarily unavailable (duration-probe, billing or pipeline failure; audio beyond the current synchronous delivery window also lands here). Any hold is refunded in full — success-only billing.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"examples":{"engine_unavailable":{"value":{"error":"engine_unavailable"}},"billing_unavailable":{"value":{"error":"engine_unavailable","message":"billing_unavailable"}}}}}}}}},"/v1/balance":{"get":{"operationId":"getBalance","summary":"Get prepaid balance","description":"Get the account's prepaid balance (HKD) plus the file-transcription / realtime-\ndictation rates and the auto top-up settings.\n\n- `balance_hkd` — the current prepaid balance (HKD).\n- `asr` — rates: HK$1.40/hour for file transcription, HK$2.80/hour for realtime\n  dictation, billed per audio second (`audio_second`).\n- `auto_topup` — auto top-up settings (enabled, threshold, amount).\n\nTest keys report the test-environment balance (isolated from production).\nTop up at [/account/billing](/account/billing) when the balance runs low.","tags":["Balance"],"security":[{"ApiKeyAuth":[]}],"responses":{"200":{"description":"Prepaid balance and rates.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Balance"},"example":{"object":"balance","currency":"HKD","balance_hkd":50,"asr":{"batch_rate_hkd_per_hour":1.4,"realtime_rate_hkd_per_hour":2.8,"billed_unit":"audio_second"},"auto_topup":{"enabled":true,"threshold_hkd":20,"amount_hkd":100}}}}},"401":{"description":"Missing, malformed, invalid or expired API key.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"invalid_or_expired_token"}}}},"503":{"description":"Balance lookup temporarily failed. Retry shortly.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]},"example":{"error":"balance_read_failed"}}}}}}}},"components":{"securitySchemes":{"ApiKeyAuth":{"type":"http","scheme":"bearer","bearerFormat":"sk_live_","description":"All endpoints require an API key passed as a Bearer token, except `GET /v1/voices`\n(the public voice catalog, anonymous access allowed):\n\n```\nAuthorization: Bearer sk_live_…\n```\n\nCreate and manage keys in the playground under 「API 金鑰」 (API Keys).\nAll keys share the `sk_live_…` prefix; free-plan keys are subject to a daily\ngeneration limit, while paid-plan usage counts against the key's monthly character cap."}},"schemas":{"SpeechRequest":{"type":"object","description":"Choose exactly **one** voice mode — they are mutually exclusive:\n\n- `voice` — a preset voice slug from `GET /v1/voices` (recommended; all preset\n  voices are Cantonese-optimized 香港聲線). Requires the `tts:generate` scope.\n- `design` — describe a synthetic voice by `gender` + `age`. Accepted with the\n  `tts:generate` scope **or** the finer-grained `tts:design` scope (either one\n  suffices, so existing `tts:generate` keys keep working unchanged).\n- `clone` — clone a voice from a base64 `reference_audio` sample (requires the\n  `tts:clone` key scope). Only `reference_audio` is needed; the legacy\n  `prompt_text` field is retired (any value is ignored).\n\nSending more than one of `voice` / `design` / `clone` returns `400 voice_mode_conflict`.","properties":{"model":{"type":"string","enum":["cantogen-tts"],"default":"cantogen-tts","description":"The TTS model. Currently only `cantogen-tts` exists; other values return `400 invalid_model`."},"voice":{"type":"string","description":"Preset voice slug from `GET /v1/voices`. Required unless `design` or `clone` is used."},"text":{"type":"string","minLength":1,"maxLength":500,"description":"The text to speak (1–500 characters on the async endpoint; the sync `POST /v1/audio/speech` caps at 300 billable characters). Cantonese text works best — 廣東話字正腔圓. Supports inline tone tags: insert `[label]` in the script, e.g. `[笑聲]你好[唷!]` — closed set of 15 tags (see the API guide, Inline tone tags). Unknown bracket tokens pass through untouched."},"pitch":{"type":"integer","minimum":-12,"maximum":12,"default":0,"deprecated":true,"description":"DEPRECATED (2026-09-20) — no effect under raw audio delivery. Still accepted with unchanged range validation (−12…+12 semitones, default 0), but the value is ignored."},"volume":{"type":"number","minimum":0,"maximum":10,"default":1,"deprecated":true,"description":"DEPRECATED (2026-09-20) — no effect under raw audio delivery. Still accepted with unchanged range validation (0–10, default 1), but the value is ignored."},"emotion":{"type":"string","enum":["happy","sad","angry","fearful","disgusted","surprised","neutral","excited","gentle"],"description":"Optional speaking emotion/style."},"style":{"type":"string","maxLength":120,"example":"brisk","description":"Delivery style. Canonical values are English slugs — rhythm: steady / composed / rapid / slow / drawl / brisk / dynamic / staccato; timbre: mellow / cute / sharp / lazy / breathy / hoarse / deep / crisp / feeble / trembling; scene: corporate / promo / wedding / guide / expo / ad / tutorial / news / travel / audiobook / latenight / product / fairytale / podcast / storytelling / sports; character: animation-hero / stylish-uncle / astronaut / support-agent / boss / grandma / lottery-host / news-anchor / sage / late-dj / teacher / storyteller / market-commentator / sports-commentator / girl-next-door / smart-kid. The original Chinese preset values (明快 / 圓潤 / 講古 …) are still accepted as aliases — both are normalized internally to the same canonical value before reaching the engine. Any other free text also passes through. Merged into the engine style slot (order: style → emotion → instruction)."},"instruction":{"type":"string","maxLength":500,"example":"用興奮嘅語氣講，好似慶祝生日咁","description":"Free-text delivery direction in Cantonese, Chinese or English — e.g. 「用興奮嘅語氣講，好似慶祝生日咁」. Applied after `style` and `emotion`, so it has the final say on how the line is delivered. Values over 500 characters return `400 invalid_instruction`."},"response_format":{"type":"string","enum":["mp3","wav"],"default":"mp3","description":"Audio container format. Other formats return `400 unsupported_format`."},"srt":{"type":"boolean","default":false,"description":"Also generate SRT subtitles. When `true`, a completed job's `srt` field carries the subtitle content (billed at a higher character rate)."},"seed":{"type":"integer","description":"Reserved. Accepted and validated as an integer but not yet used for reproducibility."},"sample_rate":{"type":"integer","description":"Reserved. Accepted and validated as an integer but not yet applied."},"design":{"$ref":"#/components/schemas/DesignObject"},"clone":{"$ref":"#/components/schemas/CloneObject"}},"required":["text"]},"DesignObject":{"type":"object","description":"Describe a synthetic voice instead of picking a preset. Accepted with the `tts:generate` or the `tts:design` key scope (either one suffices).","properties":{"gender":{"type":"string","enum":["male","female"],"description":"Voice gender."},"age":{"type":"string","enum":["child","teen","young","adult","midage","elder"],"description":"Voice age range."}},"required":["gender","age"]},"CloneObject":{"type":"object","description":"Clone a voice from a reference sample. Requires an API key with the `tts:clone` scope.","properties":{"reference_audio":{"type":"string","description":"Base64 audio sample (data URL such as `data:audio/wav;base64,…` or raw base64). Use a clean sample of at least 8 seconds — shorter samples are rejected; long samples are auto-trimmed to ~15–20 s on sentence boundaries."}},"required":["reference_audio"]},"JobStatus":{"type":"object","description":"State of a speech job. Returned by `GET /v1/audio/jobs/{id}` (200) and as the `202` body of `POST /v1/audio/jobs`. Async generation is submitted via `POST /v1/audio/jobs`; the sync `POST /v1/audio/speech` returns audio bytes directly (200).","properties":{"job_id":{"type":"string","description":"Job id (UUID)."},"status":{"type":"string","enum":["queued","processing","cancelling","completed","failed","cancelled"],"description":"Job lifecycle state. `cancelling` is transient — after `DELETE /v1/audio/jobs/{id}` on an in-flight job it settles as `cancelled`."},"poll_url":{"type":"string","description":"Relative URL of the job poll endpoint for this job."},"srt_requested":{"type":"boolean","description":"Whether the job was submitted with `srt: true`."},"srt":{"type":"string","description":"SRT subtitle content. Present only when subtitles were requested and the job has completed with captions materialized. Additive — its absence never affects other fields."},"audio_url":{"type":"string","format":"uri","description":"Pre-signed download URL for the generated audio. Present only once `status` is `completed` — URLs from the materialized library are valid for 1 hour, worker URLs (not yet materialized) for 7 days. Additive — its absence never affects other fields."}},"required":["job_id","status","poll_url","srt_requested"]},"Voice":{"type":"object","description":"A preset voice. All official voices are Cantonese-optimized (廣東話優化).","properties":{"slug":{"type":"string","description":"Use as the `voice` parameter of `POST /v1/audio/speech` (sync) or `POST /v1/audio/jobs` (async)."},"name":{"type":"string","description":"Display name (Chinese name as-is)."},"gender":{"type":"string","description":"Voice gender."},"age":{"type":"string","description":"Voice age range."},"styles":{"type":"array","items":{"type":"string"},"description":"Supported speaking styles/emotions."},"preview_url":{"type":"string","description":"URL streaming a short preview sample (no extra auth needed)."}},"required":["slug","name"]},"TranscriptionRequest":{"type":"object","description":"Transcribe audio to text","properties":{"file":{"type":"string","format":"binary","description":"The audio file to transcribe (wav, mp3, m4a/mp4, webm, ogg, flac). Max 20 MB."},"model":{"type":"string","description":"Transcription model name. Required by the API shape; any non-empty value is accepted — the current release offers a single Cantonese-optimized transcription model (`cantogen-asr`)."},"language":{"type":"string","description":"Optional ISO-639-1 language hint. Accepted as-is; transcription is currently Cantonese-optimized."},"response_format":{"type":"string","enum":["json","text"],"default":"json","description":"`json` (default) returns the structured object; `text` returns plain text. Segment-timestamp formats (`srt`, `verbose_json`) are not yet supported and return `400 unsupported_response_format`."}},"required":["file","model"]},"Transcription":{"type":"object","description":"Transcription succeeded.","properties":{"id":{"type":"string","description":"Transcription request id (UUID)."},"object":{"type":"string","enum":["transcription"],"description":"Always `transcription`."},"text":{"type":"string","description":"The transcribed text."},"duration_seconds":{"type":"number","description":"Audio duration in seconds (rounded to 2 decimals) — the billing basis."},"charged_hkd":{"type":"number","description":"HKD actually settled for this request (success-only billing, per-second, no rounding up). Additive — its absence never affects other fields."}},"required":["id","object","text"]},"Balance":{"type":"object","description":"Prepaid balance and rates.","properties":{"object":{"type":"string","enum":["balance"],"description":"Always `balance`."},"currency":{"type":"string","enum":["HKD"],"description":"Prepaid-balance currency."},"balance_hkd":{"type":"number","description":"The current prepaid balance in HKD (2-decimal display; test keys report the test-environment balance)."},"asr":{"type":"object","description":"Get prepaid balance","properties":{"batch_rate_hkd_per_hour":{"type":"number","description":"File transcription rate: HKD per audio hour (currently 1.4)."},"realtime_rate_hkd_per_hour":{"type":"number","description":"Realtime dictation rate: HKD per audio hour (currently 2.8 — 2× the file transcription rate)."},"billed_unit":{"type":"string","enum":["audio_second"],"description":"Billing unit — per audio second, no rounding up."}},"required":["batch_rate_hkd_per_hour","realtime_rate_hkd_per_hour","billed_unit"]},"auto_topup":{"type":"object","properties":{"enabled":{"type":"boolean","description":"Whether auto top-up is enabled."},"threshold_hkd":{"type":"number","description":"Balance threshold (HKD) that triggers a top-up."},"amount_hkd":{"type":"number","description":"Top-up amount (HKD)."}},"required":["enabled","threshold_hkd","amount_hkd"]}},"required":["object","currency","balance_hkd","asr","auto_topup"]},"Error":{"type":"object","properties":{"error":{"type":"string","description":"Stable machine-readable error code."},"message":{"type":"string","description":"Optional human-readable explanation."}},"required":["error"]}}},"security":[{"ApiKeyAuth":[]}],"x-realtime-websocket":{"url":"wss://api.canto-gen.com/v1/audio/realtime","protocol":"OpenAI Realtime base + documented Canto-Gen extensions","description":"Canto-Gen also exposes a WebSocket realtime endpoint for text-in → streamed PCM audio-out. The protocol is based on OpenAI Realtime; Canto-Gen extension events/fields are marked as such.","auth":{"header":"Authorization: Bearer sk_…","queryFallback":["?api_key=","?token="],"scope":"tts:generate","note":"Same `sk_` API key as REST (scope `tts:generate`). Prefer the `Authorization: Bearer sk_…` header; the browser WebSocket API cannot set custom headers, so browsers may fall back to the query parameter `?api_key=` (alias `?token=`). Query parameters are written to URLs, server logs and browser history, so keep them browser-only; server-side code must always use the header."},"clientEvents":[{"type":"session.update","origin":"openai","description":"Open/update the session: voice, language, output_audio_format, max_session_chars, tts.segmentation."},{"type":"conversation.item.create","origin":"openai","description":"Send one explicit utterance (input_text); triggers synthesis."},{"type":"input_text.append","origin":"cantogen-extension","description":"Raw token deltas — stream as you generate."},{"type":"input_text.commit","origin":"cantogen-extension","description":"Force a segment boundary (flush now)."},{"type":"response.create","origin":"openai","description":"Trigger synthesis."},{"type":"response.cancel","origin":"openai","description":"Barge-in: stop the current utterance and refund it."},{"type":"session.close","origin":"cantogen-extension","description":"Close the session and settle."}],"serverEvents":[{"type":"session.created","origin":"openai","description":"Session opened; carries the server-minted session id."},{"type":"session.updated","origin":"openai","description":"Config ack."},{"type":"response.created","origin":"openai","description":"An utterance begins."},{"type":"response.output_audio.delta","origin":"openai","description":"A base64 PCM16 @ 48 kHz audio chunk."},{"type":"response.output_audio.done","origin":"openai","description":"Utterance audio complete."},{"type":"response.done","origin":"openai","description":"Utterance done; usage carries input_chars / billed_chars / remaining (extension fields)."},{"type":"error","origin":"openai","description":"{type, code, message, http_status}."}],"segmentationNote":"`tts.segmentation` (extension) controls segmentation: `{\"mode\":\"server\"}` (default) splits on punctuation server-side, so the client just keeps appending tokens; `{\"mode\":\"client\"}` lets the client mark boundaries with `conversation.item.create` / `input_text.commit`.","usageExtensionFields":["input_chars","billed_chars","remaining"],"billingNote":"Billing reuses the same character meter, per utterance (reserve → complete/refund); `response.done` returns the utterance usage and remaining balance.","limits":{"idle_timeout_seconds":"120","max_session_duration_seconds":"300 (free) / unlimited (paid)","max_sessions_per_user":"free 1 / lite 2 / starter 3 / pro 4 / studio 5 / enterprise 5","max_sessions_global":"8","max_session_chars_default":"2000","max_in_flight_utterances":"3","raw_chars_per_session":"20000","raw_chars_per_minute":"5000","utterance_lease_seconds":"60","max_frame_bytes":"65536"},"errors":[{"code":"invalid_api_key","http_status":401},{"code":"rate_limit_exceeded","http_status":429},{"code":"sandbox_daily_limit","http_status":429},{"code":"monthly_cap_reached","http_status":429},{"code":"character_limit_reached","http_status":402},{"code":"concurrency_limit_reached","http_status":429},{"code":"session_char_limit_exceeded","http_status":403},{"code":"raw_limit_exceeded","http_status":413},{"code":"text_too_long","http_status":400},{"code":"invalid_session","http_status":400},{"code":"engine_unavailable","http_status":503}],"example":"// npm i ws\nconst WebSocket = require(\"ws\");\nconst ws = new WebSocket(\"wss://api.canto-gen.com/v1/audio/realtime\", {\n  headers: { Authorization: `Bearer ${process.env.CANTOGEN_API_KEY}` },\n});\nws.on(\"open\", () => {\n  ws.send(JSON.stringify({ type: \"session.update\", session: { voice: \"sum\", max_session_chars: 500 } }));\n  ws.send(JSON.stringify({ type: \"conversation.item.create\",\n    item: { type: \"message\", content: [{ type: \"input_text\", text: \"你好，歡迎使用港嘢！\" }] } }));\n});\nws.on(\"message\", (raw) => {\n  const ev = JSON.parse(raw.toString());\n  if (ev.type === \"response.output_audio.delta\") play(Buffer.from(ev.delta, \"base64\")); // PCM16 @ 48 kHz\n  if (ev.type === \"response.done\") ws.close();\n});"}}