22 KiB
22 KiB
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 连接
// 新增: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. 修改音频上传逻辑
// 修改 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. 实现流式音频播放
// 音频播放队列
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
@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 核心处理流程
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 处理(复用现有)
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 调用
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 解析
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 分句逻辑
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
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 工具方法
// 构建 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 依赖配置
<!-- 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 完全结束后,才开始下一个句子
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. 优化方向
短期优化
- 缓存 TTS 结果:相同文本不重复合成
- 自适应分句:根据网络状况动态调整句子长度
- 优雅降级:API 失败时使用备用服务
长期优化
- 边缘计算:STT 迁移到设备端(Whisper.cpp)
- 模型本地化:部署私有 LLM 和 TTS
- 多模态融合:支持图片、视频输入
10. 总结
本方案通过 WebSocket 全双工通信 实现了高效的语音流式交互:
✅ 前端简单:保留现有 VAD,只需改造上传和播放逻辑
✅ 后端高效:LLM+TTS 流式串联,极低延迟
✅ 用户体验:说完话 1 秒内听到回复,接近真人对话
✅ 技术成熟:Grok、MiniMax 官方支持流式 API
最终效果:从"录音-等待-播放"进化为"流式对话",用户感知延迟降低 60-80%。🚀