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 ADDED
@@ -0,0 +1,3 @@
1
+ """srtspeak package."""
2
+
3
+ __version__ = "0.1.0"
srtspeak/__main__.py ADDED
@@ -0,0 +1,8 @@
1
+ """python -m srtspeak entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from srtspeak.cli import main
6
+
7
+ if __name__ == "__main__":
8
+ raise SystemExit(main())
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"
@@ -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")