[Bugfix][Frontend] Preserve duplex chat audio format and duration (#7316)
Signed-off-by: Nolen Liang <nliang@nvidia.com>
This commit is contained in:
1 parent
1b6cd282f0
commit
e0c4f97f32
13 files changed
+684
-40
No files matched your search
@@ -462,6 +462,54 @@ def test_duplex_playback_ack_tracks_committed_cursor_separately():
|
||||
assert session.playback.committed_ms == 2_000
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sample_rate_hz,frame_count", [(22_050, 23), (24_000, 24)])
|
||||
def test_duplex_generated_audio_rounds_only_cumulative_frames(sample_rate_hz: int, frame_count: int):
|
||||
session = DuplexSessionRegistry().create()
|
||||
response_id = session.begin_response()
|
||||
|
||||
durations = [
|
||||
session.record_generated_audio(response_id, frame_count=1, sample_rate_hz=sample_rate_hz)
|
||||
for _ in range(frame_count)
|
||||
]
|
||||
|
||||
assert durations[:-1] == [0] * (frame_count - 1)
|
||||
assert durations[-1] == 1
|
||||
assert session.playback.generated_ms == 1
|
||||
assert session.playback.sent_ms == 0
|
||||
assert session.playback.played_ms == 0
|
||||
|
||||
|
||||
def test_duplex_generated_audio_rejects_rate_change_without_advancing():
|
||||
session = DuplexSessionRegistry().create()
|
||||
response_id = session.begin_response()
|
||||
session.record_generated_audio(response_id, frame_count=2205, sample_rate_hz=22_050)
|
||||
|
||||
with pytest.raises(ValueError, match="sample rate changed"):
|
||||
session.record_generated_audio(response_id, frame_count=2400, sample_rate_hz=24_000)
|
||||
|
||||
assert session.playback.generated_ms == 100
|
||||
assert session.playback.sent_ms == 0
|
||||
|
||||
|
||||
def test_duplex_late_audio_accounting_cannot_change_new_response():
|
||||
session = DuplexSessionRegistry().create()
|
||||
old_response = session.begin_response()
|
||||
session.record_generated_audio(old_response, frame_count=2205, sample_rate_hz=22_050)
|
||||
session.barge_in()
|
||||
new_response = session.begin_response()
|
||||
session.record_generated_audio(new_response, frame_count=2400, sample_rate_hz=48_000)
|
||||
current_state = session.turn_state
|
||||
|
||||
assert session.record_generated_audio(old_response, frame_count=2205, sample_rate_hz=22_050) is None
|
||||
session.mark_audio_sent(100, response_id=old_response, text_chars=99)
|
||||
|
||||
assert session.playback_for_response(old_response).sent_ms == 100
|
||||
assert session.playback.generated_ms == 50
|
||||
assert session.playback.sent_ms == 0
|
||||
assert session.assistant_audio_text_marks == ()
|
||||
assert session.turn_state == current_state
|
||||
|
||||
|
||||
def test_duplex_history_commit_uses_audio_text_alignment_marks():
|
||||
registry = DuplexSessionRegistry()
|
||||
session = registry.create()
|
||||
|
||||
@@ -122,6 +122,90 @@ def test_event_journal_overflow_is_explicit_and_does_not_evict_silently() -> Non
|
||||
journal.replay_after(0)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("journal", [False, True])
|
||||
async def test_registry_failed_send_is_accepted_only_when_journaled(mocker, journal: bool) -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
accepted = mocker.Mock()
|
||||
expected_calls = [mocker.call()] if journal else []
|
||||
|
||||
async def failing_send(payload):
|
||||
assert accepted.call_args_list == expected_calls
|
||||
raise RuntimeError("transport lost")
|
||||
|
||||
await registry.create("sid-send-failure", incarnation=0, send=failing_send, close=mocker.AsyncMock())
|
||||
|
||||
with pytest.raises(RuntimeError, match="transport lost"):
|
||||
await registry.send_event(
|
||||
"sid-send-failure",
|
||||
{"type": "response.audio.delta", "delta": "AAAA"},
|
||||
journal=journal,
|
||||
on_accepted=accepted,
|
||||
)
|
||||
|
||||
assert accepted.call_args_list == expected_calls
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("journal", [False, True])
|
||||
async def test_registry_detached_event_is_accepted_only_when_journaled(mocker, journal: bool) -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
send = mocker.AsyncMock()
|
||||
accepted = mocker.Mock()
|
||||
created = await registry.create("sid-detached-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
|
||||
await registry.detach("sid-detached-acceptance", attachment_generation=created.attachment_generation)
|
||||
|
||||
entry = await registry.send_event(
|
||||
"sid-detached-acceptance",
|
||||
{"type": "response.audio.delta", "delta": "AAAA"},
|
||||
journal=journal,
|
||||
on_accepted=accepted,
|
||||
)
|
||||
|
||||
send.assert_not_awaited()
|
||||
assert (entry is not None) is journal
|
||||
assert accepted.call_count == int(journal)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_unjournaled_event_is_accepted_after_successful_send(mocker) -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=4096)
|
||||
accepted = mocker.Mock()
|
||||
|
||||
async def send(payload):
|
||||
accepted.assert_not_called()
|
||||
|
||||
await registry.create("sid-live-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
|
||||
|
||||
entry = await registry.send_event(
|
||||
"sid-live-acceptance",
|
||||
{"type": "response.audio.delta", "delta": "AAAA"},
|
||||
journal=False,
|
||||
on_accepted=accepted,
|
||||
)
|
||||
|
||||
assert entry is None
|
||||
accepted.assert_called_once_with()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_overflow_does_not_accept_or_send_event(mocker) -> None:
|
||||
registry = DuplexSessionAttachmentRegistry(replay_ttl_s=60.0, replay_max_bytes_per_session=100)
|
||||
accepted = mocker.Mock()
|
||||
send = mocker.AsyncMock()
|
||||
await registry.create("sid-overflow-acceptance", incarnation=0, send=send, close=mocker.AsyncMock())
|
||||
|
||||
with pytest.raises(DuplexJournalOverflowError, match="byte limit"):
|
||||
await registry.send_event(
|
||||
"sid-overflow-acceptance",
|
||||
{"type": "response.audio.delta", "delta": "A" * 200},
|
||||
on_accepted=accepted,
|
||||
)
|
||||
|
||||
accepted.assert_not_called()
|
||||
send.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_registry_resume_rotates_token_replays_and_atomically_replaces_attachment() -> None:
|
||||
clock = _Clock()
|
||||
|
||||
@@ -12,6 +12,8 @@ Covers:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
from io import BytesIO
|
||||
|
||||
import numpy as np
|
||||
@@ -27,6 +29,8 @@ from vllm_omni.entrypoints.openai.protocol.audio import (
|
||||
SUPPORTED_CHAT_AUDIO_FORMATS,
|
||||
CreateAudio,
|
||||
)
|
||||
from vllm_omni.outputs import OmniRequestOutput
|
||||
from vllm_omni.outputs.mm_outputs import MultimodalCompletionOutput, MultimodalPayload
|
||||
|
||||
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
||||
|
||||
@@ -65,6 +69,13 @@ class TestCreateAudio:
|
||||
)
|
||||
response = mixin.create_audio(audio_obj)
|
||||
assert len(response.audio_data) > 0
|
||||
assert response.audio_metadata is not None
|
||||
assert response.audio_metadata.model_dump() == {
|
||||
"format": fmt,
|
||||
"sample_rate_hz": 24000,
|
||||
"frame_count": 24000,
|
||||
"channels": 1,
|
||||
}
|
||||
|
||||
def test_wav_magic_bytes(self, mixin, audio_tensor):
|
||||
audio_obj = CreateAudio(
|
||||
@@ -113,10 +124,9 @@ class TestCreateAudio:
|
||||
response = mixin.create_audio(audio_obj)
|
||||
assert response.audio_data[:4] == b"RIFF"
|
||||
assert response.media_type == "audio/wav"
|
||||
assert response.audio_metadata.format == "wav"
|
||||
|
||||
def test_base64_encoding(self, mixin, audio_tensor):
|
||||
import base64
|
||||
|
||||
audio_obj = CreateAudio(
|
||||
audio_tensor=audio_tensor,
|
||||
sample_rate=24000,
|
||||
@@ -128,21 +138,33 @@ class TestCreateAudio:
|
||||
decoded = base64.b64decode(response.audio_data)
|
||||
assert decoded[:4] == b"RIFF"
|
||||
|
||||
def test_resamples_wav_to_requested_output_rate(self, mixin, audio_tensor):
|
||||
@pytest.mark.parametrize(
|
||||
"channels,speed,frame_count",
|
||||
[(1, 1.0, 8000), (2, 2.0, 4000)],
|
||||
ids=["mono-resampled", "stereo-resampled-faster"],
|
||||
)
|
||||
def test_metadata_matches_transformed_waveform(self, mixin, audio_tensor, channels, speed, frame_count):
|
||||
response = mixin.create_audio(
|
||||
CreateAudio(
|
||||
audio_tensor=audio_tensor,
|
||||
audio_tensor=audio_tensor if channels == 1 else np.stack((audio_tensor, audio_tensor)),
|
||||
sample_rate=24000,
|
||||
output_sample_rate=8000,
|
||||
response_format="wav",
|
||||
speed=1.0,
|
||||
speed=speed,
|
||||
base64_encode=False,
|
||||
)
|
||||
)
|
||||
|
||||
with soundfile.SoundFile(BytesIO(response.audio_data)) as audio_file:
|
||||
assert audio_file.samplerate == 8000
|
||||
assert audio_file.frames == 8000
|
||||
assert audio_file.frames == frame_count
|
||||
assert audio_file.channels == channels
|
||||
assert response.audio_metadata.model_dump() == {
|
||||
"format": "wav",
|
||||
"sample_rate_hz": audio_file.samplerate,
|
||||
"frame_count": audio_file.frames,
|
||||
"channels": audio_file.channels,
|
||||
}
|
||||
|
||||
|
||||
class TestStreamingAudioResampler:
|
||||
@@ -262,3 +284,65 @@ class TestResolveAudioFormat:
|
||||
request = self._make_request({"format": fmt, "voice": "alloy"})
|
||||
result = serving_chat._resolve_audio_format(request)
|
||||
assert not isinstance(result, ErrorResponse), f"Format {fmt} should be accepted"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stream", [False, True])
|
||||
@pytest.mark.parametrize("fmt", ["pcm", "wav"])
|
||||
def test_chat_audio_metadata_survives_response_serialization(stream, fmt):
|
||||
from vllm.entrypoints.openai.chat_completion.protocol import ChatCompletionRequest
|
||||
from vllm.entrypoints.openai.engine.protocol import ErrorResponse, UsageInfo
|
||||
|
||||
from vllm_omni.entrypoints.openai.protocol.chat_completion import (
|
||||
OmniChatCompletionResponse,
|
||||
OmniChatCompletionStreamResponse,
|
||||
)
|
||||
from vllm_omni.entrypoints.openai.serving_chat import OmniOpenAIServingChat
|
||||
|
||||
serving_chat = object.__new__(OmniOpenAIServingChat)
|
||||
output = MultimodalCompletionOutput(
|
||||
index=0,
|
||||
text="",
|
||||
token_ids=[],
|
||||
cumulative_logprob=None,
|
||||
logprobs=None,
|
||||
multimodal_output=MultimodalPayload.from_dict(
|
||||
{"audio": [torch.zeros(17), torch.zeros(23)], "sr": torch.tensor(22050)}
|
||||
),
|
||||
finish_reason="stop",
|
||||
stop_reason=None,
|
||||
)
|
||||
request = ChatCompletionRequest(
|
||||
model="test-model",
|
||||
messages=[{"role": "user", "content": "hello"}],
|
||||
audio={"format": fmt, "voice": "alloy"},
|
||||
)
|
||||
choices = serving_chat._create_audio_choice(
|
||||
OmniRequestOutput(outputs=[output], final_output_type="audio"), "assistant", request, stream=stream
|
||||
)
|
||||
assert not isinstance(choices, ErrorResponse)
|
||||
response_type = OmniChatCompletionStreamResponse if stream else OmniChatCompletionResponse
|
||||
response = response_type(
|
||||
id="chatcmpl-audio-metadata",
|
||||
created=0,
|
||||
model="test-model",
|
||||
choices=choices,
|
||||
usage=UsageInfo(prompt_tokens=0, completion_tokens=0, total_tokens=0),
|
||||
)
|
||||
|
||||
serialized = json.loads(response.model_dump_json(exclude_unset=True))
|
||||
choice = serialized["choices"][0]
|
||||
frame_count = 23 if stream else 40
|
||||
assert choice["audio_metadata"] == {
|
||||
"format": fmt,
|
||||
"sample_rate_hz": 22050,
|
||||
"frame_count": frame_count,
|
||||
"channels": 1,
|
||||
}
|
||||
audio = choice["delta"]["content"] if stream else choice["message"]["audio"]["data"]
|
||||
raw = base64.b64decode(audio)
|
||||
if fmt == "wav":
|
||||
with soundfile.SoundFile(BytesIO(raw)) as audio_file:
|
||||
assert audio_file.frames == frame_count
|
||||
assert audio_file.samplerate == 22050
|
||||
else:
|
||||
assert len(raw) == frame_count * 2
|
||||
@@ -9,6 +9,8 @@ import io
|
||||
import json
|
||||
import struct
|
||||
import wave
|
||||
from collections.abc import Callable
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
|
||||
@@ -2182,7 +2184,8 @@ async def test_minicpmo_native_session_update_rejects_native_duplex_mode_flip():
|
||||
assert "session.updated" not in ws.sent_types()
|
||||
|
||||
|
||||
def test_native_realtime_protocol_audio_delta_preserves_sample_rate_hz():
|
||||
@pytest.mark.parametrize("sample_rate_hz", [22_050, 24_000])
|
||||
def test_native_realtime_protocol_audio_delta_preserves_sample_rate_hz(sample_rate_hz: int):
|
||||
ws = TimedWebSocket()
|
||||
protocol = NativeRealtimeSessionProtocol(ws) # type: ignore[arg-type]
|
||||
|
||||
@@ -2192,7 +2195,8 @@ def test_native_realtime_protocol_audio_delta_preserves_sample_rate_hz():
|
||||
"response_id": "resp-a",
|
||||
"audio": "AAAA",
|
||||
"format": "pcm",
|
||||
"sample_rate_hz": 24000,
|
||||
"sample_rate_hz": sample_rate_hz,
|
||||
"audio_duration_ms": 135,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2201,7 +2205,8 @@ def test_native_realtime_protocol_audio_delta_preserves_sample_rate_hz():
|
||||
]
|
||||
assert {payload["type"] for payload in audio_events} == {"response.output_audio.delta"}
|
||||
assert {payload["format"] for payload in audio_events} == {"pcm16"}
|
||||
assert {payload["sample_rate_hz"] for payload in audio_events} == {24000}
|
||||
assert {payload["sample_rate_hz"] for payload in audio_events} == {sample_rate_hz}
|
||||
assert audio_events[0]["metadata"]["audio_duration_ms"] == 135
|
||||
|
||||
|
||||
def test_native_realtime_protocol_ignores_removed_legacy_event_switches():
|
||||
@@ -5023,8 +5028,10 @@ async def test_duplex_chat_audio_stream_uses_output_audio_delta_event():
|
||||
response_id = session.begin_response()
|
||||
sent: list[dict[str, Any]] = []
|
||||
|
||||
async def send_json(data: dict[str, Any]) -> None:
|
||||
async def send_json(data: dict[str, Any], *, on_accepted: Callable[[], None] | None = None) -> None:
|
||||
sent.append(data)
|
||||
if on_accepted is not None:
|
||||
on_accepted()
|
||||
|
||||
await handler._emit_chat_payload(
|
||||
session,
|
||||
@@ -5034,7 +5041,13 @@ async def test_duplex_chat_audio_stream_uses_output_audio_delta_event():
|
||||
{
|
||||
"delta": {
|
||||
"content": "AAAA",
|
||||
}
|
||||
},
|
||||
"audio_metadata": {
|
||||
"format": "wav",
|
||||
"sample_rate_hz": 22_050,
|
||||
"frame_count": 2205,
|
||||
"channels": 1,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
@@ -5051,8 +5064,222 @@ async def test_duplex_chat_audio_stream_uses_output_audio_delta_event():
|
||||
"epoch": 0,
|
||||
"audio": "AAAA",
|
||||
"format": "wav",
|
||||
"sample_rate_hz": 22_050,
|
||||
"channels": 1,
|
||||
"audio_duration_ms": 100,
|
||||
}
|
||||
]
|
||||
assert session.playback.generated_ms == 100
|
||||
assert session.playback.sent_ms == 100
|
||||
assert session.playback.played_ms == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("response_format", ["pcm", "wav"])
|
||||
def test_duplex_chat_request_uses_response_audio_format(response_format: str):
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=TurnBasedFakeChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
extra_audio = {"format": "flac", "voice": "alloy"}
|
||||
session = DuplexSession(
|
||||
session_id="sid-chat-audio-format",
|
||||
config=DuplexSessionConfig(response_format=response_format, extra_body={"audio": extra_audio}),
|
||||
)
|
||||
session.begin_response()
|
||||
|
||||
request = handler._build_chat_request(session, "format-request")
|
||||
|
||||
assert request.audio == {"format": response_format, "voice": "alloy"}
|
||||
assert extra_audio == {"format": "flac", "voice": "alloy"}
|
||||
|
||||
|
||||
def _chat_audio_completion_payload(*, frame_count: int = 2205, sample_rate_hz: int = 22_050) -> dict[str, object]:
|
||||
return {
|
||||
"modality": "audio",
|
||||
"choices": [
|
||||
{
|
||||
"delta": {"content": "AAAA"},
|
||||
"audio_metadata": {
|
||||
"format": "pcm",
|
||||
"sample_rate_hz": sample_rate_hz,
|
||||
"frame_count": frame_count,
|
||||
"channels": 1,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_chat_full_audio_message_preserves_actual_format_and_duration():
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=TurnBasedFakeChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
session = DuplexSession(session_id="sid-chat-full-audio", config=DuplexSessionConfig(response_format="wav"))
|
||||
response_id = session.begin_response()
|
||||
sent: list[dict[str, object]] = []
|
||||
|
||||
async def send_json(data: dict[str, object], *, on_accepted: Callable[[], None] | None = None) -> None:
|
||||
sent.append(data)
|
||||
if on_accepted is not None:
|
||||
on_accepted()
|
||||
|
||||
await handler._emit_chat_payload(
|
||||
session,
|
||||
{
|
||||
"choices": [
|
||||
{
|
||||
"message": {"role": "assistant", "content": "hello", "audio": {"data": "AAAA"}},
|
||||
"audio_metadata": {
|
||||
"format": "pcm",
|
||||
"sample_rate_hz": 22_050,
|
||||
"frame_count": 2205,
|
||||
"channels": 1,
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
session.epoch,
|
||||
response_id,
|
||||
send_json,
|
||||
)
|
||||
|
||||
assert [event["type"] for event in sent] == ["response.text.delta", "response.output_audio.delta"]
|
||||
assert sent[1]["audio"] == "AAAA"
|
||||
assert sent[1]["format"] == "pcm"
|
||||
assert sent[1]["sample_rate_hz"] == 22_050
|
||||
assert sent[1]["audio_duration_ms"] == 100
|
||||
assert session.assistant_text_buffer == ("hello",)
|
||||
assert session.playback.sent_ms == 100
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_chat_audio_does_not_round_each_fragment():
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=TurnBasedFakeChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
session = DuplexSession(session_id="sid-chat-sub-ms", config=DuplexSessionConfig())
|
||||
response_id = session.begin_response()
|
||||
sent: list[dict[str, object]] = []
|
||||
|
||||
async def send_json(data: dict[str, object], *, on_accepted: Callable[[], None] | None = None) -> None:
|
||||
sent.append(data)
|
||||
if on_accepted is not None:
|
||||
on_accepted()
|
||||
|
||||
for _ in range(23):
|
||||
await handler._emit_chat_payload(
|
||||
session,
|
||||
_chat_audio_completion_payload(frame_count=1),
|
||||
session.epoch,
|
||||
response_id,
|
||||
send_json,
|
||||
)
|
||||
|
||||
assert [event["audio_duration_ms"] for event in sent] == [0] * 22 + [1]
|
||||
assert session.playback.generated_ms == 1
|
||||
assert session.playback.sent_ms == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("send_error", [None, RuntimeError("output unavailable")], ids=["rejected", "failed"])
|
||||
async def test_duplex_chat_unaccepted_audio_is_generated_but_not_sent(send_error: RuntimeError | None):
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=TurnBasedFakeChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
session = DuplexSession(session_id="sid-chat-unaccepted-audio", config=DuplexSessionConfig())
|
||||
response_id = session.begin_response()
|
||||
|
||||
async def send_json(data: dict[str, object], *, on_accepted: Callable[[], None] | None = None) -> None:
|
||||
if send_error is not None:
|
||||
raise send_error
|
||||
|
||||
with pytest.raises(RuntimeError, match="output unavailable") if send_error is not None else nullcontext():
|
||||
await handler._emit_chat_payload(
|
||||
session, _chat_audio_completion_payload(), session.epoch, response_id, send_json
|
||||
)
|
||||
|
||||
assert session.playback.generated_ms == 100
|
||||
assert session.playback.sent_ms == 0
|
||||
assert session.playback.played_ms == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_chat_late_send_completion_only_updates_old_response():
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=TurnBasedFakeChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
session = DuplexSession(session_id="sid-chat-late-audio", config=DuplexSessionConfig())
|
||||
old_response = session.begin_response()
|
||||
send_started = asyncio.Event()
|
||||
finish_send = asyncio.Event()
|
||||
|
||||
async def delayed_send(data: dict[str, object], *, on_accepted: Callable[[], None] | None = None) -> None:
|
||||
send_started.set()
|
||||
await finish_send.wait()
|
||||
assert on_accepted is not None
|
||||
on_accepted()
|
||||
|
||||
emit_task = asyncio.create_task(
|
||||
handler._emit_chat_payload(session, _chat_audio_completion_payload(), session.epoch, old_response, delayed_send)
|
||||
)
|
||||
try:
|
||||
await asyncio.wait_for(send_started.wait(), timeout=1)
|
||||
session.barge_in()
|
||||
new_response = session.begin_response()
|
||||
current_state = session.turn_state
|
||||
finish_send.set()
|
||||
await asyncio.wait_for(emit_task, timeout=1)
|
||||
|
||||
assert session.active_response_id == new_response
|
||||
assert session.playback_for_response(old_response).sent_ms == 100
|
||||
assert session.playback.generated_ms == 0
|
||||
assert session.playback.sent_ms == 0
|
||||
assert session.turn_state == current_state
|
||||
finally:
|
||||
emit_task.cancel()
|
||||
await asyncio.gather(emit_task, return_exceptions=True)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplex_chat_missing_audio_metadata_reports_error():
|
||||
class MissingMetadataChatService(TurnBasedFakeChatService):
|
||||
async def create_chat_completion(self, request, raw_request=None):
|
||||
async def generate():
|
||||
payload = {"modality": "audio", "choices": [{"delta": {"content": "AAAA"}}]}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
|
||||
return generate()
|
||||
|
||||
handler = OmniDuplexSessionHandler(
|
||||
chat_service=MissingMetadataChatService(FakeEngineClient()),
|
||||
config_timeout_s=0.1,
|
||||
idle_timeout_s=1,
|
||||
)
|
||||
session = DuplexSession(session_id="sid-chat-missing-metadata", config=DuplexSessionConfig())
|
||||
sent: list[dict[str, object]] = []
|
||||
|
||||
async def send_json(data: dict[str, object], *, on_accepted: Callable[[], None] | None = None) -> None:
|
||||
sent.append(data)
|
||||
if on_accepted is not None:
|
||||
on_accepted()
|
||||
|
||||
await handler._run_response(session, send_json)
|
||||
|
||||
assert [event["type"] for event in sent] == ["response.created", "error"]
|
||||
assert sent[-1]["code"] == "response_error"
|
||||
assert "AudioChunkMetadata" in sent[-1]["error"]
|
||||
assert session.playback.generated_ms == 0
|
||||
assert session.playback.sent_ms == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -5071,7 +5298,17 @@ async def test_duplex_chat_stage_metrics_use_latest_streaming_snapshot():
|
||||
for index, snapshot in enumerate(snapshots):
|
||||
payload = {
|
||||
"modality": "audio",
|
||||
"choices": [{"delta": {"content": f"audio-{index}"}}],
|
||||
"choices": [
|
||||
{
|
||||
"delta": {"content": f"audio-{index}"},
|
||||
"audio_metadata": {
|
||||
"format": "pcm",
|
||||
"sample_rate_hz": 24_000,
|
||||
"frame_count": 2400,
|
||||
"channels": 1,
|
||||
},
|
||||
}
|
||||
],
|
||||
"metrics": {"stage_metrics": {"0": snapshot}},
|
||||
}
|
||||
yield f"data: {json.dumps(payload)}\n\n"
|
||||
@@ -5089,13 +5326,22 @@ async def test_duplex_chat_stage_metrics_use_latest_streaming_snapshot():
|
||||
)
|
||||
sent: list[dict[str, Any]] = []
|
||||
|
||||
async def send_json(data: dict[str, Any]) -> None:
|
||||
async def send_json(data: dict[str, Any], *, on_accepted: Callable[[], None] | None = None) -> None:
|
||||
sent.append(data)
|
||||
if on_accepted is not None:
|
||||
on_accepted()
|
||||
|
||||
await handler._run_response(session, send_json)
|
||||
|
||||
audio_deltas = [event for event in sent if event.get("type") == "response.output_audio.delta"]
|
||||
done = next(event for event in sent if event.get("type") == "response.done")
|
||||
assert [event["audio_duration_ms"] for event in audio_deltas] == [100, 200, 300]
|
||||
assert done["playback"] == {
|
||||
"generated_ms": 300,
|
||||
"sent_ms": 300,
|
||||
"played_ms": 0,
|
||||
"committed_ms": 0,
|
||||
}
|
||||
assert all("vllm_omni" not in event for event in audio_deltas)
|
||||
assert done["vllm_omni"]["stage_metrics"]["0"] == {
|
||||
"num_tokens_out": 3,
|
||||
|
||||
Reference in new issue
Block a user