voice_audio_multi/docs/models/echo_tts.md
Fabio c36510c21e
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
Initial import: audio.cpp with Orange Pi documentation
2026-11-04 19:05:22 +01:00

414 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Echo-TTS
Echo-TTS is an English zero-shot voice-cloning TTS model. A 2.8B diffusion transformer (EchoDiT)
generates 80-dimensional latents in PCA space, which the Fish S1-DAC autoencoder decodes to 44.1 kHz
audio. Cloning takes a reference wav with **no transcript required**.
Upstream: [jordand/echo-tts-base](https://huggingface.co/jordand/echo-tts-base) ·
autoencoder: [jordand/fish-s1-dac-min](https://huggingface.co/jordand/fish-s1-dac-min)
| Family | `echo_tts` |
|---|---|
| Languages | en |
| Model directory | `models/echo-tts` |
| Modes | offline |
| Sample rate | 44 100 Hz |
| Tasks | `tts`, `clon` |
## Text Chunk Size
Use `--text-chunk-size 300` or `--request-option text_chunk_size=300` to set the
maximum Unicode codepoints per text chunk. The default is 300; explicit values
must be positive. Each chunk is generated separately within the model's fixed
audio window.
## Status
**Work in progress.** Every stage is *implemented*; the open question is how much of it is
*verified*. Those are tracked separately on purpose, because a clean build and plausible audio
prove neither.
| Milestone | Scope | Implemented | Numerically verified |
|---|---|---|---|
| M0 | Family registration, model spec v1 | yes | n/a |
| M1 | GGUF conversion, DiT, PCA inverse, Fish decode | yes | **yes** — denoiser and 40-step trajectory both above gate |
| M2 | Native speaker encoding (Fish encoder + RVQ) | yes | folded into the denoiser probe below |
| M3 | Long-form via the framework text chunker | yes | **no** |
| M4 | Q8_0 conversion, RTF and memory evidence | partial | **no** |
Cloning is self-contained — `session.cpp` calls `codec_->encode_latents` directly, so no pre-computed
speaker latent is required.
### Numerical parity against PyTorch
Measured on an RTX 3090 (sm_86), CUDA, F16 GGUF, by `tests/echo_tts/echo_tts_dit_parity.cpp`
against a dump from `tools/community_models/echo_tts/echo_tts_reference.py`. Gates are cosine over the
flattened tensors **and** max-absolute-error, never equality: cosine alone cannot see a uniform
scale error, and the host Philox stream matches CUDA to ~2 ULP rather than bit-exactly.
**The reference's own weight dtype dominates every number here**, and getting it wrong is what made
the sampler look broken for most of this port's life. ggml accumulates in F32 regardless of the
stored weight type, so the like-for-like comparison against an F16 GGUF is a **float32** reference,
not an F16 one. Run that way, every check passes:
| Check | vs float32 reference | Gate | Verdict |
|---|---|---|---|
| Denoiser, one conditional forward at t = 0.7 | cosine 0.999999899, max-abs 0.0126 | ≥ 0.999, ≤ 0.25 | **PASS** |
| 40-step sampler, reference initial noise injected | cosine 0.999516, max-abs 0.318 | ≥ 0.999, ≤ 4.0 | **PASS** |
| 40-step sampler, our own seeded draw | cosine 0.999542, max-abs 0.319 | ≥ 0.999, ≤ 4.0 | **PASS** |
For contrast, the same GGUF against lower-precision references — this is the whole of the residual
that earlier revisions of this document reported as unexplained:
| Check | vs bfloat16 reference | vs float16 reference |
|---|---|---|
| Denoiser | cosine 0.999977 | cosine 0.999999188 |
| Sampler, injected noise | cosine 0.913082 | cosine 0.988459 |
| Sampler, seeded draw | cosine 0.905481 | cosine 0.976972 |
Generate the F32 dump with `--force-dtype float32`, which also disables TF32 — TF32 has a 10-bit
mantissa, no better than F16, and Ampere would otherwise use it for matmuls and defeat the point.
**The denoiser probe passes and is the number that carries the port.** It is what settles the four
details that fail *silently* rather than loudly — half-head RoPE, the interleaved (not NEOX) rotary
pairing, the speaker patchify reshape, and the adaLN `shift/scale/gate` order. A 0 % WER cannot do
that job: it scores the words, not the speaker identity, so a wrong patchify reshape in particular
could yield fluent, correctly-worded speech in the wrong voice and still read as 0 %.
Note what the probe does **not** isolate. The reference text ids and speaker latents are injected,
but `prepare_conditioning()` then runs this port's own text encoder, speaker encoder and KV
projections, so the number covers the combined conditioning-plus-denoiser path. That is enough to
catch a wrong block; it is not enough to localise one. Per-block activation dumps
(`echo_tts_pack_reference.py --blocks`) exist for that and have not been run.
**The 40-step trajectory now passes, and the earlier shortfall was the reference, not this port.**
The evidence that pointed there before it was confirmed:
- **It was not the RNG.** Injecting the reference's own initial noise scored no better than our
seeded draw (0.988 vs 0.977), so the Philox difference was never the mechanism.
- **It tracked dtype.** Each step up in reference precision moved the seeded trajectory: 0.905 at
bfloat16, 0.977 at float16, 0.9995 at float32.
- **It compounded with step count.** At 4 steps the bfloat16 comparison scored 0.9965; at 40 it
scored 0.9055. Monotonic degradation with step count is the signature of accumulating rounding in
one of the two implementations, amplified every step by dual CFG at 3.0 and 8.0.
- **A line-by-line review of the sampler found no defect** — schedule, inclusive CFG bounds, single
application of `truncation_factor`, three-lane CFG combination, the Euler update and the
speaker-KV boundary all agree with `inference.py`.
The rounding was PyTorch's, not ours. Credit to @dignome for running the float32 reference that
settled it; the numbers above were then reproduced independently on an RTX 3090 with the gates
enforced.
### Host-side checks
`tests/echo_tts/echo_tts_host_units.cpp` is registered with `add_test` and needs neither a GPU nor
the checkpoint. It covers WhisperD normalisation (including the asymmetric quote rewrite and the
bare-`S1` tag suppression), full byte-token id vectors, truncation and padding, PCA projection and
inversion pinned independently against hand-computed values on a rectangular non-symmetric basis,
and the flattening-point crop including its standard-deviation *and* mean thresholds.
Separately, and **by hand rather than in CI**: PCA project/unproject at 5.7e-06 against numpy on
the real (80, 1024) basis, the Euler dual-CFG update at 6.6e-07 against a numpy transcription of
`inference.py`, the timestep embedding at 0.0 diff, and the tokenizer at 140/140 ids on the parity
prompt. `combine_cfg_lanes` and `euler_timestep_schedule` have no registered coverage.
### End-to-end run, RTX 3090 (sm_86), CUDA, F16 GGUF
| Check | Result |
|---|---|
| Conversion | `manifest OK`; 1117 DiT tensors written, 219 blockwise tensors dropped, 495 codec tensors |
| GGUF verifier | pass — 1614 tensors (`dit_weights/` 1117, `ae/` 495, `pca/` 2), F16 1043 / F32 571 |
| `latent_scale` | 0.0555555559694767 (= 1/18), matching the reference |
| Generation | exit 0, 44 100 Hz mono, no NaNs, peak 0.80 (below the normalisation threshold) |
| ASR round-trip, 15 words | WER 0 % — the only diffs are Whisper writing spoken "dot" as punctuation |
| ASR round-trip, 32 words | WER 0.0 %, 0 edits |
| Throughput | 9.195 s of audio in 7.89 s wall — **RTF 0.86 cold**, including the 5.5 GB model load |
Transcription used `faster-whisper-large-v3-turbo`. Generation cost is essentially constant across
those two runs (7.75 s vs 7.89 s) because the window is fixed at 640 frames, so longer text inside
one chunk is close to free.
WER on 32 words is a small sample and scores intelligibility only. It shows the pipeline runs end to
end and produces the right words; the denoiser cosine above is what shows the graph is right.
### What is still missing
- No per-block DiT activation dump, so the passing denoiser cosine proves correctness without
localising where any future regression lives.
- No A/B of the flash-attention path against `AUDIOCPP_ECHO_TTS_NO_FLASH=1` on a fixed seed.
- No listening comparison of F16 against Q8_0.
- Warm RTF and VRAM-stability-across-requests numbers.
None of these block the port from working; they bound how much the numbers above prove.
## Known limitations
### Fixed 29.72-second generation window
Echo is trained to generate at most **640 latents**, and 640 × 2048 ÷ 44100 = **29.7215 s**. This is
a property of the model, not of this port.
Behaviour outside that window:
- Text corresponding to more than ~30 s is **spoken faster** to fit, rather than truncated. This is
learned behaviour arising from global attention over the text, not an explicit compression step.
- The upstream tokenizer hard-truncates text past **768 UTF-8 bytes**.
- Requesting a shorter window does *not* compress the whole utterance into it — upstream documents
that the model generates a **prefix** of the utterance instead.
`long_form` is therefore **not** claimed in `capabilities` at this stage.
### Blockwise generation does not extend the window
Upstream ships a blockwise sampler that generates in connected blocks and supports continuing from
existing audio. It **subdivides** the ≤30 s window rather than extending it: upstream requires
`sum(block_sizes) + continuation_length < 640` "to be in-distribution with training data", and
documents prefix plus continuation as "up to 30 seconds combined". Upstream also notes blockwise
"hasn't been thoroughly tested".
## Licence — read before using output commercially
Echo-TTS is **CC-BY-NC-SA-4.0**, and the restriction covers **generated audio, not only the
weights**. The output constraint is inherited from the Fish S1-DAC autoencoder — the same mechanism
that makes Fish Speech's own outputs non-commercial.
Practically: **audio produced by this model may not be used commercially**, regardless of how the
rest of your stack is licensed. audio.cpp itself is Apache 2.0 and is unaffected; model weights are
a separate download.
There is existing precedent in-tree — `fish_audio` (Fish Audio S2 Pro) carries the identical
output restriction from the identical dependency.
## Why this model
Selected by comparing every model tracked in [tts-bench](https://github.com/5uck1ess/tts-bench) — a
public benchmark covering **62 local TTS models** across speed, objective scores, and blind human
preference — against audio.cpp's existing support table.
| Measure | Echo-TTS | Field |
|---|---|---|
| Blind cloning Elo | **1162** | #3 of 40 (35 games; 738 cloning votes total) |
| Speaker similarity (SIM) | **0.836** | 2nd of 41 scored models |
| UTMOS (naturalness) | 4.21 | — |
| WER (intelligibility) | 7.45 % | — |
| Frozen pairwise study | **21-1-6** | near-tied 1st of 28 |
Two honest caveats: the cloning arena averages ~30 games per model, so gaps under ~100 Elo are
noise, and the ranking uses a single reference clip. Echo's standing is robust to both — it is
top-3 on human votes *and* 2nd on objective SIM, which are independent measurements.
Compute profile suits a GGUF port: ~2.8 B parameters at 1.35× RTFx and 9.4 GB VRAM in PyTorch on an
RTX 3090, so there is real work to amortise.
## Architecture
| Component | Params | Role |
|---|---:|---|
| EchoDiT trunk, 24 blocks | 1.75 B | Joint attention + SwiGLU MLP, adaLN timestep modulation |
| Text encoder | 294 M | UTF-8 **byte** tokens (256 vocab) — no phonemizer or G2P |
| Speaker encoder | 294 M | Reference PCA latents → speaker states |
| Latent encoder | 294 M | Blockwise only; omitted in M1 |
| PCA state | 83 K | Fish 1024-D ↔ DiT 80-D, `latent_scale` = 1/18 |
| Fish S1-DAC | 391 M weights | Reference encoding and waveform decoding |
Sampling is 40 Euler steps with **two independent CFG scales** — text (default 3.0) and speaker
(default 8.0) — gated to `t ∈ [0.5, 1.0]` (this is the default - matching upstream - sampling behavior). There is also optional **single CFG scaling** (`echo_tts.cfg_mode=joint`) with combined text and speaker guidance scale (default 8.0), as well as optional `t_schedule_mode=sway` timestep scheduling, with default 12 Euler steps.
Note the Fish checkpoint stores an additional 303.6 M elements of `freqs_cis` and `causal_mask`
buffers. These are regenerated at runtime rather than shipped in the GGUF.
## Options
| Option | Type | Default | Description |
|---|---|---|---|
| `text_guidance_scale` | float | 3.0 | Classifier-free guidance scale on the text condition (Independent-CFG mode). Advertised range is recommended, not enforced by the parser. |
| `speaker_guidance_scale` | float | 8.0 | Classifier-free guidance scale on the speaker condition (Independent CFG mode). Advertised range is recommended, not enforced by the parser. |
| `guidance_scale` | float | 8.0 | Classifier-free guidance scale on the text+speaker condition (Joint-CFG mode). Advertised range is recommended, not enforced by the parser. |
| `cfg_mode` | string | independent | What type of classifier-free guidance to use. `independent` is the default and upstream implementation, using two guidance scales (one for text, one for the speaker). `joint` uses a single guidance scale on both the text and the speaker. |
| `num_inference_steps` | int | 40/12 | Euler sampler steps. Default 40 for `t_schedule_mode=linear`, default 12 for `t_schedule_mode=sway`. |
| `t_schedule_mode` | string | linear | `linear` time scheduling is the default upstream; `sway` scheduling modifies the grid to place more timesteps where the noise is higher. Sway time schedules can reduce the number of steps needed to produce similar quality. |
| `sway_coeff` | float | -1.0 | Sway coefficient for producing timesteps. Values must be finite and produce decreasing timesteps (validated at request) |
| `truncation_factor` | float | 0.8 | Initial-noise truncation factor. Advertised range is recommended, not enforced by the parser. |
| `speaker_kv_scale` | float | 1.0 | Force-speaker KV scaling. 1.0 disables; 1.5 is the upstream default when enabled. Advertised range is recommended, not parser-enforced; zero is invalid for reciprocal scaling. |
| `seed` | int | 0 | RNG seed for the initial latent. Negative signed-integer values are treated as zero. |
| `reference_duration_sec` | float | 15.0 | Trim the speaker reference to at most this many seconds before encoding. Shorter references are cheaper and often clone better; upstream's guidance favours around 10 s. Defaults to 15 s. Values above the trained maximum of 297.1 s are clamped. Set per request, or as a session default from CLI or server config. Must be positive. |
| `max_duration_sec` | float | — | Cap the generation window, in seconds (up to 29.7215 s, the trained maximum; larger values are clamped to it). Quantised down to a whole latent frame of 46.44 ms. Left unset, the window is estimated per chunk from text length and widened automatically if the utterance does not finish inside it, which is substantially cheaper for short text. Must be positive; rounded to at least one latent frame. |
| `mem_saver` | bool | false | Release Fish S1-DAC decode graph state when prior to and encode graph state after encoding reference speaker audio to reduce peak VRAM usage and steady-state VRAM usage when speakers are already cached.
| `guidance_interval` | int | 1 | Refresh the two unconditional CFG lanes only every Nth guided step. 1 reproduces upstream exactly. See [performance notes](../community_models/echo_tts_performance.md). |
Session options are `echo_tts.reference_duration_sec`,
`echo_tts.reference_cache_slots`, `echo_tts.mem_saver`, and `echo_tts.cfg_mode`.
## Text format
Prompts follow the [WhisperD](https://huggingface.co/jordand/whisper-d-v1a) transcription style:
- `[S1] ` is prepended automatically when neither `[S1]` nor `[S2]` is present.
- Colons, semicolons, and em dashes are normalised to commas.
- Commas generally function as pauses.
- Exclamation points and other emphatic punctuation increase expressiveness but can reduce quality.
Multi-speaker dialogue is expressed with `[S1]` / `[S2]` tags.
## Reference audio
Up to 5 minutes is accepted; 10 seconds or less works well. Audio is mixed to mono, resampled to
44.1 kHz, and peak-limited before encoding.
## Running it
```
audiocpp_cli \
--family echo_tts \
--model /path/to/Echo-TTS-GGUF \
--task clon \
--voice-ref reference.wav \
--text "[S1] Alright, I'm going to demo this new model." \
--out out.wav
```
`tts` is an alias to the same synthesis path. Route selection does not change
the model's existing input requirements.
The speaker reference is `--voice-ref`, not `--target-voice`; the latter is for
path-based voice conversion. No transcript of the reference is needed. Useful
request options: `num_inference_steps` (default 40), `text_guidance_scale`
(3.0), `speaker_guidance_scale` (8.0), `truncation_factor` (0.8), and `seed`.
## Quantisation
`--precision q8_0` produces a roughly half-size GGUF:
| | F16 | Q8_0 |
| --- | ---: | ---: |
| DiT | 4.76 GB | 2.53 GB |
| codec | 0.78 GB | 0.50 GB |
Q8_0 packs 32 weights per block behind one shared scale, so a tensor qualifies
only when its last logical dimension is a multiple of 32. The converter routes
each tensor accordingly rather than quantising blindly:
* **Q8_0** -- every 2-D matmul weight with a conforming row length. That is all
but one DiT tensor, and 78% of codec weights.
* **F16** -- convolution kernels (`ggml_conv_1d` has no quantised path, which is
why `codec.cpp` takes matmul and conv storage types separately) and the one
non-conforming matmul, `in_proj.weight` at (2048, 80).
* **F32** -- norm weights, biases, snake alphas, LayerScale/ConvNeXt gammas and
the codebooks, exactly as at other precisions.
Round-trip error is around 6e-05 RMSE with cosine similarity above 0.9999 on
weight-like distributions. Because the scale is per 32-weight block, the large
outliers this model carries in its late blocks and in `k_norm` degrade only
their own block rather than a whole row -- and `k_norm` is F32 regardless.
Quality has not been compared against F16 on real audio. Start with `orig` and
treat Q8_0 as an experiment until someone listens to both.
## Limiting the reference length
`reference_duration_sec` trims the speaker reference before encoding. Shorter
references cost less and often clone better -- upstream's guidance favours
around 10 s, and a long clip averages timbre over more prosodic variation.
Per request (bare name):
```
--request-option reference_duration_sec=30
```
As a default for a CLI run or a server, in the session scope (family-prefixed,
which is how the framework namespaces session and load options):
```
--session-option echo_tts.reference_duration_sec=30
```
In a server config file the same key goes under `session_options`, with a string
value. A request value overrides the session default. Values above the trained
maximum of 297.1 s are clamped rather than rejected. Trimming happens before
chunked encoding, so a cap also bounds encode time and VRAM.
## Reference encoding cost
Encoding the speaker reference is linear in its length: one Fish encode pass per
~29.7 s chunk, so a 4m29s clip is ten passes against one for a 28 s clip. At the
trained maximum that is roughly 22% of a request's arithmetic, before per-graph
launch overhead.
The result depends only on the audio and the trim length, so it is cached across
requests. A server rotating a few voices pays the cost once per voice instead of
once per request:
```
--session-option echo_tts.reference_cache_slots=8
```
Default 4; `0` disables it. Each slot holds only the projected latent, at most
2 MB. The cache lives with the session, so it helps a running server and not a
one-shot CLI invocation. Beyond caching, the levers are `reference_duration_sec`
and shorter references generally -- around 10 s is one chunk, the floor.
## The Fish S1-DAC autoencoder
Echo decodes its 80-D PCA latents through the Fish S1 DAC and encodes speaker
references with the same model. audio.cpp ships that codec as a framework
runtime, `engine::codecs::FishDacCodecRuntime`, so Echo and `fish_audio` share
one implementation -- but **not** the weights. `fish_audio` ships Fish Audio S2
Pro; Echo is trained against the S1 DAC (`jordand/fish-s1-dac-min`), and
`pca_state.safetensors` is fitted to that
codec's latent space specifically. Pointing Echo at S2 Pro would produce
plausible-looking latents and wrong audio, with no error anywhere.
The S1 weights are therefore packaged inside Echo's own GGUF, in the `ae`
namespace:
```
python3 tools/community_models/echo_tts/convert_echo_tts.py \
--model-dir /path/to/echo-tts-base \
--fish-dir /path/to/fish-s1-dac-min \
--outfile Echo-TTS-GGUF/model.gguf
```
No companion model and no extra options are needed at run time. Two details the
converter handles:
* **Weight normalisation is folded.** The checkpoint stores it in two forms --
`conv.parametrizations.weight.original0/original1` on the convolutions and
legacy `weight_g`/`weight_v` on the quantiser projections -- and `codec.cpp`
expects plain `conv.weight`. Both reduce to `w = g * v / ||v||` with the norm
taken over every axis but the first. Note that for `ConvTranspose1d` axis 0 is
the *input* channel count, so `g` is sized by input channels there; the
decoder's four transposed convolutions are the only place this bites.
* **Fused qkv projections are split.** `autoencoder.py` keeps one `wqkv`
linear and splits its output into three equal blocks; `codec.cpp` loads
`attention.q_proj` / `k_proj` / `v_proj` separately, so the converter
partitions the weight rows in the same order.
* **Exact tensor shapes are carried in metadata.** `ggml_n_dims()` ignores
trailing dimensions of size 1, so a `(1, C, 1)` snake alpha would read back as
`(C, 1)` and fail `codec.cpp`'s `{1, C, 1}` shape check. The converter emits
`audiocpp.tensor_ranks` (INT32) and `audiocpp.tensor_shapes` (INT64) in tensor
order, which audio.cpp uses in preference to the lossy inference.
* **The GGUF embeds its own model spec.** `package.cpp` refuses to load a
published GGUF that does not, so the converter copies
`model_specs/echo_tts.json` into the `audiocpp.model_spec.*` metadata keys.
A distributed file is therefore self-describing and does not depend on the
reader having a matching `model_specs/` checkout. Use `--model-spec` to embed
a spec from elsewhere.
* **Namespaces are separated by `/`, not `.`** -- `dit_weights/...`, `pca/...`,
`ae/...`. `PrefixedTensorSourceView` matches on `prefix + "/"`, so a
dot-separated name is never routed and the loader reports the namespace as
non-existent rather than the tensor as missing.
* **The codec namespace is `ae`, not `codec_weights`.** ggml caps tensor
names at 64 characters (`GGML_MAX_NAME`) and rejects the whole file at load
time if any name reaches it. The longest name `codec.cpp` loads is already 60
characters, so only a three-character prefix fits; `codec_weights.` would push
157 of the 455 codec tensors over. The converter refuses to write a GGUF that
would trip this, and `verify_echo_gguf.py` re-checks it.
* **Registered buffers are dropped.** Two causal masks and three RoPE tables
account for 305 MB of the 1.87 GB checkpoint and are rebuilt at graph
construction, so they are not stored.
That leaves roughly 1.57 GB of codec weights on top of the 4.76 GB DiT.
`docs/archive/echo_tts_autoencoder_reuse.md` covers how the two
families share the codec implementation and where the seam sits in
`src/framework/codecs/fish_dac_codec_runtime.cpp`.