Akshay66777's picture
AgentScope Gradio chat app — ZeroGPU-ready
9792ea7 verified
Raw
History Blame Contribute Delete
8.78 kB
# -*- coding: utf-8 -*-
"""DashScope TTS model implementation using MultiModalConversation API."""
import base64
import io
import wave
from datetime import datetime
from typing import (
Any,
AsyncGenerator,
Generator,
Literal,
TYPE_CHECKING,
)
from pydantic import BaseModel, Field
from .._tts_base import TTSModelBase
from .._tts_response import TTSResponse, TTSUsage
from ..._utils._audio import _build_streaming_wav_header
from ...credential import DashScopeCredential
from ...message import DataBlock, Base64Source
if TYPE_CHECKING:
from dashscope.api_entities.dashscope_response import (
MultiModalConversationResponse,
)
# DashScope TTS returns raw PCM (24kHz, mono, 16-bit). We wrap it as WAV
# on the way out so the frontend can play it: streaming deltas get a
# streaming WAV header on the first chunk; non-streaming returns a
# self-contained fixed-size WAV.
_TTS_SAMPLE_RATE = 24000
_TTS_CHANNELS = 1
_TTS_BITS_PER_SAMPLE = 16
_DEFAULT_MEDIA_TYPE = "audio/wav"
_SENTINEL = object()
def _parse_usage(usage: Any, elapsed: float) -> TTSUsage | None:
"""Extract a TTSUsage from the DashScope usage object, or None."""
if usage is None:
return None
return TTSUsage(
input_tokens=getattr(usage, "input_tokens", 0) or 0,
output_tokens=getattr(usage, "output_tokens", 0) or 0,
time=elapsed,
)
class DashScopeTTSModel(TTSModelBase):
"""DashScope TTS model implementation using the MultiModalConversation
API. For more details please see the `official document
<https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2879134>`_.
"""
class Parameters(BaseModel):
"""Frontend-exposed parameters for DashScope TTS models."""
voice: str = Field(
default="Cherry",
title="Voice",
description="The voice to use for synthesis.",
)
type: Literal["dashscope_tts"] = "dashscope_tts"
"""The type of the TTS model."""
realtime: bool = False
def __init__(
self,
credential: DashScopeCredential,
model: str = "qwen3-tts-flash",
parameters: "DashScopeTTSModel.Parameters | None" = None,
stream: bool = True,
) -> None:
"""Initialize the DashScope TTS model.
.. note:: More details about the parameters, such as ``model``
and ``voice``, can be found in the `official document
<https://bailian.console.aliyun.com/?tab=doc#/doc/?type=model&url=2879134>`_.
Args:
credential (`DashScopeCredential`):
The DashScope credential used to authenticate the API call.
model (`str`, defaults to ``"qwen3-tts-flash"``):
The TTS model name. Supported models include
``qwen3-tts-flash``, ``qwen-tts``, etc.
parameters (`DashScopeTTSModel.Parameters | None`, defaults to \
`None`):
The TTS parameters (voice, language, etc.). When ``None``,
the default parameters will be used.
stream (`bool`, defaults to `True`):
Whether to use streaming output. When `True`,
:meth:`synthesize` returns an async generator yielding
``TTSResponse`` chunks; when `False`, it returns a single
aggregated ``TTSResponse``.
"""
super().__init__(
credential=credential,
model=model,
parameters=parameters,
stream=stream,
)
async def synthesize(
self,
text: str | None = None,
**kwargs: Any,
) -> TTSResponse | AsyncGenerator[TTSResponse, None]:
"""Call the DashScope TTS API to synthesize speech from text.
Args:
text (`str | None`, optional):
The text to be synthesized.
**kwargs (`Any`):
Additional keyword arguments to pass to the TTS API call.
Returns:
`TTSResponse | AsyncGenerator[TTSResponse, None]`:
A single ``TTSResponse`` when ``stream=False``, or an async
generator yielding ``TTSResponse`` chunks when ``stream=True``.
"""
if not text:
return TTSResponse(content=None)
import dashscope
response = dashscope.MultiModalConversation.call(
model=self.model,
api_key=self.credential.api_key.get_secret_value(),
text=text,
voice=self.parameters.voice,
stream=True,
**kwargs,
)
if self.stream:
return self._parse_into_async_generator(response)
return self._aggregate_sync(response)
@staticmethod
def _aggregate_sync(
response: Generator["MultiModalConversationResponse", None, None],
) -> TTSResponse:
"""Aggregate all streaming chunks into a single self-contained WAV."""
start_datetime = datetime.now()
audio_bytes = bytearray()
usage = None
for chunk in response:
if chunk.usage is not None:
usage = chunk.usage
if chunk.output is not None:
audio = chunk.output.audio
if audio and audio.data:
audio_bytes += base64.b64decode(audio.data)
elapsed = (datetime.now() - start_datetime).total_seconds()
buf = io.BytesIO()
with wave.open(buf, "wb") as wav:
wav.setnchannels(_TTS_CHANNELS)
wav.setsampwidth(_TTS_BITS_PER_SAMPLE // 8)
wav.setframerate(_TTS_SAMPLE_RATE)
wav.writeframes(bytes(audio_bytes))
return TTSResponse(
content=DataBlock(
source=Base64Source(
data=base64.b64encode(buf.getvalue()).decode("ascii"),
media_type=_DEFAULT_MEDIA_TYPE,
),
),
usage=_parse_usage(usage, elapsed),
)
@staticmethod
async def _parse_into_async_generator(
response: Generator["MultiModalConversationResponse", None, None],
) -> AsyncGenerator[TTSResponse, None]:
"""Parse the streaming TTS response into an async generator.
Each yielded ``TTSResponse`` carries an **incremental** WAV chunk:
the first chunk is prefixed with a streaming WAV/RIFF header so the
frontend can start playback immediately (without waiting for
end-of-stream); subsequent chunks are raw PCM bytes appended to that
open stream. The final response has ``is_last=True``.
Args:
response (`Generator[MultiModalConversationResponse, None, None]`):
The streaming response from the DashScope TTS API.
Yields:
`TTSResponse`:
A ``TTSResponse`` for each incremental audio chunk; the final
response has ``is_last=True``.
"""
pending: TTSResponse | None = None
header_sent = False
usage = None
start_datetime = datetime.now()
it = iter(response)
while True:
chunk = next(it, _SENTINEL)
if chunk is _SENTINEL:
break
if chunk.usage is not None:
usage = chunk.usage
if chunk.output is None:
continue
audio = chunk.output.audio
if not audio or not audio.data:
continue
delta_bytes = base64.b64decode(audio.data)
if not delta_bytes:
continue
if not header_sent:
payload = (
_build_streaming_wav_header(
sample_rate=_TTS_SAMPLE_RATE,
channels=_TTS_CHANNELS,
bits_per_sample=_TTS_BITS_PER_SAMPLE,
)
+ delta_bytes
)
header_sent = True
else:
payload = delta_bytes
if pending is not None:
yield pending
pending = TTSResponse(
content=DataBlock(
source=Base64Source(
data=base64.b64encode(payload).decode("ascii"),
media_type=_DEFAULT_MEDIA_TYPE,
),
),
is_last=False,
)
elapsed = (datetime.now() - start_datetime).total_seconds()
if pending is not None:
pending.is_last = True
pending.usage = _parse_usage(usage, elapsed)
yield pending
else:
yield TTSResponse(
content=None,
is_last=True,
usage=_parse_usage(usage, elapsed),
)