Skip to content

sanotts: sanoTTS voice family community model (seven voices, 294k-2.27M params, en/vi/id, GGUF FP32) - #449

Open
voidash wants to merge 7 commits into
0xShug0:mainfrom
voidash:feat/sanotts-community-model
Open

sanotts: sanoTTS voice family community model (seven voices, 294k-2.27M params, en/vi/id, GGUF FP32)#449
voidash wants to merge 7 commits into
0xShug0:mainfrom
voidash:feat/sanotts-community-model

Conversation

@voidash

@voidash voidash commented Sep 4, 2026

Copy link
Copy Markdown

Adds the sanoTTS voice family as a community model: seven very small offline TTS voices (294k–2.27M parameters; English, Vietnamese, Indonesian), the smallest of which also runs on microcontrollers. Upstream project: https://github.com/Ampixa/sanoTTS · Packages: ampixa/sanoTTS gguf/ — FP32 tensors in PyTorch shapes with audiocpp.tensor_ranks/shapes exact-shape metadata and embedded model specs, fully standalone.

Two graphs share the sanotts family, dispatched by the package config:

  • nano (heart 2.27M, heart-nano 294k · 24 kHz): duration student → contextual acoustic student → mel-100 → noise-fed ConvNeXt-1D decoder → [log-magnitude | phase] head → HostLogMagnitudePhaseISTFT. A seed picks one of many valid renderings.
  • piperlite (amy 1.45M, hfc 1.83M, kristin 1.40M, vi 1.57M, id 1.56M · 22.05 kHz): duration + acoustic students → 192-channel latent → 3-stage ConvTranspose1d decoder with dilated residual banks (kristin adds a learned post filter). Deterministic.

