srtspeak 0.1.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.
- srtspeak/__init__.py +3 -0
- srtspeak/__main__.py +8 -0
- srtspeak/cli.py +610 -0
- srtspeak/core/__init__.py +1 -0
- srtspeak/core/cache.py +59 -0
- srtspeak/core/cancel.py +23 -0
- srtspeak/core/ffmpeg_resolve.py +51 -0
- srtspeak/core/fit.py +279 -0
- srtspeak/core/ja_yomi.py +81 -0
- srtspeak/core/languages.py +123 -0
- srtspeak/core/models.py +47 -0
- srtspeak/core/pipeline.py +513 -0
- srtspeak/core/progress.py +77 -0
- srtspeak/core/report.py +27 -0
- srtspeak/core/secrets.py +39 -0
- srtspeak/core/srt_parser.py +241 -0
- srtspeak/core/timeline.py +75 -0
- srtspeak/core/tts_xai.py +259 -0
- srtspeak/core/util.py +70 -0
- srtspeak/core/voices.py +93 -0
- srtspeak/gui/__init__.py +1 -0
- srtspeak/gui/app.py +493 -0
- srtspeak/i18n.py +155 -0
- srtspeak/locales/en/LC_MESSAGES/srtspeak.mo +0 -0
- srtspeak/locales/en/LC_MESSAGES/srtspeak.po +711 -0
- srtspeak/locales/ja/LC_MESSAGES/srtspeak.mo +0 -0
- srtspeak/locales/ja/LC_MESSAGES/srtspeak.po +676 -0
- srtspeak/locales/srtspeak.pot +646 -0
- srtspeak-0.1.0.dist-info/METADATA +434 -0
- srtspeak-0.1.0.dist-info/RECORD +34 -0
- srtspeak-0.1.0.dist-info/WHEEL +5 -0
- srtspeak-0.1.0.dist-info/entry_points.txt +2 -0
- srtspeak-0.1.0.dist-info/licenses/LICENSE +201 -0
- srtspeak-0.1.0.dist-info/top_level.txt +1 -0
srtspeak/__init__.py
ADDED
srtspeak/__main__.py
ADDED
srtspeak/cli.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""srtspeak CLI (argparse)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import signal
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Sequence
|
|
10
|
+
|
|
11
|
+
from srtspeak.core.cancel import BuildCancelled, CancellationToken
|
|
12
|
+
from srtspeak.core.ffmpeg_resolve import (
|
|
13
|
+
FFmpegNotFoundError,
|
|
14
|
+
ffmpeg_version,
|
|
15
|
+
resolve_ffmpeg,
|
|
16
|
+
)
|
|
17
|
+
from srtspeak.core.languages import (
|
|
18
|
+
guess_lang_from_filename,
|
|
19
|
+
list_language_options,
|
|
20
|
+
resolve_language_code,
|
|
21
|
+
)
|
|
22
|
+
from srtspeak.core.models import BuildConfig
|
|
23
|
+
from srtspeak.core.pipeline import BuildService
|
|
24
|
+
from srtspeak.core.progress import ProgressEvent
|
|
25
|
+
from srtspeak.core.report import write_json
|
|
26
|
+
from srtspeak.core.secrets import api_key_status, resolve_api_key
|
|
27
|
+
from srtspeak.core.tts_xai import TtsError, fetch_voices, merge_voice_catalog
|
|
28
|
+
from srtspeak.core.util import resolve_out_dir
|
|
29
|
+
from srtspeak.core.voices import (
|
|
30
|
+
builtin_voice_ids,
|
|
31
|
+
list_builtin_voices,
|
|
32
|
+
resolve_voice_id,
|
|
33
|
+
validate_voice_id,
|
|
34
|
+
)
|
|
35
|
+
from srtspeak.i18n import _, setup_i18n
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class _CliState:
|
|
39
|
+
quiet: bool = False
|
|
40
|
+
verbose: bool = False
|
|
41
|
+
last_line_len: int = 0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_STATE = _CliState()
|
|
45
|
+
_CANCEL = CancellationToken()
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _progress_printer(ev: ProgressEvent) -> None:
|
|
49
|
+
if _STATE.quiet:
|
|
50
|
+
return
|
|
51
|
+
line = (
|
|
52
|
+
f"[{ev.percent:5.1f}%] {ev.stage:8s} "
|
|
53
|
+
f"{ev.current}/{ev.total}"
|
|
54
|
+
+ (f" {ev.lang}" if ev.lang else "")
|
|
55
|
+
+ (f" cue={ev.cue_index}" if ev.cue_index is not None else "")
|
|
56
|
+
+ (f" {ev.message}" if ev.message and _STATE.verbose else "")
|
|
57
|
+
)
|
|
58
|
+
if _STATE.verbose:
|
|
59
|
+
print(line, flush=True)
|
|
60
|
+
else:
|
|
61
|
+
pad = max(0, _STATE.last_line_len - len(line))
|
|
62
|
+
sys.stdout.write("\r" + line + (" " * pad))
|
|
63
|
+
sys.stdout.flush()
|
|
64
|
+
_STATE.last_line_len = len(line)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _finish_progress_line() -> None:
|
|
68
|
+
if not _STATE.quiet and not _STATE.verbose and _STATE.last_line_len:
|
|
69
|
+
sys.stdout.write("\n")
|
|
70
|
+
sys.stdout.flush()
|
|
71
|
+
_STATE.last_line_len = 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _install_sigint() -> None:
|
|
75
|
+
def _handler(signum: int, frame: object) -> None: # noqa: ARG001
|
|
76
|
+
_CANCEL.cancel()
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
signal.signal(signal.SIGINT, _handler)
|
|
80
|
+
except Exception:
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _parse_voice_map(values: list[str] | None) -> dict[str, str] | str | None:
|
|
85
|
+
"""Return single voice str, or lang->voice map, or None."""
|
|
86
|
+
if not values:
|
|
87
|
+
return None
|
|
88
|
+
if len(values) == 1 and "=" not in values[0]:
|
|
89
|
+
return values[0]
|
|
90
|
+
out: dict[str, str] = {}
|
|
91
|
+
single: str | None = None
|
|
92
|
+
for v in values:
|
|
93
|
+
if "=" in v:
|
|
94
|
+
lang, vid = v.split("=", 1)
|
|
95
|
+
out[lang.strip().lower()] = vid.strip()
|
|
96
|
+
else:
|
|
97
|
+
single = v.strip()
|
|
98
|
+
if out and single:
|
|
99
|
+
out["*"] = single
|
|
100
|
+
if out:
|
|
101
|
+
return out
|
|
102
|
+
return single
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _voice_for_lang(
|
|
106
|
+
spec: dict[str, str] | str | None,
|
|
107
|
+
lang: str,
|
|
108
|
+
) -> str:
|
|
109
|
+
if spec is None:
|
|
110
|
+
return resolve_voice_id(None)
|
|
111
|
+
if isinstance(spec, str):
|
|
112
|
+
return resolve_voice_id(spec)
|
|
113
|
+
if lang in spec:
|
|
114
|
+
return resolve_voice_id(spec[lang])
|
|
115
|
+
if "*" in spec:
|
|
116
|
+
return resolve_voice_id(spec["*"])
|
|
117
|
+
return resolve_voice_id(None)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _parse_map_args(values: list[str] | None) -> dict[str, Path]:
|
|
121
|
+
result: dict[str, Path] = {}
|
|
122
|
+
if not values:
|
|
123
|
+
return result
|
|
124
|
+
for item in values:
|
|
125
|
+
if "=" not in item:
|
|
126
|
+
raise SystemExit(
|
|
127
|
+
_("invalid --map (expected lang=path): {item}").format(item=item)
|
|
128
|
+
)
|
|
129
|
+
lang, path = item.split("=", 1)
|
|
130
|
+
result[lang.strip().lower()] = Path(path.strip())
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _parse_lang_code_map(values: list[str] | None) -> dict[str, str]:
|
|
135
|
+
result: dict[str, str] = {}
|
|
136
|
+
if not values:
|
|
137
|
+
return result
|
|
138
|
+
for item in values:
|
|
139
|
+
if "=" not in item:
|
|
140
|
+
raise SystemExit(
|
|
141
|
+
_(
|
|
142
|
+
"invalid --language-code (expected lang=code): {item}"
|
|
143
|
+
).format(item=item)
|
|
144
|
+
)
|
|
145
|
+
lang, code = item.split("=", 1)
|
|
146
|
+
result[lang.strip().lower()] = code.strip()
|
|
147
|
+
return result
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def cmd_languages(_args: argparse.Namespace) -> int:
|
|
151
|
+
for opt in list_language_options():
|
|
152
|
+
aliases = ", ".join(opt.aliases) if opt.aliases else "-"
|
|
153
|
+
label = _(opt.label)
|
|
154
|
+
print(f"{opt.code:8s} {label:28s} aliases={aliases}")
|
|
155
|
+
return 0
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def cmd_voices(args: argparse.Namespace) -> int:
|
|
159
|
+
live = None
|
|
160
|
+
key = resolve_api_key(prompt=False)
|
|
161
|
+
if key:
|
|
162
|
+
try:
|
|
163
|
+
live = fetch_voices(api_key=key)
|
|
164
|
+
except TtsError as exc:
|
|
165
|
+
print(
|
|
166
|
+
_("warning: voices API failed ({exc}); using builtin").format(
|
|
167
|
+
exc=exc
|
|
168
|
+
),
|
|
169
|
+
file=sys.stderr,
|
|
170
|
+
)
|
|
171
|
+
voices = merge_voice_catalog(list_builtin_voices(), live)
|
|
172
|
+
filt = (args.voice_filter or "").strip().lower()
|
|
173
|
+
for v in voices:
|
|
174
|
+
if filt and filt not in {t.lower() for t in v.tags} and filt not in v.voice_id:
|
|
175
|
+
if filt not in v.description.lower() and filt not in v.name.lower():
|
|
176
|
+
continue
|
|
177
|
+
tags = ",".join(v.tags) if v.tags else ""
|
|
178
|
+
print(f"{v.voice_id:12s} {v.name:16s} {_(v.description)} [{tags}]")
|
|
179
|
+
return 0
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def cmd_doctor(_args: argparse.Namespace) -> int:
|
|
183
|
+
print(f"XAI_API_KEY: {api_key_status()}")
|
|
184
|
+
try:
|
|
185
|
+
tools = resolve_ffmpeg()
|
|
186
|
+
ver = ffmpeg_version(tools.ffmpeg)
|
|
187
|
+
print(f"ffmpeg: {tools.ffmpeg}")
|
|
188
|
+
print(_(" source: {source}").format(source=tools.source))
|
|
189
|
+
print(_(" version: {version}").format(version=ver))
|
|
190
|
+
none_label = _("(none)")
|
|
191
|
+
print(f"ffprobe: {tools.ffprobe or none_label}")
|
|
192
|
+
except FFmpegNotFoundError as exc:
|
|
193
|
+
print(_("ffmpeg: MISSING ({exc})").format(exc=exc))
|
|
194
|
+
try:
|
|
195
|
+
from srtspeak.core.ja_yomi import kanjiconv_available
|
|
196
|
+
|
|
197
|
+
if kanjiconv_available():
|
|
198
|
+
status = _("available")
|
|
199
|
+
else:
|
|
200
|
+
status = _('not available (pip install -e ".[ja]")')
|
|
201
|
+
print(f"kanjiconv: {status}")
|
|
202
|
+
except Exception as exc:
|
|
203
|
+
print(_("kanjiconv: error ({exc})").format(exc=exc))
|
|
204
|
+
try:
|
|
205
|
+
import PySide6 # type: ignore # noqa: F401
|
|
206
|
+
|
|
207
|
+
print(_("PySide6: available"))
|
|
208
|
+
except Exception:
|
|
209
|
+
print(_("PySide6: not installed (optional extra [gui])"))
|
|
210
|
+
return 0
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def cmd_gui(_args: argparse.Namespace) -> int:
|
|
214
|
+
try:
|
|
215
|
+
from srtspeak.gui.app import main as gui_main
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
print(
|
|
218
|
+
_("GUI unavailable: {exc}\nInstall with: pip install -e .[gui]").format(
|
|
219
|
+
exc=exc
|
|
220
|
+
),
|
|
221
|
+
file=sys.stderr,
|
|
222
|
+
)
|
|
223
|
+
return 2
|
|
224
|
+
return int(gui_main() or 0)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _build_one(
|
|
228
|
+
*,
|
|
229
|
+
srt_path: Path,
|
|
230
|
+
lang: str,
|
|
231
|
+
language_code: str,
|
|
232
|
+
out_dir: Path,
|
|
233
|
+
voice_id: str,
|
|
234
|
+
args: argparse.Namespace,
|
|
235
|
+
api_key: str | None,
|
|
236
|
+
) -> dict:
|
|
237
|
+
cfg = BuildConfig(
|
|
238
|
+
srt_path=srt_path,
|
|
239
|
+
lang=lang,
|
|
240
|
+
language_code=language_code,
|
|
241
|
+
out_dir=out_dir,
|
|
242
|
+
voice_id=voice_id,
|
|
243
|
+
short_mode=args.short_mode,
|
|
244
|
+
limit=args.limit,
|
|
245
|
+
dry_run=bool(args.dry_run),
|
|
246
|
+
keep_raw=not bool(getattr(args, "no_keep_raw", False)),
|
|
247
|
+
also_mp3=bool(args.also_mp3),
|
|
248
|
+
jobs=1,
|
|
249
|
+
tail_pad_ms=int(args.tail_pad_ms),
|
|
250
|
+
work_dir=Path(args.work_dir) if args.work_dir else None,
|
|
251
|
+
max_speed=args.max_speed,
|
|
252
|
+
ja_yomi=bool(getattr(args, "ja_yomi", True)),
|
|
253
|
+
)
|
|
254
|
+
cfg.validate()
|
|
255
|
+
# validate voice against builtin when dry-run or no live list
|
|
256
|
+
try:
|
|
257
|
+
validate_voice_id(voice_id, known_ids=builtin_voice_ids())
|
|
258
|
+
except ValueError:
|
|
259
|
+
if cfg.dry_run:
|
|
260
|
+
raise SystemExit(
|
|
261
|
+
_("unknown voice_id: {voice_id}").format(voice_id=voice_id)
|
|
262
|
+
) from None
|
|
263
|
+
# full build: still attempt; API may accept custom
|
|
264
|
+
print(
|
|
265
|
+
_(
|
|
266
|
+
"warning: voice_id {voice_id!r} not in builtin; will try API"
|
|
267
|
+
).format(voice_id=voice_id),
|
|
268
|
+
file=sys.stderr,
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
svc = BuildService(
|
|
272
|
+
cfg,
|
|
273
|
+
api_key=api_key,
|
|
274
|
+
progress_cb=None if _STATE.quiet else _progress_printer,
|
|
275
|
+
cancel_token=_CANCEL,
|
|
276
|
+
)
|
|
277
|
+
try:
|
|
278
|
+
report = svc.run()
|
|
279
|
+
except BuildCancelled:
|
|
280
|
+
_finish_progress_line()
|
|
281
|
+
print(_("cancelled"), file=sys.stderr)
|
|
282
|
+
raise SystemExit(130) from None
|
|
283
|
+
except TtsError as exc:
|
|
284
|
+
_finish_progress_line()
|
|
285
|
+
print(_("TTS error: {exc}").format(exc=exc), file=sys.stderr)
|
|
286
|
+
raise SystemExit(1) from None
|
|
287
|
+
except (ValueError, FileNotFoundError, FFmpegNotFoundError) as exc:
|
|
288
|
+
_finish_progress_line()
|
|
289
|
+
print(_("error: {exc}").format(exc=exc), file=sys.stderr)
|
|
290
|
+
code = 2 if isinstance(exc, (ValueError, FFmpegNotFoundError)) else 1
|
|
291
|
+
raise SystemExit(code) from None
|
|
292
|
+
_finish_progress_line()
|
|
293
|
+
return report
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def cmd_build(args: argparse.Namespace) -> int:
|
|
297
|
+
srt = Path(args.srt)
|
|
298
|
+
if not srt.is_file():
|
|
299
|
+
print(_("SRT not found: {path}").format(path=srt), file=sys.stderr)
|
|
300
|
+
return 2
|
|
301
|
+
|
|
302
|
+
lang = args.lang or guess_lang_from_filename(srt) or "ja"
|
|
303
|
+
try:
|
|
304
|
+
language_code = resolve_language_code(lang=lang, explicit=args.language_code)
|
|
305
|
+
except ValueError as exc:
|
|
306
|
+
print(_("error: {exc}").format(exc=exc), file=sys.stderr)
|
|
307
|
+
return 2
|
|
308
|
+
|
|
309
|
+
voice_spec = _parse_voice_map(args.voice_id)
|
|
310
|
+
voice_id = _voice_for_lang(voice_spec, lang)
|
|
311
|
+
|
|
312
|
+
out_dir = resolve_out_dir(args.out, lang)
|
|
313
|
+
|
|
314
|
+
api_key = None
|
|
315
|
+
if not args.dry_run:
|
|
316
|
+
api_key = resolve_api_key(prompt=True)
|
|
317
|
+
if not api_key:
|
|
318
|
+
print(_("XAI_API_KEY is not set"), file=sys.stderr)
|
|
319
|
+
return 2
|
|
320
|
+
|
|
321
|
+
report = _build_one(
|
|
322
|
+
srt_path=srt,
|
|
323
|
+
lang=lang,
|
|
324
|
+
language_code=language_code,
|
|
325
|
+
out_dir=out_dir,
|
|
326
|
+
voice_id=voice_id,
|
|
327
|
+
args=args,
|
|
328
|
+
api_key=api_key,
|
|
329
|
+
)
|
|
330
|
+
print(
|
|
331
|
+
_(
|
|
332
|
+
"status={status} lang={lang} cues={processed}/{total} track={track}"
|
|
333
|
+
).format(
|
|
334
|
+
status=report.get("status"),
|
|
335
|
+
lang=report.get("lang"),
|
|
336
|
+
processed=report.get("processed_count"),
|
|
337
|
+
total=report.get("cue_count"),
|
|
338
|
+
track=report.get("track_path", report.get("track")),
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
if report.get("status") == "cancelled":
|
|
342
|
+
return 130
|
|
343
|
+
return 0
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def cmd_build_all(args: argparse.Namespace) -> int:
|
|
347
|
+
mapping = _parse_map_args(args.map)
|
|
348
|
+
if not mapping:
|
|
349
|
+
print(
|
|
350
|
+
_("build-all requires at least one --map lang=path"),
|
|
351
|
+
file=sys.stderr,
|
|
352
|
+
)
|
|
353
|
+
return 2
|
|
354
|
+
code_map = _parse_lang_code_map(args.language_code)
|
|
355
|
+
voice_spec = _parse_voice_map(args.voice_id)
|
|
356
|
+
|
|
357
|
+
api_key = None
|
|
358
|
+
if not args.dry_run:
|
|
359
|
+
api_key = resolve_api_key(prompt=True)
|
|
360
|
+
if not api_key:
|
|
361
|
+
print(_("XAI_API_KEY is not set"), file=sys.stderr)
|
|
362
|
+
return 2
|
|
363
|
+
|
|
364
|
+
out_root = Path(args.out) if args.out else Path("out")
|
|
365
|
+
# summary.json stays at out root; per-lang artifacts under {root}/{lang}/
|
|
366
|
+
reports: dict[str, str] = {}
|
|
367
|
+
langs: list[str] = []
|
|
368
|
+
overall_status = "ok"
|
|
369
|
+
|
|
370
|
+
for lang, srt in mapping.items():
|
|
371
|
+
if not srt.is_file():
|
|
372
|
+
print(
|
|
373
|
+
_("SRT not found for {lang}: {path}").format(lang=lang, path=srt),
|
|
374
|
+
file=sys.stderr,
|
|
375
|
+
)
|
|
376
|
+
return 2
|
|
377
|
+
explicit = code_map.get(lang)
|
|
378
|
+
try:
|
|
379
|
+
language_code = resolve_language_code(lang=lang, explicit=explicit)
|
|
380
|
+
except ValueError as exc:
|
|
381
|
+
print(_("error: {exc}").format(exc=exc), file=sys.stderr)
|
|
382
|
+
return 2
|
|
383
|
+
voice_id = _voice_for_lang(voice_spec, lang)
|
|
384
|
+
out_dir = resolve_out_dir(out_root, lang)
|
|
385
|
+
report = _build_one(
|
|
386
|
+
srt_path=srt,
|
|
387
|
+
lang=lang,
|
|
388
|
+
language_code=language_code,
|
|
389
|
+
out_dir=out_dir,
|
|
390
|
+
voice_id=voice_id,
|
|
391
|
+
args=args,
|
|
392
|
+
api_key=api_key,
|
|
393
|
+
)
|
|
394
|
+
langs.append(lang)
|
|
395
|
+
reports[lang] = str(out_dir / "report.json")
|
|
396
|
+
if report.get("status") == "cancelled":
|
|
397
|
+
overall_status = "cancelled"
|
|
398
|
+
break
|
|
399
|
+
if report.get("status") not in ("ok", "dry_run"):
|
|
400
|
+
overall_status = str(report.get("status"))
|
|
401
|
+
|
|
402
|
+
summary = {
|
|
403
|
+
"status": overall_status,
|
|
404
|
+
"languages": langs,
|
|
405
|
+
"reports": reports,
|
|
406
|
+
}
|
|
407
|
+
write_json(out_root / "summary.json", summary)
|
|
408
|
+
print(
|
|
409
|
+
_("summary status={status} languages={languages}").format(
|
|
410
|
+
status=overall_status,
|
|
411
|
+
languages=langs,
|
|
412
|
+
)
|
|
413
|
+
)
|
|
414
|
+
if overall_status == "cancelled":
|
|
415
|
+
return 130
|
|
416
|
+
return 0
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def cmd_dry_run(args: argparse.Namespace) -> int:
|
|
420
|
+
args.dry_run = True
|
|
421
|
+
return cmd_build(args)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
425
|
+
p = argparse.ArgumentParser(
|
|
426
|
+
prog="srtspeak",
|
|
427
|
+
description=_(
|
|
428
|
+
"SRT multilingual TTS with forced cue-window timing (xAI Grok)"
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
p.add_argument("--verbose", action="store_true", help=_("verbose progress"))
|
|
432
|
+
p.add_argument("--quiet", action="store_true", help=_("suppress progress"))
|
|
433
|
+
p.add_argument(
|
|
434
|
+
"--locale",
|
|
435
|
+
default=None,
|
|
436
|
+
help=_(
|
|
437
|
+
"UI locale (en/ja). Default: SRTSPEAK_LOCALE / system / en"
|
|
438
|
+
),
|
|
439
|
+
)
|
|
440
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
441
|
+
|
|
442
|
+
def add_build_flags(sp: argparse.ArgumentParser, *, multi: bool) -> None:
|
|
443
|
+
if multi:
|
|
444
|
+
sp.add_argument(
|
|
445
|
+
"--map",
|
|
446
|
+
action="append",
|
|
447
|
+
help=_("lang=path (repeatable)"),
|
|
448
|
+
)
|
|
449
|
+
sp.add_argument(
|
|
450
|
+
"--language-code",
|
|
451
|
+
action="append",
|
|
452
|
+
default=None,
|
|
453
|
+
help=_("lang=BCP47 (repeatable)"),
|
|
454
|
+
)
|
|
455
|
+
else:
|
|
456
|
+
sp.add_argument("--srt", required=True, help=_("input SRT path"))
|
|
457
|
+
sp.add_argument(
|
|
458
|
+
"--lang",
|
|
459
|
+
default=None,
|
|
460
|
+
help=_("internal lang key ja/en/pt"),
|
|
461
|
+
)
|
|
462
|
+
sp.add_argument(
|
|
463
|
+
"--language-code",
|
|
464
|
+
default=None,
|
|
465
|
+
help=_("BCP-47 language code override"),
|
|
466
|
+
)
|
|
467
|
+
sp.add_argument(
|
|
468
|
+
"--out",
|
|
469
|
+
default=None,
|
|
470
|
+
help=_(
|
|
471
|
+
"output root directory (lang id always appended; default: out)"
|
|
472
|
+
),
|
|
473
|
+
)
|
|
474
|
+
sp.add_argument(
|
|
475
|
+
"--work-dir",
|
|
476
|
+
default=None,
|
|
477
|
+
help=_("work directory root"),
|
|
478
|
+
)
|
|
479
|
+
sp.add_argument(
|
|
480
|
+
"--voice-id",
|
|
481
|
+
action="append",
|
|
482
|
+
default=None,
|
|
483
|
+
help=_("voice id or lang=voice (repeatable)"),
|
|
484
|
+
)
|
|
485
|
+
sp.add_argument(
|
|
486
|
+
"--short-mode",
|
|
487
|
+
choices=("pad", "stretch"),
|
|
488
|
+
default="pad",
|
|
489
|
+
help=_("short cue fit mode (default: pad)"),
|
|
490
|
+
)
|
|
491
|
+
sp.add_argument(
|
|
492
|
+
"--limit",
|
|
493
|
+
type=int,
|
|
494
|
+
default=None,
|
|
495
|
+
help=_("process only the first N cues"),
|
|
496
|
+
)
|
|
497
|
+
sp.add_argument(
|
|
498
|
+
"--also-mp3",
|
|
499
|
+
action="store_true",
|
|
500
|
+
help=_("also write MP3 alongside WAV"),
|
|
501
|
+
)
|
|
502
|
+
sp.add_argument(
|
|
503
|
+
"--tail-pad-ms",
|
|
504
|
+
type=int,
|
|
505
|
+
default=0,
|
|
506
|
+
help=_("extra silence after last cue (ms)"),
|
|
507
|
+
)
|
|
508
|
+
sp.add_argument(
|
|
509
|
+
"--max-speed",
|
|
510
|
+
type=float,
|
|
511
|
+
default=None,
|
|
512
|
+
help=_("maximum atempo speed factor"),
|
|
513
|
+
)
|
|
514
|
+
sp.add_argument(
|
|
515
|
+
"--dry-run",
|
|
516
|
+
action="store_true",
|
|
517
|
+
help=_("estimate only, no TTS"),
|
|
518
|
+
)
|
|
519
|
+
sp.add_argument(
|
|
520
|
+
"--jobs",
|
|
521
|
+
type=int,
|
|
522
|
+
default=1,
|
|
523
|
+
help=_("MVP must be 1"),
|
|
524
|
+
)
|
|
525
|
+
sp.add_argument(
|
|
526
|
+
"--ja-yomi",
|
|
527
|
+
action=argparse.BooleanOptionalAction,
|
|
528
|
+
default=True,
|
|
529
|
+
help=_(
|
|
530
|
+
"JA only: convert kanji to hiragana via kanjiconv before TTS "
|
|
531
|
+
"(default: on)"
|
|
532
|
+
),
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
sp_build = sub.add_parser("build", help=_("build one language"))
|
|
536
|
+
add_build_flags(sp_build, multi=False)
|
|
537
|
+
sp_build.set_defaults(func=cmd_build)
|
|
538
|
+
|
|
539
|
+
sp_all = sub.add_parser("build-all", help=_("build multiple languages"))
|
|
540
|
+
add_build_flags(sp_all, multi=True)
|
|
541
|
+
sp_all.set_defaults(func=cmd_build_all)
|
|
542
|
+
|
|
543
|
+
sp_dry = sub.add_parser("dry-run", help=_("parse + cost estimate only"))
|
|
544
|
+
add_build_flags(sp_dry, multi=False)
|
|
545
|
+
sp_dry.set_defaults(func=cmd_dry_run, dry_run=True)
|
|
546
|
+
|
|
547
|
+
sp_lang = sub.add_parser("languages", help=_("list language options"))
|
|
548
|
+
sp_lang.set_defaults(func=cmd_languages)
|
|
549
|
+
|
|
550
|
+
sp_voices = sub.add_parser("voices", help=_("list Grok voices"))
|
|
551
|
+
sp_voices.add_argument(
|
|
552
|
+
"--voice-filter",
|
|
553
|
+
default=None,
|
|
554
|
+
help=_("filter voices by id/name/tag"),
|
|
555
|
+
)
|
|
556
|
+
sp_voices.set_defaults(func=cmd_voices)
|
|
557
|
+
|
|
558
|
+
sp_doc = sub.add_parser("doctor", help=_("environment check"))
|
|
559
|
+
sp_doc.set_defaults(func=cmd_doctor)
|
|
560
|
+
|
|
561
|
+
sp_gui = sub.add_parser("gui", help=_("launch GUI"))
|
|
562
|
+
sp_gui.set_defaults(func=cmd_gui)
|
|
563
|
+
|
|
564
|
+
return p
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def _peek_locale(argv: list[str] | None) -> str | None:
|
|
568
|
+
"""Extract --locale before full parse so help strings can be translated."""
|
|
569
|
+
if not argv:
|
|
570
|
+
return None
|
|
571
|
+
i = 0
|
|
572
|
+
while i < len(argv):
|
|
573
|
+
a = argv[i]
|
|
574
|
+
if a == "--locale" and i + 1 < len(argv):
|
|
575
|
+
return argv[i + 1]
|
|
576
|
+
if a.startswith("--locale="):
|
|
577
|
+
return a.split("=", 1)[1]
|
|
578
|
+
i += 1
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
583
|
+
argv_list = list(argv) if argv is not None else None
|
|
584
|
+
setup_i18n(_peek_locale(argv_list))
|
|
585
|
+
parser = build_parser()
|
|
586
|
+
args = parser.parse_args(argv_list)
|
|
587
|
+
_STATE.quiet = bool(getattr(args, "quiet", False))
|
|
588
|
+
_STATE.verbose = bool(getattr(args, "verbose", False))
|
|
589
|
+
# Re-apply in case parse defaults differ (should match peek).
|
|
590
|
+
setup_i18n(getattr(args, "locale", None))
|
|
591
|
+
if getattr(args, "jobs", 1) not in (1, None) and int(args.jobs) != 1:
|
|
592
|
+
print(_("error: jobs must be 1 in MVP"), file=sys.stderr)
|
|
593
|
+
return 2
|
|
594
|
+
_install_sigint()
|
|
595
|
+
func = getattr(args, "func", None)
|
|
596
|
+
if func is None:
|
|
597
|
+
parser.print_help()
|
|
598
|
+
return 2
|
|
599
|
+
try:
|
|
600
|
+
return int(func(args))
|
|
601
|
+
except BrokenPipeError:
|
|
602
|
+
return 0
|
|
603
|
+
except KeyboardInterrupt:
|
|
604
|
+
_CANCEL.cancel()
|
|
605
|
+
_finish_progress_line()
|
|
606
|
+
return 130
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
if __name__ == "__main__":
|
|
610
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Core pipeline modules."""
|
srtspeak/core/cache.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""TTS response cache keying."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def cache_key_payload(
|
|
11
|
+
*,
|
|
12
|
+
provider: str,
|
|
13
|
+
voice_id: str,
|
|
14
|
+
language_code: str,
|
|
15
|
+
text: str,
|
|
16
|
+
sample_rate: int,
|
|
17
|
+
codec: str,
|
|
18
|
+
tts_speed: float,
|
|
19
|
+
text_normalization: bool,
|
|
20
|
+
) -> dict[str, object]:
|
|
21
|
+
return {
|
|
22
|
+
"codec": codec,
|
|
23
|
+
"language_code": language_code,
|
|
24
|
+
"provider": provider,
|
|
25
|
+
"sample_rate": sample_rate,
|
|
26
|
+
"text": text,
|
|
27
|
+
"text_normalization": text_normalization,
|
|
28
|
+
"tts_speed": tts_speed,
|
|
29
|
+
"voice_id": voice_id,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def cache_key_hex(
|
|
34
|
+
*,
|
|
35
|
+
provider: str,
|
|
36
|
+
voice_id: str,
|
|
37
|
+
language_code: str,
|
|
38
|
+
text: str,
|
|
39
|
+
sample_rate: int,
|
|
40
|
+
codec: str,
|
|
41
|
+
tts_speed: float,
|
|
42
|
+
text_normalization: bool,
|
|
43
|
+
) -> str:
|
|
44
|
+
payload = cache_key_payload(
|
|
45
|
+
provider=provider,
|
|
46
|
+
voice_id=voice_id,
|
|
47
|
+
language_code=language_code,
|
|
48
|
+
text=text,
|
|
49
|
+
sample_rate=sample_rate,
|
|
50
|
+
codec=codec,
|
|
51
|
+
tts_speed=tts_speed,
|
|
52
|
+
text_normalization=text_normalization,
|
|
53
|
+
)
|
|
54
|
+
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
|
55
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cache_path(work_lang_dir: Path, key_hex: str) -> Path:
|
|
59
|
+
return work_lang_dir / "cache" / f"{key_hex}.wav"
|
srtspeak/core/cancel.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Cancellation token for cooperative stop."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class BuildCancelled(Exception):
|
|
7
|
+
"""User-requested cooperative cancellation."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CancellationToken:
|
|
11
|
+
def __init__(self) -> None:
|
|
12
|
+
self._cancelled = False
|
|
13
|
+
|
|
14
|
+
@property
|
|
15
|
+
def cancelled(self) -> bool:
|
|
16
|
+
return self._cancelled
|
|
17
|
+
|
|
18
|
+
def cancel(self) -> None:
|
|
19
|
+
self._cancelled = True
|
|
20
|
+
|
|
21
|
+
def check(self) -> None:
|
|
22
|
+
if self._cancelled:
|
|
23
|
+
raise BuildCancelled("build cancelled")
|