Files
server/docs/WEBSOCKET_VOICE_STREAM_DESIGN.md
2025-12-06 22:41:44 +08:00

22 KiB
Raw Blame History

WebSocket 实时语音流式对话架构设计

1. 方案概述

本方案设计了一套**"伪实时"语音交互系统**,结合前端 VAD语音活动检测和后端流式处理实现低延迟的语音对话体验。

核心特点

  • 前端保持简单:复用现有 VAD 逻辑,用户说完话才上传完整音频
  • 后端流式处理STT 同步处理LLM+TTS 流式串联
  • 极低感知延迟:用户说完话后 1 秒内听到第一句回复

技术选型

  • 前端通信WebSocket (双向实时通信)
  • STT:复用现有 Vosk/其他 STT 服务(同步处理完整音频)
  • LLMGrok API (https://api.x.ai/v1/chat/completions) + SSE Stream
  • TTSMiniMax 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 配置
  • onStartonFrameRecorded(用于波形可视化)
  • onStop(核心触发点)
  • calculateVolumeRMSvadConfig(静音检测)

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. 优化方向

短期优化

  1. 缓存 TTS 结果:相同文本不重复合成
  2. 自适应分句:根据网络状况动态调整句子长度
  3. 优雅降级API 失败时使用备用服务

长期优化

  1. 边缘计算STT 迁移到设备端Whisper.cpp
  2. 模型本地化:部署私有 LLM 和 TTS
  3. 多模态融合:支持图片、视频输入

10. 总结

本方案通过 WebSocket 全双工通信 实现了高效的语音流式交互:

前端简单:保留现有 VAD只需改造上传和播放逻辑
后端高效LLM+TTS 流式串联,极低延迟
用户体验:说完话 1 秒内听到回复,接近真人对话
技术成熟Grok、MiniMax 官方支持流式 API

最终效果:从"录音-等待-播放"进化为"流式对话",用户感知延迟降低 60-80%🚀