Continuations

View as Markdown
Real-Time

By default, every WebSocket request is its own independent generation — send text + voice_id, get back audio, done. That’s fine for one-shot utterances, but text arriving incrementally (LLM token streams, live-typed captions) usually needs to land as one utterance, not a string of separately-generated fragments with a prosody reset between each.

Send the same context_id on a sequence of fragments to have them buffered, joined at natural sentence boundaries, and spoken as one continuous generation — each new fragment in the context is primed with the audio from the one before it, so pacing and intonation carry across chunk boundaries.

Endpoint support

Continuations are WebSocket-only:

Surfacecontext_id
POST /waves/v1/tts (sync HTTP)Not supported
POST /waves/v1/tts/live (HTTP SSE)Not supported
wss://api.smallest.ai/waves/v1/tts/live (WebSocket)Supported

When to use it

  • Text is arriving incrementally from an LLM and you want to start speaking before the model has finished generating the full response, without a flat, reset-per-chunk cadence.
  • You’re already using continue + flush (the legacy buffer) for streamed input but want the server — not a fixed timer — to decide where sentences end.
  • You want fragments billed and rate-limited as one concurrent call, not one per chunk.

If you have the complete text up front, skip this — a single request already produces one continuous generation.

Parameters

ParameterTypeDefaultDescription
context_idstringGroups fragments into one continuation. Alphanumeric, -, _, . only; max 128 chars.
continuebooleanfalseWith context_id set: true means more fragments are coming for this context. Send continue: false (optionally with no text) to close it out.
max_buffer_delay_msinteger3000Upper bound (0–5000 ms) on how long a fragment waits for a clean sentence boundary before it’s spoken anyway.
context_closebooleanfalseEnds the context immediately — releases buffered text and drops the carried audio state right away instead of waiting out its idle timeout. May be sent without text / voice_id.

context_id cannot be combined with flush or max_buffer_flush_ms — those belong to the older, timer-based buffering contract. Mixing them is a validation error. Close out a continuation with continue: false or context_close: true instead.

How buffering works

A fragment sent with context_id is released to the synthesizer as soon as any one of these is true:

  1. It arrives with continue: false — no more input is coming.
  2. The buffered text ends a sentence (terminal punctuation . ? ! …, guarding against decimals, thousands separators, and abbreviations like “Mr.”) and is long enough that speaking it doesn’t sound abruptly clipped.
  3. max_buffer_delay_ms elapses since the first still-buffered fragment — the clock doesn’t restart as more fragments arrive.
  4. The buffer grows past the max chunk size — it’s split at the last sentence boundary or last space within that window.

Ending a continuation

Three ways to close a context, depending on why you’re closing it:

  • Natural end of input — send continue: false on the last fragment. It may carry no text at all: {"context_id": "call-1", "voice_id": "meher", "continue": false}.
  • Immediate teardown — send context_close: true. Use this when you know no more text is coming and want the server to drop the context’s carried state right away rather than at its idle timeout. May omit text / voice_id.
  • Barge-incancel_request: true discards whatever is currently buffered for the context without speaking it, but does not end the context itself; you can keep streaming into the same context_id afterward.

Closing the WebSocket connection also ends every open context on it (nothing buffered is flushed — there’s nothing left to bill or play).

If a context has nothing buffered when you send its closing frame (continue: false with no text, or context_close: true) — for example, everything already released earlier via a sentence boundary or max_buffer_delay_ms — the server sends back no frame at all for that message. Don’t block waiting on a response to the closing frame itself.

Response frames during a continuation

Verified against a live connection: status: "complete" behaves differently here than it does for a one-shot request.

  • One chunk/complete pair per released segment, not one per context. Every time buffered text is released — on a sentence boundary, on max_buffer_delay_ms, or on the frame that closes the context — you get its own run of chunk frames followed by a complete. A context fed in one long burst typically collapses to a single release (and a single complete), but a context spread across multiple flushes emits multiple complete frames while the context is still open.
  • complete does not close the WebSocket while the context is open. Unlike a plain non-continuation request (see Response Format, where complete is terminal and the server closes the connection), a mid-context complete just marks that one release as done — the connection stays open and you can send more fragments on the same context_id afterward.
  • session_id is stable for the whole connection; request_id changes per released segment. Use session_id if you need to correlate frames back to the connection; don’t assume one request_id spans an entire context.
  • No context_id is echoed back on any response frame. If you multiplex more than one context_id on a single connection, track which context a frame belongs to by send order — don’t rely on the response to disambiguate.
  • No frame marks “the context is fully done.” A complete after a sentence-boundary or max_buffer_delay_ms release looks identical to one after your closing frame (continue: false / context_close: true). Don’t return on the first complete you see — drain frames until the connection goes idle (or you close it yourself once you’ve accounted for every fragment you sent).

Concurrency and billing

Fragments sharing one context_id on the same connection occupy a single concurrency slot, not one per fragment — chunking your input more finely doesn’t cost you additional concurrent-call capacity. Billing still applies per generated segment through the normal path.

Example

1# ci:skip — illustrates a message sequence, not a runnable standalone script
2import asyncio
3import json
4import os
5
6import websockets
7
8API_KEY = os.environ["SMALLEST_API_KEY"]
9WS_URL = "wss://api.smallest.ai/waves/v1/tts/live"
10
11async def stream_with_continuation(fragments):
12 async with websockets.connect(
13 WS_URL,
14 additional_headers={"Authorization": f"Bearer {API_KEY}"},
15 ) as ws:
16 for i, text in enumerate(fragments):
17 is_last = i == len(fragments) - 1
18 await ws.send(json.dumps({
19 "context_id": "call-1",
20 "voice_id": "meher",
21 "model": "lightning_v3.1_pro",
22 "text": text,
23 "continue": not is_last,
24 "max_buffer_delay_ms": 1500,
25 }))
26
27 # A context can release audio in more than one segment — each gets
28 # its own chunk/complete pair, and there's no "final complete"
29 # flag on the wire. Keep draining until the socket goes quiet
30 # rather than returning on the first complete, or you can drop
31 # whatever's still in flight for a later segment.
32 while True:
33 try:
34 data = json.loads(await asyncio.wait_for(ws.recv(), timeout=5))
35 except asyncio.TimeoutError:
36 break
37 # handle "chunk" frames; "complete" marks one released segment, not necessarily the whole context
38
39asyncio.run(stream_with_continuation([
40 "Let me check that for you.",
41 " Your order ships tomorrow",
42 " and arrives by Friday.",
43]))

The three fragments above share context_id: "call-1", so they’re buffered and joined instead of spoken as three separately-paced generations — in practice this reliably collapses to a single release when fragments arrive close together, but the receive loop doesn’t assume that.

  • Streaming — WebSocket vs SSE, response frame shapes, and the legacy continue + flush buffer this feature supersedes for incremental input.
  • Word-level timestamps — another WebSocket-only opt-in feature, combinable with continuations.