TTS (NeuTTS)¶
On-device neural text-to-speech with batch and push-based streaming. Two public pipelines:
NeuTTSMultilingual— Qwen3-based, 9 languages.NeuTTS(nano) — phoneme-based, English only, faster.
Flutter consumers go through the singleton start_model + infer /
infer_stream (JSON) path — there is no direct TTS pipeline constructor
on Dart. Both surfaces share the same on-disk cache and response shape.
Main features
Two pipelines:
NeuTTSMultilingual(Qwen3-based, 9 languages) and the faster English-onlyNeuTTSnano.Batch synthesis plus push-based streaming — pipe LLM tokens straight into TTS as they arrive.
Custom voices: ship your own
voice.jsonas app assets, selectable exactly like the built-ins.24 kHz mono PCM output in
[-1.0, 1.0], with optional resampling viaoutput_sample_rate.Deterministic synthesis with the optional
seedinput.
Supported models¶
Model |
HF Repo |
Notes |
|---|---|---|
|
|
Qwen3-based, 9 languages; phonemizes internally |
|
|
Phoneme-based, English only, faster |
The multilingual model supports:
english, french, german, spanish, portuguese,
japanese, korean, chinese, urdu
The nano variant is English-only.
API surface¶
Singleton (TheStageAI)¶
TheStageAI is a Kotlin object (singleton); its methods are
suspend.
TheStageAI.start_model(
model_name = "tts",
engines_path = "TheStageAI/neutts-multilingual",
config = mapOf("voice_id" to "paul", "language" to "english")
)
val json = TheStageAI.infer(
model_name = "tts",
input_json = mapOf(
"text" to "Hello, world!",
"seed" to 42L // optional
)
)
val audio = json[0]["audio"]
JSON response keys: audio, sample_rate (Int), duration
(Double), tokens_per_second (Double), rtf (Double),
plus per-stage timings.
JSON streaming yields typed InferenceStreamChunk values; PCM samples
live on chunk.audio:
TheStageAI.infer_stream(
model_name = "tts",
input_json = mapOf("text" to "A long paragraph of text to speak.")
).collect { chunk ->
val audio = chunk.audio
if (audio != null && audio.isNotEmpty()) {
play(audio, chunk.sample_rate ?: 24000)
}
if (chunk.is_final) return@collect
}
A push-based streamer is also reachable via the singleton:
val streamer = TheStageAI.open_tts_streamer(model_name = "tts")
// same `streamer.send(...)` / `streamer.finish()` shape as below
The Flutter TheStageFlutterSDK.infer / infer_stream calls hit
this exact JSON path, so the response keys above apply unchanged on
Dart. PCM audio crosses the platform channel as Float32List; do not
promote to Float64List.
Direct constructors¶
val tts = NeuTTSMultilingual(
engines_path = dir, // local (prefetched) dir
voice_id = "paul", // voice subfolder under voices/
language = "english" // optional language override
)
val nano = NeuTTS(
engines_path = nanoDir,
voice_id = "dave"
)
Per-component device placement (LLM on NPU, NeuCodec pre/post on CPU)
is set in the bundle’s metadata.json; when downloading through the
singleton, pass overrides via start_model(device = ..., devices = ...).
TheStageAI.initialize(...) must have succeeded before either call.
Quick start¶
Kotlin — via the singleton (recommended; auto-downloads the bundle):
// build.gradle.kts: implementation(files("libs/TheStageCore.aar"))
import ai.thestage.qlip.TheStageAI
// Inside a coroutine (initialize / start_model / infer are suspend).
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 "paul", "language" to "english")
)
val json = TheStageAI.infer(
model_name = "tts",
input_json = mapOf("text" to "Hello, world!")
)
val audio = json[0]["audio"] // 24 kHz mono PCM
val sampleRate = json[0]["sample_rate"] as Int // 24000
The direct Kotlin pipelines load from a local (already-downloaded)
engines directory — they do not download, so prefetch_model first:
import ai.thestage.qlip.models.neutts.NeuTTSMultilingual
import ai.thestage.qlip.models.neutts.NeuTTS
val dir = TheStageAI.prefetch_model(repo_id = "TheStageAI/neutts-multilingual")
val tts = NeuTTSMultilingual(
engines_path = dir,
voice_id = "paul",
language = "english"
)
val result = tts.generate(text = "Hello, world!")
val audio = result.audio // FloatArray, 24 kHz mono
val sample_rate = result.sample_rate // 24000
The English-only nano variant follows the same shape:
val nanoDir = TheStageAI.prefetch_model(repo_id = "TheStageAI/neutts")
val nano = NeuTTS(engines_path = nanoDir, voice_id = "dave")
Flutter — JSON path:
import 'package:thestage_android_sdk/thestage_android_sdk.dart';
import 'dart:typed_data';
await TheStageFlutterSDK.initialize(api_token: 'your-api-token');
await TheStageFlutterSDK.start_model(
model_name: 'tts',
engines_path: 'TheStageAI/neutts-multilingual',
config: {'voice_id': 'paul', 'language': 'english'},
);
final result = await TheStageFlutterSDK.infer(
model_name: 'tts',
input_json: {'text': 'Hello, world!'},
);
final audio = result[0]['audio'] as Float32List;
final sampleRate = result[0]['sample_rate'] as int; // 24000
Configuration¶
Inputs / Outputs¶
Direction |
Type |
Description |
|---|---|---|
input |
|
Text to synthesize. |
input |
|
Deterministic sampling. |
output |
|
24 kHz mono PCM, samples in |
output |
|
Always |
output |
|
Seconds of audio. |
output |
|
Real-time factor (duration / wall time). |
output |
|
Decode speed. |
Streaming hyperparameters¶
NeuTTSStreamConfig controls codec-side audio chunking and crossfading.
Defaults match what the SDK ships with — only override these when you
need to trade latency against smoothness.
Field |
Default |
Description |
|---|---|---|
|
|
Codec frames decoded per emitted audio chunk after the first. Larger = fewer, longer chunks. |
|
|
Frames in the first chunk; pass a smaller value (or |
|
|
Future frames decoded together with each chunk to stabilise the seam (overlap-add window). |
|
|
Past frames re-decoded for context when bridging chunks; reduces audible boundaries. |
|
|
Frames of crossfade between consecutive chunks. |
|
|
Paces streaming synthesis toward real-time to keep NPU heat/power
down; |
|
|
Resample output to this rate in Hz; |
|
model default |
Keep decoder state continuous across a request’s chunks for smoother joins. |
Generation knobs (temperature, top_k, …) live on
TTSGenerationConfig and are independent of these.
Kotlin — pass stream_config: to open_streamer or infer_stream:
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
)
)
val stream = tts.infer_stream(
text = "Hello, world.",
stream_config = NeuTTSStreamConfig(first_frames_per_chunk = 12)
)
The same knobs are exposed through the singleton:
val streamer = TheStageAI.open_tts_streamer(
model_name = "tts",
stream_config = NeuTTSStreamConfig(first_frames_per_chunk = 12)
)
Flutter — drop a stream_config map into input_json:
final stream = TheStageFlutterSDK.infer_stream(
model_name: 'tts',
input_json: {
'text': 'Hello, world.',
'stream_config': {
'frames_per_chunk': 25,
'first_frames_per_chunk': 12,
'lookforward': 5,
'lookback': 50,
'overlap_frames': 1,
},
},
);
Unknown keys are ignored; defaults are kept for any field you omit.
Voices and languages¶
Voices live under voices/{voice_id}/ inside the bundle. Pass the
language at construction time (it can be overridden per-voice
default). The nano variant is English-only and ignores the parameter.
Streaming¶
open_streamer is push-based: collect streamer.output concurrently
with streamer.send(...) so audio plays the moment each sentence is
ready. Typical use case is piping LLM tokens straight into TTS.
Kotlin:
val streamer = tts.open_streamer()
val consumer = launch {
streamer.output.collect { chunk ->
val pcm = chunk.audio
if (pcm != null) player.enqueue(pcm)
}
}
streamer.send("Hello, world. ")
streamer.send("This sentence streams as it synthesizes.")
streamer.finish() // flush remaining buffer + close `output`
consumer.join()
If you already have the full text up-front, infer_stream(text) does
the same thing in a single call. Use streamer.cancel() instead of
finish() to abort an in-flight turn (e.g. on barge-in) — that drops
the buffer and closes immediately.
Flutter — push-based streaming uses infer_stream + send +
finish_stream against a stable stream_id. Open the stream with
empty text first, then push sentences as they become available:
const streamId = 'tts-utterance-1';
final player = TheStageAudioPlayer(sampleRate: 24000)..start();
// 1) Open the stream + start consuming chunks concurrently.
final consumer = () async {
final stream = TheStageFlutterSDK.infer_stream(
model_name: 'tts',
input_json: {'text': ''}, // empty = wait for `send`
stream_id: streamId,
);
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;
}
}();
// 2) Push sentences (e.g. from an LLM token stream).
await TheStageFlutterSDK.send(stream_id: streamId, text: 'Hello, world. ');
await TheStageFlutterSDK.send(
stream_id: streamId,
text: 'This sentence streams as it synthesizes.',
);
// 3) Signal end-of-input → flush remaining buffer, close the stream.
await TheStageFlutterSDK.finish_stream(stream_id: streamId);
await consumer;
For an already-known string, just call infer_stream with the full
text and skip send / finish_stream.
Output contract¶
24 kHz mono
FloatArray, samples in[-1.0, 1.0].Batch:
NeuTTSResult.audiois the full utterance.Streaming: each
InferenceStreamChunk.audiois one sentence-sized PCM slice;chunk.sample_rateis24000. The streamer applies overlap-add crossfading between sentences, so consumers can concatenate slices end-to-end.If your playback path runs at 16 kHz to match VAD/ASR, set
output_sample_rate: 16000to have the SDK resample for you, resample the output yourself, or drive yourTheStageAudioPlayerat 24 kHz (the bundled player defaults to 24 kHz).
See TheStage Android SDK (Audio I/O Contract) for the shared format used across VAD / ASR / TTS.
Lifecycle¶
TheStageAI.registerContext(context)+TheStageAI.initialize(api_token)— must have succeeded before any TTS call.start_model(auto-downloads the bundle), orprefetch_modelfollowed by a direct constructor (local-only, no network).Call
infer/infer_streamor open a streamer.stop_model(singleton) orclose()(direct pipelines) when done.
Usage Guides¶
Play synthesized audio¶
A batch result is 24 kHz mono PCM with samples in [-1.0, 1.0] —
hand it to any float-PCM player at the documented rate.
Kotlin — play the FloatArray with a standard Android
AudioTrack:
import android.media.AudioAttributes
import android.media.AudioFormat
import android.media.AudioTrack
val json = TheStageAI.infer(
model_name = "tts",
input_json = mapOf("text" to "Hello, world!")
)
val audio = json[0]["audio"] as FloatArray // 24 kHz mono PCM
val sample_rate = json[0]["sample_rate"] as Int // 24000
val track = AudioTrack.Builder()
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build()
)
.setAudioFormat(
AudioFormat.Builder()
.setEncoding(AudioFormat.ENCODING_PCM_FLOAT)
.setSampleRate(sample_rate)
.setChannelMask(AudioFormat.CHANNEL_OUT_MONO)
.build()
)
.setTransferMode(AudioTrack.MODE_STATIC)
.setBufferSizeInBytes(audio.size * Float.SIZE_BYTES)
.build()
track.write(audio, 0, audio.size, AudioTrack.WRITE_BLOCKING)
track.play()
Flutter — enqueue the Float32List into TheStageAudioPlayer
at 24 kHz (see Streaming):
final result = await TheStageFlutterSDK.infer(
model_name: 'tts',
input_json: {'text': 'Hello, world!'},
);
final audio = result[0]['audio'] as Float32List; // 24 kHz mono
final player = TheStageAudioPlayer(sampleRate: 24000)..start();
player.enqueue(audio);
await player.drain();
await player.stop();
If your playback path runs at a different rate, set
output_sample_rate or resample yourself (see Output contract).
Custom voices¶
Beyond the voices baked into a bundle, an app can supply its own. Each
custom voice is a folder with a single voice.json; the SDK merges
them with the bundle’s built-in voices, so they show up in the
voices list returned by start_model and are selectable by
voice_id exactly like the built-ins.
Custom voices ship as assets inside your app. The
custom_voices_assets config key is the name of the asset folder
that holds them; the SDK extracts that folder out of the APK on load
and merges the voices in. Inside it, each voice is its own subfolder
with a single voice.json:
neutts_voices/
my_voice/
voice.json
Where that folder lives depends on the app type. Pass the same
folder name (neutts_voices) either way — the SDK looks in both
locations, so one config value serves a Flutter app, a native app,
or one that carries both:
App |
Put the folder at |
|---|---|
Flutter |
|
Native Android |
|
Flutter — declare each voice file in pubspec.yaml, then pass the
folder name:
flutter:
assets:
- assets/neutts_voices/my_voice/voice.json
await TheStageFlutterSDK.start_model(
model_name: 'tts',
engines_path: 'TheStageAI/neutts-multilingual',
config: {
'voice_id': 'my_voice',
'language': 'english',
'custom_voices_assets': 'neutts_voices',
},
);
Native Android (Kotlin) — drop the folder at
src/main/assets/neutts_voices/ and pass its name:
TheStageAI.start_model(
model_name = "tts",
engines_path = "TheStageAI/neutts-multilingual",
config = mapOf(
"voice_id" to "my_voice",
"language" to "english",
"custom_voices_assets" to "neutts_voices",
)
)
voice.json shape¶
A voice.json is small (~4 KB) — reference text, the codec codes for
that reference audio, and per-voice generation defaults (ref_codes
truncated here; a real file holds the full code sequence for the
reference clip):
{
"name": "my_voice",
"language": "english",
"ref_text": "A short sentence spoken in the reference clip.",
"ref_codes": [ [ 1234, 5678 ] ],
"temperature": 0.7,
"top_k": 50,
"max_length": 1024,
"use_lang_token": true
}
Field |
Required |
Description |
|---|---|---|
|
yes |
Display name; usually matches the folder. |
|
yes |
One of the supported languages (nano ignores it). |
|
yes |
Transcript of the reference audio the codes came from. |
|
yes |
NeuCodec codes for the reference audio (authored offline). |
|
no |
Per-voice generation defaults; omit to use the model defaults. |
Note
ref_codes are authored offline. The on-device SDK ships the
NeuCodec decoder only, so it cannot turn a reference .wav into
codes on the phone. Produce the codes ahead of time with the NeuCodec
encoder and paste them into voice.json.
To ship a voice with your app, drop its voice.json under your asset
folder (see the table above for where that folder lives), point
custom_voices_assets at that folder, and it appears in the voices
list at load.
Troubleshooting¶
Symptom |
Cause / Fix |
|---|---|
Flutter audio glitches / NaNs |
PCM promoted to |
Direct constructor can’t find engines |
The direct Kotlin pipelines load from a local
(already-downloaded) engines directory — they do not download.
Call |
Playback wrong speed on a 16 kHz path |
Player rate ≠ 24 kHz output. Set |
Load Progress / Prefetch / Cleanup¶
Load progress¶
Download / extract / load progress is reported through the singleton
and Flutter entry points via an optional on_load_progress handler
that fires through four phases with a monotonic fraction in
0.0 .. 1.0:
TheStageAI.start_model(
model_name = "tts",
engines_path = "TheStageAI/neutts-multilingual",
config = mapOf("voice_id" to "paul"),
on_load_progress = { p ->
println("[${p.model}] ${p.phase} ${(p.fraction * 100).toInt()}%")
}
)
The same on_load_progress parameter is accepted by
TheStageAI.prefetch_model(...). For the full event contract see
TheStage Android SDK (Load Progress).
Flutter:
TheStageFlutterSDK.on_progress.listen((event) {
if (event['model_name'] != 'tts') return;
final phase = event['phase'] as String?; // downloading | extracting | loading | ready
final fraction = event['progress'] as double?; // 0.0 ... 1.0, monotonic
print('[tts] $phase ${(fraction ?? 0) * 100}%');
});
await TheStageFlutterSDK.start_model(
model_name: 'tts',
engines_path: 'TheStageAI/neutts-multilingual',
config: {'voice_id': 'paul', 'language': 'english'},
);
Prefetch engines¶
val engines_dir = TheStageAI.prefetch_model(
repo_id = "TheStageAI/neutts-multilingual"
)
// Later — instant load, no network:
val tts = NeuTTSMultilingual(
engines_path = engines_dir,
voice_id = "paul"
)
Cleanup¶
The direct pipelines expose close() to release them. When you used
the singleton API:
TheStageAI.stop_model(model_name = "tts")
Flutter:
await TheStageFlutterSDK.stop_model(model_name: 'tts');