syscon-tts 0.0.1__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.
syscon_tts/__init__.py ADDED
@@ -0,0 +1,65 @@
1
+ """Syscon TTS - offline text-to-speech for the PlantStar APU.
2
+
3
+ Wraps the Piper neural TTS engine (CPU-only, fully offline) behind a small
4
+ library, a command-line tool, and an optional HTTP server.
5
+
6
+ Piper only installs on Linux, which is where every APU runs. Importing this
7
+ package is nonetheless safe on Windows and macOS: the Piper import is deferred
8
+ until synthesis is attempted, so developers on those platforms can install the
9
+ package, inspect voices, and replay audio generated elsewhere. Attempting to
10
+ generate new audio there raises
11
+ :class:`~syscon_tts.engine.SynthesisUnavailableError`.
12
+
13
+ Typical APU usage -- resolve alert text to a WAV, synthesizing only on a cache
14
+ miss::
15
+
16
+ from syscon_tts import AlertSynthesizer
17
+
18
+ synth = AlertSynthesizer() # build once, keep it: caches models
19
+ result = synth.ensure("Press 4 cavity pressure exceeded.")
20
+ print(result.path, result.cached)
21
+ """
22
+
23
+ from .alerts import (
24
+ AlertAudio,
25
+ AlertSynthesizer,
26
+ InvalidAlertNameError,
27
+ ensure_alert_wav,
28
+ sanitize_file_name,
29
+ )
30
+ from .config import Settings, load_settings
31
+ from .engine import (
32
+ SynthesisError,
33
+ SynthesisUnavailableError,
34
+ TTSEngine,
35
+ piper_available,
36
+ )
37
+ from .voices import (
38
+ UnknownVoiceError,
39
+ VoiceError,
40
+ VoiceNotInstalledError,
41
+ VoiceProfile,
42
+ VoiceRegistry,
43
+ )
44
+
45
+ __version__ = "0.0.1"
46
+
47
+ __all__ = [
48
+ "AlertAudio",
49
+ "AlertSynthesizer",
50
+ "InvalidAlertNameError",
51
+ "Settings",
52
+ "SynthesisError",
53
+ "SynthesisUnavailableError",
54
+ "TTSEngine",
55
+ "UnknownVoiceError",
56
+ "VoiceError",
57
+ "VoiceNotInstalledError",
58
+ "VoiceProfile",
59
+ "VoiceRegistry",
60
+ "__version__",
61
+ "ensure_alert_wav",
62
+ "load_settings",
63
+ "piper_available",
64
+ "sanitize_file_name",
65
+ ]
syscon_tts/alerts.py ADDED
@@ -0,0 +1,181 @@
1
+ """Cache-first alert audio.
2
+
3
+ This is the module the PlantStar APU integrates against. It encapsulates the
4
+ one contract the rest of the APU actually depends on: a WAV file exists at
5
+ ``<alerts_dir>/<file_name>.wav``. Everything downstream -- the websocket push,
6
+ ``/media/`` serving, and the file-cleanup pass -- is agnostic about how that
7
+ file got there.
8
+
9
+ The lookup is deliberately **cache first**:
10
+
11
+ * If the WAV already exists, it is returned immediately. This path needs no
12
+ Piper, so it works on Windows and macOS exactly as it does on Linux.
13
+ * Only on a cache miss is synthesis attempted, which requires Piper and
14
+ therefore Linux. On other platforms this raises
15
+ :class:`~syscon_tts.engine.SynthesisUnavailableError`, which callers can
16
+ catch to degrade gracefully.
17
+
18
+ That split is what lets developers on Windows/macOS exercise the full alert
19
+ pipeline against audio generated on a Linux box, without installing Piper.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import os
25
+ import re
26
+ import tempfile
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Optional
30
+
31
+ from .config import Settings, load_settings
32
+ from .engine import TTSEngine
33
+ from .voices import VoiceRegistry
34
+
35
+
36
+ class InvalidAlertNameError(ValueError):
37
+ """Text reduced to a file name that would be unsafe to write."""
38
+
39
+
40
+ # Django's ``get_valid_filename`` strips everything outside [-\w.] after
41
+ # collapsing spaces to underscores. Reproduced here (rather than imported) so
42
+ # the package has no Django dependency, while still computing byte-identical
43
+ # names to the APU's existing ``get_valid_filename(message)[:50]``.
44
+ _UNSAFE_CHARS = re.compile(r"(?u)[^-\w.]")
45
+
46
+ #: The APU truncates alert file names to this length.
47
+ MAX_FILE_NAME_LEN = 50
48
+
49
+
50
+ def sanitize_file_name(text: str, max_length: int = MAX_FILE_NAME_LEN) -> str:
51
+ """Derive an alert file name (no extension) from message text.
52
+
53
+ Matches ``django.utils.text.get_valid_filename(text)[:max_length]``.
54
+ """
55
+ name = str(text).strip().replace(" ", "_")
56
+ name = _UNSAFE_CHARS.sub("", name)
57
+ name = name[:max_length]
58
+ if name in ("", ".", ".."):
59
+ raise InvalidAlertNameError(
60
+ f"Text {text!r} does not yield a usable file name."
61
+ )
62
+ return name
63
+
64
+
65
+ @dataclass(frozen=True)
66
+ class AlertAudio:
67
+ """Result of resolving an alert to a playable WAV file."""
68
+
69
+ path: Path
70
+ file_name: str
71
+ cached: bool # True when the file already existed
72
+ voice: Optional[str] # None when served from cache without synthesis
73
+
74
+ def __fspath__(self) -> str:
75
+ """Allow the result to be used anywhere a path is accepted."""
76
+ return str(self.path)
77
+
78
+
79
+ class AlertSynthesizer:
80
+ """Resolves alert text to a WAV file on disk, synthesizing only on a miss.
81
+
82
+ Holds a :class:`~syscon_tts.engine.TTSEngine`, so loaded voice models stay
83
+ cached in memory across calls. Construct one and keep it -- building a new
84
+ instance per alert throws away the model cache and reintroduces the
85
+ multi-second cold-load cost on every message.
86
+ """
87
+
88
+ def __init__(
89
+ self,
90
+ settings: Optional[Settings] = None,
91
+ registry: Optional[VoiceRegistry] = None,
92
+ engine: Optional[TTSEngine] = None,
93
+ ):
94
+ self.settings = settings or load_settings()
95
+ self.registry = registry or VoiceRegistry.from_manifest(
96
+ self.settings.voices_manifest, self.settings.voices_dir
97
+ )
98
+ self.engine = engine or TTSEngine(self.registry)
99
+
100
+ # -- lookup ------------------------------------------------------------
101
+
102
+ def alerts_dir(self, override: Optional[Path] = None) -> Path:
103
+ return Path(override) if override else self.settings.alerts_dir
104
+
105
+ def path_for(
106
+ self, file_name: str, alerts_dir: Optional[Path] = None
107
+ ) -> Path:
108
+ """Absolute path the WAV for ``file_name`` would occupy."""
109
+ return self.alerts_dir(alerts_dir) / f"{file_name}.wav"
110
+
111
+ def exists(self, text: str, alerts_dir: Optional[Path] = None) -> bool:
112
+ """True if ``text`` already has generated audio on disk."""
113
+ return self.path_for(sanitize_file_name(text), alerts_dir).is_file()
114
+
115
+ # -- resolution --------------------------------------------------------
116
+
117
+ def ensure(
118
+ self,
119
+ text: str,
120
+ file_name: Optional[str] = None,
121
+ voice: Optional[str] = None,
122
+ alerts_dir: Optional[Path] = None,
123
+ speed: float = 1.0,
124
+ sentence_silence: float = 0.2,
125
+ force: bool = False,
126
+ ) -> AlertAudio:
127
+ """Return the WAV for ``text``, synthesizing it only if absent.
128
+
129
+ ``file_name`` overrides the derived name -- pass the APU's own
130
+ ``get_valid_filename(message)[:50]`` result to guarantee both sides
131
+ agree on the path. Set ``force`` to regenerate even on a cache hit.
132
+
133
+ Raises :class:`~syscon_tts.engine.SynthesisUnavailableError` when the
134
+ file is missing and this machine cannot run Piper.
135
+ """
136
+ name = file_name or sanitize_file_name(text)
137
+ directory = self.alerts_dir(alerts_dir)
138
+ dest = directory / f"{name}.wav"
139
+
140
+ if dest.is_file() and not force:
141
+ return AlertAudio(path=dest, file_name=name, cached=True, voice=None)
142
+
143
+ voice_id = voice or self.settings.default_voice
144
+ audio = self.engine.synthesize_wav(
145
+ text,
146
+ voice_id,
147
+ speed=speed,
148
+ sentence_silence=sentence_silence,
149
+ )
150
+ directory.mkdir(parents=True, exist_ok=True)
151
+ _atomic_write(dest, audio)
152
+ return AlertAudio(path=dest, file_name=name, cached=False, voice=voice_id)
153
+
154
+
155
+ def _atomic_write(dest: Path, payload: bytes) -> None:
156
+ """Write ``payload`` to ``dest`` without exposing a partial file.
157
+
158
+ The APU's websocket handler checks ``path.exists()`` before pushing the
159
+ audio URL to clients, so a half-written file would be served as a truncated
160
+ alert. Writing to a sibling temp file and renaming makes the file appear
161
+ only once it is complete.
162
+ """
163
+ fd, tmp_name = tempfile.mkstemp(dir=str(dest.parent), suffix=".part")
164
+ tmp = Path(tmp_name)
165
+ try:
166
+ with os.fdopen(fd, "wb") as handle:
167
+ handle.write(payload)
168
+ handle.flush()
169
+ os.fsync(handle.fileno())
170
+ os.replace(tmp, dest)
171
+ except BaseException:
172
+ tmp.unlink(missing_ok=True)
173
+ raise
174
+
175
+
176
+ # Module-level convenience wrapper. Builds a fresh synthesizer per call, so it
177
+ # is fine for one-off scripts and the CLI but wasteful in a long-running
178
+ # process -- construct an AlertSynthesizer there instead.
179
+ def ensure_alert_wav(text: str, **kwargs) -> AlertAudio:
180
+ """One-shot :meth:`AlertSynthesizer.ensure`."""
181
+ return AlertSynthesizer().ensure(text, **kwargs)
syscon_tts/api.py ADDED
@@ -0,0 +1,137 @@
1
+ """HTTP API (FastAPI).
2
+
3
+ Optional -- install with ``pip install 'syscon-tts[server]'``. The APU does not
4
+ need it: it imports :class:`~syscon_tts.alerts.AlertSynthesizer` in-process, so
5
+ Django never inherits a web-framework dependency it does not use. The server
6
+ exists for local operation and smoke-testing a provisioned host.
7
+
8
+ Endpoints:
9
+ GET /health liveness/readiness probe
10
+ GET /voices list available voice profiles
11
+ POST /synthesize render text to an audio file
12
+ GET / service info
13
+
14
+ Build the app with :func:`create_app`; ``app`` at module level is the default
15
+ instance used by uvicorn / the CLI ``serve`` command.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import Optional
21
+
22
+ from fastapi import FastAPI, HTTPException
23
+ from fastapi.responses import Response
24
+ from pydantic import BaseModel, Field
25
+
26
+ from . import __version__
27
+ from .config import Settings, load_settings
28
+ from .engine import (
29
+ SynthesisError,
30
+ SynthesisUnavailableError,
31
+ TTSEngine,
32
+ piper_available,
33
+ )
34
+ from .voices import UnknownVoiceError, VoiceNotInstalledError, VoiceRegistry
35
+
36
+
37
+ class SynthesizeRequest(BaseModel):
38
+ text: str = Field(..., description="Text to speak.")
39
+ voice: Optional[str] = Field(
40
+ None, description="Voice id. Defaults to the server's default voice."
41
+ )
42
+ format: str = Field("wav", description="Output format: 'wav' or 'mp3'.")
43
+ speed: float = Field(
44
+ 1.0, gt=0, le=4.0, description="Speed multiplier (1.0 = normal)."
45
+ )
46
+ sentence_silence: float = Field(
47
+ 0.2, ge=0, le=5.0, description="Seconds of silence between sentences."
48
+ )
49
+
50
+
51
+ def create_app(settings: Optional[Settings] = None) -> FastAPI:
52
+ settings = settings or load_settings()
53
+ registry = VoiceRegistry.from_manifest(
54
+ settings.voices_manifest, settings.voices_dir
55
+ )
56
+ engine = TTSEngine(registry)
57
+
58
+ app = FastAPI(
59
+ title="Syscon TTS",
60
+ version=__version__,
61
+ description="Offline text-to-speech service with selectable voice profiles.",
62
+ )
63
+ app.state.settings = settings
64
+ app.state.registry = registry
65
+ app.state.engine = engine
66
+
67
+ @app.get("/")
68
+ def info():
69
+ return {
70
+ "service": "Syscon TTS",
71
+ "version": __version__,
72
+ "engine": "piper",
73
+ "default_voice": settings.default_voice,
74
+ "endpoints": ["/health", "/voices", "/synthesize"],
75
+ }
76
+
77
+ @app.get("/health")
78
+ def health():
79
+ installed = sum(1 for p in registry.all() if registry.is_installed(p))
80
+ return {
81
+ "status": "ok",
82
+ "can_synthesize": piper_available(),
83
+ "voices_total": len(registry.all()),
84
+ "voices_installed": installed,
85
+ }
86
+
87
+ @app.get("/voices")
88
+ def list_voices():
89
+ return {
90
+ "default": settings.default_voice,
91
+ "voices": [
92
+ p.to_public_dict(registry.is_installed(p)) for p in registry.all()
93
+ ],
94
+ }
95
+
96
+ @app.post("/synthesize")
97
+ def synthesize(req: SynthesizeRequest):
98
+ if len(req.text) > settings.max_text_chars:
99
+ raise HTTPException(
100
+ status_code=413,
101
+ detail=f"Text exceeds max length of {settings.max_text_chars} chars.",
102
+ )
103
+ voice_id = req.voice or settings.default_voice
104
+ try:
105
+ audio, media_type = engine.synthesize(
106
+ req.text,
107
+ voice_id,
108
+ fmt=req.format,
109
+ speed=req.speed,
110
+ sentence_silence=req.sentence_silence,
111
+ )
112
+ except UnknownVoiceError as exc:
113
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
114
+ except VoiceNotInstalledError as exc:
115
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
116
+ except SynthesisUnavailableError as exc:
117
+ # 501: this host will never be able to serve the request, as
118
+ # opposed to 503's "try again once the models are installed".
119
+ raise HTTPException(status_code=501, detail=str(exc)) from exc
120
+ except SynthesisError as exc:
121
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
122
+
123
+ ext = "mp3" if media_type == "audio/mpeg" else "wav"
124
+ return Response(
125
+ content=audio,
126
+ media_type=media_type,
127
+ headers={
128
+ "Content-Disposition": f'attachment; filename="speech.{ext}"',
129
+ "X-Voice": voice_id,
130
+ },
131
+ )
132
+
133
+ return app
134
+
135
+
136
+ # Default application instance for `uvicorn syscon_tts.api:app`.
137
+ app = create_app()
syscon_tts/cli.py ADDED
@@ -0,0 +1,278 @@
1
+ """Command-line interface.
2
+
3
+ Usage examples::
4
+
5
+ syscon-tts doctor
6
+ syscon-tts download-voices # fetch every voice
7
+ syscon-tts download-voices en_us_amy # or just one
8
+ syscon-tts list-voices
9
+ syscon-tts speak --voice en_us_amy --output hello.wav "Hello from PlantStar"
10
+ syscon-tts alert "Press 4 cavity pressure exceeded."
11
+ syscon-tts serve --host 127.0.0.1 --port 5002
12
+
13
+ Every command except ``download-voices`` runs fully offline. ``doctor``,
14
+ ``list-voices``, and cache hits from ``alert`` work on any platform; ``speak``
15
+ and cache misses need Piper, and therefore Linux.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import argparse
21
+ import platform
22
+ import shutil
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ from . import __version__
27
+ from .alerts import AlertSynthesizer, InvalidAlertNameError
28
+ from .config import load_settings
29
+ from .download import DownloadError, download_voices
30
+ from .engine import SynthesisError, SynthesisUnavailableError, TTSEngine, piper_available
31
+ from .voices import VoiceError, VoiceRegistry
32
+
33
+
34
+ def _build_registry_and_engine():
35
+ settings = load_settings()
36
+ registry = VoiceRegistry.from_manifest(settings.voices_manifest, settings.voices_dir)
37
+ return settings, registry, TTSEngine(registry)
38
+
39
+
40
+ def _cmd_list_voices(args: argparse.Namespace) -> int:
41
+ settings, registry, _ = _build_registry_and_engine()
42
+ print(f"Default voice: {settings.default_voice}")
43
+ print(f"Voices dir: {settings.voices_dir}\n")
44
+ header = f"{'ID':<22} {'LANGUAGE':<8} {'GENDER':<10} {'INSTALLED':<9} NAME"
45
+ print(header)
46
+ print("-" * len(header))
47
+ for p in registry.all():
48
+ installed = "yes" if registry.is_installed(p) else "no"
49
+ print(f"{p.id:<22} {p.language:<8} {p.gender:<10} {installed:<9} {p.name}")
50
+ return 0
51
+
52
+
53
+ def _cmd_doctor(args: argparse.Namespace) -> int:
54
+ """Report whether this machine can generate audio, and why not if it can't."""
55
+ settings = load_settings()
56
+ print(f"syscon-tts {__version__}")
57
+ print(f" platform {platform.system()} {platform.machine()}")
58
+ print(f" python {platform.python_version()}")
59
+ print(f" manifest {settings.voices_manifest}")
60
+ print(f" voices dir {settings.voices_dir}")
61
+ print(f" alerts dir {settings.alerts_dir}")
62
+ print(f" ffmpeg (mp3) {'yes' if shutil.which('ffmpeg') else 'no'}")
63
+
64
+ has_piper = piper_available()
65
+ print(f" piper (synthesis) {'yes' if has_piper else 'no'}")
66
+
67
+ try:
68
+ registry = VoiceRegistry.from_manifest(
69
+ settings.voices_manifest, settings.voices_dir
70
+ )
71
+ except VoiceError as exc:
72
+ print(f"\nERROR: {exc}", file=sys.stderr)
73
+ return 1
74
+
75
+ installed = [p for p in registry.all() if registry.is_installed(p)]
76
+ print(f" voices installed {len(installed)}/{len(registry.all())}")
77
+
78
+ problems = []
79
+ if not has_piper:
80
+ if platform.system() == "Linux":
81
+ problems.append(
82
+ "Piper is missing on a Linux host. Reinstall with "
83
+ "'pip install syscon-tts[piper]' using Python 3.9-3.11."
84
+ )
85
+ else:
86
+ print(
87
+ f"\nNote: {platform.system()} cannot generate audio -- Piper ships "
88
+ "Linux-only wheels. Previously generated WAVs still play and serve "
89
+ "normally from the alerts directory."
90
+ )
91
+ if not installed:
92
+ problems.append(
93
+ "No voice models on disk. Run 'syscon-tts download-voices'."
94
+ )
95
+
96
+ if problems:
97
+ # stdout is block-buffered when piped while stderr is not, so the
98
+ # report would otherwise appear after the problems it refers to.
99
+ sys.stdout.flush()
100
+ print("\nProblems:", file=sys.stderr)
101
+ for problem in problems:
102
+ print(f" - {problem}", file=sys.stderr)
103
+ return 1
104
+
105
+ print("\nReady.")
106
+ return 0
107
+
108
+
109
+ def _cmd_download_voices(args: argparse.Namespace) -> int:
110
+ settings, registry, _ = _build_registry_and_engine()
111
+ print(f"Downloading into {settings.voices_dir}")
112
+ try:
113
+ results = download_voices(
114
+ registry,
115
+ settings.voices_dir,
116
+ voice_ids=args.voice_ids,
117
+ force=args.force,
118
+ on_progress=print,
119
+ )
120
+ except (DownloadError, VoiceError) as exc:
121
+ print(f"error: {exc}", file=sys.stderr)
122
+ return 1
123
+ fetched = sum(1 for r in results if not r.skipped)
124
+ total_mb = sum(r.size for r in results if not r.skipped) / (1024 * 1024)
125
+ print(f"Done. {fetched} file(s) downloaded ({total_mb:.1f} MB).")
126
+ return 0
127
+
128
+
129
+ def _read_text(args: argparse.Namespace) -> str:
130
+ if args.text_file:
131
+ return Path(args.text_file).read_text(encoding="utf-8")
132
+ if args.text:
133
+ return args.text
134
+ # Fall back to stdin (allows piping: `echo hi | syscon-tts speak ...`).
135
+ if not sys.stdin.isatty():
136
+ return sys.stdin.read()
137
+ raise SystemExit("No text provided. Pass TEXT, --text-file, or pipe via stdin.")
138
+
139
+
140
+ def _cmd_speak(args: argparse.Namespace) -> int:
141
+ settings, _, engine = _build_registry_and_engine()
142
+ text = _read_text(args)
143
+ voice_id = args.voice or settings.default_voice
144
+ fmt = args.format or ("mp3" if args.output.lower().endswith(".mp3") else "wav")
145
+ try:
146
+ audio, _ = engine.synthesize(
147
+ text,
148
+ voice_id,
149
+ fmt=fmt,
150
+ speed=args.speed,
151
+ sentence_silence=args.sentence_silence,
152
+ )
153
+ except (VoiceError, SynthesisError) as exc:
154
+ print(f"error: {exc}", file=sys.stderr)
155
+ return 1
156
+ Path(args.output).write_bytes(audio)
157
+ print(f"Wrote {len(audio)} bytes to {args.output} (voice={voice_id}, format={fmt})")
158
+ return 0
159
+
160
+
161
+ def _cmd_alert(args: argparse.Namespace) -> int:
162
+ """Resolve alert text the same way the APU does: cache first, then synthesize."""
163
+ try:
164
+ synth = AlertSynthesizer()
165
+ result = synth.ensure(
166
+ args.text,
167
+ file_name=args.file_name,
168
+ voice=args.voice,
169
+ alerts_dir=Path(args.alerts_dir) if args.alerts_dir else None,
170
+ force=args.force,
171
+ )
172
+ except InvalidAlertNameError as exc:
173
+ print(f"error: {exc}", file=sys.stderr)
174
+ return 1
175
+ except SynthesisUnavailableError as exc:
176
+ print(f"error: {exc}", file=sys.stderr)
177
+ return 2
178
+ except (VoiceError, SynthesisError) as exc:
179
+ print(f"error: {exc}", file=sys.stderr)
180
+ return 1
181
+ state = "cached" if result.cached else f"generated (voice={result.voice})"
182
+ print(f"{result.path} [{state}]")
183
+ return 0
184
+
185
+
186
+ def _cmd_serve(args: argparse.Namespace) -> int:
187
+ try:
188
+ import uvicorn
189
+ except ImportError:
190
+ print(
191
+ "error: the HTTP server needs extra dependencies. "
192
+ "Install them with: pip install 'syscon-tts[server]'",
193
+ file=sys.stderr,
194
+ )
195
+ return 1
196
+ settings = load_settings()
197
+ host = args.host or settings.host
198
+ port = args.port or settings.port
199
+ print(f"Starting Syscon TTS on http://{host}:{port}")
200
+ uvicorn.run("syscon_tts.api:app", host=host, port=port, workers=args.workers)
201
+ return 0
202
+
203
+
204
+ def build_parser() -> argparse.ArgumentParser:
205
+ parser = argparse.ArgumentParser(
206
+ prog="syscon-tts",
207
+ description="Offline text-to-speech for the PlantStar APU.",
208
+ )
209
+ parser.add_argument("--version", action="version",
210
+ version=f"syscon-tts {__version__}")
211
+ sub = parser.add_subparsers(dest="command", required=True)
212
+
213
+ p_doctor = sub.add_parser(
214
+ "doctor", help="Report platform, Piper, and voice-model status."
215
+ )
216
+ p_doctor.set_defaults(func=_cmd_doctor)
217
+
218
+ p_list = sub.add_parser("list-voices", help="List available voice profiles.")
219
+ p_list.set_defaults(func=_cmd_list_voices)
220
+
221
+ p_dl = sub.add_parser(
222
+ "download-voices", help="Download voice models (needs internet)."
223
+ )
224
+ p_dl.add_argument("voice_ids", nargs="*",
225
+ help="Voice ids to fetch (default: all in the manifest).")
226
+ p_dl.add_argument("--force", action="store_true",
227
+ help="Re-download even if the file is already present.")
228
+ p_dl.set_defaults(func=_cmd_download_voices)
229
+
230
+ p_speak = sub.add_parser("speak", help="Synthesize text to an audio file.")
231
+ p_speak.add_argument("text", nargs="?", help="Text to speak.")
232
+ p_speak.add_argument("-v", "--voice", help="Voice id (see list-voices).")
233
+ p_speak.add_argument("-o", "--output", required=True, help="Output file path.")
234
+ p_speak.add_argument("-f", "--format", choices=["wav", "mp3"],
235
+ help="Output format (default: inferred from --output).")
236
+ p_speak.add_argument("--text-file", help="Read text from a file instead of arg.")
237
+ p_speak.add_argument("--speed", type=float, default=1.0,
238
+ help="Speed multiplier (1.0 = normal).")
239
+ p_speak.add_argument("--sentence-silence", type=float, default=0.2,
240
+ help="Seconds of silence between sentences.")
241
+ p_speak.set_defaults(func=_cmd_speak)
242
+
243
+ p_alert = sub.add_parser(
244
+ "alert",
245
+ help="Resolve alert text to a WAV in the alerts dir (cache first).",
246
+ )
247
+ p_alert.add_argument("text", help="Alert message text.")
248
+ p_alert.add_argument("-v", "--voice", help="Voice id (see list-voices).")
249
+ p_alert.add_argument("--file-name",
250
+ help="Override the derived file name (no extension).")
251
+ p_alert.add_argument("--alerts-dir",
252
+ help="Override the configured alerts directory.")
253
+ p_alert.add_argument("--force", action="store_true",
254
+ help="Regenerate even if the WAV already exists.")
255
+ p_alert.set_defaults(func=_cmd_alert)
256
+
257
+ p_serve = sub.add_parser("serve", help="Run the HTTP API server.")
258
+ p_serve.add_argument("--host", help="Bind host (default from config).")
259
+ p_serve.add_argument("--port", type=int, help="Bind port (default from config).")
260
+ p_serve.add_argument("--workers", type=int, default=1,
261
+ help="Number of worker processes.")
262
+ p_serve.set_defaults(func=_cmd_serve)
263
+
264
+ return parser
265
+
266
+
267
+ def main(argv: list[str] | None = None) -> int:
268
+ parser = build_parser()
269
+ args = parser.parse_args(argv)
270
+ try:
271
+ return args.func(args)
272
+ except VoiceError as exc:
273
+ print(f"error: {exc}", file=sys.stderr)
274
+ return 1
275
+
276
+
277
+ if __name__ == "__main__":
278
+ raise SystemExit(main())