Files
vllm-omni/tests/examples/test_minicpmo_realtime_web.py
T

1663 lines
55 KiB
Python
Raw Normal View History

import asyncio
import base64
import importlib.util
import subprocess
import sys
import wave
from pathlib import Path
from types import SimpleNamespace
from urllib.parse import parse_qs, urlsplit
import pytest
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
DEMO_PATH = Path(__file__).resolve().parents[1] / "e2e/online_serving/minicpmo_realtime_duplex_scenarios.py"
MULTI_DEMO_PATH = (
Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_multi_session.py"
)
PAIR_DEMO_PATH = Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_demo_pair.py"
SOFT_INTERRUPT_DEMO_PATH = (
Path(__file__).resolve().parents[1] / "e2e/online_serving/run_minicpmo_realtime_duplex_soft_interrupt.py"
)
def _load_demo_module():
spec = importlib.util.spec_from_file_location("minicpmo_realtime_duplex_demo_test", DEMO_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _load_multi_demo_module():
spec = importlib.util.spec_from_file_location("minicpmo_realtime_duplex_multi_session_test", MULTI_DEMO_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _load_pair_demo_module():
spec = importlib.util.spec_from_file_location("minicpmo_realtime_duplex_demo_pair_test", PAIR_DEMO_PATH)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def _load_soft_interrupt_demo_module():
spec = importlib.util.spec_from_file_location(
"minicpmo_realtime_duplex_soft_interrupt_test",
SOFT_INTERRUPT_DEMO_PATH,
)
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
def test_realtime_duplex_multi_session_script_is_directly_executable():
result = subprocess.run(
[sys.executable, str(MULTI_DEMO_PATH), "--help"],
cwd=MULTI_DEMO_PATH.parents[3],
capture_output=True,
text=True,
timeout=20,
check=False,
)
assert result.returncode == 0, result.stderr
def test_realtime_duplex_demo_pair_script_is_directly_executable():
result = subprocess.run(
[sys.executable, str(PAIR_DEMO_PATH), "--help"],
cwd=PAIR_DEMO_PATH.parents[3],
capture_output=True,
text=True,
timeout=20,
check=False,
)
assert result.returncode == 0, result.stderr
def test_realtime_duplex_soft_interrupt_script_is_directly_executable():
result = subprocess.run(
[sys.executable, str(SOFT_INTERRUPT_DEMO_PATH), "--help"],
cwd=SOFT_INTERRUPT_DEMO_PATH.parents[3],
capture_output=True,
text=True,
timeout=20,
check=False,
)
assert result.returncode == 0, result.stderr
def test_realtime_duplex_demo_pair_requires_distinct_inputs_and_outputs(tmp_path):
demo = _load_pair_demo_module()
wav_a = tmp_path / "request_a.wav"
wav_b = tmp_path / "request_b.wav"
wav_a.write_bytes(b"same")
wav_b.write_bytes(b"same")
args = SimpleNamespace(
input_wav_a=str(wav_a),
input_wav_b=str(wav_b),
output_dir_a=str(tmp_path / "session_a"),
output_dir_b=str(tmp_path / "session_a"),
)
with pytest.raises(ValueError, match="output directories must be different"):
demo._validate_pair_args(args)
args.output_dir_b = str(tmp_path / "session_b")
with pytest.raises(ValueError, match="input WAV files must have different content"):
demo._validate_pair_args(args)
def test_realtime_duplex_demo_pair_launches_demo_processes_concurrently(tmp_path, monkeypatch):
demo = _load_pair_demo_module()
fake_demo = tmp_path / "fake_realtime_duplex_demo.py"
start_log = tmp_path / "starts.log"
fake_demo.write_text(
"\n".join(
[
"import argparse, json, os, time",
"from pathlib import Path",
"parser = argparse.ArgumentParser()",
"parser.add_argument('--url')",
"parser.add_argument('--model')",
"parser.add_argument('--input-wav')",
"parser.add_argument('--output-dir')",
"parser.add_argument('--chunk-ms')",
"parser.add_argument('--timeout-s')",
"parser.add_argument('--session-id')",
"parser.add_argument('--ref-audio')",
"parser.add_argument('--require-audio', action='store_true')",
"parser.add_argument('--no-realtime-pacing', action='store_true')",
"args = parser.parse_args()",
"if args.ref_audio != os.environ.get('EXPECTED_REF_AUDIO'):",
" raise SystemExit(f'unexpected ref audio: {args.ref_audio}')",
"output = Path(args.output_dir)",
"output.mkdir(parents=True, exist_ok=True)",
"response_id = 'resp-' + output.name",
"with open(os.environ['PAIR_START_LOG'], 'a', encoding='utf-8') as f:",
" f.write(f'{output.name} {time.time()}\\n')",
"time.sleep(0.25)",
"events = [",
" {'type': 'session.created'},",
" {'type': 'response.created', 'response': {'id': response_id}},",
" {'type': 'response.audio.delta', 'response_id': response_id, "
"'delta': 'AAAA', 'sample_rate_hz': 24000, '_client_received_at_s': 10.1},",
" {'type': 'response.audio.delta', 'response_id': response_id, "
"'delta': 'AAAA', 'sample_rate_hz': 24000, '_client_received_at_s': 10.6},",
" {'type': 'response.done', 'response_id': response_id},",
" {'type': 'session.closed'},",
"]",
"Path(output / 'events.jsonl').write_text(",
" ''.join(json.dumps(event) + '\\n' for event in events),",
" encoding='utf-8',",
")",
"result = {'ok': True, 'response_ids': [response_id], 'audio_bytes': 1, 'output_dir': str(output)}",
"Path(output / 'result.json').write_text(json.dumps(result), encoding='utf-8')",
"print(json.dumps(result))",
]
),
encoding="utf-8",
)
monkeypatch.setattr(demo, "DEMO_PATH", fake_demo)
monkeypatch.setenv("PAIR_START_LOG", str(start_log))
monkeypatch.setenv("EXPECTED_REF_AUDIO", str(tmp_path / "ref.wav"))
wav_a = tmp_path / "request_a.wav"
wav_b = tmp_path / "request_b.wav"
wav_a.write_bytes(b"request a")
wav_b.write_bytes(b"request b")
result = asyncio.run(
demo.run_pair(
SimpleNamespace(
url="ws://localhost:28889/v1/realtime?duplex=1",
model="/data/why/MiniCPM-o-4_5",
input_wav_a=str(wav_a),
input_wav_b=str(wav_b),
output_dir_a=str(tmp_path / "duplex_session_a"),
output_dir_b=str(tmp_path / "duplex_session_b"),
ref_audio=str(tmp_path / "ref.wav"),
summary_output=str(tmp_path / "summary.json"),
chunk_ms=200,
timeout_s=5.0,
no_realtime_pacing=False,
require_audio=True,
min_audio_deltas_per_session=2,
)
)
)
starts = [float(line.split()[1]) for line in start_log.read_text(encoding="utf-8").splitlines()]
assert len(starts) == 2
assert max(starts) - min(starts) < 0.2
assert result["ok"] is True
assert result["input_wavs_distinct"] is True
assert result["output_dirs_distinct"] is True
assert result["identity_isolation_ok"] is True
assert result["response_audio_contract_ok"] is True
assert {session["audio_delta_count"] for session in result["sessions"]} == {2}
for session in result["sessions"]:
assert session["all_audio_deltas_same_response"] is True
assert session["response_id_nonempty"] is True
assert session["first_audio_delta_before_done"] is True
assert len(session["audio_delta_timings"]) == 2
def test_realtime_duplex_demo_pair_rejects_false_green_audio_contract(tmp_path):
demo = _load_pair_demo_module()
output = tmp_path / "session"
output.mkdir()
response_id = "resp-a"
events = [
{"type": "response.created", "response": {"id": response_id}},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "sample_rate_hz": 24000},
{"type": "response.audio.delta", "response_id": "resp-other", "delta": "AAAA", "sample_rate_hz": 24000},
{"type": "response.done", "response_id": response_id},
]
(output / "events.jsonl").write_text(
"".join(demo.json.dumps(event) + "\n" for event in events),
encoding="utf-8",
)
(output / "result.json").write_text(
demo.json.dumps({"ok": True, "response_ids": [response_id], "audio_bytes": 4}),
encoding="utf-8",
)
session = demo._summarize_session(
label="a",
input_wav="request.wav",
output_dir=str(output),
returncode=0,
stdout="",
stderr="",
)
assert session["ok"] is False
assert session["all_audio_deltas_same_response"] is False
assert session["response_audio_contract_ok"] is False
def test_realtime_duplex_demo_pair_default_requires_multiple_audio_deltas(monkeypatch):
demo = _load_pair_demo_module()
monkeypatch.setattr(
demo.sys,
"argv",
[
"pair.py",
"--input-wav-a",
"a.wav",
"--input-wav-b",
"b.wav",
"--output-dir-a",
"a",
"--output-dir-b",
"b",
"--ref-audio",
"ref.wav",
],
)
args = demo.parse_args()
assert args.ref_audio == "ref.wav"
assert args.min_audio_deltas_per_session == 2
def test_realtime_duplex_demo_pair_requires_ref_audio(monkeypatch):
demo = _load_pair_demo_module()
monkeypatch.setattr(
demo.sys,
"argv",
[
"pair.py",
"--input-wav-a",
"a.wav",
"--input-wav-b",
"b.wav",
"--output-dir-a",
"a",
"--output-dir-b",
"b",
],
)
with pytest.raises(SystemExit):
demo.parse_args()
def test_realtime_duplex_soft_interrupt_requires_ref_audio(monkeypatch):
demo = _load_soft_interrupt_demo_module()
monkeypatch.setattr(
demo.sys,
"argv",
[
"soft.py",
"--input-wav",
"input.wav",
"--output-dir",
"out",
],
)
with pytest.raises(SystemExit):
demo.parse_args()
def test_realtime_duplex_soft_interrupt_accepts_explicit_ref_audio(monkeypatch):
demo = _load_soft_interrupt_demo_module()
monkeypatch.setattr(
demo.sys,
"argv",
[
"soft.py",
"--input-wav",
"input.wav",
"--output-dir",
"out",
"--ref-audio",
"ref.wav",
],
)
args = demo.parse_args()
assert args.ref_audio == "ref.wav"
assert args.validation_mode == "model-policy"
@pytest.mark.parametrize(
"extra_args",
[
["--expect-second-response-substring", "一加一等于二"],
["--input-sha256", "a" * 64],
],
)
def test_realtime_duplex_soft_interrupt_response_required_needs_bound_fixture(monkeypatch, extra_args):
demo = _load_soft_interrupt_demo_module()
monkeypatch.setattr(
demo.sys,
"argv",
[
"soft.py",
"--input-wav",
"input.wav",
"--output-dir",
"out",
"--ref-audio",
"ref.wav",
"--validation-mode",
"response-required",
*extra_args,
],
)
with pytest.raises(SystemExit):
demo.parse_args()
def test_realtime_duplex_soft_interrupt_rejects_input_checksum_mismatch(tmp_path):
demo = _load_soft_interrupt_demo_module()
input_wav = tmp_path / "input.wav"
input_wav.write_bytes(b"two-turn fixture")
with pytest.raises(ValueError, match="input WAV SHA256 mismatch"):
demo._validate_input_sha256(input_wav, "0" * 64)
def test_realtime_duplex_soft_interrupt_accepts_multi_delta_handoff_sequence(tmp_path):
demo = _load_soft_interrupt_demo_module()
output = tmp_path / "soft_interrupt"
output.mkdir()
first_response_id = "resp-first"
second_response_id = "resp-second"
events = [
{"type": "response.listen", "_client_received_at_s": 1.0},
{"type": "response.created", "response": {"id": first_response_id}, "_client_received_at_s": 2.0},
{"type": "response.speak", "response_id": first_response_id, "_client_received_at_s": 2.0},
{
"type": "response.audio.delta",
"response_id": first_response_id,
"delta": "AAAA",
"_client_received_at_s": 2.1,
},
{
"type": "response.audio_transcript.delta",
"response_id": first_response_id,
"delta": "中国古代四大发明",
"_client_received_at_s": 2.1,
},
{
"type": "response.audio.delta",
"response_id": first_response_id,
"delta": "AAAA",
"_client_received_at_s": 2.6,
},
{
"type": "response.audio_transcript.delta",
"response_id": first_response_id,
"delta": "是造纸术。",
"_client_received_at_s": 2.6,
},
{"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 3.0},
{"type": "response.listen", "_client_received_at_s": 3.2},
{"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 4.0},
{"type": "response.speak", "response_id": second_response_id, "_client_received_at_s": 4.0},
{
"type": "response.audio.delta",
"response_id": second_response_id,
"delta": "AAAA",
"_client_received_at_s": 4.1,
},
{
"type": "response.audio_transcript.delta",
"response_id": second_response_id,
"delta": "一加一等于",
"_client_received_at_s": 4.1,
},
{
"type": "response.audio.delta",
"response_id": second_response_id,
"delta": "AAAA",
"_client_received_at_s": 4.6,
},
{
"type": "response.audio_transcript.delta",
"response_id": second_response_id,
"delta": "二。",
"_client_received_at_s": 4.6,
},
{"type": "response.done", "response_id": second_response_id, "_client_received_at_s": 5.0},
{"type": "response.listen", "_client_received_at_s": 5.2},
{"type": "input_audio_buffer.committed", "_client_received_at_s": 6.0},
{"type": "response.listen", "_client_received_at_s": 6.1},
]
(output / "events.jsonl").write_text(
"".join(demo.json.dumps(event) + "\n" for event in events),
encoding="utf-8",
)
(output / "result.json").write_text(
demo.json.dumps({"ok": True, "response_ids": [first_response_id, second_response_id]}),
encoding="utf-8",
)
summary = demo.summarize_artifacts(
output_dir=output,
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二",
)
assert summary["ok"] is True
assert summary["second_response_before_final_commit"] is True
assert summary["final_listen_after_commit"] is True
assert summary["response_audio_contract_ok"] is True
assert summary["response_summaries"][0]["audio_delta_count"] == 2
assert summary["response_summaries"][1]["transcript"] == "一加一等于二。"
def test_realtime_duplex_soft_interrupt_model_policy_accepts_single_response(tmp_path):
demo = _load_soft_interrupt_demo_module()
output = tmp_path / "model_policy"
output.mkdir()
response_id = "resp-only"
events = [
{"type": "response.listen", "_client_received_at_s": 1.0},
{"type": "response.created", "response": {"id": response_id}, "_client_received_at_s": 2.0},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.1},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.15},
{"type": "response.done", "response_id": response_id, "_client_received_at_s": 2.2},
{"type": "response.listen", "_client_received_at_s": 2.3},
{"type": "input_audio_buffer.committed", "_client_received_at_s": 3.0},
{"type": "response.listen", "_client_received_at_s": 3.1},
]
(output / "events.jsonl").write_text(
"".join(demo.json.dumps(event) + "\n" for event in events),
encoding="utf-8",
)
(output / "result.json").write_text(
demo.json.dumps({"ok": True, "response_ids": [response_id]}),
encoding="utf-8",
)
summary = demo.summarize_artifacts(
output_dir=output,
validation_mode="model-policy",
min_responses=2,
min_audio_deltas_per_response=2,
expect_second_response_substring=None,
)
assert summary["ok"] is True
assert summary["validation_mode"] == "model-policy"
assert summary["enough_responses"] is True
assert summary["response_before_final_commit"] is True
assert summary["listen_after_response_before_commit"] is True
def test_realtime_duplex_soft_interrupt_response_required_rejects_single_response(tmp_path):
demo = _load_soft_interrupt_demo_module()
output = tmp_path / "response_required"
output.mkdir()
response_id = "resp-only"
events = [
{"type": "response.listen", "_client_received_at_s": 1.0},
{"type": "response.created", "response": {"id": response_id}, "_client_received_at_s": 2.0},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.1},
{"type": "response.audio.delta", "response_id": response_id, "delta": "AAAA", "_client_received_at_s": 2.15},
{"type": "response.done", "response_id": response_id, "_client_received_at_s": 2.2},
{"type": "response.listen", "_client_received_at_s": 2.3},
{"type": "input_audio_buffer.committed", "_client_received_at_s": 3.0},
{"type": "response.listen", "_client_received_at_s": 3.1},
]
(output / "events.jsonl").write_text(
"".join(demo.json.dumps(event) + "\n" for event in events),
encoding="utf-8",
)
(output / "result.json").write_text(
demo.json.dumps({"ok": True, "response_ids": [response_id]}),
encoding="utf-8",
)
summary = demo.summarize_artifacts(
output_dir=output,
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二",
)
assert summary["ok"] is False
assert summary["enough_responses"] is False
def test_realtime_duplex_soft_interrupt_checks_expected_text_in_second_response(tmp_path):
demo = _load_soft_interrupt_demo_module()
output = tmp_path / "second_response_text"
output.mkdir()
first_response_id = "resp-first"
second_response_id = "resp-second"
events = [
{"type": "response.listen", "_client_received_at_s": 1.0},
{"type": "response.created", "response": {"id": first_response_id}, "_client_received_at_s": 2.0},
{
"type": "response.audio.delta",
"response_id": first_response_id,
"delta": "AAAA",
"_client_received_at_s": 2.1,
},
{
"type": "response.audio.delta",
"response_id": first_response_id,
"delta": "AAAA",
"_client_received_at_s": 2.2,
},
{
"type": "response.audio_transcript.delta",
"response_id": first_response_id,
"delta": "一加一等于二",
"_client_received_at_s": 2.2,
},
{"type": "response.done", "response_id": first_response_id, "_client_received_at_s": 2.3},
{"type": "response.listen", "_client_received_at_s": 2.4},
{"type": "response.created", "response": {"id": second_response_id}, "_client_received_at_s": 3.0},
{
"type": "response.audio.delta",
"response_id": second_response_id,
"delta": "AAAA",
"_client_received_at_s": 3.1,
},
{
"type": "response.audio.delta",
"response_id": second_response_id,
"delta": "AAAA",
"_client_received_at_s": 3.2,
},
{
"type": "response.audio_transcript.delta",
"response_id": second_response_id,
"delta": "不知道",
"_client_received_at_s": 3.2,
},
{"type": "response.done", "response_id": second_response_id, "_client_received_at_s": 3.3},
{"type": "response.listen", "_client_received_at_s": 3.4},
{"type": "input_audio_buffer.committed", "_client_received_at_s": 4.0},
{"type": "response.listen", "_client_received_at_s": 4.1},
]
(output / "events.jsonl").write_text(
"".join(demo.json.dumps(event) + "\n" for event in events),
encoding="utf-8",
)
(output / "result.json").write_text(
demo.json.dumps({"ok": True, "response_ids": [first_response_id, second_response_id]}),
encoding="utf-8",
)
summary = demo.summarize_artifacts(
output_dir=output,
validation_mode="response-required",
min_responses=2,
min_audio_deltas_per_response=2,
expect_second_response_substring="一加一等于二",
)
assert summary["ok"] is False
assert summary["second_response_transcript_expectation_ok"] is False
def test_realtime_duplex_multi_session_resume_url_disables_autostart():
demo = _load_multi_demo_module()
url = demo._with_resume_mode("ws://localhost:8113/v1/realtime?duplex=1&model=openbmb%2FMiniCPM-o-4_5")
query = parse_qs(urlsplit(url).query)
assert query["model"] == ["openbmb/MiniCPM-o-4_5"]
assert query["resume"] == ["1"]
def test_realtime_duplex_multi_session_rejects_cross_session_response_identity():
demo = _load_multi_demo_module()
assert demo._validate_identity_isolation(
[
{"completed_response_ids": ["resp-a"]},
{"completed_response_ids": ["resp-b"]},
]
)
assert not demo._validate_identity_isolation(
[
{"completed_response_ids": ["resp-shared"]},
{"completed_response_ids": ["resp-shared"]},
]
)
def test_realtime_duplex_multi_session_reads_nested_terminal_identity():
demo = _load_multi_demo_module()
assert demo._event_session_id({"type": "session.heartbeat_ack", "session_id": "sid"}) == "sid"
assert (
demo._event_session_id({"type": "session.closed", "event": {"type": "session.closed", "session_id": "sid"}})
== "sid"
)
def test_realtime_duplex_demo_resolves_distinct_turn_inputs():
demo = _load_demo_module()
primary = Path("first.wav")
assert demo._turn_input_paths(primary, [], turns=3) == [primary, primary, primary]
assert demo._turn_input_paths(primary, ["second.wav", "third.wav"], turns=3) == [
primary,
Path("second.wav"),
Path("third.wav"),
]
with pytest.raises(ValueError, match="one --turn-input-wav"):
demo._turn_input_paths(primary, ["second.wav"], turns=3)
def test_realtime_duplex_demo_explicitly_enables_native_runtime_before_connect():
demo = _load_demo_module()
url = demo._url_with_model(
"ws://localhost:8099/v1/realtime?duplex=1",
"openbmb/MiniCPM-o-4_5",
)
query = parse_qs(urlsplit(url).query)
assert query["minicpmo45_native_duplex"] == ["1"]
def test_realtime_duplex_demo_explicit_session_id_reaches_autostart_query():
demo = _load_demo_module()
url = demo._url_with_model(
"ws://localhost:8099/v1/realtime?duplex=1",
"openbmb/MiniCPM-o-4_5",
session_id="reopen-e2e",
)
query = parse_qs(urlsplit(url).query)
assert query["session_id"] == ["reopen-e2e"]
def test_realtime_duplex_demo_session_update_uses_explicit_session_id():
demo = _load_demo_module()
event = demo._session_update_event(
SimpleNamespace(
model="openbmb/MiniCPM-o-4_5",
output_audio_format="pcm16",
short_ack_ms=1200,
session_id="reopen-e2e",
)
)
assert event["type"] == "session.update"
assert "session_id" not in event
assert event["session"]["session_id"] == "reopen-e2e"
assert event["session"]["extra_body"]["minicpmo45_native_duplex"] is True
def test_realtime_duplex_demo_response_required_uses_deterministic_sampling():
demo = _load_demo_module()
event = demo._session_update_event(
SimpleNamespace(
model="openbmb/MiniCPM-o-4_5",
output_audio_format="pcm16",
short_ack_ms=1200,
session_id=None,
validation_mode="response-required",
temperature=None,
)
)
assert event["session"]["temperature"] == 0.0
def test_realtime_duplex_demo_model_policy_preserves_default_sampling():
demo = _load_demo_module()
event = demo._session_update_event(
SimpleNamespace(
model="openbmb/MiniCPM-o-4_5",
output_audio_format="pcm16",
short_ack_ms=1200,
session_id=None,
validation_mode="model-policy",
temperature=None,
)
)
assert "temperature" not in event["session"]
def test_realtime_duplex_demo_resolves_explicit_turn_durations():
demo = _load_demo_module()
assert demo._turn_durations([], turns=3, first_turn_ms=3000) == [3000, 1200, 1200]
assert demo._turn_durations([0, 900, 1500], turns=3, first_turn_ms=3000) == [None, 900, 1500]
with pytest.raises(ValueError, match="one --turn-duration-ms"):
demo._turn_durations([0, 900], turns=3, first_turn_ms=3000)
with pytest.raises(ValueError, match="non-negative"):
demo._turn_durations([0, -1, 900], turns=3, first_turn_ms=3000)
def test_realtime_duplex_demo_resolves_transcript_labels_for_requested_turns():
demo = _load_demo_module()
assert demo._turn_transcripts("first", turns=1) == ["first"]
assert demo._turn_transcripts("first", turns=4) == ["first", "继续", "再说一次", "turn-4"]
def test_realtime_duplex_demo_reads_response_playback_cursor():
demo = _load_demo_module()
state = demo.DemoState()
state.add(
{
"type": "response.done",
"response_id": "resp-1",
"response": {
"id": "resp-1",
"metadata": {
"playback": {
"sent_ms": 27920,
"played_ms": 0,
}
},
},
}
)
assert state.response_playback_sent_ms("resp-1") == 27920
def test_realtime_duplex_demo_partitions_timing_by_response_identity():
demo = _load_demo_module()
state = demo.DemoState()
for response_id, created_at_s, audio_at_s, token_count in (
("resp-1", 10.0, 10.1, 3),
("resp-2", 20.0, 20.2, 5),
):
state.add(
{"type": "response.created", "response": {"id": response_id}},
received_at_s=created_at_s,
)
state.add(
{
"type": "response.audio.delta",
"response_id": response_id,
"delta": base64.b64encode(b"audio").decode("ascii"),
"metadata": {
"audio_duration_ms": 80,
"vllm_omni": {
"stage_metrics": {
"0": {
"num_tokens_out": token_count,
"vllm_itls_ms": [10.0],
}
}
},
},
},
received_at_s=audio_at_s,
)
timings = state.response_timing_summaries()
assert timings["resp-1"]["stage0_tokens"]["output_token_count"] == 3
assert timings["resp-1"]["audio_output"]["response_created_to_first_audio_ms"] == 100.0
assert timings["resp-1"]["audio_output"]["commit_to_first_audio_ms"] is None
assert timings["resp-2"]["stage0_tokens"]["output_token_count"] == 5
assert timings["resp-2"]["audio_output"]["response_created_to_first_audio_ms"] == 200.0
assert timings["resp-2"]["audio_output"]["commit_to_first_audio_ms"] is None
def test_realtime_duplex_demo_model_policy_accepts_one_listen_per_streamed_turn():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "session.created"})
for turn in range(3):
state.add({"type": "input_audio_buffer.speech_started", "turn": turn})
state.add(
{
"type": "response.listen",
"response": {
"status": "listening",
"metadata": {"model_listen": True},
},
}
)
state.add({"type": "input_audio_buffer.committed", "turn": turn})
assert state.model_policy_event_order_ok(expected_turns=3)
def test_realtime_duplex_demo_model_policy_accepts_continuous_input_without_commits():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "session.created"})
state.add({"type": "input_audio_buffer.speech_started"})
for _ in range(3):
state.add(
{
"type": "response.listen",
"response": {"metadata": {"model_listen": True}},
}
)
assert not state.model_policy_event_order_ok(expected_turns=3)
assert state.model_policy_event_order_ok(expected_turns=3, require_input_commit=False)
def test_realtime_duplex_demo_accepts_overlap_unit_terminating_continuous_response():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "response.created", "response_id": "resp-1"})
state.add({"type": "response.done", "response_id": "resp-1"})
assert demo._continuous_overlap_terminal_is_outcome(
state,
validation_mode="model-policy",
model_unit_ready_while_active=True,
before_created=1,
before_model_listen=0,
)
assert not demo._continuous_overlap_terminal_is_outcome(
state,
validation_mode="response-required",
model_unit_ready_while_active=True,
before_created=1,
before_model_listen=0,
)
def test_realtime_duplex_demo_full_turn_duration_does_not_slice_audio():
demo = _load_demo_module()
pcm16 = b"\x01\x00" * (demo.PCM16_SAMPLE_RATE * 2)
assert demo._select_turn_audio(pcm16, None) == pcm16
assert len(demo._select_turn_audio(pcm16, 1000)) == demo.PCM16_SAMPLE_RATE * demo.PCM16_BYTES_PER_SAMPLE
def test_realtime_duplex_demo_distinct_inputs_compare_audio_content():
demo = _load_demo_module()
paths = [Path("first.wav"), Path("second.wav"), Path("third.wav")]
assert demo._turn_inputs_are_distinct(paths, [b"first", b"second", b"third"]) is True
assert demo._turn_inputs_are_distinct(paths, [b"same", b"same", b"third"]) is False
assert demo._turn_inputs_are_distinct([paths[0], paths[0]], [b"first", b"second"]) is False
def _add_response_transcript(state, response_id, *, transcript, audio=True):
state.add(
{
"type": "response.audio.delta",
"response_id": response_id,
"delta": "YQ==" if audio else "",
}
)
if transcript:
state.add(
{
"type": "response.audio_transcript.delta",
"response_id": response_id,
"delta": transcript,
}
)
state.add(
{
"type": "response.audio_transcript.done",
"response_id": response_id,
"transcript": transcript,
}
)
state.add(
{
"type": "response.done",
"response_id": response_id,
"response": {"id": response_id, "status": "completed"},
}
)
def test_realtime_duplex_demo_gate_checks_delta_done_and_turn_independence():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(
state,
"resp-1",
transcript="第一轮回答",
)
_add_response_transcript(
state,
"resp-2",
transcript="第二轮回答",
)
result = demo._evaluate_transcript_integrity(
state,
["resp-1", "resp-2"],
expected_empty_response_ids=set(),
require_cross_turn_independence=True,
)
assert result["transcript_delta_done_ok"] is True
assert result["cross_turn_independent_ok"] is True
def test_realtime_duplex_demo_speak_gate_accepts_one_decision_event_per_response():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "response.created", "response": {"id": "resp-1"}})
state.add(
{
"type": "response.speak",
"response_id": "resp-1",
"metadata": {"model_speak": True},
}
)
assert demo._evaluate_response_speak_contract(state)["response_speak_contract_ok"] is True
def test_realtime_duplex_demo_speak_gate_rejects_duplicate_event():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "response.created", "response": {"id": "resp-1"}})
state.add({"type": "response.speak", "response_id": "resp-1"})
state.add({"type": "response.speak", "response_id": "resp-1"})
result = demo._evaluate_response_speak_contract(state)
assert result["response_speak_contract_ok"] is False
assert result["duplicate_response_speak_ids"] == ["resp-1"]
def test_realtime_duplex_demo_speak_gate_rejects_text_channel():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "response.created", "response": {"id": "resp-1"}})
state.add(
{
"type": "response.speak",
"response_id": "resp-1",
"text": "must be emitted through response.audio_transcript.delta",
}
)
result = demo._evaluate_response_speak_contract(state)
assert result["response_speak_contract_ok"] is False
assert result["text_bearing_response_speak_count"] == 1
def test_realtime_duplex_demo_gate_rejects_cross_turn_tail_reuse():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(state, "resp-1", transcript="第一轮回答")
_add_response_transcript(state, "resp-2", transcript="第二轮仍然带着第一轮回答")
result = demo._evaluate_transcript_integrity(
state,
["resp-1", "resp-2"],
expected_empty_response_ids=set(),
require_cross_turn_independence=True,
)
assert result["cross_turn_independent_ok"] is False
def test_realtime_duplex_demo_gate_rejects_terminal_only_previous_tail():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(state, "resp-1", transcript="上一轮回答结尾是的吗?")
_add_response_transcript(state, "resp-2", transcript="的吗?")
result = demo._evaluate_transcript_integrity(
state,
["resp-1", "resp-2"],
expected_empty_response_ids=set(),
require_cross_turn_independence=True,
)
assert result["cross_turn_independent_ok"] is False
def test_realtime_duplex_demo_gate_rejects_delta_done_mismatch():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(state, "resp-1", transcript="delta文本")
done = next(event for event in state.events if event.get("type") == "response.audio_transcript.done")
done["transcript"] = "另一个done文本"
result = demo._evaluate_transcript_integrity(
state,
["resp-1"],
expected_empty_response_ids=set(),
require_cross_turn_independence=False,
)
assert result["transcript_delta_done_ok"] is False
def test_realtime_duplex_demo_gate_rejects_terminal_only_stale_tail():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(
state,
"resp-empty",
transcript="的吗?",
audio=False,
)
result = demo._evaluate_transcript_integrity(
state,
["resp-empty"],
expected_empty_response_ids={"resp-empty"},
require_cross_turn_independence=False,
)
assert result["empty_turns_ok"] is False
def test_realtime_duplex_demo_gate_rejects_audio_without_transcript():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(
state,
"resp-audio-no-text",
transcript="",
audio=True,
)
result = demo._evaluate_transcript_integrity(
state,
["resp-audio-no-text"],
expected_empty_response_ids=set(),
require_cross_turn_independence=False,
)
assert result["nonempty_audio_has_transcript_ok"] is False
def test_realtime_duplex_demo_response_required_rejects_unselected_audio_only_response():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(state, "resp-audio-only", transcript="", audio=True)
_add_response_transcript(state, "resp-with-text", transcript="你好", audio=True)
assert (
demo._all_audio_responses_have_transcript(
state,
["resp-audio-only", "resp-with-text"],
)
is False
)
def test_realtime_duplex_demo_gate_rejects_incomplete_model_turn_sentence():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(state, "resp-1", transcript="哎,不是说好不")
result = demo._evaluate_transcript_integrity(
state,
["resp-1"],
expected_empty_response_ids=set(),
require_cross_turn_independence=False,
require_terminal_punctuation=True,
)
assert result["terminal_punctuation_ok"] is False
def test_realtime_duplex_demo_gate_accepts_short_interjection_without_punctuation():
demo = _load_demo_module()
state = demo.DemoState()
_add_response_transcript(state, "resp-1", transcript="哈哈")
result = demo._evaluate_transcript_integrity(
state,
["resp-1"],
expected_empty_response_ids=set(),
require_cross_turn_independence=False,
require_terminal_punctuation=True,
)
assert result["terminal_punctuation_ok"] is True
def test_realtime_duplex_demo_response_required_skips_audio_only_model_turn(monkeypatch):
demo = _load_demo_module()
state = demo.DemoState()
async def fake_send_pcm16(*args, **kwargs):
state.add({"type": "response.created", "response": {"id": "resp-audio-only"}})
_add_response_transcript(state, "resp-audio-only", transcript="", audio=True)
state.add({"type": "response.created", "response": {"id": "resp-with-text"}})
_add_response_transcript(state, "resp-with-text", transcript="你好", audio=True)
class FakeWebSocket:
async def send(self, payload):
return None
monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16)
response_id, outcome = asyncio.run(
demo._send_clean_turn(
FakeWebSocket(),
state,
b"\x00\x00",
transcript="fixture",
duration_ms=None,
chunk_ms=200,
timeout_s=0.1,
require_audio=True,
validation_mode="response-required",
)
)
assert response_id == "resp-with-text"
assert outcome == "speak"
def test_realtime_duplex_demo_no_hint_realtime_turn_omits_transcript_hint(monkeypatch):
demo = _load_demo_module()
state = demo.DemoState()
captured = {}
async def fake_send_pcm16(*args, **kwargs):
del args
captured.update(kwargs)
state.add(
{
"type": "response.listen",
"response": {"metadata": {"model_listen": True}},
}
)
class FakeWebSocket:
async def send(self, payload):
del payload
monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16)
response_id, outcome = asyncio.run(
demo._send_clean_turn(
FakeWebSocket(),
state,
b"\x00\x00",
transcript="turn label only",
duration_ms=None,
chunk_ms=200,
timeout_s=0.1,
require_audio=False,
validation_mode="model-policy",
send_transcript_hint=False,
realtime_input=True,
model_policy_settle_s=0.01,
)
)
assert response_id is None
assert outcome == "listen"
assert captured["hints"] == {}
assert captured["realtime_delay"] is True
def test_realtime_duplex_demo_continuous_input_omits_browser_commit(monkeypatch):
demo = _load_demo_module()
state = demo.DemoState()
async def fake_send_pcm16(*args, **kwargs):
del args, kwargs
state.add({"type": "response.created", "response": {"id": "resp-continuous"}})
_add_response_transcript(state, "resp-continuous", transcript="连续输入", audio=True)
class FakeWebSocket:
def __init__(self):
self.messages = []
async def send(self, payload):
self.messages.append(demo.json.loads(payload))
monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16)
ws = FakeWebSocket()
response_id, outcome = asyncio.run(
demo._send_clean_turn(
ws,
state,
b"\x00\x00",
transcript="fixture",
duration_ms=None,
chunk_ms=200,
timeout_s=0.1,
require_audio=True,
validation_mode="response-required",
commit_input=False,
)
)
assert response_id == "resp-continuous"
assert outcome == "speak"
assert not any(message.get("type") == "input_audio_buffer.commit" for message in ws.messages)
def test_realtime_duplex_demo_no_hint_gate_does_not_require_transcription_event():
demo = _load_demo_module()
assert demo._input_transcription_ok(0, transcript_hints_enabled=False)
assert not demo._input_transcription_ok(0, transcript_hints_enabled=True)
assert demo._input_transcription_ok(3, transcript_hints_enabled=True)
def test_realtime_duplex_demo_gate_rejects_server_errors():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "error", "code": "runtime_signal_failed", "error": "signal failed"})
errors = demo._unexpected_error_events(state)
assert errors == [{"type": "error", "code": "runtime_signal_failed", "error": "signal failed"}]
def test_realtime_duplex_demo_model_policy_waits_for_speak_after_intermediate_listen(monkeypatch):
demo = _load_demo_module()
state = demo.DemoState()
async def fake_send_pcm16(*args, **kwargs):
del args, kwargs
state.add(
{
"type": "response.listen",
"response": {"metadata": {"model_listen": True}},
}
)
async def delayed_speak():
await asyncio.sleep(0.01)
state.add({"type": "response.created", "response": {"id": "resp-delayed"}})
_add_response_transcript(state, "resp-delayed", transcript="延迟回答", audio=False)
asyncio.create_task(delayed_speak())
class FakeWebSocket:
async def send(self, payload):
del payload
monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16)
response_id, outcome = asyncio.run(
demo._send_clean_turn(
FakeWebSocket(),
state,
b"\x00\x00",
transcript="fixture",
duration_ms=None,
chunk_ms=200,
timeout_s=0.2,
require_audio=False,
validation_mode="model-policy",
model_policy_settle_s=0.05,
)
)
assert response_id == "resp-delayed"
assert outcome == "speak"
def test_realtime_duplex_demo_playback_gate_covers_unassigned_completed_response():
demo = _load_demo_module()
state = demo.DemoState()
for response_id in ("resp-assigned", "resp-unassigned"):
state.add({"type": "response.created", "response": {"id": response_id}})
state.add(
{
"type": "response.done",
"response": {
"id": response_id,
"metadata": {"playback": {"sent_ms": 1200}},
},
}
)
state.add(
{
"type": "playback.acknowledged",
"event": {
"item_id": "item_resp-assigned",
"history_committed": True,
},
}
)
assert not demo._all_response_playback_history_committed(
state,
state.completed_response_ids(),
)
def test_realtime_duplex_demo_acks_unassigned_completed_response():
demo = _load_demo_module()
state = demo.DemoState()
for response_id in ("resp-assigned", "resp-unassigned"):
state.add({"type": "response.created", "response": {"id": response_id}})
state.add(
{
"type": "response.done",
"response": {
"id": response_id,
"metadata": {"playback": {"sent_ms": 1200}},
},
}
)
state.add(
{
"type": "playback.acknowledged",
"event": {
"item_id": "item_resp-assigned",
"history_committed": True,
},
}
)
class FakeWebSocket:
def __init__(self):
self.messages = []
async def send(self, payload):
message = demo.json.loads(payload)
self.messages.append(message)
state.add(
{
"type": "playback.acknowledged",
"event": {
"item_id": message["item_id"],
"history_committed": True,
},
}
)
ws = FakeWebSocket()
asyncio.run(demo._ack_all_completed_response_playback(ws, state, timeout_s=0.1))
assert [message["response_id"] for message in ws.messages] == ["resp-unassigned"]
assert demo._all_response_playback_history_committed(state, state.completed_response_ids())
def test_realtime_duplex_demo_model_policy_does_not_assume_one_response_per_physical_input():
demo = _load_demo_module()
assert demo._response_cardinality_ok(
["resp-1", "resp-2", "resp-3", "resp-4"],
expected_turns=3,
validation_mode="model-policy",
)
assert not demo._response_cardinality_ok(
["resp-1", "resp-2", "resp-3", "resp-4"],
expected_turns=3,
validation_mode="response-required",
)
def test_realtime_duplex_demo_only_requires_cross_turn_independence_for_response_required():
demo = _load_demo_module()
assert not demo._requires_cross_turn_independence(
validation_mode="model-policy",
distinct_inputs_required=True,
)
assert demo._requires_cross_turn_independence(
validation_mode="response-required",
distinct_inputs_required=True,
)
assert not demo._requires_cross_turn_independence(
validation_mode="response-required",
distinct_inputs_required=False,
)
def test_realtime_duplex_demo_drains_late_model_response_before_close():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "response.created", "response": {"id": "resp-first"}})
state.add({"type": "response.done", "response": {"id": "resp-first"}})
class FakeWebSocket:
def __init__(self):
self.messages = []
async def send(self, payload):
self.messages.append(demo.json.loads(payload))
async def run_fixture():
async def finish_late_response():
await asyncio.sleep(0.01)
state.add({"type": "response.created", "response": {"id": "resp-late"}})
await asyncio.sleep(0.01)
state.add({"type": "response.done", "response": {"id": "resp-late"}})
late_response = asyncio.create_task(finish_late_response())
ws = FakeWebSocket()
await demo._drain_model_policy_responses(
ws,
state,
timeout_s=0.2,
settle_s=0.03,
)
await late_response
return ws
ws = asyncio.run(run_fixture())
assert state.completed_response_ids() == ["resp-first", "resp-late"]
assert not [message for message in ws.messages if message.get("type") == "session.close"]
def test_realtime_duplex_demo_model_response_drain_times_out_with_active_ids():
demo = _load_demo_module()
state = demo.DemoState()
state.add({"type": "response.created", "response": {"id": "resp-active"}})
class FakeWebSocket:
async def send(self, payload):
del payload
with pytest.raises(TimeoutError, match="resp-active"):
asyncio.run(
demo._drain_model_policy_responses(
FakeWebSocket(),
state,
timeout_s=0.03,
settle_s=0.01,
)
)
def test_realtime_duplex_demo_waits_at_each_model_unit_and_stops_after_speak():
demo = _load_demo_module()
class FakeWebSocket:
def __init__(self):
self.messages = []
async def send(self, payload):
self.messages.append(demo.json.loads(payload))
async def run_fixture():
ws = FakeWebSocket()
model_unit_message_counts = []
async def on_model_unit_ready():
model_unit_message_counts.append(len(ws.messages))
await asyncio.sleep(0)
return len(model_unit_message_counts) < 2
await demo._send_pcm16(
ws,
b"\x01\x00" * (demo.PCM16_SAMPLE_RATE * 3),
chunk_ms=200,
realtime_delay=False,
on_model_unit_ready=on_model_unit_ready,
)
return ws, model_unit_message_counts
ws, model_unit_message_counts = asyncio.run(run_fixture())
assert model_unit_message_counts == [5, 10]
assert len(ws.messages) == 10
def test_realtime_duplex_demo_listen_only_overlap_sends_next_turn_before_first_done(monkeypatch):
demo = _load_demo_module()
state = demo.DemoState()
send_calls = 0
async def fake_send_pcm16(*args, **kwargs):
nonlocal send_calls
send_calls += 1
if send_calls == 1:
assert len(args[1]) == 2 + demo.PCM16_SAMPLE_RATE * demo.PCM16_BYTES_PER_SAMPLE
state.add({"type": "input_audio_buffer.speech_started", "turn": 1})
state.add({"type": "response.created", "response": {"id": "resp-first"}})
state.add(
{
"type": "response.audio.delta",
"response_id": "resp-first",
"delta": "YQ==",
}
)
assert await kwargs["on_model_unit_ready"]() is False
return
del args
assert not state.response_done("resp-first")
kwargs["on_model_unit_ready"]()
state.add({"type": "input_audio_buffer.speech_started", "turn": 2})
_add_response_transcript(state, "resp-first", transcript="第一轮完成", audio=False)
state.add({"type": "response.created", "response": {"id": "resp-second"}})
_add_response_transcript(state, "resp-second", transcript="第二轮完成", audio=True)
class FakeWebSocket:
def __init__(self):
self.messages = []
async def send(self, payload):
self.messages.append(payload)
monkeypatch.setattr(demo, "_send_pcm16", fake_send_pcm16)
ws = FakeWebSocket()
response_ids, outcomes, overlap_ok = asyncio.run(
demo._send_listen_only_overlap_pair(
ws,
state,
b"\x00\x00",
b"\x01\x00",
transcripts=("first", "second"),
durations_ms=(None, None),
chunk_ms=200,
timeout_s=0.2,
send_transcript_hint=False,
realtime_input=True,
model_policy_settle_s=0.01,
validation_mode="response-required",
silence_ms=1000,
)
)
assert response_ids == ["resp-first", "resp-second"]
assert outcomes == ["speak", "speak"]
assert overlap_ok is True
assert sum(demo.json.loads(message)["type"] == "input_audio_buffer.commit" for message in ws.messages) == 2
def test_realtime_duplex_demo_playback_ack_identifies_and_commits_response():
demo = _load_demo_module()
state = demo.DemoState()
response_id = "resp-overlap-second"
state.add(
{
"type": "response.done",
"response": {
"id": response_id,
"metadata": {"playback": {"sent_ms": 1200}},
},
}
)
class FakeWebSocket:
def __init__(self):
self.messages = []
async def send(self, payload):
message = demo.json.loads(payload)
self.messages.append(message)
state.add(
{
"type": "playback.acknowledged",
"event": {
"item_id": message.get("item_id"),
"history_committed": message.get("item_id") == f"item_{response_id}",
},
}
)
ws = FakeWebSocket()
asyncio.run(
demo._ack_response_playback(
ws,
state,
response_id,
timeout_s=0.1,
label="overlap second",
)
)
assert ws.messages == [
{
"type": "playback.ack",
"response_id": response_id,
"item_id": f"item_{response_id}",
"played_ms": 1200,
"committed_ms": 1200,
}
]
assert state.playback_history_committed_count == 1
def test_realtime_duplex_demo_writes_audio_per_response(tmp_path):
demo = _load_demo_module()
state = demo.DemoState()
for response_id, payload in (("resp-1", b"\x01\x00"), ("resp-2", b"\x02\x00")):
state.add({"type": "response.created", "response": {"id": response_id}})
state.add(
{
"type": "response.audio.delta",
"response_id": response_id,
"delta": demo.base64.b64encode(payload).decode(),
"sample_rate_hz": 24000,
}
)
demo._write_demo_artifacts(state, tmp_path, output_audio_format="pcm16")
for index, expected in enumerate((b"\x01\x00", b"\x02\x00"), start=1):
with wave.open(str(tmp_path / f"response_{index:02d}.wav"), "rb") as wf:
assert wf.getframerate() == 24000
assert wf.readframes(wf.getnframes()) == expected