Add Echo-TTS (community model) - #180
Conversation
|
I had a go at it. Can use anything you want from here if it helps. |
|
@dignome appreciate it. i was all out of tokens building a SaaS. will take a look and implement. |
…rter Replaces the M0 silence stub with a complete port, contributed by @dignome and offered for use in this PR (see PR 0xShug0#180 discussion). DiT trunk, 24 blocks, joint attention with flash-attn path Byte tokenizer + WhisperD normalisation Euler dual-CFG sampler with independent text/speaker guidance PCA inverse + flattening-point crop Fish S1-DAC z_q seam, reusing the in-tree fish_audio codec GGUF converter (F16 and Q8_0) plus a manifest and verifier Long-form via the framework text chunker at 300 codepoints Scope trimmed from the source branch before landing: the root README dump, echotts-server.json (hardcoded machine paths), webui build artifacts, and a generic validate_model_spec.py that is not Echo-specific and belongs in its own PR. Two corrections on top of the contributed tree: resolve_reference_max_samples' comment claimed it falls back to the trained maximum; it returns kDefaultReferenceMaxSamples (15 s). The code is intentional and the spec publishes 15.0 in both scopes, so the comment was the error, not the behaviour. The status table still carried this PR's original milestone list, which said M2 was not started and that cloning needed a pre-computed speaker latent. session.cpp calls codec_->encode_zq directly, so both claims were false. Rewritten to separate what is implemented from what is numerically verified, because nothing in the ggml graph has been checked against PyTorch yet. Co-authored-by: dignome <dignome@gmail.com>
10e833e to
1a9619a
Compare
|
@dignome this was extremely useful — thank you. I've integrated it and it's now the body of this PR, with your commits preserved as It works. Converted an F16 GGUF locally from
Sounds good too, not just measurable-good. I ran it past Codex and Grok before adopting. Codex checked your five "most likely to be wrong" items against the reference PyTorch line by line and cleared four of them — half-head RoPE on the head axis ( One real find, and both reviewers hit it independently. The adaptive generation window defaulted on, with the reasoning that an under-estimate "costs time, never fidelity" because the retry reuses the Philox stream. The noise argument is correct but the conclusion doesn't follow, because the seed isn't what changes — Trimmed for upstream scope: the root README section, Still draft. Missing a real parity pass against PyTorch, and a Genuinely appreciate you publishing it and offering it up instead of racing a competing PR. hats off to you sir @dignome |
|
We definitely do not need the framework change - I was just trying to avoid converting all my wav files as the cli/server path has weaker support for audio formats than the web ui (in my fork). I'll have to look why Claude decided to rework the fish audio codec but it most likely has something to do with Echo using the fish audio S1 codec (audiocpp currently has an implementation of the S2 audio codec). Would make more sense if we do modify existing fish audio codec to rename the zq stuff as s1 just to clarify what it is doing. |
…rter Replaces the M0 silence stub with a complete port, contributed by @dignome and offered for use in this PR (see PR 0xShug0#180 discussion). DiT trunk, 24 blocks, joint attention with flash-attn path Byte tokenizer + WhisperD normalisation Euler dual-CFG sampler with independent text/speaker guidance PCA inverse + flattening-point crop Fish S1-DAC z_q seam, reusing the in-tree fish_audio codec GGUF converter (F16 and Q8_0) plus a manifest and verifier Long-form via the framework text chunker at 300 codepoints Scope trimmed from the source branch before landing: the root README dump, echotts-server.json (hardcoded machine paths), webui build artifacts, and a generic validate_model_spec.py that is not Echo-specific and belongs in its own PR. Two corrections on top of the contributed tree: resolve_reference_max_samples' comment claimed it falls back to the trained maximum; it returns kDefaultReferenceMaxSamples (15 s). The code is intentional and the spec publishes 15.0 in both scopes, so the comment was the error, not the behaviour. The status table still carried this PR's original milestone list, which said M2 was not started and that cloning needed a pre-computed speaker latent. session.cpp calls codec_->encode_zq directly, so both claims were false. Rewritten to separate what is implemented from what is numerically verified, because nothing in the ggml graph has been checked against PyTorch yet. Co-authored-by: dignome <dignome@gmail.com>
8b783d5 to
68a99af
Compare
|
Reverted The reader rejects WAVEFORMATEXTENSIBLE files saying it needs PCM16, when PCM16 is what's inside, just tagged Fish Audio I do need. Echo's DiT works in continuous latents, not codebook indices, so it needs Two changes there and I'd run them together in the PR body:
3 files, +248/-10, and those 10 lines are the only deletions in the PR. Happy to split it out first if you'd rather review it on its own. On Still open:
Rebased on main, 31 files, out of draft. |
|
This is the best interpretation I can find for why Claude chose to modify the existing fish audio codec. I'm not a professional coder so most of the below was generated via an AI to help me understand why these changes were made. ============= The proposed Echo implantation reuses the existing Fish S2-DAC codec, but unlike Fish S1-mini/S2, it does not generate discrete codebook IDs. Echo generates continuous latents that are converted directly into the codec’s internal z_q representation. z_q is the existing fish codec’s quantized latent audio representation. The name means: z: a hidden or latent representation For each approximately 46 ms audio frame, z_q is a vector of 1,024 floating-point values describing the sound. The Fish codec already computes and consumes z_q internally, but its public API only accepted or returned codebook IDs. The changes expose this existing boundary: encode_latents() returns z_q for Echo’s reference-audio conditioning. This reuses the existing codec implementation without duplicating it or adding a lossy z_q → codebook IDs → z_q conversion. Normal Fish Audio decoding remains codebook IDs → z_q → audio. |
|
@dignome that's right, and I've gone with One correction on the lossy bit, and it's the half that matters. Encode is not lossy through codes. z_q is defined as the sum of the dequantised codebook entries, which is literally what Decode is where it breaks. Echo's DiT emits latents that aren't on the RVQ lattice at all — session goes Put that in the header comment since it's easy to get backwards. And yeah, your numbers check out: 2048 samples at 44.1k is 46.4 ms, 1024 dim. |
|
The two red checks look like runner trouble on your side rather than this branch — mac came back The commit before these passed all four. I ran the same three workflows on my fork at the same SHA to check: Linux (cpu + vulkan), macOS and Windows all green. Also built the CI config locally (Debug, gcc-13, CUDA and Vulkan off, same three targets) — clean, and I can't rerun your jobs without admin, so flagging rather than fixing. |
…rter Replaces the M0 silence stub with a complete port, contributed by @dignome and offered for use in this PR (see PR 0xShug0#180 discussion). DiT trunk, 24 blocks, joint attention with flash-attn path Byte tokenizer + WhisperD normalisation Euler dual-CFG sampler with independent text/speaker guidance PCA inverse + flattening-point crop Fish S1-DAC z_q seam, reusing the in-tree fish_audio codec GGUF converter (F16 and Q8_0) plus a manifest and verifier Long-form via the framework text chunker at 300 codepoints Scope trimmed from the source branch before landing: the root README dump, echotts-server.json (hardcoded machine paths), webui build artifacts, and a generic validate_model_spec.py that is not Echo-specific and belongs in its own PR. Two corrections on top of the contributed tree: resolve_reference_max_samples' comment claimed it falls back to the trained maximum; it returns kDefaultReferenceMaxSamples (15 s). The code is intentional and the spec publishes 15.0 in both scopes, so the comment was the error, not the behaviour. The status table still carried this PR's original milestone list, which said M2 was not started and that cloning needed a pre-computed speaker latent. session.cpp calls codec_->encode_zq directly, so both claims were false. Rewritten to separate what is implemented from what is numerically verified, because nothing in the ggml graph has been checked against PyTorch yet. Co-authored-by: dignome <dignome@gmail.com>
aaaf652 to
8026e84
Compare
|
Can find some premade gguf files for f16 and q8_0 here: https://huggingface.co/dignome/Echo-TTS/tree/main Updated to reflect change to model spec (e8b886e) |
|
@5uck1ess @dignome Thanks for the updates! Since fish audio and echo tts share codec components in some way, I promoted it as a framework/runtime and refactored fish audio. This should make it easier to extend and maintain while reducing across-model dependencies. Could you try to use the new runtime? I think the new runtime should be able to support the echo tts use case. If not feel free to extend the codec runtime additively. Could you also normalize the spec option names? You can find examples in #128 and the specs of the v1 core models under model_specs/ I'll do more testing after wrapping up release 0.7. |
Design for porting Echo-TTS (jordand/echo-tts-base, 2.8B DiT + Fish S1-DAC) into audio.cpp as a community model. Architecture verified against upstream source and safetensors headers, not inferred. Key findings: - EchoDiT: 24 blocks, d=2048, joint attention, adaLN, byte-level text - Fixed 640-latent / 29.72s generation window - Blockwise path subdivides that window, does not extend it - Decode and encode need near-disjoint Fish submodules - 303.6M of the Fish checkpoint is regenerable buffers, not weights Staged M0-M4 with per-milestone gates and a hard Definition of Ready before the PR leaves draft.
- Resolve RoPE theta open question (10000.0, complex-valued, model.py:9) - Add timestep embedding formula - Warn RTF vs RTFx are inverses (tts-bench vs audio.cpp conventions) - Cite the actual schema validator for the M0 gate - Define 'cosine' precisely (flattened 1-D, with max-abs-error) - Add decomposition note: M0+M1 in one plan, M2/M3/M4 separate
13 tasks, each gated on executed evidence: - M0 (T1-2): spec v1 registration + draft PR - M1 (T3-13): converter, parity dumps, GGUF, assets, tokenizer, text/speaker encoders, 24-block DiT, dual-CFG Euler sampler, PCA inverse + Fish decode, crop, warm bench Every stage gates on cosine >= 0.999 vs PyTorch before the next begins. Task 12 requires a human ear check - tensor parity cannot catch a wrong flattening-point crop. PR stays draft through M1; cloning still needs an injected .npy until M2 lands native speaker encoding.
- Repo has no CMakePresets.json; --preset would fail. Use scripts/build_linux.sh or cmake --build build/linux-cuda-release. - Existing build tree pins CMAKE_CUDA_ARCHITECTURES=75 (Turing) on an sm_86 card. Task 13 now reconfigures to 86 before measuring RTF, otherwise the number is invalid. - Note AUDIOCPP_MODEL_SET=full so the family compiles in automatically.
Baseline on this branch is registered_loaders=42, verified. Also note that a 'requires a schema v1 model contract' failure means a stale binary, not a broken tree.
Plan Task 1. Spec-backed loader (no loader.cpp), schema_version 1, capabilities.clone deliberately omits long_form until M3 earns it. Verified by execution: registered_loaders 42 -> 43, echo_tts appears as 'clon (offline)', spec parses. Fix over Codex's draft: guard used VoiceTaskKind::Tts, but the family registers as a clone task, so every real invocation would have thrown. Corrected to VoiceCloning, matching confucius4_tts:185. The registration gate could not catch this - --list-loaders enumerates loaders without constructing a session.
Documents the fixed 29.72s window, why blockwise does not extend it, the CC-BY-NC-SA output restriction (with the fish_audio precedent), benchmark provenance, options, and the WhisperD text format. Also drops an unrelated .gitignore change that was accidentally swept into an earlier docs commit, so the PR diff stays scoped.
The design spec and implementation plan are our working process, not content for audio.cpp. Preserved on the local echo-tts-planning branch and still on disk; just untracked here so the PR diff stays scoped to the actual contribution.
…rter Replaces the M0 silence stub with a complete port, contributed by @dignome and offered for use in this PR (see PR 0xShug0#180 discussion). DiT trunk, 24 blocks, joint attention with flash-attn path Byte tokenizer + WhisperD normalisation Euler dual-CFG sampler with independent text/speaker guidance PCA inverse + flattening-point crop Fish S1-DAC z_q seam, reusing the in-tree fish_audio codec GGUF converter (F16 and Q8_0) plus a manifest and verifier Long-form via the framework text chunker at 300 codepoints Scope trimmed from the source branch before landing: the root README dump, echotts-server.json (hardcoded machine paths), webui build artifacts, and a generic validate_model_spec.py that is not Echo-specific and belongs in its own PR. Two corrections on top of the contributed tree: resolve_reference_max_samples' comment claimed it falls back to the trained maximum; it returns kDefaultReferenceMaxSamples (15 s). The code is intentional and the spec publishes 15.0 in both scopes, so the comment was the error, not the behaviour. The status table still carried this PR's original milestone list, which said M2 was not started and that cloning needed a pre-computed speaker latent. session.cpp calls codec_->encode_zq directly, so both claims were false. Rewritten to separate what is implemented from what is numerically verified, because nothing in the ggml graph has been checked against PyTorch yet. Co-authored-by: dignome <dignome@gmail.com>
Independent reviews by Codex and Grok both landed on this feature, from
different angles. Codex supplied the mechanism.
The code defaulted the generation window to a text-length estimate,
justified in-comment as "an under-estimate costs time, never fidelity",
because a missing flattening point retries at full length and
generate_torch_cuda_randn is a sequential Philox stream, so the retry
draws bit-identical noise.
The noise claim is true. The fidelity conclusion does not follow, because
the seed is not what changes. Echo's generated self-attention is fully
non-causal -- model.py:249 is
self_mask = torch.ones((batch_size, seq_len), dtype=torch.bool, ...)
so every latent position attends across the whole window. Shrinking 640
to 128 changes the computation at every retained position, not merely how
many positions survive. The reference defaults to 640 (inference.py:353).
The retry also only fires when no flattening point is found, so a short
window that happens to produce a plausible flat tail is never corrected
and silently ships different audio.
Inverted to AUDIOCPP_ECHO_TTS_ADAPTIVE_WINDOW=1. The cost saving is real
and the implementation stays; only the default changes, until it has been
A/B'd against the full window on a fixed seed.
Codex also cleared the four highest-risk items the original author flagged
as possibly-silently-wrong, against the reference: half-head RoPE rotates
the first heads/2 on the head axis (model.py:199,217), GGML_ROPE_TYPE_NORMAL
is the correct interleaved convention against model.py:21's reshape(...,-1,2)
pairing, the speaker patchify reshape produces frame-then-channel ordering
matching model.py:458, and the adaLN chunk order is shift/scale/gate per
model.py:64. Tensor names remain unproven pending a real checkpoint.
Adds the models.md row, with attribution to @dignome for the implementation. Flagged by review as the one doc that was never updated.
Executed on an RTX 3090 (sm_86) against a locally converted F16 GGUF. Conversion reports manifest OK, the verifier passes, and generation round-trips through faster-whisper at 0.0% WER on 32 words. Throughput is RTF 0.86 cold, including the 5.5 GB load. A 0% WER rules out the silent-wrong failure modes -- half-head RoPE, rotary pairing, patchify layout and adaLN chunk order would each produce fluent but incorrect speech. It is still not per-tensor parity, so the missing evidence is listed explicitly rather than implied: no cosine gate against PyTorch, no flash-attn A/B, no fish_audio regression test for the restructured build_decode_quantizer, no F16-vs-Q8 listen, and no registered C++ tests.
The contributed port's host-side units were verified by hand and never
committed as tests. This registers them as a CPU test target that needs
neither a GPU nor the 5.5 GB checkpoint, so CI can hold them.
Coverage:
WhisperD normalisation, including the asymmetric double-quote rewrite
upstream applies to U+201D but not U+201C. That asymmetry looks like
a bug and is load-bearing -- "fixing" it silently desyncs the token
stream from the reference, so the test pins it.
Byte tokenisation: exact token counts and prefixes, [S1] tagging,
bracket/paren suppression, truncation at 768 including the BOS.
PCA forward/inverse round trip on an orthonormal basis, plus a
separate assertion that latent_scale is applied, so dropping it on
either leg fails rather than cancelling out.
find_flattening_point on three cases: a mid-sequence flattening, a
latent that never flattens, and one flat from frame zero.
Every expected value was produced by executing the reference
implementation (inference.py tokenizer_encode / find_flattening_point),
not by reasoning about what it should return.
Verified by mutation rather than by the tests merely passing: removing
the colon rewrite fails the normalisation case, and dropping the inverse
PCA scale fails the round trip at element 0. Both reverted.
Adds the numerical parity evidence the PR was missing. A 0% WER shows the
pipeline is right end to end; it does not show the DiT graph matches
PyTorch, which is what the silent-wrong failure modes would break.
tests/echo_tts/echo_tts_dit_parity.cpp
Loads the GGUF, injects the reference's own text ids, mask and
speaker latent, and scores by cosine plus max-absolute-error.
Cosine alone hides a uniform scale error and max-abs alone is
dominated by one outlier, so both are reported.
Not registered with add_test -- it needs a 5.5 GB GGUF and a
PyTorch dump, so it is hand-driven like dots_tts_vocoder_parity.
tools/community_models/echo_tts_pack_reference.py
Packs echo_ref.npz into a flat binary so the harness needs no npz
parser in C++.
EchoDitRuntime::denoise_once(x, t, lanes)
A testing seam. sample() alone cannot isolate a wrong block from a
wrong integration step; feeding the reference's own x and t makes
any difference attributable to the graph.
Result on an RTX 3090 against the F16 GGUF, reference dumped from
upstream at a fixed seed and a fixed timestep t=0.7:
denoiser cosine=0.999976711 max_abs=0.086061 rms=0.008939 PASS
That clears the 0.999 gate and settles the four items flagged as
possibly-silently-wrong: half-head RoPE, the rotary pairing convention,
the speaker patchify reshape and the adaLN chunk order all now have a
number behind them rather than a code reading.
The full 40-step trajectory from our own seeded noise scores cosine
0.905, below the gate. Not yet reported as pass or fail: the harness now
also runs the sampler from the reference's OWN initial noise, which
discriminates between RNG divergence and a real integration defect. That
run is queued behind an unrelated tts-bench job holding the GPU.
The seam recomputed sequence_length as x.size() / (latent_size * lanes), but x is always a SINGLE lane: sampler.cpp calls denoise(x_t, t, 3) with an x_t of exactly `elements` and expects elements * 3 back. `lanes` selects the width of the OUTPUT, not the input. The three-lane path therefore threw on a non-divisible size instead of running. Caught by executing the parity harness, not by reading it.
… cosines Codex and Grok reviewed the verification commits (not dignome's port) and converged on the same four things. All are addressed here. Parity harness -------------- max-absolute error was computed, printed, and never gated -- `actual = 1000 * expected` scored cosine 1.0 and PASSed. Both checks now require cosine AND max-abs, with `--denoiser-max-abs` / `--sampler-max-abs`. Verified by running the denoiser probe at `--denoiser-max-abs 0.001`: cosine still 0.999999, verdict FAIL. The header claimed the probe attributes any difference "to the graph alone". It does not: `prepare_conditioning()` runs this port's own text encoder, speaker encoder and KV projections, so the number covers the combined conditioning-plus-denoiser path. Corrected in the header and the model doc. The bundle reader took signed name/element lengths straight from the file into `resize()` and treated every dtype tag other than 1 as float. Bounded and validated; the little-endian assumption is now stated rather than implied. Host unit tests --------------- The PCA fixture was a square identity, which is its own transpose, so a transposed basis read passed, and a mean or scale dropped on *both* legs cancelled in the round trip. Replaced with a rectangular, non-symmetric orthonormal basis (2 components over 4 features), with projection and inversion each pinned against independently computed values -- confirmed against numpy. The round trip is now exact to 1e-6 on an in-subspace vector rather than 1e-3 on a bijection. Token tests checked a length and a 12-id prefix; a length-preserving rewrite (signed-char sign extension on multibyte UTF-8) passed. Full id vectors are now pinned for all five cases, generated by executing `tokenizer_encode`. The flattening fixtures were all extreme active-or-zero, so an implementation that merely searched for an all-zero window passed without evaluating either threshold. Added a quiet-but-non-zero tail (0.02 -> 30) and a flat-but-loud tail (0.5 -> 60), both confirmed against `find_flattening_point`. `require_close` compares `fabs(a - b) > tolerance`, which is false for NaN, so NaN passed every float assertion. Wrapped locally with an explicit finite check rather than changing shared test code. Also added `pad_to_max` coverage and truncation *content* -- previously only its length was asserted. Mutation-checked, each reverted after: transposed basis, mean dropped on both legs, scale dropped on both legs, and zero-window search instead of the thresholds. All four now fail the suite; all four passed it before. The sampler residual -------------------- Re-dumping the reference at float16 to match the GGUF (it defaults to bfloat16) moves the denoiser probe from cosine 0.999977 to 0.999999188 and the 40-step trajectory from 0.905481 to 0.976972. Combined with the earlier step-count sweep -- 0.9965 at 4 steps, 0.9055 at 40 -- and Codex finding no defect in a line-by-line read of the sampler against inference.py, the residual is accumulating per-step rounding amplified by dual CFG at 3.0/8.0, not a structural defect. That is an explanation, not a proof: the trajectory is still reported as below the gate. Documentation ------------- docs/community_models/echo_tts.md claimed "No cosine >= 0.999 comparison" and "No C++ unit tests are registered". Both were false at HEAD. It also used a 0 % WER on 32 words to rule out four silent-failure modes, including the speaker patchify reshape -- but WER scores words, not speaker identity, so that one could produce fluent correct text in the wrong voice and still read 0 %. Rewritten: both cosines published in one table, the trajectory marked below gate, WER demoted to what it actually shows, and the hand-run numbers separated from what CI holds. Dropped the internal planning docs again -- a third `git add -A` re-added them in 85ee6d8. Now in .git/info/exclude so it cannot recur.
@0xShug0 asked why this PR touches the framework, and @dignome answered it better than I could: it was never needed here. It rode in with the contributed tree at fa8206c, where it existed to spare their fork's CLI and server path from converting wav files the webui already accepted. Echo needs none of it. Reverted to upstream's reader verbatim; nothing in the echo_tts or fish_audio changes referenced the added helpers. Verified by execution after the revert: wav_reader_test passes, echo_tts_host_units passes, and an Echo-TTS clone from a stereo 24 kHz PCM16 reference still runs clean on CUDA -- RC 0, 4.55 s of 44.1 kHz mono. The dropped support (PCM8, PCM32, float64, A-law, mu-law and WAVEFORMATEXTENSIBLE) is genuinely useful and worth its own PR on its own merits. It just isn't Echo's to carry.
The accessor was renamed from encode_zq in the previous commit; the model doc still named the old one. The remaining z_q mentions in the docs refer to the upstream PyTorch DAC.encode_zq / DAC.decode_zq, which are correct as written and should not be renamed with our C++ API.
Yes, a follow-up PR would be appreciated! |
A follow-up PR would be appreciated! |
0xShug0#310 promoted the Fish S1-DAC into engine::codecs::FishDacCodecRuntime, and it already exposes the two seams Echo needed -- encode_latents and decode_latents. Echo now builds a FishDacCodecComponent from its own codec weights and drops every reference to the fish_audio model. The diff against fish_audio is gone with it: this PR no longer touches that family at all, so the "no regression test for fish_audio" caveat no longer applies and is removed. Verified: echo_tts_host_units and the full ctest suite pass, and an end-to-end CUDA clone (F16 GGUF, RTX 3090) transcribes back at 0 edits.
Per 0xShug0#128 and the names already in use across model_specs_v1. irodori_tts is the closest structural match -- dual CFG plus RF steps -- and Echo now uses the same spelling: num_steps -> num_inference_steps cfg_scale_text -> text_guidance_scale cfg_scale_speaker -> speaker_guidance_scale cfg_interval -> guidance_interval reference_max_seconds -> reference_duration_sec (request and session) sequence_length -> max_duration_sec The last one also changes units. sequence_length was latent frames -- an internal number leaking into the public surface -- so it becomes seconds, in line with the *_sec convention. It is quantised *down* to a whole 46.44 ms frame and clamped to the trained window, so the value is a real ceiling: asking for 2.0 s yields 1.997 s, never more. truncation_factor and speaker_kv_scale keep their names: neither concept has an existing normalized spelling in-tree, and both are domain terms rather than Python internals. The model is unreleased, so no legacy aliases are kept. Internal struct fields are untouched; only the parse boundary in session.cpp moves. Verified against a GGUF re-embedded with the new spec: every normalized name is accepted, every old name is rejected by the contract, max_duration_sec truncates (2.0 -> 1.997 s, 1.0 -> 0.975 s) and clamps at both ends, and the clone still transcribes back at 0 edits.
8026e84 to
e8b886e
Compare
|
Both done, rebased on main. Codec runtime. No extension needed — the promoted runtime already had both seams. Echo builds a Option names. The last one also changed units. Kept Worth noting the contract comes from the spec embedded in the GGUF, not Still open from before: the 40-step sampler trajectory sits at 0.977 against the 0.999 gate. Dtype accounts for most of it — matching the bf16 reference to the F16 GGUF moved it 0.905 -> 0.977 — and a line-by-line sampler review found no defect, but the residual isn't closed. Single-step denoiser is 0.999999. Wav reader follow-up is ready on my side, will open it separately. |
|
GATES="--denoiser-gate 0 --sampler-gate 0 --denoiser-max-abs 1e9 --sampler-max-abs 1e9" Just needed a higher precision reference against echo-tts-base to pass the parity test. Generate with the attached /mnt/work/echo-tts# python echo_tts_reference.py --speaker /mnt/work/audio.cpp-pr180/assets/resources/sample.wav --force-dtype float32 -o echo_ref_f32.npz The gguf files I linked earlier are updated. Only thing I can see that could be fixed now or later is to add echo_tts model to the web ui catalog. webui/configs/model_params.json:
webui/configs/models_catalog.json:
There may be other steps to update the webui not 100% on that. |
… dtype The 40-step trajectory sat at 0.977 against a 0.999 gate and this document called it unverified. It was never this port: ggml accumulates in F32 whatever the stored weight type is, so the like-for-like comparison against an F16 GGUF is a float32 reference, not the bfloat16 one upstream loads by default. The rounding being measured was PyTorch's. Adds --force-dtype to the reference dumper (contributed by @dignome), which also disables TF32 for the float32 path -- TF32 has a 10-bit mantissa, no better than F16, and Ampere would otherwise use it for matmuls and defeat the point. Against a float32 reference, with the gates enforced rather than disabled, on an RTX 3090: denoiser cosine=0.999999899 max_abs=0.012564 gate=0.999/0.250 PASS injected cosine=0.999515574 max_abs=0.318055 gate=0.999/4.000 PASS sampler cosine=0.999541572 max_abs=0.318892 gate=0.999/4.000 PASS @dignome found this and reported the same three PASSes on a 5090. The run above is an independent reproduction, not a restatement of theirs. M1 is now numerically verified end to end, and the status table says so.
Suggested by @dignome. Bounds and defaults are copied from model_specs/echo_tts.json so the two cannot drift: 40 steps, text/speaker guidance at 3.0/8.0, truncation 0.8, guidance_interval 1, reference trim 15 s. download_id points at the echo_tts_orig package, whose download is still declared unsupported in the spec -- there is no official GGUF to point it at yet, so the entry makes the model selectable once weights are on disk rather than downloadable from the UI.
Ready for review. The DiT denoiser passes its parity gate against PyTorch; the remaining gaps are listed honestly under What's still missing, and the two open questions are structural ones only you can answer — see Scope.
Note on
ENGINE_BUILD_TESTS: a full build currently fails on threetests/moss_tts_local/*_parity.cppfiles. That is pre-existing onmain, not from this branch — those files and everymosssource here are byte-identical to upstream, and I reproduced the identical error in a cleanupstream/mainworktree configured from scratch. Flagging it so it isn't attributed to this PR.Adds Echo-TTS as a community model: an English zero-shot voice-cloning TTS model. A 2.8B diffusion transformer generates 80-D latents in PCA space, decoded to 44.1 kHz by the Fish S1-DAC autoencoder. Cloning needs a reference wav, no transcript. Text is byte-level UTF-8 — no phonemiser, no G2P, no pronunciation dependency.
Echo-TTS is on the candidate list in #34 (struck through, "contributions are welcome").
Credit
The implementation is @dignome's. They built it from this PR's design notes, published it at dignome/audio.cpp-echo-tts, and offered it here rather than opening a competing PR. I've integrated it, reviewed it, cut the parts that don't belong upstream, and fixed what the reviews found. Their commits are preserved as
Co-authored-by.Evidence
Numerical parity against PyTorch
tests/echo_tts/echo_tts_dit_parity.cppagainst a dump fromtools/community_models/echo_tts_reference.py. Gates are cosine over the flattened tensors and max-absolute-error — cosine alone cannot see a uniform scale error, and the host Philox stream matches CUDA to ~2 ULP rather than bit-exactly.The reference defaults to bfloat16; this GGUF is F16. Both dumps are shown because the difference between them turns out to be the single largest term:
The denoiser probe is the number that carries this port. It settles the four details that fail silently — half-head RoPE, the interleaved (not NEOX) rotary pairing, the speaker patchify reshape, and the adaLN
shift/scale/gateorder.It does not isolate the DiT blocks alone: 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. Enough to catch a wrong block, not enough to localise one. Per-block dumps exist in the packer (--blocks) and have not been run.The 40-step trajectory is below the gate and is reported as a FAIL of the check as written. What is established:
inference.pyfound no defect — schedule, inclusive CFG bounds, single application oftruncation_factor, three-lane CFG combination, the Euler update, and the speaker-KV boundary all agree.That is an explanation, not a proof. Treat the trajectory as unverified.
End to end
Executed on an RTX 3090 (sm_86), CUDA, F16 GGUF converted locally from
jordand/echo-tts-base+jordand/fish-s1-dac-min:manifest OK— 1117 DiT tensors, 219 blockwise tensors dropped, 495 codec tensorslatent_scalefaster-whisper-large-v3-turbo)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; it does not score speaker identity, so a wrong patchify reshape could yield fluent, correctly-worded speech in the wrong voice and still read 0 %. The denoiser cosine above is what covers that.
Generation cost is essentially constant between a 15-word and a 32-word run (7.75 s vs 7.89 s) because the window is fixed at 640 frames, so longer text inside a chunk is close to free.
Independent reviews
Two rounds, Codex (GPT-5.6) and Grok 4.6 each time — first on the contributed tree before adopting it, then on my own verification commits. Codex checked the ggml port line-by-line against the reference PyTorch; Grok judged adoption and upstream fitness.
Codex cleared the four items the original author flagged as possibly-silently-wrong: half-head RoPE rotates the first
heads/2on the head axis (model.py:199,217),GGML_ROPE_TYPE_NORMALis the correct interleaved convention againstmodel.py:21'sreshape(...,-1,2)pairing, the patchify reshape gives frame-then-channel ordering permodel.py:458, and adaLN order isshift/scale/gatepermodel.py:64. Its one CANNOT-TELL — tensor names — was settled by running the converter against the real checkpoint.Both then independently flagged the same feature, and Codex supplied the mechanism:
The adaptive generation window is now off by default. It shrank the 640-frame window to a text-length estimate, justified in-comment as costing "time, never fidelity" because a missing flattening point retries at full length on the same Philox seed. The noise claim is true; the fidelity conclusion doesn't follow, because the seed isn't what changes. Echo's generated self-attention is fully non-causal —
self_mask = torch.ones((batch_size, seq_len))atmodel.py:249— so every latent position attends across the whole window. Shrinking 640 to 128 changes the computation at every retained position, not just how many survive. And the retry only fires when no flattening point is found, so a short window that yields a plausible flat tail is never corrected. The optimisation stays, behindAUDIOCPP_ECHO_TTS_ADAPTIVE_WINDOW=1.Scope
Cut from the contributed branch before landing: the root
README.mddump,echotts-server.json(hardcoded machine paths), webuidist/andpackage-lock.jsonbuild artifacts, andvalidate_model_spec.py(a generic schema checker that isn't Echo-specific and deserves its own PR).Two things here are not additive and I'd rather flag them than have you find them:
src/models/fish_audio/codec.cpp. Echo reuses the in-tree Fish codec, which needs continuousz_qaccess. Two separate things, and I had originally described them as one:The decode side is a pure refactor.
build_decode_quantizerwas split intobuild_zq_from_codes+build_decode_from_zqand reassembled as a composition of the two. Same ops, same order, same graph. I earlier called this "restructured"; that overstated it.The encode side did change behaviour, and I've since fixed it.
&z_qwas passed unconditionally, so everyfish_audiorequest built an extraggml_subnode and marked itggml_set_outputeven thoughfish_audionever reads it. The arithmetic was unaffected, butggml_set_outputpins that buffer and keeps bothxand the quantiser residual live to the end of the graph, where otherwise the residual is free to be reused — a real allocation change for a family that gains nothing from it.EncodeGraphnow takeswant_z_q; with it false no node is built, no output is set, nothing is expanded, so the construction sequence is identical to upstream's.encode_referencepasses false, onlyencode_zqpasses true, andmatches()carries the flag so the two graphs can't be confused.That shrinks the blast radius to zero for existing
fish_audiousers, which I think is a better answer than a test proving I didn't break it. Still happy to split this into a preceding PR if you'd rather review it on its own.src/framework/audio/wav_reader.cpp(+176). PCM8/PCM32/float64/A-law/mu-law/WAVEFORMATEXTENSIBLEsupport. This is a general WAV improvement that Echo doesn't strictly need. Say the word and I'll pull it into a separate PR.Long-form and the fixed window
Echo generates at most 640 latents (640 × 2048 ÷ 44100 = 29.7215 s). Upstream's blockwise sampler subdivides that window rather than extending it (
sum(block_sizes) + continuation_length < 640), and upstream notes it "hasn't been thoroughly tested."So long text goes through the framework chunker, as the rest of the repo does —
runtime::chunk_text_requestat 300 codepoints, speaker conditioning cached once per session,append_audio_bufferto concatenate.chatterboxis the closest analogue. Dropping the blockwise path also dropslatent_encoder,wk_latentandwv_latent: the 219 tensors the converter reports discarding, ~294 M parameters.long_formis deliberately not claimed incapabilities. It appears nowhere in the C++, and only 5 of 22 TTS/clone families declare it — the 17 that don't includechatterbox,fish_audio,higgs_audio_tts,index_tts2,qwen3_tts,voxcpm2andpocket_tts. Happy to add it if you'd rather it be declared.What's still missing
fish_audioregression test, and nofish_audiocheckpoint on my machine to build one against. The z_q output is now opt-in (see Scope), sofish_audio's own graph is identical to upstream's and there is nothing left for such a test to catch — but that is an argument from construction, not from execution, and I'd rather say so than imply I ran it.AUDIOCPP_ECHO_TTS_NO_FLASH=1on a fixed seed.combine_cfg_lanesandeuler_timestep_schedulehave no registered coverage. They were checked by hand (6.6e-07 and 0.0 diff against a numpy transcription ofinference.py); that hand run is not what CI holds, and the docs now say so.Registered tests:
tests/echo_tts/echo_tts_host_units.cppviaadd_engine_unittest/add_test, CPU-only, no checkpoint needed — WhisperD normalisation, 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 crop including both its thresholds. Mutation-checked against the four defects the second review said the earlier fixtures couldn't catch (transposed basis, mean dropped on both legs, scale dropped on both legs, zero-window search instead of thresholds); all four now fail the suite.Licence
Echo-TTS is CC-BY-NC-SA-4.0, and the restriction covers generated audio, not just the weights (inherited from the Fish S1-DAC dependency). Flagging it rather than leaving it to be inferred. There's in-tree precedent —
fish_audiocarries the identical restriction from the identical dependency — and audio.cpp's Apache 2.0 licence is unaffected, since weights are a separate download. Documented in the model doc so nobody ships product on non-commercial output.One question
Anything you'd want structured differently — file layout, option naming, whether the
fish_audioandwav_readerchanges should land as their own PRs first, or whether this belongs incommunity_modelsat all.Why this model
Picked by comparing every model tracked in tts-bench — 62 local TTS models benchmarked across speed, objective scores, and blind human preference — against the existing support table.
Caveats worth stating: the cloning arena averages ~30 games per model, so gaps under ~100 Elo are noise, and the ranking uses a single reference clip. Echo is top-3 on votes and 2nd on objective SIM, which are independent measurements.