diff --git a/README.md b/README.md index 659d7ab..0978d92 100644 --- a/README.md +++ b/README.md @@ -1,171 +1,170 @@ # VocaTa -AI 驱动的实时语音角色扮演平台,支持语音/文本对话、角色创建、历史对话管理,以及多模型服务接入。 +一个实时语音 AI 角色对话项目。 -## 当前仓库 +它不只是“发消息 + 播语音”,而是尽量把对话做得更像真人交流:你可以开口说话、随时打断、继续追问,角色会用自己的语气持续接话。 -- 用户端前端:`vocata-web` -- 管理后台:`vocata-admin` -- 后端服务:`vocata-server` -- 本地开发编排:`docker-compose.yml` -- CI:`.github/workflows/ci.yml` -- Staging 部署:`.github/workflows/cd-staging.yml` - -## 开发与部署入口 - -- 开发环境说明:[`docs/开发环境说明.md`](docs/开发环境说明.md) -- 部署环境说明:[`docs/部署环境说明.md`](docs/部署环境说明.md) -- 验证清单:[`docs/验证清单.md`](docs/验证清单.md) -- 开发工作流:[`docs/开发工作流.md`](docs/开发工作流.md) -- 提交规范:[`docs/提交规范.md`](docs/提交规范.md) -- 重构边界清单:[`docs/重构边界清单.md`](docs/重构边界清单.md) - -## 一. 功能演示 - -演示视频:[http://t313actv0.hb-bkt.clouddn.com/bandicam%202025-09-28%2023-45-36-297.mp4](http://t313actv0.hb-bkt.clouddn.com/bandicam%202025-09-28%2023-45-36-297.mp4) - - - -# 二. 项目介绍 - -语Ta(VacaTa) 一个AI驱动的实时语音角色扮演平台,用户可与哈利波特、等虚拟角色进行语音和文本对话 - -+ 语音/文本对话: 完整的语音→ 文本→ LLM → 语音链路,支持流式快速响应 -+ 多AI模型集成: 七牛云AI、硅基流动、OpenAI GPT、Google Gemini无缝切换功能模式 -+ 自定义角色: 用户可创建个性化AI角色,调节性格和对话风格。 +> 实时说话、实时接话、实时打断。 +> 让 AI 角色聊天从“消息收发”变成更接近真人的互动体验。 +## 一句话感受 +`像在和角色通电话,而不是在等一个聊天机器人回复。` -# 三. 技术架构 +## 小亮点 -**整体架构:** +| 能力 | 描述 | +| --- | --- | +| 流式回复 | 回复不是整段憋出来,而是边生成边出来 | +| 语音打断 | AI 说到一半,可以直接插话 | +| 角色感 | 不同角色可以有不同性格、语气和说话方式 | +| 多轮对话 | 能连续聊,不是一轮就断 | +| 多模型接入 | STT / LLM / TTS 可以灵活切换组合 | -![](https://cdn.nlark.com/yuque/0/2025/png/29246232/1759073523877-d803a8b1-b8f2-472e-b193-361babf7cc9b.png) +## 它可以做什么 -**技术栈:** +- 和 AI 角色进行实时语音对话 +- 支持文本聊天和语音聊天两种模式 +- 角色回复支持流式输出,不用整段生成完再看到结果 +- 语音回复支持边生成边播放,等待感更低 +- 用户插话时可以直接打断 AI,不必等它说完 +- 支持多轮连续聊天,保留会话上下文 +- 支持角色创建与角色设定管理 +- 支持根据不同角色切换不同语气、性格和说话风格 +- 支持历史会话查看与消息持久化 +- 支持多种 STT / TTS / LLM 服务接入与切换 -+ 后端: Spring Boot 3.1.4 + Java 17 + MyBatis Plus + Sa-Token + WebSocket -+ 前端: Vue 3.5 + TypeScript + Vite + Element Plus + Pinia -+ 数据库: PostgreSQL 15 + Redis 7 -+ AI服务: 七牛云ASR + 七牛云AI服务商 + 科大讯飞TTS -+ 部署: GitHub Actions CI、CD +## 项目体验感 -# 四. 服务提供 +这个项目想做出来的感觉是: -| **服务类型** | **提供商** | -| :------------ | :--------------------------------- | -| OSS对象存储 | 七牛云 | -| STT语音识别 | 七牛云ASR、科大讯飞 | -| TTS语音合成 | 科大讯飞、火山引擎 | -| LLM大语言模型 | 七牛云AI、Gemini、OpenAI、硅基流动 | +- AI 不要“慢半拍” +- 对话不要“一问一答像接口调用” +- 角色不要每轮都像失忆 +- 用户说到一半时,AI 应该能被打断 +- 回复应该更像聊天,而不是一整段说明文 +## 你会看到的效果 +### 说话更顺 -# 五.问题 +- 用户开口后,系统开始持续识别 +- 模型生成到哪,文本就显示到哪 +- 语音不必等全文结束再统一播报 -## 1.你计划将这个应用面向什么类型的用户?这些类型的用户他们面临什么样的痛点,你设想的用户故事是什么样呢? +### 互动更真 -本产品的核心目标用户可归纳为四大类:**IP/角色爱好者****学习者****情感陪伴需求者****内容创者**。他们共同的渴望是将单向、静态的内容消费(如阅读、观影)转变为双向、动态的沉浸式互动。主要痛点集中在现有媒介缺乏互动性、学习过程枯燥、现实社交存在压力以及创作时缺少灵感。本产品旨为这些用户提供一个集娱乐、学习、陪伴和创造于一体的全新互动平台。 +- AI 在“说话”的时候,用户可以直接插进来 +- 对话节奏更像来回接话,而不是轮流提交任务 +- 连续追问时不会那么容易掉出聊天状态 +### 角色更像角色 +- 不同角色可以有不同人设与说话口吻 +- 同一句话,不同角色能聊出不一样的感觉 +- 更适合做陪伴感、代入感、故事感比较强的对话 -### IP/角色爱好者 +## 功能亮点 -对特定影视、动漫、游戏人物充满热情的年轻人(年龄以 18-38 岁为主)。他们不仅希望与心仪角色进行思想上的交流,更渴望建立更深层次的情感连接。 +### 实时语音聊天 -**王海(《海贼王》粉丝)**:“作为一名海米,路飞的铁杆粉丝,我觉得仅仅通过追番和看漫画来体验他的冒险故事,总感觉隔着一层屏幕,缺少了真实的互动感。我希望能真的和‘路飞’本人进行语音对话,听他用那标志性的语气兴奋地跟我聊聊最近的冒险,甚至可以问他‘当海贼王需要具备什么条件?’,这远比在论坛上猜测剧情或重温动画更能让我感受到那种身临其境的伙伴感。” +- 音频输入后可持续识别文本 +- 模型回复按文本流返回 +- 语音合成按句子切片输出 +- 首段回复更快出来,聊天节奏更自然 -**核心痛点** +### 全双工打断 -+ 单向互动,缺乏沉浸感 -+ 情感连接肤浅,渴望深度交流 -+ 想象无法落地,互动渠道缺失 +- AI 正在说话时,用户可以重新开口 +- 新输入到来后,旧语音可以立即停止 +- 避免“用户已经插话,AI 还在继续念稿” -### 学习者 +### 角色扮演 -学习者为两类,一类是知识学习者,如希望深入理解哲学家、历史人物、文学家的学习者或爱好者;另一类是语言学习者,需要语境环境、且无压力的环境来练习外语口语。 +- 每个角色都可以配置独立人设 +- 支持性格、说话风格、示例对话等设定 +- 更适合做陪伴、故事代入、角色闲聊这类场景 -**李明(知识学习者)**:“作为一名哲学爱好者,我觉得笛卡尔的作品虽然深刻,但有些地方仅靠阅读难以完全理解。我希望能直接与‘笛卡尔’本人进行语音对话,让他用通俗的语言为我举例说明,让我能更深刻地与他的思想进行碰撞,这远比我独自钻研文本要高效和富有启发性得多。” +### 多模型接入 -**小美(语言学习者)**:“作为一名英语学习者,我觉得最大的挑战是找到一个既有真实语境、又没有社交压力的口语练习环境,而且聘请真人外教的费用实在太高了。我希望能随时随地和像‘莎士比亚’这样的AI角色进行对话,在一个虚拟的场景里围绕不同的话题进行角色扮演,这远比预约昂贵且有压力的真人外教要轻松、自由,也经济实惠得多。并且我可以勇敢地开口练习口语和听力,而不用担心因犯错而感到尴尬。” +- 支持不同 LLM 服务切换 +- 支持不同 STT / TTS 服务接入 +- 更方便根据速度、效果、成本做组合 -**核心痛点:** +### 会话管理 -+ 知识获取枯燥 -+ 缺乏语伴与环境 +- 支持角色会话列表 +- 支持历史消息保存 +- 支持持续多轮聊天 +## 适合的使用场景 +- 想做一个能“开口聊”的 AI 角色陪伴应用 +- 想体验比普通聊天框更自然的实时语音交互 +- 想做二次元角色、原创 OC、虚拟人、故事角色对话 +- 想把 `STT + LLM + TTS` 串成完整语音链路 +- 想验证流式输出、语音打断、多轮上下文这类能力 -### 情感陪伴需求者 +## 项目关键词 -因独居、工作繁忙、社交焦虑、失恋等原因,在情感上感到孤独,希望寻找一个随时可用、无压力且非评判性的倾诉对象的用户。 +`Realtime Voice Chat` `AI Roleplay` `Streaming Response` `Barge-in` `WebSocket` `STT` `LLM` `TTS` -**小张**:“作为一名独居青年,我时常在深夜感到孤独,但很多烦心事又不敢和家人朋友说,怕给他们添麻烦。我希望能有一个随时都在、且绝不评判我的倾诉对象,能听我说说心里话,这远比一个人硬扛着所有情绪要好得多。” +## 仓库结构 -**核心痛点:** +```text +. +├── vocata-server # 后端服务 +├── vocata-web # 用户端前端 +├── vocata-admin # 管理后台 +├── docs # 开发与部署文档 +├── docker-compose.yml +├── docker-compose.test.yml +└── docker-compose.prod.yml +``` -+ 即时性情感需求的无法满足 -+ 现实社交的“高成本”与“不确定性” -+ 对“无条件接纳”与“安全感”的渴望 +## 技术栈 +- 后端:`Spring Boot 3`、`Java 17`、`WebSocket`、`Reactor` +- 前端:`Vue 3`、`TypeScript`、`Vite` +- 数据库:`PostgreSQL`、`Redis` +- AI 能力:`STT`、`LLM`、`TTS` +## 快速启动 +### 启动基础依赖 +```bash +docker compose up -d +``` -### 内容创作者 +### 启动后端 -Cosplay UP主、作家、编剧、游戏设计师等需要进行创意构思和角色扮演的用户。 +```bash +cd vocata-server +mvn spring-boot:run +``` -小陈:“我写剧本的时候老是容易卡住,尤其是角色的对话,总觉得很僵硬、不够自然。我其实特别想能直接跟我构思的角色聊一聊,用语音问他各种问题、丢给他不同场景,看看他会怎么反应。这样碰撞出来的台词肯定更有火花,比我一个人对着文档死想或者疯狂查资料要省事儿、也更直观。” +### 启动用户端 -**核心痛点:** +```bash +cd vocata-web +npm install +npm run dev +``` -+ **灵感枯竭与创作瓶颈** -+ **塑造角色缺乏动态反馈** -+ **角色对话难以高效生成** +### 启动管理后台 +```bash +cd vocata-admin +npm install +npm run dev +``` +## 相关文档 -## 2.你认为这个 APP 需要哪些功能?这些功能各自的优先级是什么?你计划本次开发哪些功能? - -1. **基础语音对话**:STT+LLM+TTS的完整链路 -2. **3个核心角色**:苏格拉底、邓布利多、AI助手 -3. **用户认证**:简单注册/登录 -4. **基础对话管理**:保存历史记录 -5. **角色记忆系统**:记住用户偏好 (以及**角色身份边界问题** -6. **实时字幕**:语音识别结果展示 -7. **对话中断机制**:可随时打断AI -8. **用户创建角色** -9. **多轮对话摘要(****对话历史上下文压缩策略工程****** -10. **情感分析** -11. **...** - - - -## 3.你计划采纳哪家公司的哪个 LLM 模型能力?你对比了哪些,你为什么选择用该 LLM 模型? - -| **模型** | **优势** | **劣势** | -| --------------------------------------------------- | ------------------------------------------------------------ | --------------------------------------------------------- | -| **GPT-4o** | 角色扮演能力最强、理解力最好 | 成本高、延迟较大 | -| X-fast | 速度快、长文本处理优秀,上下文长 | 成本较低 | -| gemini-2.5fast | 速度快、长文本处理优秀,上下文长 | 成本较低 | - - -## 4.你期望 AI 角色除了语音聊天外还应该有哪些技能? - -1. **多模态交互** - - 角色形象生成(AI绘画) - - 场景渲染(如霍格沃茨大厅) - - 表情动作系统 -2. **游戏化元素** - - 角色好感度系统 - - 成就解锁 - - 剧情任务 -3. **教育功能** - - 知识点总结 - - 学习进度追踪 - - 个性化教学(如苏格拉底的哲学课) -4. **创作辅助** - - 李白:诗词创作工具 - - 哈利:魔法故事生成器 - - 苏格拉底:论文思路梳理 +- 开发环境说明:[`docs/开发环境说明.md`](docs/开发环境说明.md) +- Docker 开发环境:[`docs/Docker开发环境.md`](docs/Docker开发环境.md) +- 部署环境说明:[`docs/部署环境说明.md`](docs/部署环境说明.md) +- 验证清单:[`docs/验证清单.md`](docs/验证清单.md) +- 开发工作流:[`docs/开发工作流.md`](docs/开发工作流.md) +- 提交规范:[`docs/提交规范.md`](docs/提交规范.md) diff --git a/vocata-server/src/main/java/com/vocata/ai/stt/impl/XunfeiWebSocketSttClient.java b/vocata-server/src/main/java/com/vocata/ai/stt/impl/XunfeiWebSocketSttClient.java index 78cdbdf..d0ddc46 100644 --- a/vocata-server/src/main/java/com/vocata/ai/stt/impl/XunfeiWebSocketSttClient.java +++ b/vocata-server/src/main/java/com/vocata/ai/stt/impl/XunfeiWebSocketSttClient.java @@ -359,7 +359,7 @@ private Map buildAudioFrame(byte[] audioData, SttConfig config, business.put("language", mapLanguage(config.getLanguage())); business.put("domain", "iat"); business.put("accent", "mandarin"); - business.put("vad_eos", 3000); + business.put("vad_eos", 1000); business.put("dwa", "wpgs"); business.put("ptt", 1); business.put("nunum", 0); diff --git a/vocata-web/src/utils/aiChat.ts b/vocata-web/src/utils/aiChat.ts index 65eb8cb..780f0f8 100644 --- a/vocata-web/src/utils/aiChat.ts +++ b/vocata-web/src/utils/aiChat.ts @@ -290,6 +290,13 @@ export class VocaTaWebSocketClient { } } +// VAD 常量(ScriptProcessorNode 2048 帧 @ 16kHz = 128ms/帧) +const PROC_BUFFER = 2048 +const SPEECH_THRESHOLD = 0.015 // RMS 超过此值 → 识别为说话 +const SILENCE_THRESHOLD = 0.010 // RMS 低于此值 → 识别为静音 +const MIN_SPEECH_FRAMES = 2 // 至少 2 帧真实语音才允许 VAD 触发(防误触) +const SILENCE_FRAMES_REQUIRED = 6 // 6 × 128ms ≈ 0.8s 静音后自动停止 + // 音频管理器类 - PCM 实时录音模式(ScriptProcessorNode → 16kHz Int16 PCM) export class AudioManager { private audioContext: AudioContext | null = null @@ -313,6 +320,16 @@ export class AudioManager { private stopRecordingReject?: (reason?: unknown) => void private playbackStateListener?: (isPlaying: boolean) => void + // VAD 状态 + private isMuted = false + private isAISpeaking = false + private hasSpeechStarted = false + private speechFrameCount = 0 + private silenceFrameCount = 0 + private bargeInTriggered = false + private onVADSilenceCallback?: () => void + private onBargeInCallback?: () => void + async initialize(): Promise { try { console.log('🎵 音频管理器初始化完成(延迟初始化AudioContext)') @@ -367,6 +384,11 @@ export class AudioManager { this.stopRecordingReject = undefined this.recordingState = 'idle' this.isRecording = false + // VAD 状态重置(isMuted 跨轮次保持,不在这里重置) + this.hasSpeechStarted = false + this.speechFrameCount = 0 + this.silenceFrameCount = 0 + this.bargeInTriggered = false } // 延迟初始化AudioContext,在用户交互后调用 @@ -477,8 +499,7 @@ export class AudioManager { this.audioSourceNode = this.recordingContext.createMediaStreamSource(this.audioStream) - // ScriptProcessorNode: buffer 4096 帧 @ 16kHz ≈ 256ms/chunk - const PROC_BUFFER = 4096 + // ScriptProcessorNode: 2048 帧 @ 16kHz = 128ms/帧,比 4096 更细粒度,利于 VAD this.scriptProcessor = this.recordingContext.createScriptProcessor(PROC_BUFFER, 1, 1) this.audioSourceNode.connect(this.scriptProcessor) this.scriptProcessor.connect(this.recordingContext.destination) @@ -487,7 +508,41 @@ export class AudioManager { if (this.activeSessionId !== sessionId || this.recordingState !== 'recording') { return } + if (this.isMuted) return // 静音:跳过发送和 VAD + const float32 = event.inputBuffer.getChannelData(0) + + // RMS 计算 + let sumSq = 0 + for (let i = 0; i < float32.length; i++) sumSq += float32[i] * float32[i] + const rms = Math.sqrt(sumSq / float32.length) + + // VAD + Barge-in 状态更新 + if (rms > SPEECH_THRESHOLD) { + this.hasSpeechStarted = true + this.speechFrameCount++ + this.silenceFrameCount = 0 + // Barge-in:AI 说话时用户插话 + if (this.isAISpeaking && !this.bargeInTriggered) { + this.bargeInTriggered = true + this.onBargeInCallback?.() + } + } else if (this.hasSpeechStarted && this.speechFrameCount >= MIN_SPEECH_FRAMES) { + if (rms < SILENCE_THRESHOLD) { + this.silenceFrameCount++ + if (this.silenceFrameCount >= SILENCE_FRAMES_REQUIRED) { + console.log('🔇 VAD: silence detected, auto-stopping') + this.hasSpeechStarted = false + this.silenceFrameCount = 0 + this.speechFrameCount = 0 + this.onVADSilenceCallback?.() + return // 触发后本帧不发送 + } + } else { + this.silenceFrameCount = 0 + } + } + // Float32 [-1,1] → Int16 PCM const int16 = new Int16Array(float32.length) for (let i = 0; i < float32.length; i++) { @@ -507,7 +562,7 @@ export class AudioManager { this.recordingState = 'recording' this.isRecording = true - console.log('✅ 开始 PCM 实时录音 (16kHz, mono, Int16)') + console.log('✅ 开始 PCM 实时录音 (16kHz, mono, Int16, VAD enabled)') return true } catch (error) { this.resetRecordingState({ stopTracks: true }) @@ -577,8 +632,37 @@ export class AudioManager { }) } - async playAudio(audioBuffer: ArrayBuffer): Promise { - try { + // VAD / 静音 / Barge-in 控制 + setVADSilenceCallback(cb: (() => void) | undefined): void { + this.onVADSilenceCallback = cb + } + + setBargeInCallback(cb: (() => void) | undefined): void { + this.onBargeInCallback = cb + } + + setAISpeaking(speaking: boolean): void { + this.isAISpeaking = speaking + if (!speaking) { + // AI 停止说话后重置 barge-in,允许下一轮检测 + this.bargeInTriggered = false + } + } + + setMuted(muted: boolean): void { + this.isMuted = muted + if (muted) { + this.hasSpeechStarted = false + this.silenceFrameCount = 0 + this.speechFrameCount = 0 + } + } + + get muted(): boolean { + return this.isMuted + } + + async playAudio(audioBuffer: ArrayBuffer): Promise { try { if (!this.audioContext) { await this.initialize() } @@ -701,6 +785,7 @@ export class VocaTaAIChat { private wsClient: VocaTaWebSocketClient | null = null private audioManager: AudioManager private isAudioCallActive = false + private isContinuousModeActive = false private conversationUuid: string | null = null private connectingPromise: Promise | null = null private voiceState: 'idle' | 'starting' | 'recording' | 'stopping' = 'idle' @@ -723,6 +808,16 @@ export class VocaTaAIChat { this.audioManager = new AudioManager() this.audioManager.setPlaybackStateListener(isPlaying => { this.onAudioPlayCallback?.(isPlaying) + // 同步 AI 说话状态给 AudioManager(用于 barge-in 检测) + this.audioManager.setAISpeaking(isPlaying) + // 持续模式:TTS 播完后自动开始下一轮聆听 + if (!isPlaying && this.isAudioCallActive && this.isContinuousModeActive) { + setTimeout(() => { + if (this.isAudioCallActive && this.voiceState === 'idle') { + this.startRecording().catch(err => console.error('❌ 自动重启录音失败:', err)) + } + }, 300) + } }) } @@ -918,6 +1013,14 @@ export class VocaTaAIChat { private handleProcessComplete(message: CompleteMessage): void { console.log('✅ 处理完成:', message.message) + // STT 无结果时 TTS 不播放,不会触发 onAudioPlay(false) → 手动触发下一轮 + if (this.isAudioCallActive && this.isContinuousModeActive && !this.audioManager.playing) { + setTimeout(() => { + if (this.isAudioCallActive && this.voiceState === 'idle') { + this.startRecording().catch(err => console.error('❌ complete后自动重启失败:', err)) + } + }, 300) + } } private handleError(message: ServerErrorMessage): void { @@ -1059,15 +1162,39 @@ export class VocaTaAIChat { throw new Error('WebSocket未连接,无法启动音频通话') } - console.log('📞 音频通话已激活,等待用户点击开始实时捕获') this.isAudioCallActive = true + this.isContinuousModeActive = true - // 清空残留的播放队列,确保新的通话段落从空状态开始 + // 清空残留的播放队列 this.audioManager.clearQueue() this.onAudioPlayCallback?.(false) + + // 注册 VAD 静音回调:静音 ~0.8s 后自动提交 + this.audioManager.setVADSilenceCallback(() => { + if (this.voiceState === 'recording') { + this.stopRecording().catch(err => console.error('❌ VAD 自动停止失败:', err)) + } + }) + + // 注册 Barge-in 回调:AI 说话时用户插话 + this.audioManager.setBargeInCallback(() => { + console.log('🎤 Barge-in:用户插话,打断 AI') + this.audioManager.clearQueue() + // 发送 audio_start → 服务端 SPEAKING 状态时触发 handleBargeIn + this.wsClient?.startAudioRecording() + }) + + // 立即开始聆听(GPT Voice 体验) + console.log('📞 音频通话已激活,立即开始聆听') + await this.startRecording() } async stopAudioCall(): Promise { + this.isContinuousModeActive = false + this.audioManager.setVADSilenceCallback(undefined) + this.audioManager.setBargeInCallback(undefined) + this.audioManager.setMuted(false) + if (this.voiceState !== 'idle') { await this.stopRecording() } @@ -1137,6 +1264,18 @@ export class VocaTaAIChat { return this.audioManager.recording } + get micMuted(): boolean { + return this.audioManager.muted + } + + muteMic(): void { + this.audioManager.setMuted(true) + } + + unmuteMic(): void { + this.audioManager.setMuted(false) + } + // 清理资源 destroy(): void { console.log('🧹 清理AI对话系统资源') diff --git a/vocata-web/src/views/ChatPage.vue b/vocata-web/src/views/ChatPage.vue index b74ebf1..f985865 100644 --- a/vocata-web/src/views/ChatPage.vue +++ b/vocata-web/src/views/ChatPage.vue @@ -126,9 +126,10 @@
@@ -136,10 +137,6 @@
- -
- 提示:点击麦克风即可开始实时捕获语音,再点一次结束本轮捕获,等待 AI 回答 -
@@ -209,9 +206,8 @@ interface TypewriterState { const typewriterState = ref(null) const TYPEWRITER_SPEED = 35 -// VAD相关状态 -const vadActive = ref(false) -const vadCheckInterval = ref(null) +// VAD相关状态(已内置于 AudioManager,此处仅保留静音状态) +const isMicMuted = ref(false) // 引用 @@ -484,12 +480,11 @@ const characterInitials = computed(() => { const voiceStatusText = computed(() => { if (!isAIConnected.value) return '语音通道连接中…' - if (aiChat.value?.recording) { - return vadActive.value ? '正在实时捕获…' : '实时捕获中,准备说话' - } - if (isAISpeaking.value) return 'AI 正在回答' + if (isMicMuted.value) return '麦克风已静音' + if (aiChat.value?.recording) return '正在聆听...' + if (isAISpeaking.value) return 'AI 回答中' if (isAIThinking.value) return 'AI 正在思考…' - return '点击下方按钮开启实时语音对话' + return '语音对话中' }) const visibleVoiceTranscripts = computed(() => voiceTranscripts.value.slice(-6)) @@ -729,13 +724,11 @@ const startAudioCall = async () => { console.log('📞 开始音频通话') await aiChat.value.prepareAudioPlayback() - await aiChat.value.startAudioCall() + await aiChat.value.startAudioCall() // 内部立即开始录音 isAudioCallActive.value = true + isMicMuted.value = false voiceTranscripts.value = [] - // 启动VAD状态监控 - startVADMonitoring() - } catch (error) { console.error('❌ 启动音频通话失败:', error) ElMessage.error('无法启动音频通话: ' + (error as Error).message) @@ -749,34 +742,25 @@ const stopAudioCall = async () => { console.log('📞 停止音频通话') await aiChat.value.stopAudioCall() isAudioCallActive.value = false + isMicMuted.value = false currentSTTText.value = '' isAISpeaking.value = false - // 停止VAD监控 - stopVADMonitoring() - } catch (error) { console.error('❌ 停止音频通话失败:', error) ElMessage.error('停止音频通话失败: ' + (error as Error).message) } } -const toggleMicrophone = async () => { +const toggleMicrophone = () => { if (!aiChat.value || !isAudioCallActive.value) return - try { - if (aiChat.value.recording) { - // 当前在录音,停止录音 - console.log('🛑 停止录音') - await aiChat.value.stopRecording() - } else { - // 当前没有录音,开始录音 - console.log('🎤 开始录音') - await aiChat.value.startRecording() - } - } catch (error) { - console.error('❌ 切换麦克风状态失败:', error) - ElMessage.error('切换麦克风状态失败: ' + (error as Error).message) + if (isMicMuted.value) { + aiChat.value.unmuteMic() + isMicMuted.value = false + } else { + aiChat.value.muteMic() + isMicMuted.value = true } } @@ -919,25 +903,6 @@ const scrollToBottomWithRetry = (maxRetries: number = 3) => { }) } -// VAD监控相关函数 -const startVADMonitoring = () => { - if (vadCheckInterval.value) { - clearInterval(vadCheckInterval.value) - } - - vadCheckInterval.value = window.setInterval(() => { - vadActive.value = aiChat.value?.voiceActive ?? false - }, 100) // 每100ms检查一次VAD状态 -} - -const stopVADMonitoring = () => { - if (vadCheckInterval.value) { - clearInterval(vadCheckInterval.value) - vadCheckInterval.value = null - } - vadActive.value = false -} - // 格式化时间 const formatTime = (dateString: string) => { return new Date(dateString).toLocaleTimeString([], { @@ -2006,6 +1971,11 @@ const formatTime = (dateString: string) => { background: rgba(255, 243, 241, 0.96); } + &__control.is-mic.is-muted { + color: #ffffff; + background: #f56c6c; + } + &__control.is-cancel { color: #ef4444; }