diff --git a/litellm/litellm_core_utils/audio_utils/subtitle_utils.py b/litellm/litellm_core_utils/audio_utils/subtitle_utils.py index 615873e295d..91427ba09ad 100644 --- a/litellm/litellm_core_utils/audio_utils/subtitle_utils.py +++ b/litellm/litellm_core_utils/audio_utils/subtitle_utils.py @@ -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: diff --git a/litellm/llms/soniox/common_utils.py b/litellm/llms/soniox/common_utils.py index cb33f0b8996..8d83b4c9218 100644 --- a/litellm/llms/soniox/common_utils.py +++ b/litellm/llms/soniox/common_utils.py @@ -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)) diff --git a/tests/test_litellm/litellm_core_utils/audio_utils/test_subtitle_utils.py b/tests/test_litellm/litellm_core_utils/audio_utils/test_subtitle_utils.py index dcc4163ff10..210513f4967 100644 --- a/tests/test_litellm/litellm_core_utils/audio_utils/test_subtitle_utils.py +++ b/tests/test_litellm/litellm_core_utils/audio_utils/test_subtitle_utils.py @@ -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), diff --git a/tests/test_litellm/llms/soniox/audio_transcription/test_soniox_audio_transcription_transformation.py b/tests/test_litellm/llms/soniox/audio_transcription/test_soniox_audio_transcription_transformation.py index 261efcb7b24..eadf870cb61 100644 --- a/tests/test_litellm/llms/soniox/audio_transcription/test_soniox_audio_transcription_transformation.py +++ b/tests/test_litellm/llms/soniox/audio_transcription/test_soniox_audio_transcription_transformation.py @@ -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