Skip to content

Add speech-LM text-to-speech sample (Qwen3-TTS, Compiled Model host loop)#216

Merged
smilingday merged 8 commits into
google-ai-edge:mainfrom
john-rocky:qwen3-tts-sample
Jul 11, 2026
Merged

Add speech-LM text-to-speech sample (Qwen3-TTS, Compiled Model host loop)#216
smilingday merged 8 commits into
google-ai-edge:mainfrom
john-rocky:qwen3-tts-sample

Conversation

@john-rocky

@john-rocky john-rocky commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

What

A text-to-speech sample for the speech-LM model family: Qwen3-TTS-12Hz-0.6B-Base (Apache-2.0, 10 languages, 3-second voice cloning) running fully on device as three LiteRT graphs orchestrated by a host-side loop with the Compiled Model API. It complements the Matcha-TTS sample (#162): Matcha covers the compact acoustic-model family, this covers the modern codec-LM family (talker LM → 12.5 Hz × 16-codebook audio tokens → neural codec decoder → 24 kHz PCM).

compiled_model_api/text_to_speech_lm/

  • python/ — pipeline library + CLI. Models auto-download from litert-community/Qwen3-TTS-12Hz-0.6B-Base (~1.4 GB default int4 configuration).
  • kotlin_cpu/android/ — Android app (Kotlin, Compiled Model API on CPU): byte-level BPE tokenizer with a startup self-test against reference token ids, memory-mapped fp16 embedding tables, signature-based talker prefill/decode with ping-pong KV-cache buffers (no per-step cache copies), the 17-invoke MTP inner loop, chunked codec decode, AudioTrack playback. install_to_device.sh fetches the models from Hugging Face and pushes them to the app's filesDir.
  • conversion/ — full conversion pipeline: talker checkpoint synthesis (the stock litert-torch LLM exporter applies unchanged), MTP decode-step graph authoring, codec decoder export, host-table extraction, and voice enrollment.
cd compiled_model_api/text_to_speech_lm/python
pip install -r requirements.txt
python synthesize.py --text "Hello from LiteRT running fully on device." --output hello.wav

Why a host-side loop

Qwen3-TTS's decode loop cannot be expressed by the LiteRT-LM Engine today: each step aggregates 16 codebook embeddings plus streamed per-frame text conditioning, needs the hidden state alongside logits (it seeds a 15-step inner AR loop over a second transformer with per-step lm_heads), and outputs audio rather than text. The host loop in this sample is a reference for what an Engine-level audio-LM session would absorb.

Verification

  • Talker: the TTS multimodal RoPE provably reduces to standard RoPE (bit-exact at torch level); .tflite decode sweep correlation 1.0, top-1 100%.
  • MTP: 15/15 greedy tokens vs the reference inner loop.
  • Codec decoder: correlation 1.0 (max abs diff 1.8e-5).
  • End to end (fp32 talker, greedy): token-for-token identical codes to the PyTorch reference, waveform correlation 1.000000; ASR round-trip returns the input sentence. The int4 talker output also transcribes exactly.
  • Android app: device-verified on Pixel 8a (tokenizer self-test passes on device, including Japanese input).

Performance

Per 80 ms audio frame M4 Max CPU (Python) Pixel 8a (Kotlin)
Talker decode 45–50 ms ~60 ms
MTP inner loop (17 invokes) ~148 ms ~370 ms
Codec decode (amortized) ~10 ms ~98 ms
Total ~205 ms → RTF ≈ 2.5 ~530 ms → RTF ≈ 6.7

Pixel 8a end to end: 4.16 s of audio in 28.0 s. The MTP inner loop is the known optimization target (single unrolled graph + calibrated low-bit weights).

…oop)

Runs Qwen3-TTS-12Hz-0.6B-Base fully on device as three LiteRT graphs
(talker LM, MTP code predictor, codec decoder) orchestrated by a
host-side Python loop, with x-vector voice cloning in 10 languages.

- python/: pipeline library + CLI (models auto-download from
  litert-community/Qwen3-TTS-12Hz-0.6B-Base)
- conversion/: checkpoint synthesis, graph exports, host-table
  extraction, voice enrollment; every graph verified vs the PyTorch
  reference (correlation 1.0; end-to-end token-for-token under greedy)
Kotlin port of the Python pipeline: byte-level BPE tokenizer with a
startup self-test against reference token ids (passes on device incl
Japanese + U+3000), mmapped fp16 embedding tables, signature-based
talker prefill/decode with ping-pong KV buffers (no per-step cache
copies), 17-invoke MTP inner loop, chunked codec decode, AudioTrack
playback. install_to_device.sh downloads from Hugging Face and pushes
to the app filesDir.

Device-verified on Pixel 8a: 4.16s of audio in 28.0s (RTF 6.7;
prefill 585ms, talker 3.1s, mtp 19.2s, codec 5.1s).
- Kotlin: import TensorBuffer/Normalizer/JSONArray instead of qualified
  names, Arrays.fill -> fill(), wrap >100-col lines (Google Kotlin style)
- Python: wrap the one >80-col line (Google Python style)
- UI: NoActionBar theme (the action-bar title overlaid the content under
  edge-to-edge) + explicit vertical margins between views; layout
  re-verified on Pixel 8a
Same migration as ppocrv5-sample acfbb38 (review feedback on google-ai-edge#165): the
desktop parity/verify runs (export_codec, export_mtp, verify_talker) and
the qwen3_tts_pipeline.py host decode loop now go through
ai_edge_litert.compiled_model.CompiledModel. The talker/MTP/codec
signatures run via run_by_name with named tensor buffers created once and
rewritten per step (KV-cache feedback, int32 token ids and per-step invoke
semantics unchanged; XNNPACK thread counts preserved via CpuOptions), and
the single-shot parity runs use run_by_index. No behavior change; output
prints preserved; greedy end-to-end waveform is bit-identical to the
Interpreter-based pipeline.
The codec decoder runs on fixed-T chunks with left context. The loop advanced
by CODEC_CHUNK new frames but the window it feeds is (new frames + ctx), which
exceeds the graph's T (and the input buffer) once ctx > 0 — so any utterance
longer than one chunk (~5 s) overran the buffer. Advance by CODEC_CHUNK - ctx
new frames instead, so the window always fits. Same fix in the Kotlin and
Python host loops.
Wrap every line in the conversion scripts to the 80-column limit of the
Google Python Style Guide and add Args/Returns sections to the
module-level function docstrings. No behavior changes.
@smilingday
smilingday self-requested a review July 11, 2026 02:04
@smilingday
smilingday merged commit 64fc05c into google-ai-edge:main Jul 11, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants