Spaces:
Sleeping
Sleeping
| """ | |
| NLP-aware subtitle segmentation β Phase 1.2 (ROADMAP.md). | |
| Takes the raw ASR output (Whisper / WhisperX / mlx-whisper) and re-chunks it | |
| so every resulting segment respects the Netflix subtitle style guide: | |
| β’ β€ 42 characters per line | |
| β’ β€ 2 lines per subtitle (but we prefer single-line) | |
| β’ β€ 17 characters per second (CPS) | |
| β’ no orphan fragments (< 1.2 s or < 8 characters) | |
| Splits happen at, in priority order: | |
| 1. Sentence terminators . ? ! γ οΌ οΌ | |
| 2. Clause separators , ; : β οΌοΌοΌ | |
| 3. Conjunctions "and", "but", "or", "so", "however", β¦ | |
| 4. Last resort: greedy word packing at the 42-char boundary. | |
| When word-level timings are available (WhisperX provides `words: [{text, | |
| start, end}]` on each segment), splits are placed at exact word boundaries. | |
| Otherwise we proportionally interpolate by character offset β good enough. | |
| Short adjacent segments are merged *after* splitting so we don't emit | |
| "Yes." / "No." one-char subtitles. | |
| Pure function β no model, no network. Deterministic, fast, easy to test. | |
| """ | |
| from __future__ import annotations | |
| import re | |
| from dataclasses import dataclass, field | |
| from typing import List, Optional | |
| # ββ Tunable thresholds ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| MAX_CHARS_PER_LINE = 42 # Netflix single-line cap | |
| MAX_LINES = 2 # Netflix hard cap (we still prefer 1) | |
| MAX_CPS = 17 # Netflix reading-speed ceiling | |
| MIN_DURATION_S = 1.2 # Below this β merge with neighbour | |
| MIN_CHARS = 8 # Same for very short text | |
| MAX_CHARS_TOTAL = MAX_CHARS_PER_LINE * MAX_LINES # 84 | |
| # Sentence terminators (+CJK). Keep the terminator ON the left piece. | |
| _SENT_TERM_RE = re.compile(r"([\.!\?γοΌοΌ]+)(\s+)") | |
| # Clause separators. Keep on the left. | |
| _CLAUSE_SPLIT_RE = re.compile(r"([,;:βοΌοΌοΌ])\s+") | |
| # Conjunctions. Split BEFORE the conjunction word. | |
| _CONJUNCTIONS = { | |
| "and", "but", "or", "so", "however", "because", | |
| "although", "while", "therefore", "meanwhile", | |
| "y", "pero", "o", # es | |
| "et", "mais", "ou", "donc", # fr | |
| "und", "aber", "oder", "weil", # de | |
| } | |
| _CONJ_RE = re.compile( | |
| r"\s+(" + "|".join(sorted(_CONJUNCTIONS, key=len, reverse=True)) + r")\s+", | |
| flags=re.IGNORECASE, | |
| ) | |
| # ββ Data ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| class Word: | |
| text: str | |
| start: float | |
| end: float | |
| class Seg: | |
| start: float | |
| end: float | |
| text: str | |
| words: List[Word] = field(default_factory=list) | |
| # Any extra metadata (speaker_id, language, etc.) flows through untouched. | |
| extras: dict = field(default_factory=dict) | |
| # ββ Public API βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def segment_for_subtitles(segments: list[dict]) -> list[dict]: | |
| """ | |
| Top-level entry: list[dict] in, list[dict] out. Preserves every key the | |
| caller passed in other than `text`, `start`, `end`, and `words`, which this | |
| function owns. | |
| """ | |
| if not segments: | |
| return [] | |
| normalized = [_to_seg(s) for s in segments] | |
| splits: list[Seg] = [] | |
| for s in normalized: | |
| splits.extend(_split_one(s)) | |
| merged = _merge_tiny_neighbours(splits) | |
| return [_from_seg(s) for s in merged] | |
| # ββ Adapters ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _to_seg(d: dict) -> Seg: | |
| words = [] | |
| for w in (d.get("words") or []): | |
| if "start" in w and "end" in w and "text" in w: | |
| words.append(Word(text=str(w["text"]), start=float(w["start"]), end=float(w["end"]))) | |
| elif "word" in w and "start" in w and "end" in w: | |
| words.append(Word(text=str(w["word"]), start=float(w["start"]), end=float(w["end"]))) | |
| # Keep extras: anything not owned by the segmenter. | |
| extras = {k: v for k, v in d.items() if k not in ("start", "end", "text", "words")} | |
| return Seg( | |
| start=float(d.get("start", 0.0)), | |
| end=float(d.get("end", 0.0)), | |
| text=(d.get("text") or "").strip(), | |
| words=words, | |
| extras=extras, | |
| ) | |
| def _from_seg(s: Seg) -> dict: | |
| out = {**s.extras, "start": s.start, "end": s.end, "text": s.text.strip()} | |
| if s.words: | |
| out["words"] = [{"text": w.text, "start": w.start, "end": w.end} for w in s.words] | |
| return out | |
| # ββ Splitter ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _split_one(s: Seg) -> List[Seg]: | |
| """Recursively split `s` until every piece fits MAX_CHARS_TOTAL + MAX_CPS.""" | |
| text = s.text | |
| dur = max(1e-3, s.end - s.start) | |
| cps = len(text) / dur | |
| if len(text) <= MAX_CHARS_TOTAL and cps <= MAX_CPS: | |
| return [s] | |
| cut = _pick_cut(text) | |
| if cut is None or cut <= 0 or cut >= len(text): | |
| # Can't find a natural cut β fall back to a hard word-boundary split | |
| # closest to half-length. | |
| cut = _hard_word_cut(text) | |
| if cut is None: | |
| return [s] # one-word rumble; accept the violation | |
| left_text = text[:cut].rstrip() | |
| right_text = text[cut:].lstrip() | |
| if not left_text or not right_text: | |
| return [s] | |
| split_t = _time_at_char(s, cut) | |
| left = Seg(start=s.start, end=split_t, text=left_text, | |
| words=[w for w in s.words if w.end <= split_t + 1e-4], | |
| extras=dict(s.extras)) | |
| right = Seg(start=split_t, end=s.end, text=right_text, | |
| words=[w for w in s.words if w.start >= split_t - 1e-4], | |
| extras=dict(s.extras)) | |
| # Recurse β splits might still be too long. | |
| return _split_one(left) + _split_one(right) | |
| def _pick_cut(text: str) -> Optional[int]: | |
| """ | |
| Return the character index to split at, or None if no natural cut fits. | |
| Priority: sentence > clause > conjunction. Prefer a cut near the middle | |
| of the text so neither side is tiny. | |
| """ | |
| n = len(text) | |
| target = n / 2 | |
| cands: list[tuple[int, int]] = [] # (priority, char_index_of_cut) | |
| for m in _SENT_TERM_RE.finditer(text): | |
| # Cut after the whitespace run so the terminator stays on the left piece. | |
| cands.append((1, m.end())) | |
| for m in _CLAUSE_SPLIT_RE.finditer(text): | |
| cands.append((2, m.end())) | |
| for m in _CONJ_RE.finditer(text): | |
| # Cut BEFORE the conjunction (after the preceding whitespace). | |
| cands.append((3, m.start() + 1)) | |
| # If nothing fits OR all candidates are at the very start/end, bail. | |
| cands = [(p, c) for (p, c) in cands if 0 < c < n] | |
| if not cands: | |
| return None | |
| # Prefer highest-priority candidate closest to the mid-point that doesn't | |
| # leave either side above MAX_CHARS_TOTAL β if impossible, any mid-ish cut. | |
| cands.sort(key=lambda pc: (pc[0], abs(pc[1] - target))) | |
| for _pri, c in cands: | |
| if _fits(text[:c], text[c:]): | |
| return c | |
| # Nothing produces a legal split alone, but we still want forward progress | |
| # β return the best midpoint cut so recursion can chip away. | |
| return cands[0][1] | |
| def _fits(left: str, right: str) -> bool: | |
| return len(left.strip()) <= MAX_CHARS_TOTAL and len(right.strip()) <= MAX_CHARS_TOTAL | |
| def _hard_word_cut(text: str) -> Optional[int]: | |
| """Space-boundary cut nearest the midpoint. None if no spaces.""" | |
| target = len(text) // 2 | |
| best = None | |
| best_dist = None | |
| for i, ch in enumerate(text): | |
| if ch.isspace(): | |
| d = abs(i - target) | |
| if best_dist is None or d < best_dist: | |
| best, best_dist = i, d | |
| return best | |
| def _time_at_char(s: Seg, char_idx: int) -> float: | |
| """Find the timestamp corresponding to `char_idx`. Uses word timings if present. | |
| When `char_idx` falls in the whitespace GAP between two words, we return | |
| the previous word's end-time (the natural pause) rather than the next | |
| word's end β so splits land on real breaths, not mid-word. | |
| """ | |
| if s.words: | |
| cursor = 0 | |
| text = s.text | |
| last_end = s.start | |
| for w in s.words: | |
| # Skip whitespace between words. | |
| while cursor < len(text) and text[cursor].isspace(): | |
| cursor += 1 | |
| # Cut falls before this word starts β it's in the gap. Use the | |
| # previous word's end (the pause between them). | |
| if char_idx <= cursor: | |
| return last_end | |
| wlen = len(w.text) | |
| if cursor + wlen >= char_idx: | |
| return w.end # mid-word cut β round up to end of covering word | |
| cursor += wlen | |
| last_end = w.end | |
| return s.end | |
| # No word timings β proportional interpolation on chars. | |
| frac = char_idx / max(1, len(s.text)) | |
| return s.start + frac * (s.end - s.start) | |
| # ββ Merger ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| def _merge_tiny_neighbours(segs: List[Seg]) -> List[Seg]: | |
| """ | |
| Fold segments shorter than MIN_DURATION_S / MIN_CHARS into their | |
| nearest in-sentence neighbour, as long as the merge doesn't violate | |
| MAX_CHARS_TOTAL or MAX_CPS. | |
| """ | |
| if not segs: | |
| return segs | |
| out: List[Seg] = [] | |
| for s in segs: | |
| if out and _should_merge(out[-1], s): | |
| out[-1] = _merge(out[-1], s) | |
| else: | |
| out.append(s) | |
| return out | |
| def _should_merge(a: Seg, b: Seg) -> bool: | |
| ad = a.end - a.start | |
| bd = b.end - b.start | |
| a_tiny = ad < MIN_DURATION_S or len(a.text) < MIN_CHARS | |
| b_tiny = bd < MIN_DURATION_S or len(b.text) < MIN_CHARS | |
| if not (a_tiny or b_tiny): | |
| return False | |
| # Don't merge across a hard sentence boundary β keep the reading beat. | |
| if _SENT_TERM_RE.search(a.text + " "): | |
| return False | |
| combined = f"{a.text} {b.text}".strip() | |
| combined_dur = max(1e-3, b.end - a.start) | |
| if len(combined) > MAX_CHARS_TOTAL: | |
| return False | |
| if len(combined) / combined_dur > MAX_CPS: | |
| return False | |
| # Don't bridge speaker changes when we know them. | |
| if a.extras.get("speaker_id") and b.extras.get("speaker_id") \ | |
| and a.extras["speaker_id"] != b.extras["speaker_id"]: | |
| return False | |
| return True | |
| def _merge(a: Seg, b: Seg) -> Seg: | |
| return Seg( | |
| start=a.start, end=b.end, | |
| text=f"{a.text} {b.text}".strip(), | |
| words=a.words + b.words, | |
| extras={**a.extras, **b.extras}, | |
| ) | |
| # ββ Layout helper (UI / SRT rendering convenience) ββββββββββββββββββββββββββββ | |
| def format_subtitle_lines(text: str, max_chars: int = MAX_CHARS_PER_LINE) -> list[str]: | |
| """ | |
| Greedy word-wrap text into β€ MAX_LINES lines of β€ max_chars each. Returns | |
| the list of lines. If text is inherently too long, the last line overflows | |
| rather than truncating. | |
| """ | |
| words = text.split() | |
| lines: list[str] = [""] | |
| for w in words: | |
| tentative = f"{lines[-1]} {w}".strip() if lines[-1] else w | |
| if len(tentative) <= max_chars: | |
| lines[-1] = tentative | |
| continue | |
| if len(lines) < MAX_LINES: | |
| lines.append(w) | |
| else: | |
| # Last line β append with a space, accept the overflow. | |
| lines[-1] = f"{lines[-1]} {w}".strip() | |
| return [l for l in lines if l] | |