[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
@@ -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