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()