sanotts 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,90 @@
1
+ .DS_Store
2
+ .playwright-mcp/
3
+ **/__pycache__/
4
+ *.pyc
5
+ *.egg-info/
6
+ /build/
7
+
8
+ # Generated model/data artifacts. Keep reproducible scripts and docs in git.
9
+ artifacts/
10
+ *.ckpt
11
+ *.gguf
12
+ *.onnx
13
+ *.onnx.data
14
+ *.pt
15
+ *.pth
16
+ *.safetensors
17
+ *.bin
18
+ *.npz
19
+ *.npy
20
+ *.wav
21
+ *.mp3
22
+ *.flac
23
+ *.m4a
24
+ *.png
25
+ !docs/assets/
26
+ !docs/assets/*.png
27
+ *.jpg
28
+ *.jpeg
29
+ *.webp
30
+
31
+ # Small, immutable runtime fixtures are deliberately versioned for CI.
32
+ !mcu/test/fixtures/**/*.bin
33
+
34
+ # WASM demo build outputs (regenerate: bash mcu/ports/wasm/build.sh).
35
+ # The browser demo is hosted on GitHub Pages, so its (small) prebuilt assets
36
+ # and sample audio are deliberately versioned — the un-ignores below re-include
37
+ # them past the global *.mp3 / web/* rules above.
38
+ web/snt_tts.js
39
+ web/snt_tts.wasm
40
+ web/assets/
41
+ !web/snt_tts.js
42
+ !web/snt_tts.wasm
43
+ !web/assets/
44
+ !web/assets/*.bin
45
+ !web/assets/mascots/
46
+ !web/assets/mascots/*.png
47
+ !web/samples/
48
+ !web/samples/*.mp3
49
+ !web/samples-release/
50
+ !web/samples-release/*.mp3
51
+ !web/samples-release/*.json
52
+ !web/snt_g2p.js
53
+ !web/snt_g2p.wasm
54
+ !web/snt_g2p.data
55
+ !web/snt_voice.js
56
+ !web/snt_voice.wasm
57
+ !web/voices/
58
+ !web/voices/**
59
+
60
+ # Scratch/runtime folders.
61
+ .venv/
62
+ dist/
63
+ logs/
64
+ runs/
65
+ tmp/
66
+ esp32c3/test/emu_out/
67
+
68
+ # Host build products. Source files with similar names remain tracked.
69
+ /mcu/snt_golden_test
70
+ /mcu/snt_test_*
71
+ /esp32c3/fsd/fsd_e2e_fast
72
+ /esp32c3/fsd/fsd_e2e_frozen
73
+ /esp32c3/fsd/fsd_e2e_full
74
+ /esp32c3/fsd/fsd_e2e_test
75
+ /esp32c3/fsd/fsd_q8_test
76
+ /esp32c3/fsd/fsd_test
77
+
78
+ # LaTeX build products. Submission PDFs belong in release assets.
79
+ /paper/*.aux
80
+ /paper/*.bbl
81
+ /paper/*.blg
82
+ /paper/*.log
83
+ /paper/*.out
84
+ /paper/*.pdf
85
+
86
+ # Scratchpad host build outputs.
87
+ scratchpad/**/test_u600_acoustic
88
+ scratchpad/**/test_u600_decoder
89
+ scratchpad/**/test_u600_whole
90
+ scratchpad/**/test_u600_pipe
sanotts-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,12 @@
1
+ Metadata-Version: 2.4
2
+ Name: sanotts
3
+ Version: 0.1.0
4
+ Summary: Self-contained, numpy-only inference runtime for saanoTTS voice packages.
5
+ License: GPL-3.0-or-later
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: espeakng-loader<1,>=0.2
8
+ Requires-Dist: numpy>=1.24
9
+ Requires-Dist: phonemizer-fork<4,>=3.3
10
+ Description-Content-Type: text/plain
11
+
12
+ sanotts: pip-installable inference for saanoTTS raw-fp16 voice packages (duration + acoustic + piperlite decoder, numpy-only). Voices are downloaded on demand or loaded from a local directory produced by tools/export_roota_self_contained_package.py.
@@ -0,0 +1,32 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "sanotts"
7
+ version = "0.1.0"
8
+ description = "Self-contained, numpy-only inference runtime for saanoTTS voice packages."
9
+ readme = { text = "sanotts: pip-installable inference for saanoTTS raw-fp16 voice packages (duration + acoustic + piperlite decoder, numpy-only). Voices are downloaded on demand or loaded from a local directory produced by tools/export_roota_self_contained_package.py.", content-type = "text/plain" }
10
+ license = { text = "GPL-3.0-or-later" }
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "numpy>=1.24",
14
+ "phonemizer-fork>=3.3,<4",
15
+ "espeakng-loader>=0.2,<1",
16
+ ]
17
+
18
+ [project.scripts]
19
+ sanotts = "sanotts.cli:main"
20
+
21
+ # This directory is not (yet) committed to the parent repo's git tree, so we
22
+ # cannot rely on hatchling's default VCS-tracked-files file selection --
23
+ # without this, `sanotts/tables/*.json` (untracked) would silently be
24
+ # dropped from the wheel. Explicit include instead.
25
+ [tool.hatch.build]
26
+ include = [
27
+ "sanotts/**/*.py",
28
+ "sanotts/tables/*.json",
29
+ ]
30
+
31
+ [tool.hatch.build.targets.wheel]
32
+ packages = ["sanotts"]
@@ -0,0 +1,34 @@
1
+ """sanotts: a self-contained, numpy-only saanoTTS inference package.
2
+
3
+ import sanotts
4
+ result = sanotts.synthesize("Hello world", voice="amy")
5
+ # result.audio: float32 mono waveform in [-1, 1]
6
+ # result.sample_rate: int, e.g. 22050
7
+
8
+ For repeated synthesis, reuse a Synthesizer instead of calling
9
+ synthesize() (which reloads the voice pack every time):
10
+
11
+ synth = sanotts.Synthesizer(voice="amy")
12
+ a = synth.synthesize("First sentence.")
13
+ b = synth.synthesize("Second sentence.")
14
+
15
+ Voices are resolved either from a local directory (--voice-dir / voice_dir=,
16
+ a package produced by tools/export_roota_self_contained_package.py in the
17
+ saanoTTS research repo) or downloaded by name into ~/.cache/sanotts/.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from .engine import SynthesisResult, Synthesizer, synthesize
23
+ from .frontend import FrontendError
24
+ from .voicepack import VoicePackError
25
+
26
+ __all__ = [
27
+ "SynthesisResult",
28
+ "Synthesizer",
29
+ "synthesize",
30
+ "FrontendError",
31
+ "VoicePackError",
32
+ ]
33
+
34
+ __version__ = "0.1.0"
@@ -0,0 +1,81 @@
1
+ """sanotts command-line interface.
2
+
3
+ sanotts say "Hello world" --voice amy -o out.wav
4
+ sanotts say "Hello world" --voice-dir ./amy-en-1p46m -o out.wav
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import logging
11
+ import sys
12
+ import wave
13
+ from pathlib import Path
14
+
15
+ import numpy as np
16
+
17
+ from .engine import Synthesizer
18
+ from .frontend import FrontendError
19
+ from .voicepack import VoicePackError
20
+
21
+
22
+ def write_wav(path: Path, audio: np.ndarray, sample_rate: int) -> None:
23
+ audio = np.clip(np.asarray(audio, dtype=np.float32), -1.0, 1.0)
24
+ pcm16 = (audio * 32767.0).astype("<i2")
25
+ with wave.open(str(path), "wb") as handle:
26
+ handle.setnchannels(1)
27
+ handle.setsampwidth(2)
28
+ handle.setframerate(sample_rate)
29
+ handle.writeframes(pcm16.tobytes())
30
+
31
+
32
+ def _add_say_args(parser: argparse.ArgumentParser) -> None:
33
+ parser.add_argument("text", help="Text to synthesize.")
34
+ voice_group = parser.add_mutually_exclusive_group(required=True)
35
+ voice_group.add_argument("--voice", help="Named voice to download/use from the cache, e.g. 'amy'.")
36
+ voice_group.add_argument("--voice-dir", type=Path, help="Local voice package directory (has manifest.json).")
37
+ parser.add_argument("-o", "--out", type=Path, default=Path("out.wav"), help="Output WAV path (default: out.wav).")
38
+ parser.add_argument("--cache-dir", type=Path, default=None, help="Override the voice download cache directory.")
39
+ parser.add_argument(
40
+ "--duration-length-scale",
41
+ type=float,
42
+ default=None,
43
+ help="Override the voice's default speaking-rate scale (>0; larger = slower).",
44
+ )
45
+ parser.add_argument("-v", "--verbose", action="store_true", help="Enable info-level logging.")
46
+
47
+
48
+ def _run_say(args: argparse.Namespace) -> int:
49
+ logging.basicConfig(level=logging.INFO if args.verbose else logging.WARNING, format="%(name)s: %(message)s")
50
+ try:
51
+ synth = Synthesizer(args.voice, voice_dir=args.voice_dir, cache_dir=args.cache_dir)
52
+ result = synth.synthesize(args.text, duration_length_scale=args.duration_length_scale)
53
+ except (FrontendError, VoicePackError, ValueError, RuntimeError, NotImplementedError) as exc:
54
+ print(f"sanotts: error: {exc}", file=sys.stderr)
55
+ return 1
56
+ args.out.parent.mkdir(parents=True, exist_ok=True)
57
+ write_wav(args.out, result.audio, result.sample_rate)
58
+ duration_s = result.audio.shape[-1] / float(result.sample_rate)
59
+ print(f"sanotts: wrote {args.out} ({duration_s:.2f}s at {result.sample_rate} Hz)")
60
+ return 0
61
+
62
+
63
+ def build_parser() -> argparse.ArgumentParser:
64
+ parser = argparse.ArgumentParser(prog="sanotts", description="Self-contained saanoTTS inference CLI.")
65
+ subparsers = parser.add_subparsers(dest="command", required=True)
66
+
67
+ say_parser = subparsers.add_parser("say", help="Synthesize text to a WAV file.")
68
+ _add_say_args(say_parser)
69
+ say_parser.set_defaults(func=_run_say)
70
+
71
+ return parser
72
+
73
+
74
+ def main(argv: list[str] | None = None) -> int:
75
+ parser = build_parser()
76
+ args = parser.parse_args(argv)
77
+ return int(args.func(args))
78
+
79
+
80
+ if __name__ == "__main__":
81
+ raise SystemExit(main())
@@ -0,0 +1,76 @@
1
+ """Ties the frontend, voice pack, and numpy models into one synthesizer."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+
11
+ from . import frontend, models, voicepack
12
+
13
+ logger = logging.getLogger("sanotts.engine")
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class SynthesisResult:
18
+ audio: np.ndarray
19
+ sample_rate: int
20
+
21
+ def __array__(self, dtype=None) -> np.ndarray: # convenience: np.asarray(result) just works
22
+ return self.audio if dtype is None else self.audio.astype(dtype)
23
+
24
+
25
+ class Synthesizer:
26
+ """A loaded voice, ready to render text repeatedly without re-reading disk."""
27
+
28
+ def __init__(
29
+ self,
30
+ voice: str | None = None,
31
+ *,
32
+ voice_dir: str | Path | None = None,
33
+ cache_dir: str | Path | None = None,
34
+ ) -> None:
35
+ self.pack = voicepack.load_voice(voice, voice_dir=voice_dir, cache_dir=cache_dir)
36
+ self.phoneme_table = frontend.load_phoneme_table(self.pack.phoneme_config_path)
37
+
38
+ self._duration_tensors = self.pack.component_tensors("duration")
39
+ self._duration_config = self.pack.component_config("duration")
40
+ self._acoustic_tensors = self.pack.component_tensors("acoustic")
41
+ self._acoustic_config = self.pack.component_config("acoustic")
42
+ self._decoder_tensors = self.pack.component_tensors("decoder")
43
+ self._decoder_config = self.pack.component_config("decoder")
44
+
45
+ @property
46
+ def sample_rate(self) -> int:
47
+ return self.pack.sample_rate
48
+
49
+ def synthesize(self, text: str, *, duration_length_scale: float | None = None) -> SynthesisResult:
50
+ scale = float(duration_length_scale) if duration_length_scale is not None else self.pack.duration_length_scale
51
+ if scale <= 0.0:
52
+ raise ValueError(f"duration_length_scale must be positive, got {scale}")
53
+
54
+ ids = frontend.text_to_phoneme_ids(text, self.phoneme_table)
55
+ durations = models.duration_forward(
56
+ self._duration_tensors, self._duration_config, ids, length_scale=scale
57
+ )
58
+ latent = models.acoustic_forward(self._acoustic_tensors, self._acoustic_config, ids, durations)
59
+ audio = models.decoder_forward(self._decoder_tensors, self._decoder_config, latent)
60
+ audio = np.clip(audio, -1.0, 1.0).astype(np.float32)
61
+ return SynthesisResult(audio=audio, sample_rate=self.sample_rate)
62
+
63
+
64
+ def synthesize(
65
+ text: str,
66
+ voice: str | None = None,
67
+ *,
68
+ voice_dir: str | Path | None = None,
69
+ cache_dir: str | Path | None = None,
70
+ duration_length_scale: float | None = None,
71
+ ) -> SynthesisResult:
72
+ """One-shot convenience wrapper. For synthesizing many strings with the
73
+ same voice, construct a `Synthesizer` once instead -- it amortizes the
74
+ (much slower) weight-loading and phonemizer-initialization cost."""
75
+ synth = Synthesizer(voice, voice_dir=voice_dir, cache_dir=cache_dir)
76
+ return synth.synthesize(text, duration_length_scale=duration_length_scale)
@@ -0,0 +1,284 @@
1
+ """Text -> Piper phoneme-id frontend.
2
+
3
+ Mirrors the exact algorithm used by ``piper.phonemize_espeak`` /
4
+ ``piper.phoneme_ids`` (see the upstream piper1-gpl project) without
5
+ depending on the ``piper-tts`` package itself (that package pulls in
6
+ onnxruntime as a hard dependency and expects a full ONNX voice to be
7
+ loaded before it will phonemize anything).
8
+
9
+ Instead we drive the same underlying espeak-ng shared library through
10
+ ``phonemizer-fork`` + ``espeakng-loader``, then apply the exact
11
+ NFD-decompose-to-codepoints and ``phonemes_to_ids`` framing rules that
12
+ piper uses. This has been verified byte-for-byte against
13
+ ``piper.voice.PiperVoice.phonemize`` / ``.phonemes_to_ids`` for
14
+ representative English sentences (see the package's gate test) -- the
15
+ key ingredients are ``with_stress=True``, ``tie=False``,
16
+ ``preserve_punctuation=True`` (with piper's own punctuation set) and
17
+ ``language_switch="remove-flags"``, plus a final ``rstrip()`` before
18
+ NFD decomposition (phonemizer emits a trailing space that piper does
19
+ not).
20
+
21
+ Known espeakng-loader pitfall (see project memory): some published
22
+ ``espeakng-loader`` wheels report a data path via
23
+ ``get_data_path()`` that looks valid on disk but the bundled dylib
24
+ silently ignores it and falls back to a baked-in build-time path from
25
+ the wheel's CI runner (e.g.
26
+ ``/Users/runner/work/espeakng-loader/.../espeak-ng-data``), which does
27
+ not exist on the end user's machine. We defend against this by
28
+ actually trying to construct a working backend for each data-path
29
+ candidate (loader path, then common system install locations) instead
30
+ of trusting ``os.path.exists`` alone.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import glob
36
+ import logging
37
+ import unicodedata
38
+ from dataclasses import dataclass
39
+ from pathlib import Path
40
+ from typing import Any
41
+
42
+ import numpy as np
43
+
44
+ logger = logging.getLogger("sanotts.frontend")
45
+
46
+ # Framing ids fixed by the Piper phoneme_id_map convention; every voice's
47
+ # piper-phoneme-config.json must agree (checked in load_phoneme_table).
48
+ PAD_ID = 0
49
+ BOS_ID = 1
50
+ EOS_ID = 2
51
+
52
+ # Piper's own punctuation set (phoneme_id_map keys that are ASCII
53
+ # punctuation rather than IPA symbols). Passed to phonemizer so
54
+ # preserve_punctuation keeps exactly these and nothing extra.
55
+ PUNCTUATION_MARKS = "!'(),-.:;?\""
56
+
57
+ _DATA_PATH_CANDIDATES: list[str] = [
58
+ # Homebrew (macOS arm64/x86_64) -- versioned Cellar path, resolved via glob.
59
+ "/opt/homebrew/Cellar/espeak-ng/*/share/espeak-ng-data",
60
+ "/opt/homebrew/share/espeak-ng-data",
61
+ "/usr/local/share/espeak-ng-data",
62
+ "/usr/share/espeak-ng-data",
63
+ "/usr/lib/x86_64-linux-gnu/espeak-ng-data",
64
+ ]
65
+
66
+
67
+ class FrontendError(RuntimeError):
68
+ """Raised when the espeak-ng backend cannot be initialized or used."""
69
+
70
+
71
+ @dataclass(frozen=True)
72
+ class PhonemeTable:
73
+ """A single voice's codepoint -> Piper phoneme-id map."""
74
+
75
+ espeak_voice: str
76
+ id_map: dict[str, int]
77
+
78
+
79
+ def load_phoneme_table(piper_config_path: Path) -> PhonemeTable:
80
+ """Parse a Piper ``*.onnx.json`` / ``piper-phoneme-config.json`` file.
81
+
82
+ Raises FrontendError if the file does not have the exact shape we
83
+ depend on (single-codepoint keys, single-id values, and the
84
+ pad/bos/eos framing ids piper hardcodes).
85
+ """
86
+ import json
87
+
88
+ if not piper_config_path.is_file():
89
+ raise FrontendError(f"phoneme config not found: {piper_config_path}")
90
+ with piper_config_path.open("r", encoding="utf-8") as handle:
91
+ config = json.load(handle)
92
+
93
+ phoneme_type = config.get("phoneme_type", "espeak")
94
+ if phoneme_type not in (None, "espeak"):
95
+ raise FrontendError(f"{piper_config_path}: unsupported phoneme_type={phoneme_type!r}")
96
+
97
+ espeak_cfg = config.get("espeak") or {}
98
+ espeak_voice = espeak_cfg.get("voice")
99
+ if not espeak_voice:
100
+ raise FrontendError(f"{piper_config_path}: missing espeak.voice")
101
+
102
+ raw_map = config.get("phoneme_id_map")
103
+ if not isinstance(raw_map, dict) or not raw_map:
104
+ raise FrontendError(f"{piper_config_path}: missing/empty phoneme_id_map")
105
+
106
+ id_map: dict[str, int] = {}
107
+ for key, ids in raw_map.items():
108
+ if len(key) != 1:
109
+ raise FrontendError(f"{piper_config_path}: multi-codepoint map key {key!r}")
110
+ if not isinstance(ids, list) or len(ids) != 1:
111
+ raise FrontendError(f"{piper_config_path}: multi-id map value {key!r} -> {ids!r}")
112
+ id_map[key] = int(ids[0])
113
+
114
+ for sym, want in (("_", PAD_ID), ("^", BOS_ID), ("$", EOS_ID)):
115
+ got = id_map.get(sym)
116
+ if got != want:
117
+ raise FrontendError(
118
+ f"{piper_config_path}: framing symbol {sym!r} maps to {got}, expected {want}"
119
+ )
120
+
121
+ return PhonemeTable(espeak_voice=str(espeak_voice), id_map=id_map)
122
+
123
+
124
+ class EspeakEngine:
125
+ """Lazily-initialized espeak-ng backend, shared across voices.
126
+
127
+ One process-wide instance is enough: phonemizer's EspeakBackend
128
+ takes the target espeak voice as a constructor argument, so we
129
+ cache one backend per requested espeak voice string.
130
+ """
131
+
132
+ def __init__(self) -> None:
133
+ self._configured = False
134
+ self._backends: dict[str, Any] = {}
135
+
136
+ def _configure_once(self) -> None:
137
+ if self._configured:
138
+ return
139
+ try:
140
+ import espeakng_loader
141
+ except ImportError as exc: # pragma: no cover - dependency missing
142
+ raise FrontendError(
143
+ "the 'espeakng-loader' package is required for phonemization; "
144
+ "install it with `pip install espeakng-loader`"
145
+ ) from exc
146
+ try:
147
+ from phonemizer.backend import EspeakBackend
148
+ from phonemizer.backend.espeak.wrapper import EspeakWrapper
149
+ except ImportError as exc: # pragma: no cover - dependency missing
150
+ raise FrontendError(
151
+ "the 'phonemizer-fork' package is required for phonemization; "
152
+ "install it with `pip install phonemizer-fork`"
153
+ ) from exc
154
+
155
+ library_path = espeakng_loader.get_library_path()
156
+ data_candidates = [espeakng_loader.get_data_path()]
157
+ for pattern in _DATA_PATH_CANDIDATES:
158
+ data_candidates.extend(sorted(glob.glob(pattern)))
159
+
160
+ last_error: Exception | None = None
161
+ for data_path in data_candidates:
162
+ if not data_path or not Path(data_path).is_dir():
163
+ continue
164
+ try:
165
+ EspeakWrapper.set_library(library_path)
166
+ EspeakWrapper.set_data_path(data_path)
167
+ # Constructing a throwaway backend exercises espeak_Initialize
168
+ # end to end; a bad data path raises here rather than later
169
+ # inside phonemize(), matching the documented loader pitfall.
170
+ probe = EspeakBackend(
171
+ "en-us",
172
+ preserve_punctuation=True,
173
+ with_stress=True,
174
+ tie=False,
175
+ language_switch="remove-flags",
176
+ )
177
+ probe.phonemize(["a"], strip=False, separator=None)
178
+ except Exception as exc: # noqa: BLE001 - probing many candidates on purpose
179
+ last_error = exc
180
+ logger.debug("espeak data path candidate failed: %s (%s)", data_path, exc)
181
+ continue
182
+ logger.info("sanotts: using espeak-ng data path %s", data_path)
183
+ self._configured = True
184
+ return
185
+
186
+ raise FrontendError(
187
+ "could not initialize espeak-ng: no working espeak-ng-data directory found. "
188
+ "Tried the espeakng-loader bundled path plus common system locations "
189
+ f"({', '.join(_DATA_PATH_CANDIDATES)}). This is a known espeakng-loader "
190
+ "packaging issue where the wheel's compiled-in default path does not match "
191
+ "the data it ships. Fix: `brew install espeak-ng` (macOS) or "
192
+ "`apt-get install espeak-ng` (Debian/Ubuntu), then retry."
193
+ ) from last_error
194
+
195
+ def backend(self, espeak_voice: str) -> Any:
196
+ self._configure_once()
197
+ cached = self._backends.get(espeak_voice)
198
+ if cached is not None:
199
+ return cached
200
+ from phonemizer.backend import EspeakBackend
201
+
202
+ # Some older voice packages (e.g. kristin) were trained against an
203
+ # espeak-ng version where a bare language code like "en" was a
204
+ # directly selectable voice. Newer espeak-ng only exposes regional
205
+ # variants (en-us, en-gb, ...) as primary voices -- "en" now only
206
+ # appears as a secondary/"other language" tag, so EspeakBackend("en")
207
+ # raises. Retry with a regional variant rather than failing outright;
208
+ # this is a voice-*selection* compatibility shim only (it changes
209
+ # which accent espeak uses), not a phoneme-table substitution.
210
+ candidates = [espeak_voice]
211
+ if "-" not in espeak_voice:
212
+ candidates += [f"{espeak_voice}-us", f"{espeak_voice}-gb"]
213
+
214
+ last_error: Exception | None = None
215
+ for candidate in candidates:
216
+ try:
217
+ backend = EspeakBackend(
218
+ candidate,
219
+ preserve_punctuation=True,
220
+ punctuation_marks=PUNCTUATION_MARKS,
221
+ with_stress=True,
222
+ tie=False,
223
+ language_switch="remove-flags",
224
+ )
225
+ except Exception as exc: # noqa: BLE001 - trying multiple voice-name spellings
226
+ last_error = exc
227
+ continue
228
+ if candidate != espeak_voice:
229
+ logger.warning(
230
+ "sanotts: espeak voice %r unavailable in this espeak-ng build, "
231
+ "using %r instead (phoneme/accent may differ slightly from training)",
232
+ espeak_voice, candidate,
233
+ )
234
+ self._backends[espeak_voice] = backend
235
+ return backend
236
+ raise FrontendError(
237
+ f"espeak-ng has no voice matching {espeak_voice!r} (tried {candidates})"
238
+ ) from last_error
239
+
240
+ def phonemize(self, text: str, espeak_voice: str) -> str:
241
+ backend = self.backend(espeak_voice)
242
+ out = backend.phonemize([text], strip=False, separator=None)
243
+ if not out or not out[0]:
244
+ raise FrontendError(f"espeak-ng produced no phonemes for text: {text!r}")
245
+ # phonemizer appends a trailing separator space that piper's own
246
+ # clause-based espeakbridge does not emit at true end-of-input.
247
+ return out[0].rstrip()
248
+
249
+
250
+ _ENGINE = EspeakEngine()
251
+
252
+
253
+ def phonemes_to_ids(phonemes: list[str], table: PhonemeTable) -> list[int]:
254
+ """Reproduce piper.phoneme_ids.phonemes_to_ids exactly:
255
+
256
+ bos, pad, then (id, pad) per phoneme, then eos. Phonemes missing from
257
+ the table are skipped with a warning, matching piper's behavior.
258
+ """
259
+ ids: list[int] = [BOS_ID, PAD_ID]
260
+ missing = 0
261
+ for phoneme in phonemes:
262
+ pid = table.id_map.get(phoneme)
263
+ if pid is None:
264
+ missing += 1
265
+ continue
266
+ ids.append(pid)
267
+ ids.append(PAD_ID)
268
+ if missing:
269
+ logger.warning("sanotts: %d phoneme(s) missing from the voice's phoneme_id_map, skipped", missing)
270
+ ids.append(EOS_ID)
271
+ return ids
272
+
273
+
274
+ def text_to_phoneme_ids(text: str, table: PhonemeTable) -> np.ndarray:
275
+ """Full text -> Piper phoneme-id array, matching PiperVoice exactly."""
276
+ clean_text = text.strip()
277
+ if not clean_text:
278
+ raise FrontendError("text is empty")
279
+ phonemized = _ENGINE.phonemize(clean_text, table.espeak_voice)
280
+ phonemes = list(unicodedata.normalize("NFD", phonemized))
281
+ ids = phonemes_to_ids(phonemes, table)
282
+ if len(ids) <= 3:
283
+ raise FrontendError(f"phonemization produced no usable phonemes for: {text!r}")
284
+ return np.asarray(ids, dtype=np.int64)
@@ -0,0 +1,401 @@
1
+ """Pure-numpy forward passes for the saanoTTS Root-A student stack.
2
+
3
+ These mirror the fp32 reference C runtime (mcu/src/snt_front_f32.c and
4
+ mcu/src/snt_piperlite.c in the parent research repo), which itself was
5
+ ported from the PyTorch training code (tools/train_roota_piper_duration_student.py,
6
+ tools/train_roota_piper_latent_student.py, tools/train_roota_piper_decoder_student.py)
7
+ and gated against it at correlation 1.0. Operation *order* here does not need to be
8
+ bit-identical to either reference (numpy's BLAS-backed matmuls accumulate in a
9
+ different order than the scalar C loops or PyTorch's cuDNN/MKL kernels), only
10
+ numerically equivalent -- differences are float32-rounding-noise sized, far below
11
+ the >0.99 waveform-correlation gate this package is held to.
12
+
13
+ Only the subgraphs actually observed in the shipped voice packages
14
+ (architecture="duration_conv" / "token_context", decoder variant="piperlite",
15
+ stage*_branches=[0,1,2], no output adapter) are implemented. Anything else
16
+ raises NotImplementedError rather than guessing at an unverified tensor layout.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ logger = logging.getLogger("sanotts.models")
27
+
28
+ # Fallback id used when a phoneme id from the frontend's (larger, shared)
29
+ # codepoint table exceeds a specific component's trained vocab_size. Schwa
30
+ # is a safe, neutral, always-in-vocab choice -- the same fallback used by
31
+ # the ESP32/WASM ports for the analogous duration/acoustic vocab mismatch.
32
+ _SCHWA_FALLBACK_ID = 59
33
+
34
+
35
+ def clamp_ids_to_vocab(ids: np.ndarray, vocab_size: int, *, label: str) -> np.ndarray:
36
+ ids = np.asarray(ids, dtype=np.int64)
37
+ out_of_range = (ids < 0) | (ids >= vocab_size)
38
+ if np.any(out_of_range):
39
+ n = int(out_of_range.sum())
40
+ fallback = _SCHWA_FALLBACK_ID if _SCHWA_FALLBACK_ID < vocab_size else 0
41
+ logger.warning(
42
+ "sanotts: %d/%d %s ids fall outside vocab_size=%d, remapping to id=%d",
43
+ n, ids.size, label, vocab_size, fallback,
44
+ )
45
+ ids = np.where(out_of_range, fallback, ids)
46
+ return ids
47
+
48
+
49
+ def silu(x: np.ndarray) -> np.ndarray:
50
+ return x / (1.0 + np.exp(-x))
51
+
52
+
53
+ def leaky_relu(x: np.ndarray, slope: float) -> np.ndarray:
54
+ return np.where(x > 0.0, x, slope * x)
55
+
56
+
57
+ def linspace01(n: int) -> np.ndarray:
58
+ if n <= 0:
59
+ return np.zeros((0,), dtype=np.float32)
60
+ if n == 1:
61
+ return np.zeros((1,), dtype=np.float32)
62
+ return np.linspace(0.0, 1.0, n, dtype=np.float64).astype(np.float32)
63
+
64
+
65
+ def conv1d_same(
66
+ x: np.ndarray,
67
+ weight: np.ndarray,
68
+ bias: np.ndarray,
69
+ *,
70
+ dilation: int = 1,
71
+ ) -> np.ndarray:
72
+ """PyTorch Conv1d "same"-padding semantics: pad = dilation*(K//2).
73
+
74
+ x: [in_ch, T]; weight: [out_ch, in_ch, K]; bias: [out_ch].
75
+ Returns [out_ch, T].
76
+ """
77
+ in_ch, T = x.shape
78
+ out_ch, in_ch_w, K = weight.shape
79
+ if in_ch_w != in_ch:
80
+ raise ValueError(f"conv1d_same: channel mismatch {in_ch_w} != {in_ch}")
81
+ pad = dilation * (K // 2)
82
+ out = np.broadcast_to(bias[:, None].astype(np.float32), (out_ch, T)).copy()
83
+ for k in range(K):
84
+ off = k * dilation - pad
85
+ lo = max(0, -off)
86
+ hi = min(T, T - off)
87
+ if hi <= lo:
88
+ continue
89
+ # out[:, lo:hi] += weight[:, :, k] @ x[:, lo+off:hi+off]
90
+ out[:, lo:hi] += weight[:, :, k] @ x[:, lo + off:hi + off]
91
+ return out
92
+
93
+
94
+ def conv1d_1x1(x: np.ndarray, weight: np.ndarray, bias: np.ndarray) -> np.ndarray:
95
+ """Conv1d with kernel_size=1: a plain per-timestep linear projection."""
96
+ w = weight[:, :, 0] if weight.ndim == 3 else weight
97
+ return w @ x + bias[:, None]
98
+
99
+
100
+ def depthwise_conv1d_same(x: np.ndarray, weight: np.ndarray) -> np.ndarray:
101
+ """Depthwise Conv1d, no bias. x: [C, T]; weight: [C, K] (or [C,1,K])."""
102
+ if weight.ndim == 3:
103
+ weight = weight[:, 0, :]
104
+ C, T = x.shape
105
+ _, K = weight.shape
106
+ pad = K // 2
107
+ out = np.zeros_like(x)
108
+ for k in range(K):
109
+ off = k - pad
110
+ lo = max(0, -off)
111
+ hi = min(T, T - off)
112
+ if hi <= lo:
113
+ continue
114
+ out[:, lo:hi] += weight[:, k:k + 1] * x[:, lo + off:hi + off]
115
+ return out
116
+
117
+
118
+ def conv_transpose1d(
119
+ x: np.ndarray,
120
+ weight: np.ndarray,
121
+ bias: np.ndarray,
122
+ *,
123
+ stride: int,
124
+ padding: int,
125
+ ) -> np.ndarray:
126
+ """PyTorch ConvTranspose1d. x: [in_ch, T]; weight: [in_ch, out_ch, K]; bias: [out_ch].
127
+
128
+ out[oc, t*stride + k - padding] += weight[ic, oc, k] * x[ic, t], summed over ic, k;
129
+ output length L = (T - 1) * stride - 2 * padding + K.
130
+ """
131
+ in_ch, T = x.shape
132
+ in_ch_w, out_ch, K = weight.shape
133
+ if in_ch_w != in_ch:
134
+ raise ValueError(f"conv_transpose1d: channel mismatch {in_ch_w} != {in_ch}")
135
+ L = (T - 1) * stride - 2 * padding + K
136
+ out = np.broadcast_to(bias[:, None].astype(np.float32), (out_ch, L)).copy()
137
+ for k in range(K):
138
+ shift = k - padding
139
+ if shift >= L:
140
+ continue
141
+ t_lo = 0
142
+ if shift < 0:
143
+ t_lo = (-shift + stride - 1) // stride
144
+ t_hi = (L - 1 - shift) // stride + 1
145
+ t_hi = min(t_hi, T)
146
+ if t_hi <= t_lo:
147
+ continue
148
+ count = t_hi - t_lo
149
+ j_start = t_lo * stride + shift
150
+ j_end = j_start + (count - 1) * stride + 1
151
+ contrib = weight[:, :, k].T @ x[:, t_lo:t_hi] # [out_ch, count]
152
+ out[:, j_start:j_end:stride] += contrib
153
+ return out
154
+
155
+
156
+ def residual_conv_block(
157
+ x: np.ndarray,
158
+ tensors: dict[str, np.ndarray],
159
+ prefix: str,
160
+ ) -> np.ndarray:
161
+ """One ResidualConvBlock: x + scale * conv2(silu(conv1(x))). Kernel/pad same as x.
162
+
163
+ Kernel size is read from the weight tensor itself (weight.shape[-1]), not
164
+ passed in, so this always matches whatever the checkpoint actually stored.
165
+ """
166
+ scale = float(tensors[f"{prefix}.scale"][0])
167
+ t = conv1d_same(x, tensors[f"{prefix}.net.0.weight"], tensors[f"{prefix}.net.0.bias"])
168
+ t = silu(t)
169
+ u = conv1d_same(t, tensors[f"{prefix}.net.2.weight"], tensors[f"{prefix}.net.2.bias"])
170
+ return x + scale * u
171
+
172
+
173
+ # --------------------------------------------------------------------------
174
+ # Duration student ("architecture": "duration_conv" in the manifest)
175
+ # --------------------------------------------------------------------------
176
+
177
+ def duration_forward(
178
+ tensors: dict[str, np.ndarray],
179
+ config: dict[str, Any],
180
+ ids: np.ndarray,
181
+ *,
182
+ length_scale: float,
183
+ ) -> np.ndarray:
184
+ architecture = str(config.get("architecture") or "")
185
+ if architecture != "duration_conv":
186
+ raise NotImplementedError(f"unsupported duration architecture: {architecture!r}")
187
+
188
+ vocab_size = int(config["vocab_size"])
189
+ hidden = int(config["hidden"])
190
+ depth = int(config["depth"])
191
+ max_tokens = int(config["max_tokens"])
192
+ max_duration = int(config["max_duration"])
193
+
194
+ ids = clamp_ids_to_vocab(ids, vocab_size, label="duration")
195
+ n = ids.shape[0]
196
+ if n <= 0:
197
+ raise ValueError("duration_forward: empty id sequence")
198
+
199
+ embed = tensors["embedding.weight"] # [vocab, hidden]
200
+ if embed.shape != (vocab_size, hidden):
201
+ raise RuntimeError(f"duration embedding shape {embed.shape} != config ({vocab_size}, {hidden})")
202
+ token_x = embed[ids].T # [hidden, n]
203
+
204
+ positions = linspace01(n)
205
+ length_hint = np.float32(np.log1p(np.float64(n)) / np.log1p(np.float64(max_tokens)))
206
+ valid_hint = np.ones((n,), dtype=np.float32)
207
+ features = np.stack([positions, np.full((n,), length_hint, dtype=np.float32), valid_hint], axis=0)
208
+
209
+ x = conv1d_1x1(
210
+ np.concatenate([token_x, features], axis=0),
211
+ tensors["input_proj.weight"],
212
+ tensors["input_proj.bias"],
213
+ )
214
+ for i in range(depth):
215
+ x = residual_conv_block(x, tensors, f"blocks.{i}")
216
+
217
+ log_duration = conv1d_1x1(x, tensors["output.weight"], tensors["output.bias"])[0] # [n]
218
+
219
+ duration = np.exp(log_duration)
220
+ duration = np.clip(duration, 1.0, None)
221
+ duration = np.round(duration * float(length_scale))
222
+ duration = np.clip(duration, 1.0, float(max_duration))
223
+ return duration.astype(np.int64)
224
+
225
+
226
+ # --------------------------------------------------------------------------
227
+ # Acoustic student ("architecture": "token_context" in the manifest)
228
+ # --------------------------------------------------------------------------
229
+
230
+ def acoustic_forward(
231
+ tensors: dict[str, np.ndarray],
232
+ config: dict[str, Any],
233
+ ids: np.ndarray,
234
+ durations: np.ndarray,
235
+ ) -> np.ndarray:
236
+ architecture = str(config.get("architecture") or "")
237
+ if architecture != "token_context":
238
+ raise NotImplementedError(f"unsupported acoustic architecture: {architecture!r}")
239
+ unexpected_adapter_keys = [name for name in tensors if "adapter" in name]
240
+ if unexpected_adapter_keys:
241
+ raise NotImplementedError(
242
+ "acoustic checkpoint has an output adapter "
243
+ f"({unexpected_adapter_keys}); this package only implements the "
244
+ "adapter-free token_context path verified against the shipped voices"
245
+ )
246
+
247
+ vocab_size = int(config["vocab_size"])
248
+ hidden = int(config["hidden"])
249
+ depth = int(config["depth"])
250
+ token_depth = int(config["token_depth"])
251
+ out_channels = int(config["out_channels"])
252
+
253
+ ids = clamp_ids_to_vocab(ids, vocab_size, label="acoustic")
254
+ durations = np.asarray(durations, dtype=np.int64)
255
+ if durations.shape != ids.shape:
256
+ raise ValueError("acoustic_forward: id/duration shape mismatch")
257
+ if np.any(durations < 1):
258
+ raise ValueError("acoustic_forward: non-positive duration")
259
+
260
+ n = int(ids.shape[0])
261
+ frames = int(durations.sum())
262
+
263
+ # -- token stage --
264
+ embed = tensors["embedding.weight"]
265
+ if embed.shape != (vocab_size, hidden):
266
+ raise RuntimeError(f"acoustic embedding shape {embed.shape} != config ({vocab_size}, {hidden})")
267
+ token_x = embed[ids].T # [hidden, n]
268
+ token_pos = linspace01(n)
269
+ durations_f = durations.astype(np.float64)
270
+ max_duration = max(float(durations_f.max()), 1.0)
271
+ duration_hint = (np.log1p(durations_f) / np.log1p(max_duration)).astype(np.float32)
272
+ token_features = np.stack([token_pos, duration_hint], axis=0)
273
+
274
+ token_x = conv1d_1x1(
275
+ np.concatenate([token_x, token_features], axis=0),
276
+ tensors["token_input_proj.weight"],
277
+ tensors["token_input_proj.bias"],
278
+ )
279
+ for i in range(token_depth):
280
+ token_x = residual_conv_block(token_x, tensors, f"token_blocks.{i}")
281
+
282
+ # -- expand token context to frames --
283
+ expanded = np.repeat(token_x, durations, axis=1) # [hidden, frames]
284
+ if expanded.shape[1] != frames:
285
+ raise RuntimeError("acoustic_forward: context expansion length mismatch")
286
+
287
+ frame_pos = linspace01(frames)
288
+ token_count = max(n - 1, 1)
289
+ token_pos_frame = np.empty((frames,), dtype=np.float32)
290
+ duration_pos_frame = np.empty((frames,), dtype=np.float32)
291
+ pos = 0
292
+ for token_index in range(n):
293
+ d = int(durations[token_index])
294
+ if d <= 0:
295
+ continue
296
+ token_pos_frame[pos:pos + d] = np.float32(float(token_index) / float(token_count))
297
+ if d == 1:
298
+ duration_pos_frame[pos] = 0.0
299
+ else:
300
+ duration_pos_frame[pos:pos + d] = (
301
+ np.arange(d, dtype=np.float64) / np.float64(d - 1)
302
+ ).astype(np.float32)
303
+ pos += d
304
+
305
+ frame_features = np.stack([frame_pos, token_pos_frame, duration_pos_frame], axis=0)
306
+ x = conv1d_1x1(
307
+ np.concatenate([expanded, frame_features], axis=0),
308
+ tensors["frame_input_proj.weight"],
309
+ tensors["frame_input_proj.bias"],
310
+ )
311
+ for i in range(depth):
312
+ x = residual_conv_block(x, tensors, f"frame_blocks.{i}")
313
+
314
+ latent = conv1d_1x1(x, tensors["output.weight"], tensors["output.bias"])
315
+ if latent.shape[0] != out_channels:
316
+ raise RuntimeError("acoustic_forward: unexpected output channel count")
317
+ return latent.astype(np.float32) # [out_channels, frames]
318
+
319
+
320
+ # --------------------------------------------------------------------------
321
+ # Decoder student ("variant": "piperlite" in the manifest)
322
+ # --------------------------------------------------------------------------
323
+
324
+ _BANK_KERNELS = (3, 5, 7)
325
+ _BANK_DIL1 = (1, 2, 3)
326
+ _BANK_DIL2 = (2, 6, 12)
327
+
328
+
329
+ def _residual_bank(x: np.ndarray, tensors: dict[str, np.ndarray], prefix: str, branches: list[int]) -> np.ndarray:
330
+ """PiperResidualBank: mean over active branches of
331
+ y2 = conv2(lrelu(y1, 0.1)) + y1, y1 = conv1(lrelu(x, 0.1)) + x.
332
+ """
333
+ acc = np.zeros_like(x)
334
+ for branch in branches:
335
+ k = _BANK_KERNELS[branch]
336
+ d1 = _BANK_DIL1[branch]
337
+ d2 = _BANK_DIL2[branch]
338
+ t = leaky_relu(x, 0.1)
339
+ u = conv1d_same(t, tensors[f"{prefix}.blocks.{branch}.conv1.weight"], tensors[f"{prefix}.blocks.{branch}.conv1.bias"], dilation=d1)
340
+ y1 = u + x
341
+ t2 = leaky_relu(y1, 0.1)
342
+ u2 = conv1d_same(t2, tensors[f"{prefix}.blocks.{branch}.conv2.weight"], tensors[f"{prefix}.blocks.{branch}.conv2.bias"], dilation=d2)
343
+ y2 = u2 + y1
344
+ acc = acc + y2
345
+ return acc / float(len(branches))
346
+
347
+
348
+ def _apply_post_filter(audio: np.ndarray, tensors: dict[str, np.ndarray], config: dict[str, Any]) -> np.ndarray:
349
+ channels = int(config.get("post_filter_channels") or 0)
350
+ layers = int(config.get("post_filter_layers") or 0)
351
+ kernel = int(config.get("post_filter_kernel") or 9)
352
+ scale = float(config.get("post_filter_scale") or 0.0)
353
+ if channels <= 0:
354
+ return audio
355
+
356
+ r = conv1d_same(audio[None, :], tensors["post_filter.in_conv.weight"], tensors["post_filter.in_conv.bias"])
357
+ for layer in range(layers):
358
+ unit_scale = float(tensors[f"post_filter.units.{layer}.scale"][0])
359
+ t = leaky_relu(r, 0.1)
360
+ u = conv1d_same(t, tensors[f"post_filter.units.{layer}.conv1.weight"], tensors[f"post_filter.units.{layer}.conv1.bias"], dilation=1 + layer)
361
+ t = leaky_relu(u, 0.1)
362
+ u2 = conv1d_same(t, tensors[f"post_filter.units.{layer}.conv2.weight"], tensors[f"post_filter.units.{layer}.conv2.bias"], dilation=1)
363
+ r = r + unit_scale * u2
364
+ out = conv1d_same(r, tensors["post_filter.out_conv.weight"], tensors["post_filter.out_conv.bias"])[0]
365
+ return np.tanh(audio + scale * out).astype(np.float32)
366
+
367
+
368
+ def decoder_forward(tensors: dict[str, np.ndarray], config: dict[str, Any], latent: np.ndarray) -> np.ndarray:
369
+ variant = str(config.get("variant") or "")
370
+ if variant != "piperlite":
371
+ raise NotImplementedError(f"unsupported decoder variant: {variant!r}")
372
+ if str(config.get("activation") or "leaky_relu") != "leaky_relu":
373
+ raise NotImplementedError("only activation='leaky_relu' decoders are implemented")
374
+ if float(config.get("pre_tanh_repair_channels") or 0) > 0:
375
+ raise NotImplementedError("pre_tanh_repair is not implemented (no shipped voice uses it)")
376
+ res_layers = int(config.get("res_layers") or 1)
377
+ if res_layers != 1:
378
+ raise NotImplementedError(f"only res_layers=1 is implemented, got {res_layers}")
379
+
380
+ channels = config["channels"]
381
+ c0, c1, c2, c3 = (int(c) for c in channels[:4])
382
+
383
+ x = conv1d_same(latent, tensors["pre.weight"], tensors["pre.bias"]) # [c0, frames]
384
+
385
+ stage_specs = [
386
+ (c0, c1, 16, 8, 4, "up0", "res0.0", config.get("stage0_branches")),
387
+ (c1, c2, 16, 8, 4, "up1", "res1.0", config.get("stage1_branches")),
388
+ (c2, c3, 8, 4, 2, "up2", "res2.0", config.get("stage2_branches")),
389
+ ]
390
+ for in_c, out_c, up_k, up_s, up_p, up_name, bank_prefix, branches in stage_specs:
391
+ if branches is None:
392
+ branches = [0, 1, 2]
393
+ x = leaky_relu(x, 0.1)
394
+ x = conv_transpose1d(x, tensors[f"{up_name}.weight"], tensors[f"{up_name}.bias"], stride=up_s, padding=up_p)
395
+ x = _residual_bank(x, tensors, bank_prefix, list(branches))
396
+
397
+ x = leaky_relu(x, 0.01)
398
+ audio = conv1d_same(x, tensors["post.weight"], tensors["post.bias"])[0]
399
+ audio = np.tanh(audio).astype(np.float32)
400
+ audio = _apply_post_filter(audio, tensors, config)
401
+ return audio
@@ -0,0 +1,12 @@
1
+ {
2
+ "_comment": "Alias -> release package name for VOICE_RELEASE_BASE_URL/<package>.tar.gz downloads. Package names match the directories under releases/multivoice-20260713 in the saanoTTS research repo, which is what tools/export_roota_self_contained_package.py produces per voice.",
3
+ "voices": {
4
+ "amy": { "package": "amy-en-1p46m", "language": "en_US", "sample_rate": 22050 },
5
+ "amy-1p1m": { "package": "amy-en-1p1m", "language": "en_US", "sample_rate": 22050 },
6
+ "amy-1p8m": { "package": "amy-en-1p8m", "language": "en_US", "sample_rate": 22050 },
7
+ "hfc": { "package": "hfc-en-1p8m", "language": "en_US", "sample_rate": 22050 },
8
+ "kristin": { "package": "kristin-en-1p4m", "language": "en_US", "sample_rate": 22050 },
9
+ "vi": { "package": "vi-vais1000-1p46m", "language": "vi_VN", "sample_rate": 22050 },
10
+ "id": { "package": "id-newstts-1p46m", "language": "id_ID", "sample_rate": 22050 }
11
+ }
12
+ }
@@ -0,0 +1,200 @@
1
+ """Voice package resolution: local directories or cached downloads.
2
+
3
+ A voice package is a directory containing:
4
+ - manifest.json (format "roota.raw-fp16.v1"; see
5
+ tools/export_roota_self_contained_package.py
6
+ in the saanoTTS research repo for the exporter)
7
+ - weights.fp16.bin (flat fp16 blob, tensors addressed by
8
+ manifest offset_bytes/nbytes)
9
+ - piper-phoneme-config.json (codepoint -> phoneme-id table + espeak voice)
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import io
16
+ import json
17
+ import logging
18
+ import tarfile
19
+ import urllib.request
20
+ from dataclasses import dataclass
21
+ from pathlib import Path
22
+ from typing import Any
23
+
24
+ import numpy as np
25
+
26
+ logger = logging.getLogger("sanotts.voicepack")
27
+
28
+ # Placeholder release location -- change this constant to point at wherever
29
+ # voice packages are actually published; everything else in this module is
30
+ # indifferent to the exact hosting scheme as long as it serves a
31
+ # `<name>.tar.gz` containing manifest.json + weights.fp16.bin + piper-phoneme-config.json
32
+ # at its root.
33
+ VOICE_RELEASE_BASE_URL = "https://github.com/Ampixa/sanoTTS/releases/download/voices-v1"
34
+
35
+ DEFAULT_CACHE_DIR = Path.home() / ".cache" / "sanotts"
36
+
37
+ # name -> package archive/dir basename, for the voices published alongside
38
+ # this package (see releases/multivoice-20260713 in the research repo).
39
+ KNOWN_VOICES_TABLE = Path(__file__).parent / "tables" / "voices.json"
40
+
41
+
42
+ class VoicePackError(RuntimeError):
43
+ pass
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class VoicePack:
48
+ name: str
49
+ directory: Path
50
+ manifest: dict[str, Any]
51
+ weights: bytes
52
+
53
+ @property
54
+ def sample_rate(self) -> int:
55
+ return int(self.manifest["sample_rate"])
56
+
57
+ @property
58
+ def duration_length_scale(self) -> float:
59
+ return float(self.manifest.get("inference", {}).get("duration_length_scale", 1.0))
60
+
61
+ def component_tensors(self, component: str) -> dict[str, np.ndarray]:
62
+ """Materialize one component's tensors as float32 numpy arrays."""
63
+ comp = self.manifest["components"].get(component)
64
+ if comp is None:
65
+ raise VoicePackError(f"manifest has no component {component!r}")
66
+ out: dict[str, np.ndarray] = {}
67
+ for tensor in comp["tensors"]:
68
+ name = tensor["name"]
69
+ shape = tuple(tensor["shape"])
70
+ dtype = tensor["dtype"]
71
+ offset = int(tensor["offset_bytes"])
72
+ nbytes = int(tensor["nbytes"])
73
+ raw = self.weights[offset:offset + nbytes]
74
+ if len(raw) != nbytes:
75
+ raise VoicePackError(
76
+ f"{component}.{name}: truncated weights blob "
77
+ f"(wanted {nbytes} bytes at {offset}, got {len(raw)})"
78
+ )
79
+ if dtype == "float16":
80
+ array = np.frombuffer(raw, dtype="<f2").astype(np.float32)
81
+ elif dtype == "int64":
82
+ array = np.frombuffer(raw, dtype="<i8").astype(np.int64)
83
+ elif dtype == "int32":
84
+ array = np.frombuffer(raw, dtype="<i4").astype(np.int32)
85
+ else:
86
+ raise VoicePackError(f"{component}.{name}: unsupported dtype {dtype!r}")
87
+ out[name] = array.reshape(shape)
88
+ return out
89
+
90
+ def component_config(self, component: str) -> dict[str, Any]:
91
+ comp = self.manifest["components"].get(component)
92
+ if comp is None:
93
+ raise VoicePackError(f"manifest has no component {component!r}")
94
+ return comp["config"]
95
+
96
+ @property
97
+ def phoneme_config_path(self) -> Path:
98
+ included = self.manifest.get("frontend", {}).get("included_config") or "piper-phoneme-config.json"
99
+ path = self.directory / included
100
+ if not path.is_file():
101
+ raise VoicePackError(f"voice pack {self.name!r} is missing its phoneme config: {path}")
102
+ return path
103
+
104
+
105
+ def _sha256_hex(data: bytes) -> str:
106
+ return hashlib.sha256(data).hexdigest()
107
+
108
+
109
+ def _load_from_directory(name: str, directory: Path) -> VoicePack:
110
+ manifest_path = directory / "manifest.json"
111
+ if not manifest_path.is_file():
112
+ raise VoicePackError(f"{directory}: missing manifest.json")
113
+ with manifest_path.open("r", encoding="utf-8") as handle:
114
+ manifest = json.load(handle)
115
+ if manifest.get("format") != "roota.raw-fp16.v1":
116
+ raise VoicePackError(
117
+ f"{directory}: unsupported manifest format {manifest.get('format')!r}, "
118
+ "expected 'roota.raw-fp16.v1'"
119
+ )
120
+ weights_path = directory / manifest["weights_file"]
121
+ if not weights_path.is_file():
122
+ raise VoicePackError(f"{directory}: missing weights file {weights_path}")
123
+ weights = weights_path.read_bytes()
124
+ expected_size = int(manifest.get("weights_size_bytes", -1))
125
+ if expected_size >= 0 and len(weights) != expected_size:
126
+ raise VoicePackError(
127
+ f"{weights_path}: size {len(weights)} != manifest weights_size_bytes {expected_size}"
128
+ )
129
+ expected_sha = manifest.get("weights_sha256")
130
+ if expected_sha:
131
+ actual_sha = _sha256_hex(weights)
132
+ if actual_sha != expected_sha:
133
+ raise VoicePackError(
134
+ f"{weights_path}: sha256 mismatch (manifest={expected_sha}, actual={actual_sha}); "
135
+ "the voice package is corrupt or was tampered with"
136
+ )
137
+ return VoicePack(name=name, directory=directory, manifest=manifest, weights=weights)
138
+
139
+
140
+ def _known_voice_archive_name(voice: str) -> str:
141
+ if not KNOWN_VOICES_TABLE.is_file():
142
+ raise VoicePackError(f"missing bundled voice registry: {KNOWN_VOICES_TABLE}")
143
+ with KNOWN_VOICES_TABLE.open("r", encoding="utf-8") as handle:
144
+ registry = json.load(handle)
145
+ entry = registry.get("voices", {}).get(voice)
146
+ if entry is None:
147
+ available = ", ".join(sorted(registry.get("voices", {})))
148
+ raise VoicePackError(f"unknown voice {voice!r}; known voices: {available}")
149
+ return str(entry["package"])
150
+
151
+
152
+ def _download_and_extract(voice: str, cache_dir: Path) -> Path:
153
+ package_name = _known_voice_archive_name(voice)
154
+ dest_dir = cache_dir / package_name
155
+ if (dest_dir / "manifest.json").is_file():
156
+ return dest_dir
157
+
158
+ url = f"{VOICE_RELEASE_BASE_URL}/{package_name}.tar.gz"
159
+ logger.info("sanotts: downloading voice %r from %s", voice, url)
160
+ try:
161
+ with urllib.request.urlopen(url, timeout=60) as response: # noqa: S310 - fixed https host
162
+ archive_bytes = response.read()
163
+ except OSError as exc:
164
+ raise VoicePackError(
165
+ f"could not download voice {voice!r} from {url}: {exc}. "
166
+ f"Use --voice-dir to point at a local voice package instead, e.g. a directory "
167
+ "produced by tools/export_roota_self_contained_package.py."
168
+ ) from exc
169
+
170
+ cache_dir.mkdir(parents=True, exist_ok=True)
171
+ with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:gz") as archive:
172
+ safe_root = dest_dir.resolve()
173
+ for member in archive.getmembers():
174
+ member_path = (dest_dir / member.name).resolve()
175
+ if safe_root not in member_path.parents and member_path != safe_root:
176
+ raise VoicePackError(f"refusing to extract unsafe archive member: {member.name}")
177
+ archive.extractall(dest_dir) # noqa: S202 - paths validated above
178
+ return dest_dir
179
+
180
+
181
+ def load_voice(
182
+ voice: str | None = None,
183
+ *,
184
+ voice_dir: str | Path | None = None,
185
+ cache_dir: str | Path | None = None,
186
+ ) -> VoicePack:
187
+ """Resolve a voice pack from an explicit directory, or by name (downloading
188
+ into `cache_dir` if it is not already cached there)."""
189
+ if voice_dir is not None:
190
+ directory = Path(voice_dir).expanduser().resolve()
191
+ if not directory.is_dir():
192
+ raise VoicePackError(f"--voice-dir {directory} is not a directory")
193
+ name = voice or directory.name
194
+ return _load_from_directory(name, directory)
195
+
196
+ if not voice:
197
+ raise VoicePackError("either voice or voice_dir must be given")
198
+ resolved_cache_dir = Path(cache_dir).expanduser() if cache_dir else DEFAULT_CACHE_DIR
199
+ directory = _download_and_extract(voice, resolved_cache_dir)
200
+ return _load_from_directory(voice, directory)