mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-10 22:41:41 +00:00
fix(soniox): make synthesized subtitle cues work across scripts and languages
Cue grouping previously assumed space-separated Latin-style text. Chinese and Japanese audio fused entire utterances into one unbreakable word (and therefore one giant cue) because CJK scripts carry no spaces, and Arabic, Urdu, Hindi and Armenian sentence terminators never triggered a cue break. Words now also split at CJK character boundaries with basic kinsoku handling so punctuation stays attached, the sentence-end set covers script-specific terminators, and the cue length budget counts East Asian wide characters as double width so CJK cues match the same two-line subtitle footprint as Latin text.
This commit is contained in:
parent
8a5135110a
commit
78fbc57443
2 changed files with 123 additions and 5 deletions
|
|
@ -2,6 +2,7 @@
|
|||
Shared utilities for the Soniox provider (https://soniox.com).
|
||||
"""
|
||||
|
||||
import unicodedata
|
||||
from typing import Any, Final
|
||||
|
||||
from litellm.llms.base_llm.chat.transformation import BaseLLMException
|
||||
|
|
@ -119,7 +120,35 @@ _CUE_MAX_DURATION_MS: Final[int] = 7000
|
|||
|
||||
_CUE_GAP_MS: Final[int] = 700
|
||||
|
||||
_SENTENCE_END_CHARS = (".", "!", "?", "。", "!", "?")
|
||||
_SENTENCE_END_CHARS = (".", "!", "?", "。", "!", "?", "؟", "۔", "।", "॥", "։", "።")
|
||||
|
||||
_CJK_RANGES = (
|
||||
(0x3400, 0x4DBF),
|
||||
(0x4E00, 0x9FFF),
|
||||
(0xF900, 0xFAFF),
|
||||
(0x3040, 0x309F),
|
||||
(0x30A0, 0x30FF),
|
||||
(0x31F0, 0x31FF),
|
||||
)
|
||||
|
||||
_CJK_NO_BREAK_BEFORE = "、。,.!?:;・ー…」』)〉》】〕"
|
||||
|
||||
_CJK_NO_BREAK_AFTER = "「『(〈《【〔"
|
||||
|
||||
|
||||
def _is_cjk(ch: str) -> bool:
|
||||
cp = ord(ch)
|
||||
return any(lo <= cp <= hi for lo, hi in _CJK_RANGES)
|
||||
|
||||
|
||||
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 _format_timestamp_srt(ms: int) -> str:
|
||||
|
|
@ -151,7 +180,10 @@ def _merge_tokens_into_words(tokens: list[dict[str, Any]]) -> list[dict[str, Any
|
|||
Merge Soniox 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, or when the speaker changes.
|
||||
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.
|
||||
|
||||
Translation tokens (``translation_status == "translation"``) are excluded:
|
||||
|
|
@ -170,6 +202,7 @@ def _merge_tokens_into_words(tokens: list[dict[str, Any]]) -> list[dict[str, Any
|
|||
and not text[0].isspace()
|
||||
and not words[-1]["text"][-1:].isspace()
|
||||
and token.get("speaker") == words[-1]["speaker"]
|
||||
and not _is_cjk_word_boundary(words[-1]["text"][-1:], text[0])
|
||||
)
|
||||
if is_continuation:
|
||||
last = words[-1]
|
||||
|
|
@ -206,8 +239,8 @@ def _group_tokens_into_cues(
|
|||
- 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 (~two subtitle
|
||||
lines), or
|
||||
- adding the next word would exceed _CUE_MAX_CHARS of display width
|
||||
(~two subtitle lines; East-Asian wide characters count double), or
|
||||
- the cue would 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;
|
||||
|
|
@ -242,7 +275,7 @@ def _group_tokens_into_cues(
|
|||
w["speaker"] is not None and w["speaker"] != word["speaker"] for w in current
|
||||
)
|
||||
gap_exceeded = start_ms is not None and cue_end is not None and (start_ms - cue_end) >= _CUE_GAP_MS
|
||||
chars_exceeded = len(_cue_text(current)) + len(word["text"]) > _CUE_MAX_CHARS
|
||||
chars_exceeded = _text_width(_cue_text(current)) + _text_width(word["text"]) > _CUE_MAX_CHARS
|
||||
duration_exceeded = (
|
||||
start_ms is not None and cue_start is not None and (start_ms - cue_start) >= _CUE_MAX_DURATION_MS
|
||||
)
|
||||
|
|
|
|||
|
|
@ -467,6 +467,91 @@ class TestCueGroupingAlignment:
|
|||
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.llms.soniox.common_utils import _text_width, 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
|
||||
|
||||
def test_should_merge_subword_tokens_inside_cjk_run(self):
|
||||
from litellm.llms.soniox.common_utils import _merge_tokens_into_words
|
||||
|
||||
tokens = [
|
||||
{"text": "編", "start_ms": 0, "end_ms": 100},
|
||||
{"text": "集", "start_ms": 100, "end_ms": 200},
|
||||
{"text": "、", "start_ms": 200, "end_ms": 250},
|
||||
{"text": "保存", "start_ms": 250, "end_ms": 400},
|
||||
]
|
||||
words = _merge_tokens_into_words(tokens)
|
||||
assert [w["text"] for w in words] == ["編", "集、", "保存"]
|
||||
|
||||
|
||||
class TestRenderSonioxTokensAsVtt:
|
||||
def test_should_render_basic_vtt_with_header(self):
|
||||
from litellm.llms.soniox.common_utils import render_soniox_tokens_as_vtt
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue