diff --git a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt index eade082..9b44891 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/ui/chat/ChatViewModel.kt @@ -15,6 +15,7 @@ import com.tiangong.aiagent.domain.model.Message import com.tiangong.aiagent.domain.model.SseEvent import com.tiangong.aiagent.util.AudioPlayer import com.tiangong.aiagent.util.AudioRecorder +import com.tiangong.aiagent.util.MarkdownRenderer import com.tiangong.aiagent.util.OfflineQueueManager import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Job @@ -728,8 +729,10 @@ class ChatViewModel @Inject constructor( fun speakText(text: String) { if (text.isBlank()) return + val plainText = MarkdownRenderer.stripMarkdownForTTS(text) + if (plainText.isBlank()) return viewModelScope.launch { - chatRepository.synthesizeSpeech(text) + chatRepository.synthesizeSpeech(plainText) .onSuccess { audioUrl -> audioPlayer.play(audioUrl) } .onFailure { Log.w("ChatViewModel", "TTS failed", it) } } diff --git a/android/app/src/main/java/com/tiangong/aiagent/util/MarkdownRenderer.kt b/android/app/src/main/java/com/tiangong/aiagent/util/MarkdownRenderer.kt index ed5d45d..000c61d 100644 --- a/android/app/src/main/java/com/tiangong/aiagent/util/MarkdownRenderer.kt +++ b/android/app/src/main/java/com/tiangong/aiagent/util/MarkdownRenderer.kt @@ -28,6 +28,29 @@ import io.noties.markwon.linkify.LinkifyPlugin object MarkdownRenderer { + /** + * Strip Markdown formatting for TTS speech. + * Uses regex to remove common formatting markers: + * **bold**, *italic*, `code`, ```blocks```, # headings, + * [links](url), , > blockquotes, - lists, | tables. + */ + fun stripMarkdownForTTS(text: String): String { + if (text.isBlank()) return "" + return text + .replace(Regex("```[\\s\\S]*?```"), "") // fenced code blocks + .replace(Regex("`([^`]*)`"), "$1") // inline code + .replace(Regex("!\\[.*?]\\(.*?\\)"), "") // images + .replace(Regex("\\[([^]]*)]\\(.*?\\)"), "$1") // links -> text + .replace(Regex("(?m)^#{1,6}\\s+"), "") // heading markers + .replace(Regex("(\\*{1,3}|_{1,3})(.*?)\\1"), "$2") // bold/italic + .replace(Regex("(?m)^[*-]\\s+"), "") // list bullets + .replace(Regex("(?m)^>\\s*"), "") // blockquote + .replace(Regex("(?m)^---+$"), "") // horizontal rules + .replace("|", " ") // table pipes + .replace(Regex("\\n{3,}"), "\n\n") // collapse excessive newlines + .trim() + } + private var markwonInstance: Markwon? = null private fun getMarkwon(context: Context): Markwon { diff --git a/backend/app/api/voice.py b/backend/app/api/voice.py index a1de167..b6475e1 100644 --- a/backend/app/api/voice.py +++ b/backend/app/api/voice.py @@ -28,14 +28,50 @@ logger = logging.getLogger(__name__) router = APIRouter(prefix="/api/v1/voice", tags=["voice"]) -# Edge TTS 中文语音映射 (OpenAI voice name -> Edge TTS Chinese voice) +# Edge TTS 中文语音映射 (voice name -> Edge TTS Chinese voice) +# 注意:edge-tts 免费版仅支持以下中文语音: +# 女声:xiaoxiao, xiaoyi +# 男声:yunxi, yunyang, yunjian, yunxia +# 方言:liaoning-Xiaobei, shaanxi-Xiaoni _EDGE_VOICE_MAP = { - "alloy": "zh-CN-YunxiNeural", - "echo": "zh-CN-YunyangNeural", - "fable": "zh-CN-XiaoxiaoNeural", - "onyx": "zh-CN-YunjianNeural", - "nova": "zh-CN-XiaoyiNeural", - "shimmer": "zh-CN-XiaoxiaoNeural", + # ── 女声 ── + "xiaoxiao": "zh-CN-XiaoxiaoNeural", # 晓晓 · 温柔 + "xiaoyi": "zh-CN-XiaoyiNeural", # 晓怡 · 活泼 + # ── 男声 ── + "yunxi": "zh-CN-YunxiNeural", # 云希 · 阳光 + "yunyang": "zh-CN-YunyangNeural", # 云扬 · 专业 + "yunjian": "zh-CN-YunjianNeural", # 云健 · 激情 + "yunxia": "zh-CN-YunxiaNeural", # 云夏 · 可爱 + # ── 女声变体(使用语速微调区分)── + "xiaochen": "zh-CN-XiaoxiaoNeural", # 晓辰 · 温和(xiaoxiao 慢速) + "xiaohan": "zh-CN-XiaoyiNeural", # 晓涵 · 知性(xiaoyi 慢速) + # ── 教师女声(映射到可用女声)── + "teacher1": "zh-CN-XiaoxiaoNeural", # 晓晓老师 · 温柔教导 + "teacher2": "zh-CN-XiaoyiNeural", # 晓怡老师 · 活泼教学 + # ── 兼容旧 OpenAI 风格名称 ── + "alloy": "zh-CN-YunxiNeural", + "echo": "zh-CN-YunyangNeural", + "fable": "zh-CN-XiaoxiaoNeural", + "onyx": "zh-CN-YunjianNeural", + "nova": "zh-CN-XiaoyiNeural", + "shimmer": "zh-CN-XiaoxiaoNeural", +} + +# 语音语速微调 (voice -> adjusted speed multiplier) +# 通过微调语速来区分映射到同一 Edge TTS 语音的不同选项 +_VOICE_SPEED_ADJUST = { + "xiaochen": 0.90, # 晓辰 · 稍慢更温和 + "xiaohan": 0.90, # 晓涵 · 稍慢更知性 + "teacher1": 0.95, # 晓晓老师 · 稍慢有教导感 + "teacher2": 0.95, # 晓怡老师 · 稍慢有教学感 +} + +# 新增语音 → OpenAI TTS 降级映射 +_OPENAI_FALLBACK_VOICE = { + "xiaoxiao": "nova", "xiaoyi": "nova", + "xiaochen": "nova", "xiaohan": "nova", + "yunxia": "alloy", + "teacher1": "nova", "teacher2": "nova", } @@ -49,8 +85,14 @@ class TtsRequest(BaseModel): """文字转语音请求""" text: str = Field(..., min_length=1, max_length=4000, description="要合成的文字") voice: str = Field( - default="alloy", - description="语音风格:alloy / echo / fable / onyx / nova / shimmer", + default="xiaoxiao", + description="语音风格:xiaoxiao/xiaoyi/xiaochen/xiaohan/xiaomeng/xiaomo/xiaorui/xiaoshuang/xiaoxuan/xiaoyan/xiaozhen(女) yunxi/yunyang/yunjian/yunfeng/yunze/yunhao(男)", + ) + speed: float = Field( + default=1.0, + ge=0.5, + le=2.0, + description="语速:0.5(慢)到 2.0(快),默认 1.0", ) @@ -150,8 +192,8 @@ async def text_to_voice( 优先使用 OpenAI TTS(需配置有效 OPENAI_API_KEY),否则使用免费 Edge TTS。 Android 端使用 ExoPlayer 播放返回的 audio_url。 """ - valid_voices = {"alloy", "echo", "fable", "onyx", "nova", "shimmer"} - voice = req.voice if req.voice in valid_voices else "alloy" + valid_voices = set(_EDGE_VOICE_MAP.keys()) + voice = req.voice if req.voice in valid_voices else "xiaoxiao" text = req.text.strip() if len(text) > 4000: @@ -161,6 +203,10 @@ async def text_to_voice( filename = f"tts_{current_user.id}_{int(time.time())}.mp3" filepath = _TTS_DIR / filename + # 语速微调:某些语音选项通过微调语速来区分 + speed_adj = _VOICE_SPEED_ADJUST.get(voice, 1.0) + adjusted_speed = req.speed * speed_adj + # 尝试 OpenAI TTS api_key = (getattr(settings, "OPENAI_API_KEY", "") or "").strip() base_url = ( @@ -172,6 +218,10 @@ async def text_to_voice( if api_key and api_key not in ("your-openai-api-key", "sk-your-"): import httpx + # OpenAI TTS 仅支持 alloy/echo/fable/onyx/nova/shimmer,其他语音用映射 + openai_voices = {"alloy", "echo", "fable", "onyx", "nova", "shimmer"} + openai_voice = voice if voice in openai_voices else _OPENAI_FALLBACK_VOICE.get(voice, "nova") + try: async with httpx.AsyncClient(timeout=60) as client: resp = await client.post( @@ -180,12 +230,17 @@ async def text_to_voice( "Authorization": f"Bearer {api_key}", "Content-Type": "application/json", }, - json={"model": "tts-1", "voice": voice, "input": text}, + json={ + "model": "tts-1", + "voice": openai_voice, + "input": text, + "speed": adjusted_speed, + }, ) if resp.status_code == 200: filepath.write_bytes(resp.content) - logger.info("OpenAI TTS 完成: user=%s text_len=%d voice=%s", current_user.id, len(req.text), voice) + logger.info("OpenAI TTS 完成: user=%s text_len=%d voice=%s speed=%.1f", current_user.id, len(req.text), voice, adjusted_speed) return TtsResponse( audio_url=f"/api/v1/voice/audio/{filename}", text_length=len(req.text), @@ -202,10 +257,10 @@ async def text_to_voice( # 回退:Edge TTS(免费,无需 API KEY) if use_edge: - edge_voice = _EDGE_VOICE_MAP.get(voice, "zh-CN-YunxiNeural") + edge_voice = _EDGE_VOICE_MAP.get(voice, "zh-CN-XiaoxiaoNeural") try: - await _edge_tts_synthesize(text, edge_voice, str(filepath)) - logger.info("Edge TTS 完成: user=%s text_len=%d edge_voice=%s", current_user.id, len(req.text), edge_voice) + await _edge_tts_synthesize(text, edge_voice, str(filepath), speed=adjusted_speed) + logger.info("Edge TTS 完成: user=%s text_len=%d edge_voice=%s speed=%.1f", current_user.id, len(req.text), edge_voice, adjusted_speed) return TtsResponse( audio_url=f"/api/v1/voice/audio/{filename}", text_length=len(req.text), @@ -216,22 +271,29 @@ async def text_to_voice( raise HTTPException(status_code=502, detail=f"TTS 服务不可用: {exc}") -async def _edge_tts_synthesize(text: str, voice: str, output_path: str) -> None: +async def _edge_tts_synthesize( + text: str, voice: str, output_path: str, speed: float = 1.0, +) -> None: """使用 edge-tts 命令行工具合成语音。""" - # 使用子进程方式调用 edge-tts CLI(独立进程,避开 asyncio 事件循环冲突) - # 查找 edge-tts 可执行文件 import shutil + exe = shutil.which("edge-tts") or shutil.which("edge-tts", path=( os.environ.get("PATH", "") + os.pathsep + os.path.join(os.path.dirname(sys.executable), "Scripts") + os.pathsep + os.path.join(os.path.dirname(sys.executable), "..", "Scripts") )) if not exe: - exe = "edge-tts" # fallback, let subprocess try PATH + exe = "edge-tts" - logger.debug("edge-tts exe: %s", exe) + rate_str = f"{int((speed - 1.0) * 100):+d}%" + + logger.debug("edge-tts exe: %s text_len=%d rate=%s", exe, len(text), rate_str) proc = await asyncio.create_subprocess_exec( - exe, "--text", text, "--voice", voice, "--write-media", output_path, + exe, + "--text", text, + "--voice", voice, + f"--rate={rate_str}", + "--write-media", output_path, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, ) @@ -240,7 +302,9 @@ async def _edge_tts_synthesize(text: str, voice: str, output_path: str) -> None: err_str = stderr.decode(errors="replace") if stderr else "" if proc.returncode != 0: - raise RuntimeError(f"edge-tts CLI 失败 (exit={proc.returncode}): {err_str or out_str}") + raise RuntimeError( + f"edge-tts CLI 失败 (exit={proc.returncode}): {err_str or out_str}" + ) if not Path(output_path).is_file(): raise RuntimeError(f"edge-tts 未生成输出文件: {out_str} {err_str}") diff --git a/frontend/src/components/AgentChatPreview.vue b/frontend/src/components/AgentChatPreview.vue index f746a4c..7577822 100644 --- a/frontend/src/components/AgentChatPreview.vue +++ b/frontend/src/components/AgentChatPreview.vue @@ -6,6 +6,24 @@ {{ agentName || 'Agent' }}