|
|
@@ -262,144 +262,116 @@ Great job! That's the correct pronunciation. Keep practicing and you'll get even
|
|
|
self, text: str, emotion: EmotionType = "neutral"
|
|
|
) -> AsyncGenerator[bytes, None]:
|
|
|
"""
|
|
|
- 文本转语音(流式)
|
|
|
+ 文本转语音(流式 - 实时返回音频块)
|
|
|
|
|
|
Args:
|
|
|
text: 要合成的文本
|
|
|
emotion: 情感类型
|
|
|
|
|
|
Yields:
|
|
|
- audio_data: 音频数据块
|
|
|
+ audio_data: PCM 音频数据块(Base64 解码后的原始数据)
|
|
|
"""
|
|
|
try:
|
|
|
- print(f"\n========== TTS 开始 ==========")
|
|
|
+ import time
|
|
|
+ tts_start = time.time()
|
|
|
+
|
|
|
+ print(f"\n========== TTS 流式开始 ==========")
|
|
|
print(f"要合成的文本: {text}")
|
|
|
print(f"情感: {emotion}")
|
|
|
|
|
|
- # 根据情感选择音色或参数
|
|
|
+ # 根据情感选择音色
|
|
|
voice = self._get_voice_for_emotion(emotion)
|
|
|
print(f"使用音色: {voice}")
|
|
|
print(f"TTS模型: {settings.tts_model}")
|
|
|
+ print(f"语速: {settings.tts_speech_rate}x (1.0=正常)")
|
|
|
+ print(f"流式模式: 是 (PCM 24kHz)")
|
|
|
|
|
|
- # 在线程池中执行同步调用,避免事件循环冲突
|
|
|
- # 添加重试机制以应对网络不稳定
|
|
|
- def sync_tts():
|
|
|
- max_retries = 3
|
|
|
- retry_delay = 1 # 秒
|
|
|
-
|
|
|
- for attempt in range(max_retries):
|
|
|
- try:
|
|
|
- return dashscope.audio.qwen_tts.SpeechSynthesizer.call(
|
|
|
- model=settings.tts_model,
|
|
|
- text=text,
|
|
|
- voice=voice,
|
|
|
- )
|
|
|
- except Exception as e:
|
|
|
- if attempt < max_retries - 1:
|
|
|
- print(f"TTS调用失败(尝试 {attempt + 1}/{max_retries}): {e}")
|
|
|
- print(f"等待{retry_delay}秒后重试...")
|
|
|
- import time
|
|
|
- time.sleep(retry_delay)
|
|
|
- retry_delay *= 2 # 指数退避
|
|
|
- else:
|
|
|
- raise
|
|
|
+ # 使用流式 API
|
|
|
+ def sync_streaming_tts():
|
|
|
+ return dashscope.MultiModalConversation.call(
|
|
|
+ api_key=settings.dashscope_api_key,
|
|
|
+ model=settings.tts_model,
|
|
|
+ text=text,
|
|
|
+ voice=voice,
|
|
|
+ speech_rate=settings.tts_speech_rate, # 语速调整
|
|
|
+ stream=True # 🔥 启用流式
|
|
|
+ )
|
|
|
|
|
|
- print("调用 DashScope TTS API...")
|
|
|
- # 调用TTS API
|
|
|
- response = await asyncio.to_thread(sync_tts)
|
|
|
+ print("调用 DashScope TTS 流式 API...")
|
|
|
|
|
|
- print(f"TTS API 响应类型: {type(response)}")
|
|
|
+ # 在线程池中调用流式 API
|
|
|
+ response = await asyncio.to_thread(sync_streaming_tts)
|
|
|
|
|
|
- # 获取音频数据
|
|
|
- # response.output 包含 audio 字典,其中有 data、url、id、expires_at
|
|
|
- audio_bytes = None
|
|
|
+ chunk_count = 0
|
|
|
+ first_chunk_time = None
|
|
|
+ total_audio_size = 0
|
|
|
|
|
|
- if hasattr(response, 'output'):
|
|
|
- output = response.output
|
|
|
- print(f"output 类型: {type(output)}")
|
|
|
-
|
|
|
- # 打印finish_reason用于调试
|
|
|
- if isinstance(output, dict):
|
|
|
- print(f"finish_reason: {output.get('finish_reason', 'N/A')}")
|
|
|
- audio_info = output.get('audio', {})
|
|
|
- elif hasattr(output, 'finish_reason'):
|
|
|
- print(f"finish_reason: {output.finish_reason}")
|
|
|
- audio_info = output.get('audio', {}) if isinstance(output, dict) else getattr(output, 'audio', {})
|
|
|
- else:
|
|
|
- audio_info = {}
|
|
|
-
|
|
|
- # 检查是否有URL(新版API返回URL)
|
|
|
- if isinstance(audio_info, dict) and 'url' in audio_info and audio_info['url']:
|
|
|
- audio_url = audio_info['url']
|
|
|
- print(f"从URL下载音频: {audio_url}")
|
|
|
+ # 流式接收音频数据
|
|
|
+ for chunk in response:
|
|
|
+ if hasattr(chunk, 'output') and hasattr(chunk.output, 'audio'):
|
|
|
+ audio = chunk.output.audio
|
|
|
|
|
|
- # 下载音频数据
|
|
|
- import aiohttp
|
|
|
- async with aiohttp.ClientSession() as session:
|
|
|
- async with session.get(audio_url) as resp:
|
|
|
- if resp.status == 200:
|
|
|
- audio_bytes = await resp.read()
|
|
|
- print(f"音频下载成功,大小: {len(audio_bytes)} 字节")
|
|
|
- else:
|
|
|
- raise Exception(f"下载音频失败,HTTP状态码: {resp.status}")
|
|
|
-
|
|
|
- # 检查是否有直接的数据(旧版API)
|
|
|
- elif isinstance(audio_info, dict) and 'data' in audio_info and audio_info['data']:
|
|
|
- audio_data = audio_info['data']
|
|
|
- if isinstance(audio_data, bytes):
|
|
|
- audio_bytes = audio_data
|
|
|
- print(f"音频数据(bytes): {len(audio_bytes)} 字节")
|
|
|
- elif isinstance(audio_data, str):
|
|
|
- audio_bytes = base64.b64decode(audio_data)
|
|
|
- print(f"音频数据(base64解码): {len(audio_bytes)} 字节")
|
|
|
-
|
|
|
- # 如果audio_info直接是bytes或str
|
|
|
- elif isinstance(audio_info, bytes):
|
|
|
- audio_bytes = audio_info
|
|
|
- print(f"音频数据(bytes): {len(audio_bytes)} 字节")
|
|
|
- elif isinstance(audio_info, str):
|
|
|
- audio_bytes = base64.b64decode(audio_info)
|
|
|
- print(f"音频数据(base64解码): {len(audio_bytes)} 字节")
|
|
|
+ # 接收到音频数据块
|
|
|
+ if audio.data is not None:
|
|
|
+ # 记录首字节时间
|
|
|
+ if first_chunk_time is None:
|
|
|
+ first_chunk_time = time.time()
|
|
|
+ first_byte_latency = (first_chunk_time - tts_start) * 1000
|
|
|
+ print(f"⚡ 首字节延迟: {first_byte_latency:.1f}ms")
|
|
|
+
|
|
|
+ # Base64 解码 PCM 数据
|
|
|
+ pcm_bytes = base64.b64decode(audio.data)
|
|
|
+ chunk_count += 1
|
|
|
+ total_audio_size += len(pcm_bytes)
|
|
|
+
|
|
|
+ # 立即返回给调用方(实时传输)
|
|
|
+ yield pcm_bytes
|
|
|
+ await asyncio.sleep(0) # 让出控制权
|
|
|
+
|
|
|
+ # 检查是否结束
|
|
|
+ if chunk.output.finish_reason == "stop":
|
|
|
+ tts_end = time.time()
|
|
|
+ total_duration = (tts_end - tts_start) * 1000
|
|
|
+
|
|
|
+ print(f"✅ TTS 流式完成:")
|
|
|
+ print(f" - 总块数: {chunk_count}")
|
|
|
+ print(f" - 音频大小: {total_audio_size} 字节")
|
|
|
+ print(f" - 总耗时: {total_duration:.0f}ms")
|
|
|
+ if first_chunk_time:
|
|
|
+ print(f" - 首字节延迟: {(first_chunk_time - tts_start) * 1000:.0f}ms")
|
|
|
+
|
|
|
+ # 完整音频 URL(可选,用于备份)
|
|
|
+ if hasattr(audio, 'url') and audio.url:
|
|
|
+ print(f" - 完整音频 URL: {audio.url}")
|
|
|
+ if hasattr(audio, 'expires_at') and audio.expires_at:
|
|
|
+ print(f" - 过期时间: {audio.expires_at}")
|
|
|
+
|
|
|
+ print(f"========== TTS 流式成功 ==========\n")
|
|
|
+ break
|
|
|
|
|
|
- if audio_bytes:
|
|
|
-
|
|
|
- # 将音频数据分块返回
|
|
|
- chunk_size = 3200 # 每块3200字节
|
|
|
- chunk_count = (len(audio_bytes) + chunk_size - 1) // chunk_size
|
|
|
- print(f"将分为 {chunk_count} 个数据块发送")
|
|
|
-
|
|
|
- for i in range(0, len(audio_bytes), chunk_size):
|
|
|
- chunk = audio_bytes[i:i + chunk_size]
|
|
|
- yield chunk
|
|
|
- await asyncio.sleep(0) # 让出控制权
|
|
|
-
|
|
|
- print(f"========== TTS 成功 ==========\n")
|
|
|
- else:
|
|
|
- error_msg = "TTS API未返回音频数据"
|
|
|
- print(f"错误: {error_msg}")
|
|
|
- print(f"========== TTS 失败 ==========\n")
|
|
|
- raise Exception(error_msg)
|
|
|
+ if chunk_count == 0:
|
|
|
+ raise Exception("TTS API 未返回任何音频数据")
|
|
|
|
|
|
except Exception as e:
|
|
|
- print(f"========== TTS 错误 ==========")
|
|
|
+ print(f"========== TTS 流式错误 ==========")
|
|
|
print(f"错误: {e}")
|
|
|
import traceback
|
|
|
traceback.print_exc()
|
|
|
- print(f"========== TTS 错误结束 ==========\n")
|
|
|
+ print(f"========== TTS 流式错误结束 ==========\n")
|
|
|
raise
|
|
|
|
|
|
def _get_voice_for_emotion(self, emotion: EmotionType) -> str:
|
|
|
"""根据情感选择音色"""
|
|
|
- # 情感到音色的映射
|
|
|
+ # 情感到音色的映射(统一使用 Elias 男声)
|
|
|
voice_map = {
|
|
|
- "neutral": "Cherry",
|
|
|
- "happy": "Cherry",
|
|
|
- "sad": "Cherry",
|
|
|
- "excited": "Cherry",
|
|
|
- "angry": "Cherry",
|
|
|
- "surprised": "Cherry",
|
|
|
- "thoughtful": "Cherry",
|
|
|
- "encouraging": "Cherry",
|
|
|
+ "neutral": "Elias",
|
|
|
+ "happy": "Elias",
|
|
|
+ "sad": "Elias",
|
|
|
+ "excited": "Elias",
|
|
|
+ "angry": "Elias",
|
|
|
+ "surprised": "Elias",
|
|
|
+ "thoughtful": "Elias",
|
|
|
+ "encouraging": "Elias",
|
|
|
}
|
|
|
return voice_map.get(emotion, settings.tts_voice)
|
|
|
|