Files
vllm-omni/tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py
T
2026-08-11 14:53:49 +08:00

1737 lines
68 KiB
Python

"""Strict MiniCPM-o 4.5 Realtime duplex E2E scenario harness.
This script is intentionally scenario-based instead of a generic chat client.
It has two explicit validation contracts:
1. ``model-policy`` accepts either a model-owned listen decision or a complete
spoken response for each streamed user turn;
2. ``response-required`` uses a known fixture and requires a complete audio
response with an independent transcript for every requested turn.
Explicit serving-side barge-in is intentionally not part of this smoke test.
MiniCPM-o native duplex currently exposes model-owned listen/speak switching,
not a separate model-level barge-in contract.
Run only after a MiniCPM-o 4.5 vLLM-Omni server is up:
python tests/e2e/online_serving/helpers/minicpmo_realtime_duplex_scenarios.py \
--url ws://localhost:8099/v1/realtime?duplex=1 \
--model openbmb/MiniCPM-o-4_5 \
--input-wav input_16k_mono_pcm16.wav \
--validation-mode model-policy \
--output-dir /tmp/minicpmo_duplex_demo
"""
from __future__ import annotations
import argparse
import array
import asyncio
import base64
import hashlib
import inspect
import json
import sys
import time
import wave
from dataclasses import dataclass, field
from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parents[4]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
try:
import websockets
from websockets.exceptions import ConnectionClosed
except ImportError as exc: # pragma: no cover - demo dependency.
raise SystemExit("Install websockets first: pip install websockets") from exc
from vllm_omni.experimental.fullduplex.client import ( # noqa: E402
PCM16_BYTES_PER_SAMPLE,
PCM16_SAMPLE_RATE,
RealtimeEventCollector,
build_realtime_url,
read_pcm16_wav,
summarize_session_request_metrics,
)
_url_with_model = build_realtime_url
_read_wav_pcm16 = read_pcm16_wav
def _ref_audio_data_url(path: str | None) -> str | None:
if path is None:
return None
ref_path = Path(path).expanduser()
return "data:audio/wav;base64," + base64.b64encode(ref_path.read_bytes()).decode("ascii")
@dataclass
class DemoState:
events: list[dict[str, object]] = field(default_factory=list)
timing_events: RealtimeEventCollector = field(default_factory=RealtimeEventCollector)
audio_deltas: list[bytes] = field(default_factory=list)
response_audio_deltas: dict[str, list[bytes]] = field(default_factory=dict)
response_ids: list[str] = field(default_factory=list)
assistant_item_ids: list[str] = field(default_factory=list)
done_count: int = 0
cancelled_count: int = 0
listen_count: int = 0
model_listen_count: int = 0
buffering_listen_count: int = 0
model_speak_event_count: int = 0
model_speak_delta_count: int = 0
playback_ack_count: int = 0
playback_history_committed_count: int = 0
truncate_count: int = 0
input_transcription_count: int = 0
audio_marks_seen: bool = False
overlap_decisions: list[dict[str, object]] = field(default_factory=list)
output_sample_rate_hz: int = 24000
input_commit_sent_at_s: list[float] = field(default_factory=list)
def add(self, event: dict[str, object], *, received_at_s: float | None = None) -> None:
self.events.append(event)
self.timing_events.add(event, received_at_s=received_at_s)
event_type = event.get("type")
if event_type == "response.created":
response = event.get("response")
response_id = response.get("id") if isinstance(response, dict) else event.get("response_id")
if isinstance(response_id, str) and response_id not in self.response_ids:
self.response_ids.append(response_id)
elif event_type == "conversation.item.added":
item = event.get("item")
if isinstance(item, dict) and item.get("role") == "assistant":
item_id = item.get("id")
if isinstance(item_id, str) and item_id not in self.assistant_item_ids:
self.assistant_item_ids.append(item_id)
elif event_type == "response.audio.delta":
delta = event.get("delta") or event.get("audio")
if isinstance(delta, str) and delta:
try:
decoded = base64.b64decode(delta)
self.audio_deltas.append(decoded)
response_id = self._event_response_id(event)
if isinstance(response_id, str):
self.response_audio_deltas.setdefault(response_id, []).append(decoded)
except Exception:
pass
metadata = event.get("metadata")
if isinstance(metadata, dict):
if metadata.get("model_speak") is True:
self.model_speak_delta_count += 1
if isinstance(metadata.get("audio_text_marks"), list):
self.audio_marks_seen = True
sample_rate_hz = event.get("sample_rate_hz")
if isinstance(sample_rate_hz, int) and sample_rate_hz > 0:
self.output_sample_rate_hz = sample_rate_hz
elif event_type == "response.done":
self.done_count += 1
response = event.get("response")
if isinstance(response, dict) and response.get("status") == "cancelled":
self.cancelled_count += 1
elif event_type in {"audio.cancelled", "input.cancelled"}:
self.cancelled_count += 1
elif event_type == "response.listen":
self.listen_count += 1
response = event.get("response")
metadata = response.get("metadata") if isinstance(response, dict) else None
if isinstance(metadata, dict) and metadata.get("model_listen") is True:
self.model_listen_count += 1
if isinstance(metadata, dict) and metadata.get("buffering") is True:
self.buffering_listen_count += 1
elif event_type == "response.speak":
self.model_speak_event_count += 1
elif event_type == "overlap.decision":
self.overlap_decisions.append(event)
elif event_type == "playback.acknowledged":
self.playback_ack_count += 1
payload = event.get("event")
if isinstance(payload, dict) and payload.get("history_committed") is True:
self.playback_history_committed_count += 1
elif event_type == "conversation.item.truncated":
self.truncate_count += 1
elif event_type == "conversation.item.input_audio_transcription.completed":
self.input_transcription_count += 1
def count(self, event_type: str) -> int:
return sum(1 for event in self.events if event.get("type") == event_type)
def response_timing_summaries(self) -> dict[str, dict[str, object]]:
summaries: dict[str, dict[str, object]] = {}
for response_id in self.response_ids:
created_at_s = next(
(
received_at_s
for event, received_at_s in zip(
self.timing_events.events,
self.timing_events.event_received_at_s,
strict=True,
)
if event.get("type") == "response.created" and self._event_response_id(event) == response_id
),
None,
)
if created_at_s is None:
continue
input_committed_at_s = next(
(
committed_at_s
for committed_at_s in reversed(self.input_commit_sent_at_s)
if committed_at_s <= created_at_s
),
None,
)
timing = self.timing_events.timing_summary(
after_s=created_at_s,
input_committed_at_s=input_committed_at_s,
response_id=response_id,
)
timing["measurement_origin"] = {
"response": "response.created client receive",
"input": "input_audio_buffer.commit client send" if input_committed_at_s is not None else None,
}
summaries[response_id] = timing
return summaries
def session_request_metrics(
self,
*,
session_id: str | None = None,
) -> list[dict[str, object]]:
"""Return benchmark-style metrics for every response in this session."""
requests: list[dict[str, object]] = []
for request_index, (response_id, timing) in enumerate(self.response_timing_summaries().items()):
metrics = timing.get("request_metrics")
if not isinstance(metrics, dict):
continue
requests.append(
{
"session_id": session_id,
"request_index": request_index,
"response_id": response_id,
**metrics,
}
)
return requests
def session_metric_summary(
self,
*,
session_id: str | None = None,
) -> dict[str, object]:
"""Average TTFT, TTFP, and RTF across responses that emitted audio."""
return summarize_session_request_metrics(
self.session_request_metrics(session_id=session_id),
session_id=session_id,
)
def first_index(self, event_type: str, predicate=None) -> int | None:
for index, event in enumerate(self.events):
if event.get("type") != event_type:
continue
if predicate is not None and not predicate(event):
continue
return index
return None
@staticmethod
def _event_response_id(event: dict[str, object]) -> str | None:
response_id = event.get("response_id")
if isinstance(response_id, str) and response_id:
return response_id
response = event.get("response")
if isinstance(response, dict):
response_id = response.get("id")
if isinstance(response_id, str) and response_id:
return response_id
return None
@staticmethod
def _event_item_id(event: dict[str, object]) -> str | None:
item_id = event.get("item_id")
if isinstance(item_id, str) and item_id:
return item_id
item = event.get("item")
if isinstance(item, dict):
item_id = item.get("id")
if isinstance(item_id, str) and item_id:
return item_id
return None
def first_response_lifecycle_indices(self) -> dict[str, int]:
response_created_index = self.first_index("response.created")
if response_created_index is None:
return {}
response_id = self._event_response_id(self.events[response_created_index])
if not response_id:
return {}
item_id = f"item_{response_id}"
indices: dict[str, int] = {"response.created": response_created_index}
for event_type in (
"conversation.item.added",
"response.output_item.added",
"response.content_part.added",
"response.speak",
"response.audio.delta",
"response.audio.done",
"response.content_part.done",
"response.output_item.done",
"response.done",
):
index = self.first_index(
event_type,
lambda event, event_type=event_type: (
self._event_item_id(event) == item_id
if event_type == "conversation.item.added"
else self._event_response_id(event) == response_id
),
)
if index is None and event_type not in {
"response.speak",
"response.audio.delta",
"response.audio.done",
}:
return {}
if index is not None:
indices[event_type] = index
return indices
def event_order_ok(
self,
*,
require_input_commit: bool = True,
require_commit_before_response: bool = True,
) -> bool:
if not self.events or self.events[0].get("type") != "session.created":
return False
first_commit_index = self.first_index("input_audio_buffer.committed")
first_response_index = self.first_index("response.created")
if first_response_index is None:
return False
if require_input_commit and first_commit_index is None:
return False
if (
require_input_commit
and require_commit_before_response
and first_commit_index is not None
and first_commit_index > first_response_index
):
return False
indices_by_type = self.first_response_lifecycle_indices()
if not indices_by_type:
return False
required_types = [
"response.created",
"conversation.item.added",
"response.output_item.added",
"response.content_part.added",
"response.content_part.done",
"response.output_item.done",
"response.done",
]
if any(event_type not in indices_by_type for event_type in required_types):
return False
ordered_types = [
"response.created",
"conversation.item.added",
"response.output_item.added",
"response.content_part.added",
"response.speak",
"response.audio.delta",
"response.audio.done",
"response.content_part.done",
"response.output_item.done",
"response.done",
]
if "response.audio.delta" not in indices_by_type:
listen_index = self.first_index(
"response.listen",
lambda event: (
isinstance(event.get("response"), dict)
and isinstance(event["response"].get("metadata"), dict)
and event["response"]["metadata"].get("model_listen") is True
),
)
return (
listen_index is not None
and indices_by_type["response.created"] < listen_index < indices_by_type["response.done"]
)
if any(event_type not in indices_by_type for event_type in ordered_types):
return False
indices = [indices_by_type[event_type] for event_type in ordered_types]
return indices == sorted(indices)
def model_policy_event_order_ok(
self,
*,
expected_turns: int,
require_input_commit: bool = True,
) -> bool:
if not self.events or self.events[0].get("type") != "session.created":
return False
commit_indices = [
index for index, event in enumerate(self.events) if event.get("type") == "input_audio_buffer.committed"
]
decision_indices = [
index
for index, event in enumerate(self.events)
if event.get("type") == "response.created"
or (
event.get("type") == "response.listen"
and isinstance(event.get("response"), dict)
and isinstance(event["response"].get("metadata"), dict)
and event["response"]["metadata"].get("model_listen") is True
)
]
first_input_index = self.first_index("input_audio_buffer.speech_started")
if first_input_index is None:
first_input_index = commit_indices[0] if commit_indices else None
if first_input_index is None and not require_input_commit:
first_input_index = 0
if (
first_input_index is None
or (require_input_commit and len(commit_indices) < expected_turns)
or len(decision_indices) < expected_turns
):
return False
return decision_indices[0] > first_input_index
def model_speak_before_audio_ok(self) -> bool:
speak_index = self.first_index("response.speak")
audio_index = self.first_index("response.audio.delta")
return speak_index is not None and audio_index is not None and speak_index < audio_index
def response_done(self, response_id: str | None) -> bool:
if not response_id:
return False
return any(
event.get("type") == "response.done" and self._event_response_id(event) == response_id
for event in self.events
)
def response_audio_delta_count(self, response_id: str | None) -> int:
if not response_id:
return 0
return sum(
1
for event in self.events
if event.get("type") == "response.audio.delta" and self._event_response_id(event) == response_id
)
def response_playback_sent_ms(self, response_id: str | None) -> int:
if not response_id:
return 0
for event in reversed(self.events):
if event.get("type") != "response.done" or self._event_response_id(event) != response_id:
continue
response = event.get("response")
metadata = response.get("metadata") if isinstance(response, dict) else None
playback = metadata.get("playback") if isinstance(metadata, dict) else event.get("playback")
sent_ms = playback.get("sent_ms") if isinstance(playback, dict) else None
if isinstance(sent_ms, int | float):
return max(0, int(sent_ms))
return 0
def response_playback_history_committed(self, response_id: str) -> bool:
item_id = f"item_{response_id}"
for event in self.events:
if event.get("type") != "playback.acknowledged":
continue
payload = event.get("event")
if not isinstance(payload, dict):
continue
if payload.get("item_id") == item_id and payload.get("history_committed") is True:
return True
return False
def response_transcript_delta(self, response_id: str) -> str:
return "".join(
str(event.get("delta", ""))
for event in self.events
if event.get("type") == "response.audio_transcript.delta" and self._event_response_id(event) == response_id
)
def response_transcript_done(self, response_id: str) -> list[str]:
return [
str(event.get("transcript", ""))
for event in self.events
if event.get("type") == "response.audio_transcript.done" and self._event_response_id(event) == response_id
]
def completed_response_ids(self) -> list[str]:
response_ids: list[str] = []
for event in self.events:
if event.get("type") != "response.done":
continue
response_id = self._event_response_id(event)
if isinstance(response_id, str):
response_ids.append(response_id)
return response_ids
def stale_audio_delta_count(self) -> int:
cancelled_epochs_by_index: list[tuple[int, int]] = []
for index, event in enumerate(self.events):
if event.get("type") != "response.done":
continue
response = event.get("response")
if not isinstance(response, dict) or response.get("status") != "cancelled":
continue
metadata = response.get("metadata")
if not isinstance(metadata, dict):
continue
cancelled_epoch = metadata.get("cancelled_epoch")
if isinstance(cancelled_epoch, int):
cancelled_epochs_by_index.append((index, cancelled_epoch))
if not cancelled_epochs_by_index:
return 0
stale = 0
for index, event in enumerate(self.events):
if event.get("type") != "response.audio.delta":
continue
metadata = event.get("metadata")
if not isinstance(metadata, dict):
continue
event_epoch = metadata.get("epoch")
for cancel_index, cancelled_epoch in cancelled_epochs_by_index:
if index > cancel_index and event_epoch == cancelled_epoch:
stale += 1
break
return stale
def _session_update_event(args: argparse.Namespace) -> dict[str, object]:
session_payload: dict[str, object] = {
"model": args.model,
"modalities": ["audio", "text"],
"input_audio_format": "pcm16",
"output_audio_format": args.output_audio_format,
"turn_detection": None,
"overlap_policy": "listen_only",
"overlap_short_ack_ms": args.short_ack_ms,
"playback_commit_policy": "ack_only",
"extra_body": {
"auto_response": True,
"minicpmo45_native_duplex": True,
"force_listen_count": 0,
},
}
temperature = getattr(args, "temperature", None)
if temperature is None and getattr(args, "validation_mode", None) == "response-required":
temperature = 0.0
if temperature is not None:
session_payload["temperature"] = temperature
ref_audio = _ref_audio_data_url(getattr(args, "ref_audio", None))
if ref_audio is not None:
session_payload["ref_audio"] = ref_audio
event: dict[str, object] = {
"type": "session.update",
"session": session_payload,
}
session_id = getattr(args, "session_id", None)
if session_id:
session_payload["session_id"] = session_id
return event
def _turn_input_paths(primary: Path, additional: list[str], *, turns: int) -> list[Path]:
turn_count = max(1, turns)
if not additional:
return [primary] * turn_count
if len(additional) != turn_count - 1:
raise ValueError(
f"provide exactly one --turn-input-wav for each turn after the first "
f"(expected {turn_count - 1}, got {len(additional)})"
)
return [primary, *(Path(path) for path in additional)]
def _turn_inputs_are_distinct(paths: list[Path], pcm16_inputs: list[bytes]) -> bool:
if len(paths) != len(pcm16_inputs):
return False
distinct_paths = len({str(path.resolve()) for path in paths}) == len(paths)
distinct_audio = len({hashlib.sha256(pcm16).digest() for pcm16 in pcm16_inputs}) == len(pcm16_inputs)
return distinct_paths and distinct_audio
def _turn_durations(
explicit: list[int],
*,
turns: int,
first_turn_ms: int,
) -> list[int | None]:
turn_count = max(1, turns)
if not explicit:
return [first_turn_ms, *([min(first_turn_ms, 1200)] * (turn_count - 1))]
if len(explicit) != turn_count:
raise ValueError(
f"provide exactly one --turn-duration-ms for each turn (expected {turn_count}, got {len(explicit)})"
)
if any(duration_ms < 0 for duration_ms in explicit):
raise ValueError("--turn-duration-ms values must be non-negative")
return [None if duration_ms == 0 else duration_ms for duration_ms in explicit]
def _turn_transcripts(first: str, *, turns: int) -> list[str]:
turn_count = max(1, turns)
transcripts = [first, "继续", "再说一次"][:turn_count]
transcripts.extend(f"turn-{turn_index + 1}" for turn_index in range(len(transcripts), turn_count))
return transcripts
def _write_wav(path: Path, pcm_bytes: bytes, *, sample_rate_hz: int) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(PCM16_BYTES_PER_SAMPLE)
wf.setframerate(sample_rate_hz)
wf.writeframes(pcm_bytes)
def _write_demo_artifacts(state: DemoState, output_dir: Path, *, output_audio_format: str) -> None:
output_dir.mkdir(parents=True, exist_ok=True)
if state.audio_deltas and output_audio_format == "pcm16":
_write_wav(
output_dir / "joined_audio_deltas.wav",
b"".join(state.audio_deltas),
sample_rate_hz=state.output_sample_rate_hz,
)
elif state.audio_deltas:
(output_dir / "joined_audio_deltas.bin").write_bytes(b"".join(state.audio_deltas))
for index, response_id in enumerate(state.response_ids, start=1):
response_audio = state.response_audio_deltas.get(response_id, [])
if not response_audio:
continue
payload = b"".join(response_audio)
if output_audio_format == "pcm16":
_write_wav(
output_dir / f"response_{index:02d}.wav",
payload,
sample_rate_hz=state.output_sample_rate_hz,
)
else:
(output_dir / f"response_{index:02d}.bin").write_bytes(payload)
(output_dir / "events.jsonl").write_text(
"\n".join(json.dumps(event, ensure_ascii=False) for event in state.events) + "\n",
encoding="utf-8",
)
def _pcm16_silence(duration_ms: int) -> bytes:
samples = PCM16_SAMPLE_RATE * max(0, duration_ms) // 1000
return b"\x00\x00" * samples
def _pcm16_slice(pcm16: bytes, duration_ms: int) -> bytes:
byte_count = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * max(1, duration_ms) // 1000
return pcm16[: min(len(pcm16), byte_count)]
def _pcm16_active_slice(pcm16: bytes, duration_ms: int) -> bytes:
byte_count = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * max(1, duration_ms) // 1000
byte_count = min(len(pcm16), max(PCM16_BYTES_PER_SAMPLE, byte_count))
byte_count -= byte_count % PCM16_BYTES_PER_SAMPLE
if byte_count <= 0:
return _pcm16_slice(pcm16, duration_ms)
step = max(PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * 20 // 1000, PCM16_BYTES_PER_SAMPLE)
step -= step % PCM16_BYTES_PER_SAMPLE
best_offset = 0
best_energy = -1.0
for offset in range(0, max(1, len(pcm16) - byte_count + 1), max(PCM16_BYTES_PER_SAMPLE, step)):
chunk = pcm16[offset : offset + byte_count]
samples = array.array("h")
samples.frombytes(chunk)
if not samples:
continue
energy = sum(abs(sample) for sample in samples) / len(samples)
if energy > best_energy:
best_energy = energy
best_offset = offset
return pcm16[best_offset : best_offset + byte_count]
def _select_turn_audio(pcm16: bytes, duration_ms: int | None) -> bytes:
if duration_ms is None:
return pcm16
return _pcm16_active_slice(pcm16, duration_ms)
def _canonical_transcript(text: str) -> str:
return "".join(text.split())
def _reuses_previous_turn_tail(previous: str, current: str) -> bool:
if not previous or not current:
return False
if len(previous) >= 4 and previous in current:
return True
max_overlap = min(len(previous), len(current))
return any(previous.endswith(current[:overlap]) for overlap in range(max_overlap, 2, -1))
def _has_terminal_punctuation(text: str) -> bool:
stripped = text.rstrip("\"'”’))]} ")
return bool(stripped) and (len(stripped) <= 4 or stripped[-1] in "。!?!?…")
def _all_audio_responses_have_transcript(state: DemoState, response_ids: list[str]) -> bool:
return all(
state.response_audio_delta_count(response_id) == 0
or bool(_canonical_transcript(state.response_transcript_delta(response_id)))
for response_id in response_ids
)
def _all_response_playback_history_committed(state: DemoState, response_ids: list[str]) -> bool:
return all(
state.response_playback_history_committed(response_id)
for response_id in response_ids
if state.response_playback_sent_ms(response_id) > 0
)
def _response_cardinality_ok(
completed_response_ids: list[str],
*,
expected_turns: int,
validation_mode: str,
) -> bool:
if validation_mode == "response-required":
return len(completed_response_ids) == expected_turns
return True
def _requires_cross_turn_independence(
*,
validation_mode: str,
distinct_inputs_required: bool,
) -> bool:
return distinct_inputs_required and validation_mode == "response-required"
def _input_transcription_ok(count: int, *, transcript_hints_enabled: bool) -> bool:
return count > 0 if transcript_hints_enabled else True
def _unexpected_error_events(state: DemoState) -> list[dict[str, object]]:
return [event for event in state.events if event.get("type") == "error"]
def _evaluate_response_speak_contract(state: DemoState) -> dict[str, object]:
speak_counts: dict[str, int] = {}
invalid_response_speak_count = 0
text_bearing_response_speak_count = 0
for event in state.events:
if event.get("type") != "response.speak":
continue
response_id = state._event_response_id(event)
if not response_id or response_id not in state.response_ids:
invalid_response_speak_count += 1
else:
speak_counts[response_id] = speak_counts.get(response_id, 0) + 1
if "text" in event:
text_bearing_response_speak_count += 1
duplicate_response_speak_ids = sorted(response_id for response_id, count in speak_counts.items() if count > 1)
return {
"response_speak_contract_ok": not duplicate_response_speak_ids
and invalid_response_speak_count == 0
and text_bearing_response_speak_count == 0,
"duplicate_response_speak_ids": duplicate_response_speak_ids,
"invalid_response_speak_count": invalid_response_speak_count,
"text_bearing_response_speak_count": text_bearing_response_speak_count,
}
def _evaluate_transcript_integrity(
state: DemoState,
response_ids: list[str],
*,
expected_empty_response_ids: set[str],
require_cross_turn_independence: bool,
require_terminal_punctuation: bool = False,
) -> dict[str, object]:
details: list[dict[str, object]] = []
transcripts: list[str] = []
transcript_delta_done_ok = True
empty_turns_ok = True
nonempty_audio_has_transcript_ok = True
terminal_punctuation_ok = True
for response_id in response_ids:
transcript = state.response_transcript_delta(response_id)
done_transcripts = state.response_transcript_done(response_id)
canonical_transcript = _canonical_transcript(transcript)
response_delta_done = (
len(done_transcripts) == 1 and _canonical_transcript(done_transcripts[0]) == canonical_transcript
) or (not done_transcripts and not canonical_transcript)
expected_empty = response_id in expected_empty_response_ids
audio_delta_count = state.response_audio_delta_count(response_id)
response_empty_ok = not expected_empty or (not canonical_transcript and audio_delta_count == 0)
response_audio_has_transcript = expected_empty or audio_delta_count == 0 or bool(canonical_transcript)
response_terminal_punctuation_ok = (
expected_empty
or not canonical_transcript
or not require_terminal_punctuation
or _has_terminal_punctuation(canonical_transcript)
)
transcript_delta_done_ok = transcript_delta_done_ok and response_delta_done
empty_turns_ok = empty_turns_ok and response_empty_ok
nonempty_audio_has_transcript_ok = nonempty_audio_has_transcript_ok and response_audio_has_transcript
terminal_punctuation_ok = terminal_punctuation_ok and response_terminal_punctuation_ok
transcripts.append(canonical_transcript)
details.append(
{
"response_id": response_id,
"transcript": transcript,
"delta_done_ok": response_delta_done,
"expected_empty": expected_empty,
"empty_ok": response_empty_ok,
"audio_delta_count": audio_delta_count,
"audio_has_transcript": response_audio_has_transcript,
"terminal_punctuation_ok": response_terminal_punctuation_ok,
}
)
cross_turn_independent_ok = True
if require_cross_turn_independence:
for index, current in enumerate(transcripts):
for previous in transcripts[:index]:
if _reuses_previous_turn_tail(previous, current):
cross_turn_independent_ok = False
break
if not cross_turn_independent_ok:
break
return {
"transcript_delta_done_ok": transcript_delta_done_ok,
"cross_turn_independent_ok": cross_turn_independent_ok,
"empty_turns_ok": empty_turns_ok,
"nonempty_audio_has_transcript_ok": nonempty_audio_has_transcript_ok,
"terminal_punctuation_ok": terminal_punctuation_ok,
"transcript_integrity": details,
}
async def _reader(ws, state: DemoState, stop: asyncio.Event) -> None:
try:
while not stop.is_set():
raw = await ws.recv()
if not isinstance(raw, str):
continue
event = json.loads(raw)
if isinstance(event, dict):
state.add(event)
except ConnectionClosed:
return
async def _send_pcm16(
ws,
pcm16: bytes,
*,
chunk_ms: int,
realtime_delay: bool,
hints: dict[str, object] | None = None,
first_chunk_hints: dict[str, object] | None = None,
on_model_unit_ready=None,
frame_b64: str | None = None,
) -> None:
hints = hints or {}
first_chunk_hints = first_chunk_hints or {}
chunk_bytes = max(PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE * chunk_ms // 1000, PCM16_BYTES_PER_SAMPLE)
model_unit_bytes = PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE
audio_ms = 0
next_model_unit_bytes = model_unit_bytes
frames_sent = 0
for offset in range(0, len(pcm16), chunk_bytes):
chunk = pcm16[offset : offset + chunk_bytes]
duration_ms = int(len(chunk) / (PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE) * 1000)
audio_ms += duration_ms
chunk_hints = dict(hints)
if offset == 0:
chunk_hints.update(first_chunk_hints)
if frame_b64 is not None and audio_ms > frames_sent * 1000:
# Omni duplex cadence: one camera frame per 1 s of audio.
chunk_hints["video_frames"] = [frame_b64]
frames_sent += 1
await ws.send(
json.dumps(
{
"type": "input_audio_buffer.append",
"audio": base64.b64encode(chunk).decode("ascii"),
"input_audio_format": "pcm16",
"sample_rate_hz": PCM16_SAMPLE_RATE,
"duration_ms": duration_ms,
"audio_end_ms": audio_ms,
**chunk_hints,
}
)
)
while offset + len(chunk) >= next_model_unit_bytes:
next_model_unit_bytes += model_unit_bytes
if on_model_unit_ready is None:
continue
should_continue = on_model_unit_ready()
if inspect.isawaitable(should_continue):
should_continue = await should_continue
if should_continue is False:
return
if realtime_delay:
await asyncio.sleep(duration_ms / 1000)
async def _wait_for(
state: DemoState,
predicate,
*,
timeout_s: float,
label: str,
) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if predicate():
return
await asyncio.sleep(0.02)
raise TimeoutError(f"Timed out waiting for {label}")
async def _wait_for_model_policy_outcome(
state: DemoState,
*,
before_created: int,
before_model_listen: int,
timeout_s: float,
listen_settle_s: float,
label: str,
) -> str:
deadline = time.monotonic() + timeout_s
listen_deadline: float | None = None
while time.monotonic() < deadline:
if state.count("response.created") > before_created:
return "speak"
if state.model_listen_count > before_model_listen:
if listen_deadline is None:
listen_deadline = time.monotonic() + max(0.0, listen_settle_s)
if time.monotonic() >= listen_deadline:
return "listen"
await asyncio.sleep(0.02)
raise TimeoutError(f"Timed out waiting for {label}")
async def _send_clean_turn(
ws,
state: DemoState,
pcm16: bytes,
*,
transcript: str,
duration_ms: int | None,
chunk_ms: int,
timeout_s: float,
require_audio: bool,
validation_mode: str,
send_transcript_hint: bool = True,
realtime_input: bool = False,
model_policy_settle_s: float = 2.0,
commit_input: bool = True,
frame_b64: str | None = None,
) -> tuple[str | None, str]:
before_created = state.count("response.created")
before_model_listen = state.model_listen_count
def eligible_response_id() -> str | None:
for response_id in state.response_ids[before_created:]:
if not state.response_done(response_id):
continue
if require_audio and state.response_audio_delta_count(response_id) == 0:
continue
if require_audio and not _canonical_transcript(state.response_transcript_delta(response_id)):
continue
return response_id
return None
await _send_pcm16(
ws,
_select_turn_audio(pcm16, duration_ms),
chunk_ms=chunk_ms,
realtime_delay=realtime_input,
hints={"transcript": transcript} if send_transcript_hint else {},
frame_b64=frame_b64,
)
if commit_input:
state.input_commit_sent_at_s.append(time.monotonic())
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
if validation_mode == "model-policy":
outcome = await _wait_for_model_policy_outcome(
state,
before_created=before_created,
before_model_listen=before_model_listen,
timeout_s=timeout_s,
listen_settle_s=model_policy_settle_s,
label=f"{transcript} model speak/listen decision",
)
if outcome == "listen":
return None, "listen"
else:
await _wait_for(
state,
lambda: state.count("response.created") > before_created,
timeout_s=timeout_s,
label=f"{transcript} response.created",
)
await _wait_for(
state,
lambda: eligible_response_id() is not None,
timeout_s=timeout_s,
label=f"{transcript} completed response",
)
response_id = (
eligible_response_id()
if validation_mode == "response-required"
else (state.response_ids[before_created] if len(state.response_ids) > before_created else None)
)
if require_audio:
await _wait_for(
state,
lambda: state.response_audio_delta_count(response_id) > 0,
timeout_s=timeout_s,
label=f"{transcript} response.audio.delta",
)
await _wait_for(
state,
lambda: state.response_done(response_id),
timeout_s=timeout_s,
label=f"{transcript} response.done",
)
if not isinstance(response_id, str):
raise TimeoutError(f"Missing response id for {transcript}")
await _ack_response_playback(
ws,
state,
response_id,
timeout_s=timeout_s,
label=transcript,
)
return response_id, "speak"
async def _ack_response_playback(
ws,
state: DemoState,
response_id: str,
*,
timeout_s: float,
label: str,
) -> None:
played_ms = state.response_playback_sent_ms(response_id)
if played_ms <= 0:
return
before_ack = state.playback_ack_count
await ws.send(
json.dumps(
{
"type": "playback.ack",
"response_id": response_id,
"item_id": f"item_{response_id}",
"played_ms": played_ms,
"committed_ms": played_ms,
}
)
)
await _wait_for(
state,
lambda: state.playback_ack_count > before_ack and state.response_playback_history_committed(response_id),
timeout_s=timeout_s,
label=f"{label} playback history committed",
)
async def _ack_all_completed_response_playback(
ws,
state: DemoState,
*,
timeout_s: float,
) -> None:
for response_id in state.completed_response_ids():
if state.response_playback_sent_ms(response_id) <= 0:
continue
if state.response_playback_history_committed(response_id):
continue
await _ack_response_playback(
ws,
state,
response_id,
timeout_s=timeout_s,
label=response_id,
)
async def _drain_model_policy_responses(
ws,
state: DemoState,
*,
timeout_s: float,
settle_s: float,
) -> None:
deadline = time.monotonic() + timeout_s
settle_s = max(0.0, settle_s)
observed_created_count = state.count("response.created")
quiet_since: float | None = None
while True:
remaining_s = deadline - time.monotonic()
active_response_ids = [
response_id for response_id in state.response_ids if not state.response_done(response_id)
]
if remaining_s <= 0:
raise TimeoutError(f"Timed out draining model-policy responses; active response ids: {active_response_ids}")
await _ack_all_completed_response_playback(
ws,
state,
timeout_s=remaining_s,
)
now = time.monotonic()
created_count = state.count("response.created")
if created_count != observed_created_count:
observed_created_count = created_count
quiet_since = None
active_response_ids = [
response_id for response_id in state.response_ids if not state.response_done(response_id)
]
if active_response_ids:
quiet_since = None
else:
if quiet_since is None:
quiet_since = now
if now - quiet_since >= settle_s:
return
await asyncio.sleep(min(0.02, max(0.0, deadline - time.monotonic())))
def _event_index_for_response(state: DemoState, event_type: str, response_id: str) -> int | None:
return state.first_index(
event_type,
lambda event: state._event_response_id(event) == response_id,
)
def _nth_event_index(state: DemoState, event_type: str, occurrence: int) -> int | None:
seen = 0
for index, event in enumerate(state.events):
if event.get("type") != event_type:
continue
seen += 1
if seen == occurrence:
return index
return None
def _continuous_overlap_terminal_is_outcome(
state: DemoState,
*,
validation_mode: str,
model_unit_ready_while_active: bool,
before_created: int,
before_model_listen: int,
) -> bool:
"""Accept current-response termination as a model-policy overlap outcome."""
return (
validation_mode == "model-policy"
and model_unit_ready_while_active
and state.count("response.created") == before_created
and state.model_listen_count == before_model_listen
)
async def _send_listen_only_overlap_pair(
ws,
state: DemoState,
first_pcm16: bytes,
second_pcm16: bytes,
*,
transcripts: tuple[str, str],
durations_ms: tuple[int | None, int | None],
chunk_ms: int,
timeout_s: float,
send_transcript_hint: bool,
realtime_input: bool,
model_policy_settle_s: float,
validation_mode: str,
silence_ms: int,
frame_b64: str | None = None,
) -> tuple[list[str | None], list[str], bool]:
if not realtime_input:
raise ValueError("listen-only-overlap requires --realtime-input")
before_first_created = state.count("response.created")
observed_first_created = before_first_created
observed_first_listen = state.model_listen_count
async def continue_until_first_speak() -> bool:
nonlocal observed_first_created, observed_first_listen
outcome = await _wait_for_model_policy_outcome(
state,
before_created=observed_first_created,
before_model_listen=observed_first_listen,
timeout_s=timeout_s,
listen_settle_s=model_policy_settle_s,
label=f"{transcripts[0]} model-unit speak/listen decision",
)
observed_first_created = state.count("response.created")
observed_first_listen = state.model_listen_count
return outcome != "speak"
first_input = _select_turn_audio(first_pcm16, durations_ms[0]) + _pcm16_silence(silence_ms)
await _send_pcm16(
ws,
first_input,
chunk_ms=chunk_ms,
realtime_delay=True,
hints={"transcript": transcripts[0]} if send_transcript_hint else {},
on_model_unit_ready=continue_until_first_speak,
frame_b64=frame_b64,
)
state.input_commit_sent_at_s.append(time.monotonic())
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
await _wait_for(
state,
lambda: state.count("response.created") > before_first_created,
timeout_s=timeout_s,
label=f"{transcripts[0]} response.created",
)
first_response_id = state.response_ids[before_first_created]
await _wait_for(
state,
lambda: state.response_audio_delta_count(first_response_id) > 0,
timeout_s=timeout_s,
label=f"{transcripts[0]} response.audio.delta",
)
if state.response_done(first_response_id):
raise RuntimeError("first response completed before overlap input could start")
before_second_created = state.count("response.created")
before_second_listen = state.model_listen_count
overlap_started_while_active = not state.response_done(first_response_id)
model_unit_ready_while_active = False
second_unit_event_index: int | None = None
def record_model_unit_ready() -> None:
nonlocal model_unit_ready_while_active, second_unit_event_index
model_unit_ready_while_active = not state.response_done(first_response_id)
if model_unit_ready_while_active:
second_unit_event_index = len(state.events)
await _send_pcm16(
ws,
_select_turn_audio(second_pcm16, durations_ms[1]),
chunk_ms=chunk_ms,
realtime_delay=True,
hints={"transcript": transcripts[1]} if send_transcript_hint else {},
on_model_unit_ready=record_model_unit_ready,
frame_b64=frame_b64,
)
state.input_commit_sent_at_s.append(time.monotonic())
await ws.send(json.dumps({"type": "input_audio_buffer.commit", "final": True}))
await _wait_for(
state,
lambda: state.response_done(first_response_id),
timeout_s=timeout_s,
label=f"{transcripts[0]} response.done",
)
await _ack_response_playback(
ws,
state,
first_response_id,
timeout_s=timeout_s,
label=transcripts[0],
)
if _continuous_overlap_terminal_is_outcome(
state,
validation_mode=validation_mode,
model_unit_ready_while_active=model_unit_ready_while_active,
before_created=before_second_created,
before_model_listen=before_second_listen,
):
second_outcome = "continuous_terminal"
else:
second_outcome = await _wait_for_model_policy_outcome(
state,
before_created=before_second_created,
before_model_listen=before_second_listen,
timeout_s=timeout_s,
listen_settle_s=model_policy_settle_s,
label=f"{transcripts[1]} model speak/listen decision",
)
second_response_id: str | None = None
if second_outcome == "speak":
second_response_id = state.response_ids[before_second_created]
await _wait_for(
state,
lambda: state.response_done(second_response_id),
timeout_s=timeout_s,
label=f"{transcripts[1]} response.done",
)
await _ack_response_playback(
ws,
state,
second_response_id,
timeout_s=timeout_s,
label=transcripts[1],
)
first_done_index = _event_index_for_response(state, "response.done", first_response_id)
overlap_ok = (
overlap_started_while_active
and model_unit_ready_while_active
and second_unit_event_index is not None
and first_done_index is not None
and second_unit_event_index < first_done_index
)
return [first_response_id, second_response_id], ["speak", second_outcome], overlap_ok
async def run_demo(args: argparse.Namespace) -> dict[str, object]:
demo_frame_b64: str | None = None
if getattr(args, "frame_image", None):
demo_frame_b64 = base64.b64encode(Path(args.frame_image).read_bytes()).decode("ascii")
turn_input_paths = _turn_input_paths(
Path(args.input_wav),
list(getattr(args, "turn_input_wav", []) or []),
turns=args.turns,
)
turn_pcm16 = [_read_wav_pcm16(path) for path in turn_input_paths]
if any(not pcm16 for pcm16 in turn_pcm16):
raise ValueError("input WAV has no audio")
turn_durations = _turn_durations(
list(getattr(args, "turn_duration_ms", []) or []),
turns=args.turns,
first_turn_ms=args.first_turn_ms,
)
expected_empty_turns = set(getattr(args, "expect_empty_turn", []) or [])
invalid_empty_turns = sorted(
turn_number for turn_number in expected_empty_turns if turn_number < 1 or turn_number > max(1, args.turns)
)
if invalid_empty_turns:
raise ValueError(
f"--expect-empty-turn values are 1-based and must refer to an existing turn: {invalid_empty_turns}"
)
distinct_turn_inputs = _turn_inputs_are_distinct(turn_input_paths, turn_pcm16)
if getattr(args, "require_distinct_inputs", False) and not distinct_turn_inputs:
raise ValueError("--require-distinct-inputs requires a different WAV path and audio payload for every turn")
url = _url_with_model(
args.url,
args.model,
autostart=False if getattr(args, "ref_audio", None) else None,
session_id=getattr(args, "session_id", None),
)
state = DemoState()
stop = asyncio.Event()
output_dir = Path(args.output_dir)
turn_response_ids: list[str | None] = []
turn_outcomes: list[str] = []
validation_mode = getattr(args, "validation_mode", "response-required")
scenario = getattr(args, "scenario", "sequential")
transcript_hints_enabled = not getattr(args, "omit_transcript_hints", False)
realtime_input = getattr(args, "realtime_input", False)
continuous_input = getattr(args, "continuous_input", False)
listen_only_overlap_ok = scenario != "listen-only-overlap"
if scenario == "listen-only-overlap" and args.turns < 2:
raise ValueError("listen-only-overlap requires at least two turns")
async with websockets.connect(url, max_size=64 * 1024 * 1024) as ws:
reader = asyncio.create_task(_reader(ws, state, stop))
try:
await ws.send(json.dumps(_session_update_event(args)))
await _wait_for(state, lambda: state.count("session.created") > 0, timeout_s=20, label="session.created")
turn_transcripts = _turn_transcripts(args.first_turn_transcript, turns=args.turns)
turn_specs = list(zip(turn_transcripts, turn_durations, strict=True))
if scenario == "listen-only-overlap":
turn_response_ids, turn_outcomes, listen_only_overlap_ok = await _send_listen_only_overlap_pair(
ws,
state,
turn_pcm16[0],
turn_pcm16[1],
transcripts=(turn_specs[0][0], turn_specs[1][0]),
durations_ms=(turn_specs[0][1], turn_specs[1][1]),
chunk_ms=args.chunk_ms,
timeout_s=args.timeout_s,
send_transcript_hint=transcript_hints_enabled,
realtime_input=realtime_input,
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
validation_mode=validation_mode,
silence_ms=max(0, args.silence_ms),
frame_b64=demo_frame_b64,
)
for turn_index in range(2, len(turn_specs)):
transcript, duration_ms = turn_specs[turn_index]
response_id, outcome = await _send_clean_turn(
ws,
state,
turn_pcm16[turn_index],
transcript=transcript,
duration_ms=duration_ms,
chunk_ms=args.chunk_ms,
timeout_s=args.timeout_s,
require_audio=args.require_audio and (turn_index + 1) not in expected_empty_turns,
validation_mode=validation_mode,
send_transcript_hint=transcript_hints_enabled,
realtime_input=realtime_input,
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
commit_input=not continuous_input,
frame_b64=demo_frame_b64,
)
turn_response_ids.append(response_id)
turn_outcomes.append(outcome)
else:
for turn_index, (transcript, duration_ms) in enumerate(turn_specs):
response_id, outcome = await _send_clean_turn(
ws,
state,
turn_pcm16[turn_index],
transcript=transcript,
duration_ms=duration_ms,
chunk_ms=args.chunk_ms,
timeout_s=args.timeout_s,
require_audio=args.require_audio and (turn_index + 1) not in expected_empty_turns,
validation_mode=validation_mode,
send_transcript_hint=transcript_hints_enabled,
realtime_input=realtime_input,
model_policy_settle_s=max(0.0, args.model_policy_settle_ms / 1000),
commit_input=not continuous_input,
frame_b64=demo_frame_b64,
)
turn_response_ids.append(response_id)
turn_outcomes.append(outcome)
if validation_mode == "model-policy":
await _drain_model_policy_responses(
ws,
state,
timeout_s=args.timeout_s,
settle_s=max(0.0, args.model_policy_settle_ms / 1000),
)
await _ack_all_completed_response_playback(
ws,
state,
timeout_s=args.timeout_s,
)
await ws.send(json.dumps({"type": "session.close"}))
await _wait_for(state, lambda: state.count("session.closed") > 0, timeout_s=20, label="session.closed")
finally:
if state.count("session.created") > 0 and state.count("session.closed") == 0:
try:
await ws.send(json.dumps({"type": "session.close"}))
await _wait_for(
state,
lambda: state.count("session.closed") > 0,
timeout_s=min(args.timeout_s, 5),
label="session.closed during cleanup",
)
except (ConnectionClosed, TimeoutError):
pass
stop.set()
reader.cancel()
try:
await reader
except asyncio.CancelledError:
pass
_write_demo_artifacts(state, output_dir, output_audio_format=args.output_audio_format)
overlap_barge_in = any(decision.get("action") == "barge_in" for decision in state.overlap_decisions)
expected_turns = max(1, args.turns)
event_order_ok = (
state.model_policy_event_order_ok(
expected_turns=expected_turns,
require_input_commit=not continuous_input,
)
if validation_mode == "model-policy"
else state.event_order_ok(
require_input_commit=not continuous_input,
require_commit_before_response=False,
)
)
input_transcription_ok = _input_transcription_ok(
state.input_transcription_count,
transcript_hints_enabled=transcript_hints_enabled and not continuous_input,
)
model_speak_event_ok = state.model_speak_before_audio_ok()
realtime_audio_lifecycle_ok = state.count("response.audio.delta") > 0 and state.count("response.audio.done") > 0
completed_response_ids = state.completed_response_ids()
observed_turn_response_ids = [response_id for response_id in turn_response_ids if isinstance(response_id, str)]
expected_empty_response_ids = {
response_id
for turn_number in expected_empty_turns
if turn_number <= len(turn_response_ids)
and isinstance((response_id := turn_response_ids[turn_number - 1]), str)
}
transcript_integrity = _evaluate_transcript_integrity(
state,
observed_turn_response_ids,
expected_empty_response_ids=expected_empty_response_ids,
require_cross_turn_independence=_requires_cross_turn_independence(
validation_mode=validation_mode,
distinct_inputs_required=getattr(args, "require_distinct_inputs", False),
),
require_terminal_punctuation=getattr(args, "require_distinct_inputs", False),
)
response_speak_contract = _evaluate_response_speak_contract(state)
expected_audio_turns = expected_turns - len(expected_empty_turns)
lifecycle_counts_ok = (
state.count("response.created") == state.count("response.done")
and len(state.response_ids) == len(completed_response_ids)
and len(completed_response_ids) == len(set(completed_response_ids))
and state.count("response.audio.done") <= state.count("response.done")
and _response_cardinality_ok(
completed_response_ids,
expected_turns=expected_turns,
validation_mode=validation_mode,
)
)
turn_outcomes_ok = len(turn_outcomes) == expected_turns and (
validation_mode == "model-policy" or all(outcome == "speak" for outcome in turn_outcomes)
)
clean_turn_audio_ok = all(
state.response_audio_delta_count(response_id) > 0
for response_id in observed_turn_response_ids
if response_id not in expected_empty_response_ids
)
playback_history_committed_ok = _all_response_playback_history_committed(
state,
completed_response_ids,
)
if validation_mode == "response-required":
full_audio_response_ok = (
len(observed_turn_response_ids) == expected_audio_turns + len(expected_empty_turns)
and clean_turn_audio_ok
and model_speak_event_ok
and state.model_speak_delta_count > 0
and realtime_audio_lifecycle_ok
and state.audio_marks_seen
)
else:
full_audio_response_ok = not observed_turn_response_ids or (
realtime_audio_lifecycle_ok and state.model_speak_delta_count > 0
)
stale_audio_delta_count = state.stale_audio_delta_count()
terminal_activity_ok = state.count("response.done") > 0 or (
validation_mode == "model-policy" and state.model_listen_count > 0
)
all_audio_responses_have_transcript = (
validation_mode != "response-required"
or _all_audio_responses_have_transcript(
state,
completed_response_ids,
)
)
unexpected_error_events = _unexpected_error_events(state)
continuous_input_ok = not continuous_input or state.count("input_audio_buffer.committed") == 0
result = {
"ok": terminal_activity_ok
and state.count("session.closed") > 0
and state.cancelled_count == 0
and not overlap_barge_in
and state.truncate_count == 0
and event_order_ok
and input_transcription_ok
and stale_audio_delta_count == 0
and lifecycle_counts_ok
and turn_outcomes_ok
and full_audio_response_ok,
"event_counts": {
event_type: state.count(event_type)
for event_type in sorted({str(event.get("type")) for event in state.events})
},
"audio_delta_count": len(state.audio_deltas),
"done_count": state.done_count,
"cancelled_count": state.cancelled_count,
"listen_count": state.listen_count,
"model_listen_count": state.model_listen_count,
"buffering_listen_count": state.buffering_listen_count,
"model_speak_event_count": state.model_speak_event_count,
"model_speak_delta_count": state.model_speak_delta_count,
"playback_ack_count": state.playback_ack_count,
"playback_history_committed_count": state.playback_history_committed_count,
"playback_history_committed_ok": playback_history_committed_ok,
"truncate_count": state.truncate_count,
"input_transcription_count": state.input_transcription_count,
"audio_marks_seen": state.audio_marks_seen,
"overlap_decisions": state.overlap_decisions,
"overlap_barge_in": overlap_barge_in,
"event_order_ok": event_order_ok,
"terminal_activity_ok": terminal_activity_ok,
"input_transcription_ok": input_transcription_ok,
"completed_response_ids": completed_response_ids,
"response_timings": state.response_timing_summaries(),
"request_metrics": state.session_request_metrics(session_id=args.session_id),
"session_metrics": state.session_metric_summary(session_id=args.session_id),
"lifecycle_counts_ok": lifecycle_counts_ok,
"validation_mode": validation_mode,
"scenario": scenario,
"listen_only_overlap_ok": listen_only_overlap_ok,
"transcript_hints_enabled": transcript_hints_enabled,
"realtime_input": realtime_input,
"continuous_input": continuous_input,
"continuous_input_ok": continuous_input_ok,
"input_chunk_ms": args.chunk_ms,
"model_policy_settle_ms": args.model_policy_settle_ms,
"turn_outcomes": turn_outcomes,
"turn_outcomes_ok": turn_outcomes_ok,
"clean_turn_audio_ok": clean_turn_audio_ok,
"full_audio_response_ok": full_audio_response_ok,
"model_speak_event_ok": model_speak_event_ok,
"realtime_audio_lifecycle_ok": realtime_audio_lifecycle_ok,
"stale_audio_delta_count": stale_audio_delta_count,
"all_audio_responses_have_transcript": all_audio_responses_have_transcript,
"distinct_turn_inputs": distinct_turn_inputs,
"error_count": len(unexpected_error_events),
"errors": unexpected_error_events,
**response_speak_contract,
**transcript_integrity,
"turn_inputs": [
{
"path": str(path),
"source_duration_ms": len(pcm16) * 1000 // (PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE),
"requested_duration_ms": duration_ms,
"sent_duration_ms": len(_select_turn_audio(pcm16, duration_ms))
* 1000
// (PCM16_SAMPLE_RATE * PCM16_BYTES_PER_SAMPLE),
}
for path, pcm16, duration_ms in zip(turn_input_paths, turn_pcm16, turn_durations, strict=True)
],
"output_dir": str(output_dir),
}
result["ok"] = bool(
result["ok"]
and transcript_integrity["transcript_delta_done_ok"]
and transcript_integrity["cross_turn_independent_ok"]
and transcript_integrity["empty_turns_ok"]
and transcript_integrity["nonempty_audio_has_transcript_ok"]
and transcript_integrity["terminal_punctuation_ok"]
and all_audio_responses_have_transcript
and playback_history_committed_ok
and listen_only_overlap_ok
and not unexpected_error_events
and response_speak_contract["response_speak_contract_ok"]
and continuous_input_ok
and (distinct_turn_inputs or not getattr(args, "require_distinct_inputs", False))
)
return result
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("--url", default="ws://localhost:8099/v1/realtime?duplex=1")
parser.add_argument("--model", default="openbmb/MiniCPM-o-4_5")
parser.add_argument("--session-id", help="Use an explicit public session ID, including for close/reopen tests.")
parser.add_argument("--input-wav", required=True)
parser.add_argument("--ref-audio", help="Optional WAV used as the MiniCPM-o voice prompt.")
parser.add_argument(
"--frame-image",
default=None,
help="Optional image file sent as an omni-duplex camera frame (one per 1 s of audio)",
)
parser.add_argument(
"--turn-input-wav",
action="append",
default=[],
help="WAV for each turn after the first; repeat exactly turns-1 times.",
)
parser.add_argument("--output-dir", default="/tmp/minicpmo_realtime_duplex_demo")
parser.add_argument(
"--output-audio-format",
default="pcm16",
choices=["pcm16", "wav", "g711_ulaw", "g711_alaw"],
)
parser.add_argument("--chunk-ms", type=int, default=200)
parser.add_argument(
"--model-policy-settle-ms",
type=int,
default=2000,
help="Wait for a delayed response.created after model-listen and before closing the session.",
)
parser.add_argument("--first-turn-ms", type=int, default=1400)
parser.add_argument(
"--turn-duration-ms",
action="append",
type=int,
default=[],
help="Audio duration for each turn; repeat turns times. Use 0 to send the complete WAV.",
)
parser.add_argument("--first-turn-transcript", default="demo input speech")
parser.add_argument(
"--omit-transcript-hints",
action="store_true",
help="Keep local turn labels but do not send transcript hints to the server.",
)
parser.add_argument(
"--realtime-input",
action="store_true",
help="Pace each input chunk according to its audio duration.",
)
parser.add_argument(
"--continuous-input",
action="store_true",
help="Model-owned browser mode: stream PCM continuously without input_audio_buffer.commit.",
)
parser.add_argument(
"--scenario",
choices=["sequential", "listen-only-overlap"],
default="sequential",
help="Run sequential turns or stream turn 2 while turn 1 audio is active without interruption.",
)
parser.add_argument(
"--validation-mode",
choices=["model-policy", "response-required"],
default="response-required",
help="Allow model listen decisions, or require at least one response for every input turn.",
)
parser.add_argument(
"--temperature",
type=float,
default=None,
help="Stage0 sampling temperature; response-required defaults to deterministic sampling.",
)
parser.add_argument(
"--require-distinct-inputs",
action="store_true",
help="Require distinct WAV paths and audio payloads, and reject cross-turn transcript reuse.",
)
parser.add_argument(
"--expect-empty-turn",
action="append",
type=int,
default=[],
help="1-based turn expected to end without text or audio; repeat for multiple turns.",
)
parser.add_argument("--short-ack-ms", type=int, default=350)
parser.add_argument("--silence-ms", type=int, default=500)
parser.add_argument("--playback-ack-ms", type=int, default=500)
parser.add_argument("--turns", type=int, default=3)
parser.add_argument("--timeout-s", type=float, default=60.0)
parser.add_argument(
"--require-audio",
action="store_true",
help="Fail if the first native response listens instead of producing audio.",
)
return parser.parse_args()
def main() -> None:
result = asyncio.run(run_demo(parse_args()))
print(json.dumps(result, ensure_ascii=False, indent=2))
if not result["ok"]:
raise SystemExit(1)
if __name__ == "__main__":
main()