OmniVoice-Studio / backend /services /subtitle_segmenter.py
LΓͺ Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
12.1 kB
"""
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 ──────────────────────────────────────────────────────────────────────
@dataclass
class Word:
text: str
start: float
end: float
@dataclass
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]