Guides
.md ↗

Conversations

POST /v1/converse completes the open turn of a conversation — authored speech, contextual TTS, and spoken history.

POST /v1/converse is the API's core. You send one conversation — a list of turns, oldest first — and the model completes its last turn. There is no separate "target text" or "target speaker" field; the last turn is the request.

A turn has three fields, all combining naturally:

json
{ "speaker": "0", "text": "…", "audio_wav_b64": "…" }

The rules:

  • Every turn except the last is grounded history: it must carry text and/or audio_wav_b64.
  • The last turn is the open turn — the one to complete. speaker alone means the model authors the turn; speaker + text means it renders exactly that text. An empty text ("") counts as omitted — it flips the request to authoring, so don't send the field unless it has content.
  • audio_wav_b64 is never used on the open turn: together with text it's a 400 (nothing left to generate); alone it is discarded unread — not decoded, not billed — and the turn is authored as if it were speaker-only. Audio belongs on history turns.

Author the next turn

An open turn with only a speaker asks the model to write and voice it:

bash
curl -s https://api.kalpalabs.ai/v1/converse \
  -H "Authorization: Bearer $KALPA_API_KEY" -H 'Content-Type: application/json' \
  -d '{
    "conversation": [
      {"speaker": "0", "text": "Did you end up trying that recipe?"},
      {"speaker": "1", "text": "I did — the timing was the hard part."},
      {"speaker": "0"}
    ]
  }'

The reply carries the authored text and its audio:

json
{
  "request_id": "…",
  "model": "kalpa-tts-beta-v0.1",
  "reply": {
    "speaker": "0",
    "text": "Same here. Did you keep the flame low like she said?",
    "audio": { "format": "wav", "sample_rate": 24000, "audio_quality": "high", "data_b64": "…" }
  },
  "usage": { "input_chars": 71, "input_audio_seconds": 0.0, "output_audio_seconds": 2.4 },
  "meta": { "mode": "converse_author", "generate_ms": 812.4, "frames": 30, "duration_sec": 2.4, "context_turns": 2 }
}

Contextual TTS: render exact text, in context

An open turn with speaker and text renders exactly that text, conditioned on everything before it — same voice, continued rhythm. This is how you re-voice an edited turn or drive a scripted dialogue:

json
{
  "conversation": [
    {"speaker": "0", "text": "Welcome back to the show.", "audio_wav_b64": "<reply.audio.data_b64 from an earlier call>"},
    {"speaker": "1", "text": "Glad to be here."},
    {"speaker": "0", "text": "Let's pick up where we left off — episode twelve."}
  ]
}

The first turn's audio_wav_b64 is a complete WAV from an earlier reply.audio.data_b64. It establishes speaker "0"'s voice; the model then speaks the final, text-only open turn in that voice. Text-only history still supplies linguistic context, but it cannot by itself identify a particular voice.

Spoken history and reference audio

Any history turn may carry audio_wav_b64 — base64 16-bit PCM WAV, up to 25 MiB decoded per turn. Any sample rate is accepted and multi-channel audio is mixed down to mono; a data: URI prefix is tolerated. Two uses:

  • Spoken history: pass the actual audio of earlier turns (yours or previous API replies) so the model hears the conversation instead of just reading it.
  • Reference voice: open a conversation with a turn containing a short clip of a voice, then have that speaker complete the open turn — the model continues in that voice.

usage.input_audio_seconds meters the audio you send; see Usage.

Speaker labels

speaker values are positional role labels, not names — the labels the model was trained on, listed per model in GET /v1/models (the current conversational models use "0" and "1", in turn order). Two things matter:

  • Keep a label bound to one voice within a conversation: "0" is whoever spoke first, "1" the other party.
  • An omitted speaker defaults to "0" — on every turn. Fine for single-speaker requests; in a dialogue, set it explicitly or all turns silently collapse into one speaker.

Streaming the reply

POST /v1/converse/stream takes exactly the same request body as /v1/converse and streams the reply as server-sent events instead of one JSON response, so playback can start after the first ~80 ms audio frame. The stream is one meta event, audio events as audio is generated, then exactly one end — or one error:

text
event: meta
data: {"model":"kalpa-tts-beta-v0.1","mode":"converse_author","sample_rate":24000,"audio_quality":"high","context_turns":2}

event: audio
data: {"pcm_b64":"…","frame":1}

event: audio
data: {"pcm_b64":"…","frame":3}

event: end
data: {"request_id":"…","text":"Same here. Did you keep the flame low?","sample_rate":24000,"audio_quality":"high","audio_seconds":2.4,"usage":{"input_chars":71,"input_audio_seconds":0.0,"output_audio_seconds":2.4},"meta":{"mode":"converse_author","generate_ms":812.4,"frames":30,"duration_sec":2.4,"context_turns":2}}
  • Each audio event's pcm_b64 is base64 raw 16-bit little-endian PCM, mono, at meta.sample_rate — no WAV header (the same chunks the WebSocket emits). Decode each chunk and append: the concatenation, in order, is the full waveform. frame is the cumulative model-frame count (12.5 frames = 1 s).
  • end carries the reply text (authored text is only known at the end), the final usage, and diagnostic meta. There is no data_b64 — the audio already streamed past you.
  • A request that fails before any generation — validation, auth, an unreachable backend — is a plain JSON error response in the standard envelope, not an event stream. A failure mid-stream ends the stream with an error event: {"error": "…"}. A stream that ends with neither end nor error was cut by the network — treat it as failed.
  • Closing the connection aborts generation. Only streams that reach end are metered; an aborted stream bills nothing.

Limits

CapValueError when exceeded
Turns per conversation64422 invalid_request
Text per turn8,000 characters422 invalid_request
Audio per turn25 MiB decoded WAV400 invalid_request

Schema-bound turn and text limits fail before generation; audio decoding and semantic turn-rule failures return 400 invalid_request. See Rate limits & errors.

For live turns, use the stateful WebSocket. It keeps the conversation history for the connection and streams audio as it is generated. Put the same generation params used by REST/SSE in initializeConnection; they apply to every response on the socket. Set optional history_limit to retain only that many completed turns when a response starts. responseDone carries the final usage and public timing metadata.