feat(model): add Sopro V2 Turbo (sopro_tts) with offline and streaming - #353
Open
Brioch wants to merge 7 commits into
Open
feat(model): add Sopro V2 Turbo (sopro_tts) with offline and streaming#353Brioch wants to merge 7 commits into
Brioch wants to merge 7 commits into
Conversation
Brioch
marked this pull request as ready for review
August 30, 2026 16:15
Owner
|
@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. |
Owner
|
@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. |
- 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>
Brioch
force-pushed
the
feat/sopro-tts
branch
from
September 1, 2026 19:16
003367e to
d3963d2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.