feat: 优化语音速度
This commit is contained in:
269
TEST_MODE_GUIDE.md
Normal file
269
TEST_MODE_GUIDE.md
Normal file
@@ -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数据,你可以验证整个语音对话流程的正确性。
|
||||
|
||||
如有任何问题,请检查浏览器控制台的日志输出,那里会有详细的调试信息。
|
||||
|
||||
@@ -71,6 +71,14 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 🧪 测试按钮(仅测试模式显示) -->
|
||||
<view v-if="isTestMode" class="test-controls">
|
||||
<button class="test-btn" @click="sendTestAudio" :disabled="voiceState === 'thinking' || voiceState === 'speaking'">
|
||||
{{ testButtonText }}
|
||||
</button>
|
||||
<text class="test-hint">测试模式:点击发送假数据</text>
|
||||
</view>
|
||||
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
BIN
src/static/output.pcm
Normal file
BIN
src/static/output.pcm
Normal file
Binary file not shown.
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
310
src/utils/voiceStreamWebSocket.js
Normal file
310
src/utils/voiceStreamWebSocket.js
Normal file
@@ -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
|
||||
|
||||
262
如何准备测试音频数据.md
Normal file
262
如何准备测试音频数据.md
Normal file
@@ -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('<h', sample)) # 小端序,有符号16位
|
||||
|
||||
print("生成完成: test.pcm")
|
||||
```
|
||||
|
||||
## 推荐的测试内容
|
||||
|
||||
建议你录制以下内容之一(3-5秒):
|
||||
|
||||
1. **简单问候**:
|
||||
- "你好"
|
||||
- "今天天气怎么样"
|
||||
- "给我讲个笑话"
|
||||
|
||||
2. **简单指令**:
|
||||
- "帮我查询一下"
|
||||
- "打开设置"
|
||||
- "我要了解一下"
|
||||
|
||||
3. **随意说话**:
|
||||
- 随便说点什么都可以
|
||||
- 主要是测试流程是否正常
|
||||
|
||||
## 验证数据是否正确
|
||||
|
||||
### 检查文件大小
|
||||
|
||||
```bash
|
||||
# 应该接近这个大小
|
||||
# 3秒 = 96,000 bytes
|
||||
# 5秒 = 160,000 bytes
|
||||
|
||||
ls -lh your_file.pcm
|
||||
```
|
||||
|
||||
### 使用FFmpeg播放测试
|
||||
|
||||
```bash
|
||||
# 如果能听到声音,说明格式正确
|
||||
ffplay -f s16le -ar 16000 -ac 1 output.pcm
|
||||
```
|
||||
|
||||
### 检查base64长度
|
||||
|
||||
```bash
|
||||
# base64后的大小约为原始数据的 1.37 倍
|
||||
# 96,000 bytes → 约 131,000 字符的base64
|
||||
wc -c output_base64.txt
|
||||
```
|
||||
|
||||
## 常见问题
|
||||
|
||||
### Q: 我的base64太长了怎么办?
|
||||
A: 建议音频不要超过5秒,或者使用文件路径方式(选项B)
|
||||
|
||||
### Q: 如何确认生成的数据格式正确?
|
||||
A:
|
||||
1. 用ffplay播放测试
|
||||
2. 检查文件大小是否符合公式
|
||||
3. 查看控制台日志,看是否有格式错误
|
||||
|
||||
### Q: 为什么必须是16000Hz?
|
||||
A: 后端的STT服务要求16000Hz采样率
|
||||
|
||||
### Q: 可以用WAV文件吗?
|
||||
A: 不行!必须是纯PCM数据,不能有WAV文件头
|
||||
|
||||
### Q: 我应该说什么内容?
|
||||
A: 随便说什么都可以,主要是测试流程。如果想测试识别准确度,建议说清晰的普通话
|
||||
|
||||
## 下一步
|
||||
|
||||
准备好数据后:
|
||||
|
||||
1. 填入 `config.js` 的 `testAudioBase64` 或 `testAudioPath`
|
||||
2. 设置 `enabled: true`
|
||||
3. 运行项目,进入语音模式
|
||||
4. 点击 "🧪 发送测试音频" 按钮
|
||||
5. 观察控制台日志和结果
|
||||
|
||||
---
|
||||
|
||||
**需要帮助?** 检查浏览器控制台的 `[TestMode]` 日志输出。
|
||||
|
||||
168
测试数据快速生成命令.md
Normal file
168
测试数据快速生成命令.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# 🚀 测试数据快速生成命令
|
||||
|
||||
## 一键生成(最快方式)
|
||||
|
||||
### 方式1: 从现有音频转换
|
||||
|
||||
```bash
|
||||
# 假设你有一个 input.mp3 文件
|
||||
ffmpeg -i input.mp3 -f s16le -acodec pcm_s16le -ar 16000 -ac 1 test.pcm
|
||||
|
||||
# 转为base64(一行,无换行)
|
||||
base64 test.pcm | tr -d '\n' > 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('<h', sample))
|
||||
|
||||
# 保存PCM
|
||||
with open('test.pcm', 'wb') as f:
|
||||
f.write(data)
|
||||
|
||||
# 输出base64
|
||||
b64 = base64.b64encode(data).decode()
|
||||
print(f"生成完成!大小: {len(data)} bytes")
|
||||
print(f"Base64长度: {len(b64)} 字符")
|
||||
print(f"\n请复制以下内容到 config.js:")
|
||||
print(f"testAudioBase64: '{b64[:100]}...(太长,已截断,请从test_base64.txt读取)'")
|
||||
|
||||
with open('test_base64.txt', 'w') as f:
|
||||
f.write(b64)
|
||||
```
|
||||
|
||||
运行:
|
||||
```bash
|
||||
python generate_test.py
|
||||
```
|
||||
|
||||
## 配置到项目
|
||||
|
||||
### 打开配置文件
|
||||
|
||||
```bash
|
||||
# 打开编辑器
|
||||
code webUI/src/utils/config.js
|
||||
# 或
|
||||
vim webUI/src/utils/config.js
|
||||
```
|
||||
|
||||
### 填入数据
|
||||
|
||||
```javascript
|
||||
TEST_MODE: {
|
||||
enabled: true,
|
||||
|
||||
// 方式A: 粘贴base64(适合小文件,<200KB)
|
||||
testAudioBase64: '此处粘贴你的base64字符串',
|
||||
|
||||
// 方式B: 使用文件路径(适合大文件)
|
||||
testAudioPath: '/static/test_audio.pcm',
|
||||
}
|
||||
```
|
||||
|
||||
## 验证数据
|
||||
|
||||
### 播放测试PCM
|
||||
|
||||
```bash
|
||||
# Mac/Linux
|
||||
ffplay -f s16le -ar 16000 -ac 1 test.pcm
|
||||
|
||||
# 或使用aplay (Linux)
|
||||
aplay -f S16_LE -r 16000 -c 1 test.pcm
|
||||
```
|
||||
|
||||
### 检查大小
|
||||
|
||||
```bash
|
||||
# 应该接近: 时长(秒) × 32000 bytes
|
||||
ls -lh test.pcm
|
||||
```
|
||||
|
||||
## 格式速查
|
||||
|
||||
```
|
||||
格式: PCM
|
||||
采样率: 16000 Hz
|
||||
位深度: 16 bit (Signed)
|
||||
声道: 1 (Mono)
|
||||
字节序: Little Endian
|
||||
公式: 时长(秒) × 32000 = 文件大小(bytes)
|
||||
```
|
||||
|
||||
## 示例:完整流程
|
||||
|
||||
```bash
|
||||
# 1. 录制或转换音频
|
||||
ffmpeg -i my_voice.mp3 -f s16le -acodec pcm_s16le -ar 16000 -ac 1 test.pcm
|
||||
|
||||
# 2. 验证(能听到声音就对了)
|
||||
ffplay -f s16le -ar 16000 -ac 1 test.pcm
|
||||
|
||||
# 3. 转base64(无换行)
|
||||
base64 test.pcm | tr -d '\n' > 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秒即可):
|
||||
|
||||
- "你好"
|
||||
- "今天天气怎么样"
|
||||
- "给我讲个笑话"
|
||||
- "帮我查询一下"
|
||||
- 随便说点什么
|
||||
|
||||
---
|
||||
|
||||
**准备好后**,在项目中点击 🎧 进入语音模式,然后点击 "🧪 发送测试音频" 按钮测试!
|
||||
|
||||
Reference in New Issue
Block a user