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,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%**。🚀