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.
- sanotts-0.1.0/.gitignore +90 -0
- sanotts-0.1.0/PKG-INFO +12 -0
- sanotts-0.1.0/pyproject.toml +32 -0
- sanotts-0.1.0/sanotts/__init__.py +34 -0
- sanotts-0.1.0/sanotts/cli.py +81 -0
- sanotts-0.1.0/sanotts/engine.py +76 -0
- sanotts-0.1.0/sanotts/frontend.py +284 -0
- sanotts-0.1.0/sanotts/models.py +401 -0
- sanotts-0.1.0/sanotts/tables/voices.json +12 -0
- sanotts-0.1.0/sanotts/voicepack.py +200 -0
sanotts-0.1.0/.gitignore
ADDED
|
@@ -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)
|