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 +65 -0
- syscon_tts/alerts.py +181 -0
- syscon_tts/api.py +137 -0
- syscon_tts/cli.py +278 -0
- syscon_tts/config.py +117 -0
- syscon_tts/data/voices.json +65 -0
- syscon_tts/download.py +124 -0
- syscon_tts/engine.py +189 -0
- syscon_tts/voices.py +109 -0
- syscon_tts-0.0.1.dist-info/METADATA +267 -0
- syscon_tts-0.0.1.dist-info/RECORD +15 -0
- syscon_tts-0.0.1.dist-info/WHEEL +5 -0
- syscon_tts-0.0.1.dist-info/entry_points.txt +2 -0
- syscon_tts-0.0.1.dist-info/licenses/LICENSE +21 -0
- syscon_tts-0.0.1.dist-info/top_level.txt +1 -0
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())
|