Implementation

  • Follows the inflect_v2 pattern throughout: SpecBackedVoiceModelConfig, BackendWeightStore, CacheSlots-cached graphs keyed by token/frame count, framework modules (LayerNormModule eps 1e-6, GeluModule ExactErf, LinearModule, EmbeddingModule, ggml_conv_1d_dw, ConvTranspose1dModule with the CPU padding construction). Shared front-end structure lives in an internal graph_common.h.
  • eSpeak-ng is GPL-3.0 and is opened with dlopen at runtime, never linked — same treatment as inflect_v2. The nano front end reproduces the phonemizer punctuation-preservation pipeline plus the misaki E2M rewrite (exact tie-mode bits); the piperlite front end reproduces Piper's convention (untied phonemes, NFD decomposition to codepoints, per-voice phoneme_id_map, [BOS, PAD, (id, PAD)…, EOS] framing, schwa fallback for out-of-vocab ids, and regional-variant-first voice selection matching phonemizer's behavior on espeak-ng ≥ 1.49).
  • Reference-exact semantics: ATen-compatible MT19937 noise, torch.linspace/expand_features float behaviour, ties-to-even duration rounding, torch.istft centre trim, the reference's H(z) = (1 − z⁻¹)/(1 − 0.9973 z⁻¹) DC blocker (nano).
  • Full tensor inventories (names + shapes, derived from each package's config) are validated at load — a wrongly-shaped weight in these decoders makes plausible-sounding audio rather than an obvious failure.
  • On CUDA builds the duration graphs stay on a CPU mirror (durations round to integers and gate the frame layout), mirroring the inflect_v2 TF32 rationale; CUDA itself is untested here — this PR claims CPU only.

Verification (Ubuntu x86-64, gcc, 12 cores, default 4 threads)

End to end from clean installs (tools/model_manager_v2.py install sanotts_<voice>_orig), each voice compared against the project's numpy reference (same text, same seed where applicable, same system libespeak-ng.so.1; the references are themselves gated ≥ 0.987 against the float PyTorch models):

Voice Graph Correlation Sample count
heart nano 0.999999985 identical
heart-nano nano 0.999999985 identical
amy piperlite 0.999999993 identical
hfc piperlite 0.999999969 identical
kristin piperlite 0.999999986 identical
vi (Vietnamese) piperlite 0.999999994 identical
id (Indonesian) piperlite 0.999999984 identical

Max sample delta ~1.7e-05 in every case — the WAV's own int16 quantisation step.

Token stream parity: byte-identical phoneme id streams vs the Python front end on a 10-text punctuation corpus (quotes, parentheses, hyphens, ellipses, apostrophes, clock times), plus identical duration decisions on every parity run above.

Long-form (the shared case from tools/audiocpp_cli/audiocpp_cli_longform_tts_clone_cases.json, 6 kB of text): heart-nano renders 373 s of audio in 1.3 s wall (~283× real time, 220 MB peak RSS); amy renders 394 s in 18.5 s (~21× real time, 497 MB peak — the piperlite decoder convolves at audio rate). A chunk that phonemizes past a voice's token limit is bisected at whitespace and recursed.

Determinism: nano's default seed derives from each chunk's text (sha256(text)[:8], the reference convention); piperlite voices are deterministic and document the seed as ignored.

Checks: tools/check_loader_catalog_sync.py passes; sanotts_frontend_test passes under -DENGINE_BUILD_TESTS=ON -DENGINE_BUILD_MODEL_TESTS=ON; per-stage timings and graph-cache hits are traced via --log.

Limitations

  • Offline only, FP32 only; CPU is the verified backend.
  • Language support is per voice (en / vi / id); a session rejects text tagged with a language the voice was not trained on.
  • eSpeak-ng must be installed (apt install espeak-ng / brew install espeak-ng) or pointed to via sanotts.espeak_library_path / sanotts.espeak_data_path session options.
  • The nano voices are noise-fed: different seeds are different valid renderings, not noise on top of one canonical waveform.

https://claude.ai/code/session_01P1iL37FdfJkGxdGrpjH1we

voidash and others added 6 commits September 4, 2026 14:55
Two GGUF packages from ampixa/sanoTTS on Hugging Face: heart-nano (294,279
parameters, int8, 357 KB) as the default, and heart (2,272,145, f32, 9.1 MB).

Both are converted losslessly from the blobs the project already ships.
Rebuilding those blobs from the GGUF reproduces them byte for byte, and the
golden gate on the rebuilt weights gives the same correlation against the
float PyTorch references as the originals -- 0.989703 and 1.000000 against a
0.98 threshold -- including when the GGUF is fetched from Hugging Face rather
than built locally.

Session options mirror inflect_v2's, since sanoTTS needs the same external
eSpeak-ng phonemizer and must not embed it.
Groundwork for a ggml-native sanoTTS community model.

model_specs/sanotts.json  heart-nano (294,279 params, 24 kHz) from
                          ampixa/sanoTTS on Hugging Face. Session options
                          mirror inflect_v2's, since sanoTTS needs the same
                          external eSpeak-ng phonemizer.

assets.{h,cpp}            Reads config.json and the GGUF tensors. config.json
                          carries (tensor, offset) regions emitted by the
                          packaging tool, so nothing here translates region
                          names and the two cannot drift. Every shape constant
                          is compared against the build's own, because a
                          lineage mismatch would read weights at the wrong
                          offsets and synthesize noise rather than fail.

frontend.{h,cpp}          Text -> the 62-symbol phoneme ids the model was
                          trained on: eSpeak-ng IPA, then misaki's E2M rewrite,
                          then the character-level vocabulary. Ported from the
                          project's own JavaScript and Python front ends so all
                          three agree symbol for symbol. eSpeak-ng is opened at
                          runtime and never linked -- it is GPL-3.0 and must
                          not be embedded here, the same treatment inflect_v2
                          gives it.

The packaging is verified upstream: rebuilding both weight blobs from the GGUF
reproduces the originals byte for byte, and the golden gate on the rebuilt
weights matches the float PyTorch reference at 0.989703 against a 0.98
threshold -- including when the GGUF is fetched from Hugging Face.

The inference graph is next, built on the framework's module library rather
than a vendored runtime, so sanoTTS gets the shared backends like every other
model here.
Three cached graphs (duration, token stage, frame stage + ConvNeXt decoder)
with the reference implementations' exact semantics: ATen-compatible MT19937
noise, torch.linspace/expand_features float behavior, LayerNorm eps 1e-6,
erf GELU, torch.istft trim, and the 0.9973-pole DC blocker. The frontend
gains phonemizer-compatible punctuation preservation and the correct
eSpeak-ng tie mode so token streams are byte-identical to the Python
front end.

Verified against the project's numpy reference (same text, seed, and
eSpeak-ng build): correlation 0.999999985, identical sample count; the
reference is itself gated 0.987-1.000 against float PyTorch. 38.7 s of
audio renders in 0.22 s wall on CPU (peak RSS 76 MB).

