Merge pull request #34440 from dan2k3k4/litellm_soniox_srt_cue_grouping

fix(soniox): align synthesized SRT/VTT cues to real speech timing
This commit is contained in:
Mateo Wang 2026-08-29 12:49:57 -07:00 committed by GitHub
commit 817bbe1dc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 381 additions and 63 deletions

View file

@ -1,19 +1,36 @@
"""Provider-agnostic SRT/WebVTT subtitle synthesis from timestamped transcription tokens."""
import unicodedata
from collections.abc import Sequence
from dataclasses import dataclass
from itertools import accumulate, chain
from itertools import accumulate, groupby
from typing import Final
from pydantic import BaseModel, ConfigDict, TypeAdapter, ValidationError
CUE_MAX_TOKENS: Final = 15
CUE_MAX_DURATION_MS: Final = 5000
CUE_MAX_CHARS: Final = 84
CUE_MAX_DURATION_MS: Final = 7000
CUE_GAP_MS: Final = 700
SRT_RESPONSE_FORMAT: Final = "srt"
VTT_RESPONSE_FORMAT: Final = "vtt"
SUBTITLE_RESPONSE_FORMATS: Final = frozenset((SRT_RESPONSE_FORMAT, VTT_RESPONSE_FORMAT))
_SENTENCE_END_CHARS: Final = (".", "!", "?", "", "", "", "؟", "۔", "", "", "։", "")
_CJK_RANGES: Final = (
(0x3400, 0x4DBF),
(0x4E00, 0x9FFF),
(0xF900, 0xFAFF),
(0x3040, 0x309F),
(0x30A0, 0x30FF),
(0x31F0, 0x31FF),
)
_CJK_NO_BREAK_BEFORE: Final = "、。,.!?:;・ー…」』)〉》】〕"
_CJK_NO_BREAK_AFTER: Final = "「『(〈《【〔"
@dataclass(frozen=True, slots=True)
class SubtitleToken:
@ -31,69 +48,138 @@ class SubtitleCue:
@dataclass(frozen=True, slots=True)
class _CueAccumulator:
texts: tuple[str, ...] = ()
start_ms: int | None = None
end_ms: int | None = None
speaker: str | int | None = None
class _Word:
text: str
start_ms: int | None
end_ms: int | None
speaker: str | int | None
def _completed_cue(accumulator: _CueAccumulator) -> tuple[SubtitleCue, ...]:
if not accumulator.texts or accumulator.start_ms is None:
return ()
text: Final = "".join(accumulator.texts).strip()
if not text:
return ()
end_ms: Final = accumulator.end_ms if accumulator.end_ms is not None else accumulator.start_ms
return (SubtitleCue(start_ms=accumulator.start_ms, end_ms=end_ms, text=text),)
def _is_cjk(ch: str) -> bool:
cp: Final = ord(ch)
return any(lo <= cp <= hi for lo, hi in _CJK_RANGES)
def _cue_break_reached(accumulator: _CueAccumulator, token: SubtitleToken) -> bool:
if len(accumulator.texts) >= CUE_MAX_TOKENS:
return True
def _is_cjk_word_boundary(prev_ch: str, next_ch: str) -> bool:
if not (_is_cjk(prev_ch) or _is_cjk(next_ch)):
return False
return next_ch not in _CJK_NO_BREAK_BEFORE and prev_ch not in _CJK_NO_BREAK_AFTER
def _text_width(text: str) -> int:
return sum(2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1 for ch in text)
def _starts_new_word(prev: SubtitleToken, token: SubtitleToken) -> bool:
prev_last: Final = prev.text[-1:]
first: Final = token.text[0]
return (
accumulator.start_ms is not None
and token.start_ms is not None
and token.start_ms - accumulator.start_ms >= CUE_MAX_DURATION_MS
first.isspace()
or prev_last.isspace()
or token.speaker != prev.speaker
or _is_cjk_word_boundary(prev_last, first)
)
_AbsorbStep = tuple[tuple[SubtitleCue, ...], _CueAccumulator]
def _absorb_token(accumulator: _CueAccumulator, token: SubtitleToken) -> _AbsorbStep:
if token.start_ms is None and accumulator.start_ms is None:
return (), accumulator
if token.speaker is not None and token.speaker != accumulator.speaker:
return _completed_cue(accumulator), _CueAccumulator(
texts=(token.text,),
start_ms=token.start_ms,
end_ms=token.end_ms,
speaker=token.speaker,
)
if _cue_break_reached(accumulator, token):
return _completed_cue(accumulator), _CueAccumulator(
texts=(token.text,),
start_ms=token.start_ms,
end_ms=token.end_ms,
speaker=accumulator.speaker,
)
return (), _CueAccumulator(
texts=(*accumulator.texts, token.text),
start_ms=accumulator.start_ms if accumulator.start_ms is not None else token.start_ms,
end_ms=token.end_ms if token.end_ms is not None else accumulator.end_ms,
speaker=accumulator.speaker,
def _build_word(group: Sequence[SubtitleToken]) -> _Word:
return _Word(
text="".join(t.text for t in group),
start_ms=next((t.start_ms for t in group if t.start_ms is not None), None),
end_ms=next((t.end_ms for t in reversed(group) if t.end_ms is not None), None),
speaker=group[0].speaker,
)
def _absorb_step(carry: _AbsorbStep, token: SubtitleToken) -> _AbsorbStep:
return _absorb_token(carry[1], token)
def _merge_tokens_into_words(tokens: Sequence[SubtitleToken]) -> tuple[_Word, ...]:
"""
Merge subword tokens (e.g. ``"Hel"``, ``"lo"``) into whole words.
A token starts a new word when its text begins with whitespace, when the
previous token's text ends with whitespace, when the speaker changes, or
at a CJK character boundary (CJK scripts carry no spaces, so without this
an entire utterance would fuse into a single unbreakable "word"; CJK
punctuation stays attached to the preceding character per kinsoku rules).
Each word carries the first/last available timestamps of its tokens.
"""
kept: Final = tuple(t for t in tokens if t.text != "")
starts: Final = tuple(i for i, t in enumerate(kept) if i == 0 or _starts_new_word(kept[i - 1], t))
return tuple(_build_word(kept[begin:end]) for begin, end in zip(starts, (*starts[1:], len(kept))))
def _cue_start(ws: Sequence[_Word]) -> int | None:
return next((w.start_ms for w in ws if w.start_ms is not None), None)
def _cue_end(ws: Sequence[_Word]) -> int | None:
return next((w.end_ms for w in reversed(ws) if w.end_ms is not None), _cue_start(ws))
def _cue_text(ws: Sequence[_Word]) -> str:
return "".join(w.text for w in ws).strip()
def _should_break(cue: Sequence[_Word], word: _Word) -> bool:
speaker_changed: Final = word.speaker is not None and any(
w.speaker is not None and w.speaker != word.speaker for w in cue
)
cue_start: Final = _cue_start(cue)
cue_end: Final = _cue_end(cue)
gap_exceeded: Final = word.start_ms is not None and cue_end is not None and (word.start_ms - cue_end) >= CUE_GAP_MS
chars_exceeded: Final = _text_width(_cue_text(cue)) + _text_width(word.text) > CUE_MAX_CHARS
word_end: Final = word.end_ms if word.end_ms is not None else word.start_ms
duration_exceeded: Final = (
word_end is not None and cue_start is not None and (word_end - cue_start) > CUE_MAX_DURATION_MS
)
return speaker_changed or gap_exceeded or chars_exceeded or duration_exceeded
def _cue_start_indices(words: Sequence[_Word]) -> tuple[int, ...]:
def next_start(start: int, index: int) -> int:
if words[index - 1].text.rstrip().endswith(_SENTENCE_END_CHARS):
return index
if _should_break(words[start:index], words[index]):
return index
return start
if not words:
return ()
return tuple(start for start, _ in groupby(accumulate(range(1, len(words)), next_start, initial=0)))
def _build_cue(ws: Sequence[_Word]) -> SubtitleCue | None:
text: Final = _cue_text(ws)
start: Final = _cue_start(ws)
if not text or start is None:
return None
end: Final = _cue_end(ws)
return SubtitleCue(start_ms=start, end_ms=end if end is not None else start, text=text)
def group_subtitle_tokens_into_cues(tokens: Sequence[SubtitleToken]) -> tuple[SubtitleCue, ...]:
steps: Final = tuple(accumulate(tokens, _absorb_step, initial=((), _CueAccumulator())))
completed: Final = chain.from_iterable(emitted for emitted, _ in steps)
return (*completed, *_completed_cue(steps[-1][1]))
"""
Group transcription tokens into subtitle cues aligned to the actual speech.
Cues only ever break at word boundaries (tokens may be subwords, so they
are first merged into words). A new cue starts when:
- the speaker changes (if diarization is on),
- a silence gap of at least CUE_GAP_MS separates two words, so
subtitles never bridge pauses in speech,
- adding the next word would exceed CUE_MAX_CHARS of display width
(~two subtitle lines; East-Asian wide characters count double), or
- adding the next word would make the cue span more than
CUE_MAX_DURATION_MS.
A cue also ends after sentence-final punctuation, which keeps cue breaks
at natural seams. Cue timestamps come straight from token timestamps;
words without timestamps stay attached to the surrounding cue, and a cue
whose words carry no timestamps at all is dropped.
"""
words: Final = _merge_tokens_into_words(tokens)
starts: Final = _cue_start_indices(words)
return tuple(
cue
for begin, end in zip(starts, (*starts[1:], len(words)))
if (cue := _build_cue(words[begin:end])) is not None
)
def _format_timestamp(total_ms: int, millis_separator: str) -> str:

View file

@ -138,13 +138,25 @@ def _soniox_token_to_subtitle_token(token: SonioxToken) -> SubtitleToken:
)
def _subtitle_tokens(tokens: Sequence[SonioxToken]) -> tuple[SubtitleToken, ...]:
"""
Convert Soniox tokens for subtitle rendering, excluding translation tokens
(``translation_status == "translation"``): Soniox does not timestamp them,
so they cannot be aligned to the audio and would otherwise mix translated
text into original-language cues.
"""
return tuple(
_soniox_token_to_subtitle_token(token) for token in tokens if token.get("translation_status") != "translation"
)
def render_soniox_tokens_as_srt(tokens: Sequence[SonioxToken]) -> str:
"""
Render Soniox tokens as SRT (SubRip) subtitle format.
Returns an empty string if no tokens have timestamp data.
"""
return render_subtitle_tokens_as_srt(tuple(_soniox_token_to_subtitle_token(token) for token in tokens))
return render_subtitle_tokens_as_srt(_subtitle_tokens(tokens))
def render_soniox_tokens_as_vtt(tokens: Sequence[SonioxToken]) -> str:
@ -153,4 +165,4 @@ def render_soniox_tokens_as_vtt(tokens: Sequence[SonioxToken]) -> str:
Returns the VTT header even if no cues are present.
"""
return render_subtitle_tokens_as_vtt(tuple(_soniox_token_to_subtitle_token(token) for token in tokens))
return render_subtitle_tokens_as_vtt(_subtitle_tokens(tokens))

View file

@ -1,5 +1,6 @@
from litellm.litellm_core_utils.audio_utils.subtitle_utils import (
SubtitleToken,
_merge_tokens_into_words,
render_subtitle_tokens_as_srt,
render_subtitle_tokens_as_vtt,
synthesize_subtitle_document,
@ -23,25 +24,59 @@ class TestRenderSubtitleTokensAsSrt:
"1\n00:00:00,000 --> 00:00:01,000\nHi.\n\n2\n00:00:01,500 --> 00:00:02,500\nHey.\n"
)
def test_token_cap_starts_a_new_cue_after_15_tokens(self):
tokens = tuple(
SubtitleToken(text=f"{index} ", start_ms=index * 100, end_ms=index * 100 + 100) for index in range(16)
def test_width_budget_starts_a_new_cue_at_word_boundaries(self):
tokens = tuple(SubtitleToken(text="abcdefghi ", start_ms=i * 100, end_ms=i * 100 + 90) for i in range(20))
result = render_subtitle_tokens_as_srt(tokens)
texts = [cue.split("\n", 2)[2] for cue in result.strip().split("\n\n")]
assert len(texts) == 3
assert all(len(text) <= 84 for text in texts)
assert all(set(text.split()) == {"abcdefghi"} for text in texts)
def test_duration_cap_starts_a_new_cue_before_word_crossing_7000ms(self):
tokens = (
SubtitleToken(text="Alpha ", start_ms=0, end_ms=3400),
SubtitleToken(text="beta ", start_ms=3400, end_ms=6800),
SubtitleToken(text="gamma", start_ms=6800, end_ms=7400),
)
assert render_subtitle_tokens_as_srt(tokens) == (
"1\n00:00:00,000 --> 00:00:01,500\n0 1 2 3 4 5 6 7 8 9 10 11 12 13 14\n"
"\n2\n00:00:01,500 --> 00:00:01,600\n15\n"
"1\n00:00:00,000 --> 00:00:06,800\nAlpha beta\n\n2\n00:00:06,800 --> 00:00:07,400\ngamma\n"
)
def test_duration_cap_starts_a_new_cue_at_5000ms(self):
def test_silence_gap_starts_a_new_cue(self):
tokens = (
SubtitleToken(text="Alpha ", start_ms=0, end_ms=400),
SubtitleToken(text="beta ", start_ms=2000, end_ms=2400),
SubtitleToken(text="gamma.", start_ms=5000, end_ms=5400),
SubtitleToken(text="beta", start_ms=2000, end_ms=2400),
)
assert render_subtitle_tokens_as_srt(tokens) == (
"1\n00:00:00,000 --> 00:00:02,400\nAlpha beta\n\n2\n00:00:05,000 --> 00:00:05,400\ngamma.\n"
"1\n00:00:00,000 --> 00:00:00,400\nAlpha\n\n2\n00:00:02,000 --> 00:00:02,400\nbeta\n"
)
def test_sentence_final_punctuation_starts_a_new_cue(self):
tokens = (
SubtitleToken(text="Done. ", start_ms=0, end_ms=400),
SubtitleToken(text="Next", start_ms=500, end_ms=800),
)
assert render_subtitle_tokens_as_srt(tokens) == (
"1\n00:00:00,000 --> 00:00:00,400\nDone.\n\n2\n00:00:00,500 --> 00:00:00,800\nNext\n"
)
def test_subword_tokens_merge_into_words_before_grouping(self):
tokens = (
SubtitleToken(text=" hel", start_ms=0, end_ms=150),
SubtitleToken(text="lo", start_ms=150, end_ms=300),
SubtitleToken(text=" world.", start_ms=350, end_ms=600),
)
assert render_subtitle_tokens_as_srt(tokens) == "1\n00:00:00,000 --> 00:00:00,600\nhello world.\n"
def test_cjk_tokens_merge_and_keep_punctuation_attached(self):
tokens = (
SubtitleToken(text="", start_ms=0, end_ms=100),
SubtitleToken(text="", start_ms=100, end_ms=200),
SubtitleToken(text="", start_ms=200, end_ms=250),
SubtitleToken(text="保存", start_ms=250, end_ms=400),
)
assert [word.text for word in _merge_tokens_into_words(tokens)] == ["", "集、", "保存"]
def test_timestampless_token_joins_the_current_cue(self):
tokens = (
SubtitleToken(text="Hello ", start_ms=0, end_ms=500),

View file

@ -369,6 +369,191 @@ class TestRenderSonioxTokensAsSrt:
assert "01:01:01,000" in result
def _subword_tokens(words, start_ms=0, subword_ms=150, inter_word_gap_ms=50):
tokens = []
t = start_ms
for word in words:
halves = [word[: len(word) // 2], word[len(word) // 2 :]] if len(word) > 3 else [word]
for i, piece in enumerate(halves):
text = (" " + piece) if i == 0 else piece
tokens.append({"text": text, "start_ms": t, "end_ms": t + subword_ms})
t += subword_ms
t += inter_word_gap_ms
return tokens, t
class TestCueGroupingAlignment:
def test_should_split_cue_on_silence_gap_with_exact_timestamps(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
before, t = _subword_tokens(["hello", "there"])
after, _ = _subword_tokens(["welcome", "back"], start_ms=t + 5000)
result = render_soniox_tokens_as_srt(before + after)
cues = result.strip().split("\n\n")
assert len(cues) == 2
assert "00:00:00,000 --> 00:00:00,650" in cues[0]
assert "hello there" in cues[0]
assert "00:00:05,700 --> 00:00:06,350" in cues[1]
assert "welcome back" in cues[1]
def test_should_not_bridge_pause_shorter_than_old_duration_cap(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
before, t = _subword_tokens(["first", "part"])
after, _ = _subword_tokens(["second", "part"], start_ms=t + 3000)
result = render_soniox_tokens_as_srt(before + after)
cues = result.strip().split("\n\n")
assert len(cues) == 2
assert "first part" in cues[0]
assert "second part" in cues[1]
def test_should_never_split_mid_word(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens, _ = _subword_tokens(["hello"] * 20)
result = render_soniox_tokens_as_srt(tokens)
text_lines = [
line for line in result.split("\n") if line and "-->" not in line and not line.isdigit()
]
assert len(text_lines) >= 2
for line in text_lines:
assert set(line.split()) == {"hello"}
def test_should_split_after_sentence_final_punctuation(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens, _ = _subword_tokens(["That", "is", "done.", "Next", "topic"])
result = render_soniox_tokens_as_srt(tokens)
cues = result.strip().split("\n\n")
assert len(cues) == 2
assert cues[0].endswith("That is done.")
assert cues[1].endswith("Next topic")
def test_should_split_on_char_budget_at_word_boundary(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens, _ = _subword_tokens(["wonderful"] * 12)
result = render_soniox_tokens_as_srt(tokens)
text_lines = [
line for line in result.split("\n") if line and "-->" not in line and not line.isdigit()
]
assert len(text_lines) >= 2
for line in text_lines:
assert len(line) <= 84
assert set(line.split()) == {"wonderful"}
def test_should_exclude_untimestamped_translation_tokens_from_cues(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": " Good", "start_ms": 0, "end_ms": 200, "translation_status": "original", "language": "en"},
{"text": " Guten", "translation_status": "translation", "language": "de", "source_language": "en"},
{"text": " morning.", "start_ms": 250, "end_ms": 600, "translation_status": "original", "language": "en"},
]
result = render_soniox_tokens_as_srt(tokens)
assert "Good morning." in result
assert "Guten" not in result
assert "00:00:00,000 --> 00:00:00,600" in result
def test_should_split_before_word_whose_end_crosses_duration_cap(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [{"text": " hm", "start_ms": i * 650, "end_ms": i * 650 + 600} for i in range(10)] + [
{"text": " boom", "start_ms": 6900, "end_ms": 7600}
]
result = render_soniox_tokens_as_srt(tokens)
cues = result.strip().split("\n\n")
assert len(cues) == 2
assert "00:00:00,000 --> 00:00:06,450" in cues[0]
assert "00:00:06,900 --> 00:00:07,600" in cues[1]
assert cues[1].endswith("boom")
def test_should_keep_untimestamped_word_in_cue(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": " uh", "start_ms": None, "end_ms": None},
{"text": " hello", "start_ms": 100, "end_ms": 500},
]
result = render_soniox_tokens_as_srt(tokens)
assert "uh hello" in result
assert "00:00:00,100 --> 00:00:00,500" in result
def _cue_texts(srt: str) -> list:
return [cue.split("\n", 2)[2] for cue in srt.strip().split("\n\n")]
class TestMultilingualCueGrouping:
def test_should_split_spaceless_chinese_on_width_budget(self):
from litellm.litellm_core_utils.audio_utils.subtitle_utils import _text_width
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [{"text": "你好", "start_ms": i * 100, "end_ms": i * 100 + 90} for i in range(60)]
result = render_soniox_tokens_as_srt(tokens)
texts = _cue_texts(result)
assert len(texts) >= 3
for text in texts:
assert _text_width(text) <= 84
assert set(text) <= {"", ""}
def test_should_split_japanese_after_sentence_end_and_keep_punctuation_attached(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": "今日は", "start_ms": 0, "end_ms": 300},
{"text": "いい", "start_ms": 300, "end_ms": 500},
{"text": "天気です", "start_ms": 500, "end_ms": 900},
{"text": "", "start_ms": 900, "end_ms": 950},
{"text": "明日も", "start_ms": 1000, "end_ms": 1300},
{"text": "晴れ", "start_ms": 1300, "end_ms": 1500},
]
texts = _cue_texts(render_soniox_tokens_as_srt(tokens))
assert texts == ["今日はいい天気です。", "明日も晴れ"]
def test_should_split_arabic_after_arabic_question_mark(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": " كيف", "start_ms": 0, "end_ms": 300},
{"text": " حالك؟", "start_ms": 300, "end_ms": 700},
{"text": " أنا", "start_ms": 800, "end_ms": 1000},
{"text": " بخير", "start_ms": 1000, "end_ms": 1300},
]
texts = _cue_texts(render_soniox_tokens_as_srt(tokens))
assert texts == ["كيف حالك؟", "أنا بخير"]
def test_should_split_after_devanagari_and_urdu_terminators(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": " नमस्ते।", "start_ms": 0, "end_ms": 400},
{"text": " آپ", "start_ms": 500, "end_ms": 700},
{"text": " ٹھیک۔", "start_ms": 700, "end_ms": 1100},
{"text": " शुभ", "start_ms": 1200, "end_ms": 1400},
]
texts = _cue_texts(render_soniox_tokens_as_srt(tokens))
assert texts == ["नमस्ते।", "آپ ٹھیک۔", "शुभ"]
def test_should_split_russian_after_sentence_end(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [
{"text": " Как", "start_ms": 0, "end_ms": 200},
{"text": " дела?", "start_ms": 200, "end_ms": 600},
{"text": " Хорошо.", "start_ms": 700, "end_ms": 1200},
]
texts = _cue_texts(render_soniox_tokens_as_srt(tokens))
assert texts == ["Как дела?", "Хорошо."]
def test_should_not_split_latin_text_within_width_budget(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_srt
tokens = [{"text": f" word{i}", "start_ms": i * 100, "end_ms": i * 100 + 90} for i in range(12)]
texts = _cue_texts(render_soniox_tokens_as_srt(tokens))
assert len(texts) == 1
class TestRenderSonioxTokensAsVtt:
def test_should_render_basic_vtt_with_header(self):
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_vtt