Signed-off-by: XIN GAO <1037396230@qq.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
466 lines
17 KiB
Python
466 lines
17 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright contributors to the vLLM-Omni project
|
|
|
|
"""Prometheus metric coverage for the Speech API audio path."""
|
|
|
|
import asyncio
|
|
from types import SimpleNamespace
|
|
|
|
import numpy as np
|
|
import pytest
|
|
import torch
|
|
|
|
from vllm_omni.entrypoints.openai import serving_speech as speech_module
|
|
from vllm_omni.entrypoints.openai.protocol.audio import OpenAICreateSpeechRequest
|
|
from vllm_omni.entrypoints.openai.serving_speech import OmniOpenAIServingSpeech
|
|
|
|
pytestmark = [pytest.mark.core_model, pytest.mark.cpu]
|
|
|
|
|
|
class _MetricsStub:
|
|
def __init__(self) -> None:
|
|
self.ttfp_calls: list[tuple[str, str, float]] = []
|
|
self.underrun_calls: list[tuple[str, str, float]] = []
|
|
self.continuity_calls: list[tuple[str, str, int]] = []
|
|
self.abort_calls: list[str] = []
|
|
self.completed = 0
|
|
|
|
def inc_speech_stream_completed(self) -> None:
|
|
self.completed += 1
|
|
|
|
def inc_speech_stream_aborted(self, reason: str) -> None:
|
|
self.abort_calls.append(reason)
|
|
|
|
def observe_audio_ttfp(self, stage: str, replica: str, seconds: float) -> None:
|
|
self.ttfp_calls.append((stage, replica, seconds))
|
|
|
|
def observe_audio_underrun(self, stage: str, replica: str, seconds: float) -> None:
|
|
self.underrun_calls.append((stage, replica, seconds))
|
|
|
|
def inc_audio_continuity_ok(self, stage: str, replica: str, threshold_ms: int) -> None:
|
|
self.continuity_calls.append((stage, replica, threshold_ms))
|
|
|
|
|
|
def _serving(metrics: _MetricsStub, *, adapter=None) -> OmniOpenAIServingSpeech:
|
|
serving = OmniOpenAIServingSpeech.__new__(OmniOpenAIServingSpeech)
|
|
serving._tts_model_type = "qwen3_tts"
|
|
serving.engine_client = SimpleNamespace(mod_metrics=metrics, request_states={})
|
|
serving._get_tts_adapter = lambda: adapter
|
|
serving.create_audio = lambda audio_obj: SimpleNamespace(
|
|
audio_data=b"\0\0" * int(audio_obj.audio_tensor.size),
|
|
media_type="audio/pcm",
|
|
)
|
|
serving._mark_ref_audio_artifact_ready_for_request = lambda request_id: None
|
|
serving._discard_ref_audio_artifact_warmup = lambda request_id: None
|
|
return serving
|
|
|
|
|
|
def _result(samples: int = 320, *, stage_id: int = 1, replica_id: int | None = 2) -> SimpleNamespace:
|
|
return SimpleNamespace(
|
|
multimodal_output={"audio": torch.zeros(samples), "sr": 16000},
|
|
stage_id=stage_id,
|
|
replica_id=replica_id,
|
|
)
|
|
|
|
|
|
async def _generate(*results):
|
|
for result in results:
|
|
yield result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_speech_observes_ttfp_once_on_first_pcm_payload(monkeypatch):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
|
|
|
chunks = serving._generate_audio_chunks(
|
|
_generate(_result(), _result()),
|
|
request_id="speech-test",
|
|
request_arrival_ts=100.0,
|
|
)
|
|
assert len([chunk async for chunk in chunks]) == 2
|
|
assert metrics.ttfp_calls == [("1", "2", pytest.approx(0.25))]
|
|
assert metrics.underrun_calls == [("1", "2", pytest.approx(0.0))]
|
|
assert metrics.continuity_calls == [("1", "2", 100)]
|
|
assert metrics.abort_calls == []
|
|
assert metrics.completed == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("before_pcm", [True, False])
|
|
@pytest.mark.parametrize(
|
|
("error_type", "reason"),
|
|
[(asyncio.CancelledError, "cancelled"), (speech_module.EngineDeadError, "engine_dead"), (RuntimeError, "error")],
|
|
)
|
|
async def test_speech_stream_abort_counted_once_without_continuity(before_pcm, error_type, reason):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
|
|
async def results():
|
|
if not before_pcm:
|
|
yield _result()
|
|
raise error_type()
|
|
|
|
chunks = serving._generate_audio_chunks(results(), request_id="speech-test", request_arrival_ts=100.0)
|
|
with pytest.raises(error_type):
|
|
_ = [chunk async for chunk in chunks]
|
|
|
|
assert metrics.abort_calls == [reason]
|
|
assert metrics.completed == 0
|
|
assert len(metrics.ttfp_calls) == (0 if before_pcm else 1)
|
|
assert metrics.underrun_calls == []
|
|
assert metrics.continuity_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_speech_stream_close_counted_once_without_continuity():
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
chunks = serving._generate_audio_chunks(
|
|
_generate(_result(), _result()), request_id="speech-test", request_arrival_ts=100.0
|
|
)
|
|
assert await anext(chunks)
|
|
await chunks.aclose()
|
|
await chunks.aclose()
|
|
assert metrics.abort_calls == ["closed"]
|
|
assert metrics.completed == 0
|
|
assert metrics.underrun_calls == []
|
|
assert metrics.continuity_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_speech_retries_ttfp_labels_without_moving_first_packet_time(monkeypatch):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
clock = {"now": 100.25, "perf": 0.25}
|
|
monkeypatch.setattr(speech_module.time, "time", lambda: clock["now"])
|
|
monkeypatch.setattr(speech_module.time, "perf_counter", lambda: clock["perf"])
|
|
|
|
async def results():
|
|
yield _result(replica_id=None)
|
|
clock["now"] = 101.0
|
|
clock["perf"] = 0.26
|
|
yield _result(replica_id=2)
|
|
clock["now"] = 101.25
|
|
clock["perf"] = 0.27
|
|
yield _result(replica_id=2)
|
|
|
|
chunks = serving._generate_audio_chunks(
|
|
results(),
|
|
request_id="speech-test",
|
|
request_arrival_ts=100.0,
|
|
)
|
|
assert len([chunk async for chunk in chunks]) == 3
|
|
assert metrics.ttfp_calls == [("1", "2", pytest.approx(0.25))]
|
|
assert metrics.underrun_calls == [("1", "2", pytest.approx(0.0))]
|
|
assert metrics.continuity_calls == [("1", "2", 100)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("mode", ["raw", "sse", "pcm"])
|
|
async def test_speech_disconnect_during_send_closes_entire_generator_chain(mode):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
closed = []
|
|
|
|
async def source():
|
|
try:
|
|
yield _result()
|
|
yield _result()
|
|
finally:
|
|
closed.append(True)
|
|
|
|
# Retain the source to prove cleanup does not depend on garbage collection.
|
|
generator = source()
|
|
method = {
|
|
"raw": serving._generate_audio_chunks,
|
|
"sse": serving._generate_audio_sse_events,
|
|
"pcm": serving._generate_pcm_chunks,
|
|
}[mode]
|
|
stream = method(generator, request_id="speech-test", request_arrival_ts=100.0)
|
|
|
|
async def send(message):
|
|
if message["type"] == "http.response.body":
|
|
raise OSError("client disconnected")
|
|
|
|
if mode == "pcm":
|
|
# WebSocket handlers already explicitly close their PCM iterator.
|
|
assert await anext(stream)
|
|
await stream.aclose()
|
|
else:
|
|
response = speech_module._SpeechStreamingResponse(stream)
|
|
with pytest.raises(OSError, match="client disconnected"):
|
|
await response.stream_response(send)
|
|
assert closed == [True]
|
|
assert metrics.abort_calls == ["closed"]
|
|
assert metrics.completed == 0
|
|
assert metrics.underrun_calls == []
|
|
assert metrics.continuity_calls == []
|
|
|
|
|
|
@pytest.mark.parametrize("arrival_ts", [0.0, -1.0])
|
|
def test_speech_ttfp_invalid_arrival_does_not_consume_guard(arrival_ts):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
state = SimpleNamespace(
|
|
external_request_id="speech-test",
|
|
request_arrival_ts=arrival_ts,
|
|
first_audio_ts=None,
|
|
audio_emit_stage_id=None,
|
|
audio_emit_replica_id=None,
|
|
)
|
|
serving.engine_client.request_states = {"internal-test": state}
|
|
|
|
assert serving._observe_speech_audio_ttfp(request_id="speech-test", result=_result(), first_packet_ts=100.25) == (
|
|
1,
|
|
2,
|
|
False,
|
|
)
|
|
assert metrics.ttfp_calls == []
|
|
assert state.first_audio_ts is None
|
|
assert state.audio_emit_stage_id is None
|
|
assert state.audio_emit_replica_id is None
|
|
|
|
state.request_arrival_ts = 100.0
|
|
assert serving._observe_speech_audio_ttfp(request_id="speech-test", result=_result(), first_packet_ts=100.25) == (
|
|
1,
|
|
2,
|
|
True,
|
|
)
|
|
assert metrics.ttfp_calls == [("1", "2", pytest.approx(0.25))]
|
|
assert state.first_audio_ts == 100.25
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("during_send", [False, True])
|
|
async def test_speech_response_task_cancellation_closes_source(during_send):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
waiting = asyncio.Event()
|
|
blocker = asyncio.Event()
|
|
closed = []
|
|
|
|
async def source():
|
|
try:
|
|
if not during_send:
|
|
waiting.set()
|
|
await blocker.wait()
|
|
yield _result()
|
|
finally:
|
|
closed.append(True)
|
|
|
|
async def send(message):
|
|
if during_send and message["type"] == "http.response.body":
|
|
waiting.set()
|
|
await blocker.wait()
|
|
|
|
generator = source()
|
|
stream = serving._generate_audio_chunks(generator, request_id="speech-test", request_arrival_ts=100.0)
|
|
response = speech_module._SpeechStreamingResponse(stream)
|
|
task = asyncio.create_task(response.stream_response(send))
|
|
try:
|
|
await asyncio.wait_for(waiting.wait(), timeout=5)
|
|
finally:
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert closed == [True]
|
|
assert metrics.abort_calls == ["closed" if during_send else "cancelled"]
|
|
assert metrics.completed == 0
|
|
assert metrics.underrun_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_speech_does_not_count_empty_payload_as_first_packet(monkeypatch):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
|
|
|
chunks = serving._generate_audio_chunks(
|
|
_generate(_result(samples=0), _result()),
|
|
request_id="speech-test",
|
|
request_arrival_ts=100.0,
|
|
)
|
|
assert len([chunk async for chunk in chunks]) == 2
|
|
assert metrics.ttfp_calls == [("1", "2", pytest.approx(0.25))]
|
|
assert metrics.underrun_calls == [("1", "2", pytest.approx(0.0))]
|
|
assert metrics.continuity_calls == [("1", "2", 100)]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_speech_does_not_finalize_continuity_on_error(monkeypatch):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
|
|
|
async def failing_stream():
|
|
yield _result()
|
|
raise RuntimeError("stream failed")
|
|
|
|
chunks = serving._generate_audio_chunks(
|
|
failing_stream(),
|
|
request_id="speech-test",
|
|
request_arrival_ts=100.0,
|
|
)
|
|
with pytest.raises(RuntimeError, match="stream failed"):
|
|
_ = [chunk async for chunk in chunks]
|
|
|
|
assert len(metrics.ttfp_calls) == 1
|
|
assert metrics.underrun_calls == []
|
|
assert metrics.continuity_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_speech_does_not_finalize_continuity_on_validation_error(monkeypatch):
|
|
metrics = _MetricsStub()
|
|
|
|
def reject_generation(_tts_params, **_kwargs):
|
|
raise RuntimeError("generation validation failed")
|
|
|
|
adapter = SimpleNamespace(validates_generation=True, validate_generation=reject_generation)
|
|
serving = _serving(metrics, adapter=adapter)
|
|
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
|
|
|
chunks = serving._generate_audio_chunks(
|
|
_generate(_result()),
|
|
request_id="speech-test",
|
|
request_arrival_ts=100.0,
|
|
tts_params={"task_type": ["Base"]},
|
|
)
|
|
with pytest.raises(RuntimeError, match="generation validation failed"):
|
|
_ = [chunk async for chunk in chunks]
|
|
|
|
assert len(metrics.ttfp_calls) == 1
|
|
assert metrics.underrun_calls == []
|
|
assert metrics.continuity_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_streaming_speech_reports_late_chunk_as_underrun(monkeypatch):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.time", lambda: 100.25)
|
|
perf_times = iter((0.0, 0.1, 0.1, 2.0, 2.0))
|
|
monkeypatch.setattr("vllm_omni.entrypoints.openai.serving_speech.time.perf_counter", lambda: next(perf_times))
|
|
|
|
chunks = serving._generate_audio_chunks(
|
|
_generate(_result(), _result()),
|
|
request_id="speech-test",
|
|
request_arrival_ts=100.0,
|
|
)
|
|
assert len([chunk async for chunk in chunks]) == 2
|
|
assert metrics.underrun_calls[0][:2] == ("1", "2")
|
|
assert metrics.underrun_calls[0][2] > 0.1
|
|
assert metrics.continuity_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_non_streaming_speech_does_not_observe_ttfp():
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
serving._audio_encode_speed = lambda _request: 1.0
|
|
|
|
async def prepare(_request, **_kwargs):
|
|
result = _result()
|
|
result.metrics = {}
|
|
return "speech-test", _generate(result), {}
|
|
|
|
serving._prepare_speech_generation = prepare
|
|
request = OpenAICreateSpeechRequest(input="hello", response_format="pcm")
|
|
|
|
audio_data, media_type = await serving._generate_audio_bytes(request, request_arrival_ts=100.0)
|
|
|
|
assert audio_data
|
|
assert media_type == "audio/pcm"
|
|
assert metrics.ttfp_calls == []
|
|
assert metrics.completed == 0
|
|
assert metrics.abort_calls == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("flush_only", [False, True])
|
|
@pytest.mark.parametrize("response_format", ["pcm", "wav"])
|
|
async def test_resampled_speech_records_flush_with_audio_producer(monkeypatch, flush_only, response_format):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
clock = {"now": 0.0}
|
|
finalized = []
|
|
original_finalize = speech_module.observe_audio_streaming_finalize
|
|
|
|
def capture_finalize(*args, **kwargs):
|
|
finalized.append(kwargs)
|
|
original_finalize(*args, **kwargs)
|
|
|
|
class BufferedResampler:
|
|
def __init__(self, source_rate, target_rate):
|
|
assert (source_rate, target_rate) == (16000, 24000)
|
|
|
|
def process(self, chunk, *, final=False):
|
|
clock["now"] = 0.5 if final else 0.25
|
|
if not final and flush_only:
|
|
return np.empty(0, dtype=np.float32)
|
|
return np.zeros(2400, dtype=np.float32)
|
|
|
|
monkeypatch.setattr(speech_module, "StreamingAudioResampler", BufferedResampler)
|
|
monkeypatch.setattr(speech_module, "observe_audio_streaming_finalize", capture_finalize)
|
|
monkeypatch.setattr(speech_module.time, "time", lambda: 100.0 + clock["now"])
|
|
monkeypatch.setattr(speech_module.time, "perf_counter", lambda: clock["now"])
|
|
# The last result is not audio and must not supply the flush metric labels.
|
|
non_audio = SimpleNamespace(multimodal_output={"timestamps": []}, stage_id=9, replica_id=8)
|
|
chunks = [
|
|
chunk
|
|
async for chunk in serving._generate_audio_chunks(
|
|
_generate(_result(), non_audio),
|
|
request_id="speech-test",
|
|
response_format=response_format,
|
|
request_start_s=0.0,
|
|
request_arrival_ts=100.0,
|
|
target_sample_rate=24000,
|
|
)
|
|
]
|
|
|
|
if response_format == "wav":
|
|
assert chunks.pop(0).startswith(b"RIFF")
|
|
expected_arrivals = [0.5] if flush_only else [0.25, 0.5]
|
|
assert [len(chunk) for chunk in chunks] == [4800] * len(expected_arrivals)
|
|
assert metrics.ttfp_calls == [("1", "2", pytest.approx(expected_arrivals[0]))]
|
|
assert len(finalized) == 1
|
|
assert finalized[0]["sample_rate"] == 24000
|
|
assert finalized[0]["channels"] == 1
|
|
assert finalized[0]["chunk_bytes"] == [4800] * len(expected_arrivals)
|
|
assert finalized[0]["chunk_arrival_times_s"] == expected_arrivals
|
|
assert metrics.underrun_calls == [("1", "2", pytest.approx(0.0 if flush_only else 0.15))]
|
|
assert metrics.continuity_calls == ([("1", "2", 100)] if flush_only else [])
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("empty_pcm", [False, True])
|
|
async def test_resampled_speech_without_pcm_does_not_emit_metrics(monkeypatch, empty_pcm):
|
|
metrics = _MetricsStub()
|
|
serving = _serving(metrics)
|
|
|
|
class EmptyResampler:
|
|
def __init__(self, source_rate, target_rate):
|
|
pass
|
|
|
|
def process(self, chunk, *, final=False):
|
|
# Also cover a nonempty flush waveform whose encoder emits no PCM.
|
|
return np.zeros(10 if final and empty_pcm else 0, dtype=np.float32)
|
|
|
|
monkeypatch.setattr(speech_module, "StreamingAudioResampler", EmptyResampler)
|
|
serving.create_audio = lambda audio_obj: SimpleNamespace(audio_data=b"", media_type="audio/pcm")
|
|
chunks = [
|
|
chunk
|
|
async for chunk in serving._generate_audio_chunks(
|
|
_generate(_result()),
|
|
request_id="speech-test",
|
|
request_arrival_ts=100.0,
|
|
target_sample_rate=24000,
|
|
)
|
|
]
|
|
assert not any(chunks)
|
|
assert metrics.ttfp_calls == []
|
|
assert metrics.underrun_calls == []
|
|
assert metrics.continuity_calls == []
|