Recently Written · git

subplz-web

git clone https://github.com/equwal/subplz-web

Log | Files | Refs


backend/aligner.py (11519 bytes)

1 """The alignment backend, behind an interface.
2 
3 Everything subplz-specific lives in `SubPlzAligner`: its argument names, its
4 Japanese defaults, the shape of its progress output, where it writes the result.
5 The rest of the application talks to the `Aligner` interface, so replacing
6 subplz means writing one new class and changing SUBPLZ_WEB_ALIGNER - not
7 touching the API, the queue, storage or the job runner.
8 
9 To add a backend:
10   1. subclass Aligner
11   2. register it in ALIGNERS below
12   3. set SUBPLZ_WEB_ALIGNER to its name
13 """
14 
15 from __future__ import annotations
16 
17 import os
18 import re
19 from abc import ABC, abstractmethod
20 from dataclasses import dataclass
21 from pathlib import Path
22 
23 from . import languages
24 from .settings import settings
25 
26 
27 @dataclass(frozen=True)
28 class AlignRequest:
29     """Everything a backend needs to align one book."""
30 
31     audio: Path
32     text: Path
33     out_dir: Path
34     language: str
35     model: str
36     device: str
37     threads: int
38     # Chapter count of the audio, or 1. Backends may use it for progress.
39     chapters: int = 1
40 
41 
42 @dataclass(frozen=True)
43 class ProgressUpdate:
44     stage: str
45     # 0..1 within the alignment run, or None to leave the bar where it is.
46     fraction: float | None = None
47 
48 
49 class ProgressReader(ABC):
50     """Per-run state for interpreting a backend's console output."""
51 
52     @abstractmethod
53     def feed(self, line: str) -> ProgressUpdate | None:
54         """Interpret one output line. Return None if it says nothing useful."""
55 
56 
57 class Aligner(ABC):
58     name: str = "aligner"
59     output_suffix: str = ".srt"
60 
61     # Score at or below which this backend refuses to pair audio with text.
62     # 0 means the backend has no such notion.
63     match_threshold: float = 0.0
64 
65     def score_pair(self, audio_text: str, book_text: str) -> float:
66         """How well a transcript of some audio matches a piece of the book.
67 
68         Same arithmetic the backend uses internally, so a preflight number
69         means the same thing as the one that decides a real run.
70         """
71         return 0.0
72 
73     @abstractmethod
74     def build_command(self, req: AlignRequest) -> list[str]:
75         """The subprocess to run."""
76 
77     @abstractmethod
78     def progress_reader(self, req: AlignRequest) -> ProgressReader:
79         ...
80 
81     @abstractmethod
82     def locate_output(self, req: AlignRequest) -> Path | None:
83         """The subtitle file produced, or None if there is not one."""
84 
85     def environment(self) -> dict[str, str]:
86         """Environment for the subprocess."""
87         env = os.environ.copy()
88         # Backends print emoji; without this a piped stdout dies on a
89         # cp1252/cp932 console.
90         env["PYTHONIOENCODING"] = "utf-8"
91         env["PYTHONUTF8"] = "1"
92         return env
93 
94     def language_note(self, code: str) -> str | None:
95         """Anything the user should know about this language, or None."""
96         return None
97 
98     def failure_reason(self, req: "AlignRequest") -> str | None:
99         """Why the run produced nothing, if the backend left an explanation.
100 
101         Needed because a backend may exit 0 and still have failed, so the exit
102         code alone cannot be trusted to mean success.
103         """
104         return None
105 
106 
107 # ---------------------------------------------------------------------------
108 # subplz
109 # ---------------------------------------------------------------------------
110 
111 # subplz transcribes one chapter at a time and each chapter gets its own bar
112 # running 0->100%. Matching only the "Transcribe:" bar keeps the sentence
113 # splitting and grouping bars from yanking the number around.
114 _TRANSCRIBE_PCT = re.compile(r"Transcribe:\s*(\d{1,3})%")
115 
116 _STAGES: list[tuple[re.Pattern, str]] = [
117     (re.compile(r"Starting '"), "Loading audio"),
118     (re.compile(r"Fuzzy matching chapters"), "Matching chapters"),
119     (re.compile(r"Splitting transcript into sentences"), "Splitting text into sentences"),
120     (re.compile(r"Syncing"), "Aligning audio to text"),
121     (re.compile(r"Grouping based on transcript"), "Grouping subtitle lines"),
122     (re.compile(r"Writing generated subs"), "Writing subtitles"),
123 ]
124 
125 # Transcription dominates the wall clock; the later phases share the tail.
126 _RUN_START = 0.05
127 _TRANSCRIBE_END = 0.80
128 
129 _STAGE_PROGRESS: dict[str, float] = {
130     "Aligning audio to text": 0.82,
131     "Grouping subtitle lines": 0.86,
132     "Writing subtitles": 0.89,
133 }
134 
135 
136 def _subplz_clean(text: str, lang_code: str) -> str:
137     """What subplz feeds to fuzz.ratio: lang.normalize(lang.clean(text)).
138 
139     Falls back to the library's own implementation when it is importable, so
140     this cannot drift from the real thing; the inline version is only a
141     stand-in for when ats is not installed.
142     """
143     try:
144         from ats.lang import get_lang
145 
146         lang = get_lang(lang_code)
147         return lang.normalize(lang.clean(text))
148     except Exception:  # noqa: BLE001 - scoring must never break an upload
149         import unicodedata
150 
151         return unicodedata.normalize("NFKD", text.lower())
152 
153 
154 class _SubPlzProgress(ProgressReader):
155     def __init__(self, chapters: int):
156         self.chapters = max(1, chapters)
157         self.done = 0
158         self.last_pct = 0
159         self.stage = "Starting subplz"
160         self.best = _RUN_START
161 
162     def feed(self, line: str) -> ProgressUpdate | None:
163         for pattern, label in _STAGES:
164             if pattern.search(line):
165                 self.stage = label
166                 break
167 
168         fraction = None
169         m = _TRANSCRIBE_PCT.search(line)
170         if m:
171             pct = min(100, max(0, int(m.group(1))))
172             # The bar restarting means the previous chapter finished.
173             if pct < self.last_pct:
174                 self.done = min(self.done + 1, self.chapters - 1)
175             self.last_pct = pct
176             frac = min(1.0, (self.done + pct / 100.0) / self.chapters)
177             fraction = _RUN_START + frac * (_TRANSCRIBE_END - _RUN_START)
178             self.stage = (
179                 f"Transcribing chapter {self.done + 1} of {self.chapters}"
180                 if self.chapters > 1
181                 else "Transcribing audio"
182             )
183         elif self.stage in _STAGE_PROGRESS:
184             fraction = _STAGE_PROGRESS[self.stage]
185 
186         if fraction is not None:
187             # Progress only ever moves forward.
188             self.best = max(self.best, fraction)
189         return ProgressUpdate(stage=self.stage, fraction=self.best)
190 
191 
192 class SubPlzAligner(Aligner):
193     """kanjieater/SubPlz, driven through its `sync` subcommand.
194 
195     `sync` only ever times the text you supply. `gen`, which transcribes a book
196     from scratch, is deliberately never invoked.
197     """
198 
199     name = "subplz"
200     output_suffix = ".srt"
201 
202     # subplz/sync.py: SCORE_THRESHOLD = 40. A chapter whose best fuzz.ratio
203     # never exceeds this is reported as "too different" and left unmatched.
204     match_threshold = 40.0
205 
206     def score_pair(self, audio_text: str, book_text: str) -> float:
207         """Reproduces subplz's own match_start() scoring, exactly.
208 
209         Two details that are easy to get wrong and both matter a lot:
210 
211         * It compares only the **first min(len_a, len_b, 2000) characters**, so
212           the score is about how chapters *open*, not how similar they are
213           overall. Front matter or an unread heading at the top of a chapter
214           sinks it even when the rest is identical.
215         * Cleaning is language-dependent, and ats/lang.py only implements
216           Japanese and English - every other language falls back to English,
217           whose clean() is nothing but .lower(). So for Russian, Spanish,
218           Portuguese and the rest, punctuation and spacing are compared
219           verbatim.
220         """
221         from rapidfuzz import fuzz
222 
223         a = _subplz_clean(audio_text, self._lang_code)
224         b = _subplz_clean(book_text, self._lang_code)
225         # Below this, subplz does not even consider the pair.
226         if len(a) < 100 or len(b) < 100:
227             return 0.0
228         n = min(len(a), len(b), 2000)
229         return float(fuzz.ratio(a[:n], b[:n]))
230 
231     # Set per request so scoring matches the language subplz will run under.
232     _lang_code: str = "en"
233 
234     def for_language(self, code: str) -> "SubPlzAligner":
235         clone = SubPlzAligner()
236         clone._lang_code = code
237         return clone
238 
239     def build_command(self, req: AlignRequest) -> list[str]:
240         lang = languages.require(req.language)
241 
242         cmd = [
243             str(settings.subplz_bin), "sync",
244             # subplz takes either -d, or all three of --audio/--text/
245             # --output-dir, and rejects a mix. The explicit form pairs the files
246             # by name rather than by directory sort order.
247             "--audio", str(req.audio),
248             "--text", str(req.text),
249             "--output-dir", str(req.out_dir),
250             "--output-format", "srt",
251             # subplz defaults BOTH of these to Japanese - always set them.
252             "--language", lang.code,
253             "--lang", lang.code,
254             "--lang-ext", lang.code,
255             "--model", req.model,
256             "--device", req.device,
257             "--overwrite",
258             "--rerun",
259             "--progress",
260             "--threads", str(req.threads),
261         ]
262 
263         # pysbd cannot segment this language; make subplz use stanza instead.
264         if lang.needs_nlp_flag:
265             cmd.append("--nlp")
266 
267         return cmd
268 
269     def progress_reader(self, req: AlignRequest) -> ProgressReader:
270         return _SubPlzProgress(req.chapters)
271 
272     def locate_output(self, req: AlignRequest) -> Path | None:
273         # subplz writes <stem>.<lang-ext>.srt, and the stem is ours.
274         expected = req.out_dir / f"{req.audio.stem}.{req.language}.srt"
275         if expected.exists():
276             return expected
277         # Fall back in case upstream changes the convention.
278         return next(iter(sorted(req.out_dir.glob("*.srt"))), None)
279 
280     def failure_reason(self, req: AlignRequest) -> str | None:
281         """Read subplz's own explanation out of the .subfail it leaves behind.
282 
283         subplz exits 0 even when a sync fails, so the exit code cannot be
284         trusted. Without this the runner reports "the audio and text may not
285         match" for every failure - including ones that have nothing to do with
286         the content, such as a broken library.
287         """
288         for fail in sorted(req.out_dir.glob("*.subfail")):
289             try:
290                 text = fail.read_text(encoding="utf-8", errors="replace").strip()
291             except OSError:
292                 continue
293             if not text:
294                 continue
295             # The file repeats the audio path on every line; keep the last
296             # line, which carries the actual reason.
297             lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
298             return lines[-1] if lines else None
299         return None
300 
301     def language_note(self, code: str) -> str | None:
302         lang = languages.get(code)
303         if lang is None or not lang.needs_nlp_flag:
304             return None
305         return (
306             f"{lang.name} uses the stanza sentence splitter. "
307             f"The first {lang.name} run downloads a small model."
308         )
309 
310 
311 ALIGNERS: dict[str, type[Aligner]] = {
312     SubPlzAligner.name: SubPlzAligner,
313 }
314 
315 
316 def get_aligner() -> Aligner:
317     try:
318         return ALIGNERS[settings.aligner]()
319     except KeyError:
320         known = ", ".join(sorted(ALIGNERS))
321         raise RuntimeError(
322             f"Unknown aligner {settings.aligner!r}. Known backends: {known}"
323         ) from None
324 
325 
326 aligner: Aligner = get_aligner()