Signed-off-by: chickeyton <ngton2014@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1968 lines
76 KiB
Python
1968 lines
76 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
"""Public asynchronous client for the vLLM-Omni full-duplex Realtime API.
|
|
|
|
:class:`DuplexClientBase` holds the transport-agnostic session client (typed
|
|
events, response demultiplexing, incremental playback acking, the session
|
|
handshake). :class:`DuplexClient` connects it to ``/v1/realtime?duplex=1``
|
|
(the normative duplex contract) over WebSocket with transparent session
|
|
resume on transport drops; :class:`vllm_omni.clients.inline_duplex.InlineDuplexClient`
|
|
drives an in-process :class:`~vllm_omni.entrypoints.duplex_omni.DuplexOmni`
|
|
with exactly the same usage.
|
|
|
|
Example::
|
|
|
|
from vllm_omni.clients.duplex import DuplexClient
|
|
from vllm_omni.clients.minicpmo_4_5 import create_duplex_session_config
|
|
|
|
cfg = create_duplex_session_config(ref_audio=audio_data_url(wav_bytes))
|
|
async with DuplexClient("ws://localhost:8099", model=model, config=cfg) as client:
|
|
await client.stream_pcm(pcm16)
|
|
await client.commit()
|
|
async for response in client.responses():
|
|
if response.decision == "listen":
|
|
continue
|
|
async for chunk in response.audio():
|
|
play(chunk)
|
|
await client.ack_playback(response.played_ms)
|
|
break
|
|
|
|
Media capture/playback, VAD, and resampling stay in the application; this
|
|
module moves bytes and events.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import math
|
|
import random
|
|
import time
|
|
import wave
|
|
from abc import ABC, abstractmethod
|
|
from collections.abc import AsyncIterator, Awaitable, Callable, Iterator, Mapping, Sequence
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
from uuid import uuid4
|
|
|
|
import pybase64 as base64
|
|
|
|
__all__ = [
|
|
"DUPLEX_FIRST_UNIT_MS",
|
|
"DUPLEX_UNIT_MS",
|
|
"PCM16_BYTES_PER_SAMPLE",
|
|
"PCM16_SAMPLE_RATE",
|
|
"AudioFormat",
|
|
"AudioDelta",
|
|
"ConnectionResumed",
|
|
"DuplexClient",
|
|
"DuplexClientBase",
|
|
"DuplexClientError",
|
|
"DuplexConnectionError",
|
|
"DuplexEvent",
|
|
"DuplexProtocolError",
|
|
"DuplexSessionClosedError",
|
|
"ErrorEvent",
|
|
"EventCollector",
|
|
"ListenDecision",
|
|
"ReconnectPolicy",
|
|
"ResponseCreated",
|
|
"ResponseDone",
|
|
"ResponseHandle",
|
|
"SessionConfig",
|
|
"SessionCreated",
|
|
"SpeakDecision",
|
|
"WebSocketTransport",
|
|
"TextDelta",
|
|
"TranscriptDelta",
|
|
"acknowledge_collected_playback",
|
|
"audio_data_url",
|
|
"build_realtime_url",
|
|
"chunk_period_ms",
|
|
"duplex_unit_boundary_ms",
|
|
"has_residual_model_unit",
|
|
"image_data_url",
|
|
"read_pcm16_wav",
|
|
"reference_audio_data_url",
|
|
"distribution_summary",
|
|
"metric_mean",
|
|
"summarize_session_request_metrics",
|
|
"wait_for_condition",
|
|
"write_pcm16_wav",
|
|
]
|
|
|
|
# The default duplex input wire format (16 kHz mono PCM16), kept as plain
|
|
# constants for callers doing byte/duration arithmetic outside AudioFormat.
|
|
PCM16_SAMPLE_RATE = 16_000
|
|
PCM16_BYTES_PER_SAMPLE = 2
|
|
|
|
# Server-side model unit boundaries, in cumulative appended audio.
|
|
# Stage0 configures the streaming mel processor with first_chunk_ms=1035 and
|
|
# chunk_ms=1000; the processor aligns the first chunk down to a hop_length (160
|
|
# samples) multiple, so unit 0 closes at 16480 samples and every later unit
|
|
# closes 16000 samples after it. Camera frames must ride the append that closes
|
|
# a unit, otherwise Stage0 cannot bind them to that unit's audio.
|
|
DUPLEX_FIRST_UNIT_MS = 1030
|
|
DUPLEX_UNIT_MS = 1000
|
|
|
|
|
|
def duplex_unit_boundary_ms(unit_index: int) -> int:
|
|
"""Cumulative appended audio, in ms, that closes model unit ``unit_index``."""
|
|
return DUPLEX_FIRST_UNIT_MS + max(0, int(unit_index)) * DUPLEX_UNIT_MS
|
|
|
|
|
|
# Internal transport/backpressure bounds (promote to constructor parameters
|
|
# only if a real consumer needs to tune them).
|
|
_MAX_BUFFERED_EVENTS = 1024
|
|
_MAX_FRAME_BYTES = 64 << 20
|
|
|
|
|
|
def _bare_base64(data: str) -> str:
|
|
"""Strip a ``data:<mime>;base64,`` prefix; the wire carries bare base64."""
|
|
if data.startswith("data:"):
|
|
_, _, payload = data.partition(";base64,")
|
|
if payload:
|
|
return payload
|
|
return data
|
|
|
|
|
|
def _put_drop_oldest(queue: asyncio.Queue, item: object) -> None:
|
|
"""Deliver ``item`` without blocking, dropping the oldest entry when full.
|
|
|
|
Every internal feed path uses this: a consumer that stops draining its
|
|
queue loses its oldest buffered events instead of parking the single
|
|
reader task, which must keep dispatching (and acking) for the rest of
|
|
the session.
|
|
"""
|
|
try:
|
|
queue.put_nowait(item)
|
|
except asyncio.QueueFull:
|
|
try:
|
|
queue.get_nowait()
|
|
except asyncio.QueueEmpty: # pragma: no cover - racing consumer
|
|
pass
|
|
queue.put_nowait(item)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Errors
|
|
|
|
|
|
class DuplexClientError(Exception):
|
|
"""Base class for all duplex client errors."""
|
|
|
|
|
|
class DuplexConnectionError(DuplexClientError):
|
|
"""The WebSocket transport could not be established or failed."""
|
|
|
|
|
|
class DuplexProtocolError(DuplexClientError):
|
|
"""The server rejected a request with an ``error`` event."""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
code: str | None = None,
|
|
event_id: str | None = None,
|
|
raw: dict[str, object] | None = None,
|
|
) -> None:
|
|
super().__init__(message)
|
|
self.code = code
|
|
self.event_id = event_id
|
|
self.raw = dict(raw or {})
|
|
|
|
|
|
class DuplexSessionClosedError(DuplexClientError):
|
|
"""The session ended and the client cannot continue."""
|
|
|
|
def __init__(self, reason: str) -> None:
|
|
super().__init__(f"duplex session closed: {reason}")
|
|
self.reason = reason
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Configuration
|
|
|
|
_BYTES_PER_SAMPLE = {"pcm16": 2, "pcm_f32le": 4}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AudioFormat:
|
|
"""One PCM wire format: encoding name plus sample rate."""
|
|
|
|
encoding: str
|
|
sample_rate_hz: int
|
|
|
|
@property
|
|
def bytes_per_sample(self) -> int:
|
|
try:
|
|
return _BYTES_PER_SAMPLE[self.encoding]
|
|
except KeyError:
|
|
raise ValueError(f"Unknown audio encoding: {self.encoding!r}") from None
|
|
|
|
def duration_ms(self, byte_count: int) -> float:
|
|
return byte_count * 1000.0 / (self.sample_rate_hz * self.bytes_per_sample)
|
|
|
|
def byte_count(self, duration_ms: float) -> int:
|
|
samples = round(self.sample_rate_hz * duration_ms / 1000.0)
|
|
return int(samples) * self.bytes_per_sample
|
|
|
|
|
|
@dataclass
|
|
class SessionConfig:
|
|
"""Model-agnostic duplex session configuration.
|
|
|
|
Model-specific knobs ride in ``extra_body``; each duplex model ships a
|
|
``create_duplex_session_config`` preset in its client module (e.g.
|
|
``vllm_omni.clients.minicpmo_4_5``), so the client itself stays
|
|
model-neutral.
|
|
"""
|
|
|
|
modalities: tuple[str, ...] = ("audio", "text")
|
|
input_audio: AudioFormat = AudioFormat("pcm16", 16_000)
|
|
output_audio: AudioFormat = AudioFormat("pcm16", 24_000)
|
|
instructions: str | None = None
|
|
ref_audio: str | None = None
|
|
voice: str | None = None
|
|
auto_response: bool = True
|
|
temperature: float | None = None
|
|
overlap_policy: str | None = None
|
|
playback_commit_policy: str | None = None
|
|
turn_detection: dict[str, object] | None = None
|
|
idle_timeout_s: float | None = None
|
|
extra_body: dict[str, object] = field(default_factory=dict)
|
|
|
|
def to_session_payload(self, *, model: str) -> dict[str, object]:
|
|
payload: dict[str, object] = {
|
|
"model": model,
|
|
"modalities": list(self.modalities),
|
|
"input_audio_format": self.input_audio.encoding,
|
|
"output_audio_format": self.output_audio.encoding,
|
|
# The encodings alone do not pin the rates (presets differ from the
|
|
# server defaults), so declare both explicitly: the server reads
|
|
# the input rate from the top-level field and the output rate from
|
|
# the audio.output block.
|
|
"sample_rate_hz": self.input_audio.sample_rate_hz,
|
|
"audio": {
|
|
"input": {"sample_rate_hz": self.input_audio.sample_rate_hz},
|
|
"output": {"sample_rate_hz": self.output_audio.sample_rate_hz},
|
|
},
|
|
"turn_detection": self.turn_detection,
|
|
}
|
|
if self.instructions is not None:
|
|
payload["instructions"] = self.instructions
|
|
if self.ref_audio is not None:
|
|
payload["ref_audio"] = self.ref_audio
|
|
if self.voice is not None:
|
|
payload["voice"] = self.voice
|
|
if self.temperature is not None:
|
|
payload["temperature"] = self.temperature
|
|
if self.overlap_policy is not None:
|
|
payload["overlap_policy"] = self.overlap_policy
|
|
if self.playback_commit_policy is not None:
|
|
payload["playback_commit_policy"] = self.playback_commit_policy
|
|
if self.idle_timeout_s is not None:
|
|
payload["idle_timeout_s"] = self.idle_timeout_s
|
|
extra_body: dict[str, object] = {"auto_response": self.auto_response}
|
|
extra_body.update(self.extra_body)
|
|
payload["extra_body"] = extra_body
|
|
return payload
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ReconnectPolicy:
|
|
"""Jittered exponential backoff for ``session.resume`` reconnects."""
|
|
|
|
max_attempts: int = 5
|
|
backoff_s: tuple[float, float] = (0.25, 4.0)
|
|
|
|
def delay(self, attempt: int) -> float:
|
|
low, high = self.backoff_s
|
|
ceiling = min(high, low * (2.0 ** max(0, attempt)))
|
|
return random.uniform(low, max(low, ceiling))
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Typed events
|
|
|
|
|
|
class DuplexEvent:
|
|
"""Thin typed wrapper over one server event dict."""
|
|
|
|
__slots__ = ("raw",)
|
|
|
|
def __init__(self, raw: dict[str, object]) -> None:
|
|
self.raw = raw
|
|
|
|
@property
|
|
def type(self) -> str:
|
|
value = self.raw.get("type")
|
|
return value if isinstance(value, str) else ""
|
|
|
|
@property
|
|
def event_id(self) -> str | None:
|
|
value = self.raw.get("event_id")
|
|
return value if isinstance(value, str) else None
|
|
|
|
@property
|
|
def session_id(self) -> str | None:
|
|
value = self.raw.get("session_id")
|
|
return value if isinstance(value, str) else None
|
|
|
|
@property
|
|
def response_id(self) -> str | None:
|
|
value = self.raw.get("response_id")
|
|
if isinstance(value, str):
|
|
return value
|
|
response = self.raw.get("response")
|
|
if isinstance(response, dict):
|
|
value = response.get("id")
|
|
if isinstance(value, str):
|
|
return value
|
|
return None
|
|
|
|
@property
|
|
def item_id(self) -> str | None:
|
|
value = self.raw.get("item_id")
|
|
return value if isinstance(value, str) else None
|
|
|
|
@property
|
|
def server_event_seq(self) -> int | None:
|
|
value = self.raw.get("server_event_seq")
|
|
return value if isinstance(value, int) else None
|
|
|
|
@property
|
|
def sample_rate_hz(self) -> int | None:
|
|
value = self.raw.get("sample_rate_hz")
|
|
return value if isinstance(value, int) and value > 0 else None
|
|
|
|
@property
|
|
def audio(self) -> bytes | None:
|
|
delta = self.raw.get("delta") or self.raw.get("audio")
|
|
if not isinstance(delta, str) or not delta:
|
|
return None
|
|
try:
|
|
return base64.b64decode(delta)
|
|
except ValueError:
|
|
return None
|
|
|
|
@property
|
|
def text(self) -> str | None:
|
|
for key in ("text", "delta", "transcript"):
|
|
value = self.raw.get(key)
|
|
if isinstance(value, str):
|
|
return value
|
|
return None
|
|
|
|
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
|
return f"{type(self).__name__}(type={self.type!r})"
|
|
|
|
|
|
class SessionCreated(DuplexEvent):
|
|
@property
|
|
def session(self) -> dict[str, object]:
|
|
value = self.raw.get("session")
|
|
return value if isinstance(value, dict) else {}
|
|
|
|
@property
|
|
def resume_token(self) -> str | None:
|
|
value = self.raw.get("resume_token")
|
|
return value if isinstance(value, str) else None
|
|
|
|
|
|
class SessionResumed(SessionCreated):
|
|
pass
|
|
|
|
|
|
class SessionClosed(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class SessionExpired(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class ResponseCreated(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class ResponseDone(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class AudioDelta(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class TranscriptDelta(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class TextDelta(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class ListenDecision(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class SpeakDecision(DuplexEvent):
|
|
pass
|
|
|
|
|
|
class ConnectionResumed(DuplexEvent):
|
|
"""Synthetic client event: the transport dropped and was resumed."""
|
|
|
|
|
|
class ErrorEvent(DuplexEvent):
|
|
@property
|
|
def _error(self) -> dict[str, object] | None:
|
|
value = self.raw.get("error")
|
|
return value if isinstance(value, dict) else None
|
|
|
|
@property
|
|
def code(self) -> str | None:
|
|
error = self._error
|
|
value = (error or self.raw).get("code")
|
|
return value if isinstance(value, str) else None
|
|
|
|
@property
|
|
def message(self) -> str:
|
|
error = self._error
|
|
if error is not None:
|
|
value = error.get("message")
|
|
if isinstance(value, str):
|
|
return value
|
|
value = self.raw.get("error")
|
|
return value if isinstance(value, str) else json.dumps(self.raw)
|
|
|
|
@property
|
|
def related_event_id(self) -> str | None:
|
|
error = self._error
|
|
value = error.get("event_id") if error is not None else None
|
|
return value if isinstance(value, str) else None
|
|
|
|
def to_exception(self) -> DuplexProtocolError:
|
|
return DuplexProtocolError(
|
|
self.message,
|
|
code=self.code,
|
|
event_id=self.related_event_id,
|
|
raw=self.raw,
|
|
)
|
|
|
|
|
|
_EVENT_TYPES: dict[str, type[DuplexEvent]] = {
|
|
"session.created": SessionCreated,
|
|
"session.resumed": SessionResumed,
|
|
"session.closed": SessionClosed,
|
|
"session.expired": SessionExpired,
|
|
"response.created": ResponseCreated,
|
|
"response.done": ResponseDone,
|
|
"response.output_audio.delta": AudioDelta,
|
|
"response.output_audio_transcript.delta": TranscriptDelta,
|
|
"response.text.delta": TextDelta,
|
|
"response.output_text.delta": TextDelta,
|
|
"response.listen": ListenDecision,
|
|
"response.speak": SpeakDecision,
|
|
"connection.resumed": ConnectionResumed,
|
|
"error": ErrorEvent,
|
|
}
|
|
|
|
_AUDIO_DELTA_TYPES = frozenset(name for name, event_cls in _EVENT_TYPES.items() if event_cls is AudioDelta)
|
|
|
|
_FATAL_RESUME_CODES = frozenset(
|
|
{
|
|
"invalid_session_resume",
|
|
"session_resume_expired",
|
|
"unsupported_session_resume",
|
|
"invalid_resume_token",
|
|
"session_resume_conflict",
|
|
}
|
|
)
|
|
|
|
|
|
def wrap_event(raw: dict[str, object]) -> DuplexEvent:
|
|
event_type = raw.get("type")
|
|
event_cls = _EVENT_TYPES.get(event_type) if isinstance(event_type, str) else None
|
|
return (event_cls or DuplexEvent)(raw)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ClosedMarker:
|
|
reason: str
|
|
expected: bool
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _ErrorMarker:
|
|
error: ErrorEvent
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Response demultiplexing
|
|
|
|
|
|
class ResponseHandle:
|
|
"""One ``response.created`` → terminal lifecycle, demultiplexed.
|
|
|
|
A handle reaches :meth:`DuplexClient.responses` only once its decision is
|
|
known: when the response starts producing output (``decision`` is
|
|
``"speak"`` or about to become it) or when a terminal listen finishes it
|
|
(``decision == "listen"``, no audio). Standalone listen decisions surface
|
|
as already-finished handles too, so a turn loop stays uniform.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
response_id: str | None,
|
|
output_format: AudioFormat,
|
|
max_buffered_events: int,
|
|
decision: str | None = None,
|
|
created_event: ResponseCreated | None = None,
|
|
) -> None:
|
|
self.response_id = response_id
|
|
self.decision = decision
|
|
self.transcript = ""
|
|
self.text = ""
|
|
self.played_ms = 0.0
|
|
self.created_event = created_event
|
|
self.done_event: DuplexEvent | None = None
|
|
self._output_format = output_format
|
|
self._audio_queue: asyncio.Queue[tuple[bytes, int | None] | None] = asyncio.Queue(maxsize=max_buffered_events)
|
|
self._done = asyncio.Event()
|
|
self._announced = False
|
|
|
|
@property
|
|
def finished(self) -> bool:
|
|
return self._done.is_set()
|
|
|
|
async def wait(self, timeout_s: float | None = None) -> None:
|
|
if timeout_s is None:
|
|
await self._done.wait()
|
|
else:
|
|
await asyncio.wait_for(self._done.wait(), timeout_s)
|
|
|
|
async def audio(self) -> AsyncIterator[bytes]:
|
|
"""Yield decoded output-audio chunks until the response finishes.
|
|
|
|
``played_ms`` advances as chunks are yielded, so it reflects what the
|
|
application has taken for playback — feed it to
|
|
:meth:`DuplexClient.ack_playback`. A handle whose audio is never
|
|
drained drops its oldest buffered chunks once the buffer fills; it
|
|
never stalls the client's reader.
|
|
"""
|
|
while True:
|
|
item = await self._audio_queue.get()
|
|
if item is None:
|
|
return
|
|
chunk, sample_rate_hz = item
|
|
rate = sample_rate_hz or self._output_format.sample_rate_hz
|
|
self.played_ms += len(chunk) * 1000.0 / (rate * self._output_format.bytes_per_sample)
|
|
yield chunk
|
|
|
|
def _feed(self, event: DuplexEvent) -> None:
|
|
if self._done.is_set():
|
|
return
|
|
if isinstance(event, AudioDelta):
|
|
chunk = event.audio
|
|
if chunk:
|
|
_put_drop_oldest(self._audio_queue, (chunk, event.sample_rate_hz))
|
|
elif isinstance(event, TranscriptDelta):
|
|
self.transcript += event.text or ""
|
|
elif isinstance(event, TextDelta):
|
|
self.text += event.text or ""
|
|
elif isinstance(event, SpeakDecision):
|
|
self.decision = "speak"
|
|
elif isinstance(event, ListenDecision):
|
|
# A terminal listen on a response that already spoke is the
|
|
# model yielding the turn; it must not downgrade the decision.
|
|
if self.decision is None:
|
|
self.decision = "listen"
|
|
|
|
def _finish(self, event: DuplexEvent | None) -> None:
|
|
if self._done.is_set():
|
|
return
|
|
self.done_event = event
|
|
self._done.set()
|
|
# A full queue cannot block _finish: the sentinel is delivered by a
|
|
# non-blocking put with a drop-oldest fallback.
|
|
_put_drop_oldest(self._audio_queue, None)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Client
|
|
|
|
|
|
class WebSocketTransport(ABC):
|
|
"""Minimal WebSocket surface the client needs (satisfied by ``websockets``)."""
|
|
|
|
@abstractmethod
|
|
async def send(self, data: str) -> None: ...
|
|
|
|
@abstractmethod
|
|
async def recv(self) -> str | bytes: ...
|
|
|
|
@abstractmethod
|
|
async def close(self) -> None: ...
|
|
|
|
|
|
ConnectFn = Callable[[str], Awaitable["WebSocketTransport"]]
|
|
|
|
|
|
class DuplexClientBase(ABC):
|
|
"""Transport-agnostic duplex session client.
|
|
|
|
Everything a caller touches (typed events, response demux, input helpers,
|
|
playback acks, the ``session.update`` handshake) lives here. A subclass
|
|
only supplies the transport: :meth:`_open` establishes it and must deliver
|
|
the server's ``session.created`` payload through :meth:`_dispatch`,
|
|
:meth:`_send_command` delivers one client event, :meth:`_teardown`
|
|
releases the transport.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
model: str,
|
|
config: SessionConfig | None = None,
|
|
handshake_timeout_s: float = 30.0,
|
|
) -> None:
|
|
self.model = model
|
|
self.config = config or SessionConfig()
|
|
#: Allocated by the server; ``None`` until ``session.created`` arrives.
|
|
self.session_id: str | None = None
|
|
self.session_info: dict[str, object] = {}
|
|
self._handshake_timeout_s = handshake_timeout_s
|
|
self._subscribers: list[asyncio.Queue[DuplexEvent | _ClosedMarker]] = []
|
|
self._response_queue: asyncio.Queue[ResponseHandle | _ClosedMarker | _ErrorMarker] = asyncio.Queue(
|
|
maxsize=_MAX_BUFFERED_EVENTS
|
|
)
|
|
self._responses: dict[str, ResponseHandle] = {}
|
|
self._input_audio_end_ms = 0.0
|
|
self._closing = False
|
|
self._closed = asyncio.Event()
|
|
self._closed_marker: _ClosedMarker | None = None
|
|
|
|
# -- transport hooks ------------------------------------------------------
|
|
|
|
@abstractmethod
|
|
async def _open(self) -> None:
|
|
"""Establish the transport and start delivering server events via ``_dispatch``.
|
|
|
|
Called once from :meth:`__aenter__`; the base then sends the
|
|
``session.update`` handshake and waits for ``session.created``.
|
|
"""
|
|
|
|
@abstractmethod
|
|
async def _send_command(self, payload: dict[str, object]) -> None:
|
|
"""Deliver one client event (already stamped with ``event_id``)."""
|
|
|
|
@abstractmethod
|
|
async def _teardown(self) -> None:
|
|
"""Release the transport; must be idempotent."""
|
|
|
|
# -- lifecycle ----------------------------------------------------------
|
|
|
|
async def __aenter__(self) -> DuplexClientBase:
|
|
handshake_queue = self._add_subscriber()
|
|
try:
|
|
await self._open()
|
|
await self.send(
|
|
{
|
|
"type": "session.update",
|
|
"session": self.config.to_session_payload(model=self.model),
|
|
}
|
|
)
|
|
created = await self._wait_on_queue(
|
|
handshake_queue,
|
|
("session.created",),
|
|
timeout_s=self._handshake_timeout_s,
|
|
)
|
|
except BaseException:
|
|
await self._teardown_quietly()
|
|
raise
|
|
finally:
|
|
self._remove_subscriber(handshake_queue)
|
|
assert isinstance(created, SessionCreated)
|
|
self._adopt_session(created)
|
|
return self
|
|
|
|
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
|
try:
|
|
if exc_type is None and not self._closed.is_set():
|
|
await self.close()
|
|
elif not self._closed.is_set():
|
|
# An application error (or cancellation) is propagating. Still
|
|
# tell the server the session is over: dropping the transport
|
|
# silently reads as a resumable disconnect and holds the
|
|
# session slot for the full disconnect grace period. Best
|
|
# effort only — the teardown below closes the transport
|
|
# whether or not the close reaches the server.
|
|
self._closing = True
|
|
try:
|
|
await self.send({"type": "session.close"})
|
|
except DuplexClientError:
|
|
pass
|
|
finally:
|
|
await self._teardown_quietly()
|
|
|
|
async def close(self, *, timeout_s: float = 20.0) -> None:
|
|
"""Send ``session.close`` and wait for the server to confirm."""
|
|
if self._closed.is_set():
|
|
return
|
|
self._closing = True
|
|
try:
|
|
await self.send({"type": "session.close"})
|
|
except (DuplexConnectionError, DuplexSessionClosedError):
|
|
self._finalize("closed", expected=True)
|
|
return
|
|
try:
|
|
await asyncio.wait_for(self._closed.wait(), timeout_s)
|
|
except asyncio.TimeoutError:
|
|
self._finalize("close timed out", expected=True)
|
|
|
|
async def _teardown_quietly(self) -> None:
|
|
self._closing = True
|
|
if not self._closed.is_set():
|
|
self._finalize("closed", expected=True)
|
|
await self._teardown()
|
|
|
|
# -- input ----------------------------------------------------------------
|
|
|
|
async def append_audio(
|
|
self,
|
|
pcm: bytes,
|
|
*,
|
|
is_speech: bool | None = None,
|
|
video_frames: Sequence[str] | None = None,
|
|
) -> None:
|
|
"""Append one chunk of input audio in the session's input format.
|
|
|
|
A turn is ended with :meth:`commit`, never by a flag on the append.
|
|
|
|
``video_frames`` takes base64 JPEG/PNG strings (one per ~1 s unit for
|
|
omni models). Data URLs (:func:`image_data_url`) are accepted too;
|
|
their prefix is stripped, since the wire contract carries bare base64.
|
|
"""
|
|
input_format = self.config.input_audio
|
|
duration_ms = input_format.duration_ms(len(pcm))
|
|
self._input_audio_end_ms += duration_ms
|
|
event: dict[str, object] = {
|
|
"type": "input_audio_buffer.append",
|
|
"audio": base64.b64encode(pcm).decode("ascii"),
|
|
"format": input_format.encoding,
|
|
"sample_rate_hz": input_format.sample_rate_hz,
|
|
"duration_ms": round(duration_ms),
|
|
"audio_end_ms": round(self._input_audio_end_ms),
|
|
}
|
|
if is_speech is not None:
|
|
event["is_speech"] = is_speech
|
|
if video_frames:
|
|
event["video_frames"] = [_bare_base64(frame) for frame in video_frames]
|
|
await self.send(event)
|
|
|
|
async def stream_pcm(
|
|
self,
|
|
pcm: bytes,
|
|
*,
|
|
chunk_ms: int = 200,
|
|
realtime: bool = True,
|
|
is_speech: bool | None = None,
|
|
video_frames: Sequence[str] | None = None,
|
|
stacked_video_frames: Sequence[str | None] | None = None,
|
|
) -> int:
|
|
"""Slice ``pcm`` into ``chunk_ms`` chunks, pace, append; interleave frames.
|
|
|
|
``video_frames`` holds base64/data-URL JPEG frames in capture order,
|
|
one per second of the clip. Frame ``k`` rides the append that closes
|
|
model unit ``k`` (see :func:`duplex_unit_boundary_ms`), which
|
|
reproduces the official ``streaming_prefill(audio_waveform=<1 s>,
|
|
frame_list=[frame])`` pairing: a second of audio and the picture
|
|
captured during it enter the same unit. Sending on whole-second
|
|
boundaries instead would strand frame 0 on an append that cannot
|
|
close a unit yet, and shift every later frame one unit ahead of its
|
|
audio.
|
|
|
|
``stacked_video_frames`` is the optional parallel track of composites
|
|
(see ``vllm_omni.experimental.fullduplex.video_stacking``): entry ``k``
|
|
tiles the sub-frames captured *inside* unit ``k`` and rides the same
|
|
append right after the base frame. ``None`` entries send the base
|
|
frame alone.
|
|
|
|
Returns the number of base frames actually sent (a clip shorter than
|
|
the frame list leaves the tail unsent).
|
|
"""
|
|
input_format = self.config.input_audio
|
|
chunk_bytes = max(input_format.byte_count(chunk_ms), input_format.bytes_per_sample)
|
|
frames = list(video_frames or [])
|
|
stacked = list(stacked_video_frames or [])
|
|
|
|
def units() -> Iterator[tuple[bytes, list[str] | None]]:
|
|
audio_end_ms = 0.0
|
|
frames_sent = 0
|
|
for offset in range(0, len(pcm), chunk_bytes):
|
|
chunk = pcm[offset : offset + chunk_bytes]
|
|
audio_end_ms += input_format.duration_ms(len(chunk))
|
|
if not frames or audio_end_ms < duplex_unit_boundary_ms(frames_sent):
|
|
yield chunk, None
|
|
continue
|
|
# A video advances one frame per unit and holds its last frame
|
|
# when the audio outlives the clip; a still image is a
|
|
# one-element list and therefore repeats.
|
|
index = min(frames_sent, len(frames) - 1)
|
|
composite = stacked[index] if index < len(stacked) else None
|
|
frames_sent += 1
|
|
yield chunk, [frames[index]] if composite is None else [frames[index], composite]
|
|
|
|
frames_sent = 0
|
|
for chunk, unit_frames in units():
|
|
if not chunk:
|
|
continue
|
|
await self.append_audio(chunk, is_speech=is_speech, video_frames=unit_frames)
|
|
if unit_frames:
|
|
frames_sent += 1
|
|
if realtime:
|
|
await asyncio.sleep(input_format.duration_ms(len(chunk)) / 1000.0)
|
|
return frames_sent
|
|
|
|
async def commit(self, *, final: bool = True, create_response: bool | None = None) -> None:
|
|
event: dict[str, object] = {"type": "input_audio_buffer.commit", "final": final}
|
|
if create_response is not None:
|
|
event["response_create"] = create_response
|
|
await self.send(event)
|
|
|
|
# -- interruption ---------------------------------------------------------
|
|
|
|
async def cancel_response(self, response_id: str | None = None) -> None:
|
|
"""Cancel the active (or a specific) response.
|
|
|
|
Together with :meth:`clear_input` this composes a client-forced
|
|
barge-in for sessions whose capabilities advertise
|
|
``supports_barge_in``; the currently bundled duplex models do not,
|
|
so their overlap handling is model-owned.
|
|
"""
|
|
event: dict[str, object] = {"type": "response.cancel"}
|
|
if response_id is not None:
|
|
event["response_id"] = response_id
|
|
await self.send(event)
|
|
|
|
async def clear_input(self) -> None:
|
|
await self.send({"type": "input_audio_buffer.clear"})
|
|
|
|
# -- playback contract ------------------------------------------------------
|
|
|
|
async def ack_playback(
|
|
self,
|
|
played_ms: float,
|
|
*,
|
|
response_id: str | None = None,
|
|
item_id: str | None = None,
|
|
committed_ms: float | None = None,
|
|
) -> None:
|
|
"""Report cumulative playback progress; call periodically while playing."""
|
|
event: dict[str, object] = {
|
|
"type": "playback.ack",
|
|
"played_ms": int(played_ms),
|
|
"committed_ms": int(committed_ms if committed_ms is not None else played_ms),
|
|
}
|
|
if response_id is not None:
|
|
event["response_id"] = response_id
|
|
if item_id is not None:
|
|
event["item_id"] = item_id
|
|
await self.send(event)
|
|
|
|
# -- output ----------------------------------------------------------------
|
|
|
|
def __aiter__(self) -> AsyncIterator[DuplexEvent]:
|
|
return self.events()
|
|
|
|
async def events(self) -> AsyncIterator[DuplexEvent]:
|
|
"""Iterate over every server event from now on (typed)."""
|
|
queue = self._add_subscriber()
|
|
try:
|
|
while True:
|
|
item = await queue.get()
|
|
if isinstance(item, _ClosedMarker):
|
|
if item.expected:
|
|
return
|
|
raise DuplexSessionClosedError(item.reason)
|
|
yield item
|
|
finally:
|
|
self._remove_subscriber(queue)
|
|
|
|
async def responses(self) -> AsyncIterator[ResponseHandle]:
|
|
"""Iterate over response lifecycles (single consumer).
|
|
|
|
Raises :class:`DuplexProtocolError` when the server rejects a request
|
|
with an ``error`` event while waiting — a rejected send (an invalid
|
|
video frame, an empty commit) produces no response, so without this
|
|
the wait would never end. Re-enter the iterator to keep consuming
|
|
after handling the error.
|
|
"""
|
|
while True:
|
|
item = await self._response_queue.get()
|
|
if isinstance(item, _ClosedMarker):
|
|
if item.expected:
|
|
return
|
|
raise DuplexSessionClosedError(item.reason)
|
|
if isinstance(item, _ErrorMarker):
|
|
raise item.error.to_exception()
|
|
yield item
|
|
|
|
async def wait_for(self, *types: str, timeout_s: float) -> DuplexEvent:
|
|
"""Wait for the next event whose type is in ``types``.
|
|
|
|
Raises :class:`DuplexProtocolError` if an ``error`` event arrives
|
|
first, and :class:`DuplexSessionClosedError` if the session ends first.
|
|
"""
|
|
queue = self._add_subscriber()
|
|
try:
|
|
return await self._wait_on_queue(queue, types, timeout_s=timeout_s)
|
|
finally:
|
|
self._remove_subscriber(queue)
|
|
|
|
async def send(self, event: dict[str, object]) -> str:
|
|
"""Send one raw client event; returns the (possibly stamped) event_id."""
|
|
if self._closed.is_set():
|
|
marker = self._closed_marker
|
|
raise DuplexSessionClosedError(marker.reason if marker else "closed")
|
|
payload = dict(event)
|
|
payload.setdefault("event_id", f"evt_{uuid4().hex}")
|
|
try:
|
|
await self._send_command(payload)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except DuplexClientError:
|
|
raise
|
|
except Exception as exc:
|
|
raise DuplexConnectionError(f"send failed: {exc}") from exc
|
|
return payload["event_id"]
|
|
|
|
# -- internals ---------------------------------------------------------------
|
|
|
|
def _adopt_session(self, event: SessionCreated) -> None:
|
|
# session.created and session.resumed both carry the engine's current
|
|
# session object; keep the last one seen.
|
|
if event.session:
|
|
self.session_info = event.session
|
|
session_id = event.session.get("session_id") or event.session.get("id") or event.session_id
|
|
if isinstance(session_id, str) and session_id:
|
|
self.session_id = session_id
|
|
|
|
def _announce(self, handle: ResponseHandle) -> None:
|
|
"""Deliver a handle to responses() exactly once, decision known."""
|
|
if not handle._announced:
|
|
handle._announced = True
|
|
_put_drop_oldest(self._response_queue, handle)
|
|
|
|
def _add_subscriber(self) -> asyncio.Queue[DuplexEvent | _ClosedMarker]:
|
|
queue: asyncio.Queue[DuplexEvent | _ClosedMarker] = asyncio.Queue(maxsize=_MAX_BUFFERED_EVENTS)
|
|
if self._closed.is_set() and self._closed_marker is not None:
|
|
queue.put_nowait(self._closed_marker)
|
|
else:
|
|
self._subscribers.append(queue)
|
|
return queue
|
|
|
|
def _remove_subscriber(self, queue: asyncio.Queue[DuplexEvent | _ClosedMarker]) -> None:
|
|
try:
|
|
self._subscribers.remove(queue)
|
|
except ValueError:
|
|
pass
|
|
|
|
async def _wait_on_queue(
|
|
self,
|
|
queue: asyncio.Queue[DuplexEvent | _ClosedMarker],
|
|
types: tuple[str, ...],
|
|
*,
|
|
timeout_s: float,
|
|
) -> DuplexEvent:
|
|
deadline = time.monotonic() + timeout_s
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise asyncio.TimeoutError(f"timed out waiting for {types}")
|
|
item = await asyncio.wait_for(queue.get(), remaining)
|
|
if isinstance(item, _ClosedMarker):
|
|
raise DuplexSessionClosedError(item.reason)
|
|
if isinstance(item, ErrorEvent) and item.type not in types:
|
|
raise item.to_exception()
|
|
if item.type in types:
|
|
return item
|
|
|
|
def _accept_event(self, data: dict[str, object]) -> bool:
|
|
"""Transport hook: return False to drop ``data`` before dispatch (duplicates)."""
|
|
del data
|
|
return True
|
|
|
|
async def _after_dispatch(self, event: DuplexEvent, data: dict[str, object]) -> None:
|
|
"""Transport hook run after an event was delivered to subscribers."""
|
|
del event, data
|
|
|
|
async def _dispatch(self, data: dict[str, object]) -> None:
|
|
if not self._accept_event(data):
|
|
return
|
|
event = wrap_event(data)
|
|
|
|
if isinstance(event, SessionResumed):
|
|
self._adopt_session(event)
|
|
elif isinstance(event, SessionCreated):
|
|
self._adopt_session(event)
|
|
elif isinstance(event, ResponseCreated):
|
|
response_id = event.response_id
|
|
if response_id and response_id not in self._responses:
|
|
handle = ResponseHandle(
|
|
response_id=response_id,
|
|
output_format=self.config.output_audio,
|
|
max_buffered_events=_MAX_BUFFERED_EVENTS,
|
|
created_event=event,
|
|
)
|
|
self._responses[response_id] = handle
|
|
# Not yielded yet: the handle reaches responses() once its
|
|
# decision is known — at the first output below, or at its
|
|
# terminal event — so a turn loop can branch on `decision`
|
|
# the moment it receives the handle.
|
|
elif isinstance(event, (AudioDelta, TranscriptDelta, TextDelta, SpeakDecision)):
|
|
handle = self._responses.get(event.response_id or "")
|
|
if handle is not None:
|
|
handle._feed(event)
|
|
self._announce(handle)
|
|
elif isinstance(event, ListenDecision):
|
|
# Only a listen carrying a response_id is that response's
|
|
# terminal decision (the server stamps it exactly there); an
|
|
# id-less listen is a standalone decision beat and must never
|
|
# close an in-flight response.
|
|
handle = self._responses.pop(event.response_id or "", None)
|
|
if handle is not None:
|
|
handle._feed(event)
|
|
handle._finish(event)
|
|
self._announce(handle)
|
|
else:
|
|
listen_handle = ResponseHandle(
|
|
response_id=event.response_id,
|
|
output_format=self.config.output_audio,
|
|
max_buffered_events=_MAX_BUFFERED_EVENTS,
|
|
decision="listen",
|
|
)
|
|
listen_handle._announced = True
|
|
listen_handle._finish(event)
|
|
_put_drop_oldest(self._response_queue, listen_handle)
|
|
elif isinstance(event, ResponseDone):
|
|
handle = self._responses.pop(event.response_id or "", None)
|
|
if handle is not None:
|
|
if handle.decision is None:
|
|
handle.decision = "speak"
|
|
handle._finish(event)
|
|
self._announce(handle)
|
|
elif isinstance(event, ErrorEvent):
|
|
# A rejected request produces no response; surface the rejection
|
|
# on the response path too so a consumer waiting in responses()
|
|
# is not left waiting forever (see the responses() docstring).
|
|
_put_drop_oldest(self._response_queue, _ErrorMarker(event))
|
|
|
|
for queue in list(self._subscribers):
|
|
_put_drop_oldest(queue, event)
|
|
|
|
if isinstance(event, SessionClosed):
|
|
self._finalize("closed", expected=True)
|
|
elif isinstance(event, SessionExpired):
|
|
reason = data.get("reason")
|
|
self._finalize(f"expired: {reason}" if reason else "expired", expected=False)
|
|
|
|
await self._after_dispatch(event, data)
|
|
|
|
def _finalize(self, reason: str, *, expected: bool) -> None:
|
|
if self._closed.is_set():
|
|
return
|
|
marker = _ClosedMarker(reason=reason, expected=expected)
|
|
self._closed_marker = marker
|
|
self._closed.set()
|
|
for handle in self._responses.values():
|
|
handle._finish(None)
|
|
self._responses.clear()
|
|
for queue in (*self._subscribers, self._response_queue):
|
|
_put_drop_oldest(queue, marker)
|
|
|
|
|
|
class DuplexClient(DuplexClientBase):
|
|
"""Async client for one duplex session over ``/v1/realtime?duplex=1``."""
|
|
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
*,
|
|
model: str,
|
|
config: SessionConfig | None = None,
|
|
reconnect: ReconnectPolicy | None = ReconnectPolicy(),
|
|
heartbeat_interval_s: float | None = 30.0,
|
|
handshake_timeout_s: float = 30.0,
|
|
connect: ConnectFn | None = None,
|
|
) -> None:
|
|
super().__init__(model=model, config=config, handshake_timeout_s=handshake_timeout_s)
|
|
self.url = url
|
|
self.resume_token: str | None = None
|
|
self._reconnect = reconnect
|
|
self._heartbeat_interval_s = heartbeat_interval_s
|
|
self._connect_fn: ConnectFn = connect or self._default_connect
|
|
self._ws: WebSocketTransport | None = None
|
|
self._reader_task: asyncio.Task[None] | None = None
|
|
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
self._last_server_event_seq: int | None = None
|
|
|
|
# -- transport hooks ------------------------------------------------------
|
|
|
|
async def _open(self) -> None:
|
|
self._ws = await self._connect_fn(self._target_url())
|
|
self._reader_task = asyncio.create_task(self._read_loop(), name="duplex-client-reader")
|
|
|
|
async def __aenter__(self) -> DuplexClient:
|
|
await super().__aenter__()
|
|
if self._heartbeat_interval_s is not None:
|
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop(), name="duplex-client-heartbeat")
|
|
return self
|
|
|
|
async def _send_command(self, payload: dict[str, object]) -> None:
|
|
if self._ws is None:
|
|
raise DuplexConnectionError("send failed: transport is not connected")
|
|
await self._ws.send(json.dumps(payload))
|
|
|
|
async def _teardown(self) -> None:
|
|
for task in (self._heartbeat_task, self._reader_task):
|
|
if task is not None and not task.done():
|
|
task.cancel()
|
|
try:
|
|
await task
|
|
except (asyncio.CancelledError, Exception): # noqa: BLE001
|
|
pass
|
|
if self._ws is not None:
|
|
try:
|
|
await self._ws.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
# -- internals ---------------------------------------------------------------
|
|
|
|
def _target_url(self) -> str:
|
|
parts = urlsplit(self.url)
|
|
path = parts.path if parts.path not in ("", "/") else "/v1/realtime"
|
|
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
query.setdefault("duplex", "1")
|
|
query.setdefault("model", self.model)
|
|
# The client always opens with an explicit session.update (and resumes
|
|
# with an explicit session.resume). Autostart would race that handshake:
|
|
# with a model query param, the server creates a bare default session
|
|
# before reading the first client event, silently dropping ref_audio
|
|
# and the model-specific extra_body. Force it off even when the caller's
|
|
# URL carries autostart=1 — this client cannot operate over it.
|
|
query["autostart"] = "0"
|
|
return urlunsplit((parts.scheme or "ws", parts.netloc, path, urlencode(query), parts.fragment))
|
|
|
|
async def _default_connect(self, url: str) -> WebSocketTransport:
|
|
try:
|
|
import websockets
|
|
except ImportError as exc: # pragma: no cover - websockets is a pinned dep
|
|
raise DuplexConnectionError("The duplex client requires the 'websockets' package") from exc
|
|
try:
|
|
return await websockets.connect(url, max_size=_MAX_FRAME_BYTES)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
raise DuplexConnectionError(f"connect to {url} failed: {exc}") from exc
|
|
|
|
def _adopt_session(self, event: SessionCreated) -> None:
|
|
super()._adopt_session(event)
|
|
if event.resume_token:
|
|
self.resume_token = event.resume_token
|
|
|
|
def _accept_event(self, data: dict[str, object]) -> bool:
|
|
seq = data.get("server_event_seq")
|
|
if isinstance(seq, int):
|
|
if self._last_server_event_seq is not None and seq <= self._last_server_event_seq:
|
|
return False # duplicate delivered by resume replay
|
|
self._last_server_event_seq = seq
|
|
return True
|
|
|
|
async def _after_dispatch(self, event: DuplexEvent, data: dict[str, object]) -> None:
|
|
if event.type == "session.resync_required":
|
|
# The server stopped journaling this session's events, so any
|
|
# retained resume credential will be refused on the next
|
|
# session.resume. Drop it now: a later transport failure then
|
|
# finalizes immediately instead of burning reconnect attempts.
|
|
self.resume_token = None
|
|
|
|
seq = data.get("server_event_seq")
|
|
if isinstance(seq, int) and not self._closed.is_set() and self._ws is not None:
|
|
try:
|
|
await self._ws.send(json.dumps({"type": "session.event_ack", "server_event_seq": seq}))
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
pass # the reader will observe the transport failure itself
|
|
|
|
async def _read_loop(self) -> None:
|
|
while True:
|
|
try:
|
|
while True:
|
|
raw = await self._ws.recv()
|
|
if isinstance(raw, (bytes, bytearray)):
|
|
continue
|
|
try:
|
|
data = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if isinstance(data, dict):
|
|
await self._dispatch(data)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
if self._closing or self._closed.is_set():
|
|
self._finalize("closed", expected=True)
|
|
return
|
|
if await self._attempt_resume():
|
|
continue
|
|
self._finalize(f"connection lost: {exc}", expected=False)
|
|
return
|
|
|
|
async def _attempt_resume(self) -> bool:
|
|
policy = self._reconnect
|
|
if policy is None or not self.resume_token or not self.session_id:
|
|
return False
|
|
for attempt in range(policy.max_attempts):
|
|
await asyncio.sleep(policy.delay(attempt))
|
|
try:
|
|
ws = await self._connect_fn(self._target_url())
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception:
|
|
continue
|
|
try:
|
|
await ws.send(
|
|
json.dumps(
|
|
{
|
|
"type": "session.resume",
|
|
"session_id": self.session_id,
|
|
"resume_token": self.resume_token,
|
|
"last_received_server_event_seq": self._last_server_event_seq or 0,
|
|
}
|
|
)
|
|
)
|
|
deadline = time.monotonic() + self._handshake_timeout_s
|
|
pending: list[dict[str, object]] = []
|
|
while True:
|
|
remaining = deadline - time.monotonic()
|
|
if remaining <= 0:
|
|
raise asyncio.TimeoutError
|
|
raw = await asyncio.wait_for(ws.recv(), remaining)
|
|
if isinstance(raw, (bytes, bytearray)):
|
|
continue
|
|
data = json.loads(raw)
|
|
if not isinstance(data, dict):
|
|
continue
|
|
event_type = data.get("type")
|
|
if event_type == "session.resumed":
|
|
previous = self._ws
|
|
self._ws = ws
|
|
if previous is not None and previous is not ws:
|
|
await self._close_quietly(previous)
|
|
await self._dispatch({"type": "connection.resumed", "attempt": attempt})
|
|
await self._dispatch(data)
|
|
for entry in pending:
|
|
await self._dispatch(entry)
|
|
return True
|
|
if event_type == "session.resync_required":
|
|
await self._close_quietly(ws)
|
|
return False
|
|
if event_type == "error":
|
|
code = ErrorEvent(data).code
|
|
if code in _FATAL_RESUME_CODES:
|
|
await self._close_quietly(ws)
|
|
return False
|
|
await self._close_quietly(ws)
|
|
break # transient error: next attempt
|
|
pending.append(data)
|
|
except asyncio.CancelledError:
|
|
# ws is not yet self._ws, so _teardown will not close it.
|
|
await self._close_quietly(ws)
|
|
raise
|
|
except Exception:
|
|
await self._close_quietly(ws)
|
|
continue
|
|
return False
|
|
|
|
@staticmethod
|
|
async def _close_quietly(ws: WebSocketTransport) -> None:
|
|
try:
|
|
await ws.close()
|
|
except Exception: # noqa: BLE001
|
|
pass
|
|
|
|
async def _heartbeat_loop(self) -> None:
|
|
interval = self._heartbeat_interval_s
|
|
assert interval is not None
|
|
while not self._closed.is_set():
|
|
await asyncio.sleep(interval)
|
|
try:
|
|
await self.send({"type": "session.heartbeat"})
|
|
except DuplexConnectionError:
|
|
# Transient transport failure — a tick can land inside the
|
|
# resume window while the old socket is already dead. Keep
|
|
# ticking; the resumed session still needs heartbeats to
|
|
# reset the server's receive timeout. If the session is
|
|
# really over, _finalize sets _closed and the loop exits.
|
|
continue
|
|
except DuplexClientError:
|
|
return
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Test/benchmark collector
|
|
|
|
|
|
def _rounded_ms(value: float) -> float:
|
|
return round(float(value), 3)
|
|
|
|
|
|
def _finite_number(value: object, *, nonnegative: bool = False) -> float | None:
|
|
if not isinstance(value, int | float) or isinstance(value, bool) or not math.isfinite(float(value)):
|
|
return None
|
|
number = float(value)
|
|
if nonnegative and number < 0:
|
|
return None
|
|
return number
|
|
|
|
|
|
def _interval_summary(values: list[float]) -> dict[str, float | int]:
|
|
clean = sorted(_rounded_ms(value) for value in values if math.isfinite(value) and value >= 0)
|
|
if not clean:
|
|
return {"count": 0, "mean": 0.0, "p50": 0.0, "p95": 0.0, "max": 0.0}
|
|
|
|
def nearest_rank(percentile: float) -> float:
|
|
index = max(0, math.ceil(percentile * len(clean)) - 1)
|
|
return clean[min(index, len(clean) - 1)]
|
|
|
|
return {
|
|
"count": len(clean),
|
|
"mean": _rounded_ms(sum(clean) / len(clean)),
|
|
"p50": nearest_rank(0.50),
|
|
"p95": nearest_rank(0.95),
|
|
"max": clean[-1],
|
|
}
|
|
|
|
|
|
def distribution_summary(values: Sequence[float], *, digits: int = 3) -> dict[str, float | int] | None:
|
|
"""Summarize values as ``{count, mean, p50, p99}`` for duplex report fields."""
|
|
clean = sorted(float(value) for value in values if math.isfinite(float(value)))
|
|
if not clean:
|
|
return None
|
|
|
|
def nearest_rank(percentile: float) -> float:
|
|
index = max(0, math.ceil(percentile * len(clean)) - 1)
|
|
return clean[min(index, len(clean) - 1)]
|
|
|
|
return {
|
|
"count": len(clean),
|
|
"mean": round(sum(clean) / len(clean), digits),
|
|
"p50": round(nearest_rank(0.50), digits),
|
|
"p99": round(nearest_rank(0.99), digits),
|
|
}
|
|
|
|
|
|
def metric_mean(value: object) -> float | None:
|
|
"""Read a scalar mean, or the ``mean`` field of a distribution summary."""
|
|
if isinstance(value, Mapping):
|
|
nested = value.get("mean")
|
|
if isinstance(nested, int | float) and not isinstance(nested, bool) and math.isfinite(float(nested)):
|
|
return float(nested)
|
|
return None
|
|
if isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(float(value)):
|
|
return float(value)
|
|
return None
|
|
|
|
|
|
def _event_stage_metrics(event: dict[str, object]) -> dict[str, object] | None:
|
|
candidates: list[object] = [event.get("vllm_omni")]
|
|
metadata = event.get("metadata")
|
|
if isinstance(metadata, dict):
|
|
candidates.extend((metadata, metadata.get("vllm_omni")))
|
|
response = event.get("response")
|
|
if isinstance(response, dict):
|
|
response_metadata = response.get("metadata")
|
|
if isinstance(response_metadata, dict):
|
|
candidates.extend((response_metadata, response_metadata.get("vllm_omni")))
|
|
for candidate in candidates:
|
|
if not isinstance(candidate, dict):
|
|
continue
|
|
stage_metrics = candidate.get("stage_metrics")
|
|
if isinstance(stage_metrics, dict):
|
|
return stage_metrics
|
|
return None
|
|
|
|
|
|
def _event_response_request_metrics(event: dict[str, object]) -> dict[str, object] | None:
|
|
direct_metrics = event.get("response_request_metrics")
|
|
if isinstance(direct_metrics, dict):
|
|
return direct_metrics
|
|
candidates: list[object] = [event.get("vllm_omni")]
|
|
metadata = event.get("metadata")
|
|
if isinstance(metadata, dict):
|
|
candidates.extend((metadata, metadata.get("vllm_omni"), metadata.get("duplex_event")))
|
|
response = event.get("response")
|
|
if isinstance(response, dict):
|
|
response_metadata = response.get("metadata")
|
|
if isinstance(response_metadata, dict):
|
|
candidates.extend(
|
|
(
|
|
response_metadata,
|
|
response_metadata.get("vllm_omni"),
|
|
response_metadata.get("duplex_event"),
|
|
)
|
|
)
|
|
for candidate in candidates:
|
|
if not isinstance(candidate, dict):
|
|
continue
|
|
request_metrics = candidate.get("response_request_metrics")
|
|
if isinstance(request_metrics, dict):
|
|
return request_metrics
|
|
return None
|
|
|
|
|
|
class EventCollector:
|
|
"""Accumulate events for assertions and latency summaries (tests/benchmarks).
|
|
|
|
Unbounded by design — attach one to a short-lived probe session, not to a
|
|
production client that runs for hours.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.events: list[dict[str, object]] = []
|
|
self.event_received_at_s: list[float] = []
|
|
self.response_audio: dict[str, list[bytes]] = {}
|
|
self.response_ids: list[str] = []
|
|
self.output_sample_rate_hz = 24_000
|
|
|
|
async def consume(self, client: DuplexClientBase) -> None:
|
|
"""Subscribe to ``client`` and collect until the session ends."""
|
|
try:
|
|
async for event in client.events():
|
|
self.add(event.raw)
|
|
except DuplexSessionClosedError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def response_id(event: dict[str, object]) -> str | None:
|
|
"""Response identity of one raw event (``response_id`` or ``response.id``)."""
|
|
return wrap_event(event).response_id
|
|
|
|
def add(self, event: dict[str, object] | DuplexEvent, *, received_at_s: float | None = None) -> None:
|
|
raw = event.raw if isinstance(event, DuplexEvent) else event
|
|
received_at = time.monotonic() if received_at_s is None else float(received_at_s)
|
|
stored_event = dict(raw)
|
|
stored_event.setdefault("_client_received_at_s", received_at)
|
|
self.events.append(stored_event)
|
|
self.event_received_at_s.append(received_at)
|
|
wrapped = wrap_event(stored_event)
|
|
response_id = wrapped.response_id
|
|
if stored_event.get("type") == "response.created" and response_id and response_id not in self.response_ids:
|
|
self.response_ids.append(response_id)
|
|
if stored_event.get("type") in _AUDIO_DELTA_TYPES:
|
|
chunk = wrapped.audio
|
|
if chunk and response_id:
|
|
self.response_audio.setdefault(response_id, []).append(chunk)
|
|
sample_rate_hz = wrapped.sample_rate_hz
|
|
if sample_rate_hz:
|
|
self.output_sample_rate_hz = sample_rate_hz
|
|
|
|
def count(self, event_type: str) -> int:
|
|
return sum(event.get("type") == event_type for event in self.events)
|
|
|
|
def audio_bytes(self, response_id: str | None = None) -> bytes:
|
|
if response_id is not None:
|
|
return b"".join(self.response_audio.get(response_id, ()))
|
|
return b"".join(
|
|
chunk for response_id in self.response_ids for chunk in self.response_audio.get(response_id, ())
|
|
)
|
|
|
|
def errors(self) -> list[dict[str, object]]:
|
|
return [event for event in self.events if event.get("type") == "error"]
|
|
|
|
def response_is_done(self, response_id: str) -> bool:
|
|
return any(
|
|
event.get("type") == "response.done" and self.response_id(event) == response_id for event in self.events
|
|
)
|
|
|
|
def response_text(self, response_id: str) -> str:
|
|
"""Join all text/transcript deltas for one response identity."""
|
|
return "".join(
|
|
str(event.get("delta") or "")
|
|
for event in self.events
|
|
if self.response_id(event) == response_id
|
|
and event.get("type")
|
|
in {
|
|
"response.output_audio_transcript.delta",
|
|
"response.output_text.delta",
|
|
"response.text.delta",
|
|
}
|
|
)
|
|
|
|
def first_received_at(self, *event_types: str, after_s: float = 0.0) -> float | None:
|
|
for event, received_at_s in zip(self.events, self.event_received_at_s, strict=True):
|
|
if received_at_s >= after_s and event.get("type") in event_types:
|
|
return received_at_s
|
|
return None
|
|
|
|
def last_received_at(self, event_type: str) -> float | None:
|
|
for event, received_at_s in zip(
|
|
reversed(self.events),
|
|
reversed(self.event_received_at_s),
|
|
strict=True,
|
|
):
|
|
if event.get("type") == event_type:
|
|
return received_at_s
|
|
return None
|
|
|
|
def global_timing_summary(
|
|
self,
|
|
*,
|
|
after_s: float,
|
|
window_started_at_s: float,
|
|
response_ids: list[str],
|
|
measurement_origin: dict[str, str],
|
|
) -> dict[str, object]:
|
|
"""Summarize one client-observed timing window across responses.
|
|
|
|
Returns raw measurements only. Derived metrics such as RTF are computed
|
|
by the caller — e.g. with ``vllm_omni.metrics.definitions.compute_audio_rtf``.
|
|
"""
|
|
selected_response_ids = set(response_ids)
|
|
first_text_received_at_s: float | None = None
|
|
audio_received_at_s: list[float] = []
|
|
response_audio_duration_ms: dict[str, float] = {}
|
|
|
|
for event, received_at_s in zip(self.events, self.event_received_at_s, strict=True):
|
|
if received_at_s < after_s:
|
|
continue
|
|
response_id = self.response_id(event)
|
|
if response_id is None or response_id not in selected_response_ids:
|
|
continue
|
|
if (
|
|
event.get("type")
|
|
in {
|
|
"response.output_audio_transcript.delta",
|
|
"response.output_text.delta",
|
|
"response.text.delta",
|
|
}
|
|
and isinstance(event.get("delta"), str)
|
|
and bool(event["delta"])
|
|
and first_text_received_at_s is None
|
|
):
|
|
first_text_received_at_s = received_at_s
|
|
if event.get("type") not in _AUDIO_DELTA_TYPES:
|
|
continue
|
|
delta = event.get("delta") or event.get("audio")
|
|
if not isinstance(delta, str) or not delta:
|
|
continue
|
|
audio_received_at_s.append(received_at_s)
|
|
metadata = event.get("metadata")
|
|
duration_ms = metadata.get("audio_duration_ms") if isinstance(metadata, dict) else None
|
|
parsed_duration_ms = _finite_number(duration_ms, nonnegative=True)
|
|
if parsed_duration_ms is not None:
|
|
response_audio_duration_ms[response_id] = max(
|
|
response_audio_duration_ms.get(response_id, 0.0),
|
|
parsed_duration_ms,
|
|
)
|
|
|
|
if not audio_received_at_s:
|
|
return {}
|
|
|
|
audio_duration_ms = sum(
|
|
response_audio_duration_ms.get(
|
|
response_id,
|
|
self._format_for_bytes().duration_ms(len(self.audio_bytes(response_id))),
|
|
)
|
|
for response_id in response_ids
|
|
)
|
|
audio_generation_ms = max(
|
|
0.0,
|
|
(audio_received_at_s[-1] - window_started_at_s) * 1000.0,
|
|
)
|
|
return {
|
|
"source": "client_monotonic_receive",
|
|
"response_ids": list(response_ids),
|
|
"measurement_origin": dict(measurement_origin),
|
|
"ttft_ms": (
|
|
_rounded_ms((first_text_received_at_s - window_started_at_s) * 1000.0)
|
|
if first_text_received_at_s is not None
|
|
else None
|
|
),
|
|
"ttfp_ms": _rounded_ms((audio_received_at_s[0] - window_started_at_s) * 1000.0),
|
|
"audio_generation_ms": _rounded_ms(audio_generation_ms),
|
|
"audio_duration_ms": _rounded_ms(audio_duration_ms),
|
|
}
|
|
|
|
def timing_summary(
|
|
self,
|
|
*,
|
|
after_s: float,
|
|
input_committed_at_s: float | None = None,
|
|
response_id: str | None = None,
|
|
measurement_origin: dict[str, str] | None = None,
|
|
) -> dict[str, object]:
|
|
"""Summarize engine token metrics and client-observed audio cadence."""
|
|
stage0_metrics: dict[str, object] | None = None
|
|
response_request_metrics: dict[str, object] = {}
|
|
response_created_at_s: float | None = None
|
|
first_text_received_at_s: float | None = None
|
|
audio_received_at_s: list[float] = []
|
|
cumulative_audio_ms: list[float] = []
|
|
for event, received_at_s in zip(self.events, self.event_received_at_s, strict=True):
|
|
if received_at_s < after_s:
|
|
continue
|
|
event_response_id = wrap_event(event).response_id
|
|
if response_id is not None and event_response_id != response_id:
|
|
continue
|
|
if event.get("type") == "response.created" and response_created_at_s is None:
|
|
response_created_at_s = received_at_s
|
|
if (
|
|
event.get("type")
|
|
in {
|
|
"response.output_audio_transcript.delta",
|
|
"response.output_text.delta",
|
|
"response.text.delta",
|
|
}
|
|
and isinstance(event.get("delta"), str)
|
|
and bool(event["delta"])
|
|
and first_text_received_at_s is None
|
|
):
|
|
first_text_received_at_s = received_at_s
|
|
|
|
stage_metrics = _event_stage_metrics(event)
|
|
stage0 = stage_metrics.get("0") if isinstance(stage_metrics, dict) else None
|
|
if isinstance(stage0, dict):
|
|
stage0_metrics = stage0
|
|
|
|
event_request_metrics = _event_response_request_metrics(event)
|
|
if event_request_metrics is not None:
|
|
response_request_metrics.update(event_request_metrics)
|
|
|
|
if event.get("type") not in _AUDIO_DELTA_TYPES:
|
|
continue
|
|
delta = event.get("delta") or event.get("audio")
|
|
if not isinstance(delta, str) or not delta:
|
|
continue
|
|
audio_received_at_s.append(received_at_s)
|
|
metadata = event.get("metadata")
|
|
duration_ms = metadata.get("audio_duration_ms") if isinstance(metadata, dict) else None
|
|
if isinstance(duration_ms, int | float) and math.isfinite(float(duration_ms)):
|
|
cumulative_audio_ms.append(max(0.0, float(duration_ms)))
|
|
|
|
result: dict[str, object] = {}
|
|
if stage0_metrics is not None:
|
|
raw_itls = stage0_metrics.get("vllm_itls_ms")
|
|
itls = (
|
|
[float(value) for value in raw_itls if isinstance(value, int | float)]
|
|
if isinstance(raw_itls, list)
|
|
else []
|
|
)
|
|
result["stage0_tokens"] = {
|
|
"source": "engine_stage_metrics",
|
|
"output_token_count": int(stage0_metrics.get("num_tokens_out") or 0),
|
|
"ttft_ms": float(stage0_metrics.get("vllm_ttft_ms") or 0.0),
|
|
"tpot_ms": float(stage0_metrics.get("vllm_tpot_ms") or 0.0),
|
|
"itls_ms": itls,
|
|
"inter_token_interval_ms": _interval_summary(itls),
|
|
}
|
|
|
|
if audio_received_at_s:
|
|
intervals_ms = [
|
|
(current - previous) * 1000.0 for previous, current in zip(audio_received_at_s, audio_received_at_s[1:])
|
|
]
|
|
chunk_durations_ms: list[float] = []
|
|
previous_duration_ms = 0.0
|
|
for duration_ms in cumulative_audio_ms:
|
|
chunk_durations_ms.append(
|
|
duration_ms - previous_duration_ms if duration_ms >= previous_duration_ms else duration_ms
|
|
)
|
|
previous_duration_ms = duration_ms
|
|
interval_summary = _interval_summary(intervals_ms)
|
|
result["audio_output"] = {
|
|
"source": "client_monotonic_receive",
|
|
"chunk_count": len(audio_received_at_s),
|
|
"response_created_to_first_audio_ms": (
|
|
_rounded_ms((audio_received_at_s[0] - response_created_at_s) * 1000.0)
|
|
if response_created_at_s is not None
|
|
else None
|
|
),
|
|
"commit_to_first_audio_ms": (
|
|
_rounded_ms((audio_received_at_s[0] - input_committed_at_s) * 1000.0)
|
|
if input_committed_at_s is not None
|
|
else None
|
|
),
|
|
"inter_chunk_interval_ms": interval_summary,
|
|
"chunk_duration_ms": _interval_summary(chunk_durations_ms),
|
|
"max_chunk_gap_ms": interval_summary["max"],
|
|
}
|
|
request_started_at_s = input_committed_at_s if input_committed_at_s is not None else response_created_at_s
|
|
if request_started_at_s is not None:
|
|
default_measurement_origin = (
|
|
{
|
|
"ttft": "input_audio_buffer.commit client send to first non-empty text delta",
|
|
"ttfp": "input_audio_buffer.commit client send to first audio packet",
|
|
}
|
|
if input_committed_at_s is not None
|
|
else {
|
|
"ttft": "response.created client receive to first non-empty text delta",
|
|
"ttfp": "response.created client receive to first audio packet",
|
|
}
|
|
)
|
|
resolved_measurement_origin = {
|
|
**default_measurement_origin,
|
|
**(measurement_origin or {}),
|
|
}
|
|
if stage0_metrics is not None:
|
|
resolved_measurement_origin["tpot"] = "Stage-0 engine mean time per output token"
|
|
server_ttft_ms = _finite_number(response_request_metrics.get("ttft_ms"), nonnegative=True)
|
|
server_ttfp_ms = _finite_number(response_request_metrics.get("ttfp_ms"), nonnegative=True)
|
|
server_origins = response_request_metrics.get("measurement_origin")
|
|
if isinstance(server_origins, dict):
|
|
if server_ttft_ms is not None and isinstance(server_origins.get("ttft"), str):
|
|
resolved_measurement_origin["ttft"] = server_origins["ttft"]
|
|
if server_ttfp_ms is not None and isinstance(server_origins.get("ttfp"), str):
|
|
resolved_measurement_origin["ttfp"] = server_origins["ttfp"]
|
|
audio_duration_ms = (
|
|
max(cumulative_audio_ms)
|
|
if cumulative_audio_ms
|
|
else self._format_for_bytes().duration_ms(len(self.audio_bytes(response_id)))
|
|
)
|
|
audio_generation_ms = max(
|
|
0.0,
|
|
(audio_received_at_s[-1] - request_started_at_s) * 1000.0,
|
|
)
|
|
# Raw measurements only: derived metrics such as the RTF
|
|
# (audio_generation_ms / audio_duration_ms) are computed by
|
|
# the caller — e.g. with vllm_omni.metrics.definitions.
|
|
# compute_audio_rtf — so metric definitions stay out of this
|
|
# dependency-free client module.
|
|
result["request_metrics"] = {
|
|
"source": (
|
|
"server_request_start_and_client_receive"
|
|
if server_ttft_ms is not None or server_ttfp_ms is not None
|
|
else "client_monotonic_receive"
|
|
),
|
|
"measurement_origin": resolved_measurement_origin,
|
|
"ttft_ms": (
|
|
server_ttft_ms
|
|
if server_ttft_ms is not None
|
|
else (
|
|
_rounded_ms((first_text_received_at_s - request_started_at_s) * 1000.0)
|
|
if first_text_received_at_s is not None
|
|
else None
|
|
)
|
|
),
|
|
"ttfp_ms": (
|
|
server_ttfp_ms
|
|
if server_ttfp_ms is not None
|
|
else _rounded_ms((audio_received_at_s[0] - request_started_at_s) * 1000.0)
|
|
),
|
|
"audio_generation_ms": _rounded_ms(audio_generation_ms),
|
|
"audio_duration_ms": _rounded_ms(audio_duration_ms),
|
|
}
|
|
if server_ttft_ms is not None or server_ttfp_ms is not None:
|
|
request_metrics = result["request_metrics"]
|
|
assert isinstance(request_metrics, dict)
|
|
request_metrics["response_created_to_first_text_ms"] = (
|
|
_rounded_ms((first_text_received_at_s - response_created_at_s) * 1000.0)
|
|
if first_text_received_at_s is not None and response_created_at_s is not None
|
|
else None
|
|
)
|
|
request_metrics["response_created_to_first_audio_ms"] = (
|
|
_rounded_ms((audio_received_at_s[0] - response_created_at_s) * 1000.0)
|
|
if response_created_at_s is not None
|
|
else None
|
|
)
|
|
if stage0_metrics is not None:
|
|
request_metrics = result["request_metrics"]
|
|
assert isinstance(request_metrics, dict)
|
|
request_metrics["tpot_ms"] = _finite_number(
|
|
stage0_metrics.get("vllm_tpot_ms"),
|
|
nonnegative=True,
|
|
)
|
|
return result
|
|
|
|
def _format_for_bytes(self) -> AudioFormat:
|
|
return AudioFormat("pcm16", self.output_sample_rate_hz)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Media helpers
|
|
|
|
|
|
def audio_data_url(data: bytes | Path, *, mime: str = "audio/wav") -> str:
|
|
"""Encode audio bytes (or a file path) as a data URL for ``ref_audio``."""
|
|
payload = data.read_bytes() if isinstance(data, Path) else data
|
|
return f"data:{mime};base64," + base64.b64encode(payload).decode("ascii")
|
|
|
|
|
|
def image_data_url(data: bytes | Path, *, mime: str = "image/jpeg") -> str:
|
|
"""Encode an image (or a file path) as a data URL for ``video_frames``."""
|
|
payload = data.read_bytes() if isinstance(data, Path) else data
|
|
return f"data:{mime};base64," + base64.b64encode(payload).decode("ascii")
|
|
|
|
|
|
def reference_audio_data_url(path: str | None) -> str | None:
|
|
"""Encode a local reference WAV for a Realtime session update."""
|
|
if path is None:
|
|
return None
|
|
audio = Path(path).expanduser().resolve()
|
|
if not audio.is_file():
|
|
raise FileNotFoundError(f"Reference audio does not exist: {audio}")
|
|
return audio_data_url(audio)
|
|
|
|
|
|
def chunk_period_ms(events: Sequence[dict[str, object]], *, default: int = 1000) -> int:
|
|
"""Read the negotiated native-duplex model-unit duration from session events."""
|
|
for event in reversed(events):
|
|
session = event.get("session")
|
|
capabilities = session.get("capabilities") if isinstance(session, dict) else None
|
|
value = capabilities.get("chunk_period_ms") if isinstance(capabilities, dict) else None
|
|
if isinstance(value, int) and not isinstance(value, bool) and value > 0:
|
|
return value
|
|
return default
|
|
|
|
|
|
def has_residual_model_unit(pcm16: bytes, *, chunk_period_ms: int) -> bool:
|
|
"""True when ``pcm16`` (16 kHz mono) does not end on a model-unit boundary."""
|
|
unit_bytes = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * chunk_period_ms // 1000
|
|
return bool(unit_bytes and len(pcm16) % unit_bytes)
|
|
|
|
|
|
def read_pcm16_wav(path: Path, *, sample_rate_hz: int = 16_000) -> bytes:
|
|
"""Read a mono, uncompressed PCM16 WAV file at the expected rate."""
|
|
with wave.open(str(path), "rb") as wav_file:
|
|
if wav_file.getnchannels() != 1:
|
|
raise ValueError("input WAV must be mono")
|
|
if wav_file.getsampwidth() != 2:
|
|
raise ValueError("input WAV must be 16-bit PCM")
|
|
if wav_file.getframerate() != sample_rate_hz:
|
|
raise ValueError(f"input WAV must be {sample_rate_hz} Hz")
|
|
if wav_file.getcomptype() != "NONE":
|
|
raise ValueError("input WAV must be uncompressed PCM")
|
|
return wav_file.readframes(wav_file.getnframes())
|
|
|
|
|
|
def write_pcm16_wav(path: Path, pcm16: bytes, *, sample_rate_hz: int) -> None:
|
|
"""Write mono PCM16 bytes as a WAV file."""
|
|
with wave.open(str(path), "wb") as wav_file:
|
|
wav_file.setnchannels(1)
|
|
wav_file.setsampwidth(2)
|
|
wav_file.setframerate(sample_rate_hz)
|
|
wav_file.writeframes(pcm16)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Probe/benchmark helpers
|
|
|
|
|
|
def build_realtime_url(
|
|
url: str,
|
|
model: str | None,
|
|
*,
|
|
autostart: bool | None = None,
|
|
extra_query: dict[str, str] | None = None,
|
|
) -> str:
|
|
"""Add explicit duplex query parameters to a Realtime URL.
|
|
|
|
:class:`DuplexClient` builds its own URL; this helper is for drivers that
|
|
speak the wire protocol directly. Model-specific query flags ride in
|
|
``extra_query``. ``http(s)`` URLs are rewritten to ``ws(s)``.
|
|
"""
|
|
parts = urlsplit(url)
|
|
if parts.scheme in {"http", "https"}:
|
|
parts = parts._replace(scheme="ws" if parts.scheme == "http" else "wss")
|
|
if parts.scheme not in {"ws", "wss"} or not parts.netloc:
|
|
raise ValueError(f"Unsupported Realtime URL: {url!r}")
|
|
query = dict(parse_qsl(parts.query, keep_blank_values=True))
|
|
query.setdefault("duplex", "1")
|
|
if model:
|
|
query.setdefault("model", model)
|
|
for key, value in (extra_query or {}).items():
|
|
query.setdefault(key, value)
|
|
if autostart is not None:
|
|
query.setdefault("autostart", "1" if autostart else "0")
|
|
return urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), parts.fragment))
|
|
|
|
|
|
async def wait_for_condition(
|
|
predicate: Callable[[], bool],
|
|
*,
|
|
timeout_s: float,
|
|
label: str,
|
|
) -> None:
|
|
"""Poll a collector predicate without coupling to a scenario runner."""
|
|
deadline = time.monotonic() + timeout_s
|
|
while time.monotonic() < deadline:
|
|
if predicate():
|
|
return
|
|
await asyncio.sleep(0.02)
|
|
raise TimeoutError(f"Timed out waiting for {label}")
|
|
|
|
|
|
def summarize_session_request_metrics(
|
|
request_metrics: list[dict[str, object]],
|
|
*,
|
|
session_id: str | None,
|
|
) -> dict[str, object]:
|
|
"""Summarize client- and engine-observed metrics across audio responses.
|
|
|
|
``request_metrics`` entries are caller-assembled dicts; keys that are
|
|
absent or non-numeric in an entry are simply skipped. ``rtf`` is not
|
|
produced by :meth:`EventCollector.timing_summary` (which reports raw data
|
|
only) — callers that want session ``rtf`` add an ``rtf`` value per turn,
|
|
e.g. via ``vllm_omni.metrics.definitions.compute_audio_rtf``. Zero or
|
|
missing ``tpot_ms`` values are omitted from session ``tpot_ms``.
|
|
|
|
Aggregatable fields are nested as ``{count, mean, p50, p99}``.
|
|
"""
|
|
|
|
def values(metric: str, *, positive: bool = False) -> list[float]:
|
|
return [
|
|
float(request[metric])
|
|
for request in request_metrics
|
|
if isinstance(request.get(metric), int | float)
|
|
and not isinstance(request.get(metric), bool)
|
|
and math.isfinite(float(request[metric]))
|
|
and (not positive or float(request[metric]) > 0)
|
|
]
|
|
|
|
summary: dict[str, object] = {
|
|
"session_id": session_id,
|
|
"audio_turn_count": len(request_metrics),
|
|
"ttft_ms": distribution_summary(values("ttft_ms")),
|
|
"ttfp_ms": distribution_summary(values("ttfp_ms")),
|
|
"rtf": distribution_summary(values("rtf"), digits=6),
|
|
}
|
|
if (tpot := distribution_summary(values("tpot_ms", positive=True))) is not None:
|
|
summary["tpot_ms"] = tpot
|
|
return summary
|
|
|
|
|
|
async def acknowledge_collected_playback(client: DuplexClientBase, collector: EventCollector) -> None:
|
|
"""Ack playback of every collected response's audio (probe shorthand).
|
|
|
|
A response that has produced no audio yet is still acked (0 ms) unless it
|
|
already finished: the ack checkpoints the response's history position on
|
|
the server, so a user input committed later cannot displace it.
|
|
"""
|
|
output_format = AudioFormat("pcm16", collector.output_sample_rate_hz)
|
|
for response_id in collector.response_ids:
|
|
pcm16 = collector.audio_bytes(response_id)
|
|
if not pcm16 and collector.response_is_done(response_id):
|
|
continue
|
|
played_ms = output_format.duration_ms(len(pcm16))
|
|
await client.ack_playback(
|
|
played_ms,
|
|
response_id=response_id,
|
|
item_id=f"item_{response_id}",
|
|
)
|