Streaming¶
Real-time streaming inference for TTS and ASR pipelines (and, coming soon, the on-device LLM).
Overview¶
TTS streaming yields audio chunks as sentences are synthesized — much lower time-to-first-audio than batch mode. Streaming ASR is the inverse direction: push microphone audio in, read transcripts out. LLM streaming (decoded token text chunk by chunk) ships with the on-device chat LLM — coming soon; a remote LLM response works as a push source today.
Streaming and batch inference use the same model bundle — no extra setup.
Just use infer_stream instead of infer, or pull a push-based
TTSStreamer via TheStageAI.open_tts_streamer(...) (see
Engine Requirements below).
Usage Guides¶
TTS Streaming¶
Kotlin — Simple Consumer
// build.gradle.kts: implementation(files("libs/TheStageCore.aar"))
import ai.thestage.qlip.TheStageAI
// Inside a coroutine.
TheStageAI.registerContext(context)
TheStageAI.initialize(api_token = "your_api_token")
TheStageAI.start_model(
model_name = "tts",
engines_path = "TheStageAI/neutts-multilingual",
config = mapOf("voice_id" to "dave"),
revision = "android"
)
TheStageAI.infer_stream(
model_name = "tts",
input_json = mapOf("text" to "A long paragraph of text to speak aloud.")
).collect { chunk ->
val audio = chunk.audio ?: return@collect
play_audio(audio, chunk.sample_rate ?: 24000)
if (chunk.is_final) {
println("Decode tokens: ${chunk.generated_tokens ?: 0}")
println("Tok/s: ${chunk.tokens_per_second ?: 0.0}")
println("First chunk: ${chunk.time_to_first_token ?: 0.0}s")
println("Total wall-clock: ${chunk.total_seconds ?: 0.0}s")
}
}
Kotlin — Producer / Consumer with AudioStreamPlayer
Two concurrent coroutines: one drives TTS inference, the other plays audio as chunks arrive. This is the recommended pattern for low-latency playback.
import ai.thestage.qlip.audio.AudioStreamPlayer
import ai.thestage.qlip.audio.AudioStreamConfig
TheStageAI.start_model(
model_name = "tts",
engines_path = "TheStageAI/neutts-multilingual",
config = mapOf("voice_id" to "paul")
)
val player = AudioStreamPlayer(
AudioStreamConfig(
sample_rate = 24000,
channels = 1,
queue_capacity = 256
)
)
player.start()
TheStageAI.infer_stream(
model_name = "tts",
input_json = mapOf("text" to "Hello! This is a streaming demo with real-time audio.")
).collect { chunk ->
val audio = chunk.audio
if (audio != null && audio.isNotEmpty()) player.enqueue(audio)
}
player.drain()
player.stop()
See AudioStreamConfig Options in the API Reference below for the player’s configuration knobs.
Kotlin — Push-Based (LLM → TTS)
Feed text incrementally as it arrives from another model. The streamer splits
sentences internally and produces audio as complete sentences are ready. Use
TheStageAI.open_tts_streamer(model_name) to pull a fresh streamer per turn
— there is no need (or way) to construct TTSStreamer directly from outside
the SDK module.
TheStageAI.start_model(
model_name = "tts",
engines_path = "TheStageAI/neutts-multilingual",
config = mapOf("voice_id" to "dave")
)
val player = AudioStreamPlayer(sample_rate = 24000)
player.start()
val streamer = TheStageAI.open_tts_streamer(model_name = "tts")
// Audio consumer coroutine
val consumer = launch {
streamer.output.collect { chunk ->
val pcm = chunk.audio
if (pcm != null && pcm.isNotEmpty()) player.enqueue(pcm)
}
player.drain()
player.stop()
}
// Producer: push text deltas as they arrive from your LLM —
// e.g. a remote chat-completions stream (this is what the voice
// agent's openai_compatible provider does).
llmDeltas.collect { delta ->
if (delta.isNotEmpty()) streamer.send(delta)
}
streamer.finish() // flush tail + close output cleanly
streamer.finish() flushes any partial sentence buffered inside the
splitter before closing the output flow. Use streamer.cancel() instead if
you want to abort an in-flight turn (e.g. on barge-in) — that drops the buffer
and closes immediately.
This is exactly what TheStageVoiceAgent does internally: its TTS node
opens one push-based TTSStreamer per turn, pumps LLM deltas straight in,
and cancel()s on barge-in.
Flutter — TTS Streaming¶
Simple Usage
final stream = TheStageFlutterSDK.infer_stream(
model_name: 'tts',
input_json: {'text': 'A long paragraph of text to speak aloud.'},
);
final player = TheStageAudioPlayer(sampleRate: 24000);
await player.start();
await for (final chunk in stream) {
final audio = chunk['audio'] as Float32List?;
if (audio != null && audio.isNotEmpty) {
player.enqueue(audio);
}
if (chunk['is_final'] == true) break;
}
await player.drain();
await player.stop();
Push-Based (LLM → TTS)
final ttsStream = TheStageFlutterSDK.infer_stream(
model_name: 'tts',
input_json: {'text': ''},
stream_id: 'voice_agent_tts',
);
final player = TheStageAudioPlayer(sampleRate: 24000);
await player.start();
ttsStream.listen((chunk) {
final audio = chunk['audio'] as Float32List?;
if (audio != null) player.enqueue(audio);
});
// Push text deltas as they arrive from your LLM — e.g. a remote
// chat-completions stream.
await for (final delta in llmDeltas) {
await TheStageFlutterSDK.send(
stream_id: 'voice_agent_tts',
text: delta,
);
}
await TheStageFlutterSDK.finish_stream(stream_id: 'voice_agent_tts');
await player.drain();
await player.stop();
Streaming ASR (Speech-to-Text)¶
The inverse direction — push microphone audio in, read transcripts out — is available on Android through the Voice Agent (Voice Agent), which runs streaming ASR internally. Its ASR node re-decodes the growing turn buffer on a single serial worker and commits stable text via LocalAgreement, so partials grow monotonically and never flicker, while the authoritative end-of-turn transcript always covers the complete utterance (including the last word).
For a standalone, reusable streamer outside the voice agent, open one on a
loaded Whisper model from Kotlin (mirrors open_tts_streamer):
val asr = TheStageAI.open_asr_streamer(
model_name = "stt", language = "en",
)
scope.launch { asr.partials.collect { caption -> /* live text */ } }
for (chunk in micStream) asr.send(chunk) // 16 kHz mono FloatArray
val finalText = asr.finish() // authoritative transcript
It re-decodes the growing buffer on a single serial worker and commits stable
text via the same LocalAgreement-2 as the voice agent, so partials grow
monotonically. This is a native-Kotlin API — there is no plugin/Dart channel
route for it; from Flutter, drive TheStageVoiceAgent for live
speech-to-text or use the batch infer for one-shot transcription (see
ASR (Whisper)). Convert mic input to 16 kHz mono Float first — it is
not resampled.
Tuning the TTS Streamer¶
NeuTTSStreamConfig exposes the codec-side chunking knobs that decide
time-to-first-audio and how seams between sentences sound. Defaults match what
the SDK ships with — only override these when you need to trade latency
against smoothness.
Kotlin:
import ai.thestage.qlip.models.neutts.NeuTTSStreamConfig
val streamer = tts.open_streamer(
stream_config = NeuTTSStreamConfig(
frames_per_chunk = 25,
first_frames_per_chunk = 12, // smaller first chunk → faster first audio
lookforward = 5,
lookback = 50,
overlap_frames = 1
)
)
// Or via the singleton:
val s2 = TheStageAI.open_tts_streamer(
model_name = "tts",
stream_config = NeuTTSStreamConfig(first_frames_per_chunk = 12)
)
infer_stream(text, stream_config) accepts the same struct when you already
have the full text up front.
Flutter — pass a nested stream_config map inside input_json:
final stream = TheStageFlutterSDK.infer_stream(
model_name: 'tts',
input_json: {
'text': 'Hello, world.',
'stream_config': {
'first_frames_per_chunk': 12,
'frames_per_chunk': 25,
'lookforward': 5,
'lookback': 50,
'overlap_frames': 1,
},
},
);
Cheat-sheet — the knobs above at a glance:
Knob |
Effect |
When to change |
|---|---|---|
|
Codec frames in the first emitted chunk |
Shrink it when time-to-first-audio matters more than a slightly shorter opening chunk |
|
Frames per emitted chunk after the first — larger means fewer, longer chunks |
Raise for smoother, less chatty output; lower for finer-grained delivery |
|
Future frames decoded with each chunk to stabilise the seam |
Raise if chunk seams sound unstable |
|
Past frames re-decoded for context when bridging chunks |
Raise if chunk boundaries are audible |
|
Frames of crossfade between consecutive chunks |
Raise if you hear clicks at chunk boundaries |
See TTS (NeuTTS) (Streaming Hyperparameters) for the full field reference.
API Reference¶
AudioStreamConfig Options¶
Parameter |
Type |
Default |
Description |
|---|---|---|---|
|
|
24000 |
Audio sample rate in Hz |
|
|
1 |
Number of audio channels |
|
|
256 |
Bounded PCM queue depth feeding the writer thread |
|
|
false |
Route playback as voice communication so the platform
|
The player is backed by an Android AudioTrack in MODE_STREAM;
enqueue is non-blocking and drain() (suspend) waits until everything
queued and buffered has actually played out.
Flutter API Reference¶
Lifecycle
await TheStageFlutterSDK.initialize(api_token: 'your_token');
await TheStageFlutterSDK.start_model(
model_name: 'tts',
engines_path: 'TheStageAI/neutts-multilingual',
model_type: 'neutts-multilingual',
revision: 'android',
config: {'voice_id': 'dave'},
);
await TheStageFlutterSDK.stop_model(model_name: 'tts');
Streaming
final stream = TheStageFlutterSDK.infer_stream(
model_name: 'tts',
input_json: {'text': 'Hello world.'},
);
await TheStageFlutterSDK.send(stream_id: id, text: 'more text');
await TheStageFlutterSDK.finish_stream(stream_id: id);
await TheStageFlutterSDK.stop_stream(stream_id: id);
Audio Player
final player = TheStageAudioPlayer(sampleRate: 24000);
await player.start();
player.enqueue(audioData);
await player.pause();
await player.resume();
await player.drain();
await player.stop();
Stream Chunk Format¶
For the SDK-wide audio format contract (sample rates, mono Float, frame sizes for VAD vs ASR vs TTS) see TheStage Android SDK (Audio I/O Contract).
TTS Chunks
Field |
Type |
Description |
|---|---|---|
|
|
PCM audio samples, 24 kHz mono, normalized to |
|
|
Always 24000 |
|
|
Sequential chunk number |
|
|
|
|
|
Seconds to first audio chunk |
|
|
Decode step count (excludes prefill) |
|
|
Decode speed: |
|
|
Wall-clock time from stream start to last chunk (final only) |
LLM Chunks (on-device chat LLM — coming soon)
Field |
Type |
Description |
|---|---|---|
|
|
Decoded token text (null on the final sentinel) |
|
|
Position in sequence |
|
|
|
|
|
Seconds to first token (final only) |
|
|
Input token count (final only) |
|
|
Output token count (final only) |
|
|
Generation speed (final only) |
|
|
Wall-clock time (final only) |
Cancellation¶
Cancel any active stream at any time:
// Kotlin — cancel the collecting coroutine, or call streamer.cancel()
// Flutter
await TheStageFlutterSDK.stop_stream(stream_id: 'my_stream');
The stream will emit a final event with kind: 'cancelled' and close.
Engine Requirements¶
Streaming and batch inference use the same model bundle — no extra setup.
Just use infer_stream instead of infer, or pull a push-based
TTSStreamer via TheStageAI.open_tts_streamer(...).
Architecture¶
TTSStreamer — Single Token Stream with Sentinels:
Producer Task Consumer Task
───────────── ─────────────
sentence_stream token_stream
│ │
▼ ▼
┌──────────┐ ┌───────────┐
│preprocess│ │is sentinel?│
└────┬─────┘ └─┬───────┬─┘
│ no yes
▼ │ │
┌──────────────┐ ▼ ▼
│decoder │ accumulate flush
│ .prefill() │ codes + fade-out
│ .decode_step│ │ + reset
│ (loop) │ ▼
└────┬─────────┘ ┌─────────┐
│ │codec.infer│
▼ │ │
yield tokens └────┬────┘
│ │
▼ ▼
yield sentinel OLA + emit
The producer runs ahead — while the consumer decodes audio for the current sentence, the producer is already preprocessing and generating tokens for the next one. This eliminates inter-sentence pauses.
Troubleshooting¶
Symptom |
Cause / Fix |
|---|---|
Push-based audio stalls / stays buffered after |
Nothing is draining the streamer. Run two concurrent
coroutines — start the consumer collecting |
Tail of the pushed text is never spoken |
The producer never signalled end of input. Call
|
Stream closes immediately, dropping queued speech |
|
Clicks or rough seams between audio chunks |
Codec-side chunking trades latency against smoothness — raise
|
First |
First run downloads and extracts the model bundle; later runs
hit the cache. Track the |