Skip to content

Commit bdf965f

Browse files
author
crispasr integration
committed
feat(supertonic,#434): Supertonic-3 TTS backend — converter, runtime, full wiring
Native ggml port of the ONNX-only Supertone/supertonic-3 release (OpenRAIL-M weights, MIT sample code as the blueprint): - models/convert-supertonic3-to-gguf.py: 4 ONNX graphs -> ONE GGUF with the unicode indexer, NFKD/ccc tables (from Python unicodedata), all 10 preset voices, and every graph-baked constant verified by hard asserts (CFG 4/3, rotary theta, time freqs, attn scales, edge-pad amounts). - src/supertonic_tts.{h,cpp}: text preprocessing (NFKD via GGUF tables), CPU duration predictor + text encoder (VITS relative attention, GST cross-attn with tanh keys, attention2 residual from the ORIGINAL text), ggml graphs for the vector estimator (CFG fused: v = 4c - 3u, Euler xt += v/steps, length-normalised rotary, EDGE-padded ConvNeXt) and the vocoder (edge pads, folded BatchNorm, PReLU-as-leaky, 512 samples/frame). - tools/reference_backends/supertonic_tts.py: onnxruntime reference with promoted intermediates + seeded noise; supertonic_tts_diff() compares per stage with cos AND |mine|/|ref| norms; crispasr-diff branch wired. - CLI adapter (--voice = preset F1..F5/M1..M5, --tts-speed, --tts-steps), factory + GGUF arch detect, session C-ABI (open/synthesize/voice/seed/ steps/sample-rate/free/list), registry entry (license printed), and a quantizer allow-list (vf.*/voc.* 2-D matmuls only). Unvalidated as of this commit: builds only syntax-checked locally (8 GB box, no local builds); per-stage diff + TTS->ASR roundtrip run on Kaggle next. docs/supertonic/PLAN.md carries the full graph-derived notes.
1 parent 94eb1a3 commit bdf965f

13 files changed

Lines changed: 2632 additions & 0 deletions

docs/supertonic/PLAN.md

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Supertonic-3 TTS port (#434)
2+
3+
## NOW — active work
4+
5+
- [x] Licence verified from HF card 2026-09-12: model = OpenRAIL-M (`license: openrail`
6+
tag on Supertone/supertonic-3, not gated), sample code MIT. Attribution +
7+
responsible-use restrictions → registry `license` field must carry it.
8+
- [x] Blueprint read line-by-line: `supertone-inc/supertonic` `py/example_onnx.py` +
9+
`py/helper.py` (MIT). Upstream pipeline reproduced locally with onnxruntime
10+
(3.1 s clip, `M1` voice) as ground truth.
11+
- [x] All four ONNX graphs reverse-engineered (see notes below).
12+
- [ ] Converter → GGUF (single file, all voices embedded)
13+
- [ ] Reference dumper (onnxruntime intermediates, fixed noise)
14+
- [ ] C++ runtime + 12-point wiring
15+
- [ ] Kaggle build + diff + TTS→ASR roundtrip
16+
- [ ] HF upload + registry
17+
18+
## Model facts (from onnx/tts.json + graph inspection, NOT guessed)
19+
20+
- 4 ONNX components, opset 19, all feed-forward/deterministic:
21+
`duration_predictor` (4 MB), `text_encoder` (36 MB), `vector_estimator`
22+
(257 MB), `vocoder` (101 MB). 44.1 kHz output, 31 languages, 10 preset
23+
voices (`voice_styles/{F1..F5,M1..M5}.json`: style_ttl [1,50,256],
24+
style_dp [1,8,16]).
25+
- Text processing: NFKD → emoji strip → punctuation replacements → append "."
26+
if no trailing punctuation → wrap `<lang>``</lang>` → per-CHARACTER unicode
27+
codepoint → `unicode_indexer.json` (list[65536] → id, vocab 8322).
28+
- Chunking: 300 chars max (120 for ko/ja), 0.3 s silence joins.
29+
30+
### duration_predictor
31+
char_emb(8322×64) *mask → prepend learned sentence_token (CLS at pos 0, mask
32+
extended with 1) → 6× ConvNeXt-1D (k5 dil 1..1, masked, LN eps per graph,
33+
GELU-erf, gamma) → VITS attn encoder ×2 layers (2 heads, head 32,
34+
emb_rel_k/v [1,9,32] window 4, post-LN, ReLU FFN k1 convs, masked) with
35+
GLOBAL residual: out = attn_out + convnext_out → slice CLS pos → 1×1 conv
36+
(64→64, no bias) → concat [sent(64) | style_dp.flat(128)] → Gemm 192→128 →
37+
PReLU → Gemm 128→1 → Exp = seconds. Then dur /= speed (default 1.05).
38+
39+
### text_encoder
40+
char_emb(8322×256) → 6× ConvNeXt (dil 1,1,2,2,4,4) → VITS attn ×4 (4 heads,
41+
head 64, emb_rel [1,9,64]) → global residual add → *mask →
42+
speech_prompted_text_encoder: 2× GST cross-attn (2 heads, split axis2 →
43+
stack axis0): Q=text(256→256), K=tanh(W_k·style_key_prototype[1,50,256]),
44+
V=W_v·style_ttl, scores/scale, softmax over 50, out_fc, *mask, residual;
45+
then LayerNorm at end. Output text_emb [B,256,L].
46+
47+
### vector_estimator (flow matching, CFG INSIDE the graph)
48+
- Batch doubled: cond half + uncond half (text→text_special_token bcast,
49+
style k/v→style_{key,value}_special_token).
50+
- t = current_step/total_step; time emb: sin/cos(t·1000·freqs[32]),
51+
freqs_i = 10000^(−i/31) → MLP 64→256 →Mish→ 64.
52+
- proj_in conv1x1 144→512 (no bias), *mask.
53+
- 4 blocks, flat module list per block:
54+
[convnext×4 (dil 1,2,4,8) | time: x+=Linear64→512(t_emb) | convnext×1 |
55+
attn: rotary cross-attn text (8 heads, head 64, Q=x·mask, K/V=text_emb,
56+
angle=(arange(L)/actual_len)·theta_i, theta_i=10·10000^(−i/32),
57+
rotate-half 32|32, scores/16, mask=−inf, out_fc, *mask, residual,
58+
post-LN eps 1e-6) | convnext×1 | attention: GST style cross-attn
59+
(2 heads, Q=x·mask 512→256, K=tanh(style_key), V=style_value, out 512,
60+
residual, post-LN)]
61+
- last_convnext ×4 (dil 1,1,1,1) → proj_out conv1x1 512→144 (no bias), *mask.
62+
- Update (Euler, INSIDE graph): v = 4·v_cond − 3·v_uncond;
63+
xt ← (xt + (1/total_step)·v)·latent_mask.
64+
- Noise: xt0 = randn(B,144,ceil(dur·sr/3072)) · latent_mask (chunk 512·6).
65+
66+
### vocoder
67+
latent [B,144,L] → denorm (normalizer scale 0.25, ae.latent_mean/std
68+
[1,24,1]) + decompress 144→24 ch × 6L frames → conv k7 24→512 → 10×
69+
ConvNeXt (k7, dil 1,2,4,1,2,4,1,1,1,1, sym pad) → BatchNorm(512) →
70+
head: conv k3 512→2048 → PReLU → conv k1 2048→512 (no bias) → reshape:
71+
each frame = 512 samples → wav [B, 6L·512]. Trim to sr·dur.
72+
73+
### Constants verified from the graphs
74+
- CFG: 4.0 / 3.0. Rotary theta[32] = 10·10000^(−i/32). Attn score /16.
75+
- Time freqs[32] = 10000^(−i/31), t scale 1000. Mish in time MLP.
76+
- ConvNeXt dwconv pads symmetric 2·dil (k5) / 3·dil (k7) — EDGE (replicate) mode in ALL four graphs, incl. vocoder embed + head layer1; zero-mode pads exist only inside VITS relative attention.
77+
- softmax mask value −inf; post-softmax re-mask with 0 (VITS style).
78+
79+
## Design decisions
80+
- ONE GGUF: all 4 nets + unicode indexer (i32[65536]) + NFKD decomposition
81+
tables (generated from Python unicodedata at convert time) + all 10 voices
82+
(`voice.<name>.{ttl,dp}`). arch = "supertonic-tts".
83+
- CFG = two vector-field passes per step (cond/uncond share xt); 8 steps
84+
default ⇒ 16 VF passes.
85+
- Diff harness: reference = onnxruntime with intermediates promoted to
86+
graph outputs; fixed np.random noise saved in ref.gguf and injected into
87+
the C++ side for parity (production uses its own RNG; CRISPASR_SEED-able).
88+
- Acceptance: TTS→ASR roundtrip (HARD RULE #3), on Kaggle CUDA + CPU.
89+
90+
## Worktree
91+
`.claude/worktrees/feat-434-supertonic`, branch `feat/434-supertonic`.
92+
Kaggle account: chr1str (chr1s4 taken by a parallel agent).

examples/cli/CMakeLists.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ add_executable(${TARGET}
5353
crispasr_backend_indextts.cpp
5454
crispasr_backend_f5_tts.cpp
5555
crispasr_backend_irodori_tts.cpp
56+
crispasr_backend_supertonic.cpp
5657
crispasr_backend_pocket_tts.cpp
5758
crispasr_backend_csm.cpp
5859
crispasr_backend_parler.cpp
@@ -273,6 +274,7 @@ target_link_libraries(crispasr-diff PRIVATE
273274
parler-tts
274275
mini-omni2
275276
dots-tts
277+
supertonic-tts
276278
confucius4-tts
277279
miotts
278280
miocodec
@@ -384,6 +386,7 @@ target_link_libraries(${TARGET} PRIVATE
384386
dots-tts
385387
confucius4-tts
386388
irodori-tts
389+
supertonic-tts
387390
pocket-tts
388391
csm-tts
389392
voxcpm2_tts

examples/cli/crispasr_backend.cpp

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ std::unique_ptr<CrispasrBackend> crispasr_make_outetts_backend();
8080
std::unique_ptr<CrispasrBackend> crispasr_make_zonos_backend();
8181
std::unique_ptr<CrispasrBackend> crispasr_make_f5_tts_backend();
8282
std::unique_ptr<CrispasrBackend> crispasr_make_irodori_tts_backend();
83+
std::unique_ptr<CrispasrBackend> crispasr_make_supertonic_backend();
8384
std::unique_ptr<CrispasrBackend> crispasr_make_bark_backend();
8485
std::unique_ptr<CrispasrBackend> crispasr_make_pocket_tts_backend();
8586
std::unique_ptr<CrispasrBackend> crispasr_make_speecht5_backend();
@@ -213,6 +214,9 @@ std::unique_ptr<CrispasrBackend> crispasr_create_backend(const std::string& name
213214
return crispasr_make_f5_tts_backend();
214215
if (name == "irodori-tts" || name == "irodori_tts" || name == "irodori")
215216
return crispasr_make_irodori_tts_backend();
217+
if (name == "supertonic" || name == "supertonic-tts" || name == "supertonic_tts" || name == "supertonic3" ||
218+
name == "supertonic-3")
219+
return crispasr_make_supertonic_backend();
216220
if (name == "pocket-tts" || name == "pocket_tts" || name == "pockettts" || name == "pocket" ||
217221
name == "pocket-tts-de" || name == "pocket-tts-german" || name == "pocket-tts-es" ||
218222
name == "pocket-tts-spanish" || name == "pocket-tts-it" || name == "pocket-tts-italian" ||
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// crispasr_backend_supertonic.cpp — adapter for Supertonic-3 TTS (#434).
2+
//
3+
// Non-autoregressive flow-matching TTS (Supertone/supertonic-3, OpenRAIL-M),
4+
// 44.1 kHz, 31 languages, 10 preset voices embedded in the GGUF (F1..F5,
5+
// M1..M5). No voice cloning — the open release ships fixed style presets.
6+
//
7+
// Usage:
8+
// crispasr --backend supertonic -m auto --tts "Hello world." -o out.wav
9+
// crispasr --backend supertonic -m auto --tts "Guten Tag." -l de \
10+
// --voice F2 --tts-speed 1.2 --tts-steps 8 -o out.wav
11+
12+
#include "crispasr_backend.h"
13+
#include "whisper_params.h"
14+
15+
#include "supertonic_tts.h"
16+
17+
#include <cstdio>
18+
#include <cstdlib>
19+
#include <cstring>
20+
#include <string>
21+
#include <vector>
22+
23+
namespace {
24+
25+
class SupertonicBackend : public CrispasrBackend {
26+
public:
27+
SupertonicBackend() = default;
28+
~SupertonicBackend() override { SupertonicBackend::shutdown(); }
29+
30+
const char* name() const override { return "supertonic"; }
31+
32+
uint32_t capabilities() const override { return CAP_TTS | CAP_AUTO_DOWNLOAD | CAP_TTS_SPEED; }
33+
34+
std::vector<crispasr_segment> transcribe(const float* /*samples*/, int /*n*/, int64_t /*t_off*/,
35+
const whisper_params& /*params*/) override {
36+
return {}; // TTS-only backend
37+
}
38+
39+
bool init(const whisper_params& p) override {
40+
supertonic_context_params sp = supertonic_context_default_params();
41+
sp.n_threads = p.n_threads;
42+
sp.verbosity = p.no_prints ? 0 : 1;
43+
sp.use_gpu = p.use_gpu;
44+
sp.seed = (uint64_t)(p.seed > 0 ? p.seed : 0);
45+
if (p.tts_num_steps > 0)
46+
sp.total_steps = p.tts_num_steps;
47+
if (p.tts_speed > 0.0f && p.tts_speed != 1.0f) {
48+
// upstream default is 1.05; treat --tts-speed as a multiplier on it
49+
sp.speed = 1.05f * p.tts_speed;
50+
}
51+
ctx_ = supertonic_init_from_file(p.model.c_str(), sp);
52+
if (!ctx_) {
53+
std::fprintf(stderr, "crispasr[supertonic]: failed to load model '%s'\n", p.model.c_str());
54+
return false;
55+
}
56+
apply_language(p);
57+
apply_voice(p);
58+
return true;
59+
}
60+
61+
std::vector<float> synthesize(const std::string& text, const whisper_params& p) override {
62+
if (!ctx_ || text.empty())
63+
return {};
64+
apply_language(p);
65+
apply_voice(p);
66+
if (p.tts_num_steps > 0)
67+
supertonic_set_total_steps(ctx_, p.tts_num_steps);
68+
if (p.tts_speed > 0.0f)
69+
supertonic_set_speed(ctx_, 1.05f * p.tts_speed);
70+
int n = 0;
71+
float* pcm = supertonic_synthesize(ctx_, text.c_str(), &n);
72+
if (!pcm || n <= 0)
73+
return {};
74+
std::vector<float> out(pcm, pcm + n);
75+
std::free(pcm);
76+
return out;
77+
}
78+
79+
int tts_sample_rate() const override { return ctx_ ? supertonic_sample_rate(ctx_) : 44100; }
80+
81+
void shutdown() override {
82+
if (ctx_) {
83+
supertonic_free(ctx_);
84+
ctx_ = nullptr;
85+
}
86+
}
87+
88+
private:
89+
void apply_language(const whisper_params& p) {
90+
if (p.language.empty() || p.language == "auto")
91+
return;
92+
if (supertonic_set_language(ctx_, p.language.c_str()) != 0 && !p.no_prints && p.language != last_warn_lang_) {
93+
std::fprintf(stderr,
94+
"crispasr[supertonic]: language '%s' not supported (en ko ja ar bg cs da de el es et fi fr "
95+
"hi hr hu id it lt lv nl pl pt ro ru sk sl sv tr uk vi); keeping current\n",
96+
p.language.c_str());
97+
last_warn_lang_ = p.language;
98+
}
99+
}
100+
// --voice selects a PRESET name (F1..F5, M1..M5); a path is rejected with
101+
// a hint since this backend has no cloning.
102+
void apply_voice(const whisper_params& p) {
103+
if (p.tts_voice.empty() || p.tts_voice == last_voice_)
104+
return;
105+
if (supertonic_set_voice(ctx_, p.tts_voice.c_str()) != 0) {
106+
if (!p.no_prints)
107+
std::fprintf(stderr,
108+
"crispasr[supertonic]: unknown voice '%s' — expected a preset name F1..F5 / M1..M5 "
109+
"(Supertonic-3 has no voice cloning); keeping '%s'\n",
110+
p.tts_voice.c_str(), last_voice_.empty() ? "M1" : last_voice_.c_str());
111+
} else {
112+
last_voice_ = p.tts_voice;
113+
}
114+
}
115+
116+
supertonic_context* ctx_ = nullptr;
117+
std::string last_voice_;
118+
std::string last_warn_lang_;
119+
};
120+
121+
} // namespace
122+
123+
std::unique_ptr<CrispasrBackend> crispasr_make_supertonic_backend() {
124+
return std::make_unique<SupertonicBackend>();
125+
}

examples/cli/crispasr_diff_main.cpp

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@
9595
#include "tada_encoder.h"
9696
#include "tada_tts.h"
9797
#include "dots_tts.h"
98+
#include "supertonic_tts.h"
9899
#include "t5_translate.h"
99100
#include "miocodec.h"
100101
#include "miotts.h"
@@ -1254,6 +1255,11 @@ int main(int argc, char** argv) {
12541255
// dots-tts: self-contained per-stage parity checks (no audio needed). The
12551256
// reference is the isolated component dump from
12561257
// tools/reference_backends/dots_tts_reference.py.
1258+
// supertonic-tts (#434): self-contained — ref carries text/voice/steps
1259+
// and the seeded noise; audio arg is ignored.
1260+
if (backend_name == "supertonic-tts" || backend_name == "supertonic") {
1261+
return supertonic_tts_diff(model_path.c_str(), ref_path.c_str(), /*verbosity=*/2);
1262+
}
12571263
if (backend_name == "dots-tts") {
12581264
int rp = dots_tts_penc_diff(model_path.c_str(), ref_path.c_str(), /*verbosity=*/2);
12591265
int rd = dots_tts_dit_diff(model_path.c_str(), ref_path.c_str(), /*verbosity=*/2);

examples/crispasr-quantize/main.cpp

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,14 @@ static bool crispasr_model_quantize(const std::string& fname_inp, const std::str
265265
// run on it but do not expect much.
266266
const bool is_btc = (arch == "btc");
267267

268+
// Supertonic-3 (#434): quantize ONLY the big vector-field / vocoder 2-D
269+
// matmul weights (vf.* / voc.*). Everything else — the CPU-side duration
270+
// predictor + text encoder (dp.* / te.*, tiny), the char embedders, the
271+
// baked style prototypes / uncond tokens, all voice.* presets and the
272+
// text.* index tables — stays at source precision. The dwconv kernels are
273+
// 3-D and already fall out via ok_dims.
274+
const bool is_supertonic = (arch == "supertonic-tts");
275+
268276
const bool is_chatterbox =
269277
(arch.find("chatterbox") != std::string::npos || arch.find("kartoffelbox") != std::string::npos);
270278
// CosyVoice3: the three sub-models live in separate GGUFs but share the
@@ -798,6 +806,8 @@ static bool crispasr_model_quantize(const std::string& fname_inp, const std::str
798806
// `--q4_k` silently falls back to Q4_0. That fallback is why the q4
799807
// row costs so much -- it is Q4_0, not a k-quant.
800808
!(is_tabcnn && sname == "head.weight") &&
809+
// Supertonic-3: allow-list vf.*/voc.* (see is_supertonic note).
810+
!(is_supertonic && !(sname.rfind("vf.", 0) == 0 || sname.rfind("voc.", 0) == 0)) &&
801811
!(is_granite_family && !granite_quant_all && sname.find("enc.") == 0) &&
802812
// MOSS-Audio: keep encoder + adapter + deepstack at F16
803813
!(arch == "moss_audio" &&

0 commit comments

Comments
 (0)