Files
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

1441 lines
55 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=protected-access
"""Unit tests for the TTS module.
Covers:
* ``TTSModelBase`` default no-op behaviour for ``connect`` / ``close`` /
``push`` (so non-realtime subclasses needn't override them).
* ``DashScopeTTSModel`` non-streaming aggregation.
* ``DashScopeTTSModel`` streaming: incremental deltas and ``is_last``
placement at the final chunk only.
* ``DashScopeCosyVoiceTTSModel`` WebSocket TTS streaming and non-streaming
output over a mocked SDK.
* ``DashScopeRealtimeTTSModel`` connect / close / push / synthesize
lifecycle over a mocked WebSocket.
* ``DashScopeCosyVoiceTTSModel`` realtime connect / close / push /
synthesize lifecycle over a mocked SpeechSynthesizer.
"""
import base64
import io
import wave
from typing import Any, AsyncGenerator
from unittest import IsolatedAsyncioTestCase
from unittest.mock import MagicMock, Mock, patch
from agentscope.app._service._tts_model import _resolve_tts_class
from agentscope.credential import DashScopeCredential
from agentscope.tts import (
DashScopeCosyVoiceTTSModel,
DashScopeTTSModel,
DashScopeRealtimeTTSModel,
TTSModelBase,
TTSResponse,
)
_MEDIA_TYPE = "audio/wav"
# DashScope TTS emits 24kHz / mono / 16-bit PCM; the WAV wrapping in the
# model layer uses the same parameters.
_TTS_SAMPLE_RATE = 24000
_TTS_CHANNELS = 1
_TTS_SAMPLE_WIDTH = 2 # bytes (= 16 bit)
_WAV_HEADER_LEN = 44
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_api_chunk(
data_bytes: bytes | None,
usage: Any = None,
) -> MagicMock:
"""Build a chunk shaped like what dashscope.MultiModalConversation
yields. ``data_bytes=None`` represents a chunk with no output."""
chunk = MagicMock()
chunk.usage = usage
if data_bytes is None:
chunk.output = None
return chunk
chunk.output = MagicMock()
chunk.output.audio = MagicMock()
chunk.output.audio.data = base64.b64encode(data_bytes).decode("ascii")
return chunk
def _make_usage(
input_tokens: int = 0,
output_tokens: int = 0,
characters: int = 0,
) -> MagicMock:
"""Build a usage object shaped like what the DashScope API returns."""
usage = MagicMock()
usage.input_tokens = input_tokens
usage.output_tokens = output_tokens
usage.characters = characters
return usage
def _make_api_generator(chunks: list[bytes | None]) -> Any:
"""Build a sync generator like ``MultiModalConversation.call`` returns.
The last chunk carries a usage object."""
def _gen() -> Any:
for i, data in enumerate(chunks):
is_last = i == len(chunks) - 1
usage = _make_usage(characters=10) if is_last else None
yield _make_api_chunk(data, usage=usage)
return _gen()
# ---------------------------------------------------------------------------
# TTSModelBase — default no-op surface for non-realtime subclasses
# ---------------------------------------------------------------------------
class _DummyTTS(TTSModelBase):
"""Minimal subclass that implements only ``synthesize`` — exercises the
base class's no-op ``connect`` / ``close`` / ``push`` defaults."""
async def synthesize(
self,
text: str | None = None,
**kwargs: Any,
) -> TTSResponse | AsyncGenerator[TTSResponse, None]:
del text, kwargs
return TTSResponse(content=None)
class _RealtimeDummyTTS(_DummyTTS):
"""Realtime-flavoured dummy to assert ``__aenter__`` drives the lifecycle
hooks when ``realtime`` is True."""
realtime = True
def __init__(self, *args: Any, **kwargs: Any) -> None:
super().__init__(*args, **kwargs)
self.connect_calls = 0
self.close_calls = 0
async def connect(self) -> None:
self.connect_calls += 1
async def close(self) -> None:
self.close_calls += 1
def _make_dummy(cls: type = _DummyTTS) -> TTSModelBase:
return cls(
credential=DashScopeCredential(api_key="test"),
model="x",
stream=False,
)
class TestTTSModelBaseDefaults(IsolatedAsyncioTestCase):
"""The base class supplies safe no-op defaults so non-realtime subclasses
don't need to implement realtime-only hooks."""
async def test_default_connect_close_noop(self) -> None:
"""Default connect/close return without raising."""
model = _make_dummy()
await model.connect()
await model.close()
async def test_default_push_returns_empty(self) -> None:
"""Default push returns an empty TTSResponse rather than raising,
so a misuse on a non-realtime model degrades gracefully."""
model = _make_dummy()
resp = await model.push("ignored")
self.assertIsNone(resp.content)
async def test_aenter_skips_hooks_for_non_realtime(self) -> None:
"""``async with`` on a non-realtime model must not invoke connect/
close (gated by ``realtime``)."""
model = _make_dummy(_DummyTTS)
async with model as m:
self.assertIs(m, model)
async def test_aenter_invokes_hooks_for_realtime(self) -> None:
"""For ``realtime=True`` subclasses, connect/close fire on
enter/exit exactly once."""
model = _make_dummy(_RealtimeDummyTTS)
async with model:
self.assertEqual(model.connect_calls, 1)
self.assertEqual(model.close_calls, 0)
self.assertEqual(model.close_calls, 1)
# ---------------------------------------------------------------------------
# DashScopeTTSModel — non-streaming and streaming
# ---------------------------------------------------------------------------
def _parse_wav_payload(wav_bytes: bytes) -> bytes:
"""Decode a full WAV file and return its raw PCM frames."""
with wave.open(io.BytesIO(wav_bytes), "rb") as wav:
return wav.readframes(wav.getnframes())
class TestDashScopeTTSModel(IsolatedAsyncioTestCase):
"""The unittests for DashScope TTS model (non-realtime)."""
def setUp(self) -> None:
"""Set up the test case."""
self.patcher = patch("dashscope.MultiModalConversation")
self.mock_mmc = self.patcher.start()
def tearDown(self) -> None:
"""Tear down the test case."""
self.patcher.stop()
def _make_model(self, stream: bool = False) -> DashScopeTTSModel:
"""Create a DashScopeTTSModel with test credentials."""
return DashScopeTTSModel(
credential=DashScopeCredential(api_key="test"),
model="qwen3-tts-flash",
parameters=DashScopeTTSModel.Parameters(voice="Cherry"),
stream=stream,
)
# -- non-streaming --
async def test_aggregates_chunks(self) -> None:
"""All API chunks are aggregated into one self-contained WAV."""
self.mock_mmc.call.return_value = _make_api_generator(
[b"AAAA", b"BBBB", b"CCCC"],
)
model = self._make_model(stream=False)
result = await model.synthesize("Hello world")
self.assertIsInstance(result, TTSResponse)
self.assertEqual(result.content.source.media_type, _MEDIA_TYPE)
wav_bytes = base64.b64decode(result.content.source.data)
with wave.open(io.BytesIO(wav_bytes), "rb") as wav:
self.assertEqual(
{
"framerate": wav.getframerate(),
"channels": wav.getnchannels(),
"sampwidth": wav.getsampwidth(),
"frames": wav.readframes(wav.getnframes()),
},
{
"framerate": _TTS_SAMPLE_RATE,
"channels": _TTS_CHANNELS,
"sampwidth": _TTS_SAMPLE_WIDTH,
"frames": b"AAAABBBBCCCC",
},
)
self.assertTrue(result.is_last)
async def test_none_short_circuits(self) -> None:
"""``synthesize(None)`` returns an empty response without touching
the API."""
model = self._make_model(stream=False)
result = await model.synthesize(None)
self.assertIsNone(result.content)
self.mock_mmc.call.assert_not_called()
async def test_empty_string_short_circuits(self) -> None:
"""``synthesize("")`` returns an empty response without touching
the API."""
model = self._make_model(stream=False)
result = await model.synthesize("")
self.assertIsNone(result.content)
self.mock_mmc.call.assert_not_called()
async def test_skips_empty_chunks(self) -> None:
"""Chunks without ``output`` are ignored during aggregation."""
self.mock_mmc.call.return_value = _make_api_generator(
[None, b"AAAA", None, b"BBBB"],
)
model = self._make_model(stream=False)
result = await model.synthesize("Hello world")
wav_bytes = base64.b64decode(result.content.source.data)
self.assertEqual(_parse_wav_payload(wav_bytes), b"AAAABBBB")
# -- streaming --
async def test_incremental_deltas(self) -> None:
"""Each API chunk yields one TTSResponse with incremental PCM."""
self.mock_mmc.call.return_value = _make_api_generator(
[b"AAAA", b"BBBB", b"CCCC"],
)
model = self._make_model(stream=True)
gen = await model.synthesize("Hello world")
chunks = [c async for c in gen]
payloads = [base64.b64decode(c.content.source.data) for c in chunks]
self.assertTrue(payloads[0].startswith(b"RIFF"))
self.assertEqual(payloads[0][8:12], b"WAVE")
self.assertEqual(payloads[0][_WAV_HEADER_LEN:], b"AAAA")
self.assertEqual(payloads[1], b"BBBB")
self.assertEqual(payloads[2], b"CCCC")
self.assertEqual(
[c.is_last for c in chunks],
[False, False, True],
)
self.assertEqual(
[c.content.source.media_type for c in chunks],
[_MEDIA_TYPE, _MEDIA_TYPE, _MEDIA_TYPE],
)
async def test_single_chunk_marked_last(self) -> None:
"""A lone audio chunk is flagged ``is_last=True`` with a streaming
WAV header."""
self.mock_mmc.call.return_value = _make_api_generator([b"ONLYCHUNK"])
model = self._make_model(stream=True)
gen = await model.synthesize("Hello world")
chunks = [c async for c in gen]
self.assertEqual(len(chunks), 1)
self.assertTrue(chunks[0].is_last)
payload = base64.b64decode(chunks[0].content.source.data)
self.assertTrue(payload.startswith(b"RIFF"))
self.assertEqual(payload[_WAV_HEADER_LEN:], b"ONLYCHUNK")
async def test_empty_stream_yields_terminal(self) -> None:
"""When the API yields no audio, the generator emits a terminal
sentinel so consumers can detect EOS."""
self.mock_mmc.call.return_value = _make_api_generator([None, None])
model = self._make_model(stream=True)
gen = await model.synthesize("Hello world")
chunks = [c async for c in gen]
self.assertEqual(len(chunks), 1)
self.assertIsNone(chunks[0].content)
self.assertTrue(chunks[0].is_last)
# ---------------------------------------------------------------------------
# DashScopeCosyVoiceTTSModel — WebSocket TTS streaming and aggregation
# ---------------------------------------------------------------------------
class TestDashScopeCosyVoiceTTSModel(IsolatedAsyncioTestCase):
"""The unittests for DashScope CosyVoice TTS model."""
def setUp(self) -> None:
"""Set up the test case."""
self.patcher = patch(
"dashscope.audio.tts_v2.SpeechSynthesizer",
)
self.mock_synthesizer_class = self.patcher.start()
self.synthesizer_instances: list[Mock] = []
self.next_audio = b"FULL_AUDIO"
self.next_chunks = [b"CHUNK"]
self.mock_synthesizer_class.side_effect = self._make_synthesizer
def tearDown(self) -> None:
"""Tear down the test case."""
self.patcher.stop()
def _make_synthesizer(self, **init_kwargs: Any) -> Mock:
"""Create a mocked tts_v2 SpeechSynthesizer instance."""
synth = Mock()
synth.init_kwargs = init_kwargs
callback = init_kwargs.get("callback")
if callback is None:
synth.call = Mock(return_value=self.next_audio)
else:
def _call(text: str, **kwargs: Any) -> None:
del text, kwargs
for chunk in self.next_chunks:
callback.on_data(chunk)
callback.on_complete()
callback.on_close()
synth.call = Mock(side_effect=_call)
self.synthesizer_instances.append(synth)
return synth
def _make_model(
self,
stream: bool = False,
) -> DashScopeCosyVoiceTTSModel:
"""Create a DashScopeCosyVoiceTTSModel with test credentials."""
return DashScopeCosyVoiceTTSModel(
credential=DashScopeCredential(api_key="test"),
model="cosyvoice-v3-flash",
parameters=DashScopeCosyVoiceTTSModel.Parameters(
voice="longanhuan",
),
stream=stream,
)
async def test_empty_string_short_circuits(self) -> None:
"""``synthesize("")`` returns empty without touching the SDK."""
model = self._make_model(stream=False)
result = await model.synthesize("")
self.assertIsNone(result.content)
self.mock_synthesizer_class.assert_not_called()
async def test_non_stream_returns_full_audio(
self,
) -> None:
"""stream=False returns one full audio payload from the SDK."""
self.next_audio = b"AAAABBBB"
model = self._make_model(stream=False)
result = await model.synthesize("Hello")
self.assertIsInstance(result, TTSResponse)
self.assertEqual(result.content.source.media_type, _MEDIA_TYPE)
wav_payload = base64.b64decode(result.content.source.data)
with wave.open(io.BytesIO(wav_payload), "rb") as wav:
self.assertEqual(wav.getnchannels(), 1)
self.assertEqual(wav.getframerate(), 24000)
self.assertEqual(wav.readframes(wav.getnframes()), b"AAAABBBB")
synth = self.synthesizer_instances[-1]
init_kwargs = synth.init_kwargs
self.assertEqual(init_kwargs["model"], "cosyvoice-v3-flash")
self.assertEqual(init_kwargs["voice"], "longanhuan")
self.assertEqual(init_kwargs["format"].format, "pcm")
self.assertEqual(init_kwargs["format"].sample_rate, 24000)
synth.call.assert_called_once_with(text="Hello")
async def test_streaming_returns_callback_audio_chunks(
self,
) -> None:
"""stream=True returns callback audio chunks."""
self.next_chunks = [b"AAAA", b"BBBB"]
model = self._make_model(stream=True)
gen = await model.synthesize("Hello")
chunks = [c async for c in gen]
self.assertEqual(len(chunks), 1)
payload = base64.b64decode(chunks[0].content.source.data)
self.assertTrue(payload.startswith(b"RIFF"))
self.assertTrue(payload.endswith(b"AAAABBBB"))
self.assertEqual([c.is_last for c in chunks], [True])
synth = self.synthesizer_instances[-1]
self.assertIsNotNone(synth.init_kwargs["callback"])
synth.call.assert_called_once_with(text="Hello")
async def test_model_cards_and_credential_wiring(self) -> None:
"""CosyVoice model cards are discovered only by the CosyVoice class,
and DashScope credentials expose the class."""
cosyvoice_cards = DashScopeCosyVoiceTTSModel.list_models()
cosyvoice_names = {card.name for card in cosyvoice_cards}
self.assertEqual(len(cosyvoice_cards), 2)
self.assertIn("cosyvoice-v3-flash", cosyvoice_names)
self.assertIn("cosyvoice-v3-plus", cosyvoice_names)
self.assertTrue(all(not card.realtime for card in cosyvoice_cards))
for card in cosyvoice_cards:
properties = card.parameter_schema["properties"]
self.assertIn("voice", properties)
self.assertIn("realtime", properties)
plus_card = next(
card
for card in cosyvoice_cards
if card.name == "cosyvoice-v3-plus"
)
self.assertIn(
"longanhuan",
plus_card.parameter_schema["properties"]["voice"]["enum"],
)
qwen_names = {card.name for card in DashScopeTTSModel.list_models()}
self.assertNotIn("cosyvoice-v3-flash", qwen_names)
self.assertNotIn("cosyvoice-v3-plus", qwen_names)
credential_classes = DashScopeCredential.get_tts_model_classes()
self.assertIn(DashScopeCosyVoiceTTSModel, credential_classes)
credential_names = {
card.name for card in DashScopeCredential.list_tts_models()
}
self.assertIn("cosyvoice-v3-flash", credential_names)
self.assertIn("cosyvoice-v3-plus", credential_names)
resolved_without_realtime = _resolve_tts_class(
credential_classes,
"cosyvoice-v3-plus",
)
self.assertIs(
resolved_without_realtime,
DashScopeCosyVoiceTTSModel,
)
# ---------------------------------------------------------------------------
# DashScopeRealtimeTTSModel — realtime push / synthesize lifecycle
# ---------------------------------------------------------------------------
class TestDashScopeRealtimeTTSModel( # pylint: disable=too-many-public-methods
IsolatedAsyncioTestCase,
):
"""The unittests for DashScope Realtime TTS model."""
def setUp(self) -> None:
self.mock_modules = self._create_mock_dashscope_modules()
self.mock_client = self._create_mock_tts_client()
mock_tts_class = Mock(return_value=self.mock_client)
self.mock_modules[
"dashscope.audio.qwen_tts_realtime"
].QwenTtsRealtime = mock_tts_class
@staticmethod
def _create_mock_dashscope_modules() -> dict:
mock_qwen_tts_realtime = MagicMock()
mock_qwen_tts_realtime.QwenTtsRealtime = Mock
mock_qwen_tts_realtime.QwenTtsRealtimeCallback = Mock
mock_audio = MagicMock()
mock_audio.qwen_tts_realtime = mock_qwen_tts_realtime
mock_dashscope = MagicMock()
mock_dashscope.api_key = None
mock_dashscope.audio = mock_audio
return {
"dashscope": mock_dashscope,
"dashscope.audio": mock_audio,
"dashscope.audio.qwen_tts_realtime": mock_qwen_tts_realtime,
}
@staticmethod
def _create_mock_tts_client() -> Mock:
client = Mock()
client.connect = Mock()
client.close = Mock()
client.finish = Mock()
client.commit = Mock()
client.update_session = Mock()
client.append_text = Mock()
return client
def _make_model(self, **kwargs: Any) -> DashScopeRealtimeTTSModel:
defaults: dict[str, Any] = {
"credential": DashScopeCredential(api_key="test"),
"model": "qwen3-tts-flash-realtime",
"stream": True,
"max_retries": 1,
"retry_delay": 0.0,
}
defaults.update(kwargs)
return DashScopeRealtimeTTSModel(**defaults)
def _mock_synthesize_callback(self, model: Any) -> None:
"""Set up callback mocks so ``synthesize()`` doesn't block."""
model._callback.finish_event = Mock()
model._callback.finish_event.wait = Mock()
model._callback.has_audio_data = Mock(return_value=True)
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
# -- connect / close --
async def test_async_context_manager(self) -> None:
"""``async with`` triggers connect on enter and close on exit."""
with patch.dict("sys.modules", self.mock_modules):
model = self._make_model()
async with model:
self.assertTrue(model._connected)
self.assertFalse(model._connected)
# -- push --
async def test_push_incremental_deltas(self) -> None:
"""Consecutive deltas are each forwarded verbatim."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
await model.push("Hello")
await model.push(" world")
self.assertEqual(
self.mock_client.append_text.call_count,
2,
)
self.mock_client.append_text.assert_any_call("Hello")
self.mock_client.append_text.assert_any_call(" world")
self.assertEqual(model._accumulated_text, "Hello world")
async def test_push_returns_audio_when_available(self) -> None:
"""push() returns audio data from the callback when available."""
mock_audio_data = base64.b64encode(b"PCMDATA").decode("ascii")
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(
content=MagicMock(
source=MagicMock(
data=mock_audio_data,
media_type=_MEDIA_TYPE,
),
),
),
)
res = await model.push("Hello")
self.assertIsNotNone(res.content)
self.assertEqual(res.content.source.data, mock_audio_data)
async def test_push_cold_start_buffers_across_deltas(self) -> None:
"""Multiple small deltas are buffered until cold_start_length is
met, then flushed as a single ``append_text`` call."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(cold_start_length=10) as model:
await model.push("Hi")
await model.push(" there")
self.mock_client.append_text.assert_not_called()
self.assertFalse(model._cold_start_done)
await model.push(" friend!")
self.mock_client.append_text.assert_called_once_with(
"Hi there friend!",
)
self.assertTrue(model._cold_start_done)
async def test_push_after_cold_start_forwards_directly(self) -> None:
"""Once cold start is done, subsequent deltas bypass the buffer."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(cold_start_length=3) as model:
await model.push("Hello")
self.mock_client.append_text.assert_called_with("Hello")
await model.push(" world")
self.mock_client.append_text.assert_called_with(" world")
self.assertEqual(
self.mock_client.append_text.call_count,
2,
)
async def test_push_cold_start_words_buffers(self) -> None:
"""Deltas below cold_start_words are buffered."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(cold_start_words=3) as model:
await model.push("Hello")
await model.push(" world")
self.mock_client.append_text.assert_not_called()
self.assertFalse(model._cold_start_done)
# -- synthesize --
async def test_synthesize_commits_and_finishes(self) -> None:
"""synthesize(text=...) appends text, commits, and finishes."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
self._mock_synthesize_callback(model)
await model.synthesize(text="Hello")
self.mock_client.append_text.assert_called_once_with("Hello")
self.mock_client.commit.assert_called_once()
self.mock_client.finish.assert_called_once()
async def test_synthesize_appends_extra_text(self) -> None:
"""synthesize(text=...) after push appends the extra text."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hello")
self.mock_client.append_text.reset_mock()
self._mock_synthesize_callback(model)
await model.synthesize(text=" world")
self.mock_client.append_text.assert_called_once_with(
" world",
)
self.mock_client.commit.assert_called_once()
async def test_synthesize_no_text_drain(self) -> None:
"""synthesize(None) after push commits without extra append."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hello")
self.mock_client.append_text.reset_mock()
self._mock_synthesize_callback(model)
await model.synthesize()
self.mock_client.append_text.assert_not_called()
self.mock_client.commit.assert_called_once()
async def test_synthesize_stream_returns_generator(self) -> None:
"""stream=True returns an async generator with is_last."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(stream=True) as model:
self._mock_synthesize_callback(model)
async def mock_chunks() -> AsyncGenerator[TTSResponse, None]:
yield TTSResponse(content=None, is_last=True)
model._callback.get_audio_chunks = mock_chunks
gen = await model.synthesize(text="Hello")
chunks = [c async for c in gen]
self.assertTrue(len(chunks) >= 1)
self.assertTrue(chunks[-1].is_last)
async def test_synthesize_flushes_cold_start_buffer(self) -> None:
"""If cold start was never met during push, synthesize flushes the
buffered text before committing."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(cold_start_length=100) as model:
await model.push("Hi")
await model.push(" there")
self.mock_client.append_text.assert_not_called()
self._mock_synthesize_callback(model)
await model.synthesize()
self.mock_client.append_text.assert_called_once_with(
"Hi there",
)
self.mock_client.commit.assert_called_once()
async def test_synthesize_no_audio_raises_after_retries(self) -> None:
"""RuntimeError after max_retries with no audio received."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(
max_retries=1,
retry_delay=0.0,
) as model:
model._callback.finish_event = Mock()
model._callback.finish_event.wait = Mock()
model._callback.has_audio_data = Mock(return_value=False)
with self.assertRaises(RuntimeError):
await model.synthesize(text="Hello")
# -- callback --
async def test_get_audio_chunks_prepends_header_without_prior_push(
self,
) -> None:
"""get_audio_chunks() prepends a WAV header when push() has not
yet consumed any bytes (_consumed == 0)."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._realtime_model import (
_make_callback_class,
)
callback_cls = _make_callback_class()
cb = callback_cls()
audio_b64 = base64.b64encode(b"ALLPCM").decode()
cb.on_event({"type": "response.audio.delta", "delta": audio_b64})
cb.on_event({"type": "session.finished"})
chunks = [c async for c in cb.get_audio_chunks()]
raw_payloads = [
base64.b64decode(c.content.source.data)
for c in chunks
if c.content is not None
]
self.assertTrue(len(raw_payloads) > 0)
self.assertTrue(raw_payloads[0].startswith(b"RIFF"))
self.assertTrue(raw_payloads[0].endswith(b"ALLPCM"))
async def test_get_audio_chunks_skips_header_after_partial_push(
self,
) -> None:
"""get_audio_chunks() does NOT prepend a second WAV header when
push() already consumed and sent the first chunk with a header
(_consumed > 0)."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._realtime_model import (
_make_callback_class,
)
callback_cls = _make_callback_class()
cb = callback_cls()
# Simulate push() consuming the first chunk (with WAV header)
first_b64 = base64.b64encode(b"FIRSTPCM").decode()
cb.on_event(
{"type": "response.audio.delta", "delta": first_b64},
)
first_resp = cb.get_audio_response(block=False)
self.assertIsNotNone(first_resp.content)
self.assertGreater(cb._consumed, 0)
# More data arrives and session finishes
more_b64 = base64.b64encode(b"MOREPCM").decode()
cb.on_event(
{"type": "response.audio.delta", "delta": more_b64},
)
cb.on_event({"type": "session.finished"})
chunks = [c async for c in cb.get_audio_chunks()]
raw_payloads = [
base64.b64decode(c.content.source.data)
for c in chunks
if c.content is not None
]
self.assertTrue(len(raw_payloads) > 0)
# No second RIFF header should appear mid-stream
self.assertFalse(raw_payloads[0].startswith(b"RIFF"))
self.assertEqual(raw_payloads[0], b"MOREPCM")
async def test_get_audio_response_includes_header_without_prior_push(
self,
) -> None:
"""get_audio_response() prepends a WAV header when no data has been
consumed yet (_consumed == 0)."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._realtime_model import (
_make_callback_class,
)
callback_cls = _make_callback_class()
cb = callback_cls()
audio_b64 = base64.b64encode(b"ALLPCM").decode()
cb.on_event({"type": "response.audio.delta", "delta": audio_b64})
cb.on_event({"type": "session.finished"})
resp = cb.get_audio_response(block=True)
raw = base64.b64decode(resp.content.source.data)
self.assertTrue(raw.startswith(b"RIFF"))
self.assertTrue(raw.endswith(b"ALLPCM"))
async def test_get_audio_response_skips_header_after_partial_push(
self,
) -> None:
"""get_audio_response() does NOT prepend a second WAV header when
push() already consumed and sent the first chunk with a header
(_consumed > 0)."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._realtime_model import (
_make_callback_class,
)
callback_cls = _make_callback_class()
cb = callback_cls()
first_b64 = base64.b64encode(b"FIRSTPCM").decode()
cb.on_event(
{"type": "response.audio.delta", "delta": first_b64},
)
first_resp = cb.get_audio_response(block=False)
self.assertIsNotNone(first_resp.content)
self.assertGreater(cb._consumed, 0)
more_b64 = base64.b64encode(b"MOREPCM").decode()
cb.on_event(
{"type": "response.audio.delta", "delta": more_b64},
)
cb.on_event({"type": "session.finished"})
resp = cb.get_audio_response(block=True)
raw = base64.b64decode(resp.content.source.data)
self.assertFalse(raw.startswith(b"RIFF"))
self.assertEqual(raw, b"MOREPCM")
# ---------------------------------------------------------------------------
# DashScopeCosyVoiceTTSModel — realtime push / synthesize lifecycle
# ---------------------------------------------------------------------------
# pylint: disable=too-many-public-methods
class TestDashScopeCosyVoiceRealtimeMode(
IsolatedAsyncioTestCase,
):
"""Unit tests for CosyVoice realtime mode."""
def setUp(self) -> None:
self.mock_modules = self._create_mock_cosyvoice_modules()
self.mock_synthesizer = self._create_mock_synthesizer()
self.mock_modules["dashscope.audio.tts_v2"].SpeechSynthesizer = Mock(
return_value=self.mock_synthesizer,
)
@staticmethod
def _create_mock_cosyvoice_modules() -> dict:
mock_result_callback = Mock
mock_audio_format = MagicMock()
mock_audio_format.PCM_24000HZ_MONO_16BIT = "pcm_24000hz_mono_16bit"
mock_tts_v2 = MagicMock()
mock_tts_v2.ResultCallback = mock_result_callback
mock_tts_v2.AudioFormat = mock_audio_format
mock_tts_v2.SpeechSynthesizer = Mock
mock_audio = MagicMock()
mock_audio.tts_v2 = mock_tts_v2
mock_dashscope = MagicMock()
mock_dashscope.api_key = None
mock_dashscope.audio = mock_audio
return {
"dashscope": mock_dashscope,
"dashscope.audio": mock_audio,
"dashscope.audio.tts_v2": mock_tts_v2,
}
@staticmethod
def _create_mock_synthesizer() -> Mock:
synth = Mock()
synth.streaming_call = Mock()
synth.streaming_complete = Mock()
synth.close = Mock()
return synth
def _make_model(
self,
**kwargs: Any,
) -> DashScopeCosyVoiceTTSModel:
defaults: dict[str, Any] = {
"credential": DashScopeCredential(api_key="test"),
"model": "cosyvoice-v3-plus",
"parameters": DashScopeCosyVoiceTTSModel.Parameters(
voice="longanhuan",
realtime=True,
),
"stream": True,
"max_retries": 1,
"retry_delay": 0.0,
}
defaults.update(kwargs)
return DashScopeCosyVoiceTTSModel(**defaults)
def _mock_synthesize_callback(self, model: Any) -> None:
"""Set up callback mocks so synthesize() doesn't block."""
model._callback.finish_event = Mock()
model._callback.finish_event.wait = Mock(return_value=True)
model._callback.has_audio_data = Mock(return_value=True)
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
# -- connect / close --
async def test_async_context_manager(self) -> None:
"""async with triggers connect on enter and close on exit."""
with patch.dict("sys.modules", self.mock_modules):
model = self._make_model()
async with model:
self.assertTrue(model._connected)
self.assertFalse(model._connected)
async def test_async_context_skips_connect_when_not_realtime(self) -> None:
"""async with does not connect when the realtime parameter is off."""
with patch.dict("sys.modules", self.mock_modules):
model = self._make_model(
parameters=DashScopeCosyVoiceTTSModel.Parameters(
voice="longanhuan",
realtime=False,
),
)
async with model:
self.assertFalse(model._connected)
self.mock_modules[
"dashscope.audio.tts_v2"
].SpeechSynthesizer.assert_not_called()
# -- push --
async def test_push_incremental_deltas(self) -> None:
"""Consecutive deltas are each forwarded via streaming_call."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hello")
await model.push(" world")
self.assertEqual(
self.mock_synthesizer.streaming_call.call_count,
2,
)
self.mock_synthesizer.streaming_call.assert_any_call("Hello")
self.mock_synthesizer.streaming_call.assert_any_call(" world")
async def test_push_returns_audio_when_available(self) -> None:
"""push() returns audio data from the callback when available."""
mock_audio_data = base64.b64encode(b"PCMDATA").decode("ascii")
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(
content=MagicMock(
source=MagicMock(
data=mock_audio_data,
media_type=_MEDIA_TYPE,
),
),
),
)
res = await model.push("Hello")
self.assertIsNotNone(res.content)
self.assertEqual(res.content.source.data, mock_audio_data)
async def test_push_cold_start_buffers_across_deltas(self) -> None:
"""Multiple small deltas are buffered until cold_start_length is
met, then flushed as a single streaming_call."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(cold_start_length=10) as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hi")
await model.push(" there")
self.mock_synthesizer.streaming_call.assert_not_called()
self.assertFalse(model._cold_start_done)
await model.push(" friend!")
self.mock_synthesizer.streaming_call.assert_called_once_with(
"Hi there friend!",
)
self.assertTrue(model._cold_start_done)
async def test_push_after_cold_start_forwards_directly(self) -> None:
"""Once cold start is done, subsequent deltas bypass the buffer."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(cold_start_length=3) as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hello")
self.mock_synthesizer.streaming_call.assert_called_with(
"Hello",
)
await model.push(" world")
self.mock_synthesizer.streaming_call.assert_called_with(
" world",
)
self.assertEqual(
self.mock_synthesizer.streaming_call.call_count,
2,
)
async def test_push_cold_start_words_buffers(self) -> None:
"""Deltas below cold_start_words are buffered."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(cold_start_words=3) as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hello")
await model.push(" world")
self.mock_synthesizer.streaming_call.assert_not_called()
self.assertFalse(model._cold_start_done)
async def test_push_exception_returns_empty(self) -> None:
"""push() returns empty response if streaming_call raises."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
self.mock_synthesizer.streaming_call.side_effect = Exception(
"connection error",
)
res = await model.push("Hello")
self.assertIsNone(res.content)
# -- synthesize --
async def test_synthesize_calls_streaming_complete(self) -> None:
"""synthesize() calls streaming_call and streaming_complete."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
self._mock_synthesize_callback(model)
await model.synthesize(text="Hello")
self.mock_synthesizer.streaming_call.assert_called_once_with(
"Hello",
)
self.mock_synthesizer.streaming_complete.assert_called_once()
async def test_synthesize_appends_extra_text(self) -> None:
"""synthesize(text=...) after push appends the extra text."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hello")
self.mock_synthesizer.streaming_call.reset_mock()
self._mock_synthesize_callback(model)
await model.synthesize(text=" world")
self.mock_synthesizer.streaming_call.assert_called_once_with(
" world",
)
self.mock_synthesizer.streaming_complete.assert_called_once()
async def test_synthesize_no_text_drain(self) -> None:
"""synthesize(None) after push calls streaming_complete without
extra streaming_call."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model() as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hello")
self.mock_synthesizer.streaming_call.reset_mock()
self._mock_synthesize_callback(model)
await model.synthesize()
self.mock_synthesizer.streaming_call.assert_not_called()
self.mock_synthesizer.streaming_complete.assert_called_once()
async def test_synthesize_stream_returns_generator(self) -> None:
"""stream=True returns an async generator with is_last."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(stream=True) as model:
self._mock_synthesize_callback(model)
async def mock_chunks() -> AsyncGenerator[TTSResponse, None]:
yield TTSResponse(content=None, is_last=True)
model._callback.get_audio_chunks = mock_chunks
gen = await model.synthesize(text="Hello")
chunks = [c async for c in gen]
self.assertTrue(len(chunks) >= 1)
self.assertTrue(chunks[-1].is_last)
async def test_synthesize_non_stream_returns_single(self) -> None:
"""stream=False returns a single TTSResponse."""
mock_audio_data = base64.b64encode(b"PCMDATA").decode("ascii")
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(stream=False) as model:
self._mock_synthesize_callback(model)
model._callback.get_audio_response = Mock(
return_value=TTSResponse(
content=MagicMock(
source=MagicMock(
data=mock_audio_data,
media_type=_MEDIA_TYPE,
),
),
),
)
res = await model.synthesize(text="Hello")
self.assertIsInstance(res, TTSResponse)
self.assertIsNotNone(res.content)
async def test_synthesize_flushes_cold_start_buffer(self) -> None:
"""If cold start was never met during push, synthesize flushes the
buffered text before calling streaming_complete."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(cold_start_length=100) as model:
model._callback.get_audio_response = Mock(
return_value=TTSResponse(content=None),
)
await model.push("Hi")
await model.push(" there")
self.mock_synthesizer.streaming_call.assert_not_called()
self._mock_synthesize_callback(model)
await model.synthesize()
self.mock_synthesizer.streaming_call.assert_called_once_with(
"Hi there",
)
self.mock_synthesizer.streaming_complete.assert_called_once()
async def test_synthesize_empty_text_short_circuits(self) -> None:
"""synthesize() with no accumulated text returns empty."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(stream=True) as model:
gen = await model.synthesize()
chunks = [c async for c in gen]
self.assertEqual(len(chunks), 1)
self.assertIsNone(chunks[0].content)
self.assertTrue(chunks[0].is_last)
self.mock_synthesizer.streaming_complete.assert_not_called()
async def test_synthesize_no_audio_raises_after_retries(self) -> None:
"""RuntimeError after max_retries with no audio received."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(
max_retries=1,
retry_delay=0.0,
) as model:
model._callback.finish_event = Mock()
model._callback.finish_event.wait = Mock(return_value=True)
model._callback.has_audio_data = Mock(return_value=False)
with self.assertRaises(RuntimeError):
await model.synthesize(text="Hello")
async def test_synthesize_timeout_raises(self) -> None:
"""RuntimeError when finish_event.wait times out."""
with patch.dict("sys.modules", self.mock_modules):
async with self._make_model(
max_retries=1,
retry_delay=0.0,
) as model:
model._callback.finish_event = Mock()
model._callback.finish_event.wait = Mock(return_value=False)
with self.assertRaises(RuntimeError) as ctx:
await model.synthesize(text="Hello")
self.assertIn("timed out", str(ctx.exception))
# -- callback --
async def test_callback_on_complete_sets_finish_event(self) -> None:
"""on_complete() correctly sets finish_event and chunk_event."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
self.assertFalse(cb.finish_event.is_set())
self.assertFalse(cb.chunk_event.is_set())
cb.on_complete()
self.assertTrue(cb.finish_event.is_set())
self.assertTrue(cb.chunk_event.is_set())
async def test_callback_on_data_accumulates(self) -> None:
"""on_data() accumulates PCM bytes and signals chunk_event."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
cb.on_data(b"AAAA")
cb.on_data(b"BBBB")
self.assertEqual(bytes(cb._pcm_bytes), b"AAAABBBB")
self.assertTrue(cb.chunk_event.is_set())
async def test_callback_take_delta_incremental(self) -> None:
"""_take_delta() returns only new bytes since last call."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
cb.on_data(b"AAAA")
delta1 = cb._take_delta(header=False)
self.assertEqual(delta1, b"AAAA")
cb.on_data(b"BBBB")
delta2 = cb._take_delta(header=False)
self.assertEqual(delta2, b"BBBB")
delta3 = cb._take_delta(header=False)
self.assertIsNone(delta3)
async def test_callback_take_delta_with_header(self) -> None:
"""_take_delta(header=True) prepends WAV header to first chunk."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
cb.on_data(b"PCMPCM")
delta = cb._take_delta(header=True)
self.assertTrue(delta.startswith(b"RIFF"))
self.assertIn(b"WAVE", delta[:12])
self.assertTrue(delta.endswith(b"PCMPCM"))
async def test_get_audio_chunks_prepends_header_without_prior_push(
self,
) -> None:
"""get_audio_chunks() prepends a WAV header when push() has not
yet consumed any bytes (_consumed == 0)."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
cb.on_data(b"ALLPCM")
cb.on_complete()
chunks = [c async for c in cb.get_audio_chunks()]
raw_payloads = [
base64.b64decode(c.content.source.data)
for c in chunks
if c.content is not None
]
self.assertTrue(len(raw_payloads) > 0)
self.assertTrue(raw_payloads[0].startswith(b"RIFF"))
self.assertTrue(raw_payloads[0].endswith(b"ALLPCM"))
async def test_get_audio_chunks_skips_header_after_partial_push(
self,
) -> None:
"""get_audio_chunks() does NOT prepend a second WAV header when
push() already consumed and sent the first chunk with a header
(_consumed > 0)."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
# Simulate push() consuming the first chunk (with WAV header)
cb.on_data(b"FIRSTPCM")
first_resp = cb.get_audio_response(block=False)
self.assertIsNotNone(first_resp.content)
self.assertGreater(cb._consumed, 0)
# More data arrives and synthesis completes
cb.on_data(b"MOREPCM")
cb.on_complete()
chunks = [c async for c in cb.get_audio_chunks()]
raw_payloads = [
base64.b64decode(c.content.source.data)
for c in chunks
if c.content is not None
]
self.assertTrue(len(raw_payloads) > 0)
# No second RIFF header should appear mid-stream
self.assertFalse(raw_payloads[0].startswith(b"RIFF"))
self.assertEqual(raw_payloads[0], b"MOREPCM")
async def test_get_audio_response_includes_header_without_prior_push(
self,
) -> None:
"""get_audio_response() prepends a WAV header when no data has been
consumed yet (_consumed == 0)."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
cb.on_data(b"ALLPCM")
cb.on_complete()
resp = cb.get_audio_response(block=True)
raw = base64.b64decode(resp.content.source.data)
self.assertTrue(raw.startswith(b"RIFF"))
self.assertTrue(raw.endswith(b"ALLPCM"))
async def test_get_audio_response_skips_header_after_partial_push(
self,
) -> None:
"""get_audio_response() does NOT prepend a second WAV header when
push() already consumed and sent the first chunk with a header
(_consumed > 0)."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
cb.on_data(b"FIRSTPCM")
first_resp = cb.get_audio_response(block=False)
self.assertIsNotNone(first_resp.content)
self.assertGreater(cb._consumed, 0)
cb.on_data(b"MOREPCM")
cb.on_complete()
resp = cb.get_audio_response(block=True)
raw = base64.b64decode(resp.content.source.data)
self.assertFalse(raw.startswith(b"RIFF"))
self.assertEqual(raw, b"MOREPCM")
async def test_callback_reset(self) -> None:
"""reset() clears all state."""
with patch.dict("sys.modules", self.mock_modules):
from agentscope.tts._dashscope._cosyvoice_utils import (
_make_cosyvoice_callback_class,
)
callback_cls = _make_cosyvoice_callback_class()
cb = callback_cls()
cb.on_data(b"AAAA")
cb.on_complete()
cb.reset()
self.assertFalse(cb.finish_event.is_set())
self.assertFalse(cb.chunk_event.is_set())
self.assertEqual(bytes(cb._pcm_bytes), b"")
self.assertEqual(cb._consumed, 0)
# -- reconnect --
async def test_reconnect_recreates_synthesizer(self) -> None:
"""_reconnect() closes old synthesizer and creates a new one."""
with patch.dict("sys.modules", self.mock_modules):
model = self._make_model()
await model.connect()
old_callback = model._callback
await model._reconnect()
self.mock_synthesizer.close.assert_called_once()
self.assertTrue(model._connected)
self.assertIsNot(model._callback, old_callback)