diff --git a/TEST_MODE_GUIDE.md b/TEST_MODE_GUIDE.md new file mode 100644 index 0000000..2d01d6e --- /dev/null +++ b/TEST_MODE_GUIDE.md @@ -0,0 +1,269 @@ +# 🧪 WebSocket语音流测试模式使用指南 + +## 概述 + +测试模式允许你在不使用实际录音功能的情况下测试WebSocket语音流式对话功能。这对于在不支持录音的环境(如某些微信小程序开发环境)或需要使用特定测试数据的场景非常有用。 + +## 启用测试模式 + +在 `webUI/src/utils/config.js` 中配置: + +```javascript +TEST_MODE: { + enabled: true, // 设置为 true 启用测试模式,false 使用正常录音 + audioDuration: 3, // 测试音频时长(秒) + sampleRate: 16000, // 采样率(Hz) + channels: 1, // 声道数(1=单声道,2=立体声) + bitDepth: 16 // 位深度(8或16) +} +``` + +## 测试模式特性 + +### 启用时(enabled: true) +- ✅ 显示 "🧪 发送测试音频" 按钮 +- ✅ 禁用自动录音功能 +- ✅ 点击按钮发送假数据到WebSocket +- ✅ 页面提示 "测试模式:点击发送假数据" + +### 禁用时(enabled: false) +- ✅ 隐藏测试按钮 +- ✅ 启用正常录音功能 +- ✅ 自动模式下自动开始录音 + +## 使用步骤 + +1. **启用测试模式** + ```javascript + // 在 config.js 中 + TEST_MODE: { enabled: true } + ``` + +2. **进入语音模式** + - 点击输入框右侧的 🎧 图标切换到语音模式 + +3. **发送测试数据** + - 点击 "🧪 发送测试音频" 按钮 + - 系统会自动生成假PCM数据并发送到WebSocket服务器 + +4. **观察结果** + - STT识别结果会显示在聊天界面 + - AI回复会实时流式显示 + - TTS音频会实时播放 + +## PCM音频数据格式说明 + +### 当前生成的测试数据格式 + +代码会自动生成符合以下规格的PCM数据: + +```javascript +{ + 采样率: 16000 Hz, // 16kHz,语音常用采样率 + 位深度: 16 bit, // 16位有符号整数 + 声道数: 1, // 单声道 + 字节序: Little Endian, // 小端序 + 时长: 3秒, + 数据大小: 96000 bytes // = 16000 * 3 * 2 +} +``` + +### 测试数据生成原理 + +代码生成的是一个模拟人声的音频信号: +- **基频**: 200Hz(人声范围) +- **泛音**: 包含2次和3次谐波,让声音更丰富 +- **噪声**: 添加轻微随机噪声,模拟呼吸音 +- **包络**: 淡入淡出效果,让声音更自然 + +### 如果需要使用真实音频文件 + +如果你想使用真实的音频文件进行测试,需要先将音频转换为PCM格式: + +#### 1. 使用FFmpeg转换 + +```bash +# 从MP3/WAV转换为PCM +ffmpeg -i input.mp3 -f s16le -acodec pcm_s16le -ar 16000 -ac 1 output.pcm + +# 参数说明: +# -f s16le: 输出格式为16位小端序PCM +# -acodec pcm_s16le: 使用16位PCM编码 +# -ar 16000: 采样率16kHz +# -ac 1: 单声道 +``` + +#### 2. 在代码中使用真实PCM文件 + +修改 `ChatBox.vue` 中的 `generateTestPCMAudio()` 函数: + +```javascript +// 方案A: 直接读取PCM文件 +const generateTestPCMAudio = async () => { + // #ifdef MP-WEIXIN + const fs = uni.getFileSystemManager(); + return new Promise((resolve, reject) => { + fs.readFile({ + filePath: '/static/test_audio.pcm', // 你的PCM文件路径 + success: (res) => { + resolve(res.data); // res.data 是 ArrayBuffer + }, + fail: reject + }); + }); + // #endif +}; + +// 方案B: 从base64字符串加载 +const TEST_PCM_BASE64 = "你的base64编码的PCM数据..."; +const generateTestPCMAudio = () => { + return uni.base64ToArrayBuffer(TEST_PCM_BASE64); +}; +``` + +#### 3. 获取音频的base64编码 + +```bash +# Linux/Mac +base64 output.pcm > output_base64.txt + +# Windows PowerShell +[Convert]::ToBase64String([IO.File]::ReadAllBytes("output.pcm")) > output_base64.txt +``` + +然后将base64字符串复制到代码中。 + +## PCM数据格式要求 + +### 必须满足的要求 + +✅ **格式**: 原始PCM数据(无文件头) +✅ **采样率**: 16000 Hz(后端STT服务要求) +✅ **位深度**: 16 bit +✅ **声道数**: 1(单声道) +✅ **字节序**: Little Endian(小端序) +✅ **编码**: 有符号整数(Signed Integer) + +### 数据大小计算 + +``` +数据大小(字节)= 采样率 × 时长(秒)× 声道数 × (位深度/8) + +示例:3秒的音频 += 16000 × 3 × 1 × 2 += 96000 bytes += 93.75 KB +``` + +## 注意事项 + +⚠️ **测试数据限制** +- 生成的测试数据是简单的合成音,不包含真实语音内容 +- STT识别结果可能为空或乱码(取决于STT服务对合成音的处理) +- 如需测试真实识别,请使用真实录音的PCM数据 + +⚠️ **WebSocket模式要求** +- 测试模式需要配合WebSocket模式使用 +- 确保在 `ChatBox.vue` 中设置 `isWebSocketMode.value = true` +- 确保WebSocket服务器正在运行 + +⚠️ **性能考虑** +- 较长的音频文件会占用更多内存 +- 建议测试音频时长不超过5秒 +- 如需长时间测试,可以循环发送短音频 + +## 调试技巧 + +### 查看浏览器控制台 + +测试模式会输出详细日志: + +``` +[TestMode] 生成测试PCM音频: { + sampleRate: 16000, + duration: "3秒", + channels: 1, + bitDepth: "16位", + dataSize: "96000 bytes", + frequency: "200 Hz" +} +[TestMode] 开始发送测试音频数据 +[VoiceMode] 测试音频已通过WebSocket发送 +``` + +### 验证数据格式 + +在浏览器控制台检查: + +```javascript +// 检查生成的数据 +const data = generateTestPCMAudio(); +console.log('数据类型:', data instanceof ArrayBuffer); +console.log('数据大小:', data.byteLength, 'bytes'); +console.log('预期大小:', 16000 * 3 * 2, 'bytes'); +``` + +## 故障排除 + +### 问题1: 点击按钮无反应 +- 检查是否已进入语音模式(点击🎧图标) +- 检查是否启用了WebSocket模式 +- 查看浏览器控制台是否有错误 + +### 问题2: STT识别结果为空 +- 这是正常的,合成音不包含真实语音内容 +- 使用真实PCM录音文件进行测试 + +### 问题3: WebSocket连接失败 +- 检查后端服务是否运行 +- 检查 `config.js` 中的 `WS_BASE_URL` 配置 +- 检查网络连接和防火墙设置 + +### 问题4: 音频无法播放 +- 检查返回的TTS音频格式 +- 查看浏览器控制台的音频播放日志 +- 确认播放权限已授予 + +## 示例代码 + +### 完整的测试流程示例 + +```javascript +// 1. 配置测试模式 +// config.js +export const API_CONFIG = { + TEST_MODE: { + enabled: true, + audioDuration: 3, + sampleRate: 16000, + channels: 1, + bitDepth: 16 + } +}; + +// 2. 进入语音模式 +toggleVoiceMode(); // 点击🎧图标 + +// 3. 发送测试数据 +sendTestAudio(); // 点击测试按钮 + +// 4. 观察回调 +voiceStreamWs.on('sttResult', (text) => { + console.log('识别结果:', text); +}); + +voiceStreamWs.on('sentence', (sentence) => { + console.log('完整句子:', sentence); +}); + +voiceStreamWs.on('audioChunk', (audioData) => { + console.log('收到音频:', audioData.byteLength, 'bytes'); +}); +``` + +## 总结 + +测试模式为你提供了一个便捷的方式来测试WebSocket语音流功能,无需实际录音设备。通过生成符合格式的假PCM数据,你可以验证整个语音对话流程的正确性。 + +如有任何问题,请检查浏览器控制台的日志输出,那里会有详细的调试信息。 + diff --git a/src/components/ChatBox.vue b/src/components/ChatBox.vue index 1488e0c..f59d649 100644 --- a/src/components/ChatBox.vue +++ b/src/components/ChatBox.vue @@ -71,6 +71,14 @@ + + + + 测试模式:点击发送假数据 + + @@ -261,6 +269,12 @@ import { getUnreadMessages, clearUnreadMessages } from '@/utils/unreadMessages.js'; +// 🎙️ 新增:导入WebSocket语音流模块 +import VoiceStreamWebSocket from '@/utils/voiceStreamWebSocket.js'; +import { getWsUrl, API_CONFIG } from '@/utils/config.js'; + +// 🧪 测试模式配置 +const isTestMode = ref(API_CONFIG.TEST_MODE.enabled); // 获取组件实例 const instance = getCurrentInstance(); @@ -371,6 +385,15 @@ const vadConfig = ref({ silenceDuration: 1500 // 静音判定时间 (ms) }); +// 🎙️ WebSocket 语音流相关 +const voiceStreamWs = ref(null); +const isWebSocketMode = ref(true); // 是否使用WebSocket模式(默认false使用HTTP) +const audioPlayQueue = ref([]); // 音频播放队列 +const isPlayingAudio = ref(false); // 是否正在播放音频 +const currentSentence = ref(''); // 当前正在显示的句子 +const currentChatSessionId = ref(null); // 对话会话ID(用于历史记录) +const currentVoiceTemplateId = ref(null); // 语音模式使用的模板ID + // AI配置 const availableTemplates = ref([]); const currentModelId = ref(null); @@ -397,6 +420,17 @@ const voiceStateText = computed(() => { return stateMap[voiceState.value] || '准备就绪'; }); +// 🧪 测试按钮文本 +const testButtonText = computed(() => { + const stateMap = { + idle: '🧪 发送测试音频', + listening: '⏺️ 录音中...', + thinking: '⏳ 处理中...', + speaking: '🔊 回复中...' + }; + return stateMap[voiceState.value] || '🧪 发送测试音频'; +}); + // 根据音量动态生成声纹高度 const voiceWaveStyles = computed(() => { const barCount = 40; @@ -602,6 +636,21 @@ onBeforeUnmount(() => { voiceState.value = 'idle'; currentVolume.value = 0; + // 6. 关闭WebSocket连接 + if (voiceStreamWs.value) { + try { + voiceStreamWs.value.close(); + voiceStreamWs.value = null; + console.log('🔌 已关闭WebSocket连接'); + } catch (error) { + console.warn('关闭WebSocket失败:', error); + } + } + + // 7. 清空音频播放队列 + audioPlayQueue.value = []; + isPlayingAudio.value = false; + console.log('✅ 资源清理完成'); }); @@ -1490,7 +1539,7 @@ const clearContext = async () => { }; // 🎧 切换语音模式 -const toggleVoiceMode = () => { +const toggleVoiceMode = async () => { isVoiceMode.value = !isVoiceMode.value; if (isVoiceMode.value) { @@ -1498,14 +1547,116 @@ const toggleVoiceMode = () => { voiceState.value = 'idle'; isVoiceModeInterrupted.value = false; // 重置中断标志 + // 🎙️ 建立WebSocket连接(如果启用WebSocket模式) + if (isWebSocketMode.value) { + try { + const wsUrl = getWsUrl(API_CONFIG.WS_ENDPOINTS.VOICE_STREAM); + voiceStreamWs.value = new VoiceStreamWebSocket(wsUrl); + + // 设置事件监听器 + voiceStreamWs.value.on('connected', () => { + console.log('[VoiceMode] WebSocket已连接'); + }); + + voiceStreamWs.value.on('sttResult', (text) => { + console.log('[VoiceMode] STT结果:', text); + addMessage('user', text); + currentSentence.value = ''; + }); + + voiceStreamWs.value.on('llmToken', (token) => { + // 可选:显示LLM输出 + console.log('[VoiceMode] LLM Token:', token); + }); + + voiceStreamWs.value.on('sentence', (sentence) => { + console.log('[VoiceMode] 完整句子:', sentence); + currentSentence.value = sentence; + addMessage('ai', sentence); + }); + + voiceStreamWs.value.on('audioChunk', (audioData) => { + // 将音频数据加入播放队列 + audioPlayQueue.value.push(audioData); + processAudioQueue(); + }); + + voiceStreamWs.value.on('complete', () => { + console.log('[VoiceMode] 对话完成,等待播放队列清空'); + + // 等待播放队列清空后再设置idle + const checkQueueEmpty = () => { + if (audioPlayQueue.value.length === 0 && !isPlayingAudio.value) { + console.log('[VoiceMode] 播放队列已清空,切换到idle状态'); + voiceState.value = 'idle'; + + // 如果是自动模式,重新开始录音 + if (isAutoVoiceMode.value && isVoiceMode.value) { + console.log('↺ 自动模式:重新开始监听'); + startVoiceRecording(); + } + } else { + console.log('[VoiceMode] 队列还有数据或正在播放,100ms后再检查'); + setTimeout(checkQueueEmpty, 100); + } + }; + + checkQueueEmpty(); + }); + + voiceStreamWs.value.on('error', (error) => { + console.error('[VoiceMode] 错误:', error); + uni.showToast({ + title: '语音处理失败: ' + error, + icon: 'none', + duration: 2000 + }); + voiceState.value = 'idle'; + }); + + voiceStreamWs.value.on('disconnected', () => { + console.log('[VoiceMode] WebSocket已断开'); + }); + + // 复用文字对话的 conversationId,保持历史记录一致 + // 如果还没有 conversationId,会在initializeConversation中生成 + if (!conversationId.value) { + await initializeConversation(); + } + + // 使用当前角色的templateId(如果有的话) + currentVoiceTemplateId.value = currentCharacter.value.templateId || currentCharacter.value.roleId || null; + + console.log('[VoiceMode] 连接参数 - SessionId:', conversationId.value, 'TemplateId:', currentVoiceTemplateId.value); + + // 连接WebSocket(传递与文字对话相同的sessionId和templateId) + await voiceStreamWs.value.connect( + conversationId.value, + currentVoiceTemplateId.value, + userStore.token, + userStore.userId + ); + + } catch (error) { + console.error('[VoiceMode] WebSocket连接失败:', error); + uni.showToast({ + title: 'WebSocket连接失败', + icon: 'none', + duration: 2000 + }); + isVoiceMode.value = false; + return; + } + } + uni.showToast({ - title: '已切换到语音模式', + title: '已切换到语音模式' + (isWebSocketMode.value ? '(实时流式)' : '') + (isTestMode.value ? '(测试)' : ''), icon: 'none', duration: 1500 }); - // 如果是自动模式,直接开始监听 - if (isAutoVoiceMode.value) { + // 如果是自动模式且非测试模式,直接开始监听 + if (isAutoVoiceMode.value && !isTestMode.value) { startVoiceRecording(); } } else { @@ -1548,6 +1699,22 @@ const toggleVoiceMode = () => { voiceState.value = 'idle'; currentVolume.value = 0; + // 6. 关闭WebSocket连接 + if (voiceStreamWs.value) { + try { + voiceStreamWs.value.close(); + voiceStreamWs.value = null; + console.log('🔌 已关闭WebSocket连接'); + } catch (error) { + console.warn('关闭WebSocket失败:', error); + } + } + + // 7. 清空音频播放队列 + audioPlayQueue.value = []; + isPlayingAudio.value = false; + currentSentence.value = ''; + uni.showToast({ title: '已切换到文本模式', icon: 'none', @@ -1763,7 +1930,38 @@ const handleVoiceModeMessage = async (filePath) => { try { console.log('🎧 语音模式:开始处理录音文件', filePath); - // 调用后端voice-chat接口 + // 🎙️ WebSocket模式:通过WebSocket发送音频 + if (isWebSocketMode.value && voiceStreamWs.value && voiceStreamWs.value.isConnected) { + // 读取音频文件并通过WebSocket发送 + const fs = uni.getFileSystemManager(); + console.log(wx.env.USER_DATA_PATH) + fs.readFile({ + filePath: filePath, + success: (res) => { + console.log('[VoiceMode] 读取音频文件成功,大小:', res.data.byteLength); + + // 发送音频数据 + const success = voiceStreamWs.value.sendAudio(res.data); + if (!success) { + throw new Error('发送音频数据失败'); + } + + console.log('[VoiceMode] 音频已通过WebSocket发送'); + }, + fail: (err) => { + console.error('[VoiceMode] 读取音频文件失败:', err); + uni.showToast({ + title: '读取音频文件失败', + icon: 'none', + duration: 2000 + }); + voiceState.value = 'idle'; + } + }); + return; + } + + // HTTP模式:调用后端voice-chat接口 const result = await voiceAPI.voiceChat(filePath, { sessionId: conversationId.value, // 使用当前会话ID保持上下文 modelId: currentCharacter.value.modelId || 10, @@ -1851,6 +2049,247 @@ const handleVoiceModeMessage = async (filePath) => { } }; +// 🧪 加载测试PCM音频数据 +const generateTestPCMAudio = () => { + return new Promise((resolve, reject) => { + const config = API_CONFIG.TEST_MODE; + + // 方式1: 优先使用base64数据 + if (config.testAudioBase64 && config.testAudioBase64.trim() !== '') { + try { + console.log('[TestMode] 从base64加载测试音频数据'); + const arrayBuffer = uni.base64ToArrayBuffer(config.testAudioBase64); + console.log('[TestMode] 测试音频数据已加载:', { + dataSize: arrayBuffer.byteLength + ' bytes', + expectedFormat: '16000Hz, 16bit, 单声道, Little Endian' + }); + resolve(arrayBuffer); + } catch (error) { + console.error('[TestMode] base64解码失败:', error); + reject(new Error('base64数据解码失败')); + } + return; + } + + // 方式2: 从文件路径读取 + if (config.testAudioPath && config.testAudioPath.trim() !== '') { + // #ifdef MP-WEIXIN + const fs = uni.getFileSystemManager(); + const testAudioPath = wx.env.USER_DATA_PATH + "/output.pcm"; + fs.readFile({ + filePath: testAudioPath, + success: (res) => { + console.log('[TestMode] 从文件加载测试音频数据:', config.testAudioPath); + console.log('[TestMode] 文件大小:', res.data.byteLength + ' bytes'); + resolve(res.data); + }, + fail: (err) => { + console.error('[TestMode] 读取音频文件失败:', err); + reject(new Error('读取音频文件失败: ' + JSON.stringify(err))); + } + }); + // #endif + + // #ifndef MP-WEIXIN + reject(new Error('文件读取仅支持微信小程序')); + // #endif + return; + } + + // 如果两个都没有配置,提示错误 + reject(new Error('请在config.js中配置 testAudioBase64 或 testAudioPath')); + }); +}; + +// 🧪 发送测试音频数据 +const sendTestAudio = async () => { + if (!isVoiceMode.value) { + uni.showToast({ + title: '请先进入语音模式', + icon: 'none', + duration: 2000 + }); + return; + } + + if (voiceState.value === 'thinking' || voiceState.value === 'speaking') { + uni.showToast({ + title: '正在处理中,请稍候', + icon: 'none', + duration: 1500 + }); + return; + } + + try { + console.log('[TestMode] 开始发送测试音频数据'); + voiceState.value = 'listening'; + + // 加载测试PCM数据 + const testAudioData = await generateTestPCMAudio(); + + // 模拟VAD检测(等待一段时间模拟说话) + await new Promise(resolve => setTimeout(resolve, 1000)); + + // 切换到思考状态 + voiceState.value = 'thinking'; + + // WebSocket模式:直接发送ArrayBuffer + if (isWebSocketMode.value && voiceStreamWs.value && voiceStreamWs.value.isConnected) { + const success = voiceStreamWs.value.sendAudio(testAudioData); + if (!success) { + throw new Error('发送音频数据失败'); + } + console.log('[TestMode] 测试音频已通过WebSocket发送'); + } else { + // HTTP模式:需要先保存为文件,然后调用接口 + // #ifdef MP-WEIXIN + const fs = uni.getFileSystemManager(); + const filePath = `${wx.env.USER_DATA_PATH}/test_audio_${Date.now()}.pcm`; + + // 将ArrayBuffer转为base64 + const base64 = uni.arrayBufferToBase64(testAudioData); + + await new Promise((resolve, reject) => { + fs.writeFile({ + filePath: filePath, + data: base64, + encoding: 'base64', + success: () => { + console.log('[TestMode] 测试音频文件已保存:', filePath); + // 调用现有的处理逻辑 + handleVoiceModeMessage(filePath); + resolve(); + }, + fail: (err) => { + console.error('[TestMode] 保存测试音频文件失败:', err); + reject(err); + } + }); + }); + // #endif + + // #ifndef MP-WEIXIN + uni.showToast({ + title: 'HTTP模式仅支持微信小程序', + icon: 'none', + duration: 2000 + }); + voiceState.value = 'idle'; + // #endif + } + + } catch (error) { + console.error('[TestMode] 发送测试音频失败:', error); + uni.showToast({ + title: '发送测试音频失败', + icon: 'none', + duration: 2000 + }); + voiceState.value = 'idle'; + } +}; + +// 🎙️ 处理音频播放队列(WebSocket流式音频) +const processAudioQueue = async () => { + if (isPlayingAudio.value || audioPlayQueue.value.length === 0) { + return; + } + + isPlayingAudio.value = true; + voiceState.value = 'speaking'; + + // #ifdef MP-WEIXIN + try { + // 开始播放队列中的音频 + while (audioPlayQueue.value.length > 0 && isVoiceMode.value) { + const audioData = audioPlayQueue.value.shift(); + + // 将ArrayBuffer转换为临时文件(MP3格式) + const fs = uni.getFileSystemManager(); + const filePath = `${wx.env.USER_DATA_PATH}/stream_audio_${Date.now()}.mp3`; + + await new Promise((resolve, reject) => { + // 将ArrayBuffer转为base64 + const base64 = uni.arrayBufferToBase64(audioData); + + fs.writeFile({ + filePath: filePath, + data: base64, + encoding: 'base64', + success: () => { + console.log('[AudioQueue] MP3文件写入成功:', filePath); + + // 播放音频 + if (audioContext.value) { + audioContext.value.destroy(); + } + + uni.setInnerAudioOption({ + obeyMuteSwitch: false, + speakerOn: true + }); + + audioContext.value = uni.createInnerAudioContext(); + audioContext.value.autoplay = false; + audioContext.value.obeyMuteSwitch = false; + audioContext.value.volume = 1; + audioContext.value.loop = false; + audioContext.value.src = filePath; + + audioContext.value.onCanplay(() => { + console.log('[AudioQueue] 音频可以播放,开始播放'); + if (audioContext.value?.paused) { + audioContext.value.play(); + } + }); + + audioContext.value.onPlay(() => { + console.log('[AudioQueue] 开始播放音频块'); + startVolumeSimulation(); + }); + + audioContext.value.onEnded(() => { + console.log('[AudioQueue] 音频块播放完成'); + stopVolumeSimulation(); + audioContext.value?.destroy(); + audioContext.value = null; + resolve(); + }); + + audioContext.value.onError((err) => { + console.error('[AudioQueue] 音频播放错误:', err); + stopVolumeSimulation(); + audioContext.value?.destroy(); + audioContext.value = null; + resolve(); // 继续播放下一个 + }); + + // 尝试立即播放(某些情况下不会触发onCanplay) + audioContext.value.play(); + }, + fail: (err) => { + console.error('[AudioQueue] 写入文件失败:', err); + reject(err); + } + }); + }); + } + } catch (error) { + console.error('[AudioQueue] 播放队列处理失败:', error); + } finally { + isPlayingAudio.value = false; + + // 检查队列是否还有数据 + if (audioPlayQueue.value.length > 0 && isVoiceMode.value) { + processAudioQueue(); + } + // 不要在队列为空时设置idle,应该由complete事件来控制状态 + // 因为队列为空不代表对话完成,后端可能还在合成下一句音频 + } + // #endif +}; + // 🎧 播放Base64编码的音频 const playVoiceFromBase64 = async (audioBase64, text = '') => { return new Promise((resolve, reject) => { @@ -2823,4 +3262,42 @@ page { background: linear-gradient(180deg, #f9e076 0%, #f5d042 100%); } +/* 🧪 测试模式样式 */ +.test-controls { + margin-top: 80rpx; + display: flex; + flex-direction: column; + align-items: center; + gap: 20rpx; +} + +.test-btn { + padding: 24rpx 60rpx; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + border-radius: 50rpx; + font-size: 32rpx; + font-weight: 500; + border: none; + box-shadow: 0 8rpx 24rpx rgba(102, 126, 234, 0.4); + transition: all 0.3s ease; +} + +.test-btn:active { + transform: scale(0.95); + box-shadow: 0 4rpx 12rpx rgba(102, 126, 234, 0.3); +} + +.test-btn[disabled] { + background: linear-gradient(135deg, #bbb 0%, #999 100%); + box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1); + opacity: 0.6; +} + +.test-hint { + font-size: 24rpx; + color: rgba(255, 255, 255, 0.6); + text-align: center; +} + diff --git a/src/static/output.pcm b/src/static/output.pcm new file mode 100644 index 0000000..d14a8c4 Binary files /dev/null and b/src/static/output.pcm differ diff --git a/src/utils/config.js b/src/utils/config.js index c800673..59d32f5 100644 --- a/src/utils/config.js +++ b/src/utils/config.js @@ -1,11 +1,32 @@ // API配置统一管理 export const API_CONFIG = { // 基础API地址 - BASE_URL: 'https://api.aixsy.com.cn', + BASE_URL: 'http://192.168.3.13:8091', + + // WebSocket地址 + WS_BASE_URL: 'ws://192.168.3.13:8091', // 其他服务地址(如果需要) WEB_URL: 'https://www.aixsy.com.cn', + // 🧪 测试模式配置 + TEST_MODE: { + // 是否启用测试模式(true: 显示测试按钮,禁用录音; false: 正常录音模式) + enabled: false, + + // 📝 测试音频数据(base64编码的PCM数据) + // 格式要求: + // - 采样率: 16000 Hz + // - 位深度: 16 bit (有符号整数) + // - 声道数: 1 (单声道) + // - 字节序: Little Endian (小端序) + // - 无文件头,纯PCM数据 + testAudioBase64: '', // 👈 在这里填入你的base64编码的PCM数据 + + // 或者使用文件路径(优先使用base64) + testAudioPath: 'src/static/output.pcm', // 例如: '/static/test_audio.pcm' + }, + // API端点 ENDPOINTS: { // 登录相关 @@ -44,6 +65,11 @@ export const API_CONFIG = { CONFIG_TTS: '/app/config/tts' }, + // WebSocket端点 + WS_ENDPOINTS: { + VOICE_STREAM: '/ws/voice-stream' + }, + // 请求超时时间(毫秒) TIMEOUT: 30000, @@ -61,3 +87,8 @@ export const getWebUrl = (endpoint) => { return API_CONFIG.WEB_URL + endpoint; }; +// 导出WebSocket URL构建函数 +export const getWsUrl = (endpoint) => { + return API_CONFIG.WS_BASE_URL + endpoint; +}; + diff --git a/src/utils/voiceStreamWebSocket.js b/src/utils/voiceStreamWebSocket.js new file mode 100644 index 0000000..aaced1e --- /dev/null +++ b/src/utils/voiceStreamWebSocket.js @@ -0,0 +1,310 @@ +/** + * 语音流式对话 WebSocket 管理模块 + */ + +class VoiceStreamWebSocket { + constructor(url) { + this.url = url + this.ws = null + this.isConnected = false + this.reconnectAttempts = 0 + this.maxReconnectAttempts = 5 + this.reconnectDelay = 2000 + this.manualClose = false // 标记是否为主动关闭 + + // 事件监听器 + this.listeners = { + onConnected: null, + onDisconnected: null, + onSttResult: null, + onLlmToken: null, + onSentence: null, + onAudioChunk: null, + onComplete: null, + onError: null + } + } + + /** + * 建立连接 + * @param {String} sessionId - 聊天会话ID(用于历史记录查询和保存,与文字对话的sessionId保持一致) + * @param {Number} templateId - 模板ID + * @param {String} token - 认证令牌(可选) + * @param {String} userId - 用户ID(可选) + */ + connect(sessionId = null, templateId = null, token = null, userId = null) { + return new Promise((resolve, reject) => { + try { + // 重置主动关闭标志 + this.manualClose = false + + // 构建连接URL + let wsUrl = this.url + const params = [] + if (sessionId) params.push(`sessionId=${sessionId}`) + if (templateId) params.push(`templateId=${templateId}`) + if (token) params.push(`token=${token}`) + if (userId) params.push(`userId=${userId}`) + if (params.length > 0) { + wsUrl += '?' + params.join('&') + } + + console.log('[VoiceStreamWS] 正在连接:', wsUrl) + + // 创建WebSocket连接 + this.ws = uni.connectSocket({ + url: wsUrl, + success: () => { + console.log('[VoiceStreamWS] WebSocket创建成功') + }, + fail: (err) => { + console.error('[VoiceStreamWS] WebSocket创建失败:', err) + reject(err) + } + }) + + // 连接打开 + this.ws.onOpen(() => { + console.log('[VoiceStreamWS] 连接已建立') + this.isConnected = true + this.reconnectAttempts = 0 + if (this.listeners.onConnected) { + this.listeners.onConnected() + } + resolve() + }) + + // 接收消息 + this.ws.onMessage((res) => { + this.handleMessage(res) + }) + + // 连接错误 + this.ws.onError((err) => { + console.error('[VoiceStreamWS] 连接错误:', err) + this.isConnected = false + if (this.listeners.onError) { + this.listeners.onError('WebSocket连接错误') + } + }) + + // 连接关闭 + this.ws.onClose(() => { + console.log('[VoiceStreamWS] 连接已关闭') + this.isConnected = false + if (this.listeners.onDisconnected) { + this.listeners.onDisconnected() + } + + // 只有非主动关闭才尝试重连 + if (!this.manualClose) { + console.log('[VoiceStreamWS] 检测到异常断开,将尝试自动重连') + this.tryReconnect() + } else { + console.log('[VoiceStreamWS] 主动关闭连接,不进行重连') + } + }) + + } catch (err) { + console.error('[VoiceStreamWS] 连接异常:', err) + reject(err) + } + }) + } + + /** + * 处理接收到的消息 + */ + handleMessage(res) { + try { + // 二进制消息(音频数据) + if (res.data instanceof ArrayBuffer) { + console.log('[VoiceStreamWS] 收到音频数据:', res.data.byteLength, 'bytes') + if (this.listeners.onAudioChunk) { + this.listeners.onAudioChunk(res.data) + } + return + } + + // 文本消息(JSON格式) + const message = JSON.parse(res.data) + console.log('[VoiceStreamWS] 收到消息:', message.type) + + switch (message.type) { + case 'connected': + console.log('[VoiceStreamWS] 服务器确认连接') + break + + case 'stt_result': + // STT识别结果 + if (this.listeners.onSttResult) { + this.listeners.onSttResult(message.message) + } + break + + case 'llm_token': + // LLM输出token + if (this.listeners.onLlmToken) { + this.listeners.onLlmToken(message.message) + } + break + + case 'sentence': + // 完整句子 + if (this.listeners.onSentence) { + this.listeners.onSentence(message.message) + } + break + + case 'complete': + // 对话完成 + if (this.listeners.onComplete) { + this.listeners.onComplete() + } + break + + case 'error': + // 错误 + console.error('[VoiceStreamWS] 服务器错误:', message.message) + if (this.listeners.onError) { + this.listeners.onError(message.message) + } + break + + case 'pong': + // 心跳响应 + console.log('[VoiceStreamWS] 心跳响应') + break + + default: + console.warn('[VoiceStreamWS] 未知消息类型:', message.type) + } + } catch (err) { + console.error('[VoiceStreamWS] 处理消息失败:', err) + } + } + + /** + * 发送音频数据 + */ + sendAudio(audioData) { + if (!this.isConnected || !this.ws) { + console.error('[VoiceStreamWS] 未连接,无法发送音频') + return false + } + + try { + this.ws.send({ + data: audioData, + success: () => { + console.log('[VoiceStreamWS] 音频数据发送成功:', audioData.byteLength, 'bytes') + }, + fail: (err) => { + console.error('[VoiceStreamWS] 音频数据发送失败:', err) + if (this.listeners.onError) { + this.listeners.onError('发送音频失败') + } + } + }) + return true + } catch (err) { + console.error('[VoiceStreamWS] 发送音频异常:', err) + return false + } + } + + /** + * 发送文本消息 + */ + sendMessage(type, data = null) { + if (!this.isConnected || !this.ws) { + console.error('[VoiceStreamWS] 未连接,无法发送消息') + return false + } + + try { + const message = { + type, + timestamp: Date.now() + } + if (data) { + message.data = data + } + + this.ws.send({ + data: JSON.stringify(message), + success: () => { + console.log('[VoiceStreamWS] 消息发送成功:', type) + }, + fail: (err) => { + console.error('[VoiceStreamWS] 消息发送失败:', err) + } + }) + return true + } catch (err) { + console.error('[VoiceStreamWS] 发送消息异常:', err) + return false + } + } + + /** + * 取消当前对话(打断) + */ + cancel() { + return this.sendMessage('cancel') + } + + /** + * 发送心跳 + */ + ping() { + return this.sendMessage('ping') + } + + /** + * 尝试重连 + */ + tryReconnect() { + if (this.reconnectAttempts >= this.maxReconnectAttempts) { + console.log('[VoiceStreamWS] 达到最大重连次数,停止重连') + return + } + + this.reconnectAttempts++ + console.log(`[VoiceStreamWS] 尝试重连 (${this.reconnectAttempts}/${this.maxReconnectAttempts})`) + + setTimeout(() => { + if (!this.isConnected) { + this.connect().catch(err => { + console.error('[VoiceStreamWS] 重连失败:', err) + }) + } + }, this.reconnectDelay) + } + + /** + * 关闭连接 + */ + close() { + if (this.ws) { + console.log('[VoiceStreamWS] 主动关闭连接') + this.manualClose = true // 标记为主动关闭 + this.isConnected = false + this.reconnectAttempts = 0 // 重置重连计数 + this.ws.close() + this.ws = null + } + } + + /** + * 设置事件监听器 + */ + on(event, callback) { + if (this.listeners.hasOwnProperty(`on${event.charAt(0).toUpperCase()}${event.slice(1)}`)) { + this.listeners[`on${event.charAt(0).toUpperCase()}${event.slice(1)}`] = callback + } + } +} + +export default VoiceStreamWebSocket + diff --git a/如何准备测试音频数据.md b/如何准备测试音频数据.md new file mode 100644 index 0000000..0d446f9 --- /dev/null +++ b/如何准备测试音频数据.md @@ -0,0 +1,262 @@ +# 📝 如何准备测试音频数据 + +## 你需要提供的数据格式 + +### PCM格式要求(重要!) + +``` +✅ 采样率: 16000 Hz +✅ 位深度: 16 bit (有符号整数,Signed Integer) +✅ 声道数: 1 (单声道,Mono) +✅ 字节序: Little Endian (小端序) +✅ 格式: 纯PCM数据(无任何文件头,不是WAV) +``` + +### 数据大小计算 + +``` +数据大小(bytes)= 采样率 × 时长(秒)× 2 + +示例: +- 1秒音频 = 16000 × 1 × 2 = 32,000 bytes ≈ 31.25 KB +- 3秒音频 = 16000 × 3 × 2 = 96,000 bytes ≈ 93.75 KB +- 5秒音频 = 16000 × 5 × 2 = 160,000 bytes ≈ 156.25 KB +``` + +## 方式一:使用 FFmpeg 转换(推荐) + +### 1. 从MP3/WAV/M4A等格式转换 + +```bash +ffmpeg -i input.mp3 -f s16le -acodec pcm_s16le -ar 16000 -ac 1 output.pcm +``` + +**参数说明:** +- `-i input.mp3`: 输入文件(可以是任何音频格式) +- `-f s16le`: 输出格式为16位小端序PCM +- `-acodec pcm_s16le`: 使用16位PCM编码 +- `-ar 16000`: 采样率16000 Hz +- `-ac 1`: 单声道 + +### 2. 验证生成的PCM文件 + +```bash +# 查看文件大小 +ls -lh output.pcm + +# 计算时长(秒)= 文件大小(bytes)/ 32000 +# 例如:96000 bytes / 32000 = 3 秒 +``` + +### 3. 转换为base64 + +```bash +# Linux/Mac +base64 output.pcm > output_base64.txt + +# 或者一行输出(适合小文件) +base64 output.pcm | tr -d '\n' > output_base64.txt + +# Windows PowerShell +[Convert]::ToBase64String([IO.File]::ReadAllBytes("output.pcm")) > output_base64.txt +``` + +## 方式二:在线录音并导出 + +### 1. 使用Audacity(免费开源) + +1. 打开Audacity +2. 点击红色按钮录音 +3. 录制3-5秒的测试语音(说点什么都可以) +4. 点击停止 +5. **设置项目采样率**:左下角设置为 `16000 Hz` +6. **转换为单声道**:轨道 → 混音 → 混音立体声为单声道 +7. **导出**: + - 文件 → 导出 → 导出音频 + - 文件类型选择:`其他未压缩文件` + - 头部:`RAW (header-less)` + - 编码:`Signed 16-bit PCM` + - 保存为 `test.pcm` + +### 2. 使用Python脚本录音 + +```python +import pyaudio +import wave +import struct + +# 配置 +RATE = 16000 +CHANNELS = 1 +FORMAT = pyaudio.paInt16 +RECORD_SECONDS = 3 + +# 录音 +audio = pyaudio.PyAudio() +stream = audio.open(format=FORMAT, channels=CHANNELS, + rate=RATE, input=True, + frames_per_buffer=1024) + +print("录音中... (3秒)") +frames = [] +for i in range(0, int(RATE / 1024 * RECORD_SECONDS)): + data = stream.read(1024) + frames.append(data) + +print("录音完成") +stream.stop_stream() +stream.close() +audio.terminate() + +# 保存为PCM +with open('output.pcm', 'wb') as f: + f.write(b''.join(frames)) + +print("已保存为 output.pcm") +``` + +## 如何使用生成的数据 + +### 选项A:使用base64(推荐,适合小文件) + +1. **转换为base64**(见上面的命令) + +2. **打开** `webUI/src/utils/config.js` + +3. **填入base64数据**: + +```javascript +TEST_MODE: { + enabled: true, + testAudioBase64: 'AAEAAgADAAQABQAG...', // 👈 粘贴你的base64字符串 + testAudioPath: '', +} +``` + +**完整示例:** +```javascript +TEST_MODE: { + enabled: true, + testAudioBase64: 'AAEAAgADAAQABQAGAAcACA...(很长的base64字符串)...==', + testAudioPath: '', +} +``` + +### 选项B:使用文件路径(适合大文件) + +1. **将PCM文件放入项目**: + ``` + webUI/src/static/test_audio.pcm + ``` + +2. **配置路径**: +```javascript +TEST_MODE: { + enabled: true, + testAudioBase64: '', // 留空 + testAudioPath: '/static/test_audio.pcm', // 👈 文件路径 +} +``` + +## 快速测试数据示例 + +### 生成一个简单的测试文件(Python) + +```python +import struct + +# 生成3秒的简单正弦波(200Hz) +sample_rate = 16000 +duration = 3 +frequency = 200 + +with open('test.pcm', 'wb') as f: + for i in range(sample_rate * duration): + t = i / sample_rate + # 正弦波,振幅8000 + sample = int(8000 * (2 * 3.14159 * frequency * t) ** 0.5) + sample = max(-32768, min(32767, sample)) + f.write(struct.pack(' test_base64.txt + +# 复制 test_base64.txt 的内容,粘贴到 config.js 的 testAudioBase64 +``` + +### 方式2: 快速录音(Mac) + +```bash +# 录制3秒音频并自动转换 +rec -r 16000 -c 1 -b 16 test.pcm trim 0 3 + +# 转为base64 +base64 test.pcm | tr -d '\n' > test_base64.txt +``` + +### 方式3: 生成测试音频(Python) + +```python +# 保存为 generate_test.py +import struct +import base64 + +sample_rate = 16000 +duration = 3 + +data = bytearray() +for i in range(sample_rate * duration): + # 简单正弦波 + import math + sample = int(8000 * math.sin(2 * math.pi * 200 * i / sample_rate)) + data.extend(struct.pack(' test_base64.txt + +# 4. 复制内容 +cat test_base64.txt | pbcopy # Mac +# 或手动打开 test_base64.txt 复制 + +# 5. 粘贴到 config.js +# testAudioBase64: '刚才复制的内容' + +# 完成! +``` + +## Windows 用户 + +### 使用 PowerShell + +```powershell +# 转base64 +$bytes = [System.IO.File]::ReadAllBytes("test.pcm") +$base64 = [System.Convert]::ToBase64String($bytes) +$base64 | Out-File -Encoding ascii test_base64.txt + +# 打开查看 +notepad test_base64.txt +``` + +## 快速测试内容建议 + +录制这些内容(任选一个,3秒即可): + +- "你好" +- "今天天气怎么样" +- "给我讲个笑话" +- "帮我查询一下" +- 随便说点什么 + +--- + +**准备好后**,在项目中点击 🎧 进入语音模式,然后点击 "🧪 发送测试音频" 按钮测试! +