feat(model): add Sopro V2 Turbo (sopro_tts) with offline and streaming - #353
Conversation
|
@Brioch I reached out to the model’s author, and he may review and test the PR. |
|
Overall, the integration looks good and the offline pipeline seems okay. There are just two recent upstream changes that should be ported before merging: Band-limit vocoder synthesis - prevents an identified high-frequency vocoder hiss. |
|
@Brioch Please resolve the conficts then we are good to go. |
|
We have been making a few more changes. I would hold off for a few days until we stabilize the final release. Will ping here when that's done. |
003367e to
d3963d2
Compare
- session: seed the first segment's AR carry from the tail of the reference instead of its head, matching every later segment and the order the acoustic head concatenates in - text_tokenizer: keep the EOS marker when truncating, and take the budget from config.model.max_text_len instead of the 512 constructor default, so long segments are no longer silently cut by roughly a quarter - session: reject negative min_seconds and min_seconds > max_seconds, which left min_steps > max_steps and suppressed EOS for every segment - session: clamp style_tokens from below, symmetric with prompt_tokens - semantic_lm: bounds-check bos/eos ids before indexing the logit row - semantic_encoder: derive the resample source rate and pinned length from config instead of hardcoding 24 kHz; bit-identical for the shipped checkpoint - acoustic/vocoder/semantic_encoder/speaker_encoder: release the old graph arena before allocating its replacement, as dramabox and confucius4_tts already do - webui: add the missing min_seconds control and rebuild the bundle Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
MSVC does not define M_PI without _USE_MATH_DEFINES, breaking the Windows CPU build. Matches f5_tts, which documents the same trap for the same sway-time grid. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ports two upstream sopro 2.1 changes flagged in review: - Band-limit vocoder synthesis (samuel-vitorino/sopro 3d25c6f): new vocoder.band_limit_hz (default 10900), zeroing every ISTFT bin at or above the cut. Removes the high-frequency vocoder hiss, and with it the non-real Nyquist bin the head used to synthesise. - Boost-only, peak-guarded reference normalization (253a7f4): a reference already at or above the -19.8 dB prompt level is no longer attenuated, and a boost is capped at the headroom below 0.95 peak. normalize_reference now reports the level it lands on, carried on SoproReference and threaded into the output-gain fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…imit The reference level chain and the band limit are pure functions over plain buffers, so they can be checked without the checkpoint - which matters here because no GGUF is published and the safetensors set is a multi-file download. Eight checks over the public audio_ops surface: speech_level_db on a flat buffer and its short-input fallback, the boost-only rule (a hot reference passes through untouched), the 0.95 peak guard, the 30 dB gain limit, output_gain against the reference level, both match_gain paths, and the band_limit_bin arithmetic. Lifts band_limit_bin out of the anonymous namespace in vocoder.cpp and declares it in vocoder.h so the bin arithmetic is reachable from a test; istft_from_head now calls it. No behaviour change. Both behaviours were mutation-tested: restoring the old two-sided clamp fails the hot-reference check, and bypassing the band limit lifts the probe round trip from -63.9 to -28.8 dB across 11-12 kHz. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
d3963d2 to
50b2164
Compare
|
@Brioch Thank you! PR merged! @samuel-vitorino Sorry, I want to do some cleanup before Labor Day...Feel free to submit follow-up PRs to port upstream changes. |
Adds
sopro_ttsas a community model: samuel-vitorino/sopro-v2-turbo, a 120M Apache-2.0 zero-shot voice-cloning TTS covering English, European Portuguese, French and German at 24 kHz mono. Offline and segment-level streaming.It ships as its own family;
--family sopro,sopro_v2andsopro_v2_turboall resolve to it.Build
Warm-bench probe (optional, used for the parity work below):
Model
python3 tools/model_manager_v2.py install sopro_v2_turbo_safetensors # -> models/sopro-v2-turbo/{config.json,tokenizer.model,*.safetensors}Package ids:
sopro_v2_turbo_safetensors(safetensors, real download) andsopro_v2_turbo_f16(default GGUF). No public audio.cpp GGUF build of this family exists yet, so the GGUF package is
download.kind = "unsupported"and is built locally:build/bin/audiocpp_gguf \ --input model=models/sopro-v2-turbo/model.safetensors \ --input semantic_encoder=models/sopro-v2-turbo/semantic_encoder.safetensors \ --input speaker_encoder=models/sopro-v2-turbo/speaker_encoder.safetensors \ --input vocoder=models/sopro-v2-turbo/vocoder.safetensors \ --family sopro_tts --root models/sopro-v2-turbo \ --output models/sopro-v2-turbo-GGUF/sopro-v2-turbo-f16.gguf --type f16Run
Offline:
build/bin/audiocpp_cli \ --task tts --family sopro_tts \ --model models/sopro-v2-turbo \ --backend cpu --threads 8 \ --text "Sopro is a lightweight text-to-speech model that runs on device." \ --voice-ref assets/resources/b.wav \ --language en --seed 1234 --num-inference-steps 8 \ --out out.wav --metricsStreaming (one pull event per text segment):
build/bin/audiocpp_cli \ --task tts --family sopro_tts \ --model models/sopro-v2-turbo \ --backend cpu --threads 8 --mode streaming \ --text "$(cat article.txt)" \ --voice-ref assets/resources/b.wav \ --language en --seed 1234 --num-inference-steps 8 --text-chunk-size 120 \ --out stream.wav --out-dir segments/Server (
mode: "streaming"in the model config), raw PCM is s16le mono at 24 kHz:Path tests
Two shared long-form cases added to
tools/audiocpp_cli/audiocpp_cli_longform_tts_clone_cases.json:sopro_tts_voice_clone_longform(offline)sopro_tts_voice_clone_longform_streaming(streaming,outputs: ["audio", "named_audio"])python3 tools/audiocpp_cli/run_audiocpp_cli_path_tests.py \ --cases tools/audiocpp_cli/audiocpp_cli_longform_tts_clone_cases.json \ --family sopro_tts --backend cpu --threads 8 python3 tools/check_loader_catalog_sync.pysopro_tts_voice_clone_longform(offline) passes: 6026 chars in, 371.6 s of audio out.sopro_tts_voice_clone_longform_streamingcurrently fails in the harness, not in the model —see the runner note below. With that one line patched locally it passes, producing 47
segment_*.wavplus astream.wavof 371.64 s, exactly matching the offline duration.check_loader_catalog_sync.py:active_loaders=66 specs=64 packages=187 manager_packages=187, in sync.Pre-existing: every streaming path-test case fails on
--chunk-sizeUnrelated to this PR, but it blocks the streaming case above, so flagging rather than fixing here.
run_audiocpp_cli_path_tests.pyappends--chunk-sizeto every streaming case (build_command,around line 404), but the CLI no longer has that option — it was dropped when streaming moved to
the model's own streaming policy, and
--helpnow says only "--mode streaminguses the selectedmodel's default streaming policy".
require_known_argsrejects it:That affects all 14 streaming cases in the catalog, not just sopro's:
neutts,omnivoice,voxcpm1,voxcpm2,dots_tts,qwen3_asr,parakeet_tdt,nemotron_asr,voxtral_realtime,silero_vad,muscriptor, and bothpersonaplexcases. Deleting the line is enough (a case'schunk_sizekey then becomes documentation only); I verified that against sopro but did not runthe other 13, since most of those models are not installed here. Happy to send it as its own PR.
Validation
Backends: CPU (16-core x86-64 Release build, 8 threads) and CUDA (RTX 3090, sm_86,
CUDA build via
scripts/build_linux.sh --backend cuda). Every stage was reimplementedindependently in numpy, driven from the checkpoint's own weights, and diffed against the C++.
mu,spk, time embeddingseedreproducibilityseed--outmatmul_weight_typef16 / bf16 / q8_0seed, 1.2 s clip--outexactlyTiming, RTF, RSS
text_chunk_size=120CUDA figures are from the path-test harness with
--measure-resources(100 ms sampling, 144samples), so the RSS and VRAM peaks are observed rather than estimated. CUDA is ~12x the
8-thread CPU run on the same text.
Time-to-first-audio, streaming at
text_chunk_size=120: ~3.1 s on CPU (116 ms referenceencode + 2993 ms for a 6.66 s first segment), against ~9.9 s offline for the same text. On CUDA
at
text_chunk_size=200the first segment lands in 393 ms.Per-segment streaming RTF over the 47 long-form segments on CUDA: min 0.035, median 0.037, max
0.064 — every segment generates faster than it plays, with ~16x headroom in the worst case. On
CPU the worst case is above 1.0 for very short segments (see Known Limitations), so the floor on
useful
text_chunk_sizeis a CPU concern only.The CUDA streaming run reproduces the CUDA offline duration exactly (375.26 s both ways, with the
47 segments summing to the merged output to the sample). That is the check that matters for the
solver: the velocity graph is replayed once per Euler step and re-uploads its constants each
time, so a backend that mishandled that would show up as drifting segment durations.
Long-lived session, repeated requests
Six requests through one session via
--request-sequence, RSS sampled every 2 s:Flat after the first request; six
sopro_tts.reference.prepare_msentries and six wavs from onesession. No per-request growth. No
mem_saverpath was needed.Architecture notes
Five stages, mirroring
sopro/upstream: SentencePiece text (8192 pieces, no phonemiser) →speaker encoder (~11M, gated depthwise ResNet + attentive-statistics pooling) → semantic encoder
(~82M, Whisper-style front end, FSQ head with levels
[7,5,5,5,5]= 4375 codes) → style-prefixsemantic LM (12 blocks, dim 512) → 8-block AdaLN-Zero DiT solving a rectified flow, then a
14-layer Vocos ConvNeXt backbone and one centred ISTFT.
Three implementation details worth review attention:
LayerScale vector on each residual branch, and those branches end in a bias-free projection, so
the scale folds exactly into that projection's rows. The shared
QwenCausalDecodeRuntimethenruns the stack unmodified.
filterbank as persistent buffers; all three front ends load those rather than rebuilding the
filterbank, which removes the usual mel-parity risk. A checkpoint exported without them fails at
load with a message naming the missing tensor.
ggml_gallocrexempts onlyGGML_TENSOR_FLAG_OUTPUTtensors from being freed and reused (ggml_gallocr_free_nodeinggml-alloc.c); an input leaf's arena space is handed to a later intermediate once its lastconsumer has run. Correct for a one-shot graph, but the solver replays the velocity graph once
per step, so staging the constants once left steps ≥1 reading whatever overwrote them. This was
the bug behind the first round of garbage output — worth knowing about for any other iterative
solver in the tree.
No framework modules were modified.
Known limitations
attention plus the causal vocoder,
vocoder_streaming.safetensors) is not implemented, and thatvocoder is not part of the published checkpoint this family loads. Granularity is one text
segment;
text_chunk_sizeis the latency dial. Every segment re-solves the whole reference melprompt alongside its own frames, so per-segment cost has a floor of roughly
ref_secondsworthof DiT work — segments shorter than ~1 s take longer to generate than to play, though the stream
as a whole stays ahead of real time (
text_chunk_size=40measured 0.75 RTF overall).cannot see, so the first segment fixes the gain for the rest. Same seed reproduces the offline
waveform sample-for-sample otherwise.
in sample count with correlation 0.999338 (−28.8 dB residual) — float reassociation through
the DiT and vocoder, compounded across the Euler steps, with the semantic LM drawing the same
tokens. Over 6026 chars the sampler does eventually drift: CUDA produced 375.3 s against CPU's
371.6 s, i.e. a different number of generated tokens once a near-boundary logit flips. Expected
for an autoregressive model across backends, but it means
seedreproduces within a backend,not across them.
same source, which catches implementation bugs but not a shared misreading of the architecture.
sample_next_tokenreproduces the reference's masking,temperature, top-k and top-p arithmetic exactly, but draws from a seeded
std::mt19937_64, so agiven
seedwill not reproduce the Python output sample-for-sample. Reproducible within audio.cpp.split_textintext_tokenizer.cppis a port ofupstream's
sopro.text.split_text(sentence → clause → word packing with its own punctuationhandling) rather than
engine::text::split_text_chunks, because swapping it would change output.The cost is that this family will not inherit future chunker work and
text_chunk_modeis notavailable here. Happy to migrate it if you would rather have the shared path.
int8AR path. The upstream--int8CPU option has no equivalent; usesopro_tts.matmul_weight_type=q8_0.one plus two, not1 + 2), avoid mixing languages inside one sentence.Debugging aids kept in-tree
tests/sopro_tts/sopro_probe.cpp(built with-DENGINE_BUILD_WARMBENCH=ON) exercises each stagein isolation against a reference clip and reports the
crop_on_pausedecision, a phase-invariantmel round trip through the vocoder, the FSQ token histogram, speaker embedding statistics, and an
acoustic self-reconstruction NMSE.
SOPRO_DUMP_DIR=<dir>additionally dumps encoder and solverintermediates as raw f32. Both are what localised the allocator bug above.
WebUI
webui/configs/models_catalog.jsondeclares sopro's ten request options. Without that, the UIfalls back to "every TTS model accepts
max_tokens" (+page.svelte:704) and sopro's spec-backedvalidator rejects it. Note this only shows up after rebuilding the UI bundle, since
catalog.tsinlines the JSON at build time. The same fallback will hit the next spec-strict family — possibly
worth addressing separately.