Signed-off-by: psv666 <2693925048@qq.com>
This commit is contained in:
1 parent
825ab20b07
commit
e3be42e052
37 files changed
+3792
-1629
No files matched your search
@@ -1,218 +1,44 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Compatibility re-export: the Realtime audio codec moved out of the duplex engine.
|
||||
|
||||
Decoding PCM / G.711 / WAV, resampling and re-encoding are wire-format work with
|
||||
no duplex session in them, so the implementation now lives in
|
||||
``vllm_omni.protocol.realtime.audio`` where a non-duplex Realtime surface can
|
||||
use it too. Duplex code reaches it through ``vllm_omni.protocol.duplex``, the
|
||||
one door the engine uses --- which is also where a duplex-specific conversion
|
||||
would be introduced. Import from there in new code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import io
|
||||
import math
|
||||
import wave
|
||||
from vllm_omni.protocol.duplex import (
|
||||
MAX_INPUT_SAMPLE_RATE_HZ,
|
||||
MIN_INPUT_SAMPLE_RATE_HZ,
|
||||
convert_input_audio_with_rate,
|
||||
convert_output_audio,
|
||||
decode_g711_alaw,
|
||||
decode_g711_ulaw,
|
||||
encode_float32_mono_wav_base64,
|
||||
encode_g711_alaw,
|
||||
encode_g711_ulaw,
|
||||
resample_pcm16_mono,
|
||||
validate_input_sample_rate_hz,
|
||||
wav_payload_to_pcm16,
|
||||
)
|
||||
|
||||
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
|
||||
__all__ = [
|
||||
"MAX_INPUT_SAMPLE_RATE_HZ",
|
||||
"MIN_INPUT_SAMPLE_RATE_HZ",
|
||||
"convert_input_audio_with_rate",
|
||||
"convert_output_audio",
|
||||
"decode_g711_alaw",
|
||||
"decode_g711_ulaw",
|
||||
"encode_float32_mono_wav_base64",
|
||||
"encode_g711_alaw",
|
||||
"encode_g711_ulaw",
|
||||
"resample_pcm16_mono",
|
||||
"validate_input_sample_rate_hz",
|
||||
"wav_payload_to_pcm16",
|
||||
]
|
||||
+110
-128
@@ -1,41 +1,62 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Typed commands accepted by a duplex session.
|
||||
"""The command vocabulary one duplex session accepts, plus its mailbox rendering.
|
||||
|
||||
``DuplexSessionHandle.submit()`` takes one of these; the websocket handler and
|
||||
``InlineDuplexClient`` build them with :func:`command_from_realtime` from the
|
||||
OpenAI Realtime client event vocabulary. Every command can also render the
|
||||
session-internal mailbox payload (``payload()``), which is the dictionary
|
||||
vocabulary the session runner bodies were written against.
|
||||
``InlineDuplexClient`` build them with :func:`command_from_realtime`.
|
||||
|
||||
The *wire* half of each command --- which client event it decodes from and what
|
||||
fields survive decoding --- now lives in ``vllm_omni.protocol.duplex.commands``,
|
||||
which carries the whole vocabulary: the eight Tier 1 classes re-exported from
|
||||
``vllm_omni.protocol.realtime.commands``, the two Tier 2 ones and the seven
|
||||
Tier 3 ones. The engine imports that module and never the Tier 1 one directly,
|
||||
so a command that later grows a duplex extension changes one file
|
||||
(RFC #6592 P0a).
|
||||
|
||||
The *engine* half stays here, because it is not wire contract at all:
|
||||
``payload()`` renders the session-internal mailbox dictionary the runner bodies
|
||||
were written against, and its ``type`` is the mailbox channel rather than the
|
||||
client event. Those two genuinely differ --- ``session.update`` and
|
||||
``conversation.item.create`` / ``.delete`` / ``.truncate`` all travel on the
|
||||
``turn.signal`` channel --- which is why the halves are separated instead of
|
||||
the classes being relocated wholesale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field, fields
|
||||
from dataclasses import dataclass, fields
|
||||
from typing import TYPE_CHECKING, ClassVar
|
||||
|
||||
from vllm_omni.protocol.duplex import RealtimeProtocolError
|
||||
from vllm_omni.protocol.duplex import commands as _duplex_wire
|
||||
from vllm_omni.protocol.duplex.commands import RealtimeCommand
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm_omni.engine.duplex.realtime_commands import RealtimeInputDefaults
|
||||
from vllm_omni.protocol.duplex import RealtimeInputDefaults
|
||||
|
||||
|
||||
class DuplexCommandError(ValueError):
|
||||
"""A client payload could not be turned into a command."""
|
||||
class DuplexCommandError(RealtimeProtocolError):
|
||||
"""A client payload could not be turned into a duplex command.
|
||||
|
||||
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
|
||||
The duplex name for a Realtime protocol error: same ``code`` /
|
||||
``event_id`` contract, so the error envelope is rendered identically
|
||||
whichever consumer raised it.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class DuplexCommand:
|
||||
class DuplexCommand(RealtimeCommand):
|
||||
"""A Realtime command as the duplex engine handles it.
|
||||
|
||||
Adds the mailbox channel (``type``) and its rendering on top of the wire
|
||||
command; every concrete class below pairs this with its protocol twin.
|
||||
"""
|
||||
|
||||
#: Mailbox event type this command renders to (see ``payload()``).
|
||||
type: ClassVar[str] = ""
|
||||
#: Client correlation id (OpenAI ``event_id``), echoed on error events.
|
||||
event_id: str | None = None
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
"""Render the session-internal mailbox dictionary."""
|
||||
@@ -56,19 +77,23 @@ class DuplexCommand:
|
||||
return data
|
||||
|
||||
|
||||
# ---- GA commands ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AppendAudio(DuplexCommand):
|
||||
class UpdateSession(DuplexCommand, _duplex_wire.UpdateSession):
|
||||
type: ClassVar[str] = "turn.signal"
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
data["event"] = "session.update"
|
||||
data["payload"] = dict(data.pop("patch", {}) or {})
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AppendAudio(DuplexCommand, _duplex_wire.AppendAudio):
|
||||
type: ClassVar[str] = "input_audio_buffer.append"
|
||||
#: Raw audio bytes in ``format`` at ``sample_rate_hz`` (base64 only on the wire).
|
||||
audio: bytes
|
||||
format: str = "pcm16"
|
||||
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: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
@@ -90,21 +115,8 @@ class AppendAudio(DuplexCommand):
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AppendText(DuplexCommand):
|
||||
type: ClassVar[str] = "input.text.append"
|
||||
text: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Commit(DuplexCommand):
|
||||
class Commit(DuplexCommand, _duplex_wire.Commit):
|
||||
type: ClassVar[str] = "input_audio_buffer.commit"
|
||||
final: bool = True
|
||||
#: ``None`` means "no explicit request": the runner decides on commit (auto-response
|
||||
#: sessions answer on their own); ``True`` / ``False`` force it.
|
||||
create_response: bool | None = None
|
||||
is_speech: bool | None = None
|
||||
#: Realtime conversation item created for this commit (wire correlation only).
|
||||
realtime_item_id: str | None = None
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
@@ -114,10 +126,18 @@ class Commit(DuplexCommand):
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CreateResponse(DuplexCommand):
|
||||
class ClearInput(DuplexCommand, _duplex_wire.ClearInput):
|
||||
type: ClassVar[str] = "input_audio_buffer.clear"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ClearOutputAudio(DuplexCommand, _duplex_wire.ClearOutputAudio):
|
||||
type: ClassVar[str] = "output_audio_buffer.clear"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CreateResponse(DuplexCommand, _duplex_wire.CreateResponse):
|
||||
type: ClassVar[str] = "response.create"
|
||||
#: Raw Realtime ``response`` object (instructions, voice, modalities, ...).
|
||||
options: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
@@ -128,84 +148,13 @@ class CreateResponse(DuplexCommand):
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ClearInput(DuplexCommand):
|
||||
type: ClassVar[str] = "input_audio_buffer.clear"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CancelInput(DuplexCommand):
|
||||
type: ClassVar[str] = "input.cancel"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CancelResponse(DuplexCommand):
|
||||
class CancelResponse(DuplexCommand, _duplex_wire.CancelResponse):
|
||||
type: ClassVar[str] = "response.cancel"
|
||||
response_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class BargeIn(DuplexCommand):
|
||||
type: ClassVar[str] = "barge_in"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ClearOutputAudio(DuplexCommand):
|
||||
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 SignalTurn(DuplexCommand):
|
||||
"""Generic ``turn.signal`` (local turn transitions such as ``user_started``)."""
|
||||
|
||||
class CreateItem(DuplexCommand, _duplex_wire.CreateItem):
|
||||
type: ClassVar[str] = "turn.signal"
|
||||
event: str
|
||||
signal_payload: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
signal_payload = data.pop("signal_payload", None)
|
||||
if isinstance(signal_payload, Mapping) and signal_payload:
|
||||
data["payload"] = dict(signal_payload)
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class UpdateSession(DuplexCommand):
|
||||
"""``session.update``: a Realtime ``session`` object patch."""
|
||||
|
||||
type: ClassVar[str] = "turn.signal"
|
||||
patch: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
data["event"] = "session.update"
|
||||
data["payload"] = dict(data.pop("patch", {}) or {})
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AckPlayback(DuplexCommand):
|
||||
type: ClassVar[str] = "playback.ack"
|
||||
played_ms: int
|
||||
committed_ms: int | None = None
|
||||
response_id: str | None = None
|
||||
item_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Heartbeat(DuplexCommand):
|
||||
type: ClassVar[str] = "session.heartbeat"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CreateItem(DuplexCommand):
|
||||
"""``conversation.item.create`` (history injection or function-call output)."""
|
||||
|
||||
type: ClassVar[str] = "turn.signal"
|
||||
item: Mapping[str, object]
|
||||
previous_item_id: str | None = None
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
@@ -219,9 +168,8 @@ class CreateItem(DuplexCommand):
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class DeleteItem(DuplexCommand):
|
||||
class DeleteItem(DuplexCommand, _duplex_wire.DeleteItem):
|
||||
type: ClassVar[str] = "turn.signal"
|
||||
item_id: str
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
@@ -231,11 +179,8 @@ class DeleteItem(DuplexCommand):
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TruncateItem(DuplexCommand):
|
||||
class TruncateItem(DuplexCommand, _duplex_wire.TruncateItem):
|
||||
type: ClassVar[str] = "turn.signal"
|
||||
item_id: str
|
||||
audio_end_ms: int
|
||||
content_index: int = 0
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
@@ -248,15 +193,51 @@ class TruncateItem(DuplexCommand):
|
||||
return data
|
||||
|
||||
|
||||
# ---- duplex extension commands ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CloseSession(DuplexCommand):
|
||||
"""Graceful close requested through the command stream (``session.close``)."""
|
||||
class AppendText(DuplexCommand, _duplex_wire.AppendText):
|
||||
type: ClassVar[str] = "input.text.append"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CancelInput(DuplexCommand, _duplex_wire.CancelInput):
|
||||
type: ClassVar[str] = "input.cancel"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class BargeIn(DuplexCommand, _duplex_wire.BargeIn):
|
||||
type: ClassVar[str] = "barge_in"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SignalTurn(DuplexCommand, _duplex_wire.SignalTurn):
|
||||
type: ClassVar[str] = "turn.signal"
|
||||
|
||||
def payload(self) -> dict[str, object]:
|
||||
data = DuplexCommand.payload(self)
|
||||
signal_payload = data.pop("signal_payload", None)
|
||||
if isinstance(signal_payload, Mapping) and signal_payload:
|
||||
data["payload"] = dict(signal_payload)
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class AckPlayback(DuplexCommand, _duplex_wire.AckPlayback):
|
||||
type: ClassVar[str] = "playback.ack"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Heartbeat(DuplexCommand, _duplex_wire.Heartbeat):
|
||||
type: ClassVar[str] = "session.heartbeat"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class CloseSession(DuplexCommand, _duplex_wire.CloseSession):
|
||||
type: ClassVar[str] = "session.close"
|
||||
reason: str = "client_close"
|
||||
|
||||
|
||||
#: Realtime client event types that map onto commands (wire vocabulary).
|
||||
REALTIME_COMMAND_TYPES: frozenset[str] = frozenset(
|
||||
{
|
||||
"input_audio_buffer.append",
|
||||
@@ -300,7 +281,6 @@ def command_from_realtime(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"REALTIME_COMMAND_TYPES",
|
||||
"AckPlayback",
|
||||
"AppendAudio",
|
||||
"AppendText",
|
||||
@@ -317,6 +297,8 @@ __all__ = [
|
||||
"DuplexCommand",
|
||||
"DuplexCommandError",
|
||||
"Heartbeat",
|
||||
"REALTIME_COMMAND_TYPES",
|
||||
"RealtimeCommand",
|
||||
"SignalTurn",
|
||||
"TruncateItem",
|
||||
"UpdateSession",
|
||||
|
||||
@@ -339,26 +339,26 @@ class DuplexSessionConfig:
|
||||
``_session_create_from_realtime``.
|
||||
"""
|
||||
from vllm_omni.engine.duplex.realtime_commands import (
|
||||
RealtimeInputDefaults,
|
||||
DUPLEX_REALTIME_CAPABILITIES,
|
||||
duplex_response_format,
|
||||
)
|
||||
from vllm_omni.engine.duplex.turn_detection import normalize_turn_detection_session_payload
|
||||
from vllm_omni.protocol.duplex import (
|
||||
RealtimeInputDefaults,
|
||||
input_audio_transcription_config,
|
||||
json_safe_realtime_payload,
|
||||
realtime_max_output_tokens,
|
||||
realtime_overlap_fields,
|
||||
validate_realtime_session_audio_formats,
|
||||
)
|
||||
from vllm_omni.engine.duplex.turn_detection import (
|
||||
normalize_turn_detection_session_payload,
|
||||
validate_realtime_turn_detection,
|
||||
validate_session_payload,
|
||||
)
|
||||
|
||||
payload: dict[str, object] = dict(session_payload)
|
||||
format_error = validate_realtime_session_audio_formats(payload)
|
||||
if format_error is not None:
|
||||
raise DuplexConfigError(format_error, code="unsupported_audio_format")
|
||||
turn_detection_error = validate_realtime_turn_detection(payload)
|
||||
if turn_detection_error is not None:
|
||||
raise DuplexConfigError(turn_detection_error, code="unsupported_turn_detection", param="turn_detection")
|
||||
# One session check for every consumer (ENTRY-INV-002): the same
|
||||
# capability object ``translate_realtime_command`` uses, so a session
|
||||
# object is accepted or refused identically whichever door it came in.
|
||||
rejection = validate_session_payload(payload, capabilities=DUPLEX_REALTIME_CAPABILITIES)
|
||||
if rejection is not None:
|
||||
raise DuplexConfigError(rejection.message, code=rejection.code, param=rejection.param)
|
||||
normalize_turn_detection_session_payload(payload)
|
||||
defaults = RealtimeInputDefaults().with_session_payload(payload)
|
||||
payload.update(realtime_overlap_fields(payload))
|
||||
@@ -446,9 +446,9 @@ class DuplexSessionConfig:
|
||||
when the patch changes something a live session cannot change.
|
||||
``audio_started`` is ``playback.generated_ms > 0 or playback.sent_ms > 0``.
|
||||
"""
|
||||
from vllm_omni.engine.duplex.realtime_commands import (
|
||||
from vllm_omni.engine.duplex.realtime_commands import duplex_response_format
|
||||
from vllm_omni.protocol.duplex import (
|
||||
REALTIME_OUTPUT_AUDIO_FORMATS,
|
||||
duplex_response_format,
|
||||
input_audio_transcription_config,
|
||||
json_safe_realtime_payload,
|
||||
parse_realtime_audio_format,
|
||||
@@ -629,9 +629,9 @@ class ResponseCreateOptions:
|
||||
for options a model-native duplex session cannot apply per response.
|
||||
Private runtime keys in ``extra_body`` are dropped.
|
||||
"""
|
||||
from vllm_omni.engine.duplex.realtime_commands import (
|
||||
from vllm_omni.engine.duplex.realtime_commands import duplex_response_format
|
||||
from vllm_omni.protocol.duplex import (
|
||||
REALTIME_OUTPUT_AUDIO_FORMATS,
|
||||
duplex_response_format,
|
||||
parse_realtime_audio_format,
|
||||
realtime_max_output_tokens,
|
||||
)
|
||||
@@ -782,14 +782,14 @@ def realtime_item_to_history_message(item: object) -> dict[str, object] | None:
|
||||
|
||||
def realtime_max_output_tokens(value: object) -> int | None:
|
||||
"""Normalize Realtime max output tokens (``"inf"`` -> ``None``)."""
|
||||
from vllm_omni.engine.duplex.realtime_commands import realtime_max_output_tokens as _impl
|
||||
from vllm_omni.protocol.duplex import realtime_max_output_tokens as _impl
|
||||
|
||||
return _impl(value)
|
||||
|
||||
|
||||
def input_audio_transcription_config(session_payload: Mapping[str, object]) -> dict[str, object] | None:
|
||||
"""Return the ``input_audio_transcription`` object of a Realtime session payload."""
|
||||
from vllm_omni.engine.duplex.realtime_commands import input_audio_transcription_config as _impl
|
||||
from vllm_omni.protocol.duplex import input_audio_transcription_config as _impl
|
||||
|
||||
return _impl(session_payload)
|
||||
|
||||
|
||||
@@ -1,677 +1,79 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""Typed events emitted by a duplex session.
|
||||
"""The event vocabulary one duplex session speaks, assembled from two layers.
|
||||
|
||||
Every public event is a frozen dataclass with explicit fields. The Realtime
|
||||
wire JSON is *derived* from those fields by :meth:`DuplexEvent.to_realtime`,
|
||||
which is pure: it never consults session state. The stateful part (response /
|
||||
item ids, content-part bookkeeping) lives in
|
||||
``vllm_omni.engine.duplex.realtime_events`` on the session runner and is
|
||||
consumed when the events are *constructed*.
|
||||
The classes themselves no longer live here. ``vllm_omni.protocol.duplex.events``
|
||||
carries the whole vocabulary --- 22 Tier 1 classes re-exported from
|
||||
``vllm_omni.protocol.realtime.events``, 8 Tier 2 ones and 12 Tier 3 ones --- so
|
||||
a non-duplex Realtime consumer can take the Tier 1 half and its wire rendering
|
||||
without the duplex session control plane (RFC #6592 P0a). The engine imports
|
||||
the duplex module and never the Tier 1 one directly.
|
||||
|
||||
``session_id`` / ``epoch`` are bound by
|
||||
``DuplexSessionManager.emit`` when an event leaves the runner; producers
|
||||
(projector, serving handshake helpers) build events with the defaults.
|
||||
What is genuinely engine-side stays: :data:`DOMAIN_TERMINAL_EVENTS` and
|
||||
:data:`MODEL_OUTPUT_EVENTS` are the session runner's epoch-filter policy, not
|
||||
wire contract --- they name *internal* event types, and which of them may never
|
||||
be dropped as stale is a runtime decision.
|
||||
|
||||
``DuplexEvent`` is re-exported as the name the engine and the clients already
|
||||
use. It is an alias of
|
||||
:class:`~vllm_omni.protocol.realtime.events.RealtimeEvent`, not a subclass,
|
||||
because an event has no engine-internal half --- what the session emits is
|
||||
exactly what the socket sends. A *command* does have one (``payload()`` renders
|
||||
the runner's mailbox), which is why ``DuplexCommand`` is a real class.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field, fields
|
||||
from typing import ClassVar, cast
|
||||
from uuid import uuid4
|
||||
from vllm_omni.protocol.duplex.errors import REALTIME_ERROR_TYPES_BY_CODE
|
||||
from vllm_omni.protocol.duplex.events import (
|
||||
AudioDelta,
|
||||
AudioDone,
|
||||
ContentPartAdded,
|
||||
ContentPartDone,
|
||||
DuplexEvent,
|
||||
DuplexRawEvent,
|
||||
ErrorEvent,
|
||||
FunctionCallArgumentsDelta,
|
||||
FunctionCallArgumentsDone,
|
||||
InputCleared,
|
||||
InputCommitted,
|
||||
InputTranscriptionCompleted,
|
||||
ItemAdded,
|
||||
ItemCreated,
|
||||
ItemDeleted,
|
||||
ItemDone,
|
||||
ItemRetrieved,
|
||||
ItemTruncated,
|
||||
Listen,
|
||||
OutputAudioCleared,
|
||||
OutputItemAdded,
|
||||
OutputItemDone,
|
||||
OverlapDecision,
|
||||
PlaybackAcknowledged,
|
||||
RateLimitsUpdated,
|
||||
ResponseCreated,
|
||||
ResponseDone,
|
||||
SessionClosed,
|
||||
SessionCreated,
|
||||
SessionExpired,
|
||||
SessionHeartbeatAck,
|
||||
SessionReplaced,
|
||||
SessionResumed,
|
||||
SessionResyncRequired,
|
||||
SessionUpdated,
|
||||
Speak,
|
||||
SpeechStarted,
|
||||
SpeechStopped,
|
||||
TextDelta,
|
||||
TextDone,
|
||||
TranscriptDelta,
|
||||
TranscriptDone,
|
||||
TurnEvent,
|
||||
error_event,
|
||||
new_event_id,
|
||||
)
|
||||
|
||||
|
||||
def new_event_id() -> str:
|
||||
return f"event_{uuid4().hex}"
|
||||
|
||||
|
||||
#: OpenAI Realtime ``error.type`` for each internal error code.
|
||||
REALTIME_ERROR_TYPES_BY_CODE: dict[str, str] = {
|
||||
"bad_event": "invalid_request_error",
|
||||
"bad_audio": "invalid_request_error",
|
||||
"config_timeout": "invalid_request_error",
|
||||
"invalid_json": "invalid_request_error",
|
||||
"event_too_large": "invalid_request_error",
|
||||
"unknown_event": "invalid_request_error",
|
||||
"internal_error": "server_error",
|
||||
"runtime_append_failed": "server_error",
|
||||
"runtime_append_task_failed": "server_error",
|
||||
"runtime_signal_failed": "server_error",
|
||||
"runtime_abort_failed": "server_error",
|
||||
"runtime_data_plane_stream_failed": "server_error",
|
||||
"runtime_data_plane_text_without_audio": "server_error",
|
||||
"resource_exhausted": "rate_limit_error",
|
||||
"session_exists": "invalid_request_error",
|
||||
"session_closed": "invalid_request_error",
|
||||
"unknown_session": "invalid_request_error",
|
||||
"invalid_duplex_runtime_config": "invalid_request_error",
|
||||
"instructions_update_unsupported": "invalid_request_error",
|
||||
"persona_update_unsupported": "invalid_request_error",
|
||||
"voice_update_unsupported": "invalid_request_error",
|
||||
"unsupported_nemotron_duplex_mode": "invalid_request_error",
|
||||
"unsupported_native_response_options": "invalid_request_error",
|
||||
"runtime_touch_failed": "server_error",
|
||||
"engine_error": "server_error",
|
||||
"input_backpressure": "rate_limit_error",
|
||||
"response_already_active": "invalid_request_error",
|
||||
"response_not_active": "invalid_request_error",
|
||||
"response_create_without_input": "invalid_request_error",
|
||||
"text_only_turn_unsupported": "invalid_request_error",
|
||||
"commit_aborted": "server_error",
|
||||
"input_audio_buffer_empty": "invalid_request_error",
|
||||
"missing_item_id": "invalid_request_error",
|
||||
"item_not_found": "invalid_request_error",
|
||||
"playback_item_mismatch": "invalid_request_error",
|
||||
"playback_item_not_found": "invalid_request_error",
|
||||
"playback_ack_too_late": "invalid_request_error",
|
||||
"unsupported_audio_format": "invalid_request_error",
|
||||
"unsupported_turn_detection": "invalid_request_error",
|
||||
"unsupported_ref_audio_path": "invalid_request_error",
|
||||
"ref_audio_required": "invalid_request_error",
|
||||
"model_update_unsupported": "invalid_request_error",
|
||||
"voice_update_after_audio_unsupported": "invalid_request_error",
|
||||
"ref_audio_update_unsupported": "invalid_request_error",
|
||||
"native_text_append_unsupported": "invalid_request_error",
|
||||
"invalid_video_frames": "invalid_request_error",
|
||||
"invalid_function_call_output": "invalid_request_error",
|
||||
"server_vad_unavailable": "server_error",
|
||||
}
|
||||
|
||||
_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 DuplexEvent:
|
||||
"""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})"
|
||||
|
||||
|
||||
# ---- session lifecycle ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionCreated(DuplexEvent):
|
||||
wire_type = "session.created"
|
||||
optional_wire_fields = frozenset({"attachment_generation", "resume_token"})
|
||||
|
||||
session: Mapping[str, object] = field(default_factory=dict)
|
||||
#: Transport resume credentials (set by the websocket handler when resume is supported).
|
||||
attachment_generation: int | None = None
|
||||
resume_token: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionUpdated(DuplexEvent):
|
||||
wire_type = "session.updated"
|
||||
|
||||
session: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionResumed(SessionCreated):
|
||||
wire_type = "session.resumed"
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id, **DuplexEvent._wire_fields(self)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionReplaced(DuplexEvent):
|
||||
"""A newer connection took the session over (sent to the replaced socket)."""
|
||||
|
||||
wire_type = "session.replaced"
|
||||
|
||||
attachment_generation: int = 0
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id, **DuplexEvent._wire_fields(self)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionResyncRequired(DuplexEvent):
|
||||
"""The replay journal cannot bridge the gap; the client must start over."""
|
||||
|
||||
wire_type = "session.resync_required"
|
||||
|
||||
reason: str = "journal_gap"
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id, **DuplexEvent._wire_fields(self)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionClosed(DuplexEvent):
|
||||
wire_type = "session.closed"
|
||||
|
||||
reason: str = "closed"
|
||||
#: The session-internal close event (kept for clients that inspect it).
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def is_terminal(self) -> bool:
|
||||
return True
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id, "reason": self.reason, "event": _wire_value(self.details)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionExpired(SessionClosed):
|
||||
wire_type = "session.expired"
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id, "reason": self.reason}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SessionHeartbeatAck(DuplexEvent):
|
||||
wire_type = "session.heartbeat_ack"
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TurnEvent(DuplexEvent):
|
||||
"""Turn-state transition (``turn.event``)."""
|
||||
|
||||
wire_type = "turn.event"
|
||||
|
||||
event: str = ""
|
||||
turn_state: str = ""
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"session_id": self.session_id, "event": self.event, "turn_state": self.turn_state, "epoch": self.epoch}
|
||||
|
||||
|
||||
# ---- response lifecycle ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class _ResponseEvent(DuplexEvent):
|
||||
"""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 ResponseCreated(DuplexEvent):
|
||||
wire_type = "response.created"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
response: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Listen(DuplexEvent):
|
||||
"""The model decided to keep listening (no spoken response for this turn)."""
|
||||
|
||||
wire_type = "response.listen"
|
||||
optional_wire_fields = frozenset({"response_id"})
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
response: dict[str, object] = {
|
||||
"object": "realtime.response",
|
||||
"status": "listening",
|
||||
"metadata": _wire_value(self.details),
|
||||
}
|
||||
data: dict[str, object] = {"session_id": self.session_id, "epoch": self.epoch, "response": response}
|
||||
if self.response_id:
|
||||
response["id"] = self.response_id
|
||||
data["response_id"] = self.response_id
|
||||
return data
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class Speak(_ResponseEvent):
|
||||
wire_type = "response.speak"
|
||||
|
||||
metadata: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class OverlapDecision(DuplexEvent):
|
||||
wire_type = "overlap.decision"
|
||||
|
||||
policy: str | None = None
|
||||
action: str | None = None
|
||||
reason: str | None = None
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {
|
||||
"session_id": self.session_id,
|
||||
"epoch": self.epoch,
|
||||
"policy": self.policy,
|
||||
"action": self.action,
|
||||
"reason": self.reason,
|
||||
"metadata": _wire_value(self.details),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class OutputItemAdded(DuplexEvent):
|
||||
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(_ResponseEvent):
|
||||
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(_ResponseEvent):
|
||||
wire_type = "response.output_audio.delta"
|
||||
optional_wire_fields = frozenset({"sample_rate_hz", "metadata"})
|
||||
|
||||
#: Base64 audio in ``format``.
|
||||
delta: str = ""
|
||||
format: str = "pcm16"
|
||||
sample_rate_hz: int | None = None
|
||||
metadata: Mapping[str, object] | None = None
|
||||
|
||||
@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(_ResponseEvent):
|
||||
wire_type = "response.output_audio.done"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class TranscriptDelta(_ResponseEvent):
|
||||
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(_ResponseEvent):
|
||||
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(_ResponseEvent):
|
||||
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(_ResponseEvent):
|
||||
wire_type = "response.output_text.done"
|
||||
|
||||
text: str = "" # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class FunctionCallArgumentsDelta(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
wire_type = "response.done"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
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(DuplexEvent):
|
||||
wire_type = "rate_limits.updated"
|
||||
|
||||
rate_limits: tuple[Mapping[str, object], ...] = ()
|
||||
|
||||
|
||||
# ---- input buffer / conversation ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputCommitted(DuplexEvent):
|
||||
wire_type = "input_audio_buffer.committed"
|
||||
|
||||
previous_item_id: str | None = None
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
#: The session-internal ``input.committed`` event.
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {
|
||||
"previous_item_id": self.previous_item_id,
|
||||
"item_id": self.item_id,
|
||||
"event": _wire_value(self.details),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputCleared(DuplexEvent):
|
||||
wire_type = "input_audio_buffer.cleared"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class SpeechStarted(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
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 _ItemEvent(DuplexEvent):
|
||||
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
|
||||
|
||||
|
||||
@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(DuplexEvent):
|
||||
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(DuplexEvent):
|
||||
wire_type = "conversation.item.deleted"
|
||||
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"item_id": self.item_id, "event": _wire_value(self.details)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ItemTruncated(DuplexEvent):
|
||||
wire_type = "conversation.item.truncated"
|
||||
|
||||
item_id: str | None = None # type: ignore[assignment]
|
||||
content_index: int = 0
|
||||
audio_end_ms: int = 0
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
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,
|
||||
"event": _wire_value(self.details),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class InputTranscriptionCompleted(DuplexEvent):
|
||||
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
|
||||
|
||||
|
||||
# ---- playback / control ----
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class OutputAudioCleared(DuplexEvent):
|
||||
wire_type = "output_audio_buffer.cleared"
|
||||
|
||||
response_id: str | None = None # type: ignore[assignment]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class PlaybackAcknowledged(DuplexEvent):
|
||||
wire_type = "playback.acknowledged"
|
||||
|
||||
#: The session-internal ``playback.acknowledged`` event (cursor, committed_ms ...).
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"event": _wire_value(self.details)}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class ErrorEvent(DuplexEvent):
|
||||
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
|
||||
#: Extra keys merged into the wire ``error`` object (``retryable`` ...).
|
||||
extra: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def error_type(self) -> str:
|
||||
return REALTIME_ERROR_TYPES_BY_CODE.get(self.code, "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
|
||||
error.update(cast("Mapping[str, object]", _wire_value(self.extra)))
|
||||
return error
|
||||
|
||||
@property
|
||||
def text(self) -> str | None:
|
||||
return self.message
|
||||
|
||||
def _wire_fields(self) -> dict[str, object]:
|
||||
return {"error": self.error}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True, kw_only=True)
|
||||
class DuplexRawEvent(DuplexEvent):
|
||||
"""A session-internal event with no dedicated Realtime type (``duplex.<internal type>``)."""
|
||||
|
||||
wire_type = "duplex.raw"
|
||||
|
||||
internal_type: str = ""
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def type(self) -> str:
|
||||
return f"duplex.{self.internal_type}"
|
||||
|
||||
def to_realtime(self) -> dict[str, object]:
|
||||
return {"type": self.type, "event_id": self.event_id, "event": _wire_value(self.details)}
|
||||
|
||||
|
||||
def error_event(
|
||||
code: str,
|
||||
message: str,
|
||||
*,
|
||||
event_id: object | None = None,
|
||||
param: object | None = None,
|
||||
extra: Mapping[str, object] | None = None,
|
||||
) -> ErrorEvent:
|
||||
"""Build the error event for an internal code; ``event_id`` is the *client* event id."""
|
||||
return ErrorEvent(
|
||||
code=code,
|
||||
message=message,
|
||||
related_event_id=event_id if isinstance(event_id, str) and event_id else None,
|
||||
param=param if isinstance(param, str) and param else None,
|
||||
extra=dict(extra or {}),
|
||||
)
|
||||
|
||||
|
||||
#: Internal events that terminate a response/session and must never be dropped as stale.
|
||||
DOMAIN_TERMINAL_EVENTS = frozenset(
|
||||
{
|
||||
"response.done",
|
||||
@@ -682,7 +84,6 @@ DOMAIN_TERMINAL_EVENTS = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
#: Internal streaming model-output events subject to epoch/close stale filtering.
|
||||
MODEL_OUTPUT_EVENTS = frozenset(
|
||||
{
|
||||
"response.created",
|
||||
@@ -705,13 +106,11 @@ MODEL_OUTPUT_EVENTS = frozenset(
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN_TERMINAL_EVENTS",
|
||||
"MODEL_OUTPUT_EVENTS",
|
||||
"REALTIME_ERROR_TYPES_BY_CODE",
|
||||
"AudioDelta",
|
||||
"AudioDone",
|
||||
"ContentPartAdded",
|
||||
"ContentPartDone",
|
||||
"DOMAIN_TERMINAL_EVENTS",
|
||||
"DuplexEvent",
|
||||
"DuplexRawEvent",
|
||||
"ErrorEvent",
|
||||
@@ -727,11 +126,13 @@ __all__ = [
|
||||
"ItemRetrieved",
|
||||
"ItemTruncated",
|
||||
"Listen",
|
||||
"MODEL_OUTPUT_EVENTS",
|
||||
"OutputAudioCleared",
|
||||
"OutputItemAdded",
|
||||
"OutputItemDone",
|
||||
"OverlapDecision",
|
||||
"PlaybackAcknowledged",
|
||||
"REALTIME_ERROR_TYPES_BY_CODE",
|
||||
"RateLimitsUpdated",
|
||||
"ResponseCreated",
|
||||
"ResponseDone",
|
||||
|
||||
@@ -3,26 +3,31 @@
|
||||
|
||||
"""OpenAI Realtime client events -> :class:`DuplexCommand` (stateless).
|
||||
|
||||
This is the mapping half of the former ``entrypoints/duplex/realtime_input.py``
|
||||
translator. Everything that needed per-session state in the old translator
|
||||
(input-buffer emptiness for commits, response-id fallbacks for cancels,
|
||||
conversation-item lookups, VAD) is resolved by the session runner through the
|
||||
helpers on :class:`~vllm_omni.engine.duplex.realtime_events.RealtimeProjectionState`;
|
||||
This module is the *duplex binding* of the shared Realtime codec. Parsing,
|
||||
validating and decoding a client event is not duplex-specific and lives in
|
||||
``vllm_omni.protocol.realtime``, reached through ``vllm_omni.protocol.duplex``;
|
||||
what lives here is the part that is: which
|
||||
:class:`~vllm_omni.engine.duplex.commands.DuplexCommand` a decoded event
|
||||
becomes, what the duplex engine can serve
|
||||
(:data:`DUPLEX_REALTIME_CAPABILITIES`), and the mapping between a Realtime
|
||||
output format and the duplex ``response_format`` vocabulary.
|
||||
|
||||
Everything that needs per-session state (input-buffer emptiness for commits,
|
||||
response-id fallbacks for cancels, conversation-item lookups, VAD) is resolved
|
||||
by the session runner through the helpers on
|
||||
:class:`~vllm_omni.engine.duplex.realtime_events.RealtimeProjectionState`;
|
||||
the commands produced here carry the raw client intent only.
|
||||
|
||||
The shared names re-exported at the bottom are a compatibility surface for
|
||||
existing importers. The canonical definitions are in
|
||||
``vllm_omni.protocol.realtime`` and there is exactly one of each.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, cast
|
||||
from uuid import uuid4
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
|
||||
from vllm_omni.engine.duplex.audio import convert_input_audio_with_rate
|
||||
from vllm_omni.engine.duplex.commands import (
|
||||
AckPlayback,
|
||||
AppendAudio,
|
||||
@@ -44,93 +49,58 @@ from vllm_omni.engine.duplex.commands import (
|
||||
TruncateItem,
|
||||
UpdateSession,
|
||||
)
|
||||
|
||||
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",
|
||||
}
|
||||
|
||||
#: 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",
|
||||
from vllm_omni.protocol.duplex import (
|
||||
REALTIME_INPUT_AUDIO_FORMATS,
|
||||
REALTIME_INPUT_HINT_KEYS,
|
||||
REALTIME_OUTPUT_AUDIO_FORMATS,
|
||||
RealtimeInputDefaults,
|
||||
RealtimeProtocolCapabilities,
|
||||
RealtimeProtocolError,
|
||||
apply_realtime_session_defaults,
|
||||
copy_realtime_input_hints,
|
||||
decode_audio_append,
|
||||
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,
|
||||
normalize_conversation_item,
|
||||
parse_realtime_audio_format,
|
||||
realtime_audio_format_object,
|
||||
realtime_max_output_tokens,
|
||||
realtime_output_format,
|
||||
realtime_overlap_fields,
|
||||
text_chars_for_audio_ms_from_marks,
|
||||
truncate_realtime_item_content,
|
||||
validate_conversation_item_audio_formats,
|
||||
validate_realtime_item_truncate,
|
||||
validate_realtime_response_audio_formats,
|
||||
validate_realtime_session_audio_formats,
|
||||
validate_realtime_video_frames,
|
||||
validate_session_payload,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RealtimeInputDefaults:
|
||||
"""Session-level wire defaults an append may omit (derived from the session object)."""
|
||||
def _validate_duplex_turn_detection(session_payload: Mapping[str, object]) -> str | None:
|
||||
"""The duplex engine's answer for ``turn_detection`` on a 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
|
||||
Imported lazily: the validator lives with the Silero VAD backend, and a
|
||||
connection that never sends a session object should not pay for loading it.
|
||||
"""
|
||||
from vllm_omni.engine.duplex.turn_detection import validate_realtime_turn_detection
|
||||
|
||||
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
|
||||
return validate_realtime_turn_detection(session_payload)
|
||||
|
||||
|
||||
# ---- audio format helpers ----
|
||||
|
||||
|
||||
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
|
||||
#: What the duplex engine can serve, for the shared session-object validator.
|
||||
DUPLEX_REALTIME_CAPABILITIES = RealtimeProtocolCapabilities(
|
||||
validate_turn_detection=_validate_duplex_turn_detection,
|
||||
)
|
||||
|
||||
|
||||
def duplex_response_format(realtime_format: str) -> str:
|
||||
"""A Realtime output format -> the duplex ``response_format`` vocabulary."""
|
||||
normalized = realtime_format.lower()
|
||||
if normalized in {"pcm16", "pcm_s16le", "s16le"}:
|
||||
return "pcm"
|
||||
@@ -141,433 +111,6 @@ def duplex_response_format(realtime_format: str) -> str:
|
||||
return "wav"
|
||||
|
||||
|
||||
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]) -> str | None:
|
||||
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 REALTIME_INPUT_AUDIO_FORMATS
|
||||
):
|
||||
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 REALTIME_OUTPUT_AUDIO_FORMATS
|
||||
):
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---- 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]
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# ---- conversation items ----
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
# ---- append / item audio conversion ----
|
||||
|
||||
|
||||
@@ -577,67 +120,21 @@ def build_append_audio(
|
||||
defaults: RealtimeInputDefaults,
|
||||
hints_source: Mapping[str, object] | None = None,
|
||||
) -> AppendAudio:
|
||||
"""Validate, convert (to 16 kHz ``pcm_f32le`` base64) and pack one audio append.
|
||||
|
||||
Speech classification without VAD (``is_speech`` on the command) uses the
|
||||
explicit wire hints and the RMS fallback exactly like the old translator.
|
||||
"""
|
||||
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 DuplexCommandError(
|
||||
f"Unsupported input_audio_format: {fmt}",
|
||||
code="unsupported_audio_format",
|
||||
event_id=event_id,
|
||||
)
|
||||
"""Decode one audio append (shared codec) and pack it as the duplex command."""
|
||||
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 DuplexCommandError(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 DuplexCommandError(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 DuplexCommandError("input audio is not valid base64", code="bad_audio", event_id=event_id) from exc
|
||||
decoded = decode_audio_append(event, defaults=defaults, hints_source=hints_source)
|
||||
except RealtimeProtocolError as exc:
|
||||
raise DuplexCommandError(str(exc), code=exc.code, event_id=exc.event_id) from exc
|
||||
return AppendAudio(
|
||||
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,
|
||||
event_id=decoded.event_id,
|
||||
audio=decoded.audio,
|
||||
format=decoded.format,
|
||||
sample_rate_hz=decoded.sample_rate_hz,
|
||||
is_speech=decoded.is_speech,
|
||||
video_frames=decoded.video_frames,
|
||||
duration_ms=decoded.duration_ms,
|
||||
audio_end_ms=decoded.audio_end_ms,
|
||||
hints=decoded.hints,
|
||||
)
|
||||
|
||||
|
||||
@@ -664,14 +161,9 @@ def translate_realtime_command(
|
||||
if event_type == "session.update":
|
||||
session = payload.get("session")
|
||||
session_payload: Mapping[str, object] = session if isinstance(session, dict) else payload
|
||||
format_error = validate_realtime_session_audio_formats(session_payload)
|
||||
if format_error is not None:
|
||||
raise DuplexCommandError(format_error, code="unsupported_audio_format", event_id=event_id)
|
||||
from vllm_omni.engine.duplex.turn_detection import validate_realtime_turn_detection
|
||||
|
||||
turn_detection_error = validate_realtime_turn_detection(session_payload)
|
||||
if turn_detection_error is not None:
|
||||
raise DuplexCommandError(turn_detection_error, code="unsupported_turn_detection", event_id=event_id)
|
||||
rejection = validate_session_payload(session_payload, capabilities=DUPLEX_REALTIME_CAPABILITIES)
|
||||
if rejection is not None:
|
||||
raise DuplexCommandError(rejection.message, code=rejection.code, event_id=event_id)
|
||||
return UpdateSession(event_id=event_id, patch=dict(session_payload))
|
||||
|
||||
if event_type == "conversation.item.create":
|
||||
@@ -855,7 +347,10 @@ def translate_realtime_command(
|
||||
raise DuplexCommandError(f"Unknown duplex event type: {event_type}", code="unknown_event", event_id=event_id)
|
||||
|
||||
|
||||
#: Names this module used to define, re-exported so existing importers keep
|
||||
#: working. The canonical definitions live in ``vllm_omni.protocol.realtime``.
|
||||
__all__ = [
|
||||
"DUPLEX_REALTIME_CAPABILITIES",
|
||||
"REALTIME_INPUT_AUDIO_FORMATS",
|
||||
"REALTIME_INPUT_HINT_KEYS",
|
||||
"REALTIME_OUTPUT_AUDIO_FORMATS",
|
||||
|
||||
@@ -15,6 +15,12 @@ carries the input-side bookkeeping the old input translator kept (input buffer
|
||||
flags, conversation items, response-id fallbacks); the ``resolve_*`` /
|
||||
``note_*`` helpers give the runner the same behaviour for the corresponding
|
||||
commands.
|
||||
|
||||
What is *not* here is the model- and runtime-agnostic half of the codec ---
|
||||
audio format negotiation, conversation-item shape and truncation, transcript
|
||||
extraction, audio conversion. That lives in ``vllm_omni.protocol.realtime`` so
|
||||
a non-duplex Realtime surface can use it without the duplex session; this
|
||||
module is the duplex consumer of it (RFC #6592 P0a).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,7 +30,6 @@ from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, cast
|
||||
from uuid import uuid4
|
||||
|
||||
from vllm_omni.engine.duplex.audio import convert_output_audio
|
||||
from vllm_omni.engine.duplex.commands import (
|
||||
AppendAudio,
|
||||
CancelResponse,
|
||||
@@ -74,10 +79,11 @@ from vllm_omni.engine.duplex.events import (
|
||||
TranscriptDone,
|
||||
error_event,
|
||||
)
|
||||
from vllm_omni.engine.duplex.realtime_commands import (
|
||||
from vllm_omni.engine.duplex.realtime_commands import build_append_audio
|
||||
from vllm_omni.protocol.duplex import (
|
||||
RealtimeInputDefaults,
|
||||
apply_realtime_session_defaults,
|
||||
build_append_audio,
|
||||
convert_output_audio,
|
||||
copy_realtime_input_hints,
|
||||
input_looks_like_speech,
|
||||
input_transcript_from_item,
|
||||
|
||||
@@ -28,7 +28,6 @@ from typing import TYPE_CHECKING, TypeVar
|
||||
|
||||
from vllm.logger import init_logger
|
||||
|
||||
from vllm_omni.engine.duplex.audio import convert_input_audio_with_rate
|
||||
from vllm_omni.engine.duplex.commands import (
|
||||
AckPlayback,
|
||||
AppendAudio,
|
||||
@@ -103,6 +102,7 @@ from vllm_omni.engine.duplex.turn_detection import (
|
||||
TurnDetectionResult,
|
||||
)
|
||||
from vllm_omni.metrics.stats import StageRequestStats
|
||||
from vllm_omni.protocol.duplex import convert_input_audio_with_rate
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from vllm.config import ModelConfig
|
||||
|
||||
Reference in new issue
Block a user