Signed-off-by: R2-Y <ruiruyang2@gmail.com> Signed-off-by: psv666 <2693925048@qq.com> Co-authored-by: psv666 <2693925048@qq.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: amy-why-3459 <wuhaiyan17@huawei.com>
1737 lines
68 KiB
Python
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()
|