Skip to content

feat: Qwen3-TTS — multilingual AR codec-LM as a 2nd on-device TTS family - #408

Merged
DenisovAV merged 30 commits into
mainfrom
feat/qwen3-tts
Aug 4, 2026
Merged

feat: Qwen3-TTS — multilingual AR codec-LM as a 2nd on-device TTS family#408
DenisovAV merged 30 commits into
mainfrom
feat/qwen3-tts

Conversation

@DenisovAV

Copy link
Copy Markdown
Owner

Adds Qwen3-TTS-12Hz-0.6B as a second, selectable on-device TTS family in flutter_gemma_speech, alongside Matcha — an autoregressive codec-LM (Qwen3 talker + MTP + codec decoder) driven by a host loop over the shared LiteRT FFI.

Highlights

  • Bit-exact port. The Dart AR pipeline reproduces the official PyTorch reference token-for-token under fp32-greedy — waveform correlation 1.0, 26/26 frames exact (talker cb0, MTP residual codebooks, and codec all match their goldens). Goldens are generated from the reference recipe (tool/qwen3/gen_qwen3_goldens.py), so correctness flows Python → fixtures → Dart, never Dart → Dart.
  • No native changes. Runs over the existing LiteRT FFI — native-v0.14.0 unchanged, no new dylib. The talker's KV cache is carried host-side as ordinary in/out tensors (the STT encoder-hidden pattern generalized); a shared graph-helper was extracted from tts_core.dart and given mixed-dtype (f32/i32) input support.
  • Multilingual, one voice. 11 languages selectable via getActiveTts(language:); one bundled demo x-vector (voice selection is a follow-on — the published repo ships a single voice and no speaker encoder). CPU-only in v1 (RTF ≈ 3; ~6 GB-RAM-class device — flagged in the catalog).
  • Matcha stays byte-exact. Verified on-device (identical PCM) before and after the shared graph-helper extraction and the worker-loop split.
  • Core: adds TtsModelType.qwen3 + a plain-basename install manifest (with HF subdir URL routing) and threads language through the facade / RuntimeConfig.

Install-path hardening

Removes an unsafe install-time legacy-file adoption in TtsInstallationBuilder: it adopted an on-disk plain file by basename uniqueness within the TTS catalog only, with no content check. tokenizer.json is TTS-unique but a name STT models also ship, so a leftover STT tokenizer could be silently renamed into a Qwen3 install → corrupt/crashing model. This is a pre-existing hole that Qwen3 newly exposes; install-time adoption is dropped (unprovable ownership by bare name), and the documented-safe restore-time migration path is left unchanged.

Versions

flutter_gemma 1.5.1 → 1.6.0 (additive public API — new enum value + RuntimeConfig.language), flutter_gemma_speech 0.4.0 → 0.5.0. pubspec.yaml bumps happen at release.

Net-new text->ids encoder for the Qwen3-TTS text frontend (HfTokenizer is
decode-only). Reproduces the HF tokenizers pipeline: NFC pass-through,
atomic longest-match added-token splitting, the Qwen2 Split regex (Dart
RegExp supports the inline (?i:...) group and \p{L}/\p{N} directly),
GPT-2 byte<->unicode mapping, and rank-ordered BPE merges.

Verified against Task 0's HF-tokenizers golden fixtures: the templated
prompt (16 ids), 8 diverse bpe_cases (contractions, German/French
accents, CJK, whitespace runs, digits, a literal special-token string),
and atomic special-token ids -- all pass without any NFC normalization.
Qwen3Tables loads codec_embedding/mtp_embeddings whole (small) and keeps
text_embedding (622 MB) as an open RandomAccessFile with row-lookup via
Task 1.1's npyRowF16AsF32, never loading it fully. embedText() looks up
each id's row and runs it through the 2048->1024 SiLU projection MLP
ported from Qwen3TtsPipeline._project_text/_embed_text.
Tests 2 and 3 fired-and-forgot the load future, so assertions could run
after the test body returned once load() gains real async I/O. Match
test 1's await pattern.
Move TtsCore's private _loadGraph/_runGraph/_createF32TensorBuffer into
a new litert_graph.dart as public loadLiteRtGraph/runLiteRtGraph/
createF32TensorBuffer, generalized to mixed F32/I32 inputs (GraphInput)
and a parameterized signature index, so the upcoming Qwen3 talker/codec
graphs can reuse them. Matcha call sites now wrap inputs as F32Input
and pass signature index 0 — behavior-preserving, verified byte-exact
against the shipped Matcha golden PCM on macOS before and after.
TalkerLayout pins the C1-spike-resolved talker graph layout (3 signatures
by embeddings time-dim, 59-in/57-or-56-out) and assertLayout re-verifies
what the bound LiteRT C API allows via getInputTensorLayout. First real
load of the talker graph (talker_int4.tflite) through our Dart FFI.
Loads the talker/mtp/codec LiteRT graphs + host tables + tokenizer,
asserts the frozen talker layout, introspects mtpCacheLen/mtpKvShape/
codecChunk, and ports _run_prefill (qwen3_tts_pipeline.py:315-328) to
initialize the talker's 56-tensor KV cache from a prompt embedding.
Ports _run_decode (mask + decode signature + logit split); the golden
gate reproduces the PyTorch reference's frame-0 cb0 exactly (1342) on
talker_fp32. Qwen3TtsCore.load gains an optional talkerFileName param
(defaults to int4) so the golden test can load fp32 without disturbing
the runtime default.
Ports Qwen3TtsPipeline._run_mtp (qwen3_tts_pipeline.py:342-380): a
16-step inner loop over a 17-slot KV cache that predicts a frame's 15
residual codebooks from the talker's hidden state + codebook-0 token.
Introspects the MTP graph's output_0 (residual-head logits) shape at
load time via the bulk getOutputTensorLayouts accessor rather than
hardcoding it.

