feat: 优化 语音速度
This commit is contained in:
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%**。🚀
|
||||
|
||||
Reference in New Issue
Block a user