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
298 lines
18 KiB
Markdown
298 lines
18 KiB
Markdown
# Sopro V2 Turbo (`sopro_tts`)
|
||
|
||
[samuel-vitorino/sopro-v2-turbo](https://huggingface.co/samuel-vitorino/sopro-v2-turbo) is a
|
||
120M-parameter zero-shot voice-cloning TTS covering English, European Portuguese, French and
|
||
German, released under Apache-2.0. It clones from 5–20 s of reference audio and outputs
|
||
24 kHz mono.
|
||
|
||
> **Not the same model as `soprano_tts`.** audio.cpp's existing `soprano_tts` family is
|
||
> [ekwek/Soprano-1.1-80M](https://huggingface.co/WalkingCat/Soprano-1.1-80M-GGUF), an unrelated
|
||
> project with a Qwen3 backbone and a different decoder. Sopro V2 Turbo shares nothing with it
|
||
> beyond a similar name, so it ships as its own family. The `--family` hints `sopro`,
|
||
> `sopro_v2` and `sopro_v2_turbo` all resolve to `sopro_tts`.
|
||
|
||
## Installation
|
||
|
||
Both packages come from the model repo. The f16 GGUF is a single self-contained file and the
|
||
one the WebUI installs; the upstream safetensors checkpoint runs directly in full precision:
|
||
|
||
```bash
|
||
python3 tools/model_manager_v2.py install sopro_v2_turbo_f16
|
||
# -> models/sopro-v2-turbo-GGUF/sopro-v2-turbo-f16.gguf
|
||
python3 tools/model_manager_v2.py install sopro_v2_turbo_safetensors
|
||
# -> models/sopro-v2-turbo/{config.json,tokenizer.model,*.safetensors}
|
||
```
|
||
|
||
The GGUF was packed from the safetensors with the command below. `audiocpp_gguf` takes one
|
||
namespaced input per stage, and `--root` makes it embed `config.json` and `tokenizer.model` as
|
||
sidecars. Keep the mel front ends' filterbanks and windows in f32 when packing your own:
|
||
`--type f16` would otherwise round them too, and the reference mel the acoustic head copies from
|
||
drifts by several dB.
|
||
|
||
```bash
|
||
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 \
|
||
--keep-type 'vocoder/feature_extractor.*=f32' --keep-type 'vocoder/head.istft.window*=f32' \
|
||
--keep-type 'speaker_encoder/frontend.*=f32' --keep-type 'semantic_encoder/frontend.*=f32' \
|
||
--output models/sopro-v2-turbo-GGUF/sopro-v2-turbo-f16.gguf --type f16
|
||
```
|
||
|
||
## Build
|
||
|
||
```bash
|
||
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release \
|
||
-DAUDIOCPP_MODEL_SET=custom -DAUDIOCPP_MODELS=sopro_tts
|
||
cmake --build build --target audiocpp_cli -j"$(nproc)"
|
||
```
|
||
|
||
## Run
|
||
|
||
Zero-shot cloning always needs a reference clip:
|
||
|
||
```bash
|
||
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 ref.wav \
|
||
--request-option language=en \
|
||
--out out.wav --metrics
|
||
```
|
||
|
||
`--task clon` works the same way. The reference is resampled to 24 kHz, cropped at a pause near
|
||
`reference_duration_sec`, and level-normalised before the speaker and semantic encoders see it. That
|
||
normalisation is boost-only and peak-guarded (sopro 2.2): a reference already at or above the
|
||
−19.8 dB prompt level is passed through untouched, and a boost is never large enough to push
|
||
the peak past 0.95. The output gain is fixed by the level the reference ends up at: it maps that
|
||
level to −23 dB (`output_gain`), the same for every segment and in both run modes.
|
||
|
||
## Streaming
|
||
|
||
`--mode streaming` emits audio as the semantic LM runs, like `SoproTTS.stream` upstream:
|
||
|
||
```bash
|
||
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 ref.wav --language en \
|
||
--out stream.wav --out-dir chunks/
|
||
```
|
||
|
||
Each event carries a `chunk_<n>` named audio buffer that is already levelled, gated and faded,
|
||
so a consumer can play events back to back; `--out` writes the whole utterance, which is exactly
|
||
the concatenation of the events.
|
||
|
||
How it works (`streaming.cpp`, a port of `sopro/streaming.py`):
|
||
|
||
- The reference prompt is trimmed to a whole number of semantic tokens and its complete
|
||
`stream_chunk_frames` (64-frame) chunks are solved once, before the first text segment.
|
||
- Every 16 semantic tokens, the acoustic head solves the frames that can no longer change
|
||
(a 3-token lookahead margin behind the last token, rounded down to whole chunks). Each frame
|
||
attends to everything solved before it within its chunk boundary, and the solver keeps every
|
||
DiT layer's keys and values per Euler step, so a chunk costs only its own frames.
|
||
- The vocoder decodes each chunk once its receptive field (27 frames each side) is complete and
|
||
overlap-adds it into a streaming ISTFT.
|
||
- The opening audio of each text segment is held until speech starts, then the lead-in is cut
|
||
the same way the offline path trims it.
|
||
|
||
Streaming is not a re-cut of the offline output: it solves with chunk-limited attention and its
|
||
own noise draws, as the Python package does, so the two modes differ for the same `seed`. On an
|
||
8-core Apple M-series CPU at 6 threads the first chunk arrives about 1.6–1.8 s after the request
|
||
starts (reference encoding ~0.3–0.45 s, prompt solve ~0.8–0.95 s, first chunk ~0.4–0.5 s for a
|
||
10 s reference), and each later 0.68 s chunk takes about 0.2 s. `reference_duration_sec` scales the prompt
|
||
solve.
|
||
|
||
### Voice cache
|
||
|
||
`sopro_tts.voice_cache_slots=<n>` (off by default) keeps the last `n` prepared voices, keyed by
|
||
the clip and `reference_duration_sec`, together with each streamed voice's solved prompt, like reusing a
|
||
`prepare_reference` result upstream. A repeated voice skips the encoders and the prompt solve,
|
||
which brought the first chunk down from ~1.8 s to ~0.4–0.5 s in the setup above. A streamed
|
||
10 s voice holds about 60 MB.
|
||
|
||
To prepare a voice without generating anything, pass `audiocpp_session_prepare` a request that
|
||
carries only the voice audio (plus `reference_duration_sec` or `num_inference_steps` if the requests that
|
||
follow set them).
|
||
|
||
The cache never changes the audio: the draws a voice needs for itself (room tone for a clip
|
||
without a usable pause, the prompt noise) are seeded from the clip rather than the request.
|
||
|
||
## Options
|
||
|
||
| Request option | Type | Default | Meaning |
|
||
|---|---|---|---|
|
||
| `language` | string | see description | Language tag (en, pt, fr, de); omitted inherits transcript language, then the session default. |
|
||
| `temperature` | float | see description | Semantic sampling temperature; nonpositive selects greedy decoding. Omitted uses checkpoint generation.temperature. |
|
||
| `top_p` | float | see description | Semantic nucleus cutoff; values outside [0,1] are disabled/clamped by the sampler. Omitted uses checkpoint generation.top_p. |
|
||
| `top_k` | int | see description | Semantic top-k; nonpositive disables filtering. Omitted uses checkpoint generation.top_k. |
|
||
| `num_inference_steps` | int | see description | Positive acoustic sampling steps; omitted uses checkpoint generation.steps. |
|
||
| `max_duration_sec` | float | see description | Positive maximum segment duration; must be >= min_duration_sec. Omitted uses checkpoint generation.max_seconds. |
|
||
| `min_duration_sec` | float | see description | Nonnegative minimum duration; must be <= max_duration_sec. Omitted uses checkpoint generation.min_seconds. |
|
||
| `reference_duration_sec` | float | see description | Positive reference window in seconds; omitted uses checkpoint generation.ref_seconds. |
|
||
| `text_chunk_size` | int | see description | Positive codepoint budget per segment; omitted uses checkpoint generation.max_segment_chars. |
|
||
| `seed` | int | *(random)* | Seeds semantic sampling and the acoustic noise prior |
|
||
|
||
| Session option | Default | Meaning |
|
||
|---|---|---|
|
||
| `sopro_tts.language` | *(empty)* | Default language tag for requests that do not set one |
|
||
| `sopro_tts.voice_cache_slots` | `0` | Prepared voices kept between requests; see [Voice cache](#voice-cache) |
|
||
| `sopro_tts.matmul_weight_type` | `f32` | Storage type for matmul weights (`native`, `f32`, `f16`, `bf16`, `q8_0`) |
|
||
| `sopro_tts.conv_weight_type` | `f32` | Storage type for convolution weights (`native`, `f32`, `f16`) |
|
||
|
||
Numeric generation defaults come from the checkpoint's `config.json` `generation` block, so a
|
||
retrained variant picks up its own values without a code change. Language inherits the transcript
|
||
language, then the session default; an omitted seed is random.
|
||
|
||
## Architecture notes
|
||
|
||
Five stages run per request, mirroring `sopro/` upstream:
|
||
|
||
1. **Text** (`text_tokenizer.cpp`) — SentencePiece unigram, 8192 pieces, plus the reference's
|
||
punctuation clean-up and sentence/clause/word segmentation. No phonemiser.
|
||
2. **Speaker encoder** (`speaker_encoder.cpp`, ~11M) — 16 kHz log-mel into a three-stage gated
|
||
depthwise ResNet with squeeze-excite, then attentive-statistics pooling for identity and
|
||
multi-scale mean/std pooling for style. The convolution trunk runs on the backend; the two
|
||
pooling heads and their MLPs run on the host, where they cost a few hundred kFLOP.
|
||
3. **Semantic encoder** (`semantic_encoder.cpp`, ~82M) — a Whisper-style front end and six
|
||
non-causal transformer layers, resampled to one frame per 1024 output samples and quantised
|
||
by an FSQ head with levels `[7,5,5,5,5]` (4375 codes).
|
||
4. **Semantic LM** (`semantic_lm.cpp`) — 12 pre-norm blocks, dim 512, QK RMS-norm, SwiGLU,
|
||
half-rotation RoPE. The prompt is `[style prefix | text | prompt tokens | BOS]`, where the
|
||
prompt tokens are the first `prompt_tokens` reference tokens for the first segment and the
|
||
tail of the previous segment's tokens after that. Because the only structural difference
|
||
from a Qwen3 decoder is a LayerScale vector on each residual branch, and those branches end
|
||
in a bias-free projection, the scale is folded into that projection's rows at load time and
|
||
the shared `QwenCausalDecodeRuntime` runs the stack unmodified. The eight-query style prefix
|
||
cross-attention runs on the host.
|
||
5. **Acoustic head + vocoder** (`acoustic.cpp`, `vocoder.cpp`) — an 8-block adaptive-layer-norm
|
||
DiT solving a rectified flow in two Euler steps on a sway-sampled time grid, with the prompt
|
||
mel re-pinned after every step; then an 8-block Vocos ConvNeXt backbone and one centred
|
||
ISTFT over the full band. The vocoder config is causal with a lookahead of 3 frames on every
|
||
7-tap conv, which is the stock centred Vocos layout; other lookaheads are padded to match.
|
||
`mu` (the upsampled semantic conditioning) is built in its own graph because it is constant
|
||
across solver steps.
|
||
|
||
Implementation details worth knowing:
|
||
|
||
- **Front-end buffers come from the checkpoint.** torchaudio stores its analysis window and mel
|
||
filterbank as persistent buffers, and 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.
|
||
- **Grouped convolutions are split.** The DiT's causal positional embedding uses
|
||
`Conv1d(512, 512, k=31, groups=16)`; ggml has no grouped conv1d, so the weight is split into
|
||
16 independent convolutions at load time.
|
||
- **The velocity graph re-uploads every leaf per Euler step.** `ggml_gallocr` exempts only
|
||
`GGML_TENSOR_FLAG_OUTPUT` tensors from being freed and reused
|
||
(`ggml_gallocr_free_node` in `ggml-alloc.c`); an *input* leaf's arena space is handed to a
|
||
later intermediate once its last consumer has run. That is correct for a one-shot graph, but
|
||
the solver replays the velocity graph once per step, so staging `mu`, `cond_mel`, `cond_mask`,
|
||
`spk` and the RoPE positions once would leave the second and later steps reading whatever
|
||
overwrote them. `SoproVelocityGraph::run` uploads all of them before every compute; it costs
|
||
a few hundred kB per step against a multi-GFLOP DiT pass.
|
||
|
||
## Known limitations
|
||
|
||
- **Without the voice cache, every request prepares its voice again.** The reference arrives
|
||
with each request, so a stream pays the encoders and the prompt solve unless
|
||
[`sopro_tts.voice_cache_slots`](#voice-cache) is set.
|
||
- **Sampling RNG is not torch-bit-exact.** `sample_next_token` reproduces the reference's
|
||
masking, temperature, top-k and top-p arithmetic exactly, but draws from a seeded
|
||
`std::mt19937_64` rather than torch's generator, so a given `seed` will not reproduce the
|
||
Python output sample-for-sample. The same `seed` is reproducible within audio.cpp.
|
||
- **No `int8` AR path.** The upstream `--int8` CPU option quantises only the semantic LM; the
|
||
closest here, `sopro_tts.matmul_weight_type=q8_0`, quantises the matmuls of every stage.
|
||
- The text front end is deliberately minimal upstream: prefer words to symbols (`one plus two`,
|
||
not `1 + 2`), and avoid mixing languages inside one sentence.
|
||
|
||
## Validation status
|
||
|
||
Verified against the real checkpoint on a 16-core x86-64 CPU build, 8 threads. Every stage was
|
||
reimplemented independently in numpy, driven from the checkpoint's own weights, and diffed
|
||
against the C++.
|
||
|
||
| Stage | Check | Result |
|
||
|---|---|---|
|
||
| Vocoder mel front end | vs. numpy STFT + checkpoint filterbank | max diff 1.9e-3 |
|
||
| Semantic encoder mel | vs. numpy | max diff 1.9e-5 |
|
||
| Semantic encoder transformer | vs. numpy, all 6 layers | max diff 6.0e-5 |
|
||
| FSQ token ids | vs. numpy | 188/188 identical |
|
||
| Speaker encoder mel / trunk / heads | vs. numpy | max diff 3.7e-5 |
|
||
| Acoustic `mu`, `spk`, time embedding | vs. numpy | max diff 1.3e-5 |
|
||
| Acoustic velocity field, every Euler step | vs. numpy | max diff 5.8e-3 |
|
||
| Acoustic self-reconstruction | NMSE vs. the reference's own mel | 0.38 |
|
||
| Fixed `seed` reproducibility | byte-identical WAV across runs | pass |
|
||
| Streaming chunk sum | chunks vs. `--out` | exact |
|
||
| Voice cache on vs. off | server, 7 requests per mode (repeats, a new seed, an eviction) | byte-identical |
|
||
| Voice prepared ahead vs. not | C API, `audiocpp_session_prepare` with only the voice, both modes | byte-identical |
|
||
| Long-form, 6026 chars, `text_chunk_size=200` | ~371 s of audio, Apple M3, 6 threads | offline 118 s, streaming 136 s; peak RSS 1.57 / 1.95 GB |
|
||
| `matmul_weight_type` f16 / bf16 / q8_0 | vs. f32, same `seed`, both modes | f16 0.12–0.16 dB, bf16 0.5–2.1 dB spectrum; q8_0 samples different tokens |
|
||
| Published f16 GGUF | vs. safetensors, 12 cases, same `seed`, both modes | 0.03–0.08 dB spectrum on 8 of 12, both greedy cases included; the f16 LM samples different tokens on the 4 long sampled cases |
|
||
| `orig` GGUF | vs. safetensors, same `seed`, both modes | byte-identical |
|
||
|
||
The numpy references above are independent reimplementations from the same source, which
|
||
catches implementation bugs but not a shared misreading of the architecture.
|
||
|
||
### Parity with the Python package (sopro 2.2.0)
|
||
|
||
Measured end to end against `SoproTTS.synthesize` and `SoproTTS.stream` from the published 2.2.0
|
||
wheel and the published checkpoint, CPU backend: EN and PT, single- and multi-segment text, 16-
|
||
and 32-bit references, one reference short enough to take the room-tone crop branch. For these
|
||
checks the C++ run was given the Python run's random draws (AR tokens, solver noise, room tone),
|
||
so every other stage is computed independently on each side.
|
||
|
||
| Check | Result |
|
||
|---|---|
|
||
| Reference crop, normalisation, 16 kHz resample | max sample diff 5e-7 |
|
||
| Reference semantic tokens | identical, all cases |
|
||
| Reference analysis mel | max diff 6e-5 to 1.3e-3 on 16-bit references |
|
||
| Acoustic solve (2 Euler steps), PT case | max diff 6e-4, mean 6.5e-5 (normalised mel units) |
|
||
| Vocoder + gain + trims + fades, PT case | 77.5 dB waveform SNR when fed the same solved mel |
|
||
| Greedy AR (temperature 0, C++ samples its own tokens) | identical tokens and length, 2 cases |
|
||
| Output length | identical, all cases |
|
||
| Output level | within 0.02 dB |
|
||
| Output spectrum (STFT magnitude, cells within 60 dB of peak) | mean diff 0.03–0.05 dB on 16-bit references |
|
||
| Streaming vs `SoproTTS.stream`, 5 cases (up to 47 chunks, 2 segments) | identical length and chunk boundaries |
|
||
| Streaming output spectrum | mean diff 0.03 dB on 16-bit references |
|
||
|
||
Two things to read the numbers right:
|
||
|
||
- **Compare spectra, not samples.** The vocoder predicts phase, so the waveform is very
|
||
sensitive to its input: adding 1e-5 of noise to the normalised mel moves the Python output
|
||
itself to ~52 dB waveform SNR, and the 6e-4 float32 difference of the solve gives ~31 dB.
|
||
The magnitude spectra stay within a few hundredths of a dB.
|
||
- **Near-silent reference bands are float32 noise.** On two 32-bit studio references the analysis
|
||
mel differs (up to 0.3) in cells ~30 dB below the frame, mostly near 11–12 kHz. Those cells
|
||
sit below float32 resolution: adding 1e-8 of noise to the Python reference moves them by up
|
||
to 0.45. Those outputs differ by 0.16–0.26 dB mean; with the C++ reference mel fed to Python
|
||
the gap closes to 0.01–0.02 dB.
|
||
|
||
### Debugging
|
||
|
||
`tests/sopro_tts/sopro_probe.cpp` (built with `-DENGINE_BUILD_WARMBENCH=ON`) exercises each
|
||
stage in isolation against a reference clip:
|
||
|
||
```bash
|
||
build/bin/sopro_probe models/sopro-v2-turbo reference.wav /tmp/soproprobe
|
||
```
|
||
|
||
It reports the `crop_on_pause` decision, a mel round trip through the vocoder (which is
|
||
phase-invariant and so the meaningful vocoder check), the FSQ token histogram, the speaker
|
||
embedding statistics, and an acoustic self-reconstruction NMSE. It also writes
|
||
`probe_vocoder_roundtrip.wav` — the reference passed through mel then the vocoder; if that
|
||
sounds like the speaker, the whole back half of the pipeline is fine.
|
||
|
||
Setting `SOPRO_DUMP_DIR=<dir>` additionally dumps the encoder and solver intermediates as raw
|
||
f32 for diffing against a reference implementation.
|
||
|
||
## References
|
||
|
||
- Model card: <https://huggingface.co/samuel-vitorino/sopro-v2-turbo>
|
||
- Reference implementation: <https://github.com/samuel-vitorino/sopro>
|
||
- Blog post: <https://research.haloneuro.ai/posts/sopro-v2>
|