[Core] Give the OpenAI Realtime wire codec a reusable home (RFC #6592 P0a) (#7640)

Signed-off-by: psv666 <2693925048@qq.com>
This commit is contained in:
psv666 authored and GitHub committed 2026-09-17 02:33:40 -07:00
1 parent 825ab20b07
commit e3be42e052
37 files changed
+3792 -1629

No files matched your search

+163
View File
@@ -0,0 +1,163 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The OpenAI Realtime wire codec, shared by every vLLM-Omni Realtime surface.
What this is
------------
The model-agnostic half of ``WS /v1/realtime``: how a client event is parsed,
which audio formats and session fields are accepted, what a conversation item
means, how audio is decoded and resampled, and the shape of the error envelope
(the *codes* inside it are a consumer's own --- ours are in
``vllm_omni.protocol.duplex.errors``). It is a library of pure functions and value objects.
What it is not
--------------
It owns no session. It does not decide when a response starts, what a model is
prompted with, whether a turn is over, or where committed audio goes. Those are
runtime decisions, and after PR #7413 a duplex session's runtime decisions all
belong to ``vllm_omni.engine.duplex`` --- the session runner, the typed
``DuplexCommand`` / ``DuplexEvent`` boundary and the ``DuplexModelPlugin`` seam.
This package sits *under* that: the duplex engine binds the codec to its own
command and event vocabulary in
``vllm_omni.engine.duplex.realtime_commands`` and
``vllm_omni.engine.duplex.realtime_events``.
Why it is separate (RFC #6592, P0a)
-----------------------------------
So that a second Realtime surface --- a Qwen3-Omni GA profile, say --- can reuse
the parsing, validation, format negotiation and error envelope without adopting
the duplex session control plane (lease manager, commit policy, model channel),
and without a second copy of the codec drifting away from this one. A consumer
states what it can serve through
:class:`~vllm_omni.protocol.realtime.capabilities.RealtimeProtocolCapabilities`
and keeps its own session state.
Known duplex-shaped defaults
----------------------------
This package is model-agnostic in its *dependencies*, not yet in every
*default*. Two constants still carry the assumptions of its first consumer and
a second one will have to parameterize them:
* :func:`~vllm_omni.protocol.realtime.audio.convert_input_audio_with_rate`
resamples to 16 kHz, which is MiniCPM-o's feature-extractor rate. A GA client
sending 24 kHz is converted down rather than served at its own rate.
* :func:`~vllm_omni.protocol.realtime.items.validate_realtime_video_frames`
encodes the MiniCPM-o duplex camera contract (at most two frames per append,
``max_slice_nums`` not selectable on the wire).
Both are behaviour this extraction deliberately did **not** change (RFC #6592
P0a is behaviour-preserving); making them per-consumer is P0b/P1 work.
Dependency rule
---------------
Nothing here may import ``vllm_omni.engine``, ``vllm_omni.entrypoints``,
``vllm_omni.model_executor``, ``vllm_omni.worker`` or ``vllm_omni.clients``;
``tests/protocol/realtime/test_protocol_import_boundary.py`` asserts it.
"""
from vllm_omni.protocol.realtime.audio import (
MAX_INPUT_SAMPLE_RATE_HZ,
MIN_INPUT_SAMPLE_RATE_HZ,
convert_input_audio_with_rate,
convert_output_audio,
encode_float32_mono_wav_base64,
resample_pcm16_mono,
validate_input_sample_rate_hz,
wav_payload_to_pcm16,
)
from vllm_omni.protocol.realtime.audio_input import (
REALTIME_INPUT_HINT_KEYS,
RealtimeAudioAppend,
copy_realtime_input_hints,
decode_audio_append,
input_explicitly_non_speech,
input_looks_like_speech,
)
from vllm_omni.protocol.realtime.capabilities import (
RealtimeProtocolCapabilities,
RealtimeSessionRejection,
validate_session_payload,
)
from vllm_omni.protocol.realtime.commands import RealtimeCommand
from vllm_omni.protocol.realtime.errors import RealtimeProtocolError
from vllm_omni.protocol.realtime.events import (
RealtimeEvent,
ResponseScopedEvent,
new_event_id,
wire_value,
)
from vllm_omni.protocol.realtime.formats import (
REALTIME_INPUT_AUDIO_FORMATS,
REALTIME_OUTPUT_AUDIO_FORMATS,
is_supported_realtime_input_format,
parse_realtime_audio_format,
realtime_audio_format_object,
realtime_output_format,
validate_conversation_item_audio_formats,
validate_realtime_response_audio_formats,
validate_realtime_session_audio_formats,
)
from vllm_omni.protocol.realtime.items import (
input_transcript_from_item,
normalize_conversation_item,
text_chars_for_audio_ms_from_marks,
truncate_realtime_item_content,
validate_realtime_item_truncate,
validate_realtime_video_frames,
)
from vllm_omni.protocol.realtime.session import (
RealtimeInputDefaults,
apply_realtime_session_defaults,
input_audio_transcription_config,
json_safe_realtime_payload,
realtime_max_output_tokens,
realtime_overlap_fields,
)
__all__ = [
"MAX_INPUT_SAMPLE_RATE_HZ",
"MIN_INPUT_SAMPLE_RATE_HZ",
"REALTIME_INPUT_AUDIO_FORMATS",
"REALTIME_INPUT_HINT_KEYS",
"REALTIME_OUTPUT_AUDIO_FORMATS",
"RealtimeAudioAppend",
"RealtimeCommand",
"RealtimeEvent",
"RealtimeInputDefaults",
"RealtimeProtocolCapabilities",
"RealtimeProtocolError",
"RealtimeSessionRejection",
"ResponseScopedEvent",
"apply_realtime_session_defaults",
"convert_input_audio_with_rate",
"convert_output_audio",
"copy_realtime_input_hints",
"decode_audio_append",
"encode_float32_mono_wav_base64",
"input_audio_transcription_config",
"input_explicitly_non_speech",
"input_looks_like_speech",
"input_transcript_from_item",
"is_supported_realtime_input_format",
"json_safe_realtime_payload",
"new_event_id",
"normalize_conversation_item",
"parse_realtime_audio_format",
"realtime_audio_format_object",
"realtime_max_output_tokens",
"realtime_output_format",
"realtime_overlap_fields",
"resample_pcm16_mono",
"text_chars_for_audio_ms_from_marks",
"truncate_realtime_item_content",
"validate_conversation_item_audio_formats",
"validate_input_sample_rate_hz",
"validate_realtime_item_truncate",
"validate_realtime_response_audio_formats",
"validate_realtime_session_audio_formats",
"validate_realtime_video_frames",
"validate_session_payload",
"wav_payload_to_pcm16",
"wire_value",
]
+218
View File
@@ -0,0 +1,218 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
from __future__ import annotations
import base64
import binascii
import io
import math
import wave
import numpy as np
from vllm_omni.utils.audio_resample import StreamingAudioResampler
try:
from audioop import alaw2lin, lin2alaw, lin2ulaw, ulaw2lin
except ImportError: # pragma: no cover - audioop is removed in newer Python.
alaw2lin = lin2alaw = lin2ulaw = ulaw2lin = None
MIN_INPUT_SAMPLE_RATE_HZ = 8_000
MAX_INPUT_SAMPLE_RATE_HZ = 192_000
def validate_input_sample_rate_hz(sample_rate_hz: object) -> int:
if (
isinstance(sample_rate_hz, bool)
or not isinstance(sample_rate_hz, int | float)
or (isinstance(sample_rate_hz, float) and not math.isfinite(sample_rate_hz))
):
raise ValueError("sample_rate_hz must be a finite integer")
rate = int(sample_rate_hz)
if rate != sample_rate_hz:
raise ValueError("sample_rate_hz must be an integer")
if not MIN_INPUT_SAMPLE_RATE_HZ <= rate <= MAX_INPUT_SAMPLE_RATE_HZ:
raise ValueError(f"sample_rate_hz must be between {MIN_INPUT_SAMPLE_RATE_HZ} and {MAX_INPUT_SAMPLE_RATE_HZ}")
return rate
def resample_pcm16_mono(raw: bytes, *, source_rate_hz: int, target_rate_hz: int) -> bytes:
if source_rate_hz <= 0 or target_rate_hz <= 0 or source_rate_hz == target_rate_hz:
return raw
samples = np.frombuffer(raw, dtype="<i2")
rate_gcd = math.gcd(source_rate_hz, target_rate_hz)
reduced_factor = max(source_rate_hz // rate_gcd, target_rate_hz // rate_gcd)
if reduced_factor > StreamingAudioResampler.max_polyphase_factor:
# The FIR size scales with the reduced ratio. Keep unusual client
# rates bounded while common audio rates use the high-quality path.
if samples.size <= 1:
return raw
target_size = max(1, int(round(samples.size * target_rate_hz / source_rate_hz)))
source_x = np.linspace(0.0, 1.0, num=samples.size, endpoint=True)
target_x = np.linspace(0.0, 1.0, num=target_size, endpoint=True)
resampled = np.interp(target_x, source_x, samples)
return np.clip(resampled, -32_768, 32_767).astype("<i2").tobytes()
resampler = StreamingAudioResampler(source_rate_hz, target_rate_hz)
normalized = samples.astype(np.float32) * np.float32(1.0 / 32768.0)
resampled = resampler.process(normalized, final=True)
return np.clip(np.rint(resampled * 32768.0), -32768, 32767).astype("<i2").tobytes()
def decode_g711_ulaw(raw: bytes) -> bytes:
if ulaw2lin is not None:
return ulaw2lin(raw, 2)
data = np.frombuffer(raw, dtype=np.uint8)
value = np.bitwise_not(data).astype(np.int16)
sign = value & 0x80
exponent = (value >> 4) & 0x07
mantissa = value & 0x0F
sample = ((mantissa << 3) + 0x84) << exponent
return np.where(sign != 0, -(sample - 0x84), sample - 0x84).astype("<i2").tobytes()
def decode_g711_alaw(raw: bytes) -> bytes:
if alaw2lin is not None:
return alaw2lin(raw, 2)
data = np.bitwise_xor(np.frombuffer(raw, dtype=np.uint8), 0x55).astype(np.int16)
sign = data & 0x80
exponent = (data >> 4) & 0x07
mantissa = data & 0x0F
sample = np.where(exponent == 0, (mantissa << 4) + 8, ((mantissa << 4) + 0x108) << (exponent - 1))
return np.where(sign != 0, sample, -sample).astype("<i2").tobytes()
def encode_g711_ulaw(raw: bytes) -> bytes:
if lin2ulaw is not None:
return lin2ulaw(raw, 2)
pcm = np.clip(np.frombuffer(raw, dtype="<i2").astype(np.int32), -32635, 32635)
sign = np.where(pcm < 0, 0x80, 0)
magnitude = np.abs(pcm) + 0x84
exponent = np.zeros_like(magnitude)
for exp in range(7):
exponent = np.where(magnitude > (0xFF << exp), exp + 1, exponent)
mantissa = (magnitude >> (exponent + 3)) & 0x0F
return (np.bitwise_not(sign | (exponent << 4) | mantissa) & 0xFF).astype(np.uint8).tobytes()
def encode_g711_alaw(raw: bytes) -> bytes:
if lin2alaw is not None:
return lin2alaw(raw, 2)
pcm = np.frombuffer(raw, dtype="<i2").astype(np.int32)
sign = np.where(pcm >= 0, 0x80, 0x00)
magnitude = np.abs(pcm)
exponent = np.zeros_like(magnitude)
for exp in range(1, 8):
exponent = np.where(magnitude >= (1 << (exp + 7)), exp, exponent)
mantissa = np.where(
exponent == 0,
(magnitude >> 4) & 0x0F,
(magnitude >> (exponent + 3)) & 0x0F,
)
return ((sign | (exponent << 4) | mantissa) ^ 0x55).astype(np.uint8).tobytes()
def wav_payload_to_pcm16(raw: bytes) -> tuple[bytes | None, int | None]:
try:
with wave.open(io.BytesIO(raw), "rb") as wav_file:
channels = wav_file.getnchannels()
sample_width = wav_file.getsampwidth()
sample_rate_hz = wav_file.getframerate()
frames = wav_file.readframes(wav_file.getnframes())
if sample_width != 2:
return None, sample_rate_hz
if channels <= 1:
return frames, sample_rate_hz
pcm = np.frombuffer(frames, dtype="<i2").reshape(-1, channels)
mono = np.mean(pcm.astype(np.float32), axis=1)
return np.clip(mono, -32768, 32767).astype("<i2").tobytes(), sample_rate_hz
except (EOFError, ValueError, wave.Error):
return None, None
def encode_float32_mono_wav_base64(
samples: np.ndarray,
*,
sample_rate_hz: int,
) -> str:
"""Package normalized mono float32 samples as a PCM16 WAV data payload."""
rate = validate_input_sample_rate_hz(sample_rate_hz)
normalized = np.ascontiguousarray(samples, dtype=np.float32).reshape(-1)
pcm16 = np.clip(np.rint(normalized * 32768.0), -32768, 32767).astype("<i2")
with io.BytesIO() as buffer:
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2)
wav_file.setframerate(rate)
wav_file.writeframes(pcm16.tobytes())
return base64.b64encode(buffer.getvalue()).decode("ascii")
def convert_input_audio_with_rate(
audio: object,
fmt: object,
*,
sample_rate_hz: int | float | None = None,
target_sample_rate_hz: int = 16_000,
) -> tuple[object, object, int | float | None]:
if not isinstance(audio, str) or not isinstance(fmt, str):
return audio, fmt, sample_rate_hz
normalized = fmt.lower()
if normalized not in {"pcm16", "pcm_s16le", "s16le", "g711_ulaw", "g711_alaw"}:
return audio, fmt, sample_rate_hz
if isinstance(sample_rate_hz, int | float):
sample_rate_hz = validate_input_sample_rate_hz(sample_rate_hz)
try:
raw = base64.b64decode(audio.strip(), validate=False)
except (binascii.Error, ValueError):
return audio, fmt, sample_rate_hz
if normalized == "g711_ulaw":
raw = decode_g711_ulaw(raw)
sample_rate_hz = sample_rate_hz if isinstance(sample_rate_hz, int | float) else 8_000
elif normalized == "g711_alaw":
raw = decode_g711_alaw(raw)
sample_rate_hz = sample_rate_hz if isinstance(sample_rate_hz, int | float) else 8_000
elif len(raw) % 2:
return audio, fmt, sample_rate_hz
if isinstance(sample_rate_hz, int | float) and int(sample_rate_hz) != target_sample_rate_hz:
raw = resample_pcm16_mono(
raw,
source_rate_hz=int(sample_rate_hz),
target_rate_hz=target_sample_rate_hz,
)
sample_rate_hz = target_sample_rate_hz
pcm = np.frombuffer(raw, dtype="<i2").astype(np.float32) / 32768.0
encoded = base64.b64encode(np.ascontiguousarray(pcm, dtype="<f4").tobytes()).decode("ascii")
return encoded, "pcm_f32le", sample_rate_hz
def convert_output_audio(
audio: str,
*,
source_fmt: str,
target_fmt: str,
source_sample_rate_hz: int | None = None,
target_sample_rate_hz: int | None = None,
) -> tuple[str, str, int | None]:
target = target_fmt.lower()
if target not in {"g711_ulaw", "g711_alaw"}:
return audio, source_fmt, source_sample_rate_hz
try:
raw = base64.b64decode(audio, validate=False)
except (binascii.Error, ValueError):
return audio, source_fmt, source_sample_rate_hz
source = source_fmt.lower()
if source == "wav":
pcm_raw, wav_rate = wav_payload_to_pcm16(raw)
if pcm_raw is None:
return audio, source_fmt, source_sample_rate_hz
raw = pcm_raw
source_sample_rate_hz = source_sample_rate_hz or wav_rate
elif source not in {"pcm", "pcm16", "pcm_s16le", "s16le"} or len(raw) % 2:
return audio, source_fmt, source_sample_rate_hz
target_rate = target_sample_rate_hz or 8_000
if source_sample_rate_hz is not None:
raw = resample_pcm16_mono(raw, source_rate_hz=source_sample_rate_hz, target_rate_hz=target_rate)
encoded = encode_g711_ulaw(raw) if target == "g711_ulaw" else encode_g711_alaw(raw)
return base64.b64encode(encoded).decode("ascii"), target, target_rate
+243
View File
@@ -0,0 +1,243 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Decoding one ``input_audio_buffer.append``.
An append is the hottest client event on a Realtime connection and the one with
the most wire surface: base64 audio in any accepted format, an optional rate,
optional camera frames, and a set of optional hints (``duration_ms``,
``is_speech``, a VAD probability, a transcript) that clients attach and servers
are free to use or ignore.
:func:`decode_audio_append` turns that into :class:`RealtimeAudioAppend`, a
value object: bytes at 16 kHz ``pcm_f32le``, the surviving hints, and a speech
verdict. It is a pure function of the event plus the session's
:class:`~vllm_omni.protocol.realtime.session.RealtimeInputDefaults`, so any
consumer decodes an append the same way. Building a consumer's own command
object out of it is one constructor call --- see
``vllm_omni.engine.duplex.realtime_commands.build_append_audio``, which wraps it
for the duplex ``AppendAudio``.
The speech verdict here is the *client-declared* one (explicit flags, a VAD
probability the client sent, or an RMS floor as a last resort). It is not
server-side VAD; that is a runtime concern and lives with whoever owns the
session.
"""
from __future__ import annotations
import base64
import binascii
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import cast
import numpy as np
from vllm_omni.protocol.realtime.audio import convert_input_audio_with_rate
from vllm_omni.protocol.realtime.errors import RealtimeProtocolError
from vllm_omni.protocol.realtime.formats import (
is_supported_realtime_input_format,
parse_realtime_audio_format,
)
from vllm_omni.protocol.realtime.items import validate_realtime_video_frames
from vllm_omni.protocol.realtime.session import RealtimeInputDefaults
__all__ = [
"REALTIME_INPUT_HINT_KEYS",
"RealtimeAudioAppend",
"copy_realtime_input_hints",
"decode_audio_append",
"input_explicitly_non_speech",
"input_looks_like_speech",
]
#: Wire hint keys copied verbatim from a client append onto the internal payload.
REALTIME_INPUT_HINT_KEYS = (
"duration_ms",
"audio_duration_ms",
"audio_start_ms",
"audio_end_ms",
"is_speech",
"speech",
"speech_probability",
"vad",
"overlap_action",
"overlap",
"force_barge_in",
"force_listen",
"text",
"transcript",
)
@dataclass(frozen=True, slots=True)
class RealtimeAudioAppend:
"""One decoded ``input_audio_buffer.append``.
``audio`` is raw bytes in ``format`` at ``sample_rate_hz`` --- after
:func:`decode_audio_append` that is 16 kHz ``pcm_f32le`` for every input
format the codec converts.
"""
audio: bytes
format: str
sample_rate_hz: int | None = None
is_speech: bool | None = None
video_frames: tuple[str, ...] = ()
duration_ms: int | None = None
audio_end_ms: int | None = None
#: Model-neutral hints carried through from the wire (rms, vad, transcript hints ...).
hints: dict[str, object] = field(default_factory=dict)
#: Client correlation id (OpenAI ``event_id``), echoed on error events.
event_id: str | None = None
# ---- speech hints ----
def input_explicitly_non_speech(event: Mapping[str, object]) -> bool:
for key in ("is_speech", "speech"):
value = event.get(key)
if isinstance(value, bool):
return not value
vad = event.get("vad")
if isinstance(vad, dict):
value = vad.get("is_speech")
if isinstance(value, bool):
return not value
probability = vad.get("speech_probability", vad.get("probability"))
if isinstance(probability, int | float):
return float(probability) < 0.5
probability = event.get("speech_probability")
return isinstance(probability, int | float) and float(probability) < 0.5
def input_looks_like_speech(
event: Mapping[str, object],
*,
audio: object,
fmt: object,
overlap_silence_rms: float,
) -> bool:
if input_explicitly_non_speech(event):
return False
for key in ("is_speech", "speech"):
value = event.get(key)
if isinstance(value, bool):
return value
vad = event.get("vad")
if isinstance(vad, dict):
probability = vad.get("speech_probability", vad.get("probability"))
if isinstance(probability, int | float):
return float(probability) >= 0.5
probability = event.get("speech_probability")
if isinstance(probability, int | float):
return float(probability) >= 0.5
if fmt != "pcm_f32le":
return True
if isinstance(audio, bytes | bytearray):
raw = bytes(audio)
elif isinstance(audio, str):
try:
raw = base64.b64decode(audio, validate=True)
except (binascii.Error, ValueError):
return True
else:
return True
if len(raw) < 4 or len(raw) % 4 != 0:
return True
samples = np.frombuffer(raw, dtype=np.float32)
if samples.size == 0:
return False
rms = float(np.sqrt(np.mean(np.square(samples.astype(np.float32)))))
threshold = event.get("overlap_silence_rms")
if not isinstance(threshold, int | float):
vad = event.get("vad")
if isinstance(vad, dict):
threshold = vad.get("silence_rms")
silence_rms = float(threshold) if isinstance(threshold, int | float) else overlap_silence_rms
return rms >= max(0.0, silence_rms)
def copy_realtime_input_hints(source: Mapping[str, object], target: dict[str, object]) -> None:
for key in REALTIME_INPUT_HINT_KEYS:
if key in source:
target[key] = source[key]
# ---- append decoding ----
def decode_audio_append(
event: Mapping[str, object],
*,
defaults: RealtimeInputDefaults,
hints_source: Mapping[str, object] | None = None,
) -> RealtimeAudioAppend:
"""Validate, convert (to 16 kHz ``pcm_f32le``) and pack one audio append.
``hints_source`` is the enclosing payload when the audio arrives inside
something larger than a bare append --- a ``conversation.item.create``
audio part, say --- so its hints apply unless the part overrides them.
Raises :class:`RealtimeProtocolError` for an unsupported format, undecodable
audio or invalid camera frames.
"""
event_id = cast("str", event.get("event_id")) if isinstance(event.get("event_id"), str) else None
audio = event.get("audio") or event.get("delta")
fmt, format_rate = parse_realtime_audio_format(
event.get("format") or event.get("input_audio_format") or defaults.input_audio_format
)
sample_rate_hz = (
event.get("sample_rate_hz") or event.get("sample_rate") or format_rate or defaults.input_sample_rate_hz
)
if not is_supported_realtime_input_format(fmt):
raise RealtimeProtocolError(
f"Unsupported input_audio_format: {fmt}",
code="unsupported_audio_format",
event_id=event_id,
)
try:
audio, fmt, sample_rate_hz = convert_input_audio_with_rate(
audio,
fmt,
sample_rate_hz=sample_rate_hz if isinstance(sample_rate_hz, int | float) else None,
)
except ValueError as exc:
raise RealtimeProtocolError(str(exc), code="bad_event", event_id=event_id) from exc
hints: dict[str, object] = {}
if hints_source is not None:
copy_realtime_input_hints(hints_source, hints)
copy_realtime_input_hints(event, hints)
looks_like_speech = input_looks_like_speech(
{**(hints_source or {}), **event},
audio=audio,
fmt=fmt,
overlap_silence_rms=defaults.overlap_silence_rms,
)
video_frames: tuple[str, ...] = ()
raw_frames = event.get("video_frames")
if raw_frames is not None:
frames_error = validate_realtime_video_frames(raw_frames, event.get("max_slice_nums"))
if frames_error is not None:
raise RealtimeProtocolError(frames_error, code="invalid_video_frames", event_id=event_id)
if isinstance(raw_frames, list):
video_frames = tuple(frame for frame in raw_frames if isinstance(frame, str) and frame)
duration_ms = hints.get("duration_ms", hints.get("audio_duration_ms"))
audio_end_ms = hints.get("audio_end_ms")
try:
audio_bytes = base64.b64decode(audio, validate=True) if isinstance(audio, str) and audio else b""
except (binascii.Error, ValueError) as exc:
raise RealtimeProtocolError("input audio is not valid base64", code="bad_audio", event_id=event_id) from exc
return RealtimeAudioAppend(
event_id=event_id,
audio=audio_bytes,
format=str(fmt),
sample_rate_hz=int(sample_rate_hz) if isinstance(sample_rate_hz, int | float) else None,
is_speech=looks_like_speech,
video_frames=video_frames,
duration_ms=int(duration_ms) if isinstance(duration_ms, int | float) else None,
audio_end_ms=int(audio_end_ms) if isinstance(audio_end_ms, int | float) else None,
hints=hints,
)
+105
View File
@@ -0,0 +1,105 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""What a Realtime consumer can serve, and the session check that reads it.
``session.update`` is the one client event whose acceptance depends on who is
behind the socket. The audio formats a server can decode, and whether it can do
server-side turn detection at all, are properties of the *consumer* --- the
duplex engine, or some other runtime --- not of the protocol. The codec still
has to do the checking, because it owns the error envelope and the order the
checks run in.
:class:`RealtimeProtocolCapabilities` is how a consumer states those answers.
It is the post-#7413 form of the ``supports(turn_detection, formats)`` half of
RFC #6592's proposed ``RealtimeModelAdapter``. The other three halves of that
proposal --- building a prompt, starting a response, cancelling one --- are not
here and must not come here: for a duplex model they are already owned by
``DuplexModelPlugin`` together with ``DuplexSessionRunner``, and a second
interface over the same responsibility is exactly the fork this layer exists to
prevent.
The duplex binding of this object is
``vllm_omni.engine.duplex.realtime_commands.DUPLEX_REALTIME_CAPABILITIES``.
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from vllm_omni.protocol.realtime.formats import (
REALTIME_INPUT_AUDIO_FORMATS,
REALTIME_OUTPUT_AUDIO_FORMATS,
validate_realtime_session_audio_formats,
)
__all__ = [
"RealtimeProtocolCapabilities",
"RealtimeSessionRejection",
"validate_session_payload",
]
#: A consumer's answer to "can you do this turn detection?": an error message, or None.
TurnDetectionValidator = Callable[[Mapping[str, object]], str | None]
@dataclass(frozen=True, slots=True)
class RealtimeProtocolCapabilities:
"""One consumer's answer to what a ``session`` object may ask for.
The defaults are permissive, not restrictive: every format the codec can
decode, and **no turn-detection validation at all**. A consumer that leaves
``validate_turn_detection`` at ``None`` therefore accepts whatever
``turn_detection`` the session object carries --- including values it cannot
actually serve, such as ``semantic_vad``. A consumer that supports only some
turn-detection modes (or none) must supply its own validator; see
``vllm_omni.engine.duplex.realtime_commands.DUPLEX_REALTIME_CAPABILITIES``.
"""
input_audio_formats: frozenset[str] = field(default_factory=lambda: frozenset(REALTIME_INPUT_AUDIO_FORMATS))
output_audio_formats: frozenset[str] = field(default_factory=lambda: frozenset(REALTIME_OUTPUT_AUDIO_FORMATS))
#: Consumer-specific ``turn_detection`` validation. ``None`` means the
#: consumer takes whatever the session object says without a check of its own.
validate_turn_detection: TurnDetectionValidator | None = None
@dataclass(frozen=True, slots=True)
class RealtimeSessionRejection:
"""Why a session object was refused: the internal error code and the message.
``param`` names the offending session field where there is one, for the
``error.param`` slot of the OpenAI error envelope.
"""
code: str
message: str
param: str | None = None
def validate_session_payload(
session_payload: Mapping[str, object],
*,
capabilities: RealtimeProtocolCapabilities,
) -> RealtimeSessionRejection | None:
"""Check a ``session`` object against one consumer; ``None`` when it is acceptable.
Audio formats are checked before turn detection, so a session object that
is wrong in both ways reports the format problem --- keep that order, it is
what clients already see.
"""
format_error = validate_realtime_session_audio_formats(
session_payload,
input_audio_formats=capabilities.input_audio_formats,
output_audio_formats=capabilities.output_audio_formats,
)
if format_error is not None:
return RealtimeSessionRejection(code="unsupported_audio_format", message=format_error)
validate_turn_detection = capabilities.validate_turn_detection
if validate_turn_detection is not None:
turn_detection_error = validate_turn_detection(session_payload)
if turn_detection_error is not None:
return RealtimeSessionRejection(
code="unsupported_turn_detection", message=turn_detection_error, param="turn_detection"
)
return None
+126
View File
@@ -0,0 +1,126 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""OpenAI Realtime client events as typed commands.
A Realtime server's first job is turning a client JSON event into something
typed. These are the 10 client events OpenAI's Realtime API defines, as frozen
dataclasses carrying the fields that survive decoding.
vLLM-Omni's own client events (barge-in, playback acknowledgement, text append,
heartbeat, explicit close, generic turn signals) are a separate vocabulary in
``vllm_omni.protocol.duplex.commands``.
Decoding a payload *into* these stays with the consumer, because which command
a given event becomes can depend on what that consumer supports --- see
``vllm_omni.engine.duplex.realtime_commands.translate_realtime_command``.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import ClassVar
@dataclass(frozen=True, slots=True, kw_only=True)
class RealtimeCommand:
"""One decoded OpenAI Realtime client event.
``wire_type`` is the event type the client sent; the typed fields are what
survived decoding, so a consumer reads them instead of re-parsing JSON.
There is deliberately no rendering method here. A server receives commands,
it does not emit them, and how a runtime represents one internally is its
own business --- the duplex engine renders its mailbox dictionary in
``vllm_omni.engine.duplex.commands``.
"""
#: OpenAI Realtime client event this command decodes from.
wire_type: ClassVar[str] = ""
#: Client correlation id (OpenAI ``event_id``), echoed on error events.
event_id: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class UpdateSession(RealtimeCommand):
"""``session.update``: a Realtime ``session`` object patch."""
wire_type: ClassVar[str] = "session.update"
patch: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class AppendAudio(RealtimeCommand):
wire_type: ClassVar[str] = "input_audio_buffer.append"
#: Raw audio bytes in ``format`` at ``sample_rate_hz`` (base64 only on the wire).
audio: bytes
@dataclass(frozen=True, slots=True, kw_only=True)
class Commit(RealtimeCommand):
wire_type: ClassVar[str] = "input_audio_buffer.commit"
final: bool = True
@dataclass(frozen=True, slots=True, kw_only=True)
class ClearInput(RealtimeCommand):
wire_type: ClassVar[str] = "input_audio_buffer.clear"
@dataclass(frozen=True, slots=True, kw_only=True)
class ClearOutputAudio(RealtimeCommand):
wire_type: ClassVar[str] = "output_audio_buffer.clear"
#: Explicit response to clear; ``None`` targets the active/last response.
response_id: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class CreateResponse(RealtimeCommand):
wire_type: ClassVar[str] = "response.create"
#: Raw Realtime ``response`` object (instructions, voice, modalities, ...).
options: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class CancelResponse(RealtimeCommand):
wire_type: ClassVar[str] = "response.cancel"
response_id: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class CreateItem(RealtimeCommand):
"""``conversation.item.create`` (history injection or function-call output)."""
wire_type: ClassVar[str] = "conversation.item.create"
item: Mapping[str, object]
previous_item_id: str | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class DeleteItem(RealtimeCommand):
wire_type: ClassVar[str] = "conversation.item.delete"
item_id: str
@dataclass(frozen=True, slots=True, kw_only=True)
class TruncateItem(RealtimeCommand):
wire_type: ClassVar[str] = "conversation.item.truncate"
item_id: str
audio_end_ms: int
content_index: int = 0
__all__ = [
"AppendAudio",
"CancelResponse",
"ClearInput",
"ClearOutputAudio",
"Commit",
"CreateItem",
"CreateResponse",
"DeleteItem",
"RealtimeCommand",
"TruncateItem",
"UpdateSession",
]
+37
View File
@@ -0,0 +1,37 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""The OpenAI Realtime error envelope.
A Realtime server reports a rejected client event as one ``error`` event whose
``error`` object carries ``type`` / ``code`` / ``message`` (plus the client's
``event_id`` and an optional ``param``). ``type`` is not free-form: it is one
of OpenAI's four buckets, derived from our internal code by
:data:`REALTIME_ERROR_TYPES_BY_CODE`.
OpenAI standardises the three ``type`` classes but not the *codes*, so the code
vocabulary is a consumer's own. vLLM-Omni's lives in
``vllm_omni.protocol.duplex.errors`` (Tier 3 in
``docs/serving/realtime_duplex_api.md``); what stays here is the exception type
every consumer raises, so the envelope is rendered identically whoever built it.
"""
from __future__ import annotations
__all__ = ["RealtimeProtocolError"]
class RealtimeProtocolError(ValueError):
"""A client payload could not be decoded into a valid Realtime intent.
``code`` is the internal error code that :data:`REALTIME_ERROR_TYPES_BY_CODE`
maps to an OpenAI ``error.type``; ``event_id`` is the *client* event id the
error answers. ``vllm_omni.engine.duplex.commands.DuplexCommandError`` is
the duplex specialization, so a consumer catching either sees the same
three attributes.
"""
def __init__(self, message: str, *, code: str = "bad_event", event_id: str | None = None) -> None:
super().__init__(message)
self.code = code
self.event_id = event_id
+467
View File
@@ -0,0 +1,467 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""OpenAI Realtime server events: typed classes and their wire rendering.
Every event is a frozen dataclass with explicit fields, and the Realtime wire
JSON is *derived* from those fields by :meth:`RealtimeEvent.to_realtime`. That
rendering is pure --- it never consults session state --- which is why it can
live here, away from whoever owns the session. The stateful part (response and
item ids, content-part bookkeeping) is consumed when the events are
*constructed*, by the consumer.
This module holds the 30 events OpenAI's Realtime API defines. vLLM-Omni's own
additions to the wire (listen/speak turn-taking, playback acknowledgement,
session resume) are a separate vocabulary in
``vllm_omni.protocol.duplex.events``, so a consumer that only speaks GA does
not inherit them.
``session_id`` / ``epoch`` are bound by the consumer when an event leaves it;
producers build events with the defaults.
"""
from __future__ import annotations
import base64
from collections.abc import Mapping
from dataclasses import dataclass, field, fields
from typing import ClassVar
from uuid import uuid4
def new_event_id() -> str:
return f"event_{uuid4().hex}"
_IDENTITY_FIELDS = frozenset({"session_id", "epoch", "event_id"})
def wire_value(value: object) -> object:
if isinstance(value, Mapping):
return {str(k): wire_value(v) for k, v in value.items()}
if isinstance(value, tuple | list):
return [wire_value(v) for v in value]
return value
@dataclass(frozen=True, slots=True, kw_only=True)
class RealtimeEvent:
"""Base of every public session event."""
#: Realtime ``type`` this class renders to.
wire_type: ClassVar[str] = ""
#: Fields that are omitted from the wire object when ``None``.
optional_wire_fields: ClassVar[frozenset[str]] = frozenset()
session_id: str = ""
epoch: int | None = None
#: Server event id (OpenAI ``event_id``); generated at construction.
event_id: str = field(default_factory=new_event_id)
# ---- identity ----
@property
def type(self) -> str:
return self.wire_type
@property
def is_terminal(self) -> bool:
return False
# ---- generic accessors (None when the class has no such field) ----
@property
def response_id(self) -> str | None:
return None
@property
def item_id(self) -> str | None:
return None
@property
def text(self) -> str | None:
return None
@property
def audio(self) -> bytes | None:
return None
# ---- wire rendering (pure) ----
def _wire_fields(self) -> dict[str, object]:
data: dict[str, object] = {}
for f in fields(self):
if f.name in _IDENTITY_FIELDS:
continue
value = getattr(self, f.name)
if value is None and f.name in self.optional_wire_fields:
continue
data[f.name] = wire_value(value)
return data
def to_realtime(self) -> dict[str, object]:
"""The Realtime wire JSON object for this event (derived from the fields)."""
return {"type": self.wire_type, "event_id": self.event_id, **self._wire_fields()}
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"{type(self).__name__}(type={self.wire_type!r}, session_id={self.session_id!r}, epoch={self.epoch!r})"
# ---- shared bases ----
@dataclass(frozen=True, slots=True, kw_only=True)
class ResponseScopedEvent(RealtimeEvent):
"""Events addressed to one response (and usually one output item / content part)."""
response_id: str | None = None # type: ignore[assignment]
item_id: str | None = None # type: ignore[assignment]
output_index: int = 0
content_index: int = 0
@dataclass(frozen=True, slots=True, kw_only=True)
class _ItemEvent(RealtimeEvent):
previous_item_id: str | None = None
item: Mapping[str, object] = field(default_factory=dict)
@property
def item_id(self) -> str | None:
value = self.item.get("id")
return value if isinstance(value, str) else None
# ---- GA server events ----
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionCreated(RealtimeEvent):
wire_type = "session.created"
session: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class SessionUpdated(RealtimeEvent):
wire_type = "session.updated"
session: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class ResponseCreated(RealtimeEvent):
wire_type = "response.created"
response: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class OutputItemAdded(RealtimeEvent):
wire_type = "response.output_item.added"
response_id: str | None = None # type: ignore[assignment]
output_index: int = 0
item: Mapping[str, object] = field(default_factory=dict)
@property
def item_id(self) -> str | None:
value = self.item.get("id")
return value if isinstance(value, str) else None
@dataclass(frozen=True, slots=True, kw_only=True)
class OutputItemDone(OutputItemAdded):
wire_type = "response.output_item.done"
@dataclass(frozen=True, slots=True, kw_only=True)
class ContentPartAdded(ResponseScopedEvent):
wire_type = "response.content_part.added"
part: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True, kw_only=True)
class ContentPartDone(ContentPartAdded):
wire_type = "response.content_part.done"
@dataclass(frozen=True, slots=True, kw_only=True)
class AudioDelta(ResponseScopedEvent):
wire_type = "response.output_audio.delta"
#: Base64 audio in ``format``.
delta: str = ""
@property
def audio(self) -> bytes | None:
try:
return base64.b64decode(self.delta)
except (ValueError, TypeError):
return None
@dataclass(frozen=True, slots=True, kw_only=True)
class AudioDone(ResponseScopedEvent):
wire_type = "response.output_audio.done"
@dataclass(frozen=True, slots=True, kw_only=True)
class TranscriptDelta(ResponseScopedEvent):
wire_type = "response.output_audio_transcript.delta"
delta: str = ""
@property
def text(self) -> str | None:
return self.delta
@dataclass(frozen=True, slots=True, kw_only=True)
class TranscriptDone(ResponseScopedEvent):
wire_type = "response.output_audio_transcript.done"
transcript: str = ""
@property
def text(self) -> str | None:
return self.transcript
@dataclass(frozen=True, slots=True, kw_only=True)
class TextDelta(ResponseScopedEvent):
wire_type = "response.output_text.delta"
delta: str = ""
@property
def text(self) -> str | None:
return self.delta
@dataclass(frozen=True, slots=True, kw_only=True)
class TextDone(ResponseScopedEvent):
wire_type = "response.output_text.done"
text: str = "" # type: ignore[assignment]
@dataclass(frozen=True, slots=True, kw_only=True)
class FunctionCallArgumentsDelta(RealtimeEvent):
wire_type = "response.function_call_arguments.delta"
response_id: str | None = None # type: ignore[assignment]
item_id: str | None = None # type: ignore[assignment]
output_index: int = 0
call_id: str = ""
delta: str = ""
@dataclass(frozen=True, slots=True, kw_only=True)
class FunctionCallArgumentsDone(RealtimeEvent):
wire_type = "response.function_call_arguments.done"
response_id: str | None = None # type: ignore[assignment]
item_id: str | None = None # type: ignore[assignment]
output_index: int = 0
call_id: str = ""
arguments: str = ""
@dataclass(frozen=True, slots=True, kw_only=True)
class ResponseDone(RealtimeEvent):
wire_type = "response.done"
response: Mapping[str, object] = field(default_factory=dict)
@property
def status(self) -> str | None:
value = self.response.get("status")
return value if isinstance(value, str) else None
@dataclass(frozen=True, slots=True, kw_only=True)
class RateLimitsUpdated(RealtimeEvent):
wire_type = "rate_limits.updated"
rate_limits: tuple[Mapping[str, object], ...] = ()
@dataclass(frozen=True, slots=True, kw_only=True)
class InputCommitted(RealtimeEvent):
wire_type = "input_audio_buffer.committed"
previous_item_id: str | None = None
item_id: str | None = None # type: ignore[assignment]
def _wire_fields(self) -> dict[str, object]:
return {"previous_item_id": self.previous_item_id, "item_id": self.item_id}
@dataclass(frozen=True, slots=True, kw_only=True)
class InputCleared(RealtimeEvent):
wire_type = "input_audio_buffer.cleared"
@dataclass(frozen=True, slots=True, kw_only=True)
class SpeechStarted(RealtimeEvent):
wire_type = "input_audio_buffer.speech_started"
audio_start_ms: int = 0
item_id: str | None = None # type: ignore[assignment]
@dataclass(frozen=True, slots=True, kw_only=True)
class SpeechStopped(RealtimeEvent):
wire_type = "input_audio_buffer.speech_stopped"
audio_end_ms: int = 0
item_id: str | None = None # type: ignore[assignment]
@dataclass(frozen=True, slots=True, kw_only=True)
class ItemAdded(_ItemEvent):
wire_type = "conversation.item.added"
@dataclass(frozen=True, slots=True, kw_only=True)
class ItemCreated(_ItemEvent):
wire_type = "conversation.item.created"
@dataclass(frozen=True, slots=True, kw_only=True)
class ItemDone(_ItemEvent):
wire_type = "conversation.item.done"
@dataclass(frozen=True, slots=True, kw_only=True)
class ItemRetrieved(RealtimeEvent):
wire_type = "conversation.item.retrieved"
item: Mapping[str, object] = field(default_factory=dict)
@property
def item_id(self) -> str | None:
value = self.item.get("id")
return value if isinstance(value, str) else None
@dataclass(frozen=True, slots=True, kw_only=True)
class ItemDeleted(RealtimeEvent):
wire_type = "conversation.item.deleted"
item_id: str | None = None # type: ignore[assignment]
def _wire_fields(self) -> dict[str, object]:
return {"item_id": self.item_id}
@dataclass(frozen=True, slots=True, kw_only=True)
class ItemTruncated(RealtimeEvent):
wire_type = "conversation.item.truncated"
item_id: str | None = None # type: ignore[assignment]
content_index: int = 0
audio_end_ms: int = 0
def _wire_fields(self) -> dict[str, object]:
return {
"item_id": self.item_id,
"content_index": self.content_index,
"audio_end_ms": self.audio_end_ms,
}
@dataclass(frozen=True, slots=True, kw_only=True)
class InputTranscriptionCompleted(RealtimeEvent):
wire_type = "conversation.item.input_audio_transcription.completed"
item_id: str | None = None # type: ignore[assignment]
content_index: int = 0
transcript: str = ""
@property
def text(self) -> str | None:
return self.transcript
@dataclass(frozen=True, slots=True, kw_only=True)
class OutputAudioCleared(RealtimeEvent):
wire_type = "output_audio_buffer.cleared"
response_id: str | None = None # type: ignore[assignment]
@dataclass(frozen=True, slots=True, kw_only=True)
class ErrorEvent(RealtimeEvent):
wire_type = "error"
code: str = "internal_error"
message: str = ""
#: Client ``event_id`` this error answers (OpenAI ``error.event_id``).
related_event_id: str | None = None
param: str | None = None
@property
def error_type(self) -> str:
"""OpenAI's error class for this error.
OpenAI standardises only the three classes, not the codes, so the base
reports the default. A consumer with its own code vocabulary overrides
this --- see ``vllm_omni.protocol.duplex.events.ErrorEvent``.
"""
return "invalid_request_error"
@property
def error(self) -> dict[str, object]:
error: dict[str, object] = {"type": self.error_type, "code": self.code, "message": self.message}
if self.related_event_id:
error["event_id"] = self.related_event_id
if self.param:
error["param"] = self.param
return error
@property
def text(self) -> str | None:
return self.message
def _wire_fields(self) -> dict[str, object]:
return {"error": self.error}
__all__ = [
"AudioDelta",
"AudioDone",
"ContentPartAdded",
"ContentPartDone",
"ErrorEvent",
"FunctionCallArgumentsDelta",
"FunctionCallArgumentsDone",
"InputCleared",
"InputCommitted",
"InputTranscriptionCompleted",
"ItemAdded",
"ItemCreated",
"ItemDeleted",
"ItemDone",
"ItemRetrieved",
"ItemTruncated",
"OutputAudioCleared",
"OutputItemAdded",
"OutputItemDone",
"RateLimitsUpdated",
"RealtimeEvent",
"ResponseCreated",
"ResponseDone",
"ResponseScopedEvent",
"SessionCreated",
"SessionUpdated",
"SpeechStarted",
"SpeechStopped",
"TextDelta",
"TextDone",
"TranscriptDelta",
"TranscriptDone",
"new_event_id",
"wire_value",
]
+177
View File
@@ -0,0 +1,177 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Realtime audio-format negotiation: what a client may declare and how it is read.
A Realtime client declares audio formats in three places --- the session object
(``session.audio.input.format`` and the older flat ``input_audio_format``), a
``response.create`` override, and a ``conversation.item.create`` audio part ---
and may spell one format several ways (``audio/pcm``, ``pcm16``, ``s16le``).
This module is the single place that normalizes those spellings and says which
are supported. Folding a session's declaration into the per-connection append
defaults is the job of ``session.py``, which builds on this module.
Everything here is a pure function of wire payloads: no session state, no model,
no engine.
"""
from __future__ import annotations
from collections.abc import Collection, Mapping
__all__ = [
"REALTIME_INPUT_AUDIO_FORMATS",
"REALTIME_OUTPUT_AUDIO_FORMATS",
"is_supported_realtime_input_format",
"parse_realtime_audio_format",
"realtime_audio_format_object",
"realtime_output_format",
"validate_conversation_item_audio_formats",
"validate_realtime_response_audio_formats",
"validate_realtime_session_audio_formats",
]
REALTIME_INPUT_AUDIO_FORMATS = {
"pcm16",
"pcm_s16le",
"s16le",
"pcm_f32le",
"g711_ulaw",
"g711_alaw",
}
REALTIME_OUTPUT_AUDIO_FORMATS = {
"pcm16",
"pcm_s16le",
"s16le",
"wav",
"pcm",
"g711_ulaw",
"g711_alaw",
}
def parse_realtime_audio_format(raw_format: object) -> tuple[object, int | None]:
def normalize_format(fmt: str) -> str:
normalized = fmt.lower()
if normalized in {"audio/pcm", "pcm"}:
return "pcm16"
if normalized in {"audio/wav", "wav"}:
return "wav"
if normalized in {"audio/pcm16", "pcm16", "pcm_s16le", "s16le"}:
return "pcm16"
if normalized in {"audio/pcm_f32le", "pcm_f32le", "f32le"}:
return "pcm_f32le"
if normalized in {"audio/g711_ulaw", "g711_ulaw", "g711-ulaw", "ulaw", "mulaw"}:
return "g711_ulaw"
if normalized in {"audio/g711_alaw", "g711_alaw", "g711-alaw", "alaw"}:
return "g711_alaw"
return fmt
if isinstance(raw_format, str):
return normalize_format(raw_format), None
if not isinstance(raw_format, dict):
return raw_format, None
rate = raw_format.get("rate") or raw_format.get("sample_rate_hz") or raw_format.get("sample_rate")
sample_rate_hz = int(rate) if isinstance(rate, int | float) and rate > 0 else None
fmt = raw_format.get("type") or raw_format.get("format")
if not isinstance(fmt, str):
return raw_format, sample_rate_hz
return normalize_format(fmt), sample_rate_hz
def realtime_output_format(duplex_format: object) -> str:
if isinstance(duplex_format, str) and duplex_format.lower() in {"g711_ulaw", "g711_alaw"}:
return duplex_format.lower()
if isinstance(duplex_format, str) and duplex_format.lower() == "pcm":
return "pcm16"
return str(duplex_format or "wav")
def is_supported_realtime_input_format(fmt: object) -> bool:
return isinstance(fmt, str) and fmt.lower() in REALTIME_INPUT_AUDIO_FORMATS
def realtime_audio_format_object(fmt: object, *, sample_rate_hz: int | None = None) -> dict[str, object]:
if isinstance(fmt, str) and fmt.lower() in {"pcm16", "pcm_s16le", "s16le", "pcm"}:
payload: dict[str, object] = {"type": "audio/pcm"}
elif isinstance(fmt, str) and fmt.lower() == "pcm_f32le":
payload = {"type": "audio/pcm_f32le"}
elif isinstance(fmt, str) and fmt.lower() == "g711_ulaw":
payload = {"type": "audio/g711_ulaw"}
elif isinstance(fmt, str) and fmt.lower() == "g711_alaw":
payload = {"type": "audio/g711_alaw"}
else:
payload = {"type": "audio/wav"}
if sample_rate_hz is not None:
payload["rate"] = int(sample_rate_hz)
return payload
def validate_realtime_session_audio_formats(
session_payload: Mapping[str, object],
*,
input_audio_formats: Collection[str] | None = None,
output_audio_formats: Collection[str] | None = None,
) -> str | None:
"""Reject a session object that declares an audio format we cannot serve.
The format sets default to everything the codec can decode; a consumer that
serves a narrower set passes its own (see
``vllm_omni.protocol.realtime.capabilities``).
"""
supported_input = REALTIME_INPUT_AUDIO_FORMATS if input_audio_formats is None else input_audio_formats
supported_output = REALTIME_OUTPUT_AUDIO_FORMATS if output_audio_formats is None else output_audio_formats
audio_config = session_payload.get("audio")
input_format: object = session_payload.get("input_audio_format")
if input_format is None and isinstance(audio_config, dict):
audio_input = audio_config.get("input")
if isinstance(audio_input, dict):
input_format = audio_input.get("format")
parsed_input, _ = parse_realtime_audio_format(input_format)
if input_format is not None and not (isinstance(parsed_input, str) and parsed_input.lower() in supported_input):
return f"Unsupported input_audio_format: {input_format}"
output_format: object = session_payload.get("output_audio_format") or session_payload.get("response_format")
if output_format is None and isinstance(audio_config, dict):
audio_output = audio_config.get("output")
if isinstance(audio_output, dict):
output_format = audio_output.get("format")
parsed_output, _ = parse_realtime_audio_format(output_format)
if output_format is not None and not (isinstance(parsed_output, str) and parsed_output.lower() in supported_output):
return f"Unsupported output_audio_format: {output_format}"
return None
def validate_realtime_response_audio_formats(response_payload: Mapping[str, object]) -> str | None:
output_format: object = response_payload.get("output_audio_format") or response_payload.get("response_format")
audio_config = response_payload.get("audio")
if output_format is None and isinstance(audio_config, dict):
audio_output = audio_config.get("output")
if isinstance(audio_output, dict):
output_format = audio_output.get("format")
parsed_output, _ = parse_realtime_audio_format(output_format)
if output_format is not None and not (
isinstance(parsed_output, str) and parsed_output.lower() in REALTIME_OUTPUT_AUDIO_FORMATS
):
return f"Unsupported output_audio_format: {output_format}"
return None
def validate_conversation_item_audio_formats(item: object) -> str | None:
if not isinstance(item, dict):
return None
content = item.get("content")
if not isinstance(content, list):
return None
for part in content:
if not isinstance(part, dict):
continue
if part.get("type") not in {"input_audio", "audio"}:
continue
raw_format = part.get("format")
parsed_format, _ = parse_realtime_audio_format(raw_format)
if raw_format is not None and not (
isinstance(parsed_format, str) and parsed_format.lower() in REALTIME_INPUT_AUDIO_FORMATS
):
return f"Unsupported input_audio format in conversation.item.create: {raw_format}"
return None
+197
View File
@@ -0,0 +1,197 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Realtime conversation items: shape, truncation and the transcript inside one.
A Realtime conversation is a list of *items* --- a user message, an assistant
message, a function call --- each with a ``content`` list of typed parts. The
client creates them (``conversation.item.create``), truncates an assistant
item's audio after a barge-in (``conversation.item.truncate``), and the server
echoes them back inside ``conversation.item.*`` and ``response.done`` events.
The rules in this module are the ones both sides have to agree on: which
defaults a bare item gets, how far a transcript is cut when the client says it
only heard N milliseconds of audio, and what makes a truncate request invalid.
Item *storage* --- which items a session currently holds, in what order --- is
the consumer's state, not this module's.
``validate_realtime_video_frames`` sits here too: camera frames ride along an
``input_audio_buffer.append`` in the vLLM-Omni extension of the protocol, and
validating them is the same kind of wire-shape check.
"""
from __future__ import annotations
import base64
import binascii
from collections.abc import Mapping
from uuid import uuid4
__all__ = [
"input_transcript_from_item",
"normalize_conversation_item",
"text_chars_for_audio_ms_from_marks",
"truncate_realtime_item_content",
"validate_realtime_item_truncate",
"validate_realtime_video_frames",
]
def validate_realtime_video_frames(video_frames: object, max_slice_nums: object) -> str | None:
"""Validate omni-duplex camera frames on input_audio_buffer.append.
Wire contract matches the official MiniCPM-o duplex loop: one base
base64 JPEG per ~1 s audio chunk, optionally followed by that unit's
stacked composite tiling the sub-frames captured inside it (at most 2
images either way). A caller-supplied ``max_slice_nums`` is rejected rather
than silently ignored: slicing is Stage 0's decision here, and Stage 0
already applies the official HD suggestion for a stacked unit
(``max_slice_nums=[2, 1]``). The wire simply does not let the client choose
it.
"""
if max_slice_nums not in (None, 1):
return "max_slice_nums is not selectable on the wire; Stage0 slices stacked units itself"
if not isinstance(video_frames, list):
return "video_frames must be a list of base64-encoded images"
frames = [frame for frame in video_frames if frame is not None]
if len(frames) > 2:
return "video_frames carries more than 2 frames for one append; send ~1 frame per 1 s chunk"
for frame in frames:
if not isinstance(frame, str) or not frame:
return "video_frames entries must be non-empty base64 strings"
if len(frame) > 4_000_000:
return "video_frames entry exceeds 4MB base64; reduce capture resolution or JPEG quality"
try:
header = base64.b64decode(frame[:64] + "=" * (-len(frame[:64]) % 4))
except (binascii.Error, ValueError):
return "video_frames entries must be valid base64"
if not (header.startswith(b"\xff\xd8") or header.startswith(b"\x89PNG")):
return "video_frames entries must be JPEG or PNG images"
return None
def normalize_conversation_item(item: Mapping[str, object]) -> dict[str, object]:
normalized = dict(item)
normalized.setdefault("id", f"item_{uuid4().hex}")
normalized.setdefault("object", "realtime.item")
normalized.setdefault("type", "message")
normalized.setdefault("status", "completed")
if "role" not in normalized and normalized.get("type") == "message":
normalized["role"] = "user"
if not isinstance(normalized.get("content"), list):
normalized["content"] = []
return normalized
def text_chars_for_audio_ms_from_marks(
audio_end_ms: int,
text_len: int,
marks: list[object],
*,
final_ms: object | None = None,
) -> int:
if text_len <= 0:
return 0
clean_marks: list[tuple[int, int]] = []
for mark in marks:
if not isinstance(mark, dict):
continue
raw_text_chars = mark.get("text_chars")
raw_audio_end_ms = mark.get("audio_end_ms", mark.get("audio_ms"))
if not isinstance(raw_text_chars, int | float) or not isinstance(raw_audio_end_ms, int | float):
continue
clean_marks.append((max(0, int(raw_audio_end_ms)), min(text_len, max(0, int(raw_text_chars)))))
if not clean_marks:
return 0 if audio_end_ms <= 0 else text_len
clean_marks.sort(key=lambda item: item[0])
audio_end_ms = max(0, int(audio_end_ms))
if audio_end_ms <= 0:
return 0
previous_ms = 0
previous_chars = 0
for mark_ms, mark_chars in clean_marks:
mark_ms = max(previous_ms, mark_ms)
mark_chars = max(previous_chars, min(text_len, mark_chars))
if audio_end_ms <= mark_ms:
if mark_ms <= previous_ms:
return mark_chars
ratio = (audio_end_ms - previous_ms) / max(1, mark_ms - previous_ms)
return int(previous_chars + (mark_chars - previous_chars) * max(0.0, min(1.0, ratio)))
previous_ms = mark_ms
previous_chars = mark_chars
if isinstance(final_ms, int | float) and int(final_ms) > previous_ms:
if audio_end_ms >= int(final_ms):
return text_len
ratio = (audio_end_ms - previous_ms) / max(1, int(final_ms) - previous_ms)
return int(previous_chars + (text_len - previous_chars) * max(0.0, min(1.0, ratio)))
return text_len if audio_end_ms >= previous_ms else previous_chars
def truncate_realtime_item_content(item: dict[str, object], *, content_index: int, audio_end_ms: int) -> None:
content = item.get("content")
if not isinstance(content, list) or not content:
return
index = max(0, int(content_index))
if index >= len(content):
return
part = content[index]
if not isinstance(part, dict):
return
transcript = part.get("transcript")
if not isinstance(transcript, str) or not transcript:
return
marks = part.get("audio_text_marks")
if isinstance(marks, list):
keep_chars = text_chars_for_audio_ms_from_marks(
audio_end_ms,
len(transcript),
marks,
final_ms=part.get("audio_duration_ms") or part.get("duration_ms") or part.get("audio_ms"),
)
part["transcript"] = transcript[:keep_chars].rstrip()
return
duration_ms = part.get("audio_duration_ms") or part.get("duration_ms") or part.get("audio_ms")
if isinstance(duration_ms, int | float) and duration_ms > 0:
keep_chars = int(len(transcript) * max(0.0, min(1.0, int(audio_end_ms) / float(duration_ms))))
part["transcript"] = transcript[:keep_chars].rstrip()
elif audio_end_ms <= 0:
part["transcript"] = ""
def validate_realtime_item_truncate(item: Mapping[str, object], *, content_index: int, audio_end_ms: int) -> str | None:
if item.get("type") != "message" or item.get("role") != "assistant":
return "conversation.item.truncate only supports assistant message items"
if audio_end_ms < 0:
return "conversation.item.truncate requires non-negative audio_end_ms"
content = item.get("content")
if not isinstance(content, list) or not content:
return None
index = max(0, int(content_index))
if index >= len(content):
return f"conversation.item.truncate content_index out of range: {content_index}"
part = content[index]
if not isinstance(part, dict):
return "conversation.item.truncate target content part is invalid"
if part.get("type") not in {"audio", "output_audio"}:
return "conversation.item.truncate target content part must be audio"
duration_ms = part.get("audio_duration_ms") or part.get("duration_ms") or part.get("audio_ms")
if isinstance(duration_ms, int | float) and int(duration_ms) >= 0 and audio_end_ms > int(duration_ms):
return "conversation.item.truncate audio_end_ms exceeds item audio duration"
return None
def input_transcript_from_item(item: Mapping[str, object]) -> str:
content = item.get("content")
if not isinstance(content, list):
return ""
parts: list[str] = []
for part in content:
if not isinstance(part, dict):
continue
for key in ("transcript", "text"):
value = part.get(key)
if isinstance(value, str) and value:
if part.get("type") in {"input_audio", "audio", "audio_transcript", "transcript"}:
parts.append(value)
break
return "".join(parts).strip()
+163
View File
@@ -0,0 +1,163 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
"""Reading the Realtime ``session`` object.
``session.update`` carries one JSON object that a client may spell in either of
two shapes: the GA nest (``session.audio.input.format``,
``session.audio.output.sample_rate_hz``) or the older flat beta keys
(``input_audio_format``, ``sample_rate_hz``). Everything downstream --- the
append decoder, the projection that renders ``session.created`` back out, a
model's runtime configuration --- has to agree on what a given session object
means, so the readers live here once.
:class:`RealtimeInputDefaults` is the resolved answer for the input path: the
format, sample rate and silence threshold an ``input_audio_buffer.append``
inherits when it omits them. A connection keeps one of these and re-derives it
on every ``session.update``.
``overlap_policy`` / ``playback_commit_policy`` are vLLM-Omni extension fields
on the session object rather than GA OpenAI fields. They are read here because
this module owns what the wire may say; what a server *does* with them is the
consumer's business.
"""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, replace
from typing import Any
from vllm_omni.protocol.realtime.formats import (
REALTIME_INPUT_AUDIO_FORMATS,
REALTIME_OUTPUT_AUDIO_FORMATS,
parse_realtime_audio_format,
realtime_output_format,
)
__all__ = [
"RealtimeInputDefaults",
"apply_realtime_session_defaults",
"input_audio_transcription_config",
"json_safe_realtime_payload",
"realtime_max_output_tokens",
"realtime_overlap_fields",
]
@dataclass(frozen=True, slots=True)
class RealtimeInputDefaults:
"""Session-level wire defaults an append may omit (derived from the session object)."""
input_audio_format: str = "pcm16"
input_sample_rate_hz: int = 16000
output_audio_format: str = "pcm16"
output_sample_rate_hz: int | None = None
overlap_silence_rms: float = 0.003
def with_session_payload(self, session_payload: Mapping[str, object]) -> RealtimeInputDefaults:
"""Return defaults updated from a Realtime ``session`` object (session.update)."""
values = apply_realtime_session_defaults(self, session_payload)
return values
def apply_realtime_session_defaults(
defaults: RealtimeInputDefaults,
session_payload: Mapping[str, object],
) -> RealtimeInputDefaults:
"""Derive the wire defaults a Realtime ``session`` object declares."""
input_format: object = session_payload.get("input_audio_format")
audio_config = session_payload.get("audio")
if input_format is None and isinstance(audio_config, dict):
audio_input = audio_config.get("input")
if isinstance(audio_input, dict):
input_format = audio_input.get("format")
input_format, input_rate = parse_realtime_audio_format(input_format)
# Any: heterogeneous field values splatted into ``dataclasses.replace`` below.
updates: dict[str, Any] = {}
if isinstance(input_format, str) and input_format.lower() in REALTIME_INPUT_AUDIO_FORMATS:
updates["input_audio_format"] = input_format
output_format: object = session_payload.get("output_audio_format") or session_payload.get("response_format")
output_rate_raw: object | None = None
if output_format is None and isinstance(audio_config, dict):
audio_output = audio_config.get("output")
if isinstance(audio_output, dict):
output_format = audio_output.get("format")
output_rate_raw = audio_output.get("sample_rate_hz") or audio_output.get("sample_rate")
output_format, output_rate = parse_realtime_audio_format(output_format)
if output_rate is None and isinstance(output_rate_raw, int | float) and output_rate_raw > 0:
output_rate = int(output_rate_raw)
if isinstance(output_format, str) and output_format.lower() in REALTIME_OUTPUT_AUDIO_FORMATS:
updates["output_audio_format"] = realtime_output_format(output_format)
sample_rate = session_payload.get("sample_rate_hz") or session_payload.get("sample_rate")
if sample_rate is None and isinstance(audio_config, dict):
audio_input = audio_config.get("input")
if isinstance(audio_input, dict):
sample_rate = audio_input.get("sample_rate_hz") or audio_input.get("sample_rate")
if sample_rate is None:
sample_rate = input_rate
if isinstance(sample_rate, int | float) and sample_rate > 0:
updates["input_sample_rate_hz"] = int(sample_rate)
if isinstance(output_rate, int | float) and output_rate > 0:
updates["output_sample_rate_hz"] = int(output_rate)
overlap_fields = realtime_overlap_fields(session_payload)
overlap_silence_rms = overlap_fields.get("overlap_silence_rms")
if isinstance(overlap_silence_rms, int | float):
updates["overlap_silence_rms"] = max(0.0, float(overlap_silence_rms))
return replace(defaults, **updates) if updates else defaults
def realtime_overlap_fields(session_payload: Mapping[str, object]) -> dict[str, object]:
fields: dict[str, object] = {}
if isinstance(session_payload.get("overlap_policy"), str):
fields["overlap_policy"] = session_payload["overlap_policy"]
for key in ("overlap_short_ack_ms", "overlap_barge_in_ms", "overlap_silence_rms"):
value = session_payload.get(key)
if isinstance(value, int | float):
fields[key] = value
if isinstance(session_payload.get("playback_commit_policy"), str):
fields["playback_commit_policy"] = session_payload["playback_commit_policy"]
return fields
def json_safe_realtime_payload(payload: Mapping[str, object]) -> dict[str, object]:
clean: dict[str, object] = {}
for key, value in payload.items():
if key == "extra_body":
continue
if isinstance(value, str | int | float | bool) or value is None:
clean[key] = value
elif isinstance(value, dict):
clean[key] = json_safe_realtime_payload(value)
elif isinstance(value, list):
clean[key] = [
(json_safe_realtime_payload(item) if isinstance(item, dict) else item)
for item in value
if isinstance(item, str | int | float | bool | dict) or item is None
]
return clean
def input_audio_transcription_config(session_payload: Mapping[str, object]) -> dict[str, object] | None:
transcription = session_payload.get("input_audio_transcription")
if isinstance(transcription, dict):
return transcription
audio_config = session_payload.get("audio")
if not isinstance(audio_config, dict):
return None
audio_input = audio_config.get("input")
if not isinstance(audio_input, dict):
return None
transcription = audio_input.get("transcription")
return transcription if isinstance(transcription, dict) else None
def realtime_max_output_tokens(value: object) -> int | None:
"""Normalize Realtime max output tokens (``"inf"`` -> ``None``)."""
if value is None:
return None
if isinstance(value, str) and value.strip().lower() in {"inf", "infinity", "unlimited"}:
return None
if isinstance(value, int) and value > 0:
return int(value)
return None