feat: 优化 语音速度
This commit is contained in:
311
docs/GROK_API_DEBUG_GUIDE.md
Normal file
311
docs/GROK_API_DEBUG_GUIDE.md
Normal 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. 检查网络连接
|
||||
|
||||
---
|
||||
|
||||
**需要更多帮助?** 提供完整的错误日志和配置信息。
|
||||
|
||||
|
||||
251
docs/TTS_CONNECTION_WARMUP_TEST.md
Normal file
251
docs/TTS_CONNECTION_WARMUP_TEST.md
Normal 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. 发现的问题和改进建议
|
||||
|
||||
158
docs/VOICE_CHAT_INTERACTION.md
Normal file
158
docs/VOICE_CHAT_INTERACTION.md
Normal 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` 会捕获异常并提示用户,或使用本地模拟回复(在未登录等特定情况下)。
|
||||
|
||||
744
docs/WEBSOCKET_VOICE_STREAM_DESIGN.md
Normal file
744
docs/WEBSOCKET_VOICE_STREAM_DESIGN.md
Normal 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
183
docs/import asyncio
Normal 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())
|
||||
Reference in New Issue
Block a user