feat: 优化 语音速度

This commit is contained in:
liqupan
2025-12-06 22:41:44 +08:00
parent c20aca3da0
commit c82d24ddae
20 changed files with 3942 additions and 33 deletions

View File

@@ -0,0 +1,311 @@
# 🔍 Grok API 调试指南
## 数据格式说明
### WebClient 处理后的数据格式
Spring WebFlux 的 `WebClient.bodyToFlux(String.class)` 会自动处理 SSE 流,每个元素直接就是一个完整的 JSON 字符串:
```json
{
"id": "8670de6a-75b3-97e2-fa5a-c2e3d7f1d0f2",
"object": "chat.completion.chunk",
"created": 1764858564,
"model": "grok-4-1-fast-non-reasoning",
"choices": [{
"index": 0,
"delta": {
"content": "你",
"role": "assistant"
},
"finish_reason": null
}],
"system_fingerprint": "fp_174298dd8e"
}
```
### 字段说明
| 字段 | 说明 | 示例值 |
|------|------|--------|
| `id` | 请求唯一ID | `"8670de6a-75b3-97e2-fa5a-c2e3d7f1d0f2"` |
| `object` | 对象类型 | `"chat.completion.chunk"` |
| `created` | 创建时间戳 | `1764858564` |
| `model` | 使用的模型 | `"grok-4-1-fast-non-reasoning"` |
| `choices[0].index` | 选择索引 | `0` |
| `choices[0].delta.content` | 当前token内容 | `"你"` |
| `choices[0].delta.role` | 角色(首次出现) | `"assistant"` |
| `choices[0].finish_reason` | 结束原因 | `null`, `"stop"`, `"length"`, `"content_filter"` |
| `system_fingerprint` | 系统指纹 | `"fp_174298dd8e"` |
## finish_reason 说明
| 值 | 含义 | 处理建议 |
|----|------|----------|
| `null` | 流还在继续 | 继续接收token |
| `"stop"` | 正常结束 | ✅ 回复完整 |
| `"length"` | 达到最大token限制 | ⚠️ 回复可能被截断,建议增加 `max_tokens` |
| `"content_filter"` | 内容被过滤 | ⚠️ 回复包含敏感内容 |
## 解析流程
```
收到数据 → 跳过空行 → 检查 [DONE] → 去除 "data: " 前缀(如果有)
解析 JSON → 验证 choices 字段 → 获取 choices[0]
检查 delta 字段 → 提取 content → 回调 onToken(content)
检查 finish_reason → 记录日志 → 完成
```
## 日志级别配置
`application.yml` 中配置:
```yaml
logging:
level:
com.xiaozhi.dialogue.llm.GrokStreamService: DEBUG # 或 TRACE
```
### 各级别输出内容
#### TRACE最详细
```
TRACE - 收到原始SSE数据: {"id":"8670de6a...
TRACE - 提取到token: 你
TRACE - 提取到token: 好
```
#### DEBUG
```
DEBUG - 请求体: {"model":"grok-4-1-fast-non-reasoning",...}
DEBUG - 收到角色信息: assistant
DEBUG - JSON中缺少choices字段如果有问题
```
#### INFO
```
INFO - 开始调用Grok API - Model: grok-4-1-fast-non-reasoning, UserMessage: 你好...
INFO - 流结束原因: length
INFO - Grok API流式调用完成
```
#### ERROR
```
ERROR - JSON解析失败 - 原始数据: xxx
ERROR - LLM调用失败: Connection refused
```
## 常见问题排查
### 问题1: 没有收到任何token
**可能原因**
- API Key 错误
- 网络连接问题
- API URL 配置错误
**检查步骤**
1. 查看日志是否有 `INFO - 开始调用Grok API`
2. 检查是否有错误日志
3. 验证 `application.yml` 中的配置:
```yaml
xiaozhi:
voice-stream:
grok:
api-key: ${GROK_API_KEY}
api-url: https://api.x.ai/v1/chat/completions
```
### 问题2: token乱码或格式错误
**检查点**
1. 启用 TRACE 日志查看原始数据
2. 确认 JSON 格式是否正确
3. 查看是否有 `ERROR - JSON解析失败` 日志
### 问题3: 回复被截断
**日志特征**
```
INFO - 流结束原因: length
```
**解决方案**
增加 `DEFAULT_MAX_TOKENS` 常量:
```java
private static final int DEFAULT_MAX_TOKENS = 4000; // 或更大
```
### 问题4: WebClient 连接超时
**错误日志**
```
ERROR - LLM调用失败: Connection timeout
```
**解决方案**
在 `initWebClient()` 中增加超时配置:
```java
this.webClient = WebClient.builder()
.baseUrl(apiUrl)
.codecs(configurer -> configurer.defaultCodecs().maxInMemorySize(10 * 1024 * 1024))
.defaultHeader(HttpHeaders.ACCEPT, "text/event-stream")
.build();
```
## 测试建议
### 1. 使用 curl 测试 API
```bash
curl -X POST https://api.x.ai/v1/chat/completions \
-H "Authorization: Bearer YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "grok-4-1-fast-non-reasoning",
"messages": [{"role": "user", "content": "你好"}],
"stream": true
}'
```
预期输出:
```
data: {"id":"xxx","object":"chat.completion.chunk",...}
data: {"id":"xxx","object":"chat.completion.chunk",...}
data: [DONE]
```
### 2. 检查配置
```java
// 在 GrokStreamService 中添加测试方法
@PostConstruct
public void testConfig() {
logger.info("Grok配置:");
logger.info(" API URL: {}", voiceStreamConfig.getGrok().getApiUrl());
logger.info(" Model: {}", voiceStreamConfig.getGrok().getModel());
logger.info(" API Key配置: {}", voiceStreamConfig.getGrok().getApiKey() != null ? "已配置" : "未配置");
}
```
### 3. 单元测试
```java
@Test
public void testParseSSE() {
String testJson = """
{
"id": "test",
"choices": [{
"delta": {
"content": "测试"
}
}]
}
""";
// 测试解析逻辑
}
```
## 性能监控
### 关键指标
1. **首 token 延迟**:从发送请求到收到第一个 token 的时间
2. **token 吞吐率**:每秒收到的 token 数量
3. **总响应时间**:从开始到收到 [DONE] 的时间
### 添加监控日志
```java
private long startTime;
private int tokenCount = 0;
public void streamChat(...) {
startTime = System.currentTimeMillis();
// ...
.doOnNext(token -> {
tokenCount++;
if (tokenCount == 1) {
long firstTokenLatency = System.currentTimeMillis() - startTime;
logger.info("首token延迟: {}ms", firstTokenLatency);
}
})
.doOnComplete(() -> {
long totalTime = System.currentTimeMillis() - startTime;
double tps = tokenCount / (totalTime / 1000.0);
logger.info("总计: {}个token, 耗时: {}ms, 吞吐率: {:.2f} tokens/s",
tokenCount, totalTime, tps);
})
}
```
## 调试技巧
### 1. 保存原始响应
```java
// 在parseSSE中
logger.trace("原始响应: {}", line);
```
### 2. 使用断点调试
在以下位置设置断点:
- `parseSSE()` 方法入口
- `sink.next(content)` 之前
- `callback.onToken()` 调用处
### 3. 模拟测试
创建测试类:
```java
@Test
public void testGrokStreamService() {
GrokStreamService service = new GrokStreamService();
service.streamChat("你好", null, new TokenCallback() {
@Override
public void onToken(String token) {
System.out.println("Token: " + token);
}
@Override
public void onComplete() {
System.out.println("完成");
}
@Override
public void onError(String error) {
System.err.println("错误: " + error);
}
});
// 等待完成
Thread.sleep(10000);
}
```
## 总结
正常工作流程:
1. ✅ 看到 `INFO - 开始调用Grok API`
2. ✅ 看到多个 `TRACE - 提取到token: xxx`
3. ✅ 看到 `INFO - 流结束原因: stop`(或 length
4. ✅ 看到 `INFO - Grok API流式调用完成`
如果出现问题:
1. 检查日志级别是否足够详细
2. 查找 ERROR 级别的日志
3. 验证配置是否正确
4. 使用 curl 直接测试 API
5. 检查网络连接
---
**需要更多帮助?** 提供完整的错误日志和配置信息。

View File

@@ -0,0 +1,251 @@
# TTS连接预热优化测试指南
## 测试目标
验证TTS WebSocket连接预热和复用功能确保
1. 用户连接时成功预热TTS连接
2. 多句话复用同一个TTS连接
3. 连接断开后自动重连
4. 用户断开时正确清理连接
5. 性能提升符合预期
## 测试环境准备
1. 启动后端服务
2. 确保MiniMax TTS API配置正确
3. 使用微信小程序或WebSocket客户端
## 测试用例
### 测试1连接预热验证
**目的**验证用户连接时是否成功预热TTS连接
**步骤**
1. 前端连接到语音流WebSocket`ws://your-server:8091/ws/voice-stream`
2. 观察后端日志
**预期日志**
```
[VoiceStreamHandler] 语音流WebSocket连接建立 - SessionId: xxx, UserId: null
[VoiceStreamService] 预热TTS连接 - SessionId: xxx
[MinimaxTtsStreamService] 开始预热TTS连接 - SessionId: xxx
[MinimaxTtsStreamService] MiniMax TTS连接已建立 - SessionId: xxx
[MinimaxTtsStreamService] 收到connected_success发送task_start - SessionId: xxx
[MinimaxTtsStreamService] 收到task_started连接就绪 - SessionId: xxx
[MinimaxTtsStreamService] TTS连接预热成功 - SessionId: xxx, 耗时: XXXms
[VoiceStreamHandler] TTS连接预热成功 - SessionId: xxx
```
**成功标准**
- ✅ 连接建立后立即开始预热
- ✅ 预热在1-2秒内完成
- ✅ 连接状态变为IDLE
---
### 测试2连接复用验证
**目的**验证多句话是否复用同一个TTS连接
**步骤**
1. 确保已建立连接并预热成功
2. 发送一段音频(用户说:"你好,今天天气怎么样?"
3. 等待AI回复多句话例如"你好!今天天气很好。阳光明媚。"
4. 观察后端日志
**预期日志**
```
[VoiceStreamService] STT识别结果 - SessionId: xxx, Text: 你好,今天天气怎么样?
[VoiceStreamService] 检测到完整句子 - SessionId: xxx, Sentence: 你好!
[MinimaxTtsStreamService] 使用已有TTS连接 - SessionId: xxx, Text: 你好!
[MinimaxTtsStreamService] 缓冲音频块 - SessionId: xxx: 2048 bytes, 总计: 2048 bytes
...
[MinimaxTtsStreamService] TTS完成 - SessionId: xxx, Text: 你好!, 音频总大小: 24576 bytes
[MinimaxTtsStreamService] TTS完成 - SessionId: xxx, 耗时: 300ms复用连接节省约1秒
[VoiceStreamService] 检测到完整句子 - SessionId: xxx, Sentence: 今天天气很好。
[MinimaxTtsStreamService] 使用已有TTS连接 - SessionId: xxx, Text: 今天天气很好。
[MinimaxTtsStreamService] TTS完成 - SessionId: xxx, 耗时: 250ms复用连接节省约1秒
```
**成功标准**
- ✅ 日志显示"使用已有TTS连接"而不是"创建MiniMax TTS连接"
- ✅ 每句TTS完成时间在200-500ms而非1-1.5s
- ✅ 日志显示"复用连接节省约1秒"
- ✅ 没有重复的连接建立日志
---
### 测试3自动重连验证
**目的**:验证连接断开后是否自动重连
**步骤**
1. 正常建立连接
2. 手动关闭MiniMax TTS WebSocket连接模拟网络断开
3. 发送一段音频触发TTS
4. 观察后端日志
**预期日志**
```
[MinimaxTtsStreamService] MiniMax TTS连接关闭 - SessionId: xxx, Code: 1006, Reason: ..., Remote: true
[MinimaxTtsStreamService] 尝试自动重连TTS - SessionId: xxx, 第1次重连
[MinimaxTtsStreamService] 开始预热TTS连接 - SessionId: xxx
[MinimaxTtsStreamService] TTS连接预热成功 - SessionId: xxx, 耗时: XXXms
[MinimaxTtsStreamService] 使用已有TTS连接 - SessionId: xxx, Text: ...
```
**成功标准**
- ✅ 检测到连接断开
- ✅ 自动触发重连最多3次
- ✅ 重连成功后可正常使用
- ✅ 重连失败3次后停止尝试
---
### 测试4连接清理验证
**目的**验证用户断开时是否正确清理TTS连接
**步骤**
1. 建立连接并预热成功
2. 前端主动断开WebSocket连接
3. 观察后端日志
**预期日志**
```
[VoiceStreamHandler] 语音流WebSocket连接关闭 - SessionId: xxx, Status: CloseStatus[code=1000, reason=null]
[VoiceStreamService] 关闭TTS连接 - SessionId: xxx
[MinimaxTtsStreamService] 关闭TTS连接 - SessionId: xxx
[MinimaxTtsStreamService] 发送task_finish - SessionId: xxx
[MinimaxTtsStreamService] MiniMax TTS连接关闭 - SessionId: xxx, Code: 1000, Reason: ...
```
**成功标准**
- ✅ 正确发送task_finish
- ✅ 正常关闭TTS连接
- ✅ 从连接池中移除
- ✅ 无内存泄漏
---
### 测试5性能对比验证
**目的**:验证性能提升是否符合预期
**测试场景**AI回复3句话
**修改前性能**
- 首句延迟1-1.5秒
- 第二句延迟1-1.5秒
- 第三句延迟1-1.5秒
- **总延迟**3-4.5秒
**修改后预期性能**
- 首句延迟0.5秒仅TTS合成
- 第二句延迟0.2-0.3秒
- 第三句延迟0.2-0.3秒
- **总延迟**0.9-1.1秒
**性能提升**:约 **65-70%**
**验证方法**
1. 在日志中记录每句TTS的开始和结束时间
2. 计算总耗时
3. 与预期值对比
---
### 测试6并发测试
**目的**:验证多用户同时使用时的连接管理
**步骤**
1. 创建3-5个WebSocket连接模拟多用户
2. 每个连接同时发送音频
3. 观察后端日志和性能
**成功标准**
- ✅ 每个用户有独立的TTS连接
- ✅ 连接之间互不影响
- ✅ 无连接混乱
- ✅ 性能稳定
---
## 性能监控指标
在日志中关注以下关键指标:
1. **连接建立时间**`TTS连接预热成功 - SessionId: xxx, 耗时: XXXms`
2. **连接复用次数**:统计"使用已有TTS连接"出现次数
3. **TTS合成时间**`TTS完成 - SessionId: xxx, 耗时: XXXms`
4. **重连次数**`尝试自动重连TTS - SessionId: xxx, 第X次重连`
5. **连接状态变化**观察状态从CONNECTING → CONNECTED → TASK_STARTED → IDLE的转换
---
## 常见问题排查
### 问题1预热失败
**现象**`TTS连接预热失败 - SessionId: xxx`
**可能原因**
- MiniMax API配置错误
- 网络连接问题
- API密钥过期
**解决方法**
- 检查application.yml中的MiniMax配置
- 验证API密钥是否有效
- 检查网络连接
### 问题2连接未复用
**现象**每次TTS都显示"创建MiniMax TTS连接"
**可能原因**
- 连接状态管理错误
- 连接在使用前被关闭
**解决方法**
- 检查连接状态日志
- 确认is_final后状态设为IDLE而非关闭
### 问题3自动重连失败
**现象**重连3次后仍无法恢复
**可能原因**
- MiniMax服务问题
- 网络持续不稳定
**解决方法**
- 检查MiniMax服务状态
- 降级为按需创建连接
---
## 测试清单
- [ ] 测试1连接预热验证
- [ ] 测试2连接复用验证
- [ ] 测试3自动重连验证
- [ ] 测试4连接清理验证
- [ ] 测试5性能对比验证
- [ ] 测试6并发测试
- [ ] 检查无内存泄漏
- [ ] 检查无连接泄漏
- [ ] 压力测试10+并发用户)
---
## 测试结论
完成所有测试后,记录:
1. 实际性能提升百分比
2. 连接复用成功率
3. 自动重连成功率
4. 发现的问题和改进建议

View File

@@ -0,0 +1,158 @@
# 语音对话接口交互文档
本文档详细描述了前端 `webUI` 与后端 `server` 之间关于语音对话功能(`/voice-chat`)的交互流程、参数传递及返回值结构。
## 1. 概述
语音对话功能允许用户录制一段语音,前端将其上传至后端。后端依次执行以下操作:
1. **STT (Speech-to-Text)**:将语音转换为文本。
2. **LLM (Large Language Model)**:将识别出的文本作为输入,获取 AI 的文本回复。
3. **TTS (Text-to-Speech)**:将 AI 的文本回复转换为语音。
最终后端将用户识别文本、AI 回复文本及合成的语音数据一次性返回给前端。
## 2. 前端调用 (WebUI)
前端主要涉及的文件为 `webUI/src/components/ChatBox.vue``webUI/src/utils/api.js`
### 2.1 调用方式
`ChatBox.vue` 中,当用户完成录音后,调用 `handleVoiceModeMessage` 方法,进而调用 `voiceAPI.voiceChat`
底层通过 `uni.uploadFile` 发起 `multipart/form-data` 类型的 POST 请求。
### 2.2 请求参数
前端向后端发送的请求包含 **文件****表单数据 (FormData)**
* **URL**: `/api/chat/voice-chat` (由 `config.js` 中的 `VOICE_CHAT` 常量定义)
* **Method**: `POST`
* **Header**: `Authorization: Bearer <token>`
* **文件部分**:
* `name`: `"audio"`
* `filePath`: 录音文件的本地路径 (e.g., `.aac``.wav`)
* **表单数据 (FormData)**:
| 参数名 | 类型 | 必填 | 说明 | 来源 |
| :--- | :--- | :--- | :--- | :--- |
| `sessionId` | String | 否 | 会话 ID用于保持上下文 | `conversationId.value` |
| `modelId` | Integer | 否 | 模型 ID | `characterConfig.modelId` |
| `templateId` | Integer | 否 | 模板 ID | `characterConfig.templateId` |
| `voiceStyle` | String | 否 | 语音风格 (用于 TTS) | `options.voiceStyle` |
| `ttsConfigId` | Integer | 否 | TTS 配置 ID | `aiConfig.ttsId` |
| `sttConfigId` | Integer | 否 | STT 配置 ID | `aiConfig.sttId` |
| `useFunctionCall` | Boolean | 否 | 是否使用函数调用 | 默认为 `false` |
### 2.3 响应处理
前端接收到后端返回的 JSON 数据后,进行如下解析:
1. **用户文本**: 从 `sttResult.text` 获取,显示在聊天界面右侧。
2. **AI 回复**: 从 `llmResult.response` 获取,显示在聊天界面左侧。
3. **语音播放**: 优先使用 `ttsResult.audioBase64` (Base64 编码音频),如果没有则使用 `ttsResult.audioPath` (音频 URL) 进行播放。
---
## 3. 后端处理 (Server)
后端入口为 `server/src/main/java/com/xiaozhi/controller/ChatController.java`,核心逻辑在 `ChatSessionServiceImpl.java`
### 3.1 接口定义
* **Controller**: `ChatController`
* **Path**: `/api/chat/voice-chat`
* **Consumes**: `multipart/form-data`
### 3.2 处理流程
1. **接收文件**: 后端支持字段名为 `audioFile`, `file`, 或 `audio` 的文件上传 (前端使用 `audio`)。
2. **参数解析**: 解析 `sessionId`, `modelId` 等参数。
3. **文件验证**: 检查文件大小 (1KB - 50MB)、格式 (支持 mp3, wav, m4a, aac 等) 和 MIME 类型。
4. **音频处理**:
* 将上传的音频文件保存为临时文件。
* 使用 `AudioUtils` 将音频转换为 **PCM 16k 单声道** 格式(适配 STT 引擎)。
5. **业务逻辑 (`ChatSessionService.voiceChat`)**:
* **STT**: 调用配置的 STT 服务识别语音,得到 `recognizedText`
* **LLM**: 如果识别到文本,调用 `syncChat` 获取 AI 回复 `chatResponse`
* **TTS**: 调用 TTS 服务将 AI 回复转换为语音,生成音频文件并读取为 Base64。
6. **结果封装**: 将 STT、LLM、TTS 的结果封装到 Map 中返回。
---
## 4. 接口规范
### 4.1 请求结构
**POST** `/api/chat/voice-chat`
**Content-Type**: `multipart/form-data`
**Body**:
* `audio`: [二进制文件数据]
* `sessionId`: "session_12345"
* `modelId`: 10
* `templateId`: 6
* ...
### 4.2 响应结构
**Content-Type**: `application/json`
```json
{
"code": 200,
"message": "语音对话成功",
"data": {
"sessionId": "session_12345",
"timestamp": 1717660000000,
// 1. STT 结果
"sttResult": {
"text": "你好,请介绍一下你自己。", // 用户语音识别结果
"audioSize": 32000,
"sttProvider": "vosk"
},
// 2. LLM 结果
"llmResult": {
"response": "你好我是蔚AI很高兴为你服务。", // AI 回复文本
"inputText": "你好,请介绍一下你自己。"
},
// 3. TTS 结果
"ttsResult": {
"audioBase64": "UklGRi...", // Base64 编码的音频数据 (用于直接播放)
"audioPath": "audio/output/...", // 服务器音频文件路径
"timestamp": 1717660005000
},
// 性能统计 (耗时: ms)
"sttDuration": 500,
"llmDuration": 1200,
"ttsDuration": 800,
// 文件元数据
"originalFileName": "temp_audio.aac",
"fileSize": 15000,
"contentType": "audio/aac",
"description": null
}
}
```
### 4.3 错误响应
```json
{
"code": 400, // 或 500
"message": "请求参数错误: 音频文件不能为空",
"data": null
}
```
## 5. 总结
* **交互模式**: 同步一次性交互。前端发送音频,等待后端完成所有处理(识别+对话+合成)后,一次性接收所有数据。
* **音频格式**: 前端通常录制 `aac``wav`,后端统一转码为 `pcm` 进行处理。
* **回退机制**: 如果后端处理失败,前端 `ChatBox.vue` 会捕获异常并提示用户,或使用本地模拟回复(在未登录等特定情况下)。

View File

@@ -0,0 +1,744 @@
# WebSocket 实时语音流式对话架构设计
## 1. 方案概述
本方案设计了一套**"伪实时"语音交互系统**,结合前端 VAD语音活动检测和后端流式处理实现低延迟的语音对话体验。
### 核心特点
- **前端保持简单**:复用现有 VAD 逻辑,用户说完话才上传完整音频
- **后端流式处理**STT 同步处理LLM+TTS 流式串联
- **极低感知延迟**:用户说完话后 1 秒内听到第一句回复
### 技术选型
- **前端通信**WebSocket (双向实时通信)
- **STT**:复用现有 Vosk/其他 STT 服务(同步处理完整音频)
- **LLM**Grok API (`https://api.x.ai/v1/chat/completions`) + SSE Stream
- **TTS**MiniMax WebSocket TTS (`wss://api.minimax.io/ws/v1/t2a_v2`) + Stream
---
## 2. 整体架构流程
```
┌─────────────┐
│ 前端 UI │
└──────┬──────┘
│ 1. 用户说话
┌─────────────────┐
│ RecorderManager │ (VAD 检测说完)
│ (PCM/AAC) │
└────────┬────────┘
│ 2. WebSocket 发送完整音频 (二进制)
┌────────────────────┐
│ 后端 WebSocket │
│ Handler │
└────────┬───────────┘
↓ 3. 调用 STT (同步)
┌────────────────┐
│ STT Service │ → "你好,今天天气怎么样?"
│ (复用现有) │
└────────┬───────┘
↓ 4. 流式调用 Grok LLM (HTTP SSE)
┌─────────────────────────┐
│ Grok API Stream │
│ https://api.x.ai/v1/... │
└────────┬────────────────┘
│ Token 流: "今", "天", "天", "气", "很", "好", "。", ...
┌────────────────────┐
│ 分句缓冲器 │ (检测标点)
└────────┬───────────┘
│ 完整句子: "今天天气很好。"
↓ 5. 为每句调用 MiniMax TTS (WebSocket Stream)
┌─────────────────────────────┐
│ MiniMax TTS WebSocket │
│ wss://api.minimax.io/ws/... │
└────────┬────────────────────┘
│ 音频流 (Hex → Bytes)
↓ 6. 实时转发给前端 (WebSocket 二进制)
┌────────────────────┐
│ 前端 WebSocket │
│ onMessage │
└────────┬───────────┘
│ 7. WebAudioContext 实时播放
┌────────────────────┐
│ 音频播放队列 │
│ (无缝连续播放) │
└────────────────────┘
```
---
## 3. 前端改造方案 (ChatBox.vue)
### 3.1 保留的部分
**现有 VAD 逻辑**
- `recorderManager` 配置
- `onStart``onFrameRecorded`(用于波形可视化)
- `onStop`(核心触发点)
- `calculateVolumeRMS``vadConfig`(静音检测)
### 3.2 需要修改的部分
#### A. 建立 WebSocket 连接
```javascript
// 新增WebSocket 连接管理
const voiceWebSocket = ref(null);
const connectVoiceWebSocket = () => {
const wsUrl = `ws://192.168.3.13:8091/ws/voice-stream`;
voiceWebSocket.value = uni.connectSocket({
url: wsUrl,
success: () => {
console.log('WebSocket 连接成功');
}
});
// 监听消息(接收音频流)
voiceWebSocket.value.onMessage((res) => {
if (res.data instanceof ArrayBuffer) {
// 收到音频数据,加入播放队列
playAudioChunk(res.data);
} else {
// JSON 控制消息
const msg = JSON.parse(res.data);
if (msg.event === 'stt_done') {
addMessage('user', msg.text);
voiceState.value = 'thinking';
} else if (msg.event === 'llm_start') {
voiceState.value = 'speaking';
}
}
});
voiceWebSocket.value.onError((err) => {
console.error('WebSocket 错误:', err);
});
};
// 进入语音模式时连接
const toggleVoiceMode = () => {
if (isVoiceMode.value) {
connectVoiceWebSocket();
} else {
voiceWebSocket.value?.close();
}
};
```
#### B. 修改音频上传逻辑
```javascript
// 修改 handleVoiceModeMessage
const handleVoiceModeMessage = async (filePath) => {
if (!isVoiceMode.value) return;
voiceState.value = 'thinking';
try {
// 读取录音文件为 ArrayBuffer
const fs = uni.getFileSystemManager();
const audioData = await new Promise((resolve, reject) => {
fs.readFile({
filePath: filePath,
success: (res) => resolve(res.data),
fail: reject
});
});
// 通过 WebSocket 发送音频
voiceWebSocket.value.send({
data: audioData,
success: () => {
console.log('音频已发送');
},
fail: (err) => {
console.error('发送失败:', err);
}
});
} catch (error) {
console.error('处理音频失败:', error);
voiceState.value = 'idle';
}
};
```
#### C. 实现流式音频播放
```javascript
// 音频播放队列
const audioQueue = ref([]);
const isPlayingAudio = ref(false);
const playAudioChunk = (arrayBuffer) => {
audioQueue.value.push(arrayBuffer);
if (!isPlayingAudio.value) {
processAudioQueue();
}
};
const processAudioQueue = async () => {
if (audioQueue.value.length === 0) {
isPlayingAudio.value = false;
// 播放完成,回到待机状态
if (isVoiceMode.value && isAutoVoiceMode.value) {
startVoiceRecording();
} else {
voiceState.value = 'idle';
}
return;
}
isPlayingAudio.value = true;
const chunk = audioQueue.value.shift();
// 使用 InnerAudioContext (需先转为临时文件)
const fs = uni.getFileSystemManager();
const tempPath = `${wx.env.USER_DATA_PATH}/temp_audio_${Date.now()}.mp3`;
fs.writeFile({
filePath: tempPath,
data: chunk,
encoding: 'binary',
success: () => {
const audio = uni.createInnerAudioContext();
audio.src = tempPath;
audio.onEnded(() => {
processAudioQueue(); // 播放下一块
});
audio.onError(() => {
processAudioQueue(); // 出错也继续
});
audio.play();
}
});
};
```
---
## 4. 后端实现方案 (Java)
### 4.1 WebSocket Handler
```java
@ServerEndpoint(value = "/ws/voice-stream")
@Component
public class VoiceStreamHandler {
private static final Logger logger = LoggerFactory.getLogger(VoiceStreamHandler.class);
@Autowired
private SttService sttService;
@OnOpen
public void onOpen(Session session) {
logger.info("WebSocket 连接建立: {}", session.getId());
}
@OnMessage
public void onBinaryMessage(ByteBuffer audioBuffer, Session session) {
logger.info("收到音频数据: {} bytes", audioBuffer.remaining());
// 转为 byte[]
byte[] audioData = new byte[audioBuffer.remaining()];
audioBuffer.get(audioData);
// 异步处理(避免阻塞 WebSocket 线程)
CompletableFuture.runAsync(() -> {
processVoiceStream(audioData, session);
});
}
@OnClose
public void onClose(Session session) {
logger.info("WebSocket 连接关闭: {}", session.getId());
}
@OnError
public void onError(Session session, Throwable error) {
logger.error("WebSocket 错误: {}", session.getId(), error);
}
}
```
### 4.2 核心处理流程
```java
private void processVoiceStream(byte[] audioData, Session session) {
try {
// ========== 1. STT (同步处理) ==========
long sttStart = System.currentTimeMillis();
String recognizedText = performStt(audioData);
long sttDuration = System.currentTimeMillis() - sttStart;
if (recognizedText == null || recognizedText.trim().isEmpty()) {
sendJsonMessage(session, Map.of("event", "error", "message", "未识别到语音"));
return;
}
logger.info("STT 完成 ({}ms): {}", sttDuration, recognizedText);
// 通知前端识别结果
sendJsonMessage(session, Map.of(
"event", "stt_done",
"text", recognizedText,
"duration", sttDuration
));
// ========== 2. LLM Stream + TTS Stream (串联) ==========
streamLlmAndTts(recognizedText, session);
} catch (Exception e) {
logger.error("处理语音流失败", e);
sendJsonMessage(session, Map.of("event", "error", "message", e.getMessage()));
}
}
```
### 4.3 STT 处理(复用现有)
```java
private String performStt(byte[] audioData) {
try {
// 转换为 PCM 16k 单声道
byte[] pcmData = AudioUtils.bytesToPcm(audioData);
// 调用 STT 服务(复用现有逻辑)
String text = sttService.recognition(pcmData);
return text != null ? text.trim() : "";
} catch (Exception e) {
logger.error("STT 处理失败", e);
return "";
}
}
```
### 4.4 Grok LLM Stream 调用
```java
private void streamLlmAndTts(String userText, Session frontendSession) {
// 分句缓冲器
StringBuilder sentenceBuffer = new StringBuilder();
// 使用 WebClient 调用 Grok SSE API
WebClient client = WebClient.builder()
.baseUrl("https://api.x.ai")
.defaultHeader("Authorization", "Bearer " + grokApiKey)
.build();
sendJsonMessage(frontendSession, Map.of("event", "llm_start"));
client.post()
.uri("/v1/chat/completions")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of(
"model", "grok-beta",
"messages", List.of(
Map.of("role", "system", "content", "你是一个友好的AI助手"),
Map.of("role", "user", "content", userText)
),
"stream", true,
"temperature", 0.7
))
.retrieve()
.bodyToFlux(String.class) // SSE 流
.subscribe(
// onNext: 处理每个 SSE chunk
sseChunk -> {
String token = parseGrokToken(sseChunk);
if (token != null && !token.isEmpty()) {
sentenceBuffer.append(token);
// 检测句子结束
if (isSentenceEnd(sentenceBuffer.toString())) {
String sentence = sentenceBuffer.toString().trim();
sentenceBuffer.setLength(0); // 清空
logger.info("检测到完整句子: {}", sentence);
// 调用 TTS 并流式发送
streamTtsToFrontend(sentence, frontendSession);
}
}
},
// onError
error -> {
logger.error("Grok LLM Stream 错误", error);
sendJsonMessage(frontendSession, Map.of("event", "error", "message", "LLM 处理失败"));
},
// onComplete
() -> {
// 如果还有剩余内容,也发送
if (sentenceBuffer.length() > 0) {
String lastSentence = sentenceBuffer.toString().trim();
if (!lastSentence.isEmpty()) {
streamTtsToFrontend(lastSentence, frontendSession);
}
}
sendJsonMessage(frontendSession, Map.of("event", "llm_complete"));
logger.info("LLM 处理完成");
}
);
}
```
### 4.5 Grok SSE 解析
```java
private String parseGrokToken(String sseChunk) {
// SSE 格式: data: {"choices":[{"delta":{"content":"你好"}}]}\n\n
if (!sseChunk.startsWith("data: ")) {
return null;
}
String jsonData = sseChunk.substring(6).trim();
if (jsonData.equals("[DONE]")) {
return null;
}
try {
JsonObject json = JsonParser.parseString(jsonData).getAsJsonObject();
JsonArray choices = json.getAsJsonArray("choices");
if (choices != null && choices.size() > 0) {
JsonObject delta = choices.get(0).getAsJsonObject().getAsJsonObject("delta");
if (delta != null && delta.has("content")) {
return delta.get("content").getAsString();
}
}
} catch (Exception e) {
logger.warn("解析 Grok token 失败: {}", sseChunk);
}
return null;
}
```
### 4.6 分句逻辑
```java
private boolean isSentenceEnd(String text) {
// 中英文标点检测
String trimmed = text.trim();
if (trimmed.isEmpty()) return false;
char lastChar = trimmed.charAt(trimmed.length() - 1);
// 中文标点
if (lastChar == '。' || lastChar == '' || lastChar == '' ||
lastChar == '' || lastChar == '') {
return true;
}
// 英文标点
if (lastChar == '.' || lastChar == '!' || lastChar == '?') {
return true;
}
// 防止句子过长超过50字强制分句
if (trimmed.length() > 50) {
return true;
}
return false;
}
```
### 4.7 MiniMax TTS WebSocket Stream
```java
private void streamTtsToFrontend(String text, Session frontendSession) {
try {
URI uri = new URI("wss://api.minimax.io/ws/v1/t2a_v2");
WebSocketClient ttsClient = new WebSocketClient(uri) {
private boolean taskStarted = false;
@Override
public void onOpen(ServerHandshake handshake) {
logger.info("MiniMax TTS 连接成功");
}
@Override
public void onMessage(String message) {
try {
JsonObject msg = JsonParser.parseString(message).getAsJsonObject();
String event = msg.get("event").getAsString();
if ("connected_success".equals(event)) {
// 发送 task_start
send(buildTaskStartJson());
} else if ("task_started".equals(event)) {
// 发送 task_continue
taskStarted = true;
send(buildTaskContinueJson(text));
} else if (msg.has("data")) {
JsonObject data = msg.getAsJsonObject("data");
if (data.has("audio")) {
String hexAudio = data.get("audio").getAsString();
// 转为字节数组
byte[] audioBytes = hexToBytes(hexAudio);
// 立即转发给前端
frontendSession.getAsyncRemote().sendBinary(
ByteBuffer.wrap(audioBytes)
);
logger.debug("转发音频数据: {} bytes", audioBytes.length);
}
// 检查是否结束
if (msg.has("is_final") && msg.get("is_final").getAsBoolean()) {
send("{\"event\":\"task_finish\"}");
close();
}
}
} catch (Exception e) {
logger.error("处理 TTS 消息失败", e);
}
}
@Override
public void onError(Exception ex) {
logger.error("MiniMax TTS 错误", ex);
}
@Override
public void onClose(int code, String reason, boolean remote) {
logger.info("MiniMax TTS 连接关闭");
}
};
// 添加认证头
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", "Bearer " + minimaxApiKey);
ttsClient.setHttpHeaders(headers);
// 连接阻塞等待最多5秒
boolean connected = ttsClient.connectBlocking(5, TimeUnit.SECONDS);
if (!connected) {
throw new RuntimeException("连接 MiniMax TTS 超时");
}
} catch (Exception e) {
logger.error("调用 MiniMax TTS 失败", e);
sendJsonMessage(frontendSession, Map.of("event", "error", "message", "TTS 处理失败"));
}
}
```
### 4.8 工具方法
```java
// 构建 task_start JSON
private String buildTaskStartJson() {
return """
{
"event": "task_start",
"model": "speech-2.6-hd",
"voice_setting": {
"voice_id": "male-qn-qingse",
"speed": 1.0,
"vol": 1.0,
"pitch": 0
},
"audio_setting": {
"sample_rate": 32000,
"bitrate": 128000,
"format": "mp3",
"channel": 1
}
}
""";
}
// 构建 task_continue JSON
private String buildTaskContinueJson(String text) {
JsonObject json = new JsonObject();
json.addProperty("event", "task_continue");
json.addProperty("text", text);
return json.toString();
}
// Hex 转字节数组
private byte[] hexToBytes(String hex) {
int len = hex.length();
byte[] data = new byte[len / 2];
for (int i = 0; i < len; i += 2) {
data[i / 2] = (byte) ((Character.digit(hex.charAt(i), 16) << 4)
+ Character.digit(hex.charAt(i+1), 16));
}
return data;
}
// 发送 JSON 消息给前端
private void sendJsonMessage(Session session, Map<String, Object> data) {
try {
String json = new Gson().toJson(data);
session.getAsyncRemote().sendText(json);
} catch (Exception e) {
logger.error("发送消息失败", e);
}
}
```
---
## 5. Maven 依赖配置
```xml
<!-- Spring Boot WebSocket -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<!-- WebClient (用于 Grok SSE) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<!-- Java WebSocket Client (用于 MiniMax) -->
<dependency>
<groupId>org.java-websocket</groupId>
<artifactId>Java-WebSocket</artifactId>
<version>1.5.3</version>
</dependency>
<!-- JSON 处理 -->
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
</dependency>
```
---
## 6. 关键技术难点与解决方案
### 6.1 并发控制
**问题**:多个句子的 TTS 如何保证顺序?
**解决方案**
- 使用 **信号量 (Semaphore)****串行队列**
- 一个句子的 TTS 完全结束后,才开始下一个句子
```java
private final Semaphore ttsSemaphore = new Semaphore(1);
private void streamTtsToFrontend(String text, Session frontendSession) {
try {
ttsSemaphore.acquire(); // 获取锁
// ... TTS 处理 ...
} finally {
ttsSemaphore.release(); // 释放锁
}
}
```
### 6.2 Grok SSE 连接稳定性
**问题**SSE 长连接可能中断。
**解决方案**
- 设置超时和重试机制
- 使用 `Flux.retry(3)` 自动重试
### 6.3 前端音频播放卡顿
**问题**:网络抖动导致音频断续。
**解决方案**
- 实现 **Jitter Buffer**(缓冲 3-5 个音频块再开始播放)
- 检测队列长度,动态调整播放速率
### 6.4 音频格式兼容性
**问题**:小程序对 PCM 格式支持不佳。
**解决方案**
- MiniMax 配置输出 `mp3` 格式(压缩率高,兼容性好)
- 前端直接播放 MP3 无需解码
---
## 7. 性能指标预估
| 阶段 | 预估耗时 | 说明 |
|---|---|---|
| **前端 VAD** | 实时 | 用户说话期间持续检测 |
| **音频上传** | 50-200ms | 取决于网络和文件大小 |
| **STT 处理** | 300-800ms | Vosk 本地处理较快 |
| **LLM 首 Token** | 500-1000ms | Grok 响应速度 |
| **TTS 首块音频** | 200-500ms | MiniMax WebSocket 延迟 |
| **首次播放延迟** | **1-2秒** | 用户说完到听到回复 |
---
## 8. 测试建议
### 8.1 单元测试
- STT 服务独立测试
- Grok API 调用测试Mock SSE 流)
- MiniMax TTS 调用测试Mock WebSocket
### 8.2 集成测试
- 完整语音流测试(录音 -> STT -> LLM -> TTS -> 播放)
- 并发测试(多用户同时对话)
- 异常场景网络中断、API 超时)
### 8.3 压力测试
- 模拟 100 并发用户
- 监控服务器 CPU、内存、网络 I/O
- 检查 WebSocket 连接泄漏
---
## 9. 优化方向
### 短期优化
1. **缓存 TTS 结果**:相同文本不重复合成
2. **自适应分句**:根据网络状况动态调整句子长度
3. **优雅降级**API 失败时使用备用服务
### 长期优化
1. **边缘计算**STT 迁移到设备端Whisper.cpp
2. **模型本地化**:部署私有 LLM 和 TTS
3. **多模态融合**:支持图片、视频输入
---
## 10. 总结
本方案通过 **WebSocket 全双工通信** 实现了高效的语音流式交互:
**前端简单**:保留现有 VAD只需改造上传和播放逻辑
**后端高效**LLM+TTS 流式串联,极低延迟
**用户体验**:说完话 1 秒内听到回复,接近真人对话
**技术成熟**Grok、MiniMax 官方支持流式 API
最终效果:**从"录音-等待-播放"进化为"流式对话"**,用户感知延迟降低 **60-80%**。🚀

183
docs/import asyncio Normal file
View File

@@ -0,0 +1,183 @@
import asyncio
import websockets
import json
import ssl
import subprocess
import os
model = "speech-2.6-hd"
file_format = "mp3"
class StreamAudioPlayer:
def __init__(self):
self.mpv_process = None
def start_mpv(self):
"""Start MPV player process"""
try:
mpv_command = ["mpv", "--no-cache", "--no-terminal", "--", "fd://0"]
self.mpv_process = subprocess.Popen(
mpv_command,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
print("MPV player started")
return True
except FileNotFoundError:
print("Error: mpv not found. Please install mpv")
return False
except Exception as e:
print(f"Failed to start mpv: {e}")
return False
def play_audio_chunk(self, hex_audio):
"""Play audio chunk"""
try:
if self.mpv_process and self.mpv_process.stdin:
audio_bytes = bytes.fromhex(hex_audio)
self.mpv_process.stdin.write(audio_bytes)
self.mpv_process.stdin.flush()
return True
except Exception as e:
print(f"Play failed: {e}")
return False
return False
def stop(self):
"""Stop player"""
if self.mpv_process:
if self.mpv_process.stdin and not self.mpv_process.stdin.closed:
self.mpv_process.stdin.close()
try:
self.mpv_process.wait(timeout=20)
except subprocess.TimeoutExpired:
self.mpv_process.terminate()
async def establish_connection(api_key):
"""Establish WebSocket connection"""
url = "wss://api.minimax.io/ws/v1/t2a_v2"
headers = {"Authorization": f"Bearer {api_key}"}
ssl_context = ssl.create_default_context()
ssl_context.check_hostname = False
ssl_context.verify_mode = ssl.CERT_NONE
try:
ws = await websockets.connect(url, additional_headers=headers, ssl=ssl_context)
connected = json.loads(await ws.recv())
if connected.get("event") == "connected_success":
print("Connection successful")
return ws
return None
except Exception as e:
print(f"Connection failed: {e}")
return None
async def start_task(websocket):
"""Send task start request"""
start_msg = {
"event": "task_start",
"model": model,
"voice_setting": {
"voice_id": "male-qn-qingse",
"speed": 1,
"vol": 1,
"pitch": 0,
"english_normalization": False
},
"audio_setting": {
"sample_rate": 32000,
"bitrate": 128000,
"format": file_format,
"channel": 1
}
}
await websocket.send(json.dumps(start_msg))
response = json.loads(await websocket.recv())
return response.get("event") == "task_started"
async def continue_task_with_stream_play(websocket, text, player):
"""Send continue request and stream play audio"""
await websocket.send(json.dumps({
"event": "task_continue",
"text": text
}))
chunk_counter = 1
total_audio_size = 0
audio_data = b""
while True:
try:
response = json.loads(await websocket.recv())
if "data" in response and "audio" in response["data"]:
audio = response["data"]["audio"]
if audio:
print(f"Playing chunk #{chunk_counter}")
audio_bytes = bytes.fromhex(audio)
if player.play_audio_chunk(audio):
total_audio_size += len(audio_bytes)
audio_data += audio_bytes
chunk_counter += 1
if response.get("is_final"):
print(f"Audio synthesis completed: {chunk_counter-1} chunks")
if player.mpv_process and player.mpv_process.stdin:
player.mpv_process.stdin.close()
# Save audio to file
with open(f"output.{file_format}", "wb") as f:
f.write(audio_data)
print(f"Audio saved as output.{file_format}")
estimated_duration = total_audio_size * 0.0625 / 1000
wait_time = max(estimated_duration + 5, 10)
return wait_time
except Exception as e:
print(f"Error: {e}")
break
return 10
async def close_connection(websocket):
"""Close connection"""
if websocket:
try:
await websocket.send(json.dumps({"event": "task_finish"}))
await websocket.close()
except Exception:
pass
async def main():
API_KEY = os.getenv("MINIMAX_API_KEY")
TEXT = "The real danger is not that computers start thinking like people, but that people start thinking like computers. Computers can only help us with simple tasks."
player = StreamAudioPlayer()
try:
if not player.start_mpv():
return
ws = await establish_connection(API_KEY)
if not ws:
return
if not await start_task(ws):
print("Task startup failed")
return
wait_time = await continue_task_with_stream_play(ws, TEXT, player)
await asyncio.sleep(wait_time)
except Exception as e:
print(f"Error: {e}")
finally:
player.stop()
if 'ws' in locals():
await close_connection(ws)
if __name__ == "__main__":
asyncio.run(main())