feat: add DashScope bidirectional streaming TTS adapter
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import asyncio
|
||||
import json
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pipecat.frames.frames import TTSAudioRawFrame, TTSStoppedFrame
|
||||
from services.pipecat.dashscope_tts import DashScopeTTSService, SynthesisSession, SUPPORTED_MODELS
|
||||
from services.model_resource_tester import test_model_resource
|
||||
|
||||
URL = 'wss://workspace.cn-beijing.maas.aliyuncs.com/api-ws/v1/inference'
|
||||
|
||||
|
||||
class Socket:
|
||||
def __init__(self):
|
||||
self.sent = []
|
||||
self.queue = asyncio.Queue()
|
||||
self.close = AsyncMock()
|
||||
|
||||
async def send(self, raw):
|
||||
msg = json.loads(raw)
|
||||
self.sent.append(msg)
|
||||
if msg['header']['action'] == 'run-task':
|
||||
self.task_id = msg['header']['task_id']
|
||||
self.event('task-started')
|
||||
|
||||
def event(self, name):
|
||||
self.queue.put_nowait(json.dumps({'header': {'event': name, 'task_id': self.task_id}}))
|
||||
|
||||
async def recv(self):
|
||||
return await self.queue.get()
|
||||
|
||||
|
||||
class DashScopeTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_protocol_streams_before_finish_and_preserves_spaces(self):
|
||||
socket = Socket()
|
||||
session = SynthesisSession(URL, 'secret', 'cosyvoice-v1', 'longxiaochun')
|
||||
with patch('services.pipecat.dashscope_tts.connect', AsyncMock(return_value=socket)):
|
||||
await session.start()
|
||||
await session.text('Hello')
|
||||
await session.text(' world')
|
||||
socket.queue.put_nowait(b'\x01\x00' * 20)
|
||||
audio = session.audio()
|
||||
self.assertEqual(await anext(audio), b'\x01\x00' * 20)
|
||||
self.assertFalse(session.finished_input)
|
||||
await session.finish()
|
||||
await session.finish()
|
||||
socket.event('task-finished')
|
||||
with self.assertRaises(StopAsyncIteration):
|
||||
await anext(audio)
|
||||
await session.close()
|
||||
self.assertEqual([m['header']['action'] for m in socket.sent],
|
||||
['run-task', 'continue-task', 'continue-task', 'finish-task'])
|
||||
self.assertEqual(socket.sent[2]['payload']['input']['text'], ' world')
|
||||
self.assertEqual(socket.sent[0]['payload']['parameters']['format'], 'pcm')
|
||||
self.assertEqual(len({m['header']['task_id'] for m in socket.sent}), 1)
|
||||
|
||||
async def test_empty_audio_and_provider_failure(self):
|
||||
for failure in (False, True):
|
||||
socket = Socket()
|
||||
session = SynthesisSession(URL, 'secret', 'cosyvoice-v2', 'voice')
|
||||
with patch('services.pipecat.dashscope_tts.connect', AsyncMock(return_value=socket)):
|
||||
await session.start()
|
||||
socket.event('task-failed' if failure else 'task-finished')
|
||||
with self.assertRaises(RuntimeError):
|
||||
await anext(session.audio())
|
||||
await session.close()
|
||||
|
||||
async def test_pipecat_audio_completion_and_interruption(self):
|
||||
socket = Socket()
|
||||
service = DashScopeTTSService(api_key='secret', model='qwen-audio-3.1-tts-flash',
|
||||
voice='voice', url=URL)
|
||||
service._sample_rate = 24000
|
||||
service.create_task = asyncio.create_task
|
||||
service.cancel_task = AsyncMock(side_effect=self.cancel_task)
|
||||
service.audio_context_available = lambda _: True
|
||||
service.append_to_audio_context = AsyncMock()
|
||||
service.remove_audio_context = AsyncMock()
|
||||
with patch('services.pipecat.dashscope_tts.connect', AsyncMock(return_value=socket)):
|
||||
async for _ in service.run_tts('你好', 'turn1'):
|
||||
pass
|
||||
receiver = service._streams['turn1'].receiver
|
||||
socket.queue.put_nowait(b'\0\0' * 20)
|
||||
await service.flush_audio('turn1')
|
||||
socket.event('task-finished')
|
||||
await receiver
|
||||
frames = [call.args[1] for call in service.append_to_audio_context.call_args_list]
|
||||
self.assertIsInstance(frames[0], TTSAudioRawFrame)
|
||||
self.assertIsInstance(frames[-1], TTSStoppedFrame)
|
||||
async for _ in service.run_tts('下一轮', 'turn2'):
|
||||
pass
|
||||
await service.on_audio_context_interrupted('turn2')
|
||||
self.assertFalse(service._streams)
|
||||
self.assertGreaterEqual(socket.close.await_count, 2)
|
||||
|
||||
async def cancel_task(self, task):
|
||||
task.cancel()
|
||||
try:
|
||||
await task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def test_tester_requires_audio_and_completion(self):
|
||||
with patch.object(SynthesisSession, 'start', AsyncMock()), \
|
||||
patch.object(SynthesisSession, 'text', AsyncMock()), \
|
||||
patch.object(SynthesisSession, 'finish', AsyncMock()), \
|
||||
patch.object(SynthesisSession, 'close', AsyncMock()):
|
||||
async def no_audio(self):
|
||||
raise RuntimeError('no audio secret')
|
||||
yield b''
|
||||
with patch.object(SynthesisSession, 'audio', no_audio):
|
||||
result = await test_model_resource('dashscope-tts', 'TTS',
|
||||
{'apiUrl': URL, 'modelId': 'cosyvoice-v2', 'voice': 'voice'}, {'apiKey': 'secret'})
|
||||
self.assertFalse(result.ok)
|
||||
self.assertNotIn('secret', result.detail)
|
||||
|
||||
def test_factory_routes_dashscope_to_token_streaming(self):
|
||||
from models import AssistantConfig
|
||||
from services.pipecat.service_factory import create_tts
|
||||
service = create_tts(AssistantConfig(
|
||||
tts_interface_type="dashscope-tts", tts_model="cosyvoice-v1",
|
||||
voice="longxiaochun", tts_api_key="secret", tts_base_url=URL))
|
||||
self.assertIsInstance(service, DashScopeTTSService)
|
||||
self.assertTrue(service._is_streaming_tokens)
|
||||
|
||||
def test_models_and_old_http_url(self):
|
||||
for model in SUPPORTED_MODELS:
|
||||
SynthesisSession(URL, 'secret', model, 'voice')
|
||||
with self.assertRaisesRegex(ValueError, '旧 HTTP'):
|
||||
SynthesisSession('https://dashscope.aliyuncs.com/api/v1/services/audio/tts',
|
||||
'secret', 'cosyvoice-v1', 'voice')
|
||||
Reference in New Issue
Block a user