Signed-off-by: chickeyton <ngton2014@gmail.com> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
888 lines
37 KiB
Python
888 lines
37 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
|
|
"""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`;
|
|
the commands produced here carry the raw client intent only.
|
|
"""
|
|
|
|
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
|
|
|
|
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,
|
|
AppendText,
|
|
BargeIn,
|
|
CancelInput,
|
|
CancelResponse,
|
|
ClearInput,
|
|
ClearOutputAudio,
|
|
CloseSession,
|
|
Commit,
|
|
CreateItem,
|
|
CreateResponse,
|
|
DeleteItem,
|
|
DuplexCommand,
|
|
DuplexCommandError,
|
|
Heartbeat,
|
|
SignalTurn,
|
|
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",
|
|
)
|
|
|
|
|
|
@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
|
|
|
|
|
|
# ---- 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
|
|
|
|
|
|
def duplex_response_format(realtime_format: str) -> str:
|
|
normalized = realtime_format.lower()
|
|
if normalized in {"pcm16", "pcm_s16le", "s16le"}:
|
|
return "pcm"
|
|
if normalized in {"g711_ulaw", "g711_alaw"}:
|
|
return "pcm"
|
|
if normalized in {"wav", "pcm"}:
|
|
return normalized
|
|
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 ----
|
|
|
|
|
|
def build_append_audio(
|
|
event: Mapping[str, object],
|
|
*,
|
|
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,
|
|
)
|
|
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
|
|
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,
|
|
)
|
|
|
|
|
|
# ---- translation ----
|
|
|
|
|
|
def translate_realtime_command(
|
|
payload: Mapping[str, object],
|
|
*,
|
|
defaults: RealtimeInputDefaults | None = None,
|
|
) -> DuplexCommand:
|
|
"""Map one OpenAI Realtime client event onto a :class:`DuplexCommand`.
|
|
|
|
Raises :class:`DuplexCommandError` for malformed or unsupported payloads.
|
|
``session.resume`` and ``session.event_ack`` are transport concerns and are
|
|
rejected with ``code="unknown_event"``.
|
|
"""
|
|
defaults = defaults or RealtimeInputDefaults()
|
|
event_type = payload.get("type")
|
|
event_id = cast("str", payload.get("event_id")) if isinstance(payload.get("event_id"), str) else None
|
|
if not isinstance(event_type, str):
|
|
raise DuplexCommandError("Duplex event missing string type", code="bad_event", event_id=event_id)
|
|
|
|
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)
|
|
return UpdateSession(event_id=event_id, patch=dict(session_payload))
|
|
|
|
if event_type == "conversation.item.create":
|
|
item = payload.get("item")
|
|
format_error = validate_conversation_item_audio_formats(item)
|
|
if format_error is not None:
|
|
raise DuplexCommandError(format_error, code="unsupported_audio_format", event_id=event_id)
|
|
if not isinstance(item, dict):
|
|
raise DuplexCommandError("conversation.item.create requires item", code="bad_event", event_id=event_id)
|
|
previous_item_id = payload.get("previous_item_id")
|
|
return CreateItem(
|
|
event_id=event_id,
|
|
item=normalize_conversation_item(item),
|
|
previous_item_id=previous_item_id if isinstance(previous_item_id, str) else None,
|
|
)
|
|
|
|
if event_type == "conversation.item.delete":
|
|
item_id = payload.get("item_id")
|
|
if not isinstance(item_id, str) or not item_id:
|
|
raise DuplexCommandError(
|
|
"conversation.item.delete requires item_id", code="missing_item_id", event_id=event_id
|
|
)
|
|
return DeleteItem(event_id=event_id, item_id=item_id)
|
|
|
|
if event_type == "conversation.item.truncate":
|
|
item_id = payload.get("item_id")
|
|
audio_end_ms = payload.get("audio_end_ms")
|
|
content_index = payload.get("content_index", 0)
|
|
if not isinstance(item_id, str) or not item_id:
|
|
raise DuplexCommandError(
|
|
"conversation.item.truncate requires item_id", code="missing_item_id", event_id=event_id
|
|
)
|
|
if not isinstance(audio_end_ms, int | float):
|
|
raise DuplexCommandError(
|
|
"conversation.item.truncate requires numeric audio_end_ms", code="bad_event", event_id=event_id
|
|
)
|
|
return TruncateItem(
|
|
event_id=event_id,
|
|
item_id=item_id,
|
|
audio_end_ms=int(audio_end_ms),
|
|
content_index=int(content_index) if isinstance(content_index, int | float) else 0,
|
|
)
|
|
|
|
if event_type == "input_audio_buffer.append":
|
|
return build_append_audio(dict(payload), defaults=defaults)
|
|
|
|
if event_type in {"input_audio_buffer.commit", "input.commit"}:
|
|
final = payload.get("final", True)
|
|
create_response = payload.get("response_create", payload.get("create_response"))
|
|
is_speech = payload.get("is_speech")
|
|
return Commit(
|
|
event_id=event_id,
|
|
final=bool(final) if isinstance(final, bool) else True,
|
|
create_response=(
|
|
bool(create_response)
|
|
if isinstance(create_response, bool)
|
|
else (True if event_type == "input.commit" and create_response is None else None)
|
|
),
|
|
is_speech=is_speech if isinstance(is_speech, bool) else None,
|
|
)
|
|
|
|
if event_type == "input_audio_buffer.clear":
|
|
return ClearInput(event_id=event_id)
|
|
|
|
if event_type == "output_audio_buffer.clear":
|
|
response_id = payload.get("response_id")
|
|
return ClearOutputAudio(
|
|
event_id=event_id,
|
|
response_id=response_id if isinstance(response_id, str) and response_id else None,
|
|
)
|
|
|
|
if event_type == "response.cancel":
|
|
response_id = payload.get("response_id")
|
|
return CancelResponse(
|
|
event_id=event_id,
|
|
response_id=response_id if isinstance(response_id, str) and response_id else None,
|
|
)
|
|
|
|
if event_type == "response.create":
|
|
response_payload = payload.get("response")
|
|
if isinstance(response_payload, dict):
|
|
format_error = validate_realtime_response_audio_formats(response_payload)
|
|
if format_error is not None:
|
|
raise DuplexCommandError(format_error, code="unsupported_audio_format", event_id=event_id)
|
|
return CreateResponse(
|
|
event_id=event_id,
|
|
options=dict(response_payload) if isinstance(response_payload, dict) else {},
|
|
)
|
|
|
|
if event_type in {"playback.ack", "audio.playback_ack"}:
|
|
played_ms = payload.get("played_ms")
|
|
if not isinstance(played_ms, int | float):
|
|
raise DuplexCommandError("playback.ack requires numeric played_ms", code="bad_event", event_id=event_id)
|
|
committed_ms = payload.get("committed_ms")
|
|
response_id = payload.get("response_id")
|
|
item_id = payload.get("item_id")
|
|
return AckPlayback(
|
|
event_id=event_id,
|
|
played_ms=int(played_ms),
|
|
committed_ms=int(committed_ms) if isinstance(committed_ms, int | float) else None,
|
|
response_id=response_id if isinstance(response_id, str) and response_id else None,
|
|
item_id=item_id if isinstance(item_id, str) and item_id else None,
|
|
)
|
|
|
|
if event_type == "session.heartbeat":
|
|
return Heartbeat(event_id=event_id)
|
|
|
|
if event_type == "conversation.item.retrieve":
|
|
# The projected conversation items live engine-side; the runner answers.
|
|
return SignalTurn(event_id=event_id, event="conversation.item.retrieve", signal_payload=dict(payload))
|
|
|
|
if event_type in {"session.close", "close", "close_session"}:
|
|
reason = payload.get("reason")
|
|
return CloseSession(event_id=event_id, reason=reason if isinstance(reason, str) and reason else "client_close")
|
|
|
|
if event_type in {"input.text.append", "input_text.append", "push_text"}:
|
|
text = payload.get("text")
|
|
if not isinstance(text, str):
|
|
raise DuplexCommandError("input.text.append requires text", code="bad_event", event_id=event_id)
|
|
return AppendText(event_id=event_id, text=text)
|
|
|
|
if event_type == "input.cancel":
|
|
return CancelInput(event_id=event_id)
|
|
|
|
if event_type == "barge_in":
|
|
return BargeIn(event_id=event_id)
|
|
|
|
if event_type in {"turn.signal", "signal_turn"}:
|
|
signal_event = payload.get("event")
|
|
if not isinstance(signal_event, str) or not signal_event:
|
|
raise DuplexCommandError("turn.signal requires event", code="bad_event", event_id=event_id)
|
|
signal_payload = payload.get("payload")
|
|
signal_payload = dict(signal_payload) if isinstance(signal_payload, dict) else {}
|
|
if signal_event == "input.cancel":
|
|
return CancelInput(event_id=event_id)
|
|
if signal_event == "barge_in":
|
|
return BargeIn(event_id=event_id)
|
|
if signal_event == "response.cancel":
|
|
response_id = signal_payload.get("response_id", payload.get("response_id"))
|
|
return CancelResponse(
|
|
event_id=event_id,
|
|
response_id=response_id if isinstance(response_id, str) and response_id else None,
|
|
)
|
|
if signal_event == "session.update":
|
|
return UpdateSession(event_id=event_id, patch=signal_payload)
|
|
if signal_event == "conversation.item.create":
|
|
item = signal_payload.get("item")
|
|
if not isinstance(item, dict):
|
|
raise DuplexCommandError("conversation.item.create requires item", code="bad_event", event_id=event_id)
|
|
previous_item_id = signal_payload.get("previous_item_id")
|
|
return CreateItem(
|
|
event_id=event_id,
|
|
item=normalize_conversation_item(item),
|
|
previous_item_id=previous_item_id if isinstance(previous_item_id, str) else None,
|
|
)
|
|
if signal_event == "conversation.item.delete":
|
|
item_id = signal_payload.get("item_id")
|
|
if not isinstance(item_id, str) or not item_id:
|
|
raise DuplexCommandError(
|
|
"conversation.item.delete requires item_id", code="missing_item_id", event_id=event_id
|
|
)
|
|
return DeleteItem(event_id=event_id, item_id=item_id)
|
|
if signal_event == "conversation.item.truncate":
|
|
item_id = signal_payload.get("item_id")
|
|
audio_end_ms = signal_payload.get("audio_end_ms")
|
|
content_index = signal_payload.get("content_index", 0)
|
|
if not isinstance(item_id, str) or not item_id or not isinstance(audio_end_ms, int | float):
|
|
raise DuplexCommandError(
|
|
"conversation.item.truncate requires item_id and numeric audio_end_ms",
|
|
code="bad_event",
|
|
event_id=event_id,
|
|
)
|
|
return TruncateItem(
|
|
event_id=event_id,
|
|
item_id=item_id,
|
|
audio_end_ms=int(audio_end_ms),
|
|
content_index=int(content_index) if isinstance(content_index, int | float) else 0,
|
|
)
|
|
return SignalTurn(event_id=event_id, event=signal_event, signal_payload=signal_payload)
|
|
|
|
raise DuplexCommandError(f"Unknown duplex event type: {event_type}", code="unknown_event", event_id=event_id)
|
|
|
|
|
|
__all__ = [
|
|
"REALTIME_INPUT_AUDIO_FORMATS",
|
|
"REALTIME_INPUT_HINT_KEYS",
|
|
"REALTIME_OUTPUT_AUDIO_FORMATS",
|
|
"RealtimeInputDefaults",
|
|
"apply_realtime_session_defaults",
|
|
"build_append_audio",
|
|
"copy_realtime_input_hints",
|
|
"duplex_response_format",
|
|
"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",
|
|
"translate_realtime_command",
|
|
"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",
|
|
]
|