Claude-Session: https://claude.ai/code/session_01P1iL37FdfJkGxdGrpjH1we
The codepoint chunker cannot see phoneme counts, so a dense 280-codepoint
chunk can exceed the duration model's 207-token training limit. encode()
now throws a typed SanoTtsTooLongError and the session splits the chunk at
the whitespace nearest its middle and recurses, so the shared long-form
case (6 kB of text, 6.2 minutes of audio) renders instead of failing.

Claude-Session: https://claude.ai/code/session_01P1iL37FdfJkGxdGrpjH1we
Same graph, wider and deeper; the runtime now derives the expected tensor
count from the config instead of hardcoding heart-nano's 103, and the
weight arena covers the 9.1 MB FP32 payload. Verified like heart-nano:
correlation 0.999999985 against the numpy reference at identical sample
count, installed end to end from the published Hugging Face package.

Claude-Session: https://claude.ai/code/session_01P1iL37FdfJkGxdGrpjH1we
Second graph in the family: duration and acoustic students into a
192-channel latent, then a 3-stage ConvTranspose1d decoder with dilated
residual banks (kristin adds a learned post filter). Deterministic, 22.05
kHz. The shared front-end structure moves into graph_common.h; the session
dispatches on the config's graph field.

The piperlite front end reproduces Piper's convention exactly: untied
eSpeak-ng phonemes through the phonemizer punctuation pipeline, NFD
decomposition to codepoints, the per-voice phoneme_id_map with
[BOS, PAD, (id, PAD)..., EOS] framing, the schwa fallback for ids outside
a component's trained vocab, and regional-variant-first voice selection
(phonemizer rejects bare language codes on espeak-ng >= 1.49, so 'en'
must resolve to en-us in both stacks).

All five voices verified against the project's numpy reference with the
same eSpeak-ng build: correlation >= 0.99999996 at identical sample
counts, installed end to end from the published Hugging Face packages.
Vietnamese and Indonesian exercise their own espeak voices and language
validation.

Claude-Session: https://claude.ai/code/session_01P1iL37FdfJkGxdGrpjH1we
@voidash voidash changed the title sanotts: sanoTTS heart-nano community model (294k-parameter English TTS, GGUF FP32) sanotts: sanoTTS voice family community model (seven voices, 294k-2.27M params, en/vi/id, GGUF FP32) Sep 4, 2026
The duration and token stages run under identical tensor names in both
lineages, so their four graph builders collapse into one shared
build_front_graph over a small stage spec; the frame-level acoustic stage
both decoders open with becomes acoustic_frame_stage. BackendState now
carries the backend/weights/CUDA-duration-mirror boilerplate once, a
cached_graph template replaces the six cache accessors, and the host-side
feature builders and duration rounding are shared (rounding unified to
double, which is what the numpy references both runtimes are gated
against actually compute).

All seven voices re-verified after the refactor: correlations unchanged
(>= 0.99999996) at identical sample counts; unit test and the shared
long-form case pass. Net -363 lines.

Claude-Session: https://claude.ai/code/session_01P1iL37FdfJkGxdGrpjH1we
@0xShug0 0xShug0 added the new model Request for new model support label Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

new model Request for new model support

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants