Some checks failed
Linux Build / Linux cpu (push) Has been cancelled
Linux Build / Linux cpu-clang (push) Has been cancelled
Linux Build / Linux vulkan (push) Has been cancelled
mac-build / macOS CPU (push) Has been cancelled
Nix Build / Nix (cpu) (push) Has been cancelled
Nix Build / Nix (vulkan) (push) Has been cancelled
windows-build / Windows CPU (push) Has been cancelled
470 lines
21 KiB
Python
470 lines
21 KiB
Python
#!/usr/bin/env python3
|
|
"""Build the stateful RKNN speech decoder (vocoder) for the Qwen3-TTS family.
|
|
|
|
The runtime side (``src/models/qwen3_tts/rknn_speech_decoder.cpp``, session option
|
|
``qwen3_tts.exec_plan``) runs one compiled RKNN step graph
|
|
in place of the ggml speech tokenizer decoder. Nothing in this repository produces that graph, so
|
|
this script is its only producer: it re-traces the WavTokenizer-v2 decoder from the
|
|
``Qwen/Qwen3-TTS-12Hz-0.6B-Base`` Hugging Face checkpoint (the ``speech_tokenizer`` subdirectory,
|
|
i.e. the same weights the GGUF package was converted from) as a fixed-shape streaming step, checks
|
|
it against a full decode, and compiles it for the NPU.
|
|
|
|
The exported step graph is stateful: every causal conv's tail and the pre-transformer's
|
|
sliding-window KV history travel through the graph I/O as one flat fp32 cache, so a step decodes
|
|
from continuous state and there are no chunk seams. Four inputs
|
|
``audio_codes [1,16,B] int64`` / ``freqs_in [B,head_dim/2] fp32`` / ``cache_valid [1] fp32`` /
|
|
``cache_in [N] fp32``, two outputs ``audio_values`` / ``cache_out``, ``1920`` samples per token at
|
|
24 kHz. ``N`` does not depend on ``B``, so a small-B step's cache feeds a large-B step directly —
|
|
that is what the execution plan's optional ``artifacts.first`` uses.
|
|
|
|
Requirements (host, x86_64, dedicated venv — the RKNN toolkit pins its own numpy/onnx/torch):
|
|
pip install torch onnx onnxruntime numpy
|
|
pip install rknn-toolkit2 # PC-side converter, x86_64 only
|
|
On Python 3.12 the 2.3.2 converter additionally needs ``setuptools<81`` and an ``onnx/mapping.py``
|
|
shim inside the venv's onnx package; see docs/build/rockchip-npu.md.
|
|
|
|
Usage:
|
|
python3 tools/rknn/prepare_qwen3_tts_rknn_vocoder.py --model-dir ~/.cache/huggingface/hub/\\
|
|
models--Qwen--Qwen3-TTS-12Hz-0.6B-Base/snapshots/<sha> --codes-length 64
|
|
python3 tools/rknn/prepare_qwen3_tts_rknn_vocoder.py --model-dir <dir> --codes-length 16
|
|
python3 tools/rknn/prepare_qwen3_tts_rknn_vocoder.py --model-dir <dir> --skip-rknn # ONNX only
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import math
|
|
import os
|
|
import sys
|
|
|
|
|
|
def conv_cache_len(kernel_size, dilation):
|
|
return (kernel_size - 1) * dilation
|
|
|
|
|
|
def convT_cache_len(kernel_size, stride):
|
|
return math.ceil((kernel_size - 1) / stride)
|
|
|
|
|
|
def build_stateful_decoder(dec, B):
|
|
"""Wrap ``dec`` (the checkpoint's decoder, eval mode, eager attention) as a step graph:
|
|
B tokens + flat cache in, B*samples_per_token samples + flat cache out.
|
|
|
|
``forward(codes [1,16,B] int64, freqs [B,head_dim/2] fp32, cache_valid [1] fp32,
|
|
cache [N] fp32)``. The host-supplied choices below are NPU-driven and load-bearing; each is
|
|
measured, and re-simplifying one costs 30-62 dB of output quality.
|
|
"""
|
|
import torch
|
|
|
|
class Step(torch.nn.Module):
|
|
def __init__(self):
|
|
super().__init__()
|
|
self.dec = dec
|
|
self.B = B
|
|
cfg = dec.config
|
|
self.Lc = cfg.sliding_window - 1 # KV history slots
|
|
self.n_layers = cfg.num_hidden_layers
|
|
self.n_heads = cfg.num_attention_heads
|
|
self.head_dim = cfg.head_dim
|
|
self.scaling = self.head_dim ** -0.5
|
|
|
|
specs = [("pre_conv", conv_cache_len(3, 1) * cfg.codebook_dim)]
|
|
specs.append(
|
|
("kv", self.n_layers * 2 * self.n_heads * self.Lc * self.head_dim)
|
|
)
|
|
for bi in range(len(cfg.upsampling_ratios)):
|
|
specs.append((f"up_convT{bi}", convT_cache_len(2, 2) * cfg.latent_dim))
|
|
specs.append((f"up_dw{bi}", conv_cache_len(7, 1) * cfg.latent_dim))
|
|
specs.append(("dec0", conv_cache_len(7, 1) * cfg.latent_dim))
|
|
for i, r in enumerate(cfg.upsample_rates):
|
|
in_dim = cfg.decoder_dim // 2 ** i
|
|
out_dim = cfg.decoder_dim // 2 ** (i + 1)
|
|
specs.append((f"b{i}_convT", convT_cache_len(2 * r, r) * in_dim))
|
|
for d in (1, 3, 9):
|
|
specs.append((f"b{i}_r{d}", conv_cache_len(7, d) * out_dim))
|
|
out_dim = cfg.decoder_dim // 2 ** len(cfg.upsample_rates)
|
|
specs.append(("final", conv_cache_len(7, 1) * out_dim))
|
|
self.specs = specs
|
|
self.cache_numel = sum(n for _, n in specs)
|
|
|
|
# KV history joins the step's k/v through constant-selection matmuls, not Concat:
|
|
# the RKNN Concat path corrupts sliding-window attention (measured: step-0 audio SNR
|
|
# 9.5 dB with kv zeroed, vs 56.8 dB for the concat-free graph); matmul and add are
|
|
# first-class NPU ops.
|
|
a = torch.zeros(self.Lc + B, self.Lc)
|
|
a[: self.Lc, :] = torch.eye(self.Lc)
|
|
p = torch.zeros(self.Lc + B, B)
|
|
p[self.Lc :, :] = torch.eye(B)
|
|
self.register_buffer("kv_sel_hist", a)
|
|
self.register_buffer("kv_sel_cur", p)
|
|
# Sliding-window causal mask on the RELATIVE grid (depends only on qp-kp), as a graph
|
|
# constant at -100 (exp(-100) is exactly 0 in fp16; -3e4 leaks through the NPU
|
|
# softmax). The training mask's "no history before token 0" guard is not expressible
|
|
# on a relative grid, and feeding an absolute mask as an input together with in-graph
|
|
# Cos/Sin segfaults the board runtime, so the cache slots carry a per-slot bias built
|
|
# in forward() from the host-supplied count of real slots.
|
|
qp = torch.arange(B, dtype=torch.float32).view(B, 1)
|
|
kp = (torch.arange(self.Lc + B, dtype=torch.float32) - self.Lc).view(1, self.Lc + B)
|
|
m = torch.where(
|
|
(kp <= qp) & (kp > qp - self.Lc - 1),
|
|
torch.zeros(()),
|
|
torch.full((), -100.0),
|
|
)
|
|
self.register_buffer("attn_mask", m.view(1, 1, B, self.Lc + B))
|
|
self.register_buffer(
|
|
"slot_idx",
|
|
torch.arange(self.Lc, dtype=torch.float32).view(1, 1, 1, self.Lc),
|
|
)
|
|
|
|
def _slot_bias(self, n_valid):
|
|
# -100 on the Lc - n_valid never-written slots, 0 on the real ones. Elementwise on a
|
|
# [1,1,1,Lc] grid: no comparison/Where, and no reduction over the 1.3 MB cache tensor
|
|
# (whose Abs/ReduceSum/Clip version costs 30 dB of NPU fp16 accuracy).
|
|
phantom = torch.clamp(self.Lc - n_valid - self.slot_idx, 0.0, 1.0)
|
|
bias = -100.0 * phantom
|
|
cur = torch.zeros(1, 1, 1, self.B)
|
|
return torch.cat([bias, cur], dim=-1)
|
|
|
|
def _split(self, flat):
|
|
out, off = {}, 0
|
|
for name, n in self.specs:
|
|
out[name] = flat[off : off + n]
|
|
off += n
|
|
return out
|
|
|
|
def _conv_step(self, cnet, x, cache):
|
|
cl = x.shape[1]
|
|
n = conv_cache_len(cnet.conv.kernel_size[0], cnet.conv.dilation[0])
|
|
cs = cache.reshape(1, cl, n) if n else cache.reshape(1, cl, 0)
|
|
xin = torch.cat([cs, x], dim=-1)
|
|
y = cnet.conv(xin)
|
|
return y, xin[..., -n:].reshape(-1) if n else x[..., :0].reshape(-1)
|
|
|
|
def _convT_step(self, tnet, x, cache):
|
|
cl, L = x.shape[1], x.shape[2]
|
|
n = convT_cache_len(tnet.conv.kernel_size[0], tnet.conv.stride[0])
|
|
cs = cache.reshape(1, cl, n) if n else cache.reshape(1, cl, 0)
|
|
xin = torch.cat([cs, x], dim=-1)
|
|
s = tnet.conv.stride[0]
|
|
y = tnet.conv(xin)
|
|
total = xin.shape[-1]
|
|
y = y[..., : total * s]
|
|
y = y[..., (total - L) * s :]
|
|
return y, x[..., -n:].reshape(-1) if n else x[..., :0].reshape(-1)
|
|
|
|
@staticmethod
|
|
def _rot_half(t):
|
|
d = t.shape[-1]
|
|
a, b = t[..., : d // 2], t[..., d // 2 :]
|
|
return torch.cat([-b, a], dim=-1)
|
|
|
|
def forward(self, codes, freqs, cache_valid, flat_cache):
|
|
# freqs = (pos * inv_freq) reduced mod 2*pi on the host, cos/sin computed in-graph:
|
|
# cos/sin as RKNN inputs explodes the fp16 pipeline, and raw angles near pos 1e3 land
|
|
# on an fp16 ulp of 0.5.
|
|
# cache_valid = min(pos, Lc), the COUNT of real cache slots. The cache is a rolling
|
|
# window, so the real slots are always the last ones; never-written slots hold
|
|
# all-zero keys, whose score is 0 (not -inf), so they steal softmax mass. A boolean
|
|
# "any history" flag is only true at step 0, so every later step leaks Lc - B phantom
|
|
# keys into the softmax: measured 10.2 dB at B=16 (40 of 87 keys phantom) against
|
|
# 48.8 dB at B=64 (7 of 79).
|
|
dec = self.dec
|
|
B = self.B
|
|
c = self._split(flat_cache)
|
|
parts = []
|
|
|
|
hidden = dec.quantizer.decode(codes)
|
|
hidden, cache = self._conv_step(dec.pre_conv, hidden, c["pre_conv"])
|
|
parts.append(cache)
|
|
hidden = hidden.transpose(1, 2)
|
|
|
|
t = dec.pre_transformer
|
|
hidden = t.input_proj(hidden)
|
|
emb = torch.cat([freqs, freqs], dim=-1)
|
|
cos = emb.cos()
|
|
sin = emb.sin()
|
|
|
|
kv = c["kv"].reshape(self.n_layers, 2, self.n_heads, self.Lc, self.head_dim)
|
|
for i, layer in enumerate(t.layers):
|
|
res = hidden
|
|
hs = layer.input_layernorm(hidden)[0]
|
|
a = layer.self_attn
|
|
q = a.q_proj(hs).view(B, self.n_heads, self.head_dim).transpose(0, 1)
|
|
k = a.k_proj(hs).view(B, self.n_heads, self.head_dim).transpose(0, 1)
|
|
v = a.v_proj(hs).view(B, self.n_heads, self.head_dim).transpose(0, 1)
|
|
q = q * cos + self._rot_half(q) * sin
|
|
k = k * cos + self._rot_half(k) * sin
|
|
k_full = torch.matmul(self.kv_sel_hist, kv[i, 0]) + torch.matmul(
|
|
self.kv_sel_cur, k
|
|
)
|
|
v_full = torch.matmul(self.kv_sel_hist, kv[i, 1]) + torch.matmul(
|
|
self.kv_sel_cur, v
|
|
)
|
|
parts.append(k_full[:, -self.Lc :].reshape(-1))
|
|
parts.append(v_full[:, -self.Lc :].reshape(-1))
|
|
att = torch.softmax(
|
|
q[None] @ k_full[None].transpose(2, 3) * self.scaling
|
|
+ self.attn_mask
|
|
+ self._slot_bias(cache_valid),
|
|
dim=-1,
|
|
)
|
|
o = (att @ v_full[None]).transpose(1, 2).reshape(1, B, -1)
|
|
hidden = res + layer.self_attn_layer_scale(a.o_proj(o))
|
|
hidden = hidden + layer.mlp_layer_scale(
|
|
layer.mlp(layer.post_attention_layernorm(hidden))
|
|
)
|
|
hidden = t.norm(hidden)
|
|
hidden = t.output_proj(hidden).transpose(1, 2)
|
|
|
|
for bi, blocks in enumerate(dec.upsample):
|
|
hidden, cache = self._convT_step(blocks[0], hidden, c[f"up_convT{bi}"])
|
|
parts.append(cache)
|
|
nb = blocks[1]
|
|
res = hidden
|
|
hidden, cache = self._conv_step(nb.dwconv, hidden, c[f"up_dw{bi}"])
|
|
parts.append(cache)
|
|
hs = hidden.permute(0, 2, 1)
|
|
hs = nb.pwconv2(nb.act(nb.pwconv1(nb.norm(hs))))
|
|
hidden = res + (nb.gamma * hs).permute(0, 2, 1)
|
|
|
|
hidden, cache = self._conv_step(dec.decoder[0], hidden, c["dec0"])
|
|
parts.append(cache)
|
|
for bi in range(len(dec.config.upsample_rates)):
|
|
blk = dec.decoder[1 + bi]
|
|
hidden = blk.block[0](hidden)
|
|
hidden, cache = self._convT_step(blk.block[1], hidden, c[f"b{bi}_convT"])
|
|
parts.append(cache)
|
|
for unit, d in zip(blk.block[2:], (1, 3, 9)):
|
|
res = hidden
|
|
hidden = unit.act1(hidden)
|
|
hidden, cache = self._conv_step(unit.conv1, hidden, c[f"b{bi}_r{d}"])
|
|
parts.append(cache)
|
|
hidden = unit.act2(hidden)
|
|
hidden = unit.conv2(hidden) + res
|
|
hidden = dec.decoder[-2](hidden)
|
|
hidden, cache = self._conv_step(dec.decoder[-1], hidden, c["final"])
|
|
parts.append(cache)
|
|
|
|
wav = hidden.clamp(min=-1, max=1).reshape(-1)
|
|
return wav, torch.cat([p.reshape(-1) for p in parts])
|
|
|
|
return Step().eval()
|
|
|
|
|
|
def export_onnx(args):
|
|
from rknn_build import report_engine
|
|
import numpy as np
|
|
import torch
|
|
|
|
tokenizer_dir = os.path.join(args.model_dir, "speech_tokenizer")
|
|
if not os.path.isdir(tokenizer_dir):
|
|
sys.exit(f"ERROR: {tokenizer_dir} is not a Qwen3-TTS speech_tokenizer directory")
|
|
from qwen_tts.core.tokenizer_12hz.modeling_qwen3_tts_tokenizer_v2 import (
|
|
Qwen3TTSTokenizerV2Model,
|
|
)
|
|
|
|
full = Qwen3TTSTokenizerV2Model.from_pretrained(tokenizer_dir, local_files_only=True)
|
|
dec = full.decoder.eval()
|
|
dec.config._attn_implementation = "eager"
|
|
|
|
B = args.codes_length
|
|
step = build_stateful_decoder(dec, B)
|
|
print(f"step B={B} cache_numel={step.cache_numel} "
|
|
f"({step.cache_numel * 4 / 1e6:.1f} MB fp32) head_dim={step.head_dim} kv_len={step.Lc}")
|
|
|
|
torch.manual_seed(0)
|
|
n = 3 * B
|
|
codes = torch.randint(0, 2048, (1, 16, n), dtype=torch.int64)
|
|
inv_freq = dec.pre_transformer.rotary_emb.inv_freq.float()
|
|
|
|
def rope(i, L):
|
|
pos = torch.arange(i, i + L, dtype=torch.float32)
|
|
return torch.remainder(torch.outer(pos, inv_freq), 2 * math.pi)
|
|
|
|
with torch.no_grad():
|
|
ref = dec(codes).reshape(-1)
|
|
flat = torch.zeros(step.cache_numel, dtype=torch.float32)
|
|
outs = []
|
|
for i in range(0, n, B):
|
|
L = min(B, n - i)
|
|
wav, flat = step(codes[:, :, i : i + L], rope(i, L), np.float32(min(i, step.Lc)), flat)
|
|
outs.append(wav)
|
|
stepped = torch.cat(outs)
|
|
|
|
L = min(len(ref), len(stepped))
|
|
snr = 10 * torch.log10(
|
|
ref[:L].pow(2).sum() / ((ref[:L] - stepped[:L]).pow(2).sum() + 1e-12)
|
|
)
|
|
print(f"torch stepwise vs full decode: len {len(stepped)}/{len(ref)} SNR {snr:.1f} dB")
|
|
# fp32 self-check: semantic errors land below 20 dB; the kv-selection matmuls and the
|
|
# mask broadcast add fp32 accumulation noise (measured 103-111 dB). Do not raise this to
|
|
# 80: it aborts before torch.onnx.export and leaves no ONNX for the converter to load.
|
|
if snr < 40:
|
|
d = (ref[:L] - stepped[:L]).abs()
|
|
print("max diff by 10k-sample blocks:",
|
|
[round(float(d[i : i + 10000].max()), 5) for i in range(0, L, 10000)][:12])
|
|
sys.exit("stateful decode does not match full decode")
|
|
if args.check_only:
|
|
return None
|
|
|
|
out = args.output or f"vocoder_stateful_{B}.onnx"
|
|
if out.endswith(".rknn"):
|
|
# --output names the compiled engine; the ONNX lands beside it.
|
|
out = os.path.splitext(out)[0] + ".onnx"
|
|
os.makedirs(os.path.dirname(out) or ".", exist_ok=True)
|
|
dummy_c = torch.randint(0, 2048, (1, 16, B), dtype=torch.int64)
|
|
dummy_cache = torch.zeros(step.cache_numel, dtype=torch.float32)
|
|
print(f"exporting {out} ...")
|
|
# cache_valid is traced as a dynamic input; trace it mid-range so no branch of the
|
|
# phantom-slot clamp degenerates into a constant.
|
|
torch.onnx.export(
|
|
step,
|
|
(dummy_c, rope(0, B), torch.full((1,), float(B)), dummy_cache),
|
|
out,
|
|
input_names=["audio_codes", "freqs_in", "cache_valid", "cache_in"],
|
|
output_names=["audio_values", "cache_out"],
|
|
opset_version=args.opset,
|
|
do_constant_folding=True,
|
|
dynamo=False,
|
|
)
|
|
report_engine(out)
|
|
|
|
# Preflight for the converter stage: rknn-toolkit2 2.3.2's fold_constant aborts (core dump,
|
|
# no Python traceback) on the decomposed LayerNorm/Softmax patterns a recent torch emits.
|
|
# Measured with this graph: torch 2.4.0 + onnx 1.22 exports 1064 nodes (Gemm linears, no
|
|
# Pad/Exp/Shape/ConstantOfShape) and converts byte-identically to the reference .rknn;
|
|
# torch 2.14 + onnx 1.23 exports 2653 nodes and aborts. Say it here, at export time,
|
|
# instead of losing a 20-minute converter run to a core dump.
|
|
import onnx
|
|
|
|
nodes = onnx.load(out, load_external_data=False).graph.node
|
|
risky = sorted({n.op_type for n in nodes} & {"Pad", "Exp", "ConstantOfShape", "Shape", "Split"})
|
|
print(f"graph: {len(nodes)} nodes, risky ops for rknn-toolkit2: {risky or 'none'}")
|
|
if risky:
|
|
print("WARNING: the RKNN stage will most likely abort in fold_constant with this graph; "
|
|
"re-export it in the converter venv (toolkit-pinned torch/onnx) and pass that file "
|
|
"with --onnx.")
|
|
|
|
import onnxruntime as ort
|
|
|
|
opts = ort.SessionOptions()
|
|
opts.intra_op_num_threads = 4
|
|
sess = ort.InferenceSession(out, sess_options=opts, providers=["CPUExecutionProvider"])
|
|
flat = np.zeros(step.cache_numel, dtype=np.float32)
|
|
outs = []
|
|
for i in range(0, n, B):
|
|
L = min(B, n - i)
|
|
w, flat = sess.run(None, {
|
|
"audio_codes": codes[:, :, i : i + L].numpy(),
|
|
"freqs_in": rope(i, L).numpy(),
|
|
"cache_valid": np.full(1, min(i, step.Lc), dtype=np.float32),
|
|
"cache_in": flat,
|
|
})
|
|
outs.append(np.asarray(w).reshape(-1)[: L * args.samples_per_token])
|
|
onnx_step = np.concatenate(outs)
|
|
ref_np = ref[:L].numpy()
|
|
snr = 10 * np.log10(
|
|
np.sum(ref_np**2) / (np.sum((ref_np - onnx_step[:L]) ** 2) + 1e-12)
|
|
)
|
|
print(f"onnxruntime stepwise vs full decode: SNR {snr:.1f} dB")
|
|
if snr < 40:
|
|
sys.exit("exported ONNX does not reproduce the torch step graph")
|
|
return out, step.cache_numel, step.head_dim, step.Lc
|
|
|
|
|
|
def graph_contract(onnx_path):
|
|
"""Read (B, cache_numel, head_dim) back out of an exported ONNX, so the RKNN stage can run
|
|
alone in the converter venv without restating the contract."""
|
|
import onnx
|
|
|
|
graph = onnx.load(onnx_path, load_external_data=False).graph
|
|
dims = {
|
|
i.name: [d.dim_value for d in i.type.tensor_type.shape.dim] for i in graph.input
|
|
}
|
|
for name in ("audio_codes", "freqs_in", "cache_in"):
|
|
if name not in dims:
|
|
sys.exit(f"ERROR: {onnx_path} is not a stateful step export (no {name} input)")
|
|
codes, freqs, cache = dims["audio_codes"], dims["freqs_in"], dims["cache_in"]
|
|
if len(codes) != 3 or len(freqs) != 2:
|
|
sys.exit(f"ERROR: {onnx_path} does not have the stateful step shapes")
|
|
return codes[2], cache[0], freqs[1] * 2
|
|
|
|
|
|
def convert_rknn(args, onnx_path, cache_numel, head_dim, kv_len):
|
|
out = args.output or (
|
|
os.path.splitext(os.path.basename(onnx_path))[0] + f"_{args.target}.rknn"
|
|
)
|
|
print(f"=== {onnx_path} -> {out} ({args.target}) ===")
|
|
# FP16 (w16a16), not quantized: the Snake activation makes Q8/INT8 audibly noisy
|
|
# (SNR 9.5-13.4 dB) for 1.75x the speed, while fp16 measures lossless (56.8 dB).
|
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
from rknn_build import build_engine, report_engine
|
|
|
|
build_engine(
|
|
onnx_path,
|
|
out,
|
|
args.target,
|
|
names=["audio_codes", "freqs_in", "cache_valid", "cache_in"],
|
|
shapes=[
|
|
[1, 16, args.codes_length],
|
|
[args.codes_length, head_dim // 2],
|
|
[1],
|
|
[cache_numel],
|
|
],
|
|
opt_level=args.opt_level,
|
|
verbose=True,
|
|
gate_label=f"build {os.path.basename(out)}",
|
|
gate_log=out + ".build.log",
|
|
)
|
|
report_engine(out)
|
|
print(
|
|
f"\nDeploy (per-SoC file, not portable between chips):\n"
|
|
f" scp {out} <board>:/root/tts-rknn/vocoder/\n"
|
|
f"then run with\n"
|
|
" --session-option 'qwen3_tts.exec_plan="
|
|
+ json.dumps({"speech_decoder": {"impl": "rknn", "artifacts": {"main": f"/root/tts-rknn/vocoder/{os.path.basename(out)}"}}})
|
|
+ "'"
|
|
)
|
|
|
|
|
|
def main():
|
|
ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
|
ap.add_argument(
|
|
"--model-dir",
|
|
default=os.environ.get("QWEN3_TTS_MODEL", ""),
|
|
help="Qwen/Qwen3-TTS-12Hz-0.6B-Base snapshot directory (env QWEN3_TTS_MODEL)",
|
|
)
|
|
ap.add_argument("--codes-length", type=int, default=64,
|
|
help="tokens per compiled step (B); one compiled graph per value")
|
|
ap.add_argument("--output", default=None)
|
|
ap.add_argument("--opset", type=int, default=17)
|
|
ap.add_argument("--target", default="rk3588",
|
|
choices=["rk3588", "rk3576", "rk3562", "rk3566", "rk3568"])
|
|
ap.add_argument("--opt-level", type=int, default=1, help="RKNN graph optimization level")
|
|
ap.add_argument("--samples-per-token", type=int, default=1920,
|
|
help="24 kHz / 12.5 Hz token duration")
|
|
ap.add_argument("--check-only", action="store_true", help="run the self-check, export nothing")
|
|
ap.add_argument("--skip-rknn", action="store_true", help="export ONNX only")
|
|
ap.add_argument("--onnx", default=None,
|
|
help="compile an existing stateful ONNX step (the converter-venv-only path); "
|
|
"B, cache_numel and head_dim are read back out of the graph")
|
|
args = ap.parse_args()
|
|
|
|
if args.onnx:
|
|
B, cache_numel, head_dim = graph_contract(args.onnx)
|
|
args.codes_length = B
|
|
print(f"onnx {args.onnx}: B={B} cache_numel={cache_numel} head_dim={head_dim}")
|
|
convert_rknn(args, args.onnx, cache_numel, head_dim, 0)
|
|
return
|
|
|
|
if not args.model_dir:
|
|
sys.exit("ERROR: --model-dir (or QWEN3_TTS_MODEL) is required")
|
|
exported = export_onnx(args)
|
|
if exported is None:
|
|
return
|
|
if args.skip_rknn:
|
|
return
|
|
convert_rknn(args, *exported)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|