Realtime TTS
Stream text-to-speech audio with the public Realtime TTS WebSocket v2 protocol.
Realtime TTS
Realtime TTS v2 is the canonical WebSocket protocol for new Kitta AI and Kitta Audio integrations. It streams MessagePack events over one WebSocket connection and keeps each generation tied to a caller-provided idempotency key.
Endpoints and protocol
| Brand | Endpoint |
|---|---|
| Kitta AI | wss://realtime.kittaai.com/v2/tts/live |
| Kitta Audio | wss://realtime.fishaudio.org/v2/tts/live |
| Kitta Audio CN | wss://kittaai.com/v2/tts/live |
Every connection must request the WebSocket subprotocol realtime.tts.msgpack.v2. Client and server application messages are MessagePack binary frames, not JSON text frames.
Existing /v1/tts/live integrations remain available for compatibility, but v1 is frozen. New integrations should use v2 and the contract on this page.
Site capabilities
| Site | Providers | Canonical models | Formats |
|---|---|---|---|
| Kitta AI / Kitta Audio | FishAudio | fishaudio-s21pro-flash | mp3, wav, opus |
| Kitta Audio CN | FishAudio, MiniMax, Qwen | fishaudio-s21pro-flash, minimax-2.8-turbo, qwen3-tts-flash | Provider-dependent; includes PCM |
The public voice_id must support the selected model. FishAudio supports speed, volume, language, emotion, chunk_length, and latency on CN. MiniMax supports speed, volume, language, and emotion. Qwen returns 24 kHz mono 16-bit PCM and supports language, emotion, instructions, and optimize_instructions. The server rejects combinations outside the runtime capability matrix.
Authentication
Backend clients should authenticate during the WebSocket handshake:
Authorization: Bearer API_KEY
Sec-WebSocket-Protocol: realtime.tts.msgpack.v2The browser WebSocket API cannot set an Authorization header. A trusted browser tool may connect with the v2 subprotocol and send this as its first MessagePack frame:
{ event: 'auth', token: 'API_KEY' }Send the frame before the authentication timeout. Never place an API key in the URL: token and api_key query parameters are rejected. Production browser applications should keep long-lived API keys on a backend and use a short-lived credential exchange.
Session flow
connect → authenticated → start → ready
→ text → flush → audio (one or more) → usage
→ [text → flush → audio → usage] → stop → finishOne WebSocket connection carries one generation request. stop flushes any remaining buffered text and completes the session. Reconnecting creates a new session and does not resume an interrupted provider stream.
Client events
All objects are strict and fields are case-sensitive.
| Event | MessagePack object |
|---|---|
auth | { event: 'auth', token } — browser first-frame authentication only |
start | { event: 'start', request_id, request } — exactly once after auth |
text | { event: 'text', text } — appends text to the current segment |
flush | { event: 'flush' } — submits one non-empty, independently billed segment |
stop | { event: 'stop' } — flushes remaining text and completes the session |
ping | { event: 'ping', timestamp? } — returns pong |
request_id is required on start, must contain 1–128 characters, and is returned unchanged on request-level server events. It is also the idempotency key: after a v2 session has been created for an account, reusing the same request_id is rejected with invalid_state.
start.request parameters
| Field | Required | Type and constraints |
|---|---|---|
voice_id | Yes | Public voice UUID returned by the voice catalog |
model | Yes | Public engine model ID; currently fishaudio-s21pro-flash overseas |
format | No | mp3 (default), wav, or opus overseas; pcm on capable sites |
speed | No | Number from 0.5 to 2 |
volume | No | Number from 0 to 10 |
language | No | Language hint, 1–32 characters |
chunk_length | No | Integer from 50 to 1000 |
latency | No | normal or balanced for the overseas Kitta Audio endpoint |
emotion | No | Reserved by v2; currently rejected by the overseas endpoint |
instructions | No | Reserved by v2; currently rejected by the overseas endpoint |
optimize_instructions | No | Reserved by v2; currently rejected by the overseas endpoint |
Unsupported fields or provider combinations return unsupported_parameter or model_not_found; they are never silently ignored.
Example:
{
event: 'start',
request_id: crypto.randomUUID(),
request: {
voice_id: '00000000-0000-0000-0000-000000000000',
model: 'fishaudio-s21pro-flash',
format: 'mp3',
speed: 1,
volume: 0,
language: 'en',
chunk_length: 200,
latency: 'normal'
}
}Server events
| Event | Important fields |
|---|---|
authenticated | session_id, site, protocol_version, quota |
ready | request_id, session_id, provider, model, voice_id, format, PCM metadata |
audio | request_id, session_id, segment_id, increasing sequence, binary audio, format, PCM metadata |
usage | request_id, segment_id, segment_units, session_units, quota_remaining, audio_bytes |
warning | request_id?, code, message |
error | request_id?, session_id?, stable code, message, retryable |
finish | request_id, session_id, reason, units; terminal reason is completed, error, timeout, or server_draining |
pong | session_id, request_id?, timestamp |
audio.audio is a byte array inside the MessagePack frame. Do not decode it as Base64. Concatenate audio frames in sequence order for the selected output format. When format is pcm, both ready and audio include the required sample_rate, channels, and bit_depth fields so the client can construct a WAV header or initialize a PCM player.
Error codes
Every error event has a stable code, a safe message, and retryable. The current codes are:
| Category | Codes |
|---|---|
| Authentication | authentication_failed, authentication_required, already_authenticated, permission_denied |
| Message/state | invalid_message, invalid_messagepack, invalid_state, empty_segment, segment_too_large |
| Limits | session_limit_exceeded, quota_exceeded, client_backpressure |
| Capability | unsupported_parameter, voice_not_found, model_not_found |
| Provider | provider_unavailable, provider_timeout, generation_failed |
| Lifecycle | billing_failed, connection_closed, internal_error |
Minimal Node.js client
Install ws and @msgpack/msgpack, then wait for each protocol phase:
import { randomUUID } from 'node:crypto';
import { writeFileSync } from 'node:fs';
import { decode, encode } from '@msgpack/msgpack';
import WebSocket from 'ws';
const requestId = randomUUID();
const audio = [];
const ws = new WebSocket('wss://realtime.fishaudio.org/v2/tts/live', 'realtime.tts.msgpack.v2', {
headers: { Authorization: `Bearer ${process.env.API_KEY}` },
});
ws.on('message', (frame) => {
const event = decode(frame);
if (event.event === 'authenticated') {
ws.send(
encode({
event: 'start',
request_id: requestId,
request: {
voice_id: process.env.VOICE_ID,
model: 'fishaudio-s21pro-flash',
format: 'mp3',
},
}),
);
} else if (event.event === 'ready') {
ws.send(encode({ event: 'text', text: 'Hello from Realtime TTS v2.' }));
ws.send(encode({ event: 'flush' }));
ws.send(encode({ event: 'stop' }));
} else if (event.event === 'audio') {
audio.push(event.audio);
} else if (event.event === 'error') {
console.error(event.code, event.message, event.retryable);
} else if (event.event === 'finish') {
writeFileSync('realtime-output.mp3', Buffer.concat(audio.map((chunk) => Buffer.from(chunk))));
console.log({ requestId: event.request_id, units: event.units, chunks: audio.length });
ws.close(1000);
}
});Limits, billing, and retries
Each non-empty flush creates an independently billed segment. Quota is reserved before provider generation and committed when the first valid audio frame is delivered. A segment that fails before audio delivery is refunded. Once audio has been delivered, do not automatically replay that segment because doing so can duplicate both audio and usage.
The protocol defaults are 2,000 characters per text event, 10,000 characters per flushed segment, and 100,000 billable units per session. Deployments can enforce a lower session or concurrency limit. Treat server errors as authoritative because limits may evolve.
Retry only when retryable is true or the connection closes with 1012 or 1013. Use exponential backoff with jitter. Generate a new request_id only for a new user-intended generation; do not bypass duplicate protection to replay an uncertain request.
Handshake failures are HTTP responses before 101 Switching Protocols. Session failures are MessagePack error events followed by a WebSocket close.