Files

166 lines
6.1 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Single-sample MiniCPM-o native-duplex generation."""
from __future__ import annotations
import asyncio
import hashlib
import json
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import TYPE_CHECKING
import pybase64 as base64
from vllm_omni.benchmarks.duplex.omni_duplex_eval_dataset import DuplexSample
from vllm_omni.experimental.fullduplex.client import (
RealtimeDuplexClient,
RealtimeEventCollector,
build_realtime_url,
wait_for,
)
if TYPE_CHECKING:
from vllm_omni.clients.duplex import EventCollector
from .omni_duplex_eval_clock import extract_timed_sentences
from .omni_duplex_eval_media import iter_av_units, iter_jpegs, materialize_media, read_audio_pcm16, video_duration
@dataclass(frozen=True)
class GenerateSampleResult:
"""Timed-sentence path plus duplex metrics for one generate sample."""
output: Path
request_metrics: list[dict[str, object]] = field(default_factory=list)
session_metrics: dict[str, object] = field(default_factory=dict)
def _ref_audio(path: str | Path) -> str:
value = Path(path).expanduser().read_bytes()
return "data:audio/wav;base64," + base64.b64encode(value).decode("ascii")
def _event_collector_from_realtime(source: RealtimeEventCollector) -> EventCollector:
"""Replay experimental collector events onto the public EventCollector."""
from vllm_omni.clients.duplex import EventCollector
collector = EventCollector()
for event, received_at_s in zip(source.events, source.event_received_at_s, strict=True):
collector.add(event, received_at_s=received_at_s)
return collector
def _with_sample_identity(payload: dict[str, object], sample: DuplexSample) -> dict[str, object]:
return {"sample_id": sample.id, "split": sample.split, **payload}
async def generate_sample(
sample: DuplexSample,
*,
url: str,
model: str,
ref_audio: str | Path,
output_root: str | Path,
fps: float = 1.0,
mix: str = "question",
pace: str = "realtime",
clock: str = "media",
overwrite: bool = False,
unit_ms: int = 1000,
) -> GenerateSampleResult:
"""Generate one sample's timed sentences and collect duplex session metrics.
Timed sentences and ``*.meta.json`` stay the quality-eval artifacts. Duplex
request/session metrics are returned for the CLI to write
``duplex_metrics.json``; they are not written into meta.
"""
output = Path(output_root) / sample.split / f"{sample.id}.json"
meta_path = output.with_name(output.stem + ".meta.json")
if output.exists() and not overwrite:
return GenerateSampleResult(output=output)
if mix != "question":
raise NotImplementedError("v1 supports mix=question; soundtrack mixing is reserved for P1")
media_dir = output.parent / ".media"
audio_path = materialize_media(sample.question_audio, media_dir, f"{sample.id}_question", ".wav")
video_path = materialize_media(sample.video, media_dir, sample.id, ".mp4")
pcm = read_audio_pcm16(audio_path)
duration = sample.video_duration or video_duration(video_path)
frames = iter_jpegs(video_path, fps=fps, duration=duration)
realtime = pace == "realtime"
if pace not in {"realtime", "as-fast-as-possible"}:
raise ValueError("pace must be realtime or as-fast-as-possible")
client = RealtimeDuplexClient(build_realtime_url(url, model, autostart=False))
response_done = False
drain_timeout = None
close_timeout = None
stream_start: float | None = None
async with client:
await client.configure(model, ref_audio=_ref_audio(ref_audio), instructions="Streaming Omni Conversation.")
ack_task = asyncio.create_task(_ack_playback(client))
try:
stream_start = time.monotonic()
await client.stream_av_units(iter_av_units(pcm, frames, unit_ms=unit_ms), realtime=realtime)
await client.commit()
try:
await wait_for(
lambda: client.events.count("response.done") > 0,
timeout_s=max(20.0, duration + 20.0),
label="response.done",
)
response_done = True
except TimeoutError as exc:
drain_timeout = str(exc)
finally:
ack_task.cancel()
try:
await ack_task
except asyncio.CancelledError:
pass
await client.acknowledge_playback()
try:
await client.close_session(timeout_s=20.0)
except TimeoutError as exc:
close_timeout = str(exc)
events = list(client.events.events)
collector = _event_collector_from_realtime(client.events)
timed = [sentence.as_dict() for sentence in extract_timed_sentences(events, clock=clock)]
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(json.dumps(timed, ensure_ascii=False, indent=2), encoding="utf-8")
meta = {
"id": sample.id,
"split": sample.split,
"clock": clock if realtime else "invalid",
"pace": pace,
"mix": mix,
"fps": fps,
"unit_ms": unit_ms,
"model": model,
"response_done": response_done,
"drain_timeout": drain_timeout,
"close_timeout": close_timeout,
"ref_audio_sha256": hashlib.sha256(Path(ref_audio).read_bytes()).hexdigest(),
}
meta_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
from vllm_omni.benchmarks.duplex_session_metrics import collect_duplex_session_metrics
bundle = collect_duplex_session_metrics(
collector,
stream_start=stream_start if stream_start is not None else 0.0,
session_id=sample.id,
)
return GenerateSampleResult(
output=output,
request_metrics=[_with_sample_identity(metric, sample) for metric in bundle.request_metrics],
session_metrics=_with_sample_identity(bundle.session_metrics, sample),
)
async def _ack_playback(client: RealtimeDuplexClient) -> None:
while True:
await asyncio.sleep(0.25)
await client.acknowledge_playback()