Signed-off-by: chickeyton <ngton2014@gmail.com> Signed-off-by: Yueqian Lin <linyueqian@outlook.com> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com> Co-authored-by: Yueqian Lin <linyueqian@outlook.com>
This commit is contained in:
49 files changed
+3056
-1715
No files matched your search
@@ -1,21 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""PersonaPlex duplex is not on the duplex plugin framework yet.
|
||||
|
||||
RFC vllm-omni#7181 splits the port: this PR lands the unified framework with
|
||||
MiniCPM-o 4.5, and the follow-up PR moves PersonaPlex onto ``DuplexModelPlugin``.
|
||||
Until then the modules under test still import the pre-framework duplex
|
||||
runtime / serving adapter that this PR removed, so collecting them would fail
|
||||
at import time and abort the whole pytest session. The tests themselves are
|
||||
unchanged and come back with the port.
|
||||
|
||||
Listed per file rather than globbed, so the coverage loss is visible and shrinks
|
||||
file by file as the port lands.
|
||||
"""
|
||||
|
||||
#: ``test_unified_runtime`` imports ``engine.duplex.runtime`` and
|
||||
#: ``entrypoints.duplex.runtime_adapter`` directly. ``test_stage0_runtime`` fails
|
||||
#: transitively: ``personaplex.duplex.runtime_extension`` imports ``DuplexFence``
|
||||
#: from ``engine.duplex.messages``, which no longer exports it.
|
||||
collect_ignore = ["test_stage0_runtime.py", "test_unified_runtime.py"]
|
||||
@@ -0,0 +1,489 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
"""PersonaPlex on the unified full-duplex framework: the plugin contract and the E2E driver helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from vllm.sampling_params import SamplingParams
|
||||
|
||||
from tests.e2e.online_serving import personaplex_realtime_duplex as e2e_driver
|
||||
from vllm_omni.config.stage_config import load_deploy_config, merge_pipeline_deploy
|
||||
from vllm_omni.engine.duplex.config import DuplexSessionConfig
|
||||
from vllm_omni.engine.duplex.contracts import DuplexFence
|
||||
from vllm_omni.engine.duplex.plugin import (
|
||||
DefaultDuplexModelSessionState,
|
||||
DuplexDataPlaneContext,
|
||||
DuplexRuntimeConfigError,
|
||||
load_duplex_plugin,
|
||||
validate_duplex_plugin_sampling,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.duplex import stage0
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.config import DEFAULT_PERSONA, FRAME_SIZE, SAMPLE_RATE
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.data_plane import PersonaPlexDataPlaneSession
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.input import PersonaPlexPcmAppendBuffer
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.plugin import (
|
||||
PRIVATE_RUNTIME_CONFIG_KEYS,
|
||||
PersonaPlexDuplexPlugin,
|
||||
PersonaPlexSessionState,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.pipeline import PERSONAPLEX_PIPELINE
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
DEPLOY_PATH = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml"
|
||||
|
||||
|
||||
def _pcm_payload(samples: np.ndarray, *, sample_rate_hz: int = SAMPLE_RATE) -> dict[str, object]:
|
||||
samples = np.ascontiguousarray(samples, dtype="<f4")
|
||||
return {
|
||||
"type": "audio",
|
||||
"format": "pcm_f32le",
|
||||
"sample_rate_hz": sample_rate_hz,
|
||||
"audio": base64.b64encode(samples.tobytes()).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
def _encode_audio(audio, _sample_rate, _response_format, _speed):
|
||||
if audio is None:
|
||||
return None
|
||||
size = int(np.asarray(audio, dtype=np.float32).size)
|
||||
return f"audio-{size}" if size else None
|
||||
|
||||
|
||||
def _plugin() -> PersonaPlexDuplexPlugin:
|
||||
return PersonaPlexDuplexPlugin(_encode_audio)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Pipeline binding #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_pipeline_declares_the_duplex_plugin_and_no_pre_framework_wiring() -> None:
|
||||
assert PERSONAPLEX_PIPELINE.duplex_plugin == (
|
||||
"vllm_omni.model_executor.models.personaplex.duplex.plugin.PersonaPlexDuplexPlugin"
|
||||
)
|
||||
assert PERSONAPLEX_PIPELINE.duplex_runtime_extension is None
|
||||
assert PERSONAPLEX_PIPELINE.duplex_serving_adapter is None
|
||||
assert PERSONAPLEX_PIPELINE.duplex_control_enabled is False
|
||||
|
||||
plugin = load_duplex_plugin(PERSONAPLEX_PIPELINE.duplex_plugin, _encode_audio)
|
||||
|
||||
assert isinstance(plugin, PersonaPlexDuplexPlugin)
|
||||
assert plugin.plugin_id == "personaplex"
|
||||
assert isinstance(plugin.data_plane, PersonaPlexDataPlaneSession)
|
||||
validate_duplex_plugin_sampling(plugin, sampling_defaults=(SamplingParams(), SamplingParams()))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("engine_arg", "expected"),
|
||||
[("skip_tokenizer_init", True), ("enable_prefix_caching", False)],
|
||||
)
|
||||
def test_personaplex_stage_engine_args(engine_arg: str, expected: bool) -> None:
|
||||
stages = merge_pipeline_deploy(PERSONAPLEX_PIPELINE, load_deploy_config(DEPLOY_PATH))
|
||||
|
||||
assert [stage.yaml_engine_args.get(engine_arg) for stage in stages] == [expected, expected]
|
||||
|
||||
|
||||
def test_personaplex_deploy_is_duplex_and_propagates_capacity_to_all_model_stages() -> None:
|
||||
deploy = load_deploy_config(DEPLOY_PATH)
|
||||
stages = merge_pipeline_deploy(PERSONAPLEX_PIPELINE, deploy)
|
||||
|
||||
assert deploy.session_mode == "duplex"
|
||||
assert deploy.duplex_session.max_sessions == 2
|
||||
assert [stage.yaml_engine_args.get("duplex_max_sessions") for stage in stages] == [2, 2]
|
||||
assert "personaplex_codec_max_sessions" not in deploy.connectors["connector_of_shared_memory"]["extra"]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Session policy #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_capabilities_are_honest() -> None:
|
||||
single = _plugin().capabilities(max_sessions=1)
|
||||
multi = _plugin().capabilities(max_sessions=2)
|
||||
|
||||
assert multi.as_dict()["implementation_level"] == "model_native_duplex"
|
||||
assert multi.as_dict()["input_modes"] == ["append_audio_chunk"]
|
||||
assert multi.chunk_period_ms == 80
|
||||
assert single.supports_multi_session is False
|
||||
assert multi.supports_multi_session is True
|
||||
assert multi.supports_multi_session_same_replica is True
|
||||
assert multi.supports_barge_in is False
|
||||
assert multi.supports_client_commit is False
|
||||
assert multi.supports_external_turn_signal is False
|
||||
assert multi.supports_session_resume is False
|
||||
assert multi.supports_audio_truncate is False
|
||||
assert multi.supports_chat_completions is False
|
||||
assert multi.session_admission_mode == "engine_managed"
|
||||
|
||||
|
||||
def test_session_state_is_the_framework_default_with_the_personaplex_buffer() -> None:
|
||||
state = _plugin().create_session_state()
|
||||
|
||||
assert isinstance(state, PersonaPlexSessionState)
|
||||
assert isinstance(state, DefaultDuplexModelSessionState)
|
||||
assert isinstance(state.audio_buffer, PersonaPlexPcmAppendBuffer)
|
||||
state.retain_committed_audio({"audio": ""}, operation_id="op", reserved_bytes=8)
|
||||
assert state.clear_committed_audio() == 8
|
||||
assert state.committed_audio_payload is None
|
||||
|
||||
|
||||
def test_private_runtime_keys_are_rejected_in_extra_body() -> None:
|
||||
plugin = _plugin()
|
||||
|
||||
assert PRIVATE_RUNTIME_CONFIG_KEYS == {
|
||||
"personaplex_prefill_slots",
|
||||
"personaplex_model_path",
|
||||
"personaplex_voice_prompt",
|
||||
"personaplex_persona",
|
||||
}
|
||||
plugin.validate_client_extra_body({"auto_response": True})
|
||||
plugin.validate_client_extra_body(None)
|
||||
with pytest.raises(DuplexRuntimeConfigError, match="personaplex_persona") as excinfo:
|
||||
plugin.validate_client_extra_body({"personaplex_persona": "x", "auto_response": True})
|
||||
assert excinfo.value.code == "invalid_duplex_runtime_config"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_runtime_config_resolves_voice_and_prefill_slots_once(monkeypatch) -> None:
|
||||
calls: list[tuple[str, str, str]] = []
|
||||
|
||||
def fake_prefill_slots(model_path: str, voice: str, persona: str) -> int:
|
||||
calls.append((model_path, voice, persona))
|
||||
return 42
|
||||
|
||||
monkeypatch.setattr(stage0, "personaplex_prefill_slots", fake_prefill_slots)
|
||||
plugin = _plugin()
|
||||
model_config = SimpleNamespace(model="/models/personaplex")
|
||||
|
||||
runtime_config = await plugin.prepare_runtime_config(
|
||||
DuplexSessionConfig(voice="NATM1.pt", instructions="Be brief."),
|
||||
model_config=model_config,
|
||||
)
|
||||
again = await plugin.prepare_runtime_config(
|
||||
DuplexSessionConfig(voice="NATM1.pt", instructions="Be brief."),
|
||||
model_config=model_config,
|
||||
)
|
||||
defaults = await plugin.prepare_runtime_config(DuplexSessionConfig(), model_config=model_config)
|
||||
|
||||
assert runtime_config == {
|
||||
"personaplex_model_path": "/models/personaplex",
|
||||
"personaplex_voice_prompt": "NATM1.pt",
|
||||
"personaplex_persona": "Be brief.",
|
||||
"personaplex_prefill_slots": 42,
|
||||
}
|
||||
assert again == runtime_config
|
||||
assert defaults["personaplex_voice_prompt"] == "NATF2.pt"
|
||||
assert defaults["personaplex_persona"] == DEFAULT_PERSONA
|
||||
# The voice bundle and tokenizer are read once per (model, voice, persona).
|
||||
assert calls == [
|
||||
("/models/personaplex", "NATM1.pt", "Be brief."),
|
||||
("/models/personaplex", "NATF2.pt", DEFAULT_PERSONA),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_runtime_config_reports_prefill_failures_as_runtime_config_errors(monkeypatch) -> None:
|
||||
def failing_prefill_slots(model_path: str, voice: str, persona: str) -> int:
|
||||
raise FileNotFoundError("voices.tgz missing")
|
||||
|
||||
monkeypatch.setattr(stage0, "personaplex_prefill_slots", failing_prefill_slots)
|
||||
|
||||
with pytest.raises(DuplexRuntimeConfigError, match="voices.tgz missing") as excinfo:
|
||||
await _plugin().prepare_runtime_config(DuplexSessionConfig(), model_config=SimpleNamespace(model="/m"))
|
||||
assert excinfo.value.code == "prefill_unavailable"
|
||||
|
||||
with pytest.raises(DuplexRuntimeConfigError, match="model path") as excinfo:
|
||||
await _plugin().prepare_runtime_config(DuplexSessionConfig(), model_config=None)
|
||||
assert excinfo.value.code == "model_path_unavailable"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("voice", ["../NATF2.pt", "voices/NATF2.pt", "NATF2.wav", "/abs/NATF2.pt"])
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_runtime_config_rejects_voices_that_are_not_bundled_basenames(voice: str) -> None:
|
||||
with pytest.raises(DuplexRuntimeConfigError, match="bundled .pt basename") as excinfo:
|
||||
await _plugin().prepare_runtime_config(
|
||||
DuplexSessionConfig(voice=voice), model_config=SimpleNamespace(model="/m")
|
||||
)
|
||||
assert excinfo.value.code == "invalid_voice"
|
||||
|
||||
|
||||
def test_runtime_config_update_rejects_changed_persona() -> None:
|
||||
plugin = _plugin()
|
||||
short_persona = "You are a helpful assistant."
|
||||
current = {"personaplex_persona": short_persona, "personaplex_voice_prompt": "NATF2.pt"}
|
||||
|
||||
with pytest.raises(DuplexRuntimeConfigError, match="persona") as excinfo:
|
||||
plugin.runtime_config_for_update(DuplexSessionConfig(instructions="You are a pirate."), current)
|
||||
assert excinfo.value.code == "persona_update_unsupported"
|
||||
|
||||
unchanged = plugin.runtime_config_for_update(DuplexSessionConfig(instructions=short_persona), current)
|
||||
assert unchanged["personaplex_persona"] == short_persona
|
||||
assert unchanged is not current
|
||||
|
||||
|
||||
def test_runtime_config_update_rejects_changed_voice() -> None:
|
||||
plugin = _plugin()
|
||||
current = {"personaplex_persona": DEFAULT_PERSONA, "personaplex_voice_prompt": "NATF2.pt"}
|
||||
|
||||
with pytest.raises(DuplexRuntimeConfigError, match="voice") as excinfo:
|
||||
plugin.runtime_config_for_update(DuplexSessionConfig(voice="NATM1.pt"), current)
|
||||
assert excinfo.value.code == "voice_update_unsupported"
|
||||
|
||||
unchanged = plugin.runtime_config_for_update(DuplexSessionConfig(voice="NATF2.pt"), current)
|
||||
assert unchanged["personaplex_voice_prompt"] == "NATF2.pt"
|
||||
|
||||
|
||||
def test_data_plane_context_is_the_framework_default() -> None:
|
||||
context = _plugin().data_plane_context(
|
||||
epoch=1,
|
||||
turn_id=2,
|
||||
active_response_turn_id=None,
|
||||
active_response_id="resp",
|
||||
auto_responds=True,
|
||||
response_format="pcm16",
|
||||
speed=None,
|
||||
modalities=("audio",),
|
||||
)
|
||||
|
||||
assert isinstance(context, DuplexDataPlaneContext)
|
||||
assert context.response_format == "pcm16" and context.epoch == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Engine policy #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def test_sampling_params_are_greedy_one_token_on_stage0_only() -> None:
|
||||
defaults = (SamplingParams(temperature=0.8, top_k=10, max_tokens=10), SamplingParams(max_tokens=1024))
|
||||
|
||||
configured = _plugin().configure_sampling_params(runtime_config={}, defaults=defaults)
|
||||
|
||||
assert configured[0].temperature == 0.0
|
||||
assert configured[0].top_k == 1
|
||||
assert configured[0].max_tokens == 1
|
||||
assert configured[1] is defaults[1]
|
||||
assert defaults[0].max_tokens == 10
|
||||
assert _plugin().configure_sampling_params(runtime_config={}, defaults=()) == ()
|
||||
|
||||
|
||||
def test_plan_append_reserves_one_slot_plus_prefill_on_the_first_seq() -> None:
|
||||
plugin = _plugin()
|
||||
fence = DuplexFence("session", epoch=3, turn_id=1)
|
||||
common = {
|
||||
"request_id": "req",
|
||||
"fence": fence,
|
||||
"session_config": {"instructions": "Be concise."},
|
||||
"runtime_config": {"personaplex_prefill_slots": 4},
|
||||
"turn_seq": 1,
|
||||
"payload": _pcm_payload(np.zeros(FRAME_SIZE, np.float32)),
|
||||
"final": False,
|
||||
"sampling_params": SamplingParams(),
|
||||
}
|
||||
|
||||
first = plugin.plan_append(seq=1, **common)
|
||||
second = plugin.plan_append(seq=2, **common)
|
||||
|
||||
assert len(first.prompt["prompt_token_ids"]) == 5
|
||||
assert len(second.prompt["prompt_token_ids"]) == 1
|
||||
duplex = first.prompt["model_intermediate_buffer"]["duplex"]
|
||||
assert duplex["session_id"] == "session"
|
||||
assert duplex["epoch"] == 3
|
||||
assert duplex["turn_id"] == 1
|
||||
assert duplex["seq"] == 1
|
||||
assert duplex["data_plane"] is True
|
||||
assert duplex["fence"] == fence
|
||||
assert duplex["scheduler_token_budget"] == 5
|
||||
assert duplex["runtime_config"] == {"personaplex_prefill_slots": 4}
|
||||
assert "incarnation" not in duplex
|
||||
assert first.prompt["model_intermediate_buffer"]["global_request_id"] == ["session"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("payload", "match"),
|
||||
[
|
||||
(_pcm_payload(np.zeros(FRAME_SIZE - 1, np.float32)), "exactly 1920 samples"),
|
||||
(_pcm_payload(np.zeros(FRAME_SIZE, np.float32), sample_rate_hz=16000), "sample_rate_hz must be 24000"),
|
||||
({"format": "pcm16", "sample_rate_hz": SAMPLE_RATE, "audio": ""}, "format must be pcm_f32le"),
|
||||
("frame", "must be a mapping"),
|
||||
],
|
||||
)
|
||||
def test_plan_append_rejects_anything_but_one_24k_frame(payload: object, match: str) -> None:
|
||||
with pytest.raises(ValueError, match=match):
|
||||
_plugin().plan_append(
|
||||
request_id="req",
|
||||
fence=DuplexFence("session"),
|
||||
session_config={},
|
||||
runtime_config={},
|
||||
seq=1,
|
||||
turn_seq=1,
|
||||
payload=payload,
|
||||
final=False,
|
||||
sampling_params=None,
|
||||
)
|
||||
|
||||
|
||||
def test_plan_append_rejects_a_malformed_prefill_slot_count() -> None:
|
||||
with pytest.raises(ValueError, match="personaplex_prefill_slots"):
|
||||
_plugin().plan_append(
|
||||
request_id="req",
|
||||
fence=DuplexFence("session"),
|
||||
session_config={},
|
||||
runtime_config={"personaplex_prefill_slots": "many"},
|
||||
seq=1,
|
||||
turn_seq=1,
|
||||
payload=_pcm_payload(np.zeros(FRAME_SIZE, np.float32)),
|
||||
final=False,
|
||||
sampling_params=None,
|
||||
)
|
||||
|
||||
|
||||
def test_decide_output_never_decides() -> None:
|
||||
assert (
|
||||
_plugin().decide_output(
|
||||
stage_id=0,
|
||||
final_stage_id=1,
|
||||
segment_finished=True,
|
||||
segment_token_ids=(1,),
|
||||
segment_output_metadata={},
|
||||
output=SimpleNamespace(outputs=[]),
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_silence_unit_is_one_24k_frame_that_plan_append_accepts() -> None:
|
||||
plugin = _plugin()
|
||||
|
||||
unit = plugin.silence_unit_payload()
|
||||
|
||||
assert unit["format"] == "pcm_f32le"
|
||||
assert unit["sample_rate_hz"] == SAMPLE_RATE
|
||||
assert len(base64.b64decode(unit["audio"])) == FRAME_SIZE * 4
|
||||
plan = plugin.plan_append(
|
||||
request_id="req",
|
||||
fence=DuplexFence("session"),
|
||||
session_config={},
|
||||
runtime_config={},
|
||||
seq=7,
|
||||
turn_seq=7,
|
||||
payload=unit,
|
||||
final=False,
|
||||
sampling_params=None,
|
||||
)
|
||||
assert len(plan.prompt["prompt_token_ids"]) == 1
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# E2E driver helpers (CPU-checked parts of the GPU driver) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def _audio_client(frame_count: int = 10, *, voiced_frames: int = 0) -> e2e_driver.RawRealtimeProbe:
|
||||
"""A probe double carrying only the collected events the driver's result builder reads."""
|
||||
frames = np.full((frame_count, e2e_driver.FRAME_SAMPLES), 7, dtype="<i2")
|
||||
frames[:voiced_frames] = 1000
|
||||
raw = frames.tobytes()
|
||||
event = {
|
||||
"type": "response.output_audio.delta",
|
||||
"response_id": "response-1",
|
||||
"sample_rate_hz": e2e_driver.SAMPLE_RATE_HZ,
|
||||
"metadata": {
|
||||
"vllm_omni": {
|
||||
"runtime_impl": "scheduler_data_plane",
|
||||
"uses_model_runner_scheduler": True,
|
||||
"runner_kv_backed": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
events = SimpleNamespace(events=[event], response_audio={"response-1": [raw]}, audio_bytes=lambda: raw)
|
||||
return cast(e2e_driver.RawRealtimeProbe, SimpleNamespace(events=events))
|
||||
|
||||
|
||||
def test_realtime_audio_frame_stats_separate_voiced_and_silent_frames() -> None:
|
||||
silent = np.zeros(e2e_driver.FRAME_SAMPLES, dtype="<i2")
|
||||
voiced = np.full(e2e_driver.FRAME_SAMPLES, 1000, dtype="<i2")
|
||||
|
||||
stats = e2e_driver._audio_frame_stats(
|
||||
silent.tobytes() + voiced.tobytes(),
|
||||
input_frames=3,
|
||||
voiced_frame_rms_threshold=1e-4,
|
||||
)
|
||||
|
||||
assert stats == {
|
||||
"output_frames": 2,
|
||||
"voiced_frames": 1,
|
||||
"silent_frames": 1,
|
||||
"frame_deficit": 1,
|
||||
"frame_coverage_ratio": pytest.approx(2 / 3),
|
||||
}
|
||||
|
||||
|
||||
def test_realtime_audio_frame_stats_reject_partial_codec_frame() -> None:
|
||||
partial_frame = np.zeros(e2e_driver.FRAME_SAMPLES - 1, dtype="<i2")
|
||||
|
||||
with pytest.raises(AssertionError, match="whole 80 ms codec frames"):
|
||||
e2e_driver._audio_frame_stats(partial_frame.tobytes(), input_frames=1, voiced_frame_rms_threshold=1e-4)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame_count", [10, 40])
|
||||
def test_e2e_driver_rejects_inaudible_sessions(frame_count: int) -> None:
|
||||
args = argparse.Namespace(max_frame_deficit=4, voiced_frame_rms_threshold=1e-3, min_voiced_frames=5)
|
||||
|
||||
with pytest.raises(AssertionError, match="audible speech"):
|
||||
e2e_driver._session_result(_audio_client(frame_count), input_frames=frame_count, args=args, minimum_chunks=1)
|
||||
|
||||
|
||||
def test_e2e_driver_uses_absolute_audible_floor() -> None:
|
||||
args = argparse.Namespace(max_frame_deficit=4, voiced_frame_rms_threshold=1e-3, min_voiced_frames=5)
|
||||
|
||||
e2e_driver._session_result(_audio_client(200, voiced_frames=5), input_frames=200, args=args, minimum_chunks=1)
|
||||
|
||||
|
||||
def test_e2e_driver_defaults_replacement_to_full_input() -> None:
|
||||
args = e2e_driver.parse_args(["--model", "/model", "--input-wav", "/input.wav"])
|
||||
|
||||
assert args.replacement_frames == 0
|
||||
assert args.min_voiced_frames == 5
|
||||
|
||||
|
||||
def test_e2e_driver_records_and_validates_input_sha256(tmp_path: Path) -> None:
|
||||
input_wav = tmp_path / "input.wav"
|
||||
input_wav.write_bytes(b"fixture-wav")
|
||||
|
||||
identity = e2e_driver._input_identity(
|
||||
input_wav,
|
||||
expected_sha256="bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304",
|
||||
)
|
||||
|
||||
assert identity == {
|
||||
"path": str(input_wav.resolve()),
|
||||
"sha256": "bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304",
|
||||
}
|
||||
|
||||
|
||||
def test_e2e_driver_rejects_unexpected_input_sha256(tmp_path: Path) -> None:
|
||||
input_wav = tmp_path / "input.wav"
|
||||
input_wav.write_bytes(b"fixture-wav")
|
||||
|
||||
with pytest.raises(ValueError, match="input WAV SHA-256 mismatch"):
|
||||
e2e_driver._input_identity(input_wav, expected_sha256="0" * 64)
|
||||
|
||||
|
||||
def test_e2e_driver_lets_the_server_allocate_the_session_id() -> None:
|
||||
url = e2e_driver._realtime_url("ws://127.0.0.1:8099/v1/realtime?duplex=1", "nvidia/personaplex-7b-v1")
|
||||
|
||||
assert "session_id=" not in url
|
||||
assert "duplex=1" in url and "autostart=0" in url
|
||||
@@ -14,6 +14,7 @@ from vllm_omni.model_executor.models.personaplex.duplex.policy import (
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.stage0 import (
|
||||
PersonaPlexStage0DuplexRuntime,
|
||||
PersonaPlexStage0PreparedAppend,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.personaplex_talker import (
|
||||
PersonaPlexTalkerForConditionalGeneration,
|
||||
@@ -80,13 +81,12 @@ class _FakeTalker:
|
||||
return torch.full((1, 4), value, device=device)
|
||||
|
||||
|
||||
def _duplex_info(*, seq: int, session_id: str = "session", incarnation: int = 1):
|
||||
def _duplex_info(*, seq: int, session_id: str = "session", epoch: int = 0):
|
||||
pcm = np.zeros(1920, dtype="<f4")
|
||||
return {
|
||||
"data_plane": True,
|
||||
"session_id": session_id,
|
||||
"incarnation": incarnation,
|
||||
"epoch": 0,
|
||||
"epoch": epoch,
|
||||
"seq": seq,
|
||||
"payload": {
|
||||
"format": "pcm_f32le",
|
||||
@@ -103,7 +103,7 @@ def _duplex_info(*, seq: int, session_id: str = "session", incarnation: int = 1)
|
||||
|
||||
def _runtime(*codecs: _FakeCodec) -> PersonaPlexStage0DuplexRuntime:
|
||||
voice_embeddings = torch.arange(8, dtype=torch.float32).reshape(2, 1, 1, 4)
|
||||
codec_args = {"codec": codecs[0]}
|
||||
codec_args: dict[str, object] = {"codec": codecs[0]}
|
||||
if len(codecs) > 1:
|
||||
available = iter(codecs)
|
||||
codec_args = {
|
||||
@@ -248,15 +248,20 @@ def test_decoded_pcm_is_writable_for_torch_zero_copy() -> None:
|
||||
|
||||
def _prepare_two_sessions(
|
||||
runtime: PersonaPlexStage0DuplexRuntime,
|
||||
) -> tuple[object, object, object, object]:
|
||||
) -> tuple[
|
||||
PersonaPlexStage0PreparedAppend,
|
||||
PersonaPlexStage0PreparedAppend,
|
||||
PersonaPlexStage0PreparedAppend,
|
||||
PersonaPlexStage0PreparedAppend,
|
||||
]:
|
||||
first_1 = runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
|
||||
second_1 = runtime.prepare_append(
|
||||
_duplex_info(seq=1, session_id="other", incarnation=2),
|
||||
_duplex_info(seq=1, session_id="other"),
|
||||
prompt_len=18,
|
||||
)
|
||||
first_2 = runtime.prepare_append(_duplex_info(seq=2), prompt_len=19)
|
||||
second_2 = runtime.prepare_append(
|
||||
_duplex_info(seq=2, session_id="other", incarnation=2),
|
||||
_duplex_info(seq=2, session_id="other"),
|
||||
prompt_len=19,
|
||||
)
|
||||
return first_1, second_1, first_2, second_2
|
||||
@@ -281,7 +286,7 @@ def test_stage0_session_capacity_fails_before_codec_state_is_shared() -> None:
|
||||
|
||||
with pytest.raises(RuntimeError, match="capacity 2"):
|
||||
runtime.prepare_append(
|
||||
_duplex_info(seq=1, session_id="third", incarnation=3),
|
||||
_duplex_info(seq=1, session_id="third"),
|
||||
prompt_len=18,
|
||||
)
|
||||
|
||||
@@ -292,12 +297,55 @@ def test_close_session_resets_and_reuses_released_codec() -> None:
|
||||
runtime = _runtime(first_codec, second_codec)
|
||||
_prepare_two_sessions(runtime)
|
||||
|
||||
runtime.close_session("session", 1)
|
||||
runtime.close_session("session", 0)
|
||||
|
||||
assert first_codec.reset_calls == 1
|
||||
assert second_codec.reset_calls == 0
|
||||
replacement = runtime.prepare_append(
|
||||
_duplex_info(seq=1, session_id="replacement", incarnation=4),
|
||||
_duplex_info(seq=1, session_id="replacement"),
|
||||
prompt_len=18,
|
||||
)
|
||||
assert replacement.user_codes[:, 0].tolist() == [3]
|
||||
|
||||
|
||||
def test_a_new_epoch_replays_the_prefill_and_recycles_the_codec() -> None:
|
||||
codec = _FakeCodec()
|
||||
runtime = _runtime(codec)
|
||||
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18, request_id="req-e0")
|
||||
runtime.prepare_append(_duplex_info(seq=2), prompt_len=19, request_id="req-e0")
|
||||
|
||||
# A cancel advanced the fence: the next append is seq 1 of epoch 1 on a
|
||||
# fresh Stage 0 request, so the voice/persona prefill is replayed and the
|
||||
# earlier epoch's lockstep state is released first.
|
||||
restarted = runtime.prepare_append(_duplex_info(seq=1, epoch=1), prompt_len=18, request_id="req-e1")
|
||||
|
||||
assert restarted.prefill_applied is True
|
||||
assert restarted.prompt_offset == 0
|
||||
assert restarted.user_codes.shape == (1, 8)
|
||||
assert list(runtime.sessions) == [("session", 1)]
|
||||
assert runtime.request_sessions == {"req-e1": ("session", 1)}
|
||||
assert codec.reset_calls == 1
|
||||
assert runtime.sessions[("session", 1)].codec is codec
|
||||
|
||||
|
||||
def test_a_late_finish_of_the_old_epoch_request_does_not_close_the_new_state() -> None:
|
||||
runtime = _runtime(_FakeCodec())
|
||||
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18, request_id="req-e0")
|
||||
runtime.prepare_append(_duplex_info(seq=1, epoch=1), prompt_len=18, request_id="req-e1")
|
||||
|
||||
runtime.close_request("req-e0")
|
||||
|
||||
assert list(runtime.sessions) == [("session", 1)]
|
||||
runtime.close_request("req-e1")
|
||||
assert runtime.sessions == {}
|
||||
|
||||
|
||||
def test_stage0_capacity_counts_live_epochs_not_superseded_ones() -> None:
|
||||
runtime = _runtime(_FakeCodec(), _FakeCodec())
|
||||
runtime.prepare_append(_duplex_info(seq=1), prompt_len=18)
|
||||
runtime.prepare_append(_duplex_info(seq=1, session_id="other"), prompt_len=18)
|
||||
|
||||
# Restarting one session must not need a third codec.
|
||||
runtime.prepare_append(_duplex_info(seq=1, epoch=1), prompt_len=18)
|
||||
|
||||
assert sorted(runtime.sessions) == [("other", 0), ("session", 1)]
|
||||
@@ -1,420 +0,0 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
||||
|
||||
import base64
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from vllm.sampling_params import SamplingParams
|
||||
from vllm_omni.engine.duplex.runtime import (
|
||||
DuplexInputMode,
|
||||
)
|
||||
from vllm_omni.entrypoints.duplex.runtime_adapter import (
|
||||
ServingRuntimeConfigError,
|
||||
)
|
||||
|
||||
from tests.e2e.online_serving import personaplex_realtime_duplex as e2e_driver
|
||||
from vllm_omni.config.stage_config import (
|
||||
load_deploy_config,
|
||||
merge_pipeline_deploy,
|
||||
)
|
||||
from vllm_omni.engine.duplex.messages import DuplexFence
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.config import DEFAULT_PERSONA
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.data_plane import (
|
||||
PersonaPlexDataPlaneContext,
|
||||
PersonaPlexDataPlaneSession,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.input import (
|
||||
PersonaPlexPcmAppendBuffer,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.runtime_extension import (
|
||||
PersonaPlexDuplexRuntimeExtension,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.duplex.serving_adapter import (
|
||||
PersonaPlexServingRuntimeAdapter,
|
||||
)
|
||||
from vllm_omni.model_executor.models.personaplex.pipeline import (
|
||||
PERSONAPLEX_PIPELINE,
|
||||
)
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
|
||||
def _pcm_payload(samples: np.ndarray, *, sample_rate_hz: int = 24000) -> dict[str, object]:
|
||||
samples = np.ascontiguousarray(samples, dtype="<f4")
|
||||
return {
|
||||
"type": "audio",
|
||||
"format": "pcm_f32le",
|
||||
"sample_rate_hz": sample_rate_hz,
|
||||
"audio": base64.b64encode(samples.tobytes()).decode("ascii"),
|
||||
}
|
||||
|
||||
|
||||
def _audio_client(
|
||||
frame_count: int = 10,
|
||||
*,
|
||||
voiced_frames: int = 0,
|
||||
) -> SimpleNamespace:
|
||||
frames = np.full((frame_count, e2e_driver.FRAME_SAMPLES), 7, dtype="<i2")
|
||||
frames[:voiced_frames] = 1000
|
||||
raw = frames.tobytes()
|
||||
event = {
|
||||
"type": "response.output_audio.delta",
|
||||
"response_id": "response-1",
|
||||
"sample_rate_hz": e2e_driver.SAMPLE_RATE_HZ,
|
||||
"metadata": {
|
||||
"vllm_omni": {
|
||||
"runtime_impl": "scheduler_data_plane",
|
||||
"uses_model_runner_scheduler": True,
|
||||
"runner_kv_backed": True,
|
||||
}
|
||||
},
|
||||
}
|
||||
events = SimpleNamespace(
|
||||
events=[event],
|
||||
response_audio={"response-1": [raw]},
|
||||
audio_bytes=lambda: raw,
|
||||
)
|
||||
return SimpleNamespace(events=events)
|
||||
|
||||
|
||||
def test_realtime_audio_frame_stats_separate_voiced_and_silent_frames() -> None:
|
||||
silent = np.zeros(e2e_driver.FRAME_SAMPLES, dtype="<i2")
|
||||
voiced = np.full(e2e_driver.FRAME_SAMPLES, 1000, dtype="<i2")
|
||||
|
||||
stats = e2e_driver._audio_frame_stats(
|
||||
silent.tobytes() + voiced.tobytes(),
|
||||
input_frames=3,
|
||||
voiced_frame_rms_threshold=1e-4,
|
||||
)
|
||||
|
||||
assert stats == {
|
||||
"output_frames": 2,
|
||||
"voiced_frames": 1,
|
||||
"silent_frames": 1,
|
||||
"frame_deficit": 1,
|
||||
"frame_coverage_ratio": pytest.approx(2 / 3),
|
||||
}
|
||||
|
||||
|
||||
def test_realtime_audio_frame_stats_reject_partial_codec_frame() -> None:
|
||||
partial_frame = np.zeros(e2e_driver.FRAME_SAMPLES - 1, dtype="<i2")
|
||||
|
||||
with pytest.raises(AssertionError, match="whole 80 ms codec frames"):
|
||||
e2e_driver._audio_frame_stats(
|
||||
partial_frame.tobytes(),
|
||||
input_frames=1,
|
||||
voiced_frame_rms_threshold=1e-4,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("frame_count", [10, 40])
|
||||
def test_e2e_driver_rejects_inaudible_sessions(frame_count: int) -> None:
|
||||
args = SimpleNamespace(
|
||||
max_frame_deficit=4,
|
||||
voiced_frame_rms_threshold=1e-3,
|
||||
min_voiced_frames=5,
|
||||
)
|
||||
|
||||
with pytest.raises(AssertionError, match="audible speech"):
|
||||
e2e_driver._session_result(
|
||||
_audio_client(frame_count),
|
||||
input_frames=frame_count,
|
||||
args=args,
|
||||
minimum_chunks=1,
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_driver_uses_absolute_audible_floor() -> None:
|
||||
args = SimpleNamespace(
|
||||
max_frame_deficit=4,
|
||||
voiced_frame_rms_threshold=1e-3,
|
||||
min_voiced_frames=5,
|
||||
)
|
||||
|
||||
e2e_driver._session_result(
|
||||
_audio_client(200, voiced_frames=5),
|
||||
input_frames=200,
|
||||
args=args,
|
||||
minimum_chunks=1,
|
||||
)
|
||||
|
||||
|
||||
def test_e2e_driver_defaults_replacement_to_full_input() -> None:
|
||||
args = e2e_driver.parse_args(
|
||||
[
|
||||
"--model",
|
||||
"/model",
|
||||
"--input-wav",
|
||||
"/input.wav",
|
||||
]
|
||||
)
|
||||
|
||||
assert args.replacement_frames == 0
|
||||
assert args.min_voiced_frames == 5
|
||||
|
||||
|
||||
def test_e2e_driver_records_and_validates_input_sha256(tmp_path: Path) -> None:
|
||||
input_wav = tmp_path / "input.wav"
|
||||
input_wav.write_bytes(b"fixture-wav")
|
||||
|
||||
identity = e2e_driver._input_identity(
|
||||
input_wav,
|
||||
expected_sha256="bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304",
|
||||
)
|
||||
|
||||
assert identity == {
|
||||
"path": str(input_wav.resolve()),
|
||||
"sha256": "bade4b3c163edde390ff391207d34a887257d8a9cc3b621cc8c618b6e6761304",
|
||||
}
|
||||
|
||||
|
||||
def test_e2e_driver_rejects_unexpected_input_sha256(tmp_path: Path) -> None:
|
||||
input_wav = tmp_path / "input.wav"
|
||||
input_wav.write_bytes(b"fixture-wav")
|
||||
|
||||
with pytest.raises(ValueError, match="input WAV SHA-256 mismatch"):
|
||||
e2e_driver._input_identity(input_wav, expected_sha256="0" * 64)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("attribute", "expected"),
|
||||
[
|
||||
(
|
||||
"duplex_runtime_extension",
|
||||
"vllm_omni.model_executor.models.personaplex.duplex.runtime_extension.PersonaPlexDuplexRuntimeExtension",
|
||||
),
|
||||
(
|
||||
"duplex_serving_adapter",
|
||||
"vllm_omni.model_executor.models.personaplex.duplex.serving_adapter.PersonaPlexServingRuntimeAdapter",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_personaplex_pipeline_registers_unified_duplex_components(
|
||||
attribute: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
assert PERSONAPLEX_PIPELINE.duplex_control_enabled is True
|
||||
assert getattr(PERSONAPLEX_PIPELINE, attribute) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("engine_arg", "expected"),
|
||||
[
|
||||
("skip_tokenizer_init", True),
|
||||
("enable_prefix_caching", False),
|
||||
],
|
||||
)
|
||||
def test_personaplex_stage_engine_args(
|
||||
engine_arg: str,
|
||||
expected: bool,
|
||||
) -> None:
|
||||
deploy_path = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml"
|
||||
stages = merge_pipeline_deploy(
|
||||
PERSONAPLEX_PIPELINE,
|
||||
load_deploy_config(deploy_path),
|
||||
)
|
||||
|
||||
assert [stage.yaml_engine_args.get(engine_arg) for stage in stages] == [
|
||||
expected,
|
||||
expected,
|
||||
]
|
||||
|
||||
|
||||
def test_personaplex_duplex_capacity_is_propagated_to_all_model_stages() -> None:
|
||||
deploy_path = Path(__file__).parents[5] / "vllm_omni" / "deploy" / "personaplex.yaml"
|
||||
deploy = load_deploy_config(deploy_path)
|
||||
stages = merge_pipeline_deploy(PERSONAPLEX_PIPELINE, deploy)
|
||||
|
||||
assert deploy.duplex_session.max_sessions == 2
|
||||
assert [stage.yaml_engine_args.get("duplex_max_sessions") for stage in stages] == [2, 2]
|
||||
assert "personaplex_codec_max_sessions" not in deploy.connectors["connector_of_shared_memory"]["extra"]
|
||||
|
||||
|
||||
def test_personaplex_capabilities_are_honest() -> None:
|
||||
single = PersonaPlexServingRuntimeAdapter(lambda *_: None).capabilities(max_sessions=1)
|
||||
multi = PersonaPlexServingRuntimeAdapter(lambda *_: None).capabilities(max_sessions=2)
|
||||
|
||||
assert multi.implementation_level == "model_native_duplex"
|
||||
assert multi.input_modes == ["append_audio_chunk"]
|
||||
assert multi.chunk_period_ms == 80
|
||||
assert single.supports_multi_session is False
|
||||
assert multi.supports_multi_session is True
|
||||
assert multi.supports_multi_session_same_replica is True
|
||||
assert multi.supports_barge_in is False
|
||||
|
||||
|
||||
def test_personaplex_runtime_config_update_rejects_changed_persona() -> None:
|
||||
adapter = PersonaPlexServingRuntimeAdapter(lambda *_: None)
|
||||
short_persona = "You are a helpful assistant."
|
||||
longer_persona = (
|
||||
"You are now a swashbuckling pirate captain who speaks entirely in "
|
||||
"nautical slang, references buried treasure constantly, and never "
|
||||
"breaks character no matter what the user asks."
|
||||
)
|
||||
current = {"personaplex_persona": short_persona, "personaplex_voice_prompt": "NATF2.pt"}
|
||||
|
||||
with pytest.raises(ServingRuntimeConfigError, match="persona"):
|
||||
adapter.runtime_config_for_update(
|
||||
SimpleNamespace(instructions=longer_persona, extra_body={}),
|
||||
current,
|
||||
)
|
||||
|
||||
unchanged = adapter.runtime_config_for_update(
|
||||
SimpleNamespace(instructions=short_persona, extra_body={}),
|
||||
current,
|
||||
)
|
||||
assert unchanged["personaplex_persona"] == short_persona
|
||||
|
||||
|
||||
def test_personaplex_runtime_config_update_rejects_changed_voice() -> None:
|
||||
adapter = PersonaPlexServingRuntimeAdapter(lambda *_: None)
|
||||
current = {"personaplex_persona": DEFAULT_PERSONA, "personaplex_voice_prompt": "NATF2.pt"}
|
||||
|
||||
with pytest.raises(ServingRuntimeConfigError, match="voice"):
|
||||
adapter.runtime_config_for_update(
|
||||
SimpleNamespace(instructions=None, voice="OtherVoice.pt", extra_body={}),
|
||||
current,
|
||||
)
|
||||
|
||||
unchanged = adapter.runtime_config_for_update(
|
||||
SimpleNamespace(instructions=None, voice="NATF2.pt", extra_body={}),
|
||||
current,
|
||||
)
|
||||
assert unchanged["personaplex_voice_prompt"] == "NATF2.pt"
|
||||
|
||||
|
||||
def test_personaplex_rejects_native_duplex_opt_out_flag() -> None:
|
||||
# PersonaPlex is always model-native; the per-session opt-in knob is
|
||||
# server-owned there, under both the canonical name and its deprecated
|
||||
# model-prefixed alias.
|
||||
adapter = PersonaPlexServingRuntimeAdapter(lambda *_: None)
|
||||
|
||||
with pytest.raises(ServingRuntimeConfigError, match="native_duplex"):
|
||||
adapter.validate_client_extra_body({"native_duplex": False})
|
||||
with pytest.raises(ServingRuntimeConfigError, match="minicpmo45_native_duplex"):
|
||||
adapter.validate_client_extra_body({"minicpmo45_native_duplex": False})
|
||||
|
||||
|
||||
def test_pcm_buffer_emits_one_80ms_frame_transactionally() -> None:
|
||||
buffer = PersonaPlexPcmAppendBuffer()
|
||||
reservation = buffer.prepare_append(
|
||||
_pcm_payload(np.arange(1920, dtype=np.float32)),
|
||||
operation_id="op-1",
|
||||
chunk_period_ms=80,
|
||||
allow_emit=True,
|
||||
)
|
||||
|
||||
assert reservation is not None
|
||||
assert reservation.byte_count == 1920 * 4
|
||||
assert buffer.pending_byte_count == 0
|
||||
reservation.rollback()
|
||||
assert buffer.pending_byte_count == 1920 * 4
|
||||
|
||||
|
||||
def test_pcm_buffer_rejects_changed_sample_rate() -> None:
|
||||
buffer = PersonaPlexPcmAppendBuffer()
|
||||
assert (
|
||||
buffer.prepare_append(
|
||||
_pcm_payload(np.zeros(100, np.float32)),
|
||||
operation_id="op-1",
|
||||
chunk_period_ms=80,
|
||||
allow_emit=False,
|
||||
)
|
||||
is None
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="sample_rate"):
|
||||
buffer.prepare_append(
|
||||
_pcm_payload(np.zeros(100, np.float32), sample_rate_hz=16000),
|
||||
operation_id="op-2",
|
||||
chunk_period_ms=80,
|
||||
allow_emit=False,
|
||||
)
|
||||
|
||||
|
||||
def test_pcm_buffer_rejects_non_finite_samples() -> None:
|
||||
buffer = PersonaPlexPcmAppendBuffer()
|
||||
|
||||
with pytest.raises(ValueError, match="finite"):
|
||||
buffer.prepare_append(
|
||||
_pcm_payload(np.array([0.0, np.nan], np.float32)),
|
||||
operation_id="op-1",
|
||||
chunk_period_ms=80,
|
||||
allow_emit=False,
|
||||
)
|
||||
|
||||
|
||||
def test_runtime_extension_builds_one_frame_scheduler_append() -> None:
|
||||
extension = PersonaPlexDuplexRuntimeExtension()
|
||||
defaults = (
|
||||
SamplingParams(temperature=0.8, top_k=10, max_tokens=10),
|
||||
SamplingParams(max_tokens=1024),
|
||||
)
|
||||
|
||||
configured = extension.configure_sampling_params(runtime_config={}, defaults=defaults)
|
||||
assert configured[0].temperature == 0.0
|
||||
assert configured[0].top_k == 1
|
||||
assert configured[0].max_tokens == 1
|
||||
assert configured[1] is defaults[1]
|
||||
|
||||
plan = extension.plan_append(
|
||||
request_id="req",
|
||||
fence=DuplexFence("session", incarnation=2, epoch=3),
|
||||
session_config={"instructions": "Be concise."},
|
||||
runtime_config={"personaplex_prefill_slots": 4},
|
||||
seq=1,
|
||||
turn_seq=1,
|
||||
mode=DuplexInputMode.APPEND_AUDIO_CHUNK,
|
||||
payload=_pcm_payload(np.zeros(1920, np.float32)),
|
||||
final=False,
|
||||
sampling_params=configured[0],
|
||||
)
|
||||
assert len(plan.prompt["prompt_token_ids"]) == 5
|
||||
duplex = plan.prompt["model_intermediate_buffer"]["duplex"]
|
||||
assert duplex["session_id"] == "session"
|
||||
assert duplex["incarnation"] == 2
|
||||
assert duplex["epoch"] == 3
|
||||
assert duplex["seq"] == 1
|
||||
assert duplex["data_plane"] is True
|
||||
|
||||
|
||||
def test_projector_emits_only_cumulative_audio_and_text_suffixes() -> None:
|
||||
encoded_sizes: list[int] = []
|
||||
|
||||
def encode_audio(audio, _sample_rate, _response_format, _speed):
|
||||
size = int(np.asarray(audio, dtype=np.float32).size)
|
||||
encoded_sizes.append(size)
|
||||
return f"audio-{size}"
|
||||
|
||||
projector = PersonaPlexDataPlaneSession(encode_audio)
|
||||
projector.begin_request("req")
|
||||
context = PersonaPlexDataPlaneContext(response_format="wav")
|
||||
|
||||
first = SimpleNamespace(
|
||||
request_id="req",
|
||||
outputs=[SimpleNamespace(text="he", multimodal_output={})],
|
||||
multimodal_output={"model_outputs": np.arange(4, dtype=np.float32), "sr": 24000},
|
||||
finished=False,
|
||||
)
|
||||
second = SimpleNamespace(
|
||||
request_id="req",
|
||||
outputs=[SimpleNamespace(text="hello", multimodal_output={})],
|
||||
multimodal_output={"model_outputs": np.arange(6, dtype=np.float32), "sr": 24000},
|
||||
finished=False,
|
||||
)
|
||||
|
||||
projected = list(
|
||||
projector.project(
|
||||
{"data_plane_outputs": [first, second]},
|
||||
context=context,
|
||||
)
|
||||
)
|
||||
|
||||
assert encoded_sizes == [4, 2]
|
||||
assert [item["text"] for item in projected] == ["he", "llo"]
|
||||
assert [item["audio_data"] for item in projected] == ["audio-4", "audio-2"]
|
||||
assert all(item["sample_rate_hz"] == 24000 for item in projected)
|
||||
assert all(item["end_of_turn"] is False for item in projected)
|
||||
Reference in new issue
Block a user