[Feature][TTS] Add Speech API streaming metrics (#6853)
Signed-off-by: XIN GAO <1037396230@qq.com> Co-authored-by: Hongsheng Liu <liuhongsheng4@huawei.com>
This commit is contained in:
1 parent
02aaa34059
commit
33fff2155e
10 files changed
+895
-92
No files matched your search
@@ -3287,7 +3287,7 @@ class TestStreamingResponse:
|
||||
finalized_tts_params = {"_qwen3_tts_effective_max_tokens": [192]}
|
||||
captured: dict = {}
|
||||
|
||||
async def prepare(_request, request_id=None):
|
||||
async def prepare(_request, request_id=None, arrival_time=None):
|
||||
return request_id, object(), finalized_tts_params
|
||||
|
||||
async def generate_chunks(
|
||||
@@ -3296,6 +3296,7 @@ class TestStreamingResponse:
|
||||
_response_format="pcm",
|
||||
raw_request=None,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
usage_acc=None,
|
||||
tts_params=None,
|
||||
|
||||
@@ -0,0 +1,465 @@
|
||||
# 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 == []
|
||||
@@ -48,7 +48,14 @@ def _build_test_app(
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
collect=None,
|
||||
):
|
||||
for chunk in (b"\x01\x02", b"\x03\x04\x05"):
|
||||
yield (chunk, 24000) if include_sample_rate else chunk
|
||||
@@ -249,6 +256,7 @@ class TestStreamingSpeechWebSocket:
|
||||
|
||||
def test_streaming_multiple_binary_frames(self, mocker: MockerFixture):
|
||||
captured_requests = []
|
||||
captured_timing = {}
|
||||
captured_tts_params = []
|
||||
|
||||
speech_service = mocker.MagicMock(spec=OmniOpenAIServingSpeech)
|
||||
@@ -257,13 +265,27 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_prepare_speech_generation(request):
|
||||
async def mock_prepare_speech_generation(request, *, arrival_time=None):
|
||||
captured_requests.append(request)
|
||||
assert arrival_time is not None
|
||||
captured_timing["prepare_arrival"] = arrival_time
|
||||
return "req-stream", object(), {"_qwen3_tts_effective_max_tokens": [192]}
|
||||
|
||||
speech_service._prepare_speech_generation = mock_prepare_speech_generation
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, tts_params=None):
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
):
|
||||
assert request_start_s is not None
|
||||
assert request_arrival_ts is not None
|
||||
captured_timing["chunk_start"] = request_start_s
|
||||
captured_timing["chunk_arrival"] = request_arrival_ts
|
||||
captured_tts_params.append(tts_params)
|
||||
for chunk in (b"\x01\x02", b"\x03\x04\x05", b"\x06"):
|
||||
yield (chunk, 24000) if include_sample_rate else chunk
|
||||
@@ -309,6 +331,8 @@ class TestStreamingSpeechWebSocket:
|
||||
assert captured_requests[0].stream is True
|
||||
assert captured_requests[0].response_format == "pcm"
|
||||
assert captured_requests[0].initial_codec_chunk_frames == 12
|
||||
assert captured_timing["prepare_arrival"] == captured_timing["chunk_arrival"]
|
||||
assert captured_timing["chunk_start"] > 0
|
||||
assert captured_tts_params == [{"_qwen3_tts_effective_max_tokens": [192]}]
|
||||
assert speech_service._generate_audio_bytes.await_count == 0
|
||||
|
||||
@@ -341,8 +365,9 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
speech_service.forced_aligner_enabled = True
|
||||
|
||||
async def mock_prepare_speech_generation(request):
|
||||
async def mock_prepare_speech_generation(request, *, arrival_time=None):
|
||||
captured_requests.append(request)
|
||||
assert arrival_time is not None
|
||||
return "req-stream", object(), {}
|
||||
|
||||
speech_service._prepare_speech_generation = mock_prepare_speech_generation
|
||||
@@ -353,8 +378,17 @@ class TestStreamingSpeechWebSocket:
|
||||
# The forced-aligner stage rides the same generator: its pooling output
|
||||
# is surfaced via the ``collect`` channel once the audio has streamed.
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
collect=None,
|
||||
):
|
||||
assert request_start_s is not None
|
||||
assert request_arrival_ts is not None
|
||||
for chunk in (first_chunk, second_chunk):
|
||||
yield (chunk, 1000) if include_sample_rate else chunk
|
||||
if collect is not None:
|
||||
@@ -438,7 +472,14 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service._prepare_speech_generation = mocker.AsyncMock(return_value=("req", object(), {}))
|
||||
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator, _request_id, *, include_sample_rate=False, tts_params=None, collect=None
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
collect=None,
|
||||
):
|
||||
chunk = b"\x01" * 1000
|
||||
yield (chunk, 1000) if include_sample_rate else chunk
|
||||
@@ -620,7 +661,15 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, tts_params=None):
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
):
|
||||
yield b"\x01\x02"
|
||||
raise RuntimeError("stream boom")
|
||||
|
||||
@@ -716,7 +765,15 @@ class TestStreamingSpeechWebSocket:
|
||||
speech_service.engine_client.abort = mocker.AsyncMock()
|
||||
speech_service.forced_aligner_enabled = False
|
||||
|
||||
async def mock_generate_pcm_chunks(_generator, _request_id, *, include_sample_rate=False, tts_params=None):
|
||||
async def mock_generate_pcm_chunks(
|
||||
_generator,
|
||||
_request_id,
|
||||
*,
|
||||
request_start_s=None,
|
||||
request_arrival_ts=None,
|
||||
include_sample_rate=False,
|
||||
tts_params=None,
|
||||
):
|
||||
yield b"\x01\x02"
|
||||
|
||||
speech_service._generate_pcm_chunks = mock_generate_pcm_chunks
|
||||
|
||||
Reference in new issue
Block a user