Gate: runMtp fed the golden frame-0 hidden/cb0 reproduces the PyTorch
reference's 15-code residual exactly.
Ports Qwen3TtsPipeline._decode_codes (qwen3_tts_pipeline.py:382-399):
sliding-window codec decode with 25-frame left context, transposing
[T,16] codes into the codec's [1,16,chunk] int32 input and cropping
context samples out of each window's [1,1,chunk*1920] PCM output.

Golden gate against frames.json -> waveform_f32.bin: corr=1.0,
maxAbsDiff=0.0 on the real fp32 codec graph.
Qwen3TtsCore.synthesize/synthesizePcm16 tie the talker + MTP + codec
into the host-orchestrated text->waveform loop (qwen3_tts_pipeline.py
:173-266). runMtp gains a sampled residual-picking path. Golden gate
(fp32, greedy): corr=1.0, frame agreement=1.0 vs the PyTorch reference.
Adds the qwen3 enum value + its 9-file install manifest (plain
basenames matching Qwen3TtsCore.load's artifactPaths keys exactly),
extends the TTS size-floor to .npy/.npz, and teaches
TtsInstallationBuilder to fetch the 4 table files + demo voice from
their tables/voices subdirectory on HF without letting that leak into
the installed identity (I5: plain-basename manifest, not nested or
double-underscore-flattened — see tts_model_spec.dart doc comments).
…or wiring

Review fix (round 1): the prior "flat<->url round trip" test in
tts_model_spec_test.dart re-implemented the urlSuffixFor wiring in its
own sourceFor closure instead of exercising TtsInstallationBuilder
.install() itself, so a revert of the joinUrl change would pass every
test while fresh qwen3 network installs 404 on the 5 subdir-hosted
files. Adds a builder-level test (mocked ServiceRegistry + a recording
DownloadService, mirroring install_identity_namespacing_test.dart's
pattern) that calls install() for TtsModelType.qwen3 and asserts the
resulting NetworkSource URLs carry the tables/voices subdirectory
while every prefsKey stays a plain basename. Verified this test fails
if the joinUrl wiring is reverted, then confirmed it passes against
the real code. Also adds a small matcha spot-check.
Add TtsPipelineKind.qwen3ArCodec and const TtsModelProfile.qwen3()
(subwordTokens + no G2P; pipeline is the only load-bearing field,
Qwen3TtsCore hardcodes its own manifest basenames). forType(qwen3)
resolves it; supertonic/kokoro still fail-loud. Add fail-loud arms
in TtsTextFrontend.load and clarify TtsCore.load's guard message so
qwen3ArCodec never silently runs the Matcha pipeline.
_workerEntry now branches on profile.pipeline: matchaCfm keeps the
existing TtsTextFrontend+TtsCore path (clause split + CFM seed +
inter-clause silence) unchanged in _runMatchaWorker; qwen3ArCodec
loads Qwen3TtsCore + the demo x-vector once and serves each request
with a single synthesizePcm16 call (no clause-splitting, no CFM
seed, no inter-clause silence — Qwen3 is one AR pass over its own
KV cache). Load failures surface to the caller, never fall back to
Matcha. TtsWorker.spawn/LiteRtSpeechSynthesizer.create gain an
optional language param (default 'english') threaded to the qwen3
arm; Matcha ignores it.
LiteRtSpeechSynthesizer.create now validates language against a new
public qwen3SupportedLanguages list (languageIds keys + 'auto',
single source of truth also used by Qwen3Prompt.build) before
spawning the worker, fail-fast ahead of the ~1.9 GB model load.
voice is wired through to the worker as a forward-compat x-vector
override, defaulting to the bundle's demo voice.

RuntimeConfig/createTtsModel/getActiveTts gain an optional language
param so the example can actually select one. tts_screen.dart adds
a model dropdown (previously hardcoded to matcha) and a language
dropdown shown for qwen3; switching either closes the old
synthesizer and reinstalls/reactivates with the new selection.

Adds the qwen3 catalog entry (isSupported: true, CPU-only/RTF≈3/
6GB-RAM note).
createTtsModel's same-model reuse branch (mobile + desktop shells)
compared only the active model name, so a second getActiveTts() call
with a different language silently returned the stale synthesizer —
wrong-language audio with no error. Now stores the language the
singleton was built with and throws StateError on a mismatch instead
of reusing it; unaffected when language is unset (matcha).

Also: assertQwen3LanguageSupported validated case-insensitively but
Qwen3Prompt.build's 'auto' compare was case-sensitive, so 'Auto'/
'AUTO' passed create() but threw ArgumentError after the ~1.9 GB
model load, at the first synthesize call. LiteRtSpeechSynthesizer.
create now normalizes the language once, right after validation, via
the new normalizeQwen3Language helper.
Adds qwen3_tts_test.dart: installs Qwen3-TTS from the real public HF
URL, synthesizes english, closes, switches to german, and asserts the
output actually differs — the on-device install->getModelFilePaths->
Qwen3TtsCore.load path the artifact-gated unit tests can't cover since
they build their own artifactPaths.

Device run found a real bug: TtsInstallationBuilder's legacy-file
adoption blindly renamed an unrelated leftover STT tokenizer.json into
the qwen3 slot (its uniqueness check only scans the TTS catalog, not
STT/embedding/inference catalogs sharing the same generic basename),
corrupting the BPE vocab. Not fixed here (needs a design call); local
test-machine state cleaned up to get a real signal on the rest of the
pipeline, which now passes: english/german PCM differ, RMS/duration
plausible, 24 kHz confirmed both ways.

CHANGELOG: qwen3-tts entries in speech (0.5.0) and core (1.5.2).
TtsInstallationBuilder.install() adopted an on-disk plain
(pre-1.5.1-namespacing) file whenever its basename was unique within the
TTS catalog, with zero content check. Qwen3's tokenizer.json is a
cross-catalog-generic basename, so a leftover STT tokenizer.json could be
silently adopted -> wrong tokenizer. Adoption at install time is removed;
a not-yet-installed manifest file is now always downloaded fresh. The
restore-time migration (MobileModelManager._migrateLegacyCompanionForRestore)
is untouched -- it stays safe because it operates on a single known active
model.

Sibling builders (stt_/embedding_/inference_installation_builder.dart) never
had this pattern -- no changes needed there.

Bump core CHANGELOG header 1.5.2 -> 1.6.0: this branch adds public API
(TtsModelType.qwen3 + RuntimeConfig.language + facade language params),
and a new enum value can break downstream exhaustive switches.
…uards

- pin flutter_gemma_speech's core dep to ^1.5.2 (TtsModelType.qwen3 + RuntimeConfig.language need it)
- warn when Qwen3 TTS hits maxFrames before EOS (truncated output signal, no behavior change)
- throw on a GraphInput payload length mismatch instead of silently zero-padding the tensor
- fail loud at load time if codecChunk doesn't exceed the codec decode loop's left-context
- test that every wired TTS manifest uses only plain basenames; de-duplicate urlSuffixFor's table set
- normalize the TTS language reuse-guard (default + lowercase) so null/'english'/'English' don't spuriously collide
Add a T=135 fp32 golden (frames_long.json/waveform_long_f32.bin) and a
codec test gate on it — the existing T=26 golden is under codecChunk
(64), so decodeCodes's sliding-window + left-context-carry path was
never exercised by any test.
Strip SDD/process scaffolding (Task/Phase refs, docs/superpowers path) from
shipped dartdoc across the qwen3 TTS port and touched TTS files, fix two
dangling doc-links and an overstated install-note. Vendor the litert-samples
Qwen3-TTS recipe (Apache-2.0) under tool/qwen3/ so the ~78 recipe citations
resolve; soften them to function-name references since the vendored copy's
added provenance line shifts its raw line numbers vs. upstream.
…r leak

Qwen3TtsCore.synthesize now throws a StateError when the AR loop hits
maxFrames without EOS instead of a gemmaLog-only warning, which is
compile-time stripped in release and silently truncated audio.

litert_graph.dart's _createTensorBuffer now validates the payload
length before any native allocation, so a mismatch no longer leaks
the aligned buffer + type struct that used to be allocated ahead of
the check.
…3-TTS

- CLAUDE.md Current Version: flutter_gemma 1.5.2, flutter_gemma_speech 0.4.1
- website pubspec snippets bumped (speech.md, genkit.md, migration.md)
- speech.md: document Qwen3-TTS (2nd selectable TTS family, language: param)
- speech.md: mark Whisper/Parakeet STT shipped (were stale 'follow-on' since 0.4.0)
@DenisovAV
DenisovAV merged commit fd49608 into main Aug 4, 2026
5 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.

1 participant