From ac5e68f5a57fa736023e4f4598cbe18dd71f7cfd Mon Sep 17 00:00:00 2001 From: Marcus <1922576605@qq.com> Date: Mon, 30 Jun 2025 10:49:24 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=83=A8=E5=88=86=E8=AF=AD=E9=9F=B3?= =?UTF-8?q?=E8=81=8A=E5=A4=A9=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/api/v1/endpoints/tts.py | 8 +- .../app/api/v1/endpoints/websocket_service.py | 3 +- web/src/components/tts.vue | 4 +- web/src/layouts/BasicLayout.vue | 15 +- web/src/router/index.ts | 15 +- web/src/services/websocket.ts | 24 +- web/src/stores/index.ts | 3 +- web/src/stores/voice_store.ts | 293 ++++++++++++++++++ .../{CommunityView.vue => ChatLLMView.vue} | 0 web/src/views/VoiceView.vue | 254 +++++++++++++++ 10 files changed, 594 insertions(+), 25 deletions(-) create mode 100644 web/src/stores/voice_store.ts rename web/src/views/{CommunityView.vue => ChatLLMView.vue} (100%) create mode 100644 web/src/views/VoiceView.vue diff --git a/backend/app/api/v1/endpoints/tts.py b/backend/app/api/v1/endpoints/tts.py index e3462c8..687bcfc 100644 --- a/backend/app/api/v1/endpoints/tts.py +++ b/backend/app/api/v1/endpoints/tts.py @@ -9,7 +9,7 @@ from typing import Dict, Any, Optional as OptionalType from app.constants.tts import APP_ID, TOKEN, SPEAKER -# 协议常量保持不变... +# 协议常量 PROTOCOL_VERSION = 0b0001 DEFAULT_HEADER_SIZE = 0b0001 FULL_CLIENT_REQUEST = 0b0001 @@ -35,7 +35,7 @@ EVENT_TTSSentenceEnd = 351 EVENT_TTSResponse = 352 -# 所有类定义保持不变... +# 所有类定义 class Header: def __init__(self, protocol_version=PROTOCOL_VERSION, @@ -93,7 +93,7 @@ class Response: self.payload_json = None -# 工具函数保持不变... +# 工具函数 def gen_log_id(): """生成logID""" ts = int(time.time() * 1000) @@ -191,7 +191,7 @@ async def send_event(ws, header, optional=None, payload=None): await ws.send(full_client_request) -# 修改:TTS状态管理类,添加消息ID和任务追踪 +# TTS状态管理类,添加消息ID和任务追踪 class TTSState: def __init__(self, message_id: str): self.message_id = message_id diff --git a/backend/app/api/v1/endpoints/websocket_service.py b/backend/app/api/v1/endpoints/websocket_service.py index 3e683ed..8d54c35 100644 --- a/backend/app/api/v1/endpoints/websocket_service.py +++ b/backend/app/api/v1/endpoints/websocket_service.py @@ -5,7 +5,6 @@ from aip import AipSpeech from app.constants.asr import APP_ID, API_KEY, SECRET_KEY import json -# 导入修改后的TTS模块 from . import tts router = APIRouter() @@ -62,7 +61,7 @@ async def websocket_online_count(websocket: WebSocket): await websocket.send_json({"type": "asr_result", "result": asr_text}) temp_buffer = bytes() - # 修改:TTS处理支持消息ID + # TTS处理 elif msg_type == "tts_text": message_id = data.get("messageId") text = data.get("text", "") diff --git a/web/src/components/tts.vue b/web/src/components/tts.vue index b7000e6..1e673bd 100644 --- a/web/src/components/tts.vue +++ b/web/src/components/tts.vue @@ -34,13 +34,15 @@ const handleClick = () => { ttsStore.convertText(text, messageId); } }; -// 当文本改变时清理之前的音频 + +// 文本改变清理之前的音频 watch( () => text, () => { ttsStore.clearAudio(messageId); } ); + onUnmounted(() => { ttsStore.clearAudio(messageId); }); diff --git a/web/src/layouts/BasicLayout.vue b/web/src/layouts/BasicLayout.vue index d2c6c0d..0664ca1 100644 --- a/web/src/layouts/BasicLayout.vue +++ b/web/src/layouts/BasicLayout.vue @@ -45,7 +45,20 @@ const { hiddenLeftSidebar, simpleMode } = storeToRefs(layoutStore); " to="/" > - 聊天 + 对话 + + + 语音聊天
diff --git a/web/src/router/index.ts b/web/src/router/index.ts index e4daaed..10ee4a9 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -2,7 +2,8 @@ import { createRouter, createWebHistory } from "vue-router"; import BasicLayout from "@/layouts/BasicLayout.vue"; import { resetDescription, setTitle } from "@/utils"; -import community from "@/views/CommunityView.vue"; +import ChatLLM from "@/views/ChatLLMView.vue"; +import VoiceView from "@/views/VoiceView.vue"; const router = createRouter({ history: createWebHistory(import.meta.env.BASE_URL), @@ -13,11 +14,19 @@ const router = createRouter({ children: [ { path: "", - name: "community", - component: community, + name: "ChatLLM", + component: ChatLLM, meta: { title: "对话" } + }, + { + path: "/voice", + name: "Voice", + component: VoiceView, + meta: { + title: "语音对话" + } } ] } diff --git a/web/src/services/websocket.ts b/web/src/services/websocket.ts index 489ec21..115ba05 100644 --- a/web/src/services/websocket.ts +++ b/web/src/services/websocket.ts @@ -1,4 +1,4 @@ -import { useChatStore, useTtsStore } from "@/stores"; +import { useChatStore, useTtsStore,useVoiceStore } from "@/stores"; // WebSocket export const useWebSocketStore = defineStore("websocket", () => { @@ -6,6 +6,8 @@ export const useWebSocketStore = defineStore("websocket", () => { const connected = ref(false); const chatStore = useChatStore(); const ttsStore = useTtsStore(); + const voiceStore = useVoiceStore(); + const router = useRouter(); const { onlineCount } = storeToRefs(chatStore); @@ -31,7 +33,14 @@ export const useWebSocketStore = defineStore("websocket", () => { onlineCount.value = data.online_count; break; case "asr_result": - chatStore.addMessageToHistory(data.result); + if (router.currentRoute.value.path === "/") { + chatStore.addMessageToHistory(data.result); + } else if (router.currentRoute.value.path === "/voice") { + // 在语音页面,使用VoiceStore处理 + voiceStore.handleASRResult(data.result); + } else { + console.warn(data); + } break; // 新的TTS消息格式处理 @@ -76,7 +85,6 @@ export const useWebSocketStore = defineStore("websocket", () => { ttsStore.finishConversion(data.messageId); } else { console.log("TTS音频传输完成(无messageId)"); - // 兜底处理,可能是旧格式 ttsStore.finishConversion(data.messageId); } break; @@ -85,7 +93,6 @@ export const useWebSocketStore = defineStore("websocket", () => { // TTS会话结束 if (data.messageId) { console.log(`TTS会话结束 [${data.messageId}]`); - // 可以添加额外的清理逻辑 } else { console.log("TTS会话结束"); } @@ -98,19 +105,10 @@ export const useWebSocketStore = defineStore("websocket", () => { ttsStore.handleError(data.message, data.messageId); } else { console.error("TTS错误:", data.message); - // 兜底处理,可能是旧格式 ttsStore.handleError(data.message, data.messageId || "unknown"); } break; - // 保留旧的消息类型作为兜底处理 - case "tts_audio_complete_legacy": - case "tts_complete_legacy": - case "tts_error_legacy": - console.log("收到旧格式TTS消息:", data.type); - // 可以选择处理或忽略 - break; - default: console.log("未知消息类型:", data.type, data); } diff --git a/web/src/stores/index.ts b/web/src/stores/index.ts index 9647e3d..e40c7b1 100644 --- a/web/src/stores/index.ts +++ b/web/src/stores/index.ts @@ -1,4 +1,5 @@ export * from "./asr_store"; export * from "./chat_store"; export * from "./layout_store"; -export * from "./tts_store"; \ No newline at end of file +export * from "./tts_store"; +export * from "./voice_store"; diff --git a/web/src/stores/voice_store.ts b/web/src/stores/voice_store.ts new file mode 100644 index 0000000..442607e --- /dev/null +++ b/web/src/stores/voice_store.ts @@ -0,0 +1,293 @@ +import { useWebSocketStore } from "@/services"; +import { useChatStore, useTtsStore } from "@/stores"; + +export const useVoiceStore = defineStore("voice", () => { + // 状态管理 + const isListening = ref(false); // 是否正在监听语音输入 + const isProcessing = ref(false); // 是否正在处理(包括ASR、LLM、TTS全流程) + const currentSessionId = ref(null); // 当前会话ID + + // 依赖的其他store + const chatStore = useChatStore(); + const ttsStore = useTtsStore(); + const wsStore = useWebSocketStore(); + + // 语音消息历史 + const voiceMessages = ref< + { + id: string; + type: "user" | "assistant"; + text: string; + audioId?: string; + timestamp: number; + isProcessing?: boolean; + }[] + >([]); + + // ASR缓冲区状态 + const isRecording = ref(false); + const recordingStartTime = ref(null); + const recordingMaxDuration = 60 * 1000; // 最大录音时长 60 秒 + + /** + * 开始语音输入 + */ + const startListening = async () => { + if (isListening.value) return; + + try { + await wsStore.connect(); + + // 创建新的会话ID + currentSessionId.value = new Date().getTime().toString(); + isListening.value = true; + isRecording.value = true; + recordingStartTime.value = Date.now(); + + // 开始录音 - 假设我们有一个 startRecording 方法 + // 这里通常会调用浏览器的 MediaRecorder API + await startRecording(); + + console.log("开始语音输入"); + } catch (error) { + console.error("启动语音输入失败:", error); + stopListening(); + } + }; + + /** + * 停止语音输入 + */ + const stopListening = async () => { + if (!isListening.value) return; + + try { + // 停止录音 + if (isRecording.value) { + await stopRecording(); + isRecording.value = false; + } + + isListening.value = false; + recordingStartTime.value = null; + + // 发送结束信号 + wsStore.send(JSON.stringify({ type: "asr_end" })); + console.log("停止语音输入,等待ASR结果"); + } catch (error) { + console.error("停止语音输入失败:", error); + } + }; + + /** + * 录音时间检查 + */ + const checkRecordingTime = () => { + if (isRecording.value && recordingStartTime.value) { + const currentTime = Date.now(); + const duration = currentTime - recordingStartTime.value; + + if (duration >= recordingMaxDuration) { + console.log("录音达到最大时长,自动停止"); + stopListening(); + } + } + }; + + // 定时检查录音时间 + let recordingTimer: any = null; + watch(isRecording, (newVal) => { + if (newVal) { + recordingTimer = setInterval(checkRecordingTime, 1000); + } else if (recordingTimer) { + clearInterval(recordingTimer); + recordingTimer = null; + } + }); + + /** + * 处理ASR结果 + */ + const handleASRResult = async (text: string) => { + if (!text.trim()) return; + + console.log("收到ASR结果:", text); + isProcessing.value = true; + + // 添加用户消息 + const userMessageId = new Date().getTime().toString(); + voiceMessages.value.push({ + id: userMessageId, + type: "user", + text, + timestamp: Date.now() + }); + + // 添加助手消息占位 + const assistantMessageId = new Date().getTime().toString(); + voiceMessages.value.push({ + id: assistantMessageId, + type: "assistant", + text: "", + timestamp: Date.now(), + isProcessing: true + }); + + // 调用LLM生成回复 + await generateLLMResponse(text, assistantMessageId); + }; + + /** + * 生成LLM回复 + */ + const generateLLMResponse = async (userInput: string, responseId: string) => { + try { + console.log("生成LLM回复..."); + + // 构建消息历史 + const messages = [ + ...voiceMessages.value + .filter((msg) => !msg.isProcessing) + .map((msg) => ({ + role: msg.type === "user" ? "user" : "assistant", + content: msg.text + })), + { role: "user", content: userInput } + ]; + + let responseText = ""; + + // 调用ChatStore的聊天方法 + await chatStore.chatWithLLM( + { + messages, + model: chatStore.modelInfo?.model_id || "" + }, + // 处理流式回复 + (content) => { + responseText = content; + // 更新助手消息 + const index = voiceMessages.value.findIndex( + (msg) => msg.id === responseId + ); + if (index !== -1) { + voiceMessages.value[index].text = content; + } + } + ); + + // LLM生成完成,转换为语音 + console.log("LLM回复生成完成:", responseText); + await synthesizeSpeech(responseText, responseId); + } catch (error) { + console.error("生成LLM回复失败:", error); + const index = voiceMessages.value.findIndex( + (msg) => msg.id === responseId + ); + if (index !== -1) { + voiceMessages.value[index].text = "抱歉,生成回复时出错"; + voiceMessages.value[index].isProcessing = false; + } + isProcessing.value = false; + } + }; + + /** + * 转换文本为语音 + */ + const synthesizeSpeech = async (text: string, messageId: string) => { + try { + console.log("转换文本为语音..."); + + // 调用TTS生成语音 + await ttsStore.convertText(text, messageId); + + // 注意:TTS音频生成完成后会自动播放 + // 这部分逻辑在TTS Store的finishConversion方法中处理 + + // 更新消息状态 + const index = voiceMessages.value.findIndex( + (msg) => msg.id === messageId + ); + if (index !== -1) { + voiceMessages.value[index].audioId = messageId; + voiceMessages.value[index].isProcessing = false; + } + } catch (error) { + console.error("转换文本为语音失败:", error); + const index = voiceMessages.value.findIndex( + (msg) => msg.id === messageId + ); + if (index !== -1) { + voiceMessages.value[index].isProcessing = false; + } + } finally { + isProcessing.value = false; + } + }; + + /** + * 清除所有消息 + */ + const clearMessages = () => { + voiceMessages.value = []; + }; + + /** + * 播放指定消息的语音 + */ + const playMessageAudio = async (messageId: string) => { + const message = voiceMessages.value.find((msg) => msg.id === messageId); + if (message && message.audioId) { + await ttsStore.play(message.audioId); + } + }; + + /** + * 暂停当前播放的语音 + */ + const pauseAudio = () => { + ttsStore.pauseAll(); + }; + + // 录音相关方法 - 这里需要根据实际情况实现 + // 通常会使用MediaRecorder API + const startRecording = async () => { + // 实现录音开始逻辑 + // 1. 获取麦克风权限 + // 2. 创建MediaRecorder + // 3. 监听数据可用事件,发送到WebSocket + console.log("开始录音..."); + }; + + const stopRecording = async () => { + // 实现录音停止逻辑 + console.log("停止录音..."); + }; + + // 在组件卸载时清理资源 + onUnmounted(() => { + if (isRecording.value) { + stopRecording(); + } + if (recordingTimer) { + clearInterval(recordingTimer); + } + }); + + return { + // 状态 + isListening, + isProcessing, + isRecording, + voiceMessages, + + // 方法 + startListening, + stopListening, + handleASRResult, + clearMessages, + playMessageAudio, + pauseAudio + }; +}); diff --git a/web/src/views/CommunityView.vue b/web/src/views/ChatLLMView.vue similarity index 100% rename from web/src/views/CommunityView.vue rename to web/src/views/ChatLLMView.vue diff --git a/web/src/views/VoiceView.vue b/web/src/views/VoiceView.vue new file mode 100644 index 0000000..3d3ee1d --- /dev/null +++ b/web/src/views/VoiceView.vue @@ -0,0 +1,254 @@ + + +