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 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,8 @@
1
+ """Allow ``python -m tts_studio`` to work the same as the ``tts-studio`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
@@ -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
+ }