Files
diodiogod-TTS-Audio-Suite/utils/asr/srt_builder.py

1359 lines
55 KiB
Python

"""
SRT builder for ASR outputs with smart readability defaults.
"""
from functools import lru_cache
from typing import Dict, List, Optional, Tuple
import re
from utils.asr.types import ASRSegment, ASRWord
from utils.asr.srt_heuristic_profiles import (
ENGLISH_DANGLING_TAIL_ALLOWLIST,
ENGLISH_INCOMPLETE_KEYWORDS,
)
from utils.asr.tagged_text import TaggedTextProfile, parse_tagged_text
SENTENCE_END_CHARS = {".", "!", "?", "…"}
SOFT_BREAK_CHARS = {",", ";", ":"}
DEFAULT_CONNECTOR_ALLOWLIST = ENGLISH_DANGLING_TAIL_ALLOWLIST
DEFAULT_INCOMPLETE_KEYWORDS = ENGLISH_INCOMPLETE_KEYWORDS
ALIGNMENT_TOKEN_RE = re.compile(r"[^\W_]+(?:[-'][^\W_]+)*|[^\w\s]", flags=re.UNICODE)
PHRASE_TOKEN_RE = re.compile(r"[^\W_]+(?:[-'][^\W_]+)*", flags=re.UNICODE)
CJK_CHAR_RE = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff\u3040-\u30ff\uac00-\ud7af]")
CJK_CLOSE_PUNCT = ",。!?、;:」』”’)】》〉〕]}"
CJK_OPEN_PUNCT = "「『“‘(【《〈〔[{"
NO_SPACE_BEFORE = set(".,!?;:%)]}\"'") | set(CJK_CLOSE_PUNCT)
NO_SPACE_AFTER = set("([{\"'") | set(CJK_OPEN_PUNCT)
def _format_timestamp(seconds: float) -> str:
if seconds < 0:
seconds = 0.0
hours = int(seconds // 3600)
minutes = int((seconds % 3600) // 60)
secs = int(seconds % 60)
millis = int(round((seconds - int(seconds)) * 1000))
if millis == 1000:
secs += 1
millis = 0
if secs == 60:
minutes += 1
secs = 0
if minutes == 60:
hours += 1
minutes = 0
return f"{hours:02d}:{minutes:02d}:{secs:02d},{millis:03d}"
def _wrap_text(text: str, max_chars_per_line: int, max_lines: int) -> str:
del max_lines # Overflow is handled by cue splitting, not silent truncation.
words = text.split()
if not words:
return text
line_groups = _group_items_into_lines(words, max_chars_per_line)
return "\n".join(_join_display_items(line).strip() for line in line_groups)
def _trim_trailing_soft_punctuation(text: str) -> str:
return re.sub(r"[.,;:]+$", "", text).rstrip()
def _capitalize_segment_start(text: str) -> str:
for idx, char in enumerate(text):
if char.isalpha():
return text[:idx] + char.upper() + text[idx + 1:]
return text
def _text_of_item(item) -> str:
return item.text if hasattr(item, "text") else str(item)
def _contains_cjk(text: str) -> bool:
return bool(CJK_CHAR_RE.search(text or ""))
def _is_cjk_char(text: str) -> bool:
return bool(text) and len(text) == 1 and bool(CJK_CHAR_RE.match(text))
def _needs_separator(left: str, right: str) -> bool:
if not left or not right:
return False
left_last = left[-1]
right_first = right[0]
if right_first in NO_SPACE_BEFORE or left_last in NO_SPACE_AFTER:
return False
if _contains_cjk(left_last) and (_contains_cjk(right_first) or right_first in CJK_CLOSE_PUNCT):
return False
if left_last in CJK_CLOSE_PUNCT and _contains_cjk(right_first):
return False
return True
def _join_display_texts(texts) -> str:
parts: List[str] = []
for raw_text in texts:
text = str(raw_text or "")
if not text:
continue
if parts and _needs_separator(parts[-1], text):
parts.append(" ")
parts.append(text)
return "".join(parts).strip()
def _join_display_items(items) -> str:
return _join_display_texts(_text_of_item(item) for item in items)
def _normalize_token(text: str) -> str:
return re.sub(r"[^\w]+", "", text, flags=re.UNICODE).lower()
def _is_punctuation_only(text: str) -> bool:
return bool(text) and not re.search(r"\w", text, flags=re.UNICODE)
def _tokenize_phrase_text(text: str) -> List[str]:
return [token.lower() for token in PHRASE_TOKEN_RE.findall(text or "")]
def _parse_phrase_csv(text: str) -> List[Tuple[str, ...]]:
phrases: List[Tuple[str, ...]] = []
for item in (text or "").split(","):
tokens = tuple(_tokenize_phrase_text(item))
if tokens:
phrases.append(tokens)
return phrases
def _last_clause_text(text: str) -> str:
clauses = re.split(r"[.!?…]\s*", text or "")
return clauses[-1].strip() if clauses else (text or "").strip()
def _tail_matches_phrase(text: str, phrases: List[Tuple[str, ...]]) -> bool:
tokens = _tokenize_phrase_text(text)
if not tokens:
return False
for phrase in phrases:
phrase_len = len(phrase)
if phrase_len <= len(tokens) and tuple(tokens[-phrase_len:]) == phrase:
return True
return False
def _clause_starts_with_phrase(text: str, phrases: List[Tuple[str, ...]]) -> bool:
tokens = _tokenize_phrase_text(text)
if not tokens:
return False
for phrase in phrases:
phrase_len = len(phrase)
if phrase_len <= len(tokens) and tuple(tokens[:phrase_len]) == phrase:
return True
return False
def _looks_incomplete_tail(text: str, phrases: List[Tuple[str, ...]], *, max_clause_tokens: int = 8) -> bool:
clause = _last_clause_text(text)
clause_tokens = _tokenize_phrase_text(clause)
if not clause_tokens or len(clause_tokens) > max_clause_tokens:
return False
return _clause_starts_with_phrase(clause, phrases)
def _strip_edge_punctuation(text: str) -> str:
return text.strip(".,!?…:;\"'()[]{}")
def _ends_sentence(text: str) -> bool:
return bool(text) and text[-1:] in SENTENCE_END_CHARS
def _ends_soft_break(text: str) -> bool:
return bool(text) and text[-1:] in SOFT_BREAK_CHARS
def _is_capitalizedish(text: str) -> bool:
stripped = _strip_edge_punctuation(text)
if not stripped:
return False
return stripped[0].isupper() or stripped.isupper()
def _group_items_into_lines(items, max_chars_per_line: int):
lines: List[str] = []
current_line = []
for item in items:
candidate_items = current_line + [item]
candidate_text = _join_display_items(candidate_items)
if current_line and len(candidate_text) > max_chars_per_line:
lines.append(current_line)
current_line = [item]
continue
current_line = candidate_items
if current_line:
lines.append(current_line)
return lines
def _render_control_block(controls) -> str:
return "".join(control.text for control in controls if getattr(control, "text", ""))
def _append_render_token(parts: List[str], token: str):
if not token:
return
if parts and not parts[-1].endswith((" ", "\n")) and _needs_separator(parts[-1], token):
parts.append(" ")
parts.append(token)
def _render_tagged_segment(seg: ASRSegment,
max_chars_per_line: int,
max_lines: int,
tagged_profile: TaggedTextProfile,
word_index_map: Dict[int, int],
global_last_word_idx: int) -> str:
if not seg.words:
return _wrap_text(seg.text, max_chars_per_line, max_lines)
line_groups = _group_items_into_lines(seg.words, max_chars_per_line)
if len(line_groups) > max_lines:
line_groups = line_groups[:max_lines]
if not line_groups:
return ""
rendered_lines: List[str] = []
for line_words in line_groups:
first_word_idx = word_index_map.get(id(line_words[0]))
if first_word_idx is None:
rendered_lines.append(_join_display_items(line_words))
continue
parts: List[str] = []
anchor_controls = tagged_profile.controls_for_anchor(first_word_idx)
if any(control.kind == "state" for control in anchor_controls):
prefix_block = _render_control_block(anchor_controls)
else:
carried_state = tagged_profile.active_state_for_word(first_word_idx) or ""
carried_controls = [control for control in anchor_controls if control.kind != "state"]
prefix_block = f"{carried_state}{_render_control_block(carried_controls)}"
if prefix_block:
_append_render_token(parts, prefix_block)
for word_pos, word in enumerate(line_words):
word_idx = word_index_map.get(id(word))
if word_pos > 0 and word_idx is not None:
inline_controls = tagged_profile.controls_for_anchor(word_idx)
inline_block = _render_control_block(inline_controls)
if inline_block:
_append_render_token(parts, inline_block)
_append_render_token(parts, word.text)
line_last_word_idx = word_index_map.get(id(line_words[-1]))
if line_last_word_idx is not None and line_last_word_idx == global_last_word_idx:
trailing_controls = tagged_profile.controls_for_anchor(global_last_word_idx + 1)
trailing_block = _render_control_block(trailing_controls)
if trailing_block:
_append_render_token(parts, trailing_block)
rendered_line = "".join(parts).strip()
if rendered_line:
rendered_lines.append(rendered_line)
return "\n".join(rendered_lines)
def _items_text(items) -> str:
return _join_display_items(items)
def _items_duration(items, total_duration: float = 0.0, total_items: int = 0) -> float:
if not items:
return 0.0
if hasattr(items[0], "start") and hasattr(items[-1], "end"):
return max(0.0, items[-1].end - items[0].start)
if total_items <= 0:
return 0.0
return total_duration * (len(items) / max(1, total_items))
def _looks_phrase_bridge(left_items, right_items) -> bool:
if not left_items or not right_items:
return False
left_last = _strip_edge_punctuation(_text_of_item(left_items[-1]))
right_first = _strip_edge_punctuation(_text_of_item(right_items[0]))
right_second = _strip_edge_punctuation(_text_of_item(right_items[1])) if len(right_items) > 1 else ""
if not right_first:
return False
if right_first.isupper() and 1 <= len(right_first) <= 4:
return True
if _is_capitalizedish(left_last) and _is_capitalizedish(right_first):
return True
if _is_capitalizedish(right_first) and right_second and _is_capitalizedish(right_second):
return True
return False
def _tts_ready_duration_slack(max_duration: float) -> float:
return max(0.45, min(1.5, max_duration * 0.12))
def _boundary_score(left_items,
right_items,
*,
target_chars: int,
min_words_per_segment: int,
min_segment_seconds: float,
connector_words,
sentence_bonus: float = 120.0,
soft_break_bonus: float = 60.0,
phrase_bridge_penalty: float = 90.0,
total_duration: float = 0.0,
total_items: int = 0) -> float:
if not left_items:
return float("-inf")
left_text = _items_text(left_items)
right_text = _items_text(right_items)
left_duration = _items_duration(left_items, total_duration=total_duration, total_items=total_items)
right_duration = _items_duration(right_items, total_duration=total_duration, total_items=total_items)
left_count = len(left_items)
right_count = len(right_items)
score = 0.0
fill_target = max(8.0, target_chars * 0.72)
score -= abs(len(left_text) - fill_target) / 4.0
if _ends_sentence(left_text):
score += sentence_bonus
elif _ends_soft_break(left_text):
score += soft_break_bonus
left_tail = _normalize_token(_text_of_item(left_items[-1]))
right_head = _normalize_token(_text_of_item(right_items[0])) if right_items else ""
if left_tail in connector_words:
score -= 95.0
if right_head in connector_words:
score -= 30.0
if left_count < max(1, min_words_per_segment):
score -= 120.0
if right_items and right_count < max(1, min_words_per_segment):
score -= 110.0
if left_duration <= min_segment_seconds:
score -= 90.0
if right_items and right_duration <= min_segment_seconds:
score -= 80.0
if right_items and not _ends_sentence(left_text) and _looks_phrase_bridge(left_items, right_items):
score -= phrase_bridge_penalty
if right_items:
if len(right_text) <= max(10, int(target_chars * 0.4)):
score -= 35.0
if right_count <= max(2, min_words_per_segment):
score -= 50.0
return score
def _choose_best_overflow_split(candidate_items,
*,
max_duration: float,
min_duration: float,
max_chars: int,
max_cps: float,
tts_ready_mode: bool,
tts_duration_slack: float,
punctuation_grace_chars: int,
min_words_per_segment: int,
min_segment_seconds: float,
minimum_score: float,
connector_words,
connector_phrases):
if len(candidate_items) < 2:
return None
best_idx = None
best_score = float("-inf")
total_duration = _items_duration(candidate_items)
total_items = len(candidate_items)
for split_idx in range(1, len(candidate_items)):
left_items = candidate_items[:split_idx]
right_items = candidate_items[split_idx:]
left_text = _items_text(left_items)
left_duration = _items_duration(left_items)
left_cps = len(left_text) / max(0.001, left_duration)
hard_boundary = _ends_sentence(left_text) or _ends_soft_break(left_text)
left_tail_is_connector = _tail_matches_phrase(left_text, connector_phrases)
score = _boundary_score(
left_items,
right_items,
target_chars=max_chars,
min_words_per_segment=min_words_per_segment,
min_segment_seconds=min_segment_seconds,
connector_words=connector_words,
sentence_bonus=140.0,
soft_break_bonus=95.0,
phrase_bridge_penalty=110.0,
total_duration=total_duration,
total_items=total_items,
)
allowed_chars = max_chars + (punctuation_grace_chars if _ends_sentence(left_text) else 0)
if not tts_ready_mode and len(left_text) > allowed_chars:
score -= 220.0 + ((len(left_text) - allowed_chars) * 8.0)
allowed_duration = max_duration + (tts_duration_slack if tts_ready_mode else 0.0)
if left_duration > allowed_duration:
if tts_ready_mode:
score -= 140.0 + ((left_duration - allowed_duration) * 35.0)
else:
score -= 240.0 + ((left_duration - allowed_duration) * 60.0)
if not tts_ready_mode and left_duration >= min_duration and left_cps > max_cps:
if hard_boundary:
score -= 55.0 + ((left_cps - max_cps) * 8.0)
else:
score -= 180.0 + ((left_cps - max_cps) * 25.0)
if left_duration < min_duration:
score -= 35.0 + ((min_duration - left_duration) * 40.0)
if tts_ready_mode and left_tail_is_connector:
score -= 260.0
if score > best_score:
best_score = score
best_idx = split_idx
if best_idx is None or best_score < minimum_score:
return None
return best_idx
def _choose_display_split_groups(source_items,
*,
max_chars_per_line: int,
max_lines: int,
min_words_per_segment: int,
min_segment_seconds: float,
total_duration: float = 0.0,
connector_words=None):
if not source_items:
return []
connector_words = connector_words or {
w.strip().lower() for w in DEFAULT_CONNECTOR_ALLOWLIST.split(",") if w.strip()
}
total_items = len(source_items)
max_chars_total = max_chars_per_line * max(1, max_lines)
@lru_cache(None)
def solve(start_idx: int):
if start_idx >= total_items:
return 0.0, ()
best_score = float("-inf")
best_cuts = None
for end_idx in range(start_idx + 1, total_items + 1):
left_items = source_items[start_idx:end_idx]
if len(_group_items_into_lines(left_items, max_chars_per_line)) > max_lines:
break
right_items = source_items[end_idx:]
local_score = _boundary_score(
left_items,
right_items,
target_chars=max_chars_total,
min_words_per_segment=min_words_per_segment,
min_segment_seconds=min_segment_seconds,
connector_words=connector_words,
sentence_bonus=110.0,
soft_break_bonus=35.0,
phrase_bridge_penalty=95.0,
total_duration=total_duration,
total_items=total_items,
)
if right_items:
local_score -= 22.0
next_score, next_cuts = solve(end_idx)
total_score = local_score + next_score
if total_score > best_score:
best_score = total_score
best_cuts = (end_idx,) + next_cuts
return best_score, best_cuts or ()
_, cuts = solve(0)
groups = []
start_idx = 0
for end_idx in cuts:
if end_idx > start_idx:
groups.append(source_items[start_idx:end_idx])
start_idx = end_idx
return groups or [source_items]
def _split_segment_for_display(seg: ASRSegment,
max_chars_per_line: int,
max_lines: int,
min_words_per_segment: int = 1,
min_segment_seconds: float = 0.0,
connector_words=None) -> List[ASRSegment]:
if not seg.text or max_lines <= 0:
return [seg]
source_items = seg.words if seg.words else seg.text.split()
if not source_items:
return [seg]
if len(_group_items_into_lines(source_items, max_chars_per_line)) <= max_lines:
return [seg]
total_items = len(source_items)
total_duration = max(0.0, seg.end - seg.start)
connector_words = connector_words or {
w.strip().lower() for w in DEFAULT_CONNECTOR_ALLOWLIST.split(",") if w.strip()
}
cue_item_groups = _choose_display_split_groups(
source_items,
max_chars_per_line=max_chars_per_line,
max_lines=max_lines,
min_words_per_segment=min_words_per_segment,
min_segment_seconds=min_segment_seconds,
total_duration=total_duration,
connector_words=connector_words,
)
split_segments: List[ASRSegment] = []
consumed_items = 0
for group_index, cue_items in enumerate(cue_item_groups):
cue_text = _join_display_items(cue_items)
if not cue_text:
consumed_items += len(cue_items)
continue
if seg.words:
cue_words = cue_items
cue_start = cue_words[0].start
cue_end = cue_words[-1].end
else:
cue_words = []
cue_item_count = len(cue_items)
cue_start = seg.start + (total_duration * (consumed_items / max(1, total_items)))
cue_end = seg.start + (total_duration * ((consumed_items + cue_item_count) / max(1, total_items)))
if group_index == len(cue_item_groups) - 1:
cue_end = seg.end
split_segments.append(
ASRSegment(
start=cue_start,
end=cue_end,
text=cue_text,
speaker=seg.speaker,
words=cue_words,
)
)
consumed_items += len(cue_items)
return split_segments or [seg]
def _segments_from_words(words: List[ASRWord],
min_gap: float,
max_duration: float,
min_duration: float,
max_chars: int,
max_cps: float,
tts_ready_mode: bool = False,
tts_ready_paragraph_mode: bool = False,
punctuation_grace_chars: int = 12,
min_words_per_segment: int = 2,
min_segment_seconds: float = 0.4,
merge_trailing_punct_word: bool = True,
merge_trailing_punct_max_gap: float = 1.0,
merge_leading_short_phrase: bool = True,
merge_leading_short_max_words: int = 2,
merge_leading_short_max_gap: float = 2.0,
merge_dangling_tail: bool = True,
merge_dangling_tail_max_words: int = 3,
merge_dangling_tail_max_gap: float = 3.0,
merge_dangling_tail_allowlist: str = DEFAULT_CONNECTOR_ALLOWLIST,
merge_leading_short_no_punct: bool = True,
merge_leading_short_no_punct_max_words: int = 2,
merge_leading_short_no_punct_max_gap: float = 1.5,
merge_incomplete_sentence: bool = True,
merge_incomplete_max_gap: float = 1.2,
merge_incomplete_keywords: str = DEFAULT_INCOMPLETE_KEYWORDS,
merge_incomplete_split_next: bool = True,
merge_allow_overlong: bool = False) -> List[ASRSegment]:
segments: List[ASRSegment] = []
if not words:
return segments
current_words: List[ASRWord] = []
current_text = ""
seg_start = words[0].start
connector_phrases = _parse_phrase_csv(merge_dangling_tail_allowlist)
connector_words = {phrase[-1] for phrase in connector_phrases if phrase}
incomplete_phrases = _parse_phrase_csv(merge_incomplete_keywords)
tts_duration_slack = _tts_ready_duration_slack(max_duration) if tts_ready_mode else 0.0
def flush_segment(end_time: float, words_override=None, text_override: str = None):
nonlocal current_words, current_text, seg_start
words_to_flush = words_override if words_override is not None else current_words
text_to_flush = text_override if text_override is not None else current_text.strip()
if not words_to_flush:
return
segments.append(ASRSegment(start=words_to_flush[0].start, end=end_time, text=text_to_flush, words=words_to_flush))
if words_override is None:
current_words = []
current_text = ""
for idx, word in enumerate(words):
gap = 0.0
if current_words:
gap = max(0.0, word.start - current_words[-1].end)
if not current_words:
seg_start = word.start
projected_text = (current_text + " " + word.text).strip() if current_text else word.text
projected_end = word.end
projected_duration = max(0.001, projected_end - seg_start)
projected_cps = len(projected_text) / projected_duration
last_char_projected = projected_text[-1:] if projected_text else ""
is_sentence_end = last_char_projected in [".", "!", "?", "…"]
split_on_gap = gap >= min_gap
split_on_duration = projected_duration > (max_duration + tts_duration_slack)
split_on_chars = (not tts_ready_mode) and len(projected_text) > max_chars
if split_on_chars and is_sentence_end and punctuation_grace_chars > 0:
split_on_chars = len(projected_text) > (max_chars + punctuation_grace_chars)
split_on_cps = (
not tts_ready_mode
and
projected_cps > max_cps
and projected_duration >= min_duration
and len(projected_text) >= int(max_chars * 0.65)
)
if split_on_gap and merge_trailing_punct_word:
if gap <= merge_trailing_punct_max_gap and is_sentence_end:
split_on_gap = False
if split_on_gap and tts_ready_mode and gap <= merge_dangling_tail_max_gap and current_text:
if _tail_matches_phrase(_last_clause_text(current_text), connector_phrases):
split_on_gap = False
if current_words and (split_on_gap or split_on_duration or split_on_chars or split_on_cps):
soft_pressure_only = split_on_cps and not (split_on_duration or split_on_chars or split_on_gap)
if split_on_duration or split_on_chars or split_on_cps:
candidate_words = current_words + [word]
split_idx = _choose_best_overflow_split(
candidate_words,
max_duration=max_duration,
min_duration=min_duration,
max_chars=max_chars,
max_cps=max_cps,
tts_ready_mode=tts_ready_mode,
tts_duration_slack=tts_duration_slack,
punctuation_grace_chars=punctuation_grace_chars,
min_words_per_segment=min_words_per_segment,
min_segment_seconds=min_segment_seconds,
minimum_score=25.0 if split_on_cps and not (split_on_duration or split_on_chars) else 0.0,
connector_words=connector_words,
connector_phrases=connector_phrases,
)
if split_idx is not None and 0 < split_idx < len(candidate_words):
left_words = candidate_words[:split_idx]
right_words = candidate_words[split_idx:]
flush_segment(
left_words[-1].end,
words_override=left_words,
text_override=_items_text(left_words),
)
current_words = right_words
current_text = _items_text(right_words)
seg_start = right_words[0].start
continue
if soft_pressure_only:
split_idx = None
if not soft_pressure_only:
flush_segment(current_words[-1].end)
seg_start = word.start
projected_text = word.text
current_words.append(word)
current_text = (current_text + " " + word.text).strip() if current_text else word.text
# Punctuation-based split
if current_text:
last_char = current_text[-1:]
if last_char in [".", "!", "?", "…"]:
if (word.end - seg_start) >= min_duration and not tts_ready_paragraph_mode:
flush_segment(word.end)
elif last_char in [",", ";", ":"]:
if (
not tts_ready_mode
and (word.end - seg_start) >= min_duration
and len(current_text) >= int(max_chars * 0.5)
):
flush_segment(word.end)
if current_words:
flush_segment(current_words[-1].end)
# Merge micro-segments (very short or too few words)
merged: List[ASRSegment] = []
i = 0
while i < len(segments):
seg = segments[i]
word_count = len(seg.text.split())
duration = seg.end - seg.start
too_short_for_reading = duration < min_duration and not _ends_sentence(seg.text)
if (word_count < min_words_per_segment or duration <= min_segment_seconds or too_short_for_reading) and i + 1 < len(segments):
nxt = segments[i + 1]
merged_text = _join_display_texts([seg.text, nxt.text])
merged_seg = ASRSegment(start=seg.start, end=nxt.end, text=merged_text, words=(seg.words + nxt.words))
merged.append(merged_seg)
i += 2
continue
merged.append(seg)
i += 1
if tts_ready_mode:
max_merge_duration = max_duration if not merge_allow_overlong else max(max_duration * 1.6, max_duration + 4.0)
else:
max_merge_duration = max_duration if not merge_allow_overlong else max_duration * 1.2
def can_merge_by_duration(start: float, end: float) -> bool:
return (end - start) <= max_merge_duration
# Merge a very short leading phrase into the previous segment if it follows punctuation
if merge_leading_short_phrase and len(merged) > 1:
stitched: List[ASRSegment] = [merged[0]]
for seg in merged[1:]:
prev = stitched[-1]
gap = seg.start - prev.end
if gap <= merge_leading_short_max_gap:
if prev.text and prev.text[-1:] in [".", "!", "?", "…"]:
if len(seg.text.split()) <= max(1, merge_leading_short_max_words):
if can_merge_by_duration(prev.start, seg.end):
merged_text = _join_display_texts([prev.text, seg.text])
stitched[-1] = ASRSegment(
start=prev.start,
end=seg.end,
text=merged_text,
words=(prev.words + seg.words),
)
continue
stitched.append(seg)
merged = stitched
if merge_leading_short_no_punct and len(merged) > 1:
stitched: List[ASRSegment] = [merged[0]]
for seg in merged[1:]:
prev = stitched[-1]
gap = seg.start - prev.end
if gap <= merge_leading_short_no_punct_max_gap:
if prev.text and prev.text[-1:] not in [".", "!", "?", "…"]:
if len(seg.text.split()) <= max(1, merge_leading_short_no_punct_max_words):
if can_merge_by_duration(prev.start, seg.end):
merged_text = _join_display_texts([prev.text, seg.text])
stitched[-1] = ASRSegment(
start=prev.start,
end=seg.end,
text=merged_text,
words=(prev.words + seg.words),
)
continue
stitched.append(seg)
merged = stitched
if merge_dangling_tail and len(merged) > 1:
stitched: List[ASRSegment] = [merged[0]]
for seg in merged[1:]:
prev = stitched[-1]
gap = seg.start - prev.end
if gap <= merge_dangling_tail_max_gap and prev.text:
# Only look at the last clause after sentence-ending punctuation
last_clause = _last_clause_text(prev.text)
clause_words = last_clause.split()
next_has_sentence_end = bool(seg.text and re.search(r"[.!?…]", seg.text))
if _tail_matches_phrase(last_clause, connector_phrases) and next_has_sentence_end:
tail_clause_limit = max(1, merge_dangling_tail_max_words)
if tts_ready_mode:
tail_clause_limit = max(tail_clause_limit, 12)
if len(clause_words) <= tail_clause_limit:
# Prefer merging only the first sentence from next segment
split_idx = -1
for i, w in enumerate(seg.words):
if w.text and w.text[-1:] in [".", "!", "?", "…"]:
split_idx = i
break
if split_idx >= 0:
head_words = seg.words[:split_idx + 1]
tail_words = seg.words[split_idx + 1:]
head_text = _join_display_items(head_words)
tail_text = _join_display_items(tail_words)
combined = _join_display_texts([prev.text, head_text])
if (tts_ready_mode or len(combined) <= (max_chars + punctuation_grace_chars)) and can_merge_by_duration(prev.start, head_words[-1].end):
stitched[-1] = ASRSegment(
start=prev.start,
end=head_words[-1].end,
text=combined,
words=(prev.words + head_words),
)
if tail_words:
stitched.append(ASRSegment(
start=tail_words[0].start,
end=tail_words[-1].end,
text=tail_text,
words=tail_words,
))
continue
if can_merge_by_duration(prev.start, seg.end):
merged_text = _join_display_texts([prev.text, seg.text])
stitched[-1] = ASRSegment(
start=prev.start,
end=seg.end,
text=merged_text,
words=(prev.words + seg.words),
)
continue
stitched.append(seg)
merged = stitched
if merge_incomplete_sentence and len(merged) > 1:
stitched: List[ASRSegment] = [merged[0]]
for seg in merged[1:]:
prev = stitched[-1]
gap = seg.start - prev.end
if gap <= merge_incomplete_max_gap:
if prev.text and prev.text[-1:] not in [".", "!", "?", "…"]:
if _looks_incomplete_tail(prev.text, incomplete_phrases):
combined = _join_display_texts([prev.text, seg.text])
if (tts_ready_mode or len(combined) <= (max_chars + punctuation_grace_chars)) and can_merge_by_duration(prev.start, seg.end):
stitched[-1] = ASRSegment(
start=prev.start,
end=seg.end,
text=combined,
words=(prev.words + seg.words),
)
continue
stitched.append(seg)
merged = stitched
if merge_incomplete_split_next and len(merged) > 1:
stitched: List[ASRSegment] = [merged[0]]
for seg in merged[1:]:
prev = stitched[-1]
gap = seg.start - prev.end
if gap <= merge_incomplete_max_gap:
if prev.text and prev.text[-1:] not in [".", "!", "?", "…"]:
if _looks_incomplete_tail(prev.text, incomplete_phrases):
# Split next segment at first sentence-ending punctuation
split_idx = -1
for i, w in enumerate(seg.words):
if w.text and w.text[-1:] in [".", "!", "?", "…"]:
split_idx = i
break
if split_idx >= 0:
head_words = seg.words[:split_idx + 1]
tail_words = seg.words[split_idx + 1:]
head_text = _join_display_items(head_words)
tail_text = _join_display_items(tail_words)
combined = _join_display_texts([prev.text, head_text])
if (tts_ready_mode or len(combined) <= (max_chars + punctuation_grace_chars)) and can_merge_by_duration(prev.start, head_words[-1].end):
stitched[-1] = ASRSegment(
start=prev.start,
end=head_words[-1].end,
text=combined,
words=(prev.words + head_words),
)
if tail_words:
stitched.append(ASRSegment(
start=tail_words[0].start,
end=tail_words[-1].end,
text=tail_text,
words=tail_words,
))
continue
stitched.append(seg)
merged = stitched
return merged
def segments_from_words(words: List[ASRWord], **kwargs) -> List[ASRSegment]:
return _segments_from_words(words, **kwargs)
def _apply_punctuation(words: List[ASRWord], full_text: str):
if not words or not full_text:
return words, 0, 0, [], []
words = [word for word in words if not _is_punctuation_only(word.text)]
if not words:
return words, 0, 0, [], []
# Tokenize text into words and punctuation
# Keep apostrophes and hyphens inside compounds so they do not get
# mis-attached as free punctuation during alignment.
tokens = ALIGNMENT_TOKEN_RE.findall(full_text)
word_idx = 0
last_word_idx = -1
skipped_word_indices = set()
def normalize(w: str) -> str:
return re.sub(r"[^\w]+", "", w, flags=re.UNICODE).lower()
def expand_contraction(token: str) -> List[str]:
# Split common apostrophe contractions into word tokens
if "'" in token:
parts = token.split("'")
if len(parts) == 2 and parts[0] and parts[1]:
return [parts[0], parts[1]]
return [token]
def expand_compound(token: str) -> List[str]:
if "-" in token:
return [part for part in token.split("-") if part]
return [token]
def expand_cjk(token: str) -> List[str]:
if not _contains_cjk(token):
return [token]
parts: List[str] = []
latin_buffer: List[str] = []
for char in token:
if _is_cjk_char(char):
if latin_buffer:
parts.append("".join(latin_buffer))
latin_buffer = []
parts.append(char)
else:
latin_buffer.append(char)
if latin_buffer:
parts.append("".join(latin_buffer))
return [part for part in parts if part]
total_word_tokens = 0
matched_word_tokens = 0
missed_tokens = []
punct_events = []
def try_match(norm_tok: str, replacement: str, credit: int) -> bool:
nonlocal word_idx, last_word_idx, matched_word_tokens
lookahead = 4
for j in range(word_idx, min(len(words), word_idx + lookahead)):
if j in skipped_word_indices:
continue
if normalize(words[j].text) == norm_tok:
words[j].text = replacement
last_word_idx = j
matched_word_tokens += credit
word_idx = j + 1
return True
return False
def try_match_compound(token: str, parts: List[str], credit: int) -> bool:
nonlocal word_idx, last_word_idx, matched_word_tokens
if len(parts) <= 1:
return False
lookahead = max(4, len(parts) + 2)
for j in range(word_idx, min(len(words), word_idx + lookahead)):
if j in skipped_word_indices:
continue
matched_indices: List[int] = []
cursor = j
for part in parts:
while cursor < len(words) and cursor in skipped_word_indices:
cursor += 1
if cursor >= len(words) or normalize(words[cursor].text) != normalize(part):
matched_indices = []
break
matched_indices.append(cursor)
cursor += 1
if not matched_indices:
continue
first_idx = matched_indices[0]
last_idx = matched_indices[-1]
words[first_idx].text = token
words[first_idx].end = words[last_idx].end
for idx in matched_indices[1:]:
skipped_word_indices.add(idx)
last_word_idx = first_idx
matched_word_tokens += credit
word_idx = last_idx + 1
return True
return False
for tok in tokens:
# Word token
if re.match(r"[^\W_]", tok, flags=re.UNICODE):
cjk_parts = expand_cjk(tok)
if len(cjk_parts) > 1:
total_word_tokens += len(cjk_parts)
if try_match(normalize(tok), tok, len(cjk_parts)):
continue
matched_all_parts = True
for sub in cjk_parts:
if not try_match(normalize(sub), sub, 1):
matched_all_parts = False
if len(missed_tokens) < 3:
missed_tokens.append(sub)
if matched_all_parts:
continue
continue
compound_parts = expand_compound(tok)
apostrophe_parts = expand_contraction(tok)
total_word_tokens += len(compound_parts if "-" in tok else apostrophe_parts)
if word_idx >= len(words):
if len(missed_tokens) < 3:
missed_tokens.append(tok)
break
if "-" in tok:
if try_match(normalize(tok), tok, len(compound_parts)):
continue
if try_match_compound(tok, compound_parts, len(compound_parts)):
continue
if len(missed_tokens) < 3:
missed_tokens.append(tok)
continue
# Prefer matching the full contraction first (e.g. "I'll" -> "i'll").
if "'" in tok and try_match(normalize(tok), tok, len(apostrophe_parts)):
continue
# Fall back to aligning each contraction part separately when the
# timing stream splits them into multiple words.
for sub in apostrophe_parts:
if not try_match(normalize(sub), sub, 1) and len(missed_tokens) < 3:
missed_tokens.append(sub)
else:
# punctuation: attach to previous word, or next word if none
attached = False
if last_word_idx >= 0:
words[last_word_idx].text = f"{words[last_word_idx].text}{tok}"
punct_events.append({
"punct": tok,
"attached_to": words[last_word_idx].text,
"start": words[last_word_idx].start,
"end": words[last_word_idx].end,
"fallback": False,
})
attached = True
else:
# attach to next available word (fallback to avoid dropping)
for j in range(word_idx, len(words)):
if words[j].text:
words[j].text = f"{tok}{words[j].text}"
punct_events.append({
"punct": tok,
"attached_to": words[j].text,
"start": words[j].start,
"end": words[j].end,
"fallback": True,
})
attached = True
break
if not attached:
punct_events.append({
"punct": tok,
"attached_to": "[dropped]",
"start": None,
"end": None,
"fallback": True,
})
if skipped_word_indices:
words = [word for idx, word in enumerate(words) if idx not in skipped_word_indices]
return words, matched_word_tokens, total_word_tokens, missed_tokens, punct_events
def _dedupe_overlaps(words: List[ASRWord],
window_ms: int = 1500,
min_match_words: int = 2,
overlap_ratio: float = 0.6):
if not words:
return words, 0, []
window_s = max(0.0, window_ms / 1000.0)
out: List[ASRWord] = []
removed = 0
events = []
# Normalize text for matching
def norm(w: str) -> str:
return re.sub(r"[^\w]+", "", w, flags=re.UNICODE).lower()
i = 0
while i < len(words):
w = words[i]
# Look back for a potential overlap window
j = len(out) - 1
while j >= 0 and (w.start - out[j].end) <= window_s:
# Check overlap in time
if w.start <= out[j].end:
# Try to match sequences from out[j:] to words[i:]
max_k = min(6, len(words) - i, len(out) - j)
for k in range(max_k, min_match_words - 1, -1):
seq_a = [norm(out[j + t].text) for t in range(k)]
seq_b = [norm(words[i + t].text) for t in range(k)]
if seq_a == seq_b:
# Overlap ratio check using time span
span_a = out[j + k - 1].end - out[j].start
span_b = words[i + k - 1].end - words[i].start
if span_b <= 0:
span_b = 0.001
overlap = max(0.0, out[j + k - 1].end - words[i].start)
if overlap / span_b >= overlap_ratio:
# Skip duplicate sequence in current stream
removed += k
phrase = " ".join([out[j + t].text for t in range(k)])
events.append({
"phrase": phrase,
"start": words[i].start,
"end": words[i + k - 1].end,
"overlap_ratio": overlap / span_b,
"count": k,
})
i += k
break
else:
j -= 1
continue
break
j -= 1
else:
out.append(w)
i += 1
continue
# If we skipped, don't append current word
if i < len(words) and (len(out) == 0 or out[-1] is not words[i]):
continue
return out, removed, events
def build_srt(segments: List[ASRSegment],
mode: str = "smart",
full_text: str = "",
max_chars_per_line: int = 42,
max_lines: int = 2,
max_duration: float = 6.0,
min_duration: float = 1.0,
min_gap: float = 0.6,
max_cps: float = 20.0,
tts_ready_mode: bool = False,
tts_ready_paragraph_mode: bool = False,
return_stats: bool = False,
dedupe_overlaps: bool = True,
dedupe_window_ms: int = 1500,
dedupe_min_words: int = 2,
dedupe_overlap_ratio: float = 0.6,
punctuation_grace_chars: int = 12,
min_words_per_segment: int = 2,
min_segment_seconds: float = 0.4,
merge_trailing_punct_word: bool = True,
merge_trailing_punct_max_gap: float = 1.0,
merge_leading_short_phrase: bool = True,
merge_leading_short_max_words: int = 2,
merge_leading_short_max_gap: float = 2.0,
merge_dangling_tail: bool = True,
merge_dangling_tail_max_words: int = 3,
merge_dangling_tail_max_gap: float = 3.0,
merge_dangling_tail_allowlist: str = DEFAULT_CONNECTOR_ALLOWLIST,
merge_leading_short_no_punct: bool = True,
merge_leading_short_no_punct_max_words: int = 2,
merge_leading_short_no_punct_max_gap: float = 1.5,
merge_incomplete_sentence: bool = True,
merge_incomplete_max_gap: float = 1.2,
merge_incomplete_keywords: str = DEFAULT_INCOMPLETE_KEYWORDS,
merge_incomplete_split_next: bool = True,
merge_allow_overlong: bool = False,
normalize_cue_end_punctuation: bool = False):
if not segments:
return ("", {"matched": 0, "total": 0}) if return_stats else ""
tagged_profile = parse_tagged_text(full_text)
alignment_text = tagged_profile.spoken_text if tagged_profile.has_control_tags else full_text
if mode == "words":
flat_words = [w for s in segments for w in (s.words or [])]
if dedupe_overlaps:
flat_words, removed, dedupe_events = _dedupe_overlaps(
flat_words,
window_ms=dedupe_window_ms,
min_match_words=dedupe_min_words,
overlap_ratio=dedupe_overlap_ratio,
)
else:
removed = 0
dedupe_events = []
flat_words, matched, total, missed, punct_events = _apply_punctuation(flat_words, alignment_text)
segments_to_use = [ASRSegment(start=w.start, end=w.end, text=w.text, words=[w]) for w in flat_words]
elif mode == "engine_segments":
segments_to_use = segments
else:
flat_words = [w for s in segments for w in (s.words or [])]
if dedupe_overlaps:
flat_words, removed, dedupe_events = _dedupe_overlaps(
flat_words,
window_ms=dedupe_window_ms,
min_match_words=dedupe_min_words,
overlap_ratio=dedupe_overlap_ratio,
)
else:
removed = 0
dedupe_events = []
flat_words, matched, total, missed, punct_events = _apply_punctuation(flat_words, alignment_text)
if flat_words:
segments_to_use = _segments_from_words(
flat_words,
min_gap=min_gap,
max_duration=max_duration,
min_duration=min_duration,
max_chars=max_chars_per_line * max_lines,
max_cps=max_cps,
tts_ready_mode=tts_ready_mode,
tts_ready_paragraph_mode=tts_ready_paragraph_mode,
punctuation_grace_chars=punctuation_grace_chars,
min_words_per_segment=min_words_per_segment,
min_segment_seconds=min_segment_seconds,
merge_trailing_punct_word=merge_trailing_punct_word,
merge_trailing_punct_max_gap=merge_trailing_punct_max_gap,
merge_leading_short_phrase=merge_leading_short_phrase,
merge_leading_short_max_words=merge_leading_short_max_words,
merge_leading_short_max_gap=merge_leading_short_max_gap,
merge_dangling_tail=merge_dangling_tail,
merge_dangling_tail_max_words=merge_dangling_tail_max_words,
merge_dangling_tail_max_gap=merge_dangling_tail_max_gap,
merge_dangling_tail_allowlist=merge_dangling_tail_allowlist,
merge_leading_short_no_punct=merge_leading_short_no_punct,
merge_leading_short_no_punct_max_words=merge_leading_short_no_punct_max_words,
merge_leading_short_no_punct_max_gap=merge_leading_short_no_punct_max_gap,
merge_incomplete_sentence=merge_incomplete_sentence,
merge_incomplete_max_gap=merge_incomplete_max_gap,
merge_incomplete_keywords=merge_incomplete_keywords,
merge_incomplete_split_next=merge_incomplete_split_next,
merge_allow_overlong=merge_allow_overlong,
)
else:
segments_to_use = segments
stats = {
"matched": matched if 'matched' in locals() else 0,
"total": total if 'total' in locals() else 0,
"missed": missed if 'missed' in locals() else [],
"punct": punct_events if 'punct_events' in locals() else [],
"deduped": removed if 'removed' in locals() else 0,
"dedupe_events": dedupe_events if 'dedupe_events' in locals() else [],
}
# Heuristic: add sentence-ending punctuation when gaps + capitalization suggest a new sentence
for i in range(len(segments_to_use) - 1):
cur = segments_to_use[i]
nxt = segments_to_use[i + 1]
if not cur.text:
continue
if cur.text[-1:] in [".", "!", "?", "…"]:
continue
if nxt.text and nxt.text[:1].isupper() and (nxt.start - cur.end) >= min_gap:
cur.text = f"{cur.text}."
if cur.words:
cur.words[-1].text = f"{cur.words[-1].text}."
connector_words = {phrase[-1] for phrase in _parse_phrase_csv(merge_dangling_tail_allowlist) if phrase}
if tts_ready_mode:
display_segments = list(segments_to_use)
else:
display_segments: List[ASRSegment] = []
for seg in segments_to_use:
display_segments.extend(
_split_segment_for_display(
seg,
max_chars_per_line=max_chars_per_line,
max_lines=max_lines,
min_words_per_segment=min_words_per_segment,
min_segment_seconds=min_segment_seconds,
connector_words=connector_words,
)
)
if normalize_cue_end_punctuation:
for idx, seg in enumerate(display_segments):
if seg.text:
trimmed = _trim_trailing_soft_punctuation(seg.text)
stripped_soft_punct = trimmed != seg.text
seg.text = trimmed
if seg.words:
seg.words[-1].text = _trim_trailing_soft_punctuation(seg.words[-1].text)
if stripped_soft_punct and idx + 1 < len(display_segments):
next_seg = display_segments[idx + 1]
if next_seg.text:
next_seg.text = _capitalize_segment_start(next_seg.text)
if next_seg.words:
next_seg.words[0].text = _capitalize_segment_start(next_seg.words[0].text)
tagged_word_index_map: Optional[Dict[int, int]] = None
if tagged_profile.has_control_tags:
ordered_words = [word for segment in display_segments for word in (segment.words or [])]
if ordered_words:
tagged_word_index_map = {id(word): index for index, word in enumerate(ordered_words)}
lines: List[str] = []
for idx, seg in enumerate(display_segments, start=1):
start_ts = _format_timestamp(seg.start)
end_ts = _format_timestamp(seg.end)
if tagged_word_index_map and seg.words:
text = _render_tagged_segment(
seg,
max_chars_per_line=1000000 if tts_ready_mode else max_chars_per_line,
max_lines=1 if tts_ready_mode else max_lines,
tagged_profile=tagged_profile,
word_index_map=tagged_word_index_map,
global_last_word_idx=len(tagged_word_index_map) - 1,
)
else:
text = _join_display_texts(seg.text.split()) if tts_ready_mode else _wrap_text(seg.text, max_chars_per_line, max_lines)
if not text:
continue
lines.append(str(idx))
lines.append(f"{start_ts} --> {end_ts}")
lines.append(text)
lines.append("")
srt = "\n".join(lines).strip()
return (srt, stats) if return_stats else srt