Prince-1 commited on
Commit
e9e0b5a
·
verified ·
1 Parent(s): b96bbc5

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
.gitattributes CHANGED
@@ -33,3 +33,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ cuda_fp32/talker_cache.onnx.data filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Downloaded checkpoint + intermediate/build artifacts
2
+ model/
3
+ cache/
4
+ *.onnx
5
+ *.onnx.data
6
+ *.safetensors
7
+ _genai_*/
8
+ __pycache__/
9
+ *.pyc
10
+
11
+ codes/**/__pycache__/
12
+ _load_test.py
13
+ build_tok.log
README.md ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ pipeline_tag: text-to-speech
4
+ language:
5
+ - zh
6
+ - en
7
+ - ja
8
+ - ko
9
+ - de
10
+ - fr
11
+ - ru
12
+ - pt
13
+ - es
14
+ - it
15
+ tags:
16
+ - tts
17
+ - qwen
18
+ - audio
19
+ - onnx
20
+ - onnxruntime-genai
21
+ - multilingual
22
+ base_model:
23
+ - Qwen/Qwen3-TTS-12Hz-0.6B-VoiceDesign
24
+ arxiv: 2601.15621
25
+ ---
26
+
27
+ # Qwen3-TTS-12Hz-0.6B-CustomVoice
28
+
29
+ [Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS) is a series of advanced multilingual, controllable, robust, and streaming text-to-speech models developed by the Qwen team.
30
+
31
+ This specific checkpoint is the **0.6B CustomVoice** variant, based on the **12Hz** tokenizer. It supports 9 premium timbres and allows for fine-grained style control over target voices via natural language instructions across 10 major languages.
32
+
33
+ - **Paper:** [Qwen3-TTS Technical Report](https://huggingface.co/papers/2601.15621)
34
+ - **GitHub:** [QwenLM/Qwen3-TTS](https://github.com/QwenLM/Qwen3-TTS)
35
+ - **Demo:** [Hugging Face Spaces](https://huggingface.co/spaces/Qwen/Qwen3-TTS)
36
+
37
+ ## Key Features
38
+ * **Multilingual Synthesis**: Supports Chinese, English, Japanese, Korean, German, French, Russian, Portuguese, Spanish, and Italian.
39
+ * **Intelligent Control**: Adapts tone, rhythm, and emotional expression based on natural language instructions (e.g., "Speak in a very happy tone").
40
+ * **Low Latency**: Optimized for streaming generation with the Qwen3-TTS-Tokenizer-12Hz, achieving end-to-end synthesis latency as low as 97ms.
41
+
42
+ ## Quickstart
43
+
44
+ To use Qwen3-TTS, you can install the `qwen-tts` package:
45
+
46
+ ```bash
47
+ pip install -U qwen-tts
48
+ ```
49
+
50
+ ### Sample Usage
51
+
52
+ ```python
53
+ import torch
54
+ import soundfile as sf
55
+ from qwen_tts import Qwen3TTSModel
56
+
57
+ # Load the model
58
+ model = Qwen3TTSModel.from_pretrained(
59
+ "Qwen/Qwen3-TTS-12Hz-0.6B-CustomVoice",
60
+ device_map="cuda:0",
61
+ dtype=torch.bfloat16,
62
+ attn_implementation="flash_attention_2",
63
+ )
64
+
65
+ # Generate speech with specific instructions
66
+ wavs, sr = model.generate_custom_voice(
67
+ text="其实我真的有发现,我是一个特别善于观察别人情绪的人。",
68
+ language="Chinese",
69
+ speaker="Vivian",
70
+ instruct="用特别愤怒的语气说",
71
+ )
72
+
73
+ # Save the generated audio
74
+ sf.write("output_custom_voice.wav", wavs[0], sr)
75
+ ```
76
+
77
+ ## Supported Speakers
78
+
79
+ For `Qwen3-TTS-12Hz-0.6B-CustomVoice`, the following speakers are supported. We recommend using each speaker’s native language for the best results:
80
+
81
+ | Speaker | Voice Description | Native Language |
82
+ | --- | --- | --- |
83
+ | Vivian | Bright young female voice. | Chinese |
84
+ | Serena | Warm, gentle young female voice. | Chinese |
85
+ | Uncle_Fu | Seasoned male voice, mellow timbre. | Chinese |
86
+ | Dylan | Youthful Beijing male voice. | Chinese (Beijing) |
87
+ | Eric | Lively Chengdu male voice. | Chinese (Sichuan) |
88
+ | Ryan | Dynamic male voice with rhythm. | English |
89
+ | Aiden | Sunny American male voice. | English |
90
+ | Ono_Anna | Playful Japanese female voice. | Japanese |
91
+ | Sohee | Warm Korean female voice. | Korean |
92
+
93
+ ## Citation
94
+ If you find Qwen3-TTS useful for your research, please consider citing:
95
+
96
+ ```bibtex
97
+ @article{Qwen3-TTS,
98
+ title={Qwen3-TTS Technical Report},
99
+ author={Hangrui Hu and Xinfa Zhu and Ting He and Dake Guo and Bin Zhang and Xiong Wang and Zhifang Guo and Ziyue Jiang and Hongkun Hao and Zishan Guo and Xinyu Zhang and Pei Zhang and Baosong Yang and Jin Xu and Jingren Zhou and Junyang Lin},
100
+ journal={arXiv preprint arXiv:2601.15621},
101
+ year={2026}
102
+ }
103
+ ```
STATUS.md ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Qwen3-TTS — Current Status
2
+
3
+ _Updated: 2026-06-19_
4
+
5
+ ## ✅ De-duplicated talker — ship talker_cache only (~870 MB/dir saved)
6
+ `talker.onnx` (no-cache) and `talker_cache.onnx` (KV-cache) hold the SAME transformer weights
7
+ → shipping both duplicated ~870 MB per dir. Fix: default export now builds **only
8
+ `talker_cache`** (it does prefill+decode, faster O(n), and is what inference auto-uses); plain
9
+ `talker` is still buildable via explicit `--components talker`. Dropped the redundant
10
+ `talker.onnx` (+ manifest entry) from all dirs that had both → **freed ~22.5 GB** across the
11
+ 8 built variants. inference.py guards for "neither talker present". Verified generation still
12
+ works on talker_cache-only dirs (KV-cache path, audio produced).
13
+
14
+ ## ✅ Config-driven dims — 0.6B now exports; 1.7B undisturbed
15
+ `user_script.py` had 1.7B dims hardcoded (hidden 2048, 28 layers, etc.) in the talker /
16
+ talker_cache / code_predictor / residual_embed io+dummy funcs → 0.6B (hidden **1024**) would
17
+ export wrong. Fixed with a module `_DIMS` populated by `_load_tts()` from the loaded model's
18
+ `talker_config` (Olive calls model_loader before io/dummy). **Defaults equal the 1.7B values**,
19
+ so 1.7B exports are byte-identical (verified: voicedesign/customvoice/base17 dirs untouched).
20
+ **Validated**: full `base/0.6B` int4 export — all 9 components incl. talker/talker_cache/
21
+ speaker_encoder — `onnx/base06/cpu_int4/`, talker `inputs_embeds [batch,seq,1024]` ✓.
22
+ (0.6B differs from 1.7B only in hidden_size; layers/kv/head_dim/groups are identical.)
23
+
24
+ ## ✅ EXPORT OOM RESOLVED — `optimize.py` isolates each component in a subprocess
25
+ The Windows pagefile/OOM kill on full-run exports is fixed: `main()` now re-invokes itself once
26
+ per component in a fresh subprocess (`--_child`), so memory is fully reclaimed between builds and
27
+ the heavy talker/talker_cache run alone. Default behavior; `--no-isolate` forces single-process.
28
+ `speaker_encoder` auto-dropped from defaults for non-base models. **Validated**: a full
29
+ **cpu_fp16** voicedesign export (the heaviest case — 5.6 GB fp16 talker + talker_cache) completed
30
+ in ONE command, all 8 components, no OOM → `onnx/voicedesign/cpu_fp16/`.
31
+
32
+ ## ✅ All 3 model types exported + validated with their feature
33
+ | dir | model_type | feature | validated |
34
+ |---|---|---|---|
35
+ | `onnx/voicedesign/cpu_int4/` | voice_design | `--instruct` | ✅ generates |
36
+ | `onnx/customvoice/cpu_int4/` | custom_voice | `--speaker ryan` | ✅ generates (27 frames) |
37
+ | `onnx/base17_cpu_int4/` | base | clone `--ref-audio/--ref-text` | ✅ runs e2e; speaker_encoder parity 1.0 |
38
+ Model-type gating enforces the right feature per model (wrong flag → clear error).
39
+ Pending exports: 0.6B Base, other precisions/devices (fp16/fp32, cuda) — mechanical repeats
40
+ (component-by-component due to the pagefile cap). KV-cache talker still blocked.
41
+
42
+ ## TL;DR (current session)
43
+ - ✅ Full text→speech `generate()` implemented + **100% greedy parity vs PyTorch** (all-16
44
+ codebooks 432/432). Needed talker→(logits,hidden) + `residual_embed.onnx` + the
45
+ `dynamic_shapes` seq fix — all done & validated.
46
+ - ✅ **Model-type feature gating in inference.py** — `model_type` read from config;
47
+ `_check_features` enforces: voice_design→`--instruct`, custom_voice→`--speaker`,
48
+ base→clone (`--ref-audio/--ref-text`). VALIDATED both ways: voice_design+instruct generates;
49
+ voice_design+speaker raises a clear ValueError. `--text` prints `model_type=…`.
50
+ - ✅ **voicedesign `onnx/voicedesign/cpu_int4/` restored** (all 7 components) and generation
51
+ re-validated (model_type=voice_design, 24-frame wav). Re-export had to be done
52
+ **component-by-component** (talker solo) — a single full-run OOM/pagefile-killed the talker
53
+ (`OSError 1455: paging file too small`). The lighter parts export together.
54
+ - ✅ **Env fix:** `optimize.py` + `inference.py` PEP-723 now pin `numba>=0.60 / llvmlite>=0.43`
55
+ — librosa otherwise pulls numba 0.53.1 → llvmlite 0.36 which won't build on Python 3.12.
56
+ - ✅ Base checkpoints downloaded (`base/1.7B`, `base/0.6B`); **Base-1.7B int4 exported** →
57
+ `onnx/base17_cpu_int4/` (7 components incl. `residual_embed` + 2-output dynamic talker).
58
+ - ✅ **Base voice cloning — IMPLEMENTED & runs end-to-end.** `generate(ref_audio, ref_text)`
59
+ → `_generate_clone`. Pieces:
60
+ • `speaker_encoder.onnx` exported (ECAPA + **inline mel/STFT** front-end, dynamic audio len,
61
+ fp32, dynamo; `audio[B,T]→x-vector[B,2048]`). `SpeakerEncoderWrapper` reimplements
62
+ mel_spectrogram WITHOUT its `if torch.min(y)<-1` debug branch (broke torch.export).
63
+ • ICL prefill mirrors `generate_icl_prompt`: ref_text+text+eos / codec_bos + **per-frame
64
+ ref-code sum (= `step_embed`/residual_embed!)**, with the x-vector injected in the codec
65
+ prefix. Ref audio→codes via `tok_encoder` in 1 s windows (`encode_chunked`).
66
+ • AR loop factored into shared `_ar_loop`. Verified runs: ref 39 frames + x-vector →
67
+ prefill 64 → 40 frames → 3.2 s wav (`onnx/base17_cpu_int4/`, model_type=base).
68
+ ✅ **speaker_encoder parity** (`eval_speaker.py`): ONNX x-vector vs PyTorch
69
+ `extract_speaker_embedding` = **cosine 1.000000, max|Δ| ~1e-6** at 2/3.5/6 s → the
70
+ reimplemented mel/STFT is exact. With tok_encoder (100%), residual_embed/talker/predictor
71
+ (100% TTS parity) all verified and the ICL prefill mirroring the reference, clone is correct
72
+ by construction. (A real-voice listen is still the only thing measuring perceptual fidelity.)
73
+ - ✅ **KV-cache talker — SOLVED (was blocked).** Fix: feed the cache as a plain list of
74
+ [k,v] tensor pairs under input name **`past_kv`** (NOT `past_key_values`, so Olive's
75
+ DynamicCache-pytree auto-conversion — which torch.export rejected — doesn't fire), and
76
+ build the `DynamicCache` in-graph (`from_legacy_cache`/`to_legacy_cache`). `talker_cache.onnx`
77
+ exports (59 in / 58 out: flattened 28×2 K/V). Verified: empty-past output == no-cache talker
78
+ (logits/hidden cos 1.0, max|Δ| 0.0); end-to-end greedy **100% exact** vs no-cache. `inference.py`
79
+ auto-uses it (`_ar_loop_cached`) when present. Speedup grows with length (talker O(n) vs O(n²);
80
+ ~1.1x at 36 frames where the 15-call predictor dominates, more for long utterances).
81
+ Exported for `onnx/voicedesign/cpu_int4/` so far.
82
+ - ⏳ **Export dirs:** the earlier `onnx/voicedesign/` & `onnx/customvoice/` were cleared in a
83
+ reorg; only `onnx/base17_cpu_int4/` survives. Re-exporting voicedesign cpu_int4 now.
84
+ - TODO: validate generate on base export; export 0.6B; optional tok_decoder dynamic-frames.
85
+
86
+ ## Targets (3 models, auto-detected by config `model_type`) — all downloaded locally
87
+ - `Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign` (qwen3_tts) ← `voicedesign/`
88
+ - `Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice` (qwen3_tts) ← `customvoice/`
89
+ - `Qwen/Qwen3-TTS-Tokenizer-12Hz` (qwen3_tts_tokenizer_12hz) ← `tokenizer/` (also embedded in each TTS as `speech_tokenizer/`)
90
+
91
+ ## Architecture (verified from repo + load)
92
+ TTS `Qwen3TTSForConditionalGeneration` → `talker`:
93
+ - `talker.model` — Qwen3-style **28 L, hidden 2048, MROPE**, dual codec/text embedding
94
+ - `talker.codec_head` — Linear 2048→3072 (first-codebook logits)
95
+ - `talker.code_predictor` — 5 L, hidden 1024, **16 per-group heads** (residual codes)
96
+ - `talker.text_projection`
97
+ Tokenizer `Qwen3TTSTokenizerV2Model` → **encoder** (wav→codes) + **decoder** (codes→wav),
98
+ 24 kHz, 12.5/12 fps, 16 quantizers (decode), conv+transformer+RVQ.
99
+
100
+ ## Decomposition → `onnx/{device}_{precision}/` (flat)
101
+ - TTS: `talker.onnx`, `code_predictor.onnx` (+ `tok_encoder/decoder.onnx` from embedded codec)
102
+ - Tokenizer: `tok_encoder.onnx`, `tok_decoder.onnx`
103
+ - codecs forced **fp32** (DAC int4/fp16 too lossy); LLM/predictor honor `--precision`.
104
+
105
+ ## create_model (ModelBuilder) — empirically checked, NOT viable for the talker
106
+ onnxruntime-genai ModelBuilder supports these archs: Llama, Mistral, Qwen2, **Qwen3**,
107
+ Gemma/2/3, Phi*, Granite, Nemotron, Olmo, SmolLM3, GptOss, InternLM2, Lfm2, HunYuan,
108
+ VideoChatFlashQwen. The talker is **`qwen3_tts_talker`** (not listed) and, decisively,
109
+ uses **MROPE** (`mrope_section=[24,20,20]`, interleaved) + dual codec/text embedding +
110
+ `codec_head`. Even remapped to `Qwen3ForCausalLM`, ModelBuilder applies **standard RoPE
111
+ (no MROPE)** → positionally wrong output. So create_model can't correctly convert it →
112
+ **Olive export (with the real MROPE forward) is the correct path.** Confirmed via the
113
+ supported-architecture list + the talker rope config.
114
+
115
+ ## Key decisions / findings
116
+ - **Talker is NOT ModelBuilder-able** (custom MROPE + dual embedding ≠ stock Qwen3) →
117
+ exported via **Olive** (`OnnxConversion` + RtnQuant/Float16), like CSM backbone.
118
+ (ModelBuilder remap attempted only if a talker is detectably stock.)
119
+ - **transformers version**: vendored `qwen_tts` (in `codes/`) needs **transformers==4.57.3**;
120
+ the shared venv is 5.10.2 (incompatible — check_model_inputs, config defaults,
121
+ ROPE_INIT_FUNCTIONS, …). Solved by making **optimize.py a PEP-723 uv script** that pins
122
+ 4.57.3 in an isolated env (`uv run optimize.py ...`). Shared venv untouched.
123
+ One vendored patch: `@check_model_inputs()` → `@check_model_inputs` (tf5 API); 25Hz
124
+ tokenizer import made optional (needs `sox`).
125
+ - **Model loads cleanly** under 4.57.3 (instantiates; submodules mapped).
126
+
127
+ ## Fixed — external-data relink on flattened models
128
+ `optimize.py` flattens Olive's `model.onnx[.data]` → `{name}.onnx[.data]`, but the proto's
129
+ `external_data.location` still pointed at `model.onnx.data` → onnxruntime failed to load any
130
+ model with external data (`External data path does not exist`). Affected talker fp32/fp16
131
+ (int4 talker is self-contained, no `.data`). Fixed: `relink_external_data()` rewrites each
132
+ tensor's `location` (proto-only, no multi-GB RAM load) after the move; existing dirs relinked
133
+ in place (cpu/cuda fp32=313 refs, fp16=255). Verified by `inference.py --selftest` loading talker.
134
+
135
+ ## inference.py (manifest-driven) — full generation implemented
136
+ `Pipeline(onnx/{dev}_{prec}, tts_dir=...)` loads all parts with the manifest EP (CPU fallback).
137
+ Building blocks verified in eval_*.py: `embed_text`, `embed_codec`, `talker_step`,
138
+ `predict_residual`, `step_embed`, `encode`, `decode`/`decode_chunked`.
139
+
140
+ `generate(text, language, instruct=, speaker=)` now implements the real text→speech path,
141
+ faithful to `Qwen3TTSForConditionalGeneration.generate` with `non_streaming_mode=True`:
142
+ • **text-to-speech** — text + language
143
+ • **voice design** — text + `instruct` (natural-language style) [VoiceDesign]
144
+ • **custom voice** — text + `speaker` name (+ optional instruct) [CustomVoice]
145
+ Prefill assembly mirrors modeling lines 2068-2234 (role + codec tags + pad/bos + text body +
146
+ eos + codec_bos; instruct embeds prepended). AR talker loop is no-cache (re-runs the growing
147
+ prefix; MROPE collapses to `arange` since `get_rope_index`=cumsum(mask)-1 with 3 identical rows
148
+ and no padding). Each step: talker→first-codebook logits + last hidden → 15 residual codes via
149
+ the **causal** teacher-forced predictor (fill known codes, read `group_logits[j-1]`) → next input
150
+ = `residual_embed(codes16)` (=`codec_hiddens.sum(1)`) + `tts_pad`. suppress_tokens + repetition
151
+ penalty + top-k/top-p sampling replicated in numpy. `--text` CLI bug fixed (was `if selftest or
152
+ True:`; now proper branching).
153
+
154
+ **Two new exported components** were required (the old 6 couldn't roll out generation):
155
+ • `talker.onnx` now emits **(logits, hidden_states)** — the predictor is conditioned on the
156
+ talker's last hidden state, not its logits. (re-export needed)
157
+ • `residual_embed.onnx` — `codec_ids[B,16] → codec_hiddens.sum(1)[B,2048]`, summing
158
+ `talker.model.codec_embedding(code0)` + `code_predictor.model.codec_embedding[i](code_{i+1})`.
159
+ Those residual per-group embeddings were buried in the predictor graph; needed for the
160
+ next-step talker input.
161
+
162
+ **Audio-clone (ICL ref_audio/ref_text)** is a `base`-model feature: `create_voice_clone_prompt`
163
+ raises for non-base, and `speaker_encoder` is None for VoiceDesign/CustomVoice. Not applicable to
164
+ our two targets → intentionally not implemented (documented in inference.py).
165
+
166
+ Re-export to refresh a dir:
167
+ `uv run optimize.py --model voicedesign --skip-download --device cpu --precision fp32 \
168
+ --components residual_embed talker`
169
+
170
+ ## Fixed — talker needed `dynamic_shapes` (dynamo ignores `dynamic_axes`)
171
+ The first talker re-export locked seq to the dummy's 32 (`attention_mask Got 26 Expected 32`
172
+ at gen step 0), breaking the AR loop. Root cause: with `use_dynamo_exporter`, Olive
173
+ (`conversion.py` ~L368) passes **`dynamic_shapes`** to torch.export and **drops `dynamic_axes`**.
174
+ Fix: added `dynamic_shapes` to the talker io_config (`{input: {axis:int → "dimname"}}`, shared
175
+ `"seq"` ties the 3 inputs; torch 2.12 accepts string dim names). Re-exported → talker inputs are
176
+ now `['batch','seq',2048]` etc. (Same root cause as the tok_decoder fixed-25-frame limitation —
177
+ that decoder could be re-exported with `dynamic_shapes` too if a dynamic-frame decoder is wanted.)
178
+
179
+ ## ✅ Generation validated end-to-end (cpu_fp32, voicedesign)
180
+ `uv run inference.py --model-path onnx/customvoice/cpu_fp32 --tts-dir voicedesign \
181
+ --text "Hello, this is a test." --instruct "A calm female voice." --out out.wav`
182
+ → AR loop runs (dynamic-seq talker → first code + hidden; 15 residuals via causal predictor;
183
+ `residual_embed` next-step input), decodes real audio (RMS 0.10, peak 0.64, non-silent).
184
+
185
+ **Greedy parity vs PyTorch (`eval_generate.py`, voicedesign, cpu_fp32):** tokenization identical;
186
+ first-codebook 27/27 frames (100%); **all-16-codebooks 432/432 = 100.00%** over 27 frames — the
187
+ ONNX generation reproduces `model.generate` exactly (prefill + MROPE + AR talker + causal residual
188
+ predictor + residual_embed + EOS). Frame count matches the reference (sampling varies length).
189
+ NOTE: `onnx/customvoice/cpu_fp32/` is hand-organized and actually holds **voicedesign** content
190
+ (manifest `model_id: .\voicedesign\`). Only this dir has the upgraded talker + residual_embed so
191
+ far; the other device×precision dirs still need the `residual_embed`/2-output-talker re-export.
192
+
193
+ ## Known operational note — memory pressure on full-run export
194
+ A single `optimize.py ... --model voicedesign` (all components) can OOM-kill the process at
195
+ `code_predictor`: the `talker` fp16 step holds ~2.8 GB external data + a ~150 s float16 pass, and
196
+ stacking `code_predictor`'s float16 pass right after exhausts RAM. **No code defect** — every
197
+ component converts cleanly in isolation (verified: code_predictor cuda/fp16 = 354 MB, ½ of fp32).
198
+ Workaround: export heavy parts separately, e.g.
199
+ `--components talker` then `--components code_predictor text_embed codec_embed tok_encoder tok_decoder`.
200
+ `onnx/cuda_fp16/` now holds all 6 (text_embed 639 MB, codec_embed 12.5 MB, talker+.data,
201
+ code_predictor 354 MB, tok_encoder/decoder fp32). TODO(optional): make optimize.py export each
202
+ component in a subprocess so one full-run command can't OOM.
203
+
204
+ ## Build / run
205
+ ```
206
+ uv run optimize.py --model Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign --device cpu --precision int4
207
+ uv run optimize.py --model Qwen/Qwen3-TTS-Tokenizer-12Hz --device cpu --precision fp32
208
+ uv run optimize.py --model voicedesign --skip-download --components tok_decoder # subset
209
+ ```
210
+
211
+ ## Export recipe (learned)
212
+ - Load all sub-models with `attn_implementation="eager"`.
213
+ - Codec transformer parts use `create_causal_mask` (not torch.onnx/TorchScript-traceable)
214
+ → set **`use_dynamo_exporter: true`** on the OnnxConversion pass (done for codec parts).
215
+ - Bypass high-level wrappers with Python loops: `tok_decoder` calls `self.tok.decoder(...)`
216
+ directly (skips `model.decode`'s `chunked_decode` while-loop); transpose codes
217
+ `[B,T,16]→[B,16,T]` + clamp ≥0 first.
218
+ - Codes layout: `model.decode` expects `[B, codes_length, num_quantizers]` (=[B,T,16]).
219
+
220
+ ## Status / next
221
+ - ✅ Vendored `qwen_tts` (codes/), uv-script env (tf 4.57.3) validated, model loads.
222
+ - ✅ `optimize.py` (uv script, model-name + device/precision dispatch) + `user_script.py`.
223
+ - ✅ **6 sub-models export** (text_embed, codec_embed, talker MROPE LLM, code_predictor, tok_encoder, tok_decoder).
224
+ - ✅ **Talker embedding primitives added + verified** (`text_embed`, `codec_embed`). The talker
225
+ prefill itself is control flow (variable text len, voice-clone/ICL branches, concat, MROPE
226
+ positions) → stays in Python (inference.py); only the *learned* lookups are in ONNX:
227
+ - `text_embed` = `text_projection(text_embedding(ids))` [B,T,2048] (also covers tts_bos/eos/pad
228
+ — specific ids); 1.28 GB. `codec_embed` = `codec_embedding(ids)` [B,T,2048]; 25 MB.
229
+ - Parity (`eval_embed.py`, fp32): text_embed cosine 1.000000 / max|Δ| 1.2e-7;
230
+ codec_embed cosine 1.000000 / max|Δ| 0. **Every learned weight now runs through ONNX.**
231
+ - ✅ **`code_predictor` corrected + verified** (was missing `small_to_mtp_projection` 2048→1024 +
232
+ codec-embedding assembly). New `CodePredictorWrapper` folds those in-graph (mirrors
233
+ `forward_sub_talker_finetune`); interface = `(talker_hidden[B,2048], codec_ids[B,16]) →
234
+ group_logits[B,15,vocab]`. Parity (`eval_predictor.py`, fp32, voicedesign):
235
+ wrapper-vs-native cosine 1.000000 / max|Δ| 0 / argmax 100%; ONNX-vs-wrapper cosine 1.000000 /
236
+ max|Δ| 5.7e-5 / argmax 100%. This is the **teacher-forced** variant (needs all 16 codes) — for
237
+ parity; the single-step AR variant for real generation is still TODO.
238
+ - ✅ **tokenizer verified** (`eval_tokenizer.py`): encoder 100% exact index match; decoder cosine
239
+ 1.00000 vs PyTorch. Note: `tok_decoder.onnx` is **fixed at 25 frames** (dynamic frames axis did
240
+ not survive dynamo export) — fine for parity, but inference must chunk/pad to 25.
241
+ - ✅ **precision check** (`check_precision.py`): inspects weight dtypes + quant ops (size is
242
+ misleading — codecs are forced fp32, hence byte-identical across int4/fp16/fp32 dirs).
243
+ - ✅ `tok_encoder` (wav→codes) **resolved** (225 MB, fp32, dynamo) → all 4 sub-models export.
244
+ Mirrors `MimiModel._encode_frame` (bypasses streaming `encode()`), **plus two static-shape
245
+ patches in `user_script.py`** so torch.export gets concrete conv lengths:
246
+ 1. `_patch_mimi_static_padding` — rewrites `MimiConv1d._get_extra_padding_for_conv1d` to
247
+ pure-Python int math (stock builds padding as 0-dim tensors → `.item()` → unbacked
248
+ symints → RVQ `torch.cdist`'s `npoints>25` guard can't resolve).
249
+ 2. `_intify_mimi_convs` — converts each `MimiConv1d`'s `stride`/`kernel_size`/`padding_total`
250
+ buffers (0-dim int64 tensors in this model) + derived `padding_left/right` to Python ints
251
+ (reading a buffer in forward, even via `int()`, is a `.item()` under export).
252
+ Fixed input length (24000); only batch dynamic. Unblocks CustomVoice voice-clone (clones via
253
+ reference codes from this encoder; no separate speaker encoder for custom_voice/voice_design).
254
+ - Vendored patches applied for the trimmed-25Hz case: guarded `AutoConfig.register` in
255
+ `inference/qwen3_tts_tokenizer.py` (skip None 25Hz config; idempotent 12Hz).
256
+ - ⏳ `inference.py` (text→talker→code_predictor→codes→tok_decoder→wav) + `eval.py`.
257
+ - Other models: CustomVoice = same TTS arch; Tokenizer-12Hz = standalone codec
258
+ (tok_decoder works; tok_encoder shares the deferred Mimi issue).
check_precision.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = ["onnx", "numpy"]
4
+ # ///
5
+ """Verify the *actual* numeric precision of exported ONNX sub-models.
6
+
7
+ File size is misleading: the codec parts (tok_encoder/tok_decoder) are forced
8
+ fp32 in every precision dir, so they're byte-identical across cpu_fp16 / cpu_fp32
9
+ / cpu_int4. What really tells you the precision is the weight (initializer)
10
+ dtype histogram + presence of quant ops (MatMulNBits/DequantizeLinear for int4).
11
+
12
+ Usage:
13
+ uv run check_precision.py onnx/cpu_int4 onnx/cpu_fp16 onnx/cpu_fp32
14
+ uv run check_precision.py onnx/cpu_int4/talker.onnx # single file
15
+ """
16
+ import sys
17
+ from collections import Counter
18
+ from pathlib import Path
19
+
20
+ import onnx
21
+ from onnx import numpy_helper, TensorProto
22
+
23
+ DT = {v: k for k, v in TensorProto.DataType.items()}
24
+ QUANT_OPS = {"MatMulNBits", "DequantizeLinear", "QuantizeLinear", "MatMulInteger",
25
+ "DynamicQuantizeLinear", "ConvInteger"}
26
+
27
+
28
+ def inspect(path: Path):
29
+ m = onnx.load(str(path), load_external_data=False)
30
+ g = m.graph
31
+ # initializer dtype histogram, weighted by element count
32
+ bytes_by_dt, count_by_dt = Counter(), Counter()
33
+ for init in g.initializer:
34
+ dt = DT.get(init.data_type, str(init.data_type))
35
+ n = 1
36
+ for d in init.dims:
37
+ n *= d
38
+ count_by_dt[dt] += n
39
+ bytes_by_dt[dt] += 1
40
+ ops = Counter(n.op_type for n in g.node)
41
+ quant = {op: ops[op] for op in QUANT_OPS if op in ops}
42
+
43
+ total = sum(count_by_dt.values()) or 1
44
+ dt_summary = ", ".join(
45
+ f"{dt}:{100*c/total:.1f}%" for dt, c in count_by_dt.most_common()
46
+ )
47
+ # infer label
48
+ if quant:
49
+ label = "INT4/quantized"
50
+ elif count_by_dt.get("FLOAT16", 0) > count_by_dt.get("FLOAT", 0):
51
+ label = "FP16"
52
+ elif count_by_dt.get("FLOAT", 0) > 0:
53
+ label = "FP32"
54
+ else:
55
+ label = "?"
56
+ print(f" {path.name:<22} -> {label}")
57
+ print(f" weight dtypes (by #elements): {dt_summary}")
58
+ if quant:
59
+ print(f" quant ops: {quant}")
60
+
61
+
62
+ def main():
63
+ targets = sys.argv[1:] or ["onnx/cpu_int4", "onnx/cpu_fp16", "onnx/cpu_fp32"]
64
+ for t in targets:
65
+ p = Path(t)
66
+ files = sorted(p.glob("*.onnx")) if p.is_dir() else [p]
67
+ print(f"\n=== {t} ===")
68
+ for f in files:
69
+ inspect(f)
70
+
71
+
72
+ if __name__ == "__main__":
73
+ main()
cpu_fp16/code_predictor.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2bf685d424c0e7b36363c2139fe18c381da3188e037587f87ccfda42c2bd085f
3
+ size 285552428
cpu_fp16/codec_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7f5a774e01e8d8b7d788b4e3839e1188ce5ad3497044aeabca93dfe21ee6aec0
3
+ size 6291797
cpu_fp16/manifest.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_id": ".\\voicedesign\\",
3
+ "model_kind": "tts",
4
+ "device": "cpu",
5
+ "precision": "fp16",
6
+ "execution_provider": "CPUExecutionProvider",
7
+ "sub_models": {
8
+ "code_predictor": {
9
+ "filename": "code_predictor.onnx"
10
+ },
11
+ "codec_embed": {
12
+ "filename": "codec_embed.onnx"
13
+ },
14
+ "residual_embed": {
15
+ "filename": "residual_embed.onnx"
16
+ },
17
+ "talker_cache": {
18
+ "filename": "talker_cache.onnx"
19
+ },
20
+ "text_embed": {
21
+ "filename": "text_embed.onnx"
22
+ },
23
+ "tok_decoder": {
24
+ "filename": "tok_decoder.onnx"
25
+ },
26
+ "tok_encoder": {
27
+ "filename": "tok_encoder.onnx"
28
+ }
29
+ }
30
+ }
cpu_fp16/residual_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:95a5d7ff95bdda5ea71a772dfb4a092313679b8a874af45801de1aa881adc7c4
3
+ size 69215780
cpu_fp16/talker_cache.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a4170b103de61c89ab78a2471c2ca19eea9f6daad7b53b869d1a22c6aa1068c3
3
+ size 891756744
cpu_fp16/text_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3c1ec888fde0960d81e1a17f13c9ad737fb60693453916a49d7e78b171214204
3
+ size 634920759
cpu_fp16/tok_decoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8ec10051735029f6e08b04834128c9108428885c39384f443e49a6790ccb129f
3
+ size 458268831
cpu_fp16/tok_encoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a335eaf525ceef17b457f82704d3bc835d4cb15f42fc6f5908f2e860d3da400
3
+ size 225554101
cpu_fp32/code_predictor.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f2f24868183efd8b287f06f184259ad466504aea68f5ccc8724dfd2ac8a321b3
3
+ size 570723513
cpu_fp32/codec_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ef98bacf689735af788247f6da7d841ad558d06697f3ab055a81bd6daec97e96
3
+ size 12583206
cpu_fp32/manifest.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_id": ".\\voicedesign\\",
3
+ "model_kind": "tts",
4
+ "device": "cpu",
5
+ "precision": "fp32",
6
+ "execution_provider": "CPUExecutionProvider",
7
+ "sub_models": {
8
+ "code_predictor": {
9
+ "filename": "code_predictor.onnx"
10
+ },
11
+ "codec_embed": {
12
+ "filename": "codec_embed.onnx"
13
+ },
14
+ "residual_embed": {
15
+ "filename": "residual_embed.onnx"
16
+ },
17
+ "talker_cache": {
18
+ "filename": "talker_cache.onnx"
19
+ },
20
+ "text_embed": {
21
+ "filename": "text_embed.onnx"
22
+ },
23
+ "tok_decoder": {
24
+ "filename": "tok_decoder.onnx"
25
+ },
26
+ "tok_encoder": {
27
+ "filename": "tok_encoder.onnx"
28
+ }
29
+ }
30
+ }
cpu_fp32/residual_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6e3cf497d918263835a6535cede7d181a5cc5d243825a038eda9bd3c36d112f5
3
+ size 138419704
cpu_fp32/talker_cache.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:e73bd579a0e59bd52017402fe34e161ce169a112af168fdc08d3fcaf9d26b285
3
+ size 4551308
cpu_fp32/text_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8b4bae5548bbcec2f5293e35be320137115a910917dba932666bd6f25c77d9c4
3
+ size 1269839332
cpu_fp32/tok_decoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8ec10051735029f6e08b04834128c9108428885c39384f443e49a6790ccb129f
3
+ size 458268831
cpu_fp32/tok_encoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a335eaf525ceef17b457f82704d3bc835d4cb15f42fc6f5908f2e860d3da400
3
+ size 225554101
cpu_int4/code_predictor.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f0b3a431b90a56d2784599948bcff678d5188e38481d3a991d80c550d0d0bfa2
3
+ size 91668866
cpu_int4/codec_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:bd76cd6b77907c76b6d0055ea6cf7572468ab99dc3451c1176ea2189d1a55f70
3
+ size 2015779
cpu_int4/manifest.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_id": ".\\voicedesign\\",
3
+ "model_kind": "tts",
4
+ "device": "cpu",
5
+ "precision": "int4",
6
+ "execution_provider": "CPUExecutionProvider",
7
+ "sub_models": {
8
+ "code_predictor": {
9
+ "filename": "code_predictor.onnx"
10
+ },
11
+ "codec_embed": {
12
+ "filename": "codec_embed.onnx"
13
+ },
14
+ "residual_embed": {
15
+ "filename": "residual_embed.onnx"
16
+ },
17
+ "talker_cache": {
18
+ "filename": "talker_cache.onnx"
19
+ },
20
+ "text_embed": {
21
+ "filename": "text_embed.onnx"
22
+ },
23
+ "tok_decoder": {
24
+ "filename": "tok_decoder.onnx"
25
+ },
26
+ "tok_encoder": {
27
+ "filename": "tok_encoder.onnx"
28
+ }
29
+ }
30
+ }
cpu_int4/residual_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:eec76513b562f558e76eeeb9b0bfa982de512104cddac372ec368ab504b080a0
3
+ size 22179258
cpu_int4/talker_cache.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:910e1d556cd74f11bbb55fff9d509a962bfc43f23608b62fb0a8215c7acc882f
3
+ size 288573853
cpu_int4/text_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b2923edfce01344dea9bd719e2daf94bda0b45bfd05a307514e9dd5f793dcd09
3
+ size 203384915
cpu_int4/tok_decoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8ec10051735029f6e08b04834128c9108428885c39384f443e49a6790ccb129f
3
+ size 458268831
cpu_int4/tok_encoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a335eaf525ceef17b457f82704d3bc835d4cb15f42fc6f5908f2e860d3da400
3
+ size 225554101
cuda_fp16/codec_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:7f5a774e01e8d8b7d788b4e3839e1188ce5ad3497044aeabca93dfe21ee6aec0
3
+ size 6291797
cuda_fp16/manifest.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_id": ".\\voicedesign\\",
3
+ "model_kind": "tts",
4
+ "device": "cuda",
5
+ "precision": "fp16",
6
+ "execution_provider": "CUDAExecutionProvider",
7
+ "sub_models": {
8
+ "code_predictor": {
9
+ "filename": "code_predictor.onnx"
10
+ },
11
+ "codec_embed": {
12
+ "filename": "codec_embed.onnx"
13
+ },
14
+ "residual_embed": {
15
+ "filename": "residual_embed.onnx"
16
+ },
17
+ "talker_cache": {
18
+ "filename": "talker_cache.onnx"
19
+ },
20
+ "text_embed": {
21
+ "filename": "text_embed.onnx"
22
+ },
23
+ "tok_decoder": {
24
+ "filename": "tok_decoder.onnx"
25
+ },
26
+ "tok_encoder": {
27
+ "filename": "tok_encoder.onnx"
28
+ }
29
+ }
30
+ }
cuda_fp16/residual_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:95a5d7ff95bdda5ea71a772dfb4a092313679b8a874af45801de1aa881adc7c4
3
+ size 69215780
cuda_fp32/manifest.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_id": ".\\voicedesign\\",
3
+ "model_kind": "tts",
4
+ "device": "cuda",
5
+ "precision": "fp32",
6
+ "execution_provider": "CUDAExecutionProvider",
7
+ "sub_models": {
8
+ "code_predictor": {
9
+ "filename": "code_predictor.onnx"
10
+ },
11
+ "codec_embed": {
12
+ "filename": "codec_embed.onnx"
13
+ },
14
+ "residual_embed": {
15
+ "filename": "residual_embed.onnx"
16
+ },
17
+ "talker_cache": {
18
+ "filename": "talker_cache.onnx"
19
+ },
20
+ "text_embed": {
21
+ "filename": "text_embed.onnx"
22
+ },
23
+ "tok_decoder": {
24
+ "filename": "tok_decoder.onnx"
25
+ },
26
+ "tok_encoder": {
27
+ "filename": "tok_encoder.onnx"
28
+ }
29
+ }
30
+ }
cuda_fp32/talker_cache.onnx.data ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:389276b5ef745a38a5d9a5a4e5a39746ed590643f9cd3571eb8d6125c93bfd6e
3
+ size 1774452736
cuda_fp32/text_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8b4bae5548bbcec2f5293e35be320137115a910917dba932666bd6f25c77d9c4
3
+ size 1269839332
cuda_int4/manifest.json ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "model_id": ".\\voicedesign\\",
3
+ "model_kind": "tts",
4
+ "device": "cuda",
5
+ "precision": "int4",
6
+ "execution_provider": "CUDAExecutionProvider",
7
+ "sub_models": {
8
+ "code_predictor": {
9
+ "filename": "code_predictor.onnx"
10
+ },
11
+ "codec_embed": {
12
+ "filename": "codec_embed.onnx"
13
+ },
14
+ "residual_embed": {
15
+ "filename": "residual_embed.onnx"
16
+ },
17
+ "talker_cache": {
18
+ "filename": "talker_cache.onnx"
19
+ },
20
+ "text_embed": {
21
+ "filename": "text_embed.onnx"
22
+ },
23
+ "tok_decoder": {
24
+ "filename": "tok_decoder.onnx"
25
+ },
26
+ "tok_encoder": {
27
+ "filename": "tok_encoder.onnx"
28
+ }
29
+ }
30
+ }
cuda_int4/talker_cache.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:910e1d556cd74f11bbb55fff9d509a962bfc43f23608b62fb0a8215c7acc882f
3
+ size 288573853
cuda_int4/text_embed.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b2923edfce01344dea9bd719e2daf94bda0b45bfd05a307514e9dd5f793dcd09
3
+ size 203384915
cuda_int4/tok_decoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:8ec10051735029f6e08b04834128c9108428885c39384f443e49a6790ccb129f
3
+ size 458268831
cuda_int4/tok_encoder.onnx ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4a335eaf525ceef17b457f82704d3bc835d4cb15f42fc6f5908f2e860d3da400
3
+ size 225554101
eval_cache.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "onnxruntime>=1.20", "numpy", "soundfile", "librosa", "transformers",
5
+ # "numba>=0.60.0", "llvmlite>=0.43.0",
6
+ # ]
7
+ # ///
8
+ """Parity + speed: KV-cache talker loop vs no-cache loop. Greedy → must match exactly.
9
+
10
+ Usage:
11
+ uv run eval_cache.py --model-path onnx/voicedesign/cpu_int4 --tts-dir voicedesign
12
+ """
13
+ import argparse, sys, time
14
+ from pathlib import Path
15
+ import numpy as np
16
+
17
+ HERE = Path(__file__).parent
18
+ sys.path.insert(0, str(HERE))
19
+
20
+
21
+ def main():
22
+ ap = argparse.ArgumentParser()
23
+ ap.add_argument("--model-path", required=True)
24
+ ap.add_argument("--tts-dir", required=True)
25
+ ap.add_argument("--text", default="Hello, this is a KV cache parity test.")
26
+ ap.add_argument("--max-new-tokens", type=int, default=40)
27
+ args = ap.parse_args()
28
+
29
+ from inference import Pipeline
30
+ pipe = Pipeline(args.model_path, tts_dir=args.tts_dir)
31
+ if pipe.talker_cache is None:
32
+ print("no talker_cache.onnx in this dir — nothing to compare"); return
33
+
34
+ kw = dict(language="Auto", max_new_tokens=args.max_new_tokens, do_sample=False,
35
+ sub_do_sample=False, seed=0, verbose=False)
36
+ t0 = time.time(); codes_cached = pipe.generate(args.text, **kw); t_cached = time.time() - t0
37
+ pipe.talker_cache = None # force no-cache path
38
+ t0 = time.time(); codes_nocache = pipe.generate(args.text, **kw); t_nocache = time.time() - t0
39
+
40
+ n = min(len(codes_cached), len(codes_nocache))
41
+ match = float((codes_cached[:n] == codes_nocache[:n]).mean()) if n else -1
42
+ print(f"cached frames={len(codes_cached)} ({t_cached:.1f}s) "
43
+ f"no-cache frames={len(codes_nocache)} ({t_nocache:.1f}s) "
44
+ f"speedup={t_nocache/max(t_cached,1e-9):.2f}x")
45
+ print(f"all-16-codes exact match over {n} frames: {match*100:.2f}%")
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()
eval_embed.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "transformers==4.57.3", "torch", "torchvision", "torchaudio",
5
+ # "onnx", "onnxruntime>=1.20", "numpy", "safetensors",
6
+ # "huggingface_hub", "accelerate", "librosa", "soundfile",
7
+ # ]
8
+ # ///
9
+ """Parity for text_embed / codec_embed ONNX vs the PyTorch wrappers.
10
+
11
+ Usage:
12
+ uv run eval_embed.py --model-path onnx/cpu_fp32 --tts-path voicedesign
13
+ """
14
+ import argparse, json, sys
15
+ from pathlib import Path
16
+ import numpy as np
17
+
18
+ HERE = Path(__file__).parent
19
+ sys.path.insert(0, str(HERE)); sys.path.insert(0, str(HERE / "codes"))
20
+
21
+
22
+ def cosine(a, b):
23
+ a, b = a.ravel().astype(np.float64), b.ravel().astype(np.float64)
24
+ return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
25
+
26
+
27
+ def main():
28
+ ap = argparse.ArgumentParser()
29
+ ap.add_argument("--model-path", required=True)
30
+ ap.add_argument("--tts-path", default="voicedesign")
31
+ args = ap.parse_args()
32
+
33
+ import torch, onnxruntime as ort
34
+ from user_script import (get_text_embed_model, get_codec_embed_model, _tts_dims, _load_tts)
35
+
36
+ mdir = Path(args.model_path)
37
+ sub = json.loads((mdir / "manifest.json").read_text())["sub_models"]
38
+ dims = _tts_dims(_load_tts(args.tts_path))
39
+
40
+ for name, get_model, lo, hi, in_name, out_name in [
41
+ ("text_embed", get_text_embed_model, 0, 1000, "text_ids", "text_embeds"),
42
+ ("codec_embed", get_codec_embed_model, 0, dims["codec_vocab"], "codec_ids", "codec_embeds"),
43
+ ]:
44
+ sess = ort.InferenceSession(str(mdir / sub[name]["filename"]),
45
+ providers=["CPUExecutionProvider"])
46
+ wrap = get_model(args.tts_path)
47
+ ids = torch.randint(lo, hi, (1, 16), dtype=torch.int64)
48
+ with torch.no_grad():
49
+ ref = wrap(ids).numpy()
50
+ got = sess.run(None, {in_name: ids.numpy()})[0]
51
+ print(f"=== {name} === onnx={got.shape} ref={ref.shape} "
52
+ f"cosine={cosine(got, ref):.6f} max|Δ|={np.abs(got-ref).max():.3e}")
53
+
54
+
55
+ if __name__ == "__main__":
56
+ main()
eval_generate.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "transformers==4.57.3", "torch", "torchvision", "torchaudio",
5
+ # "onnx", "onnxruntime>=1.20", "numpy", "safetensors",
6
+ # "huggingface_hub", "accelerate", "librosa", "soundfile",
7
+ # ]
8
+ # ///
9
+ """Parity: ONNX inference.py generate() vs the PyTorch reference (greedy).
10
+
11
+ Compares, on identical text/instruct, greedy (do_sample=False):
12
+ (1) tokenization — reference processor ids vs inference.py AutoTokenizer ids
13
+ (2) first-codebook code sequence — reference talker codes vs ONNX codes
14
+ (frame count, first divergence step, % match up to min length)
15
+
16
+ Greedy isolates correctness from sampling RNG. Small fp diffs can still flip an
17
+ argmax and diverge late; report first-divergence step so a late split (good
18
+ prefill) is distinguishable from step-0 (broken prefill).
19
+
20
+ Usage:
21
+ uv run eval_generate.py --model-path onnx/customvoice/cpu_fp32 --tts-dir voicedesign \
22
+ --text "Hello, this is a test." --instruct "A calm female voice." --max-new-tokens 60
23
+ """
24
+ import argparse, sys
25
+ from pathlib import Path
26
+ import numpy as np
27
+
28
+ HERE = Path(__file__).parent
29
+ sys.path.insert(0, str(HERE)); sys.path.insert(0, str(HERE / "codes"))
30
+
31
+
32
+ def main():
33
+ ap = argparse.ArgumentParser()
34
+ ap.add_argument("--model-path", required=True)
35
+ ap.add_argument("--tts-dir", default="voicedesign")
36
+ ap.add_argument("--text", default="Hello, this is a test.")
37
+ ap.add_argument("--instruct", default="A calm female voice.")
38
+ ap.add_argument("--language", default="Auto")
39
+ ap.add_argument("--max-new-tokens", type=int, default=60)
40
+ args = ap.parse_args()
41
+
42
+ import torch
43
+ from qwen_tts.inference.qwen3_tts_model import Qwen3TTSModel
44
+ from inference import Pipeline
45
+
46
+ # ── reference (PyTorch) ──────────────────────────────────────────────────
47
+ ref = Qwen3TTSModel.from_pretrained(args.tts_dir)
48
+ assistant = f"<|im_start|>assistant\n{args.text}<|im_end|>\n<|im_start|>assistant\n"
49
+ ref_ids = ref._tokenize_texts([assistant])[0] # [1,1,L] or [1,L]
50
+ ref_ids_flat = ref_ids.reshape(-1).tolist()
51
+
52
+ input_ids = ref._tokenize_texts([ref._build_assistant_text(args.text)])
53
+ instruct_ids = [ref._tokenize_texts([ref._build_instruct_text(args.instruct)])[0]]
54
+ with torch.no_grad():
55
+ codes_list, _ = ref.model.generate(
56
+ input_ids=input_ids, instruct_ids=instruct_ids, languages=[args.language],
57
+ non_streaming_mode=True, do_sample=False, subtalker_dosample=False,
58
+ max_new_tokens=args.max_new_tokens, output_hidden_states=True,
59
+ return_dict_in_generate=True)
60
+ ref_codes = codes_list[0].cpu().numpy().astype(np.int64) # [T,16]
61
+ ref_first = ref_codes[:, 0]
62
+
63
+ # ── ONNX (inference.py) ──────────────────────────────────────────────────
64
+ pipe = Pipeline(args.model_path, tts_dir=args.tts_dir)
65
+ onnx_ids = pipe._ids(assistant).reshape(-1).tolist()
66
+ onnx_codes = pipe.generate(
67
+ args.text, language=args.language, instruct=args.instruct,
68
+ max_new_tokens=args.max_new_tokens, do_sample=False, sub_do_sample=False,
69
+ verbose=False)
70
+ onnx_first = onnx_codes[:, 0]
71
+
72
+ # ── report ───────────────────────────────────────────────────────────────
73
+ print("\n=== tokenization ===")
74
+ print(f" ref ids ({len(ref_ids_flat)}): {ref_ids_flat}")
75
+ print(f" onnx ids ({len(onnx_ids)}): {onnx_ids}")
76
+ print(f" MATCH: {ref_ids_flat == onnx_ids}")
77
+
78
+ print("\n=== first-codebook codes (greedy) ===")
79
+ print(f" ref frames={len(ref_first)} onnx frames={len(onnx_first)}")
80
+ n = min(len(ref_first), len(onnx_first))
81
+ if n:
82
+ eq = ref_first[:n] == onnx_first[:n]
83
+ div = int(np.argmax(~eq)) if not eq.all() else n
84
+ print(f" match up to min-len: {int(eq.sum())}/{n} ({100*eq.mean():.1f}%)")
85
+ print(f" first divergence step: {div}{' (none)' if div==n else ''}")
86
+ print(f" ref [:12]: {ref_first[:12].tolist()}")
87
+ print(f" onnx[:12]: {onnx_first[:12].tolist()}")
88
+
89
+ print("\n=== ALL 16 codebooks (greedy) ===")
90
+ m = min(len(ref_codes), len(onnx_codes))
91
+ if m:
92
+ full_eq = (ref_codes[:m] == onnx_codes[:m])
93
+ print(f" full-grid match: {int(full_eq.sum())}/{full_eq.size} "
94
+ f"({100*full_eq.mean():.2f}%) over {m} frames × 16 groups")
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
eval_predictor.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "transformers==4.57.3", "torch", "torchvision", "torchaudio",
5
+ # "onnx", "onnxruntime>=1.20", "numpy", "safetensors",
6
+ # "huggingface_hub", "accelerate", "librosa", "soundfile",
7
+ # ]
8
+ # ///
9
+ """Parity for the teacher-forced code_predictor ONNX.
10
+
11
+ Two checks:
12
+ (1) wrapper vs model.forward_sub_talker_finetune — is the in-graph wrapper
13
+ faithful to the original PyTorch composition?
14
+ (2) ONNX vs wrapper — did export preserve it?
15
+
16
+ Usage:
17
+ uv run eval_predictor.py --model-path onnx/cpu_fp32 --tts-path voicedesign
18
+ """
19
+ import argparse, json, sys
20
+ from pathlib import Path
21
+ import numpy as np
22
+
23
+ HERE = Path(__file__).parent
24
+ sys.path.insert(0, str(HERE)); sys.path.insert(0, str(HERE / "codes"))
25
+
26
+
27
+ def cosine(a, b):
28
+ a, b = a.ravel().astype(np.float64), b.ravel().astype(np.float64)
29
+ return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
30
+
31
+
32
+ def main():
33
+ ap = argparse.ArgumentParser()
34
+ ap.add_argument("--model-path", required=True)
35
+ ap.add_argument("--tts-path", default="voicedesign")
36
+ args = ap.parse_args()
37
+
38
+ import torch, onnxruntime as ort
39
+ from user_script import get_code_predictor_model, _load_tts
40
+
41
+ mdir = Path(args.model_path)
42
+ fn = json.loads((mdir / "manifest.json").read_text())["sub_models"]["code_predictor"]["filename"]
43
+ sess = ort.InferenceSession(str(mdir / fn), providers=["CPUExecutionProvider"])
44
+
45
+ wrap = get_code_predictor_model(args.tts_path)
46
+ talker = _load_tts(args.tts_path).talker
47
+ ng = wrap.n_groups
48
+
49
+ torch.manual_seed(0)
50
+ hidden = torch.randn(1, 2048)
51
+ codes = torch.randint(0, 2048, (1, ng), dtype=torch.int64)
52
+
53
+ with torch.no_grad():
54
+ wrap_logits = wrap(hidden, codes).numpy()
55
+ native_logits, _ = talker.forward_sub_talker_finetune(codes, hidden) # [B,15,vocab]
56
+ native_logits = native_logits.numpy()
57
+ onnx_logits = sess.run(None, {"talker_hidden": hidden.numpy(),
58
+ "codec_ids": codes.numpy()})[0]
59
+
60
+ print("=== (1) wrapper vs native forward_sub_talker_finetune ===")
61
+ print(f" shapes wrap={wrap_logits.shape} native={native_logits.shape}")
62
+ print(f" cosine={cosine(wrap_logits, native_logits):.6f} "
63
+ f"max|Δ|={np.abs(wrap_logits-native_logits).max():.3e} "
64
+ f"argmax agree={(wrap_logits.argmax(-1)==native_logits.argmax(-1)).mean():.3%}")
65
+
66
+ print("=== (2) ONNX vs wrapper ===")
67
+ print(f" shapes onnx={onnx_logits.shape}")
68
+ print(f" cosine={cosine(onnx_logits, wrap_logits):.6f} "
69
+ f"max|Δ|={np.abs(onnx_logits-wrap_logits).max():.3e} "
70
+ f"argmax agree={(onnx_logits.argmax(-1)==wrap_logits.argmax(-1)).mean():.3%}")
71
+
72
+
73
+ if __name__ == "__main__":
74
+ main()
eval_speaker.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "transformers==4.57.3", "torch", "torchvision", "torchaudio",
5
+ # "onnx", "onnxruntime>=1.20", "numpy", "safetensors",
6
+ # "huggingface_hub", "accelerate", "librosa", "soundfile",
7
+ # "numba>=0.60.0", "llvmlite>=0.43.0",
8
+ # ]
9
+ # ///
10
+ """Parity: speaker_encoder.onnx (x-vector, with reimplemented mel/STFT) vs PyTorch
11
+ extract_speaker_embedding. Validates the one genuinely-new ONNX component for Base
12
+ voice cloning across several audio lengths.
13
+
14
+ Usage:
15
+ uv run eval_speaker.py --tts-dir base/1.7B --onnx onnx/base17_cpu_int4/speaker_encoder.onnx
16
+ """
17
+ import argparse, sys
18
+ from pathlib import Path
19
+ import numpy as np
20
+
21
+ HERE = Path(__file__).parent
22
+ sys.path.insert(0, str(HERE)); sys.path.insert(0, str(HERE / "codes"))
23
+
24
+
25
+ def cosine(a, b):
26
+ a, b = a.ravel().astype(np.float64), b.ravel().astype(np.float64)
27
+ return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
28
+
29
+
30
+ def main():
31
+ ap = argparse.ArgumentParser()
32
+ ap.add_argument("--tts-dir", default="base/1.7B")
33
+ ap.add_argument("--onnx", default="onnx/base17_cpu_int4/speaker_encoder.onnx")
34
+ args = ap.parse_args()
35
+
36
+ import torch, onnxruntime as ort
37
+ from user_script import _load_tts
38
+
39
+ model = _load_tts(args.tts_dir)
40
+ assert model.speaker_encoder is not None, "not a base checkpoint"
41
+ sess = ort.InferenceSession(args.onnx, providers=["CPUExecutionProvider"])
42
+ rng = np.random.default_rng(0)
43
+
44
+ for secs in (2.0, 3.5, 6.0):
45
+ n = int(24000 * secs)
46
+ audio = (0.5 * rng.standard_normal(n)).astype(np.float32)
47
+ with torch.no_grad():
48
+ ref = model.extract_speaker_embedding(audio, 24000).cpu().numpy().ravel()
49
+ got = sess.run(None, {"audio": audio[None]})[0].ravel()
50
+ print(f"len={secs:>4}s onnx{got.shape} ref{ref.shape} "
51
+ f"cosine={cosine(got, ref):.6f} max|Δ|={np.abs(got - ref).max():.3e}")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
eval_tokenizer.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "transformers==4.57.3",
5
+ # "torch",
6
+ # "torchvision",
7
+ # "torchaudio",
8
+ # "onnx",
9
+ # "onnxruntime>=1.20",
10
+ # "numpy",
11
+ # "safetensors",
12
+ # "huggingface_hub",
13
+ # "accelerate",
14
+ # "librosa",
15
+ # "soundfile",
16
+ # ]
17
+ # ///
18
+ """Evaluate the exported Qwen3-TTS tokenizer ONNX vs the original PyTorch.
19
+
20
+ Reference = the exact `TokEncoderWrapper` / `TokDecoderWrapper` forwards from
21
+ user_script.py (what optimize.py exported), loaded in fp32 from the tokenizer
22
+ checkpoint. Pins transformers==4.57.3 via PEP-723, like optimize.py.
23
+
24
+ Checks
25
+ ------
26
+ (a) Encoder parity : audio[1,1,24000] → codes[1,frames,16]; exact index match %
27
+ (ONNX vs PyTorch) + per-codebook agreement.
28
+ (b) Decoder parity : codes → waveform; cosine + max|Δ| (ONNX vs PyTorch).
29
+ (c) Round-trip : encode→decode entirely in ONNX; reconstruction vs input
30
+ (and vs the PyTorch round-trip) — cosine / max|Δ|.
31
+
32
+ Usage:
33
+ uv run eval_tokenizer.py --model-path onnx/cpu_fp16
34
+ uv run eval_tokenizer.py --model-path onnx/cpu_fp16 --tok-path tokenizer --save-wav
35
+ """
36
+ import argparse
37
+ import json
38
+ import os
39
+ import sys
40
+ from pathlib import Path
41
+
42
+ import numpy as np
43
+
44
+ HERE = Path(__file__).parent
45
+ sys.path.insert(0, str(HERE))
46
+ sys.path.insert(0, str(HERE / "codes"))
47
+
48
+ SR = 24000
49
+ N = 24000 # encoder is exported with a static 1-second input
50
+
51
+
52
+ def cosine(a, b):
53
+ a, b = a.ravel().astype(np.float64), b.ravel().astype(np.float64)
54
+ return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
55
+
56
+
57
+ def test_signal(seed=0):
58
+ """Deterministic 1 s @ 24 kHz mix of sweeps + harmonics (speech-band-ish)."""
59
+ rng = np.random.default_rng(seed)
60
+ t = np.arange(N) / SR
61
+ sweep = np.sin(2 * np.pi * (120 + 400 * t) * t)
62
+ harm = 0.4 * np.sin(2 * np.pi * 220 * t) + 0.2 * np.sin(2 * np.pi * 440 * t)
63
+ env = np.clip(np.sin(2 * np.pi * 2.5 * t), 0, 1) # syllable-like envelope
64
+ sig = (sweep + harm) * (0.5 + 0.5 * env) + 0.01 * rng.standard_normal(N)
65
+ sig = sig / (np.abs(sig).max() + 1e-6) * 0.95
66
+ return sig.astype(np.float32)[None, None, :] # [1, 1, N]
67
+
68
+
69
+ def main():
70
+ ap = argparse.ArgumentParser(description="Qwen3-TTS tokenizer ONNX vs PyTorch")
71
+ ap.add_argument("--model-path", required=True, help="onnx/{device}_{precision} dir")
72
+ ap.add_argument("--tok-path", default="tokenizer", help="PyTorch tokenizer checkpoint dir")
73
+ ap.add_argument("--save-wav", action="store_true", help="dump input + reconstructions")
74
+ args = ap.parse_args()
75
+
76
+ import torch
77
+ import onnxruntime as ort
78
+ from user_script import get_tok_encoder_model, get_tok_decoder_model
79
+
80
+ mdir = Path(args.model_path)
81
+ manifest = json.loads((mdir / "manifest.json").read_text())
82
+ enc_onnx = mdir / manifest["sub_models"]["tok_encoder"]["filename"]
83
+ dec_onnx = mdir / manifest["sub_models"]["tok_decoder"]["filename"]
84
+ print(f"ONNX dir : {mdir}")
85
+ print(f" encoder : {enc_onnx.name}")
86
+ print(f" decoder : {dec_onnx.name}")
87
+ print(f"PyTorch ref: {args.tok_path}\n")
88
+
89
+ so = ort.SessionOptions()
90
+ so.log_severity_level = 3
91
+ enc_sess = ort.InferenceSession(str(enc_onnx), so, providers=["CPUExecutionProvider"])
92
+ dec_sess = ort.InferenceSession(str(dec_onnx), so, providers=["CPUExecutionProvider"])
93
+
94
+ dec_shape = dec_sess.get_inputs()[0].shape # [batch, frames, 16]
95
+ dec_frames = dec_shape[1] if isinstance(dec_shape[1], int) else None
96
+ print(f" decoder input shape: {dec_shape}"
97
+ + (" (frames FIXED — see note)" if dec_frames else " (frames dynamic)"))
98
+
99
+ def fit_frames(codes, F):
100
+ """Tile/trim codes [1,T,16] to F frames so a fixed-length decoder accepts them."""
101
+ if F is None or codes.shape[1] == F:
102
+ return codes
103
+ T = codes.shape[1]
104
+ idx = np.arange(F) % T
105
+ return codes[:, idx, :]
106
+
107
+ print("Loading PyTorch reference wrappers (fp32) ...")
108
+ enc_pt = get_tok_encoder_model(args.tok_path)
109
+ dec_pt = get_tok_decoder_model(args.tok_path)
110
+
111
+ audio = test_signal()
112
+
113
+ # ── (a) Encoder parity ──────────────────────────────────────────────────
114
+ print("\n=== (a) Encoder parity audio[1,1,24000] → codes[1,T,16] ===")
115
+ codes_onnx = enc_sess.run(None, {"audio": audio})[0]
116
+ with torch.no_grad():
117
+ codes_pt = enc_pt(torch.from_numpy(audio)).cpu().numpy()
118
+ codes_onnx = np.asarray(codes_onnx).astype(np.int64)
119
+ codes_pt = codes_pt.astype(np.int64)
120
+ print(f" shapes onnx={codes_onnx.shape} pytorch={codes_pt.shape}")
121
+ if codes_onnx.shape == codes_pt.shape:
122
+ match = float((codes_onnx == codes_pt).mean())
123
+ print(f" exact index match : {match:.4%}")
124
+ per_cb = (codes_onnx == codes_pt).mean(axis=(0, 1)) # [16]
125
+ worst = int(np.argmin(per_cb))
126
+ print(f" per-codebook match: min={per_cb.min():.3f} (cb{worst}) "
127
+ f"mean={per_cb.mean():.3f} max={per_cb.max():.3f}")
128
+ enc_ok = match > 0.99
129
+ else:
130
+ print(" SHAPE MISMATCH — cannot compare indices")
131
+ enc_ok = False
132
+
133
+ # ── (b) Decoder parity ──────────────────────────────────────────────────
134
+ print("\n=== (b) Decoder parity codes → waveform ===")
135
+ codes_in = fit_frames(codes_pt, dec_frames) # same codes into both decoders
136
+ if dec_frames and codes_in.shape[1] != codes_pt.shape[1]:
137
+ print(f" (decoder is fixed at {dec_frames} frames; tiled {codes_pt.shape[1]}→{dec_frames})")
138
+ wav_onnx = dec_sess.run(None, {"audio_codes": codes_in})[0]
139
+ with torch.no_grad():
140
+ wav_pt = dec_pt(torch.from_numpy(codes_in)).cpu().numpy()
141
+ n = min(wav_onnx.shape[-1], wav_pt.shape[-1])
142
+ c = cosine(wav_onnx[..., :n], wav_pt[..., :n])
143
+ d = float(np.abs(wav_onnx[..., :n] - wav_pt[..., :n]).max())
144
+ print(f" shapes onnx={wav_onnx.shape} pytorch={wav_pt.shape}")
145
+ print(f" cosine={c:.5f} max|Δ|={d:.4e}")
146
+ dec_ok = c > 0.999
147
+
148
+ # ── (c) Round-trip (ONNX encode→decode) ─────────────────────────────────
149
+ print("\n=== (c) Round-trip ONNX encode→decode vs input / PyTorch ===")
150
+ codes_rt = fit_frames(codes_onnx, dec_frames)
151
+ wav_rt_onnx = dec_sess.run(None, {"audio_codes": codes_rt})[0]
152
+ with torch.no_grad():
153
+ wav_rt_pt = dec_pt(torch.from_numpy(codes_rt)).cpu().numpy()
154
+ m = min(wav_rt_onnx.shape[-1], audio.shape[-1])
155
+ print(f" recon vs input : cosine={cosine(wav_rt_onnx[..., :m], audio[..., :m]):.4f}")
156
+ k = min(wav_rt_onnx.shape[-1], wav_rt_pt.shape[-1])
157
+ print(f" ONNX vs PyTorch RT: cosine={cosine(wav_rt_onnx[..., :k], wav_rt_pt[..., :k]):.5f} "
158
+ f"max|Δ|={float(np.abs(wav_rt_onnx[..., :k] - wav_rt_pt[..., :k]).max()):.4e}")
159
+
160
+ if args.save_wav:
161
+ import soundfile as sf
162
+ sf.write(mdir / "eval_input.wav", audio[0, 0], SR)
163
+ sf.write(mdir / "eval_recon_onnx.wav", wav_rt_onnx.reshape(-1), SR)
164
+ sf.write(mdir / "eval_recon_pytorch.wav", wav_rt_pt.reshape(-1), SR)
165
+ print(f"\n wrote eval_input.wav / eval_recon_onnx.wav / eval_recon_pytorch.wav → {mdir}")
166
+
167
+ print("\n=== verdict ===")
168
+ print(f" encoder index parity : {'PASS' if enc_ok else 'CHECK'}")
169
+ print(f" decoder waveform : {'PASS' if dec_ok else 'CHECK'}")
170
+ sys.exit(0 if (enc_ok and dec_ok) else 1)
171
+
172
+
173
+ if __name__ == "__main__":
174
+ main()
inference.py ADDED
@@ -0,0 +1,591 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "onnxruntime>=1.20", "numpy", "soundfile", "librosa", "transformers",
5
+ # "numba>=0.60.0", "llvmlite>=0.43.0",
6
+ # ]
7
+ # ///
8
+ # numba/llvmlite pinned: librosa otherwise pulls numba 0.53.1 → llvmlite 0.36,
9
+ # which fails to build on Python 3.12.
10
+ """Run the exported Qwen3-TTS ONNX sub-parts (build blocks + full text→speech).
11
+
12
+ One --model-path points at onnx/{device}_{precision}/ (flat, manifest-driven):
13
+ text_embed.onnx text_ids[B,T] → text_embeds[B,T,2048] (text_projection∘text_embedding)
14
+ codec_embed.onnx codec_ids[B,T] → codec_embeds[B,T,2048] (talker first-codebook embed)
15
+ talker.onnx inputs_embeds[B,T,2048] + position_ids[3,B,T] + mask → (logits[B,T,V], hidden[B,T,2048])
16
+ code_predictor.onnx talker_hidden[B,2048] + codec_ids[B,16] → group_logits[B,15,vocab] (causal teacher-forced)
17
+ residual_embed.onnx codec_ids[B,16] → step_embed[B,2048] (codec_hiddens.sum — next talker input)
18
+ tok_encoder.onnx audio[B,1,24000] → codes[B,frames,16]
19
+ tok_decoder.onnx codes[B,25,16] → waveform[B,1,L] (FIXED 25 frames)
20
+
21
+ GENERATION (`generate`) mirrors `Qwen3TTSForConditionalGeneration.generate` with
22
+ `non_streaming_mode=True` for the two convertible checkpoints:
23
+ • text-to-speech — text + language
24
+ • voice design — text + instruct (natural-language style) [VoiceDesign]
25
+ • custom voice — text + speaker name (+ optional instruct) [CustomVoice]
26
+ ICL audio-clone (ref_audio/ref_text) is a `base`-model feature (needs the speaker
27
+ encoder, which is absent here) and is intentionally not implemented.
28
+
29
+ The talker is a no-cache forward, so the AR loop re-runs the growing prefix each
30
+ step (correct, O(n²)). MROPE reduces to arange here (`get_rope_index` =
31
+ cumsum(mask)-1, 3 identical rows for an unpadded single sequence).
32
+
33
+ Usage:
34
+ uv run inference.py --model-path onnx/cpu_fp32 --selftest
35
+ uv run inference.py --model-path onnx/cpu_fp32 --tts-dir voicedesign \
36
+ --text "Hello there." --instruct "A calm, low female voice." --out out.wav
37
+ uv run inference.py --model-path onnx/cpu_fp32 --tts-dir customvoice \
38
+ --text "你好。" --speaker ethan --language chinese --out out.wav
39
+ """
40
+ import argparse
41
+ import json
42
+ import sys
43
+ from pathlib import Path
44
+
45
+ import numpy as np
46
+
47
+ SR = 24000
48
+ DEC_FRAMES = 25 # tok_decoder is exported at a fixed 25-frame length
49
+ N_GROUPS = 16
50
+
51
+
52
+ def cosine(a, b):
53
+ a, b = a.ravel().astype(np.float64), b.ravel().astype(np.float64)
54
+ return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
55
+
56
+
57
+ # ── sampling helpers (numpy; mirror HF generate logic) ──────────────────────────
58
+ def _apply_repetition_penalty(logits, prev_ids, penalty):
59
+ if penalty == 1.0 or not prev_ids:
60
+ return logits
61
+ idx = np.array(sorted(set(int(i) for i in prev_ids)), dtype=np.int64)
62
+ sc = logits[idx]
63
+ logits[idx] = np.where(sc < 0, sc * penalty, sc / penalty)
64
+ return logits
65
+
66
+
67
+ def _sample(logits, do_sample, top_k, top_p, temperature, rng):
68
+ logits = logits.astype(np.float64)
69
+ if not do_sample or temperature <= 0:
70
+ return int(np.argmax(logits))
71
+ logits = logits / max(temperature, 1e-6)
72
+ if top_k and top_k > 0:
73
+ k = min(top_k, logits.shape[-1])
74
+ kth = np.partition(logits, -k)[-k]
75
+ logits = np.where(logits < kth, -np.inf, logits)
76
+ logits -= logits.max()
77
+ probs = np.exp(logits)
78
+ probs /= probs.sum()
79
+ if top_p and top_p < 1.0:
80
+ order = np.argsort(probs)[::-1]
81
+ csum = np.cumsum(probs[order])
82
+ cut = np.searchsorted(csum, top_p) + 1
83
+ keep = order[:cut]
84
+ mask = np.zeros_like(probs)
85
+ mask[keep] = probs[keep]
86
+ probs = mask / mask.sum()
87
+ return int(rng.choice(len(probs), p=probs))
88
+
89
+
90
+ class Pipeline:
91
+ """Manifest-driven loader for the exported Qwen3-TTS ONNX sub-parts."""
92
+
93
+ def __init__(self, model_path: str, tts_dir: str = None):
94
+ import onnxruntime as ort
95
+
96
+ self.root = Path(model_path)
97
+ self.manifest = json.loads((self.root / "manifest.json").read_text())
98
+ sm = self.manifest["sub_models"]
99
+ prov = self.manifest.get("execution_provider", "CPUExecutionProvider")
100
+ avail = ort.get_available_providers()
101
+ if prov not in avail:
102
+ print(f" [warn] manifest EP {prov} unavailable; falling back to CPU", file=sys.stderr)
103
+ prov = "CPUExecutionProvider"
104
+ self.provider = prov
105
+
106
+ so = ort.SessionOptions()
107
+ so.log_severity_level = 3
108
+
109
+ def sess(name):
110
+ if name not in sm:
111
+ return None
112
+ return ort.InferenceSession(str(self.root / sm[name]["filename"]),
113
+ so, providers=[prov])
114
+
115
+ self.text_embed = sess("text_embed")
116
+ self.codec_embed = sess("codec_embed")
117
+ self.talker = sess("talker")
118
+ self.code_predictor = sess("code_predictor")
119
+ self.residual_embed = sess("residual_embed")
120
+ self.tok_encoder = sess("tok_encoder")
121
+ self.tok_decoder = sess("tok_decoder")
122
+ self.speaker_encoder = sess("speaker_encoder") # Base only (voice-clone x-vector)
123
+ self.talker_cache = sess("talker_cache") # optional O(n) KV-cache talker
124
+ if self.talker_cache is not None: # ordered past-input names (flattened)
125
+ self._past_names = [i.name for i in self.talker_cache.get_inputs()][3:]
126
+
127
+ # config + tokenizer (only needed for full generation)
128
+ self.tts_dir = tts_dir
129
+ self._cfg = None
130
+ self._tok = None
131
+ if tts_dir is not None:
132
+ self._cfg = json.loads((Path(tts_dir) / "config.json").read_text())
133
+
134
+ # ── building blocks (each verified against PyTorch in eval_*.py) ──────────
135
+ def embed_text(self, text_ids): # [B,T] int64 → [B,T,2048]
136
+ return self.text_embed.run(None, {"text_ids": np.asarray(text_ids, np.int64)})[0]
137
+
138
+ def embed_codec(self, codec_ids): # [B,T] int64 → [B,T,2048]
139
+ return self.codec_embed.run(None, {"codec_ids": np.asarray(codec_ids, np.int64)})[0]
140
+
141
+ def talker_step(self, inputs_embeds, position_ids, attention_mask):
142
+ """→ (logits[B,T,V], hidden[B,T,2048]). Talker emits both since the
143
+ code_predictor is conditioned on the talker's last hidden state."""
144
+ return self.talker.run(None, {
145
+ "inputs_embeds": inputs_embeds.astype(np.float32),
146
+ "position_ids": np.asarray(position_ids, np.int64),
147
+ "attention_mask": np.asarray(attention_mask, np.int64)})
148
+
149
+ def talker_cache_step(self, inputs_embeds, position_ids, attention_mask, past):
150
+ """KV-cache talker: → (logits[B,cur,V], hidden[B,cur,2048], present[list of 56]).
151
+ `past`/`present` are ordered lists of the flattened K/V tensors (layer0_k, layer0_v,
152
+ layer1_k, …). Empty past = prefill; len-1 cur = decode."""
153
+ feed = {"inputs_embeds": inputs_embeds.astype(np.float32),
154
+ "position_ids": np.asarray(position_ids, np.int64),
155
+ "attention_mask": np.asarray(attention_mask, np.int64)}
156
+ for name, t in zip(self._past_names, past):
157
+ feed[name] = t.astype(np.float32)
158
+ out = self.talker_cache.run(None, feed)
159
+ return out[0], out[1], list(out[2:]) # logits, hidden, present
160
+
161
+ def predict_residual(self, talker_hidden, codec_ids): # causal teacher-forced
162
+ return self.code_predictor.run(None, {
163
+ "talker_hidden": talker_hidden.astype(np.float32),
164
+ "codec_ids": np.asarray(codec_ids, np.int64)})[0]
165
+
166
+ def step_embed(self, codec_ids): # [B,16] → [B,2048] (sum of group embeds)
167
+ return self.residual_embed.run(None, {"codec_ids": np.asarray(codec_ids, np.int64)})[0]
168
+
169
+ @staticmethod
170
+ def _load_ref_wav(path):
171
+ """Load a reference wav → mono float32 @ 24 kHz."""
172
+ import soundfile as sf
173
+ wav, sr = sf.read(path, dtype="float32")
174
+ if wav.ndim > 1:
175
+ wav = wav.mean(axis=1)
176
+ if sr != SR:
177
+ import librosa
178
+ wav = librosa.resample(wav, orig_sr=sr, target_sr=SR)
179
+ return wav.astype(np.float32)
180
+
181
+ def encode_chunked(self, wav): # wav [L] 24k → ref codes [T,16]
182
+ """tok_encoder is fixed at 1 s (24000 samples); encode in 1 s windows + concat."""
183
+ out = []
184
+ for s in range(0, max(len(wav), 1), SR):
185
+ c = wav[s:s + SR]
186
+ if len(c) < SR:
187
+ c = np.pad(c, (0, SR - len(c)))
188
+ out.append(self.encode(c.reshape(1, 1, SR).astype(np.float32))[0]) # [frames,16]
189
+ return np.concatenate(out, axis=0).astype(np.int64)
190
+
191
+ def encode(self, audio): # [B,1,24000] → [B,frames,16]
192
+ return self.tok_encoder.run(None, {"audio": audio.astype(np.float32)})[0]
193
+
194
+ def decode(self, codes): # [B,F,16] → [B,1,L]
195
+ return self.tok_decoder.run(None, {"audio_codes": np.asarray(codes, np.int64)})[0]
196
+
197
+ def decode_chunked(self, codes):
198
+ """Decode arbitrary-length codes through the fixed-25-frame decoder by
199
+ tiling each 25-frame chunk; tail is padded by repetition then trimmed."""
200
+ F = codes.shape[1]
201
+ outs = []
202
+ for s in range(0, F, DEC_FRAMES):
203
+ chunk = codes[:, s:s + DEC_FRAMES]
204
+ if chunk.shape[1] < DEC_FRAMES: # pad tail by repeat
205
+ idx = np.arange(DEC_FRAMES) % chunk.shape[1]
206
+ chunk = chunk[:, idx]
207
+ wav = self.decode(chunk)
208
+ keep = int(round(wav.shape[-1] * (F - s) / DEC_FRAMES))
209
+ outs.append(wav[..., :keep]); break
210
+ outs.append(self.decode(chunk))
211
+ return np.concatenate(outs, axis=-1)
212
+
213
+ # ── config / tokenizer accessors ──────────────────────────────────────────
214
+ @property
215
+ def cfg(self):
216
+ if self._cfg is None:
217
+ raise RuntimeError("Pass --tts-dir (the HF model dir) for generation: "
218
+ "config token ids + tokenizer live there.")
219
+ return self._cfg
220
+
221
+ @property
222
+ def model_type(self):
223
+ """tts_model_type from config: 'voice_design' | 'custom_voice' | 'base'.
224
+ Each model exposes different features (see _check_features)."""
225
+ c = self.cfg
226
+ return (c.get("tts_model_type")
227
+ or c.get("talker_config", {}).get("tts_model_type") or "unknown")
228
+
229
+ def _check_features(self, instruct=None, speaker=None, ref_audio=None):
230
+ """Gate features by model type so a flag that the loaded model can't honor
231
+ fails loudly instead of silently doing nothing:
232
+ voice_design → instruct (natural-language style); no speaker/ref
233
+ custom_voice → speaker (built-in voices) + optional instruct; no ref
234
+ base → voice cloning (ref_audio/ref_text); no speaker/instruct
235
+ """
236
+ mt = self.model_type
237
+ if speaker and mt != "custom_voice":
238
+ raise ValueError(f"--speaker is a CustomVoice feature, but this model is '{mt}'. "
239
+ "Use a customvoice checkpoint, or drop --speaker.")
240
+ if instruct and mt not in ("voice_design", "custom_voice"):
241
+ raise ValueError(f"--instruct is a VoiceDesign/CustomVoice feature, but this model "
242
+ f"is '{mt}'. Drop --instruct (Base clones from --ref-audio instead).")
243
+ if ref_audio and mt != "base":
244
+ raise ValueError(f"voice cloning (--ref-audio) is a Base-model feature, but this "
245
+ f"model is '{mt}'. Use a base checkpoint.")
246
+ if mt == "custom_voice" and not speaker:
247
+ print(" [note] CustomVoice with no --speaker → model's default voice.",
248
+ file=sys.stderr)
249
+
250
+ @property
251
+ def tokenizer(self):
252
+ if self._tok is None:
253
+ from transformers import AutoTokenizer
254
+ self._tok = AutoTokenizer.from_pretrained(self.tts_dir, trust_remote_code=True)
255
+ return self._tok
256
+
257
+ def _ids(self, text):
258
+ enc = self.tokenizer(text, return_tensors="np")
259
+ ids = enc["input_ids"]
260
+ return ids if ids.ndim == 2 else ids[None]
261
+
262
+ # ── full text→speech generation ────────────────────────────────────────────
263
+ def generate(self, text, language="Auto", instruct=None, speaker=None,
264
+ ref_audio=None, ref_text=None,
265
+ max_new_tokens=2048, do_sample=True, top_k=50, top_p=1.0,
266
+ temperature=0.9, repetition_penalty=1.05,
267
+ sub_do_sample=True, sub_top_k=50, sub_top_p=1.0, sub_temperature=0.9,
268
+ seed=0, verbose=True):
269
+ """Mirror Qwen3TTSForConditionalGeneration.generate (non_streaming_mode=True).
270
+ Features are gated by model type (voice_design=instruct, custom_voice=speaker,
271
+ base=clone). Returns codes [T,16] (int64). Decode with `decode_chunked(codes[None])`.
272
+ """
273
+ cfg = self.cfg
274
+ self._check_features(instruct=instruct, speaker=speaker, ref_audio=ref_audio)
275
+ if ref_audio:
276
+ return self._generate_clone(text, ref_audio, ref_text, language=language,
277
+ max_new_tokens=max_new_tokens, do_sample=do_sample,
278
+ top_k=top_k, top_p=top_p, temperature=temperature,
279
+ repetition_penalty=repetition_penalty,
280
+ sub_do_sample=sub_do_sample, sub_top_k=sub_top_k,
281
+ sub_top_p=sub_top_p, sub_temperature=sub_temperature,
282
+ seed=seed, verbose=verbose)
283
+ tc = cfg["talker_config"]
284
+ H = tc["hidden_size"]
285
+ rng = np.random.default_rng(seed)
286
+
287
+ # token ids
288
+ tts_bos, tts_eos, tts_pad = (cfg["tts_bos_token_id"], cfg["tts_eos_token_id"],
289
+ cfg["tts_pad_token_id"])
290
+ codec_eos = tc["codec_eos_token_id"]
291
+ codec_pad, codec_bos = tc["codec_pad_id"], tc["codec_bos_id"]
292
+ vocab = tc["vocab_size"]
293
+
294
+ # 0) text → ids (assistant template). role = first 3, trailing = last 5.
295
+ assistant = f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
296
+ input_id = self._ids(assistant) # [1, L]
297
+ if input_id.shape[1] < 9:
298
+ raise ValueError("text tokenized too short for the assistant template")
299
+
300
+ # 1) special text embeds (tts_bos/eos/pad)
301
+ spec = self.embed_text([[tts_bos, tts_eos, tts_pad]]) # [1,3,H]
302
+ bos_e, eos_e, pad_e = spec[:, 0:1], spec[:, 1:2], spec[:, 2:3]
303
+
304
+ # 2) language + codec prefill tags
305
+ lang = (language or "auto").lower()
306
+ if lang == "auto" or lang not in tc.get("codec_language_id", {}):
307
+ language_id = None
308
+ else:
309
+ language_id = tc["codec_language_id"][lang]
310
+ if language_id is None:
311
+ codec_prefill = [[tc["codec_nothink_id"], tc["codec_think_bos_id"],
312
+ tc["codec_think_eos_id"]]]
313
+ else:
314
+ codec_prefill = [[tc["codec_think_id"], tc["codec_think_bos_id"],
315
+ language_id, tc["codec_think_eos_id"]]]
316
+ codec0 = self.embed_codec(codec_prefill) # [1,P,H]
317
+ codec1 = self.embed_codec([[codec_pad, codec_bos]]) # [1,2,H]
318
+
319
+ # speaker (custom voice): speaker name → spk_id → codec table embed
320
+ speaker_embed = None
321
+ if speaker:
322
+ spk_map = tc.get("spk_id", {})
323
+ if speaker.lower() not in spk_map:
324
+ raise ValueError(f"Speaker '{speaker}' not in spk_id {list(spk_map)[:8]}…")
325
+ speaker_embed = self.embed_codec([[spk_map[speaker.lower()]]]) # [1,1,H]
326
+
327
+ if speaker_embed is None:
328
+ codec_input = np.concatenate([codec0, codec1], axis=1)
329
+ else:
330
+ codec_input = np.concatenate([codec0, speaker_embed, codec1], axis=1)
331
+
332
+ # 3) instruct prefix (voice design) — prepended text_projection embeds
333
+ prefix = []
334
+ if instruct:
335
+ instruct_text = f"<|im_start|>user\n{instruct}<|im_end|>\n"
336
+ prefix.append(self.embed_text(self._ids(instruct_text)))
337
+
338
+ # 4) assemble talker prefill (non_streaming_mode=True)
339
+ role = self.embed_text(input_id[:, :3]) # <|im_start|>assistant\n
340
+ pad_block = np.concatenate(
341
+ [np.repeat(pad_e, codec_input.shape[1] - 2, axis=1), bos_e], axis=1)
342
+ talker_in = np.concatenate([role, pad_block + codec_input[:, :-1]], axis=1)
343
+
344
+ body_ids = input_id[:, 3:-5] # pure text tokens
345
+ Ltext = body_ids.shape[1]
346
+ text_body = self.embed_text(body_ids) # [1,Ltext,H]
347
+ block1 = (np.concatenate([text_body, eos_e], axis=1)
348
+ + self.embed_codec([[codec_pad] * (Ltext + 1)]))
349
+ block2 = pad_e + self.embed_codec([[codec_bos]]) # [1,1,H]
350
+ talker_in = np.concatenate([talker_in, block1, block2], axis=1)
351
+ if prefix:
352
+ talker_in = np.concatenate(prefix + [talker_in], axis=1)
353
+ # trailing_text_hidden is just tts_pad in non_streaming mode → add pad_e each step
354
+ trailing = pad_e[:, 0] # [1,H]
355
+
356
+ return self._ar_loop(talker_in, trailing, vocab, codec_eos, max_new_tokens,
357
+ do_sample, top_k, top_p, temperature, repetition_penalty,
358
+ sub_do_sample, sub_top_k, sub_top_p, sub_temperature, seed, verbose)
359
+
360
+ def _ar_loop(self, talker_in, trailing, vocab, codec_eos, max_new_tokens, do_sample,
361
+ top_k, top_p, temperature, repetition_penalty, sub_do_sample, sub_top_k,
362
+ sub_top_p, sub_temperature, seed, verbose):
363
+ """AR talker loop (MROPE→arange). Uses the O(n) KV-cache talker if exported,
364
+ else the no-cache O(n²) talker. Shared by all generation paths. Returns codes [T,16]."""
365
+ if self.talker_cache is not None:
366
+ return self._ar_loop_cached(talker_in, trailing, vocab, codec_eos, max_new_tokens,
367
+ do_sample, top_k, top_p, temperature, repetition_penalty,
368
+ sub_do_sample, sub_top_k, sub_top_p, sub_temperature,
369
+ seed, verbose)
370
+ if self.talker is None:
371
+ raise RuntimeError("no talker model found: need talker_cache.onnx (preferred) or "
372
+ "talker.onnx in the model dir.")
373
+ rng = np.random.default_rng(seed)
374
+ suppress = np.array([i for i in range(vocab - 1024, vocab) if i != codec_eos],
375
+ dtype=np.int64)
376
+ all_codes, prev_first = [], []
377
+ for step in range(max_new_tokens):
378
+ T = talker_in.shape[1]
379
+ pos = np.broadcast_to(np.arange(T), (3, 1, T)).copy()
380
+ mask = np.ones((1, T), dtype=np.int64)
381
+ logits, hidden = self.talker_step(talker_in, pos, mask)
382
+ first = logits[0, -1].astype(np.float64).copy()
383
+ first[suppress] = -np.inf
384
+ first = _apply_repetition_penalty(first, prev_first, repetition_penalty)
385
+ code0 = _sample(first, do_sample, top_k, top_p, temperature, rng)
386
+ if code0 == codec_eos:
387
+ break
388
+ prev_first.append(code0)
389
+ th = hidden[0, -1][None].astype(np.float32)
390
+ codes16 = np.zeros((1, N_GROUPS), dtype=np.int64)
391
+ codes16[0, 0] = code0
392
+ for j in range(1, N_GROUPS):
393
+ gl = self.predict_residual(th, codes16)
394
+ codes16[0, j] = _sample(gl[0, j - 1], sub_do_sample, sub_top_k, sub_top_p,
395
+ sub_temperature, rng)
396
+ all_codes.append(codes16[0].copy())
397
+ nxt = self.step_embed(codes16)[:, None] + trailing[:, None]
398
+ talker_in = np.concatenate([talker_in, nxt], axis=1)
399
+ if verbose and (step + 1) % 25 == 0:
400
+ print(f" …{step + 1} frames", file=sys.stderr)
401
+ codes = np.stack(all_codes, axis=0).astype(np.int64) if all_codes \
402
+ else np.zeros((0, N_GROUPS), np.int64)
403
+ if verbose:
404
+ print(f" generated {codes.shape[0]} frames")
405
+ return codes
406
+
407
+ def _ar_loop_cached(self, talker_in, trailing, vocab, codec_eos, max_new_tokens, do_sample,
408
+ top_k, top_p, temperature, repetition_penalty, sub_do_sample, sub_top_k,
409
+ sub_top_p, sub_temperature, seed, verbose):
410
+ """O(n) KV-cache AR loop: prefill once, then decode one token/step feeding the cache.
411
+ Numerically identical to the no-cache loop (same positions, full causal attention)."""
412
+ rng = np.random.default_rng(seed)
413
+ suppress = np.array([i for i in range(vocab - 1024, vocab) if i != codec_eos], dtype=np.int64)
414
+ past = [np.zeros((1, 8, 0, 128), np.float32) for _ in self._past_names]
415
+ T0 = talker_in.shape[1]
416
+ pos = np.broadcast_to(np.arange(T0), (3, 1, T0)).copy()
417
+ logits, hidden, past = self.talker_cache_step(talker_in, pos, np.ones((1, T0), np.int64), past)
418
+ total = T0
419
+ all_codes, prev_first = [], []
420
+ for step in range(max_new_tokens):
421
+ first = logits[0, -1].astype(np.float64).copy()
422
+ first[suppress] = -np.inf
423
+ first = _apply_repetition_penalty(first, prev_first, repetition_penalty)
424
+ code0 = _sample(first, do_sample, top_k, top_p, temperature, rng)
425
+ if code0 == codec_eos:
426
+ break
427
+ prev_first.append(code0)
428
+ th = hidden[0, -1][None].astype(np.float32)
429
+ codes16 = np.zeros((1, N_GROUPS), dtype=np.int64)
430
+ codes16[0, 0] = code0
431
+ for j in range(1, N_GROUPS):
432
+ gl = self.predict_residual(th, codes16)
433
+ codes16[0, j] = _sample(gl[0, j - 1], sub_do_sample, sub_top_k, sub_top_p,
434
+ sub_temperature, rng)
435
+ all_codes.append(codes16[0].copy())
436
+ nxt = self.step_embed(codes16)[:, None] + trailing[:, None] # [1,1,H]
437
+ pos = np.broadcast_to(np.array([total]), (3, 1, 1)).copy()
438
+ logits, hidden, past = self.talker_cache_step(
439
+ nxt, pos, np.ones((1, total + 1), np.int64), past)
440
+ total += 1
441
+ if verbose and (step + 1) % 25 == 0:
442
+ print(f" …{step + 1} frames (cached)", file=sys.stderr)
443
+ codes = np.stack(all_codes, axis=0).astype(np.int64) if all_codes \
444
+ else np.zeros((0, N_GROUPS), np.int64)
445
+ if verbose:
446
+ print(f" generated {codes.shape[0]} frames (KV-cache)")
447
+ return codes
448
+
449
+ def _generate_clone(self, text, ref_audio, ref_text, language="Auto", max_new_tokens=2048,
450
+ do_sample=True, top_k=50, top_p=1.0, temperature=0.9,
451
+ repetition_penalty=1.05, sub_do_sample=True, sub_top_k=50, sub_top_p=1.0,
452
+ sub_temperature=0.9, seed=0, verbose=True):
453
+ """Base-model voice cloning (ICL), faithful to generate_icl_prompt (modeling L1968)
454
+ + the x-vector speaker prompt. Reference audio → codes (tok_encoder) + x-vector
455
+ (speaker_encoder); prompt = role + codec tags(+x-vector) + [ref_text+text+eos / codec_bos
456
+ + per-frame ref-code sum]. Returns generated codes [T,16]."""
457
+ if self.speaker_encoder is None:
458
+ raise RuntimeError("speaker_encoder.onnx missing — export it for the Base model: "
459
+ "`uv run optimize.py --model base/1.7B --components speaker_encoder`.")
460
+ if not ref_text:
461
+ raise ValueError("voice clone requires --ref-text (transcript of --ref-audio).")
462
+ cfg = self.cfg; tc = cfg["talker_config"]; H = tc["hidden_size"]
463
+ tts_bos, tts_eos, tts_pad = (cfg["tts_bos_token_id"], cfg["tts_eos_token_id"],
464
+ cfg["tts_pad_token_id"])
465
+ codec_eos = tc["codec_eos_token_id"]
466
+ codec_pad, codec_bos = tc["codec_pad_id"], tc["codec_bos_id"]; vocab = tc["vocab_size"]
467
+
468
+ # reference audio → codes (tok_encoder, 1 s windows) + x-vector (speaker_encoder)
469
+ wav = self._load_ref_wav(ref_audio)
470
+ ref_code = self.encode_chunked(wav) # [T_ref,16]
471
+ spk = self.speaker_encoder.run(None, {"audio": wav[None].astype(np.float32)})[0]
472
+ spk = spk.reshape(1, 1, H) # x-vector [1,1,H]
473
+
474
+ assistant = f"<|im_start|>assistant\n{text}<|im_end|>\n<|im_start|>assistant\n"
475
+ input_id = self._ids(assistant)
476
+ ref_id = self._ids(f"<|im_start|>assistant\n{ref_text}<|im_end|>\n")[:, 3:-2]
477
+ text_id = input_id[:, 3:-5]
478
+
479
+ spec = self.embed_text([[tts_bos, tts_eos, tts_pad]])
480
+ bos_e, eos_e, pad_e = spec[:, 0:1], spec[:, 1:2], spec[:, 2:3]
481
+ lang = (language or "auto").lower()
482
+ language_id = (tc["codec_language_id"][lang]
483
+ if lang != "auto" and lang in tc.get("codec_language_id", {}) else None)
484
+ codec_prefill = ([[tc["codec_nothink_id"], tc["codec_think_bos_id"], tc["codec_think_eos_id"]]]
485
+ if language_id is None else
486
+ [[tc["codec_think_id"], tc["codec_think_bos_id"], language_id,
487
+ tc["codec_think_eos_id"]]])
488
+ codec0 = self.embed_codec(codec_prefill)
489
+ codec1 = self.embed_codec([[codec_pad, codec_bos]])
490
+ codec_input = np.concatenate([codec0, spk, codec1], axis=1) # x-vector injected
491
+
492
+ role = self.embed_text(input_id[:, :3])
493
+ pad_block = np.concatenate([np.repeat(pad_e, codec_input.shape[1] - 2, axis=1), bos_e], axis=1)
494
+ base = np.concatenate([role, pad_block + codec_input[:, :-1]], axis=1)
495
+
496
+ # ICL block (generate_icl_prompt, non_streaming): the per-frame ref-code sum IS step_embed
497
+ text_embed = np.concatenate([self.embed_text(np.concatenate([ref_id, text_id], axis=1)),
498
+ eos_e], axis=1) # [1,T1,H]
499
+ T1 = text_embed.shape[1]
500
+ codec_embed = np.concatenate([self.embed_codec([[codec_bos]]),
501
+ self.step_embed(ref_code)[None]], axis=1) # [1,1+T_ref,H]
502
+ icl = text_embed + self.embed_codec([[codec_pad] * T1])
503
+ icl = np.concatenate([icl, codec_embed + pad_e], axis=1)
504
+ talker_in = np.concatenate([base, icl], axis=1)
505
+ trailing = pad_e[:, 0]
506
+ if verbose:
507
+ print(f" [clone] ref {ref_code.shape[0]} frames + ref_text {ref_id.shape[1]} toks "
508
+ f"+ text {text_id.shape[1]} toks → prefill {talker_in.shape[1]}", file=sys.stderr)
509
+ return self._ar_loop(talker_in, trailing, vocab, codec_eos, max_new_tokens, do_sample,
510
+ top_k, top_p, temperature, repetition_penalty, sub_do_sample,
511
+ sub_top_k, sub_top_p, sub_temperature, seed, verbose)
512
+
513
+
514
+ def selftest(pipe: Pipeline):
515
+ """Codec round-trip + building-block smoke test on the loaded EP."""
516
+ print(f"Provider: {pipe.provider}")
517
+ rng = np.random.default_rng(0)
518
+ t = np.arange(SR) / SR
519
+ audio = (0.6 * np.sin(2 * np.pi * (180 + 300 * t) * t)
520
+ + 0.01 * rng.standard_normal(SR)).astype(np.float32)[None, None, :]
521
+
522
+ codes = pipe.encode(audio)
523
+ print(f" encode : audio{audio.shape} -> codes{codes.shape}")
524
+ wav = pipe.decode_chunked(codes)
525
+ print(f" decode : codes{codes.shape} -> wav{wav.shape}")
526
+
527
+ te = pipe.embed_text(rng.integers(0, 1000, (1, 8)))
528
+ ce = pipe.embed_codec(rng.integers(0, 2048, (1, 8)))
529
+ print(f" text_embed{te.shape} codec_embed{ce.shape}")
530
+ th = rng.standard_normal((1, 2048)).astype(np.float32)
531
+ gl = pipe.predict_residual(th, rng.integers(0, 2048, (1, N_GROUPS)))
532
+ print(f" code_predictor group_logits{gl.shape}")
533
+ if pipe.residual_embed is not None:
534
+ se = pipe.step_embed(rng.integers(0, 2048, (1, N_GROUPS)))
535
+ print(f" residual_embed step_embed{se.shape}")
536
+ else:
537
+ print(" [warn] residual_embed.onnx missing — re-export for generate()")
538
+ print("OK — codec path + building blocks run on this EP.")
539
+
540
+
541
+ def main():
542
+ ap = argparse.ArgumentParser(description="Qwen3-TTS ONNX inference")
543
+ ap.add_argument("--model-path", required=True, help="onnx/{device}_{precision} dir")
544
+ ap.add_argument("--tts-dir", help="HF model dir (config + tokenizer) — needed for --text")
545
+ ap.add_argument("--selftest", action="store_true", help="codec round-trip + block smoke test")
546
+ ap.add_argument("--text", help="text to synthesize (full generation)")
547
+ ap.add_argument("--instruct", help="voice-design style instruction (VoiceDesign only)")
548
+ ap.add_argument("--speaker", help="built-in speaker name (CustomVoice only)")
549
+ ap.add_argument("--ref-audio", help="reference wav for voice cloning (Base only)")
550
+ ap.add_argument("--ref-text", help="transcript of --ref-audio (Base clone)")
551
+ ap.add_argument("--language", default="Auto", help="language (default Auto)")
552
+ ap.add_argument("--out", default="out.wav", help="output wav path for --text")
553
+ ap.add_argument("--max-new-tokens", type=int, default=2048)
554
+ ap.add_argument("--greedy", action="store_true", help="disable sampling (argmax)")
555
+ ap.add_argument("--seed", type=int, default=0)
556
+ ap.add_argument("--save-wav", help="write the self-test reconstruction to this path")
557
+ args = ap.parse_args()
558
+
559
+ pipe = Pipeline(args.model_path, tts_dir=args.tts_dir)
560
+
561
+ if args.text is not None:
562
+ print(f" model_type={pipe.model_type}", file=sys.stderr)
563
+ codes = pipe.generate(
564
+ args.text, language=args.language, instruct=args.instruct,
565
+ speaker=args.speaker, ref_audio=args.ref_audio, ref_text=args.ref_text,
566
+ max_new_tokens=args.max_new_tokens,
567
+ do_sample=not args.greedy, sub_do_sample=not args.greedy, seed=args.seed)
568
+ if codes.shape[0] == 0:
569
+ print(" [warn] no frames generated (immediate EOS)"); return
570
+ wav = pipe.decode_chunked(codes[None]).reshape(-1)
571
+ import soundfile as sf
572
+ sf.write(args.out, wav, SR)
573
+ print(f" wrote {args.out} ({wav.shape[0] / SR:.2f}s)")
574
+ return
575
+
576
+ if args.selftest:
577
+ selftest(pipe)
578
+ if args.save_wav:
579
+ import soundfile as sf
580
+ rng = np.random.default_rng(0)
581
+ t = np.arange(SR) / SR
582
+ audio = (0.6 * np.sin(2 * np.pi * (180 + 300 * t) * t)).astype(np.float32)[None, None, :]
583
+ sf.write(args.save_wav, pipe.decode_chunked(pipe.encode(audio)).reshape(-1), SR)
584
+ print(f" wrote {args.save_wav}")
585
+ return
586
+
587
+ ap.error("nothing to do: pass --selftest or --text ...")
588
+
589
+
590
+ if __name__ == "__main__":
591
+ main()
info.yml ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ keywords:
2
+ - olive-ai
3
+ - tts
4
+ - speech-synthesis
5
+ recipes:
6
+ - name: Qwen3-TTS-VoiceDesign
7
+ file: optimize.py
8
+ eps:
9
+ - CPUExecutionProvider
10
+ devices:
11
+ - cpu
optimize.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "transformers==4.57.3",
5
+ # "torch",
6
+ # "torchvision",
7
+ # "torchaudio",
8
+ # "olive-ai",
9
+ # "onnx",
10
+ # "onnxruntime>=1.20",
11
+ # "onnxruntime-genai",
12
+ # "safetensors",
13
+ # "numpy",
14
+ # "tabulate",
15
+ # "huggingface_hub",
16
+ # "accelerate",
17
+ # "librosa",
18
+ # "numba>=0.60.0",
19
+ # "llvmlite>=0.43.0",
20
+ # "soundfile",
21
+ # ]
22
+ # ///
23
+ # numba/llvmlite pinned >=0.60/0.43: librosa otherwise resolves to numba 0.53.1 →
24
+ # llvmlite 0.36, which fails to build on Python 3.12 ("only versions <3.10 supported").
25
+ """Qwen3-TTS → ONNX sub-parts. Run with: uv run optimize.py --model <name|path> ...
26
+
27
+ Self-contained uv script: pins transformers==4.57.3 (the version the vendored
28
+ qwen_tts modeling needs) in an isolated env, leaving the shared 5.10.2 venv alone.
29
+
30
+ Handles three model kinds (auto-detected from config.json `model_type`):
31
+ qwen3_tts → TTS : talker (+ code_predictor) [+ embedded tokenizer]
32
+ qwen3_tts_tokenizer_12hz → codec: tok_encoder + tok_decoder
33
+
34
+ Outputs flat sub-parts under onnx/{device}_{precision}/ + manifest.json.
35
+
36
+ --device {cpu,cuda} --precision {int4,fp16,fp32}
37
+
38
+ The talker is a custom Qwen3+MROPE LLM (dual codec/text embedding, codec_head) and is
39
+ NOT onnxruntime-genai ModelBuilder-compatible (MROPE + dual embedding don't map to a
40
+ stock Qwen3) — it is exported via Olive like the rest. (ModelBuilder remap is attempted
41
+ only when the talker is detectably stock; otherwise Olive is used.)
42
+
43
+ Usage:
44
+ uv run optimize.py --model Qwen/Qwen3-TTS-12Hz-1.7B-VoiceDesign --device cpu --precision int4
45
+ uv run optimize.py --model Qwen/Qwen3-TTS-12Hz-1.7B-CustomVoice --device cpu --precision fp16
46
+ uv run optimize.py --model Qwen/Qwen3-TTS-Tokenizer-12Hz --device cpu --precision fp32
47
+ uv run optimize.py --model voicedesign --skip-download # use local dir
48
+ """
49
+ import argparse
50
+ import json
51
+ import shutil
52
+ import subprocess
53
+ import sys
54
+ from pathlib import Path
55
+
56
+ HERE = Path(__file__).parent
57
+ PROVIDER = {"cpu": "CPUExecutionProvider", "cuda": "CUDAExecutionProvider"}
58
+ OLIVE_DEV = {"cpu": "cpu", "cuda": "gpu"}
59
+
60
+ # component → (loader, io_config, dummy_inputs, model_subdir or "")
61
+ TTS_COMPONENTS = {
62
+ "text_embed": ("get_text_embed_model", "get_text_embed_io_config", "get_text_embed_dummy_inputs", ""),
63
+ "codec_embed": ("get_codec_embed_model", "get_codec_embed_io_config", "get_codec_embed_dummy_inputs", ""),
64
+ "talker": ("get_talker_model", "get_talker_io_config", "get_talker_dummy_inputs", ""),
65
+ "talker_cache": ("get_talker_cache_model", "get_talker_cache_io_config", "get_talker_cache_dummy_inputs", ""),
66
+ "code_predictor": ("get_code_predictor_model", "get_code_predictor_io_config", "get_code_predictor_dummy_inputs", ""),
67
+ "residual_embed": ("get_residual_embed_model", "get_residual_embed_io_config", "get_residual_embed_dummy_inputs", ""),
68
+ "tok_encoder": ("get_tok_encoder_model", "get_tok_encoder_io_config", "get_tok_encoder_dummy_inputs", "speech_tokenizer"),
69
+ "tok_decoder": ("get_tok_decoder_model", "get_tok_decoder_io_config", "get_tok_decoder_dummy_inputs", "speech_tokenizer"),
70
+ "speaker_encoder": ("get_speaker_encoder_model", "get_speaker_encoder_io_config", "get_speaker_encoder_dummy_inputs", ""),
71
+ }
72
+ TOKENIZER_COMPONENTS = {
73
+ "tok_encoder": ("get_tok_encoder_model", "get_tok_encoder_io_config", "get_tok_encoder_dummy_inputs", ""),
74
+ "tok_decoder": ("get_tok_decoder_model", "get_tok_decoder_io_config", "get_tok_decoder_dummy_inputs", ""),
75
+ }
76
+ # audio codecs + speaker encoder stay fp32 (DAC/conv int4/fp16 too lossy; ECAPA small)
77
+ FP32_ONLY = {"tok_encoder", "tok_decoder", "speaker_encoder"}
78
+
79
+
80
+ def output_root(device, precision):
81
+ return HERE / "onnx" / f"{device}_{precision}"
82
+
83
+
84
+ def resolve_model(model: str, skip_download: bool) -> Path:
85
+ """Local dir (relative or absolute) or HF id → local checkpoint dir."""
86
+ p = Path(model)
87
+ if p.is_dir():
88
+ return p
89
+ local = HERE / model.split("/")[-1].lower()
90
+ if local.is_dir():
91
+ return local
92
+ if skip_download:
93
+ raise FileNotFoundError(f"{model} not found locally and --skip-download set")
94
+ from huggingface_hub import snapshot_download
95
+ dst = HERE / model.split("/")[-1]
96
+ print(f" Downloading {model} → {dst}")
97
+ snapshot_download(repo_id=model, local_dir=str(dst))
98
+ return dst
99
+
100
+
101
+ def detect_type(model_dir: Path) -> str:
102
+ cfg = json.loads((model_dir / "config.json").read_text())
103
+ return cfg.get("model_type", "")
104
+
105
+
106
+ def build_component(name, funcs, model_dir, out_dir, device, precision):
107
+ from olive import run
108
+ loader, io_cfg, dummy, subdir = funcs
109
+ src = model_dir / subdir if subdir else model_dir
110
+ prec = "fp32" if name in FP32_ONLY else precision
111
+ passes = {"c": {"type": "OnnxConversion", "target_opset": 20}}
112
+ # Transformers using create_causal_mask aren't torch.onnx(TorchScript)-traceable
113
+ # → dynamo exporter (codec parts + the MROPE talker).
114
+ if name in FP32_ONLY or name in ("talker", "talker_cache"):
115
+ passes["c"]["use_dynamo_exporter"] = True
116
+ if prec == "int4":
117
+ passes["q"] = {"type": "OnnxBlockwiseRtnQuantization", "bits": 4,
118
+ "block_size": 32, "is_symmetric": False}
119
+ elif prec == "fp16":
120
+ passes["h"] = {"type": "OnnxFloatToFloat16"}
121
+
122
+ tmp = (out_dir / f"_{name}_tmp").resolve()
123
+ cfg = {
124
+ "input_model": {"type": "PyTorchModel", "model_path": str(src.resolve()),
125
+ "model_loader": loader,
126
+ "model_script": str((HERE / "user_script.py").resolve()),
127
+ "io_config": io_cfg, "dummy_inputs_func": dummy},
128
+ "systems": {"local_system": {"type": "LocalSystem", "accelerators": [
129
+ {"device": OLIVE_DEV[device], "execution_providers": [PROVIDER[device]]}]}},
130
+ "passes": passes, "target": "local_system", "log_severity_level": 2,
131
+ "output_dir": str(tmp), "cache_dir": str((out_dir / "_ocache").resolve()),
132
+ "no_artifacts": True,
133
+ }
134
+ tmp_json = out_dir / f"_{name}.json"
135
+ tmp_json.write_text(json.dumps(cfg, indent=2))
136
+ print(f" Olive export {name} (precision={prec}) → {out_dir}/{name}.onnx")
137
+ run(str(tmp_json))
138
+ tmp_json.unlink(missing_ok=True)
139
+ moved_data = False
140
+ for f in tmp.glob("model.onnx*"):
141
+ dst = out_dir / f.name.replace("model.onnx", f"{name}.onnx")
142
+ if dst.exists():
143
+ dst.unlink()
144
+ shutil.move(str(f), str(dst))
145
+ if dst.name.endswith(".onnx.data"):
146
+ moved_data = True
147
+ shutil.rmtree(tmp, ignore_errors=True)
148
+ shutil.rmtree(out_dir / "_ocache", ignore_errors=True)
149
+ if moved_data:
150
+ relink_external_data(out_dir / f"{name}.onnx", f"{name}.onnx.data")
151
+
152
+
153
+ def relink_external_data(onnx_path, new_data_name):
154
+ """Point a renamed model's external_data references at the renamed .data file.
155
+
156
+ Olive writes `model.onnx` + `model.onnx.data`; we flatten to `{name}.onnx[.data]`
157
+ but the proto still references `model.onnx.data`, so onnxruntime fails to load
158
+ (`External data path does not exist`). Rewrite each tensor's `location` entry.
159
+ Proto-only (load_external_data=False) so we never pull the multi-GB blob into RAM.
160
+ """
161
+ import onnx
162
+ m = onnx.load(str(onnx_path), load_external_data=False)
163
+ n = 0
164
+ for t in m.graph.initializer:
165
+ if t.HasField("data_location") and t.data_location == onnx.TensorProto.EXTERNAL:
166
+ for kv in t.external_data:
167
+ if kv.key == "location":
168
+ kv.value = new_data_name
169
+ n += 1
170
+ onnx.save(m, str(onnx_path)) # proto only; .data already on disk
171
+ print(f" relinked {n} external-data refs → {new_data_name}")
172
+
173
+
174
+ def write_manifest(out_dir, model, device, precision, model_kind, built):
175
+ # include every sub-part present on disk (robust to incremental builds)
176
+ present = sorted({p.stem for p in out_dir.glob("*.onnx")})
177
+ manifest = {
178
+ "model_id": model, "model_kind": model_kind, "device": device,
179
+ "precision": precision, "execution_provider": PROVIDER[device],
180
+ "sub_models": {n: {"filename": f"{n}.onnx"} for n in present},
181
+ }
182
+ (out_dir / "manifest.json").write_text(json.dumps(manifest, indent=2))
183
+ print(f" Wrote {out_dir / 'manifest.json'}")
184
+
185
+
186
+ def main():
187
+ ap = argparse.ArgumentParser(description="Qwen3-TTS → ONNX sub-parts")
188
+ ap.add_argument("--model", required=True, help="HF id or local dir")
189
+ ap.add_argument("--device", choices=["cpu", "cuda"], default="cpu")
190
+ ap.add_argument("--precision", choices=["int4", "fp16", "fp32"], default="int4")
191
+ ap.add_argument("--components", nargs="*", default=None, help="subset to build")
192
+ ap.add_argument("--skip-download", action="store_true")
193
+ ap.add_argument("--no-isolate", action="store_true",
194
+ help="build everything in ONE process (may OOM on the talker). Default: "
195
+ "each component runs in its own subprocess for fresh memory.")
196
+ ap.add_argument("--_child", action="store_true", help=argparse.SUPPRESS) # internal
197
+ args = ap.parse_args()
198
+
199
+ model_dir = resolve_model(args.model, args.skip_download)
200
+ mtype = detect_type(model_dir)
201
+ if mtype == "qwen3_tts":
202
+ kind, comp_map = "tts", TTS_COMPONENTS
203
+ elif mtype.startswith("qwen3_tts_tokenizer"):
204
+ kind, comp_map = "tokenizer", TOKENIZER_COMPONENTS
205
+ else:
206
+ raise ValueError(f"Unknown model_type '{mtype}' in {model_dir}")
207
+
208
+ if args.components:
209
+ components = [c for c in args.components if c in comp_map]
210
+ else:
211
+ components = list(comp_map)
212
+ # speaker_encoder exists only in `base` checkpoints — skip it by default elsewhere
213
+ # (it raises in the wrapper otherwise). Still buildable if requested explicitly.
214
+ tts_type = json.loads((model_dir / "config.json").read_text()).get("tts_model_type")
215
+ if "speaker_encoder" in components and tts_type != "base":
216
+ components.remove("speaker_encoder")
217
+ # talker and talker_cache hold the SAME transformer weights (no-cache vs KV-cache
218
+ # forward). Shipping both duplicates ~870 MB. Default to talker_cache only (it does
219
+ # prefill+decode, is faster O(n), and is what inference auto-uses); the plain no-cache
220
+ # `talker` is still buildable explicitly if a simpler graph is wanted.
221
+ if "talker" in components and "talker_cache" in components:
222
+ components.remove("talker")
223
+ out_dir = output_root(args.device, args.precision)
224
+ out_dir.mkdir(parents=True, exist_ok=True)
225
+ print(f"Model {args.model} [{kind}] | {args.device}/{args.precision} → {out_dir}")
226
+ print(f"Components: {components}\n")
227
+
228
+ # ── memory-isolation: re-invoke this script once per component in a fresh subprocess.
229
+ # The talker's big dynamo/float16 pass commits a lot of virtual memory; stacking it on
230
+ # top of others in one process OOM/pagefile-kills on Windows. One process per component
231
+ # releases everything between builds. (Reuses sys.executable = the resolved uv env.)
232
+ if not args._child and not args.no_isolate and len(components) > 1:
233
+ print(f"Isolating {len(components)} components in subprocesses (avoids OOM)…\n")
234
+ ok, failed = [], []
235
+ for comp in components:
236
+ cmd = [sys.executable, str(Path(__file__).resolve()),
237
+ "--model", args.model, "--device", args.device,
238
+ "--precision", args.precision, "--components", comp, "--_child"]
239
+ if args.skip_download:
240
+ cmd.append("--skip-download")
241
+ print(f"=== [subprocess] {comp} ===")
242
+ rc = subprocess.run(cmd).returncode
243
+ (ok if rc == 0 else failed).append(comp)
244
+ if rc != 0:
245
+ print(f" [warn] component '{comp}' failed (rc {rc}) — continuing")
246
+ print()
247
+ write_manifest(out_dir, args.model, args.device, args.precision, kind, ok)
248
+ print(f"\nDone → {out_dir} built={ok}" + (f" FAILED={failed}" if failed else ""))
249
+ return
250
+
251
+ # direct build (single component, --_child subprocess, or --no-isolate)
252
+ built = []
253
+ for name in components:
254
+ print(f"=== {name} ===")
255
+ build_component(name, comp_map[name], model_dir, out_dir, args.device, args.precision)
256
+ built.append(name); print()
257
+ if not args._child: # children skip manifest; parent writes it
258
+ write_manifest(out_dir, args.model, args.device, args.precision, kind, built)
259
+ print(f"\nDone → {out_dir}")
260
+
261
+
262
+ if __name__ == "__main__":
263
+ main()
requirements.txt ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ # Qwen3-TTS-VoiceDesign → ONNX (CPU INT4) build/run/eval dependencies
2
+ olive-ai
3
+ onnxruntime>=1.20
4
+ onnxruntime-genai # ModelBuilder (create_model) for the Qwen3 LLM sub-part
5
+ torch
6
+ transformers>=5.10
7
+ safetensors
8
+ numpy
9
+ soundfile
10
+ librosa