Files
vllm-omni/vllm_omni/benchmarks/duplex/omni_duplex_eval_clock.py
T
2026-09-11 08:50:12 +08:00

111 lines
3.9 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Media-clock sentence extraction and response JSON normalization."""
from __future__ import annotations
from collections.abc import Iterable
from dataclasses import dataclass
from typing import Any
import regex as re
_TERMINAL = re.compile(r"(.+?[.!?。!?]+)(?:\s+|$)", re.S)
@dataclass
class TimedSentence:
sentence: str
start: float
end: float
def as_dict(self) -> dict[str, Any]:
return {"sentence": self.sentence, "start": round(self.start, 3), "end": round(self.end, 3)}
def split_text(text: str) -> list[str]:
text = str(text or "").strip()
if not text:
return []
parts: list[str] = []
position = 0
for match in _TERMINAL.finditer(text):
parts.append(match.group(1).strip())
position = match.end()
tail = text[position:].strip()
if tail:
parts.append(tail)
return [part for part in parts if part]
def extract_timed_sentences(events: Iterable[dict[str, Any]], *, clock: str = "media") -> list[TimedSentence]:
"""Split output deltas while anchoring text to input ``audio_end_ms``."""
if clock != "media":
raise ValueError("only clock=media is supported")
sentences: list[TimedSentence] = []
pending = ""
start: float | None = None
last: float | None = None
for event in events:
event_type = event.get("type")
if event_type not in {
"response.output_text.delta",
"response.output_audio_transcript.delta",
"response.text.delta",
}:
continue
delta = event.get("delta")
if not isinstance(delta, str) or not delta.strip():
continue
timestamp = event.get("audio_end_ms")
if timestamp is None:
timestamp = event.get("_media_clock_ms", event.get("current_time_ms", 0.0))
try:
# Every accepted event field is explicitly millisecond-valued.
now = float(timestamp) / 1000.0
except (TypeError, ValueError):
now = last or 0.0
pending += delta
start = now if start is None else start
last = now
pieces = split_text(pending)
complete = pieces if pieces and re.search(r"[.!?。!?]$", pending.strip()) else pieces[:-1]
if complete:
for piece in complete:
sentences.append(TimedSentence(piece, start, max(start, now)))
consumed = " ".join(complete)
pending = pending[len(consumed) :].strip()
start = now if pending else None
if pending.strip():
sentences.append(TimedSentence(pending.strip(), start or last or 0.0, last or start or 0.0))
return sentences
def normalize_response_items(raw: Any) -> list[dict[str, Any]]:
if isinstance(raw, dict):
items = raw.get("sentences", raw.get("chunks"))
else:
items = raw
if not isinstance(items, list):
raise ValueError("response JSON must be a list or contain sentences/chunks")
normalized = []
for item in items:
if not isinstance(item, dict):
continue
text = item.get("sentence", item.get("text", item.get("content", "")))
if not isinstance(text, str) or not text.strip():
continue
try:
start = float(item.get("start", item.get("current_time", item.get("time", 0.0))) or 0.0)
end = float(item.get("end", item.get("current_time", item.get("time", start))) or start)
except (TypeError, ValueError):
continue
normalized.append({"sentence": text.strip(), "start": start, "end": max(start, end)})
return normalized
def validate_clock(meta: dict[str, Any], *, allow_invalid: bool = False) -> None:
if meta.get("clock") == "invalid" and not allow_invalid:
raise ValueError("generate artifact has clock=invalid; pass --allow-invalid-clock to score it")