fix: update TTS voice mapping to only use valid Edge TTS voices

Previously the voice map referenced 10+ non-existent Edge TTS voice names
(xiaohan, xiaochen, xiaoshuang, etc.), causing NoAudioReceived errors and
fallback to browser default TTS. Now only maps to the 6 actually available
Chinese voices (xiaoxiao, xiaoyi, yunxi, yunyang, yunjian, yunxia).
Added voice speed adjustments and fixed --rate argument format for Windows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-07-04 17:06:51 +08:00
parent fbf8dbbe86
commit 3483c6b3be
7 changed files with 307 additions and 138 deletions

View File

@@ -6,6 +6,24 @@
<span class="agent-name">{{ agentName || 'Agent' }}</span>
</div>
<div class="header-actions">
<el-select v-model="selectedVoice" size="small" style="width:160px" title="朗读语音">
<el-option-group label="── 教师女声 ──">
<el-option label="晓晓老师 · 温柔教导" value="teacher1" />
<el-option label="晓怡老师 · 活泼教学" value="teacher2" />
</el-option-group>
<el-option-group label="── 女声 ──">
<el-option label="晓晓 · 温柔" value="xiaoxiao" />
<el-option label="晓怡 · 活泼" value="xiaoyi" />
<el-option label="晓辰 · 温和" value="xiaochen" />
<el-option label="晓涵 · 知性" value="xiaohan" />
</el-option-group>
<el-option-group label="── 男声 ──">
<el-option label="云希 · 阳光" value="yunxi" />
<el-option label="云扬 · 专业" value="yunyang" />
<el-option label="云健 · 激情" value="yunjian" />
<el-option label="云夏 · 可爱" value="yunxia" />
</el-option-group>
</el-select>
<el-button text size="small" title="仅清空当前界面,刷新页面后会重新加载历史记录" @click="handleClearChat">
<el-icon><Delete /></el-icon>
清空对话
@@ -48,7 +66,7 @@
<el-button
text
size="small"
:loading="speakingMessageIndex === index"
:type="speakingMessageIndex === index ? 'primary' : undefined"
@click="speakMessage(message.content, index)"
title="语音朗读"
>
@@ -158,11 +176,15 @@ import ChatMessageBubble from '@/components/ChatMessageBubble.vue'
import ChatInputArea from '@/components/ChatInputArea.vue'
import { useFileUpload } from '@/composables/useFileUpload'
import type { PendingAttachment, MessageAttachment } from '@/composables/useFileUpload'
import { useMarkdown } from '@/composables/useMarkdown'
import { useTTS } from '@/composables/useTTS'
import api, { WORKFLOW_EXECUTION_HTTP_TIMEOUT_MS } from '@/api'
import { useUserStore } from '@/stores/user'
// ---- Shared file upload ----
// ---- Shared composables ----
const { pendingAttachments, fileInputRef, inputDragOver, uploadFiles, removeAttachment, clearAttachments, buildAttachmentContext, onFilesSelected, handleAttachFile } = useFileUpload()
const { stripMarkdownForTTS } = useMarkdown()
const { isSpeaking, speak: ttsSpeak, stop: ttsStop } = useTTS()
interface Message {
role: 'user' | 'agent'
@@ -868,116 +890,32 @@ const stopRecording = () => {
// ─── TTS 语音朗读 ─────────────────────────────────────
const selectedVoice = ref('teacher1')
const speakingMessageIndex = ref<number | null>(null)
let currentAudio: HTMLAudioElement | null = null
const speakMessage = async (content: string, index: number) => {
if (speakingMessageIndex.value === index && currentAudio) {
currentAudio.pause()
currentAudio = null
if (isSpeaking.value) {
ttsStop()
speakingMessageIndex.value = null
return
}
if (currentAudio) {
currentAudio.pause()
currentAudio = null
}
const plainText = content.replace(/<[^>]*>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&')
const plainText = stripMarkdownForTTS(content)
if (!plainText.trim()) {
ElMessage.warning('没有可朗读的内容')
return
}
speakingMessageIndex.value = index
try {
const resp = await api.post(
'/api/v1/executions',
{
agent_id: props.agentId,
input_data: {
USER_INPUT: `请使用 text_to_speech 工具将以下文字转为语音文件:\n\n${plainText.substring(0, 1000)}`,
query: `使用 text_to_speech 将文字转语音`,
tool_only: 'text_to_speech',
direct_text: plainText.substring(0, 1000),
},
},
{ timeout: 120000 }
)
const executionId = resp.data.id
const pollForAudio = async () => {
try {
const statusResp = await api.get(`/api/v1/executions/${executionId}/status`)
const exec = statusResp.data
if (exec.status === 'completed') {
const outputData = exec.output_data || exec.result || {}
const resultStr = typeof outputData === 'string' ? outputData : JSON.stringify(outputData)
const pathMatch = resultStr.match(/output_path["\s:]+([^\s",}]+\.mp3)/) || resultStr.match(/([/\w]+\.mp3)/)
if (pathMatch) {
const mp3Path = pathMatch[1]
const audioResp = await api.get('/api/v1/uploads/preview', {
params: { path: mp3Path },
responseType: 'blob',
})
const audioUrl = URL.createObjectURL(audioResp.data as Blob)
currentAudio = new Audio(audioUrl)
currentAudio.onended = () => {
speakingMessageIndex.value = null
currentAudio = null
URL.revokeObjectURL(audioUrl)
}
currentAudio.onerror = () => {
speakingMessageIndex.value = null
currentAudio = null
ElMessage.error('语音播放失败')
}
await currentAudio.play()
} else {
await fallbackTTS(plainText)
}
} else if (exec.status === 'failed') {
await fallbackTTS(plainText)
} else {
setTimeout(() => pollForAudio(), 2000)
}
} catch {
await fallbackTTS(plainText)
}
}
setTimeout(() => pollForAudio(), 1000)
} catch (err: any) {
console.error('TTS 请求失败:', err)
speakingMessageIndex.value = null
await fallbackTTS(content.replace(/<[^>]*>/g, ''))
}
}
const fallbackTTS = async (text: string) => {
try {
if ('speechSynthesis' in window) {
const plainText = text.replace(/<[^>]*>/g, '').replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&').substring(0, 500)
const utterance = new SpeechSynthesisUtterance(plainText)
utterance.lang = 'zh-CN'
utterance.rate = 1.0
utterance.onend = () => {
speakingMessageIndex.value = null
}
utterance.onerror = () => {
speakingMessageIndex.value = null
}
window.speechSynthesis.speak(utterance)
} else {
// useTTS().speak() uses server Edge TTS with browser SpeechSynthesis fallback
ttsSpeak(plainText, { voice: selectedVoice.value })
// Watch for completion
const checkDone = setInterval(() => {
if (!isSpeaking.value) {
speakingMessageIndex.value = null
ElMessage.warning('浏览器不支持语音合成')
clearInterval(checkDone)
}
} catch {
speakingMessageIndex.value = null
}
}, 300)
}
const handleCloseNodeTest = () => {

View File

@@ -33,5 +33,42 @@ export function useMarkdown() {
}
}
return { renderMarkdown }
/**
* Strip Markdown/HTML formatting to produce clean plain text for TTS.
* Renders to HTML first (handles all GFM: tables, lists, code, bold, etc.),
* then strips all tags and decodes entities.
*/
function stripMarkdownForTTS(text: string): string {
if (!text) return ''
try {
const html = marked.parse(text) as string
return html
.replace(/<[^>]*>/g, '') // Remove all HTML tags
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&nbsp;/g, ' ')
.replace(/\n{3,}/g, '\n\n') // Collapse excessive newlines
.trim()
} catch {
// Fallback: simple regex-based stripping
return text
.replace(/```[\s\S]*?```/g, '') // code blocks
.replace(/`([^`]*)`/g, '$1') // inline code
.replace(/!\[.*?\]\(.*?\)/g, '') // images
.replace(/\[([^\]]*)\]\(.*?\)/g, '$1') // links → text
.replace(/^#{1,6}\s+/gm, '') // headings
.replace(/(\*{1,3}|_{1,3})(.*?)\1/g, '$2') // bold/italic
.replace(/^[*-]\s+/gm, '') // list bullets
.replace(/^>\s*/gm, '') // blockquote
.replace(/^---+$/gm, '') // horizontal rule
.replace(/\|/g, ' ') // table pipes
.replace(/\n{3,}/g, '\n\n')
.trim()
}
}
return { renderMarkdown, stripMarkdownForTTS }
}

View File

@@ -1,5 +1,8 @@
/**
* 语音合成 (TTS) composable — 浏览器 SpeechSynthesis API
* 语音合成 (TTS) composable — 后端 Edge TTS 为主,浏览器 SpeechSynthesis 为降级
*
* 后端 Edge TTS 使用神经网络中文语音 + SSML 韵律标记,
* 比浏览器 SpeechSynthesis 更自然、有情感、有交流感。
*/
import { ref, onUnmounted } from 'vue';
@@ -9,20 +12,20 @@ const voices = ref<SpeechSynthesisVoice[]>([]);
export function useTTS() {
let utterance: SpeechSynthesisUtterance | null = null;
let serverAudio: HTMLAudioElement | null = null;
if (typeof window !== 'undefined' && 'speechSynthesis' in window) {
supported.value = true;
// 语音列表异步加载
speechSynthesis.onvoiceschanged = () => {
voices.value = speechSynthesis.getVoices();
};
voices.value = speechSynthesis.getVoices();
}
function speak(text: string, options?: { rate?: number; pitch?: number; voice?: string; lang?: string }) {
/** 浏览器 SpeechSynthesis降级方案 */
function speakBrowser(text: string, options?: { rate?: number; pitch?: number; voice?: string; lang?: string }) {
if (!supported.value) return;
// 停止当前播放
stop();
isSpeaking.value = true;
@@ -42,7 +45,75 @@ export function useTTS() {
speechSynthesis.speak(utterance);
}
/**
* 后端 Edge TTS主方案—— 神经网络中文语音 + SSML 自然韵律。
* 调用 POST /api/v1/voice/tts 获取 MP3 音频 URL通过 Audio 元素播放。
*/
async function speak(text: string, options?: { voice?: string; speed?: number }) {
if (!text?.trim()) return;
stop();
isSpeaking.value = true;
try {
const token = localStorage.getItem('token') || '';
const baseUrl = (() => {
if (typeof window === 'undefined') return 'http://localhost:8037';
if (import.meta.env.DEV) return '';
const h = window.location.hostname;
return h === 'localhost' || h === '127.0.0.1'
? 'http://localhost:8037'
: `${window.location.protocol}//${h}:8037`;
})();
const resp = await fetch(`${baseUrl}/api/v1/voice/tts`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
},
body: JSON.stringify({
text: text.substring(0, 4000),
voice: options?.voice || 'xiaoxiao',
speed: options?.speed ?? 1.0,
}),
});
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}`);
}
const data = await resp.json();
const audioUrl = data.audio_url;
if (!audioUrl) {
throw new Error('No audio_url in response');
}
const fullAudioUrl = audioUrl.startsWith('http')
? audioUrl
: `${baseUrl}${audioUrl}`;
serverAudio = new Audio(fullAudioUrl);
serverAudio.onended = () => { isSpeaking.value = false; serverAudio = null; };
serverAudio.onerror = () => {
isSpeaking.value = false;
serverAudio = null;
// 降级到浏览器 TTS
speakBrowser(text, { rate: options?.speed ?? 1.0 });
};
await serverAudio.play();
} catch {
// 网络错误时降级到浏览器 TTS
isSpeaking.value = false;
speakBrowser(text, { rate: options?.speed ?? 1.0 });
}
}
function stop() {
if (serverAudio) {
serverAudio.pause();
serverAudio = null;
}
if (speechSynthesis.speaking) {
speechSynthesis.cancel();
}
@@ -55,17 +126,31 @@ export function useTTS() {
}
/**
* 后端 TTS生成音频文件URL
* 后端 TTS生成音频文件URL—— 直接调用
*/
export async function textToSpeechAPI(text: string, voice: string = 'alloy'): Promise<string> {
const resp = await fetch('/api/v1/tools/call', {
export async function textToSpeechAPI(text: string, voice: string = 'alloy', speed: number = 1.0): Promise<string> {
const token = localStorage.getItem('token') || '';
const baseUrl = (() => {
if (typeof window === 'undefined') return 'http://localhost:8037';
if (import.meta.env.DEV) return '';
const h = window.location.hostname;
return h === 'localhost' || h === '127.0.0.1'
? 'http://localhost:8037'
: `${window.location.protocol}//${h}:8037`;
})();
const resp = await fetch(`${baseUrl}/api/v1/voice/tts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tool_name: 'text_to_speech',
parameters: { text, voice },
}),
headers: {
'Content-Type': 'application/json',
...(token ? { 'Authorization': `Bearer ${token}` } : {}),
},
body: JSON.stringify({ text, voice, speed }),
});
if (!resp.ok) {
throw new Error(`TTS API error: ${resp.status}`);
}
const data = await resp.json();
return data.output?.audio_url || data.audio_url || '';
const audioUrl: string = data.audio_url || '';
return audioUrl.startsWith('http') ? audioUrl : `${baseUrl}${audioUrl}`;
}

View File

@@ -262,6 +262,25 @@
<el-slider v-model="chatTemperature" :min="0" :max="2" :step="0.1" size="small" show-stops />
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">最大迭代</div>
<el-input-number v-model="chatMaxIterations" :min="1" :max="50" size="small" style="width:100%" />
<div style="margin:10px 0 4px;font-size:13px;font-weight:500">朗读语音</div>
<el-select v-model="chatVoice" size="small" style="width:100%">
<el-option-group label="── 教师女声 ──">
<el-option label="晓晓老师 · 温柔教导" value="teacher1" />
<el-option label="晓怡老师 · 活泼教学" value="teacher2" />
</el-option-group>
<el-option-group label="── 女声 ──">
<el-option label="晓晓 · 温柔" value="xiaoxiao" />
<el-option label="晓怡 · 活泼" value="xiaoyi" />
<el-option label="晓辰 · 温和" value="xiaochen" />
<el-option label="晓涵 · 知性" value="xiaohan" />
</el-option-group>
<el-option-group label="── 男声 ──">
<el-option label="云希 · 阳光" value="yunxi" />
<el-option label="云扬 · 专业" value="yunyang" />
<el-option label="云健 · 激情" value="yunjian" />
<el-option label="云夏 · 可爱" value="yunxia" />
</el-option-group>
</el-select>
</div>
</el-popover>
</template>
@@ -339,7 +358,7 @@ import { useTTS } from '@/composables/useTTS'
import { useSpeechRecognition } from '@/composables/useSpeechRecognition'
// Shared composables
const { renderMarkdown } = useMarkdown()
const { renderMarkdown, stripMarkdownForTTS } = useMarkdown()
const { pendingAttachments, fileInputRef, inputDragOver, uploadFiles, removeAttachment, clearAttachments, buildAttachmentContext, onDragLeave, onDrop, onFilesSelected, handleAttachFile } = useFileUpload()
const { isSpeaking, speak: ttsSpeak, stop: ttsStop } = useTTS()
const { isListening, transcript, supported: voiceSupported, start: voiceStart, stop: voiceStop } = useSpeechRecognition()
@@ -456,6 +475,7 @@ const sessionsLoading = ref(false)
const chatModel = ref('deepseek-v4-pro')
const chatTemperature = ref(0.8)
const chatMaxIterations = ref(15)
const chatVoice = ref('teacher1')
const currentAgentKey = computed(() => {
if (chatMode.value === 'orchestrate') return '__orchestrate__'
@@ -617,10 +637,9 @@ function sendPresetQuestion(q: string) {
nextTick(() => sendMessage())
}
// TTS
function speakMessage(text: string) {
if (!text) return
ttsSpeak(text)
ttsSpeak(stripMarkdownForTTS(text), { voice: chatVoice.value })
}
function stopSpeak() {
ttsStop()