tts-studio 1.0.0__py3-none-any.whl
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.
- tts_studio/__init__.py +11 -0
- tts_studio/__main__.py +8 -0
- tts_studio/audio_convert.py +186 -0
- tts_studio/cli.py +145 -0
- tts_studio/config.py +81 -0
- tts_studio/engines/__init__.py +30 -0
- tts_studio/engines/base.py +222 -0
- tts_studio/engines/catalogs.py +401 -0
- tts_studio/engines/kitten_engine.py +384 -0
- tts_studio/engines/kokoro_engine.py +296 -0
- tts_studio/engines/piper_engine.py +541 -0
- tts_studio/engines/pocket_engine.py +313 -0
- tts_studio/frontend_dist/assets/index-C084SbHr.js +67 -0
- tts_studio/frontend_dist/assets/index-Cl6__h45.css +1 -0
- tts_studio/frontend_dist/index.html +24 -0
- tts_studio/hardware.py +126 -0
- tts_studio/jobs.py +196 -0
- tts_studio/logbus.py +153 -0
- tts_studio/main.py +139 -0
- tts_studio/model_manager.py +403 -0
- tts_studio/openai_store.py +296 -0
- tts_studio/routers/__init__.py +1 -0
- tts_studio/routers/generate.py +194 -0
- tts_studio/routers/models.py +110 -0
- tts_studio/routers/openai_admin.py +245 -0
- tts_studio/routers/openai_api.py +445 -0
- tts_studio/routers/system.py +58 -0
- tts_studio/routers/voices.py +217 -0
- tts_studio/schemas.py +35 -0
- tts_studio/storage.py +341 -0
- tts_studio-1.0.0.dist-info/METADATA +528 -0
- tts_studio-1.0.0.dist-info/RECORD +35 -0
- tts_studio-1.0.0.dist-info/WHEEL +5 -0
- tts_studio-1.0.0.dist-info/entry_points.txt +2 -0
- tts_studio-1.0.0.dist-info/top_level.txt +1 -0
tts_studio/__init__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""TTS Studio - local-first text-to-speech studio.
|
|
2
|
+
|
|
3
|
+
Pick an engine, pick a voice, type your text and get audio - all running on
|
|
4
|
+
your own machine. Engines: Kokoro-82M, KittenTTS, Pocket TTS and Piper.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
__version__ = "1.0.0"
|
|
10
|
+
|
|
11
|
+
__all__ = ["__version__"]
|
tts_studio/__main__.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""Audio rendering for the OpenAI-compatible speech endpoint.
|
|
2
|
+
|
|
3
|
+
Engines return float PCM at their native sample rate. The public API can
|
|
4
|
+
deliver ``wav``, ``flac`` and ``pcm`` directly through soundfile, while
|
|
5
|
+
``mp3``, ``opus`` and ``aac`` are transcoded with ffmpeg when available.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import uuid
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
import soundfile as sf
|
|
18
|
+
|
|
19
|
+
from . import config
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger("tts.openai.audio")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AudioFormatError(RuntimeError):
|
|
25
|
+
"""Raised when a requested response format cannot be produced."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, message: str, code: str = "format_unavailable") -> None:
|
|
28
|
+
super().__init__(message)
|
|
29
|
+
self.code = code
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
FFMPEG = shutil.which("ffmpeg")
|
|
33
|
+
|
|
34
|
+
MEDIA_TYPES = {
|
|
35
|
+
"mp3": "audio/mpeg",
|
|
36
|
+
"opus": "audio/ogg",
|
|
37
|
+
"aac": "audio/aac",
|
|
38
|
+
"flac": "audio/flac",
|
|
39
|
+
"wav": "audio/wav",
|
|
40
|
+
"pcm": "audio/pcm", # raw 16-bit little-endian samples, no header
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
EXTENSIONS = {
|
|
44
|
+
"mp3": ".mp3",
|
|
45
|
+
"opus": ".opus",
|
|
46
|
+
"aac": ".aac",
|
|
47
|
+
"flac": ".flac",
|
|
48
|
+
"wav": ".wav",
|
|
49
|
+
"pcm": ".pcm",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
# ffmpeg output args per container/format.
|
|
53
|
+
FFMPEG_ARGS = {
|
|
54
|
+
"mp3": ["-c:a", "libmp3lame", "-b:a", "128k", "-f", "mp3"],
|
|
55
|
+
"opus": ["-c:a", "libopus", "-b:a", "64k", "-f", "ogg"],
|
|
56
|
+
"aac": ["-c:a", "aac", "-b:a", "128k", "-f", "adts"],
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def formats_available() -> dict[str, bool]:
|
|
61
|
+
"""Which response formats can currently be produced."""
|
|
62
|
+
return {
|
|
63
|
+
"mp3": FFMPEG is not None,
|
|
64
|
+
"opus": FFMPEG is not None,
|
|
65
|
+
"aac": FFMPEG is not None,
|
|
66
|
+
"flac": True,
|
|
67
|
+
"wav": True,
|
|
68
|
+
"pcm": True,
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _ensure_workdir() -> Path:
|
|
73
|
+
config.API_AUDIO_DIR.mkdir(parents=True, exist_ok=True)
|
|
74
|
+
return config.API_AUDIO_DIR
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _write_wav(audio: np.ndarray, sample_rate: int, target: Path) -> None:
|
|
78
|
+
samples = np.clip(np.asarray(audio, dtype=np.float32).reshape(-1), -1.0, 1.0)
|
|
79
|
+
sf.write(str(target), samples, sample_rate, subtype="PCM_16")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _ffmpeg_transcode(source: Path, fmt: str, target: Path) -> None:
|
|
83
|
+
if FFMPEG is None:
|
|
84
|
+
raise AudioFormatError(
|
|
85
|
+
f"response_format '{fmt}' requires the ffmpeg binary on the host, "
|
|
86
|
+
"but none was found. Install ffmpeg or use 'wav', 'flac' or 'pcm'.",
|
|
87
|
+
code="format_unavailable",
|
|
88
|
+
)
|
|
89
|
+
command = [
|
|
90
|
+
FFMPEG,
|
|
91
|
+
"-y",
|
|
92
|
+
"-loglevel",
|
|
93
|
+
"error",
|
|
94
|
+
"-i",
|
|
95
|
+
str(source),
|
|
96
|
+
*FFMPEG_ARGS[fmt],
|
|
97
|
+
str(target),
|
|
98
|
+
]
|
|
99
|
+
try:
|
|
100
|
+
result = subprocess.run(
|
|
101
|
+
command,
|
|
102
|
+
capture_output=True,
|
|
103
|
+
text=True,
|
|
104
|
+
timeout=180,
|
|
105
|
+
)
|
|
106
|
+
except subprocess.TimeoutExpired as exc:
|
|
107
|
+
raise AudioFormatError(
|
|
108
|
+
f"Transcoding to '{fmt}' timed out.", code="transcode_timeout"
|
|
109
|
+
) from exc
|
|
110
|
+
if result.returncode != 0 or not target.is_file():
|
|
111
|
+
detail = (result.stderr or "").strip().splitlines()
|
|
112
|
+
message = detail[-1] if detail else f"ffmpeg exited with {result.returncode}"
|
|
113
|
+
raise AudioFormatError(
|
|
114
|
+
f"Could not transcode audio to '{fmt}': {message}",
|
|
115
|
+
code="transcode_failed",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render_audio(
|
|
120
|
+
audio: np.ndarray,
|
|
121
|
+
sample_rate: int,
|
|
122
|
+
fmt: str,
|
|
123
|
+
) -> tuple[Path, str]:
|
|
124
|
+
"""Write ``audio`` in the requested format inside the API audio dir.
|
|
125
|
+
|
|
126
|
+
Returns ``(path, media_type)``. The caller is responsible for scheduling
|
|
127
|
+
:func:`cleanup_paths` with the returned files after the response is sent.
|
|
128
|
+
"""
|
|
129
|
+
fmt = (fmt or "wav").lower()
|
|
130
|
+
if fmt not in MEDIA_TYPES:
|
|
131
|
+
raise AudioFormatError(
|
|
132
|
+
f"Unknown response_format '{fmt}'. Supported formats: "
|
|
133
|
+
+ ", ".join(MEDIA_TYPES),
|
|
134
|
+
code="invalid_response_format",
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
workdir = _ensure_workdir()
|
|
138
|
+
stem = f"api_{uuid.uuid4().hex[:16]}"
|
|
139
|
+
wav_path = workdir / f"{stem}.wav"
|
|
140
|
+
out_path = workdir / f"{stem}{EXTENSIONS[fmt]}"
|
|
141
|
+
|
|
142
|
+
_write_wav(audio, sample_rate, wav_path)
|
|
143
|
+
|
|
144
|
+
if fmt == "wav":
|
|
145
|
+
out_path = wav_path
|
|
146
|
+
elif fmt == "pcm":
|
|
147
|
+
samples, _ = sf.read(str(wav_path), dtype="int16")
|
|
148
|
+
out_path.write_bytes(np.asarray(samples, dtype=np.int16).tobytes())
|
|
149
|
+
wav_path.unlink(missing_ok=True)
|
|
150
|
+
elif fmt == "flac":
|
|
151
|
+
samples = np.clip(np.asarray(audio, dtype=np.float32).reshape(-1), -1.0, 1.0)
|
|
152
|
+
sf.write(str(out_path), samples, sample_rate, format="FLAC", subtype="PCM_16")
|
|
153
|
+
wav_path.unlink(missing_ok=True)
|
|
154
|
+
else:
|
|
155
|
+
_ffmpeg_transcode(wav_path, fmt, out_path)
|
|
156
|
+
wav_path.unlink(missing_ok=True)
|
|
157
|
+
|
|
158
|
+
return out_path, MEDIA_TYPES[fmt]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def cleanup_paths(*paths: Path | None) -> None:
|
|
162
|
+
"""Best-effort removal of temporary API audio files."""
|
|
163
|
+
for path in paths:
|
|
164
|
+
if not path:
|
|
165
|
+
continue
|
|
166
|
+
try:
|
|
167
|
+
if Path(path).is_file():
|
|
168
|
+
Path(path).unlink()
|
|
169
|
+
except OSError: # pragma: no cover - best effort
|
|
170
|
+
logger.debug("Could not remove %s", path)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def prune_old_files(max_age_s: int = 3600) -> None:
|
|
174
|
+
"""Remove leftover API audio older than ``max_age_s`` (crash safety net)."""
|
|
175
|
+
workdir = config.API_AUDIO_DIR
|
|
176
|
+
if not workdir.is_dir():
|
|
177
|
+
return
|
|
178
|
+
import time
|
|
179
|
+
|
|
180
|
+
now = time.time()
|
|
181
|
+
for path in workdir.glob("api_*"):
|
|
182
|
+
try:
|
|
183
|
+
if now - path.stat().st_mtime > max_age_s:
|
|
184
|
+
path.unlink()
|
|
185
|
+
except OSError: # pragma: no cover
|
|
186
|
+
continue
|
tts_studio/cli.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""``tts-studio`` command line interface.
|
|
2
|
+
|
|
3
|
+
Starts the whole application in a single process:
|
|
4
|
+
|
|
5
|
+
* The FastAPI backend serves the built web UI, the studio API and the
|
|
6
|
+
OpenAI-compatible ``/v1`` API - all on ONE local port.
|
|
7
|
+
* Your browser opens the app automatically.
|
|
8
|
+
* There is no separate backend port to remember or expose: the single URL
|
|
9
|
+
printed in the terminal IS the application.
|
|
10
|
+
|
|
11
|
+
Runtime data (models, voices, audio, config) lives in ``~/.tts-studio`` by
|
|
12
|
+
default; override with ``--data-dir`` or the ``TTS_DATA_DIR`` environment
|
|
13
|
+
variable.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import os
|
|
20
|
+
import socket
|
|
21
|
+
import sys
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
import webbrowser
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
|
|
27
|
+
DEFAULT_HOST = "127.0.0.1"
|
|
28
|
+
DEFAULT_PORT = 8000
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _free_port(host: str, preferred: int) -> int:
|
|
32
|
+
"""Return ``preferred`` if it is available, else the next free port."""
|
|
33
|
+
for port in range(preferred, preferred + 50):
|
|
34
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
35
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
36
|
+
try:
|
|
37
|
+
sock.bind((host, port))
|
|
38
|
+
except OSError:
|
|
39
|
+
continue
|
|
40
|
+
return port
|
|
41
|
+
raise SystemExit(
|
|
42
|
+
f"error: no free port found between {preferred} and {preferred + 49}"
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _open_browser_when_ready(url: str, host: str, port: int) -> None:
|
|
47
|
+
"""Wait until the server accepts connections, then open the browser."""
|
|
48
|
+
|
|
49
|
+
def _wait_and_open() -> None:
|
|
50
|
+
deadline = time.time() + 30
|
|
51
|
+
while time.time() < deadline:
|
|
52
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
53
|
+
sock.settimeout(0.5)
|
|
54
|
+
try:
|
|
55
|
+
sock.connect((host, port))
|
|
56
|
+
except OSError:
|
|
57
|
+
time.sleep(0.25)
|
|
58
|
+
continue
|
|
59
|
+
break
|
|
60
|
+
webbrowser.open(url)
|
|
61
|
+
|
|
62
|
+
threading.Thread(target=_wait_and_open, daemon=True).start()
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv: list[str] | None = None) -> int:
|
|
66
|
+
parser = argparse.ArgumentParser(
|
|
67
|
+
prog="tts-studio",
|
|
68
|
+
description=(
|
|
69
|
+
"Launch the TTS Studio web app - local-first text-to-speech with "
|
|
70
|
+
"Kokoro, Kitten, Pocket and Piper. One command, one port, no API keys."
|
|
71
|
+
),
|
|
72
|
+
)
|
|
73
|
+
parser.add_argument(
|
|
74
|
+
"--host",
|
|
75
|
+
default=DEFAULT_HOST,
|
|
76
|
+
help=f"Bind address (default: {DEFAULT_HOST})",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"--port",
|
|
80
|
+
type=int,
|
|
81
|
+
default=None,
|
|
82
|
+
help=f"Port to serve on (default: {DEFAULT_PORT}; auto-skips busy ports)",
|
|
83
|
+
)
|
|
84
|
+
parser.add_argument(
|
|
85
|
+
"--data-dir",
|
|
86
|
+
type=str,
|
|
87
|
+
default=None,
|
|
88
|
+
help="Directory for models, voices and audio (default: ~/.tts-studio)",
|
|
89
|
+
)
|
|
90
|
+
parser.add_argument(
|
|
91
|
+
"--no-browser",
|
|
92
|
+
action="store_true",
|
|
93
|
+
help="Do not open the browser automatically",
|
|
94
|
+
)
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
"--version",
|
|
97
|
+
action="version",
|
|
98
|
+
version=f"%(prog)s {__import__('tts_studio').__version__}",
|
|
99
|
+
)
|
|
100
|
+
args = parser.parse_args(argv)
|
|
101
|
+
|
|
102
|
+
# --- Configure the runtime environment BEFORE importing the app. -------
|
|
103
|
+
# tts_studio.config reads these at import time.
|
|
104
|
+
if args.data_dir:
|
|
105
|
+
os.environ["TTS_DATA_DIR"] = str(Path(args.data_dir).expanduser().resolve())
|
|
106
|
+
os.environ.setdefault("TTS_HOST", args.host)
|
|
107
|
+
|
|
108
|
+
from tts_studio import __version__
|
|
109
|
+
|
|
110
|
+
port = _free_port(args.host, args.port if args.port is not None else DEFAULT_PORT)
|
|
111
|
+
display_host = "localhost" if args.host in ("0.0.0.0", "::", "") else args.host
|
|
112
|
+
url = f"http://{display_host}:{port}"
|
|
113
|
+
data_dir = os.environ.get("TTS_DATA_DIR", "~/.tts-studio")
|
|
114
|
+
|
|
115
|
+
print()
|
|
116
|
+
print(f" TTS Studio v{__version__}")
|
|
117
|
+
print(f" {'-' * 46}")
|
|
118
|
+
print(f" App : {url}")
|
|
119
|
+
print(f" Data dir : {data_dir}")
|
|
120
|
+
print(f" Stop : Ctrl+C")
|
|
121
|
+
print()
|
|
122
|
+
print(" The UI, the studio API and the OpenAI-compatible /v1 API are all")
|
|
123
|
+
print(" served from the single URL above - no backend port to manage.")
|
|
124
|
+
print()
|
|
125
|
+
|
|
126
|
+
if not args.no_browser:
|
|
127
|
+
_open_browser_when_ready(url, args.host, port)
|
|
128
|
+
|
|
129
|
+
try:
|
|
130
|
+
import uvicorn
|
|
131
|
+
|
|
132
|
+
uvicorn.run(
|
|
133
|
+
"tts_studio.main:app",
|
|
134
|
+
host=args.host,
|
|
135
|
+
port=port,
|
|
136
|
+
log_level="warning",
|
|
137
|
+
# The app prints its own banner; uvicorn stays quiet.
|
|
138
|
+
)
|
|
139
|
+
except KeyboardInterrupt:
|
|
140
|
+
pass
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__": # pragma: no cover
|
|
145
|
+
sys.exit(main())
|
tts_studio/config.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Application paths and settings.
|
|
2
|
+
|
|
3
|
+
All runtime data (models, downloaded voices, generated audio, logs) is kept
|
|
4
|
+
inside ``~/.tts-studio`` by default so that everything stays local and the
|
|
5
|
+
installed package never writes into ``site-packages``. Override with the
|
|
6
|
+
``TTS_DATA_DIR`` environment variable or the ``--data-dir`` CLI flag.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
BASE_DIR = Path(__file__).resolve().parent
|
|
15
|
+
|
|
16
|
+
APP_NAME = "TTS Studio"
|
|
17
|
+
APP_VERSION = "1.0.0"
|
|
18
|
+
|
|
19
|
+
DATA_DIR = Path(
|
|
20
|
+
os.environ.get("TTS_DATA_DIR", str(Path.home() / ".tts-studio"))
|
|
21
|
+
).resolve()
|
|
22
|
+
|
|
23
|
+
# Keep every Hugging Face download (Kokoro weights, Kitten weights, Pocket
|
|
24
|
+
# weights and voices) inside the local data directory. Must be set before
|
|
25
|
+
# huggingface_hub is imported anywhere.
|
|
26
|
+
HF_CACHE_DIR = DATA_DIR / "hf_cache"
|
|
27
|
+
os.environ.setdefault("HF_HOME", str(HF_CACHE_DIR))
|
|
28
|
+
|
|
29
|
+
MODELS_DIR = DATA_DIR / "models"
|
|
30
|
+
PIPER_VOICES_DIR = DATA_DIR / "piper_voices"
|
|
31
|
+
OUTPUTS_DIR = DATA_DIR / "audio"
|
|
32
|
+
PREVIEWS_DIR = DATA_DIR / "previews"
|
|
33
|
+
CUSTOM_VOICES_DIR = DATA_DIR / "custom_voices"
|
|
34
|
+
LOGS_DIR = DATA_DIR / "logs"
|
|
35
|
+
API_AUDIO_DIR = DATA_DIR / "api_audio"
|
|
36
|
+
|
|
37
|
+
HISTORY_FILE = DATA_DIR / "history.json"
|
|
38
|
+
SETTINGS_FILE = DATA_DIR / "settings.json"
|
|
39
|
+
PIPER_CATALOG_FILE = DATA_DIR / "piper_catalog.json"
|
|
40
|
+
API_KEYS_FILE = DATA_DIR / "api_keys.json"
|
|
41
|
+
API_CONFIG_FILE = DATA_DIR / "api_config.json"
|
|
42
|
+
|
|
43
|
+
HOST = os.environ.get("TTS_HOST", "127.0.0.1")
|
|
44
|
+
PORT = int(os.environ.get("TTS_PORT", "8000"))
|
|
45
|
+
|
|
46
|
+
# Comma separated origins; "*" by default so the Vite dev server and
|
|
47
|
+
# reverse-proxied deployments both work out of the box.
|
|
48
|
+
CORS_ORIGINS = [
|
|
49
|
+
origin.strip()
|
|
50
|
+
for origin in os.environ.get("TTS_CORS_ORIGINS", "*").split(",")
|
|
51
|
+
if origin.strip()
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
# Seconds before the Piper voice catalogue is considered stale and re-fetched.
|
|
55
|
+
PIPER_CATALOG_TTL = int(os.environ.get("TTS_PIPER_CATALOG_TTL", str(7 * 24 * 3600)))
|
|
56
|
+
|
|
57
|
+
PIPER_CATALOG_URL = (
|
|
58
|
+
"https://huggingface.co/rhasspy/piper-voices/resolve/main/voices.json"
|
|
59
|
+
)
|
|
60
|
+
PIPER_VOICE_BASE_URL = "https://huggingface.co/rhasspy/piper-voices/resolve/main/"
|
|
61
|
+
|
|
62
|
+
KOKORO_REPO = "hexgrad/Kokoro-82M"
|
|
63
|
+
KOKORO_SAMPLE_RATE = 24000
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def ensure_dirs() -> None:
|
|
67
|
+
for directory in (
|
|
68
|
+
DATA_DIR,
|
|
69
|
+
HF_CACHE_DIR,
|
|
70
|
+
MODELS_DIR,
|
|
71
|
+
PIPER_VOICES_DIR,
|
|
72
|
+
OUTPUTS_DIR,
|
|
73
|
+
PREVIEWS_DIR,
|
|
74
|
+
CUSTOM_VOICES_DIR,
|
|
75
|
+
LOGS_DIR,
|
|
76
|
+
API_AUDIO_DIR,
|
|
77
|
+
):
|
|
78
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
ensure_dirs()
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""TTS engine adapters."""
|
|
2
|
+
|
|
3
|
+
from . import catalogs # noqa: F401
|
|
4
|
+
from .base import ( # noqa: F401
|
|
5
|
+
BaseEngine,
|
|
6
|
+
EngineError,
|
|
7
|
+
GenerationResult,
|
|
8
|
+
LoadedHandle,
|
|
9
|
+
Voice,
|
|
10
|
+
VoiceRef,
|
|
11
|
+
option,
|
|
12
|
+
)
|
|
13
|
+
from .kokoro_engine import KokoroEngine # noqa: F401
|
|
14
|
+
from .kitten_engine import KittenEngine # noqa: F401
|
|
15
|
+
from .piper_engine import PiperEngine # noqa: F401
|
|
16
|
+
from .pocket_engine import PocketEngine # noqa: F401
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"BaseEngine",
|
|
20
|
+
"EngineError",
|
|
21
|
+
"GenerationResult",
|
|
22
|
+
"LoadedHandle",
|
|
23
|
+
"Voice",
|
|
24
|
+
"VoiceRef",
|
|
25
|
+
"option",
|
|
26
|
+
"KokoroEngine",
|
|
27
|
+
"KittenEngine",
|
|
28
|
+
"PocketEngine",
|
|
29
|
+
"PiperEngine",
|
|
30
|
+
]
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Engine adapter interface shared by Kokoro / Kitten / Pocket / Piper."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import importlib.util
|
|
7
|
+
import logging
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any, Callable
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("tts.engines")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class EngineError(RuntimeError):
|
|
18
|
+
"""Raised for user-facing engine failures (missing model, bad options...)."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, message: str, code: str = "engine_error") -> None:
|
|
21
|
+
super().__init__(message)
|
|
22
|
+
self.code = code
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def option(
|
|
26
|
+
key: str,
|
|
27
|
+
label: str,
|
|
28
|
+
type_: str = "float",
|
|
29
|
+
default: Any = None,
|
|
30
|
+
choices: list[Any] | None = None,
|
|
31
|
+
minimum: float | None = None,
|
|
32
|
+
maximum: float | None = None,
|
|
33
|
+
step: float | None = None,
|
|
34
|
+
help_: str = "",
|
|
35
|
+
advanced: bool = False,
|
|
36
|
+
) -> dict[str, Any]:
|
|
37
|
+
"""Describe a load/generation option so the frontend can render a form."""
|
|
38
|
+
return {
|
|
39
|
+
"key": key,
|
|
40
|
+
"label": label,
|
|
41
|
+
"type": type_,
|
|
42
|
+
"default": default,
|
|
43
|
+
"choices": choices,
|
|
44
|
+
"min": minimum,
|
|
45
|
+
"max": maximum,
|
|
46
|
+
"step": step,
|
|
47
|
+
"help": help_,
|
|
48
|
+
"advanced": advanced,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Voice:
|
|
54
|
+
id: str
|
|
55
|
+
name: str
|
|
56
|
+
language: str # human readable, e.g. "American English"
|
|
57
|
+
lang_code: str | None = None
|
|
58
|
+
gender: str | None = None # "female" | "male" | None
|
|
59
|
+
description: str = ""
|
|
60
|
+
grade: str | None = None
|
|
61
|
+
tags: list[str] = field(default_factory=list)
|
|
62
|
+
variants: list[str] | None = None # variant ids this voice works with
|
|
63
|
+
speaker_count: int = 1
|
|
64
|
+
speakers: list[dict[str, Any]] | None = None
|
|
65
|
+
size_bytes: int | None = None
|
|
66
|
+
downloaded: bool | None = None
|
|
67
|
+
custom: bool = False
|
|
68
|
+
path: str | None = None # for custom voices
|
|
69
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
def as_dict(self) -> dict[str, Any]:
|
|
72
|
+
return {
|
|
73
|
+
"id": self.id,
|
|
74
|
+
"name": self.name,
|
|
75
|
+
"language": self.language,
|
|
76
|
+
"lang_code": self.lang_code,
|
|
77
|
+
"gender": self.gender,
|
|
78
|
+
"description": self.description,
|
|
79
|
+
"grade": self.grade,
|
|
80
|
+
"tags": self.tags,
|
|
81
|
+
"variants": self.variants,
|
|
82
|
+
"speaker_count": self.speaker_count,
|
|
83
|
+
"speakers": self.speakers or [],
|
|
84
|
+
"size_bytes": self.size_bytes,
|
|
85
|
+
"downloaded": self.downloaded,
|
|
86
|
+
"custom": self.custom,
|
|
87
|
+
"extra": self.extra,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class VoiceRef:
|
|
93
|
+
kind: str # "builtin" | "file"
|
|
94
|
+
value: str # builtin voice id/name or local file path
|
|
95
|
+
voice: Voice | None = None
|
|
96
|
+
language: str | None = None
|
|
97
|
+
reference_text: str | None = None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
ProgressFn = Callable[[float, str | None], None]
|
|
101
|
+
CancelFn = Callable[[], bool]
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class GenerationResult:
|
|
106
|
+
audio: np.ndarray
|
|
107
|
+
sample_rate: int
|
|
108
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@dataclass
|
|
112
|
+
class LoadedHandle:
|
|
113
|
+
engine_id: str
|
|
114
|
+
variant_id: str
|
|
115
|
+
options: dict[str, Any]
|
|
116
|
+
handle: Any
|
|
117
|
+
loaded_at: float = field(default_factory=time.time)
|
|
118
|
+
device: str = "cpu"
|
|
119
|
+
sample_rate: int = 24000
|
|
120
|
+
meta: dict[str, Any] = field(default_factory=dict)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class BaseEngine:
|
|
124
|
+
"""Common behaviour for all engines."""
|
|
125
|
+
|
|
126
|
+
id: str = ""
|
|
127
|
+
name: str = ""
|
|
128
|
+
description: str = ""
|
|
129
|
+
homepage: str = ""
|
|
130
|
+
license: str = ""
|
|
131
|
+
install_specs: dict[str, list[str]] = {}
|
|
132
|
+
default_variant: str = ""
|
|
133
|
+
cloning: bool = False
|
|
134
|
+
custom_voice_types: list[dict[str, str]] = []
|
|
135
|
+
import_names: list[str] = []
|
|
136
|
+
|
|
137
|
+
# ------------------------------------------------------------------
|
|
138
|
+
# Installation / introspection
|
|
139
|
+
# ------------------------------------------------------------------
|
|
140
|
+
def installed(self) -> bool:
|
|
141
|
+
return all(
|
|
142
|
+
importlib.util.find_spec(name) is not None for name in self.import_names
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def version(self) -> str | None:
|
|
146
|
+
if not self.installed():
|
|
147
|
+
return None
|
|
148
|
+
for package in self.installed_packages():
|
|
149
|
+
for name in (package.split("[")[0], self.id):
|
|
150
|
+
try:
|
|
151
|
+
return importlib.metadata.version(name)
|
|
152
|
+
except importlib.metadata.PackageNotFoundError:
|
|
153
|
+
continue
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
def installed_packages(self) -> list[str]:
|
|
157
|
+
return self.install_specs.get("base", [])
|
|
158
|
+
|
|
159
|
+
# ------------------------------------------------------------------
|
|
160
|
+
# Catalogue
|
|
161
|
+
# ------------------------------------------------------------------
|
|
162
|
+
def variants(self) -> list[dict[str, Any]]:
|
|
163
|
+
return []
|
|
164
|
+
|
|
165
|
+
def voices(
|
|
166
|
+
self, variant_id: str | None, custom_voices: list[Any] | None = None
|
|
167
|
+
) -> list[Voice]:
|
|
168
|
+
return []
|
|
169
|
+
|
|
170
|
+
# ------------------------------------------------------------------
|
|
171
|
+
# Model lifecycle
|
|
172
|
+
# ------------------------------------------------------------------
|
|
173
|
+
def load(self, variant_id: str, options: dict[str, Any]) -> LoadedHandle:
|
|
174
|
+
raise NotImplementedError
|
|
175
|
+
|
|
176
|
+
def unload(self, loaded: LoadedHandle) -> None:
|
|
177
|
+
"""Release the handle. Best effort - used for logs/cleanup."""
|
|
178
|
+
handle = loaded.handle
|
|
179
|
+
try:
|
|
180
|
+
if hasattr(handle, "to"):
|
|
181
|
+
handle.to("cpu") # type: ignore[operator]
|
|
182
|
+
except Exception: # pragma: no cover - defensive
|
|
183
|
+
pass
|
|
184
|
+
try:
|
|
185
|
+
import gc
|
|
186
|
+
|
|
187
|
+
gc.collect()
|
|
188
|
+
except Exception: # pragma: no cover - defensive
|
|
189
|
+
pass
|
|
190
|
+
|
|
191
|
+
# ------------------------------------------------------------------
|
|
192
|
+
# Generation
|
|
193
|
+
# ------------------------------------------------------------------
|
|
194
|
+
def generate(
|
|
195
|
+
self,
|
|
196
|
+
loaded: LoadedHandle,
|
|
197
|
+
text: str,
|
|
198
|
+
voice: VoiceRef,
|
|
199
|
+
options: dict[str, Any],
|
|
200
|
+
progress: ProgressFn,
|
|
201
|
+
cancelled: CancelFn,
|
|
202
|
+
) -> GenerationResult:
|
|
203
|
+
raise NotImplementedError
|
|
204
|
+
|
|
205
|
+
# ------------------------------------------------------------------
|
|
206
|
+
# Helpers
|
|
207
|
+
# ------------------------------------------------------------------
|
|
208
|
+
def describe(self) -> dict[str, Any]:
|
|
209
|
+
return {
|
|
210
|
+
"id": self.id,
|
|
211
|
+
"name": self.name,
|
|
212
|
+
"description": self.description,
|
|
213
|
+
"homepage": self.homepage,
|
|
214
|
+
"license": self.license,
|
|
215
|
+
"installed": self.installed(),
|
|
216
|
+
"version": self.version() if self.installed() else None,
|
|
217
|
+
"install_specs": self.install_specs,
|
|
218
|
+
"default_variant": self.default_variant,
|
|
219
|
+
"cloning": self.cloning,
|
|
220
|
+
"custom_voice_types": self.custom_voice_types,
|
|
221
|
+
"installed_packages": self.installed_packages(),
|
|
222
|
+
}
|