Files
vllm-omni/vllm_omni/entrypoints/openai/speech_text_splitter.py
T
2026-09-08 19:15:59 -07:00

205 lines
6.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Incremental text splitting for WebSocket TTS input.
Default serving still synthesizes one request per ``input.done`` (see
``split_granularity='none'``). Opting into ``sentence`` or ``clause`` restores
per-boundary requests so incremental STT/LLM clients can start audio before
the full utterance arrives.
Terminators cover Latin, CJK, Indic danda, and Arabic question/semicolon marks
so Hindi and similar scripts are not stuck waiting for ``.`` / ``?``.
"""
from __future__ import annotations
# ASCII punctuation is ambiguous (abbreviations, decimals, thousands
# separators), so it only closes a unit when whitespace or a flush follows.
# Script-specific marks below are unambiguous and close a unit as soon as the
# character after them is known.
_ASCII_SENTENCE_TERMINATORS = frozenset(".!?")
_ASCII_CLAUSE_TERMINATORS = _ASCII_SENTENCE_TERMINATORS | frozenset(",;")
_SCRIPT_SENTENCE_TERMINATORS = frozenset(
{
"。",
"!",
"?",
"…",
"।", # Devanagari danda
"॥", # Devanagari double danda
"؟", # Arabic question mark
}
)
_SCRIPT_CLAUSE_TERMINATORS = _SCRIPT_SENTENCE_TERMINATORS | {
",",
";",
"،", # Arabic comma
"؛", # Arabic semicolon
}
_SENTENCE_TERMINATORS = _ASCII_SENTENCE_TERMINATORS | _SCRIPT_SENTENCE_TERMINATORS
_CLAUSE_TERMINATORS = _ASCII_CLAUSE_TERMINATORS | _SCRIPT_CLAUSE_TERMINATORS
# Trailing characters that belong to the unit they close, so `Wait...` and
# `He said "Hello."` stay one TTS request instead of several.
_CLOSING_DELIMITERS = frozenset("\"'”’»)]}』」)】")
_RUN_CHARS = _CLAUSE_TERMINATORS | _CLOSING_DELIMITERS
# Abbreviations that end in `.` mid-sentence. Single letters are covered
# separately so initials such as `J. R. R.` do not split either.
_ABBREVIATIONS = frozenset(
{
"dr",
"mr",
"mrs",
"ms",
"prof",
"sr",
"jr",
"st",
"mt",
"fig",
"no",
"vs",
"etc",
"approx",
"dept",
"inc",
"ltd",
"co",
"al",
"e.g",
"i.e",
"u.s",
"u.k",
"a.m",
"p.m",
}
)
_MAX_ABBREVIATION_LEN = max(len(abbrev) for abbrev in _ABBREVIATIONS)
def _is_token_char(ch: str) -> bool:
return ch.isalnum() or ch == "."
def _is_numeric_separator(buffer: str, index: int) -> bool:
"""True for the `.`/`,` inside `3.14` or `1,000`."""
if buffer[index] not in ".,":
return False
if index == 0 or not buffer[index - 1].isdigit():
return False
return index + 1 < len(buffer) and buffer[index + 1].isdigit()
def _is_abbreviation(buffer: str, index: int) -> bool:
"""True for the `.` of `Dr.`, `e.g.`, or an initial like `J.`."""
if buffer[index] != ".":
return False
# Bounded so a frame of `a.a.a...` cannot walk the whole prefix at every
# `.`. Every abbreviation fits inside the limit, so hitting it can only
# rule a token out, never produce a false positive.
limit = max(0, index - _MAX_ABBREVIATION_LEN)
start = index
while start > limit and _is_token_char(buffer[start - 1]):
start -= 1
if start > 0 and _is_token_char(buffer[start - 1]):
return False
token = buffer[start:index]
if not token:
return False
if len(token) == 1 and token.isalpha():
return True
return token.lower().strip(".") in _ABBREVIATIONS
def extract_complete_units(
buffer: str,
terminators: frozenset[str],
*,
flush: bool,
scan_from: int = 0,
) -> tuple[list[str], str, int]:
"""Split ``buffer`` into complete units.
Returns ``(units, remainder, next_scan)``. ``next_scan`` is an offset into
``remainder``: everything before it has been examined already, so a caller
appending more text can pass it back as ``scan_from`` instead of rescanning
the whole buffer on every ``input.text`` message.
"""
units: list[str] = []
last_split = 0
i = max(scan_from, 0)
length = len(buffer)
while i < length:
ch = buffer[i]
if ch not in terminators or _is_numeric_separator(buffer, i) or _is_abbreviation(buffer, i):
i += 1
continue
# Absorb the punctuation/closing-delimiter run so `Wait...` emits once.
run_end = i + 1
while run_end < length and buffer[run_end] in _RUN_CHARS:
run_end += 1
if run_end >= length and not flush:
# The run may still grow; re-examine this terminator next feed.
return units, buffer[last_split:], i - last_split
ascii_terminator = ch in _ASCII_CLAUSE_TERMINATORS
if run_end < length and ascii_terminator and not buffer[run_end].isspace():
i = run_end
continue
piece = buffer[last_split:run_end].strip()
if piece:
units.append(piece)
i = run_end
while i < length and buffer[i].isspace():
i += 1
last_split = i
remainder = buffer[last_split:]
if flush:
tail = remainder.strip()
if tail:
units.append(tail)
return units, "", 0
return units, remainder, len(remainder)
class SpeechTextSplitter:
"""Stateful splitter used by one WebSocket utterance."""
def __init__(self, granularity: str = "none") -> None:
if granularity not in ("none", "sentence", "clause"):
raise ValueError(f"Unsupported split_granularity: {granularity!r}")
self.granularity = granularity
self._buf = ""
self._scan = 0
def _terminators(self) -> frozenset[str] | None:
if self.granularity == "none":
return None
if self.granularity == "clause":
return _CLAUSE_TERMINATORS
return _SENTENCE_TERMINATORS
def feed(self, text: str) -> list[str]:
self._buf += text
terminators = self._terminators()
if terminators is None:
return []
units, self._buf, self._scan = extract_complete_units(self._buf, terminators, flush=False, scan_from=self._scan)
return units
def flush(self) -> list[str]:
terminators = self._terminators()
if terminators is None:
piece = self._buf.strip()
self._buf = ""
self._scan = 0
return [piece] if piece else []
units, self._buf, self._scan = extract_complete_units(self._buf, terminators, flush=True, scan_from=self._scan)
return units