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秒即可):
+
+- "你好"
+- "今天天气怎么样"
+- "给我讲个笑话"
+- "帮我查询一下"
+- 随便说点什么
+
+---
+
+**准备好后**,在项目中点击 🎧 进入语音模式,然后点击 "🧪 发送测试音频" 按钮测试!
+