193 lines
5.8 KiB
Python
193 lines
5.8 KiB
Python
"""Shared fixtures and argument builders for MiniCPM-o 4.5 duplex E2E tests."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import uuid
|
|
import wave
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from huggingface_hub import snapshot_download
|
|
|
|
from tests.helpers.runtime import OmniServerParams
|
|
from tests.helpers.stage_config import get_deploy_config_path, modify_stage_config
|
|
|
|
MODEL = "openbmb/MiniCPM-o-4_5"
|
|
DEPLOY_CONFIG = modify_stage_config(
|
|
get_deploy_config_path("minicpmo_4_5_duplex.yaml"),
|
|
updates={
|
|
"base_config": get_deploy_config_path("minicpmo_4_5.yaml"),
|
|
"stages": {
|
|
0: {"kv_cache_memory_bytes": 6 * 1024 * 1024 * 1024},
|
|
1: {"kv_cache_memory_bytes": 512 * 1024 * 1024},
|
|
2: {"kv_cache_memory_bytes": 256 * 1024 * 1024},
|
|
},
|
|
# Platform overrides are applied after ordinary stage settings. Keep
|
|
# the constrained duplex test's Talker budget ahead of the base CUDA
|
|
# profile's single-GPU default.
|
|
"platforms": {
|
|
"cuda": {
|
|
"stages": [
|
|
{
|
|
"stage_id": 1,
|
|
"kv_cache_memory_bytes": 512 * 1024 * 1024,
|
|
}
|
|
]
|
|
}
|
|
},
|
|
},
|
|
)
|
|
CORE_DEPLOY_CONFIG = modify_stage_config(
|
|
DEPLOY_CONFIG,
|
|
updates={
|
|
"stages": {
|
|
0: {"enforce_eager": True},
|
|
1: {"enforce_eager": True},
|
|
}
|
|
},
|
|
)
|
|
ASSET_DIR = Path(__file__).resolve().parents[1] / "assets" / "minicpmo_4_5"
|
|
RESPONSE_REQUIRED_WAV = ASSET_DIR / "response_required_16k.wav"
|
|
RESPONSE_REQUIRED_SHA256 = "2e5fd4eb3ee434ce107ee3a0591fa624a33f7683c7462f45fe651c443c9af941"
|
|
SOFT_INTERRUPT_WAV = ASSET_DIR / "soft_interrupt_16k.wav"
|
|
SOFT_INTERRUPT_SHA256 = "cadae6d0ddc510310f16f8775d6379f30a5195369ccb54ff10a8e3f9a1f4a2ea"
|
|
REF_AUDIO_RELATIVE_PATH = Path("assets") / "HT_ref_audio.wav"
|
|
|
|
SERVER_PARAMS = [
|
|
pytest.param(
|
|
OmniServerParams(
|
|
model=MODEL,
|
|
stage_config_path=DEPLOY_CONFIG,
|
|
use_stage_cli=True,
|
|
server_args=["--trust-remote-code"],
|
|
),
|
|
id="three-stage-single-gpu",
|
|
)
|
|
]
|
|
CORE_SERVER_PARAMS = [
|
|
pytest.param(
|
|
OmniServerParams(
|
|
model=MODEL,
|
|
stage_config_path=CORE_DEPLOY_CONFIG,
|
|
use_stage_cli=True,
|
|
server_args=["--trust-remote-code"],
|
|
),
|
|
id="three-stage-single-gpu",
|
|
)
|
|
]
|
|
|
|
|
|
def validated_wav(path: Path, expected_sha256: str) -> Path:
|
|
actual_sha256 = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
if actual_sha256 != expected_sha256:
|
|
raise AssertionError(
|
|
f"MiniCPM-o duplex fixture SHA256 mismatch: expected {expected_sha256}, got {actual_sha256}"
|
|
)
|
|
with wave.open(str(path), "rb") as wav_file:
|
|
actual_format = (
|
|
wav_file.getframerate(),
|
|
wav_file.getnchannels(),
|
|
wav_file.getsampwidth(),
|
|
wav_file.getcomptype(),
|
|
)
|
|
expected_format = (16_000, 1, 2, "NONE")
|
|
if actual_format != expected_format:
|
|
raise AssertionError(f"MiniCPM-o duplex fixture must be 16 kHz mono PCM16, got {actual_format}")
|
|
return path
|
|
|
|
|
|
def validated_input_wav() -> Path:
|
|
return validated_wav(RESPONSE_REQUIRED_WAV, RESPONSE_REQUIRED_SHA256)
|
|
|
|
|
|
def validated_soft_interrupt_wav() -> Path:
|
|
return validated_wav(SOFT_INTERRUPT_WAV, SOFT_INTERRUPT_SHA256)
|
|
|
|
|
|
def resolve_ref_audio(model_prefix: str) -> Path:
|
|
if model_prefix:
|
|
model_root = Path(model_prefix) / MODEL
|
|
else:
|
|
model_root = Path(snapshot_download(MODEL, local_files_only=True))
|
|
ref_audio = model_root / REF_AUDIO_RELATIVE_PATH
|
|
if not ref_audio.is_file():
|
|
raise FileNotFoundError(f"MiniCPM-o checkpoint ref audio is missing: {ref_audio}")
|
|
return ref_audio
|
|
|
|
|
|
def realtime_url(omni_server) -> str:
|
|
return f"ws://{omni_server.host}:{omni_server.port}/v1/realtime?duplex=1"
|
|
|
|
|
|
def demo_args(
|
|
*,
|
|
omni_server,
|
|
input_wav: Path,
|
|
ref_audio: Path,
|
|
output_dir: Path,
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
url=realtime_url(omni_server),
|
|
model=omni_server.model,
|
|
session_id=f"duplex-ci-single-{uuid.uuid4().hex}",
|
|
input_wav=str(input_wav),
|
|
ref_audio=str(ref_audio),
|
|
turn_input_wav=[],
|
|
output_dir=str(output_dir),
|
|
output_audio_format="pcm16",
|
|
chunk_ms=200,
|
|
realtime_input=True,
|
|
first_turn_ms=1400,
|
|
turn_duration_ms=[],
|
|
first_turn_transcript="duplex CI speech",
|
|
omit_transcript_hints=False,
|
|
validation_mode="response-required",
|
|
temperature=0.0,
|
|
scenario="sequential",
|
|
require_audio=True,
|
|
require_distinct_inputs=False,
|
|
expect_empty_turn=[],
|
|
short_ack_ms=350,
|
|
turns=1,
|
|
timeout_s=120.0,
|
|
model_policy_settle_ms=2000,
|
|
)
|
|
|
|
|
|
def multi_session_args(
|
|
*,
|
|
omni_server,
|
|
input_wav: Path,
|
|
ref_audio: Path,
|
|
output_dir: Path,
|
|
response_required: bool,
|
|
) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
url=realtime_url(omni_server),
|
|
model=omni_server.model,
|
|
sessions=2,
|
|
input_wav=str(input_wav),
|
|
ref_audio=str(ref_audio),
|
|
session_input_wav=[],
|
|
session_expected_token=[],
|
|
turn_input_wav=[],
|
|
output_dir=str(output_dir),
|
|
realtime_input=True,
|
|
chunk_ms=200,
|
|
turns=1,
|
|
first_turn_ms=1400,
|
|
turn_duration_ms=[],
|
|
response_required=response_required,
|
|
temperature=0.0 if response_required else None,
|
|
disconnect_session_index=0,
|
|
takeover_session_index=1,
|
|
resume_after_ms=100,
|
|
expire_session_index=None,
|
|
expire_after_s=40.0,
|
|
verify_admission_limit=None,
|
|
model_policy_settle_ms=2000,
|
|
timeout_s=180.0,
|
|
)
|