204 lines
6.5 KiB
Python
204 lines
6.5 KiB
Python
# 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
|