feat(voice): real-time streaming STT with FunASR + WebSocket - #3
Merged
Conversation
- git-workflow.md: Plexus Git 工作流规范(分支策略、commit 格式、合并策略) - docs/research/stt-selection-report.json: 数字人面试场景 STT 选型深度研究 - CLAUDE.md: 新增 Git Workflow 小节,引用 git-workflow.md
为流式 STT 链路引入 FunASR (SenseVoice-Small): - funasr>=1.1.0: 阿里达摩院开源 ASR 工具包,支持 SenseVoice / Paraformer / Paraformer-large - torch>=2.0.0: FunASR 的运行时依赖(CPU 模式即可,不需要 GPU) 注意: - 首次启动会从 HF 镜像下载 SenseVoice-Small 模型 (~230MB) - torch 会让 Docker 镜像增加 ~1.5GB,建议后续用多阶段构建优化 - 当前保留 faster-whisper 不删,作为 HTTP 整段模式的 fallback
为流式 STT 链路添加配置和错误码: VoiceInterviewSettings 新增字段: - funasr_model: 默认 iic/SenseVoiceSmall(中文 CER 7.81%) - funasr_device: cpu/cuda - funasr_quantize: int8 量化开关 - funasr_hf_endpoint: 模型下载镜像 - streaming_stt_chunk_ms: 每帧 200ms - streaming_stt_sample_rate: 16kHz - streaming_stt_max_session_seconds: 单次会话 10 分钟硬上限 ErrorCode 新增: - STT_STREAM_ERROR = 1005(流式 STT WebSocket 异常)
新增 app/modules/interview/voice_streaming_service.py: - VoiceStreamingService (lazy singleton, 线程安全双检锁) - STTEvent / STTEventType (partial / final / error) - stream_transcribe 异步生成器:消费 PCM chunks,周期 partial + 流尾 final 核心设计: - 滚动窗口推理(最近 5s)+ 1s 推理间隔,单次推理量固定,O(n) 复杂度 - 进程内 FunASR (funasr.AutoModel),不开独立 funasr-server - asyncio.to_thread 跑阻塞推理,不阻塞事件循环 - 模型用 model_lock 串行化(FunASR 非线程安全) - 连续 3 次错误终止 stream - 单会话 10 分钟硬上限(可配) 为什么用滚动窗口而不是流式原生模型: - SenseVoice-Small 是非流式模型,全量推理是 O(n²) - 滚动窗口把单次推理量固定在 5s 音频,CPU 可控 - partial 文本随窗口滚动自然增长,体感边说边出字 测试:commit 4 写 unit test (mock _transcribe_sync)
新增 tests/test_voice_streaming_service.py,8 个测试覆盖: - 正常流:多个 partial + 1 个 final - 重复 partial 去重 - 连续错误达到上限后终止 - session 超时主动终止 - 空音频:无 partial 也不发 final - SenseVoice 标签清理 - 事件 to_dict 序列化 - 默认 sample_rate Mock 策略:monkeypatch 服务实例的 _transcribe_sync 方法(不真加载 FunASR) 将 _INFER_INTERVAL_MS 调到 10ms 让测试快;max_session 调到 0.1s 测超时 不需要 funasr / torch 也能跑
新增 app/modules/interview/ws_router.py: - /api/interview/voice/stream WebSocket 端点 - 鉴权:客户端传 ?token=<jwt>,handler 内手动 decode(HTTP auth_middleware 不覆盖 WS) - 首帧可发 JSON start 控制消息,也可直接推 PCM - 后续 binary 帧:Int16 LE 单声道 PCM - JSON end 或 disconnect 终止流 - 服务端通过 voice_streaming_service 跑识别,逐事件 send_json main.py: - 新增 interview_ws_router 注册(prefix=/api/interview) - 与 interview_router 同一 prefix,tag group 面试 WebSocket WS close code:1008 鉴权失败 / 1011 server error / 1000 正常关闭 这是项目第一个 WebSocket 端点,建立 WS 鉴权 / 帧处理 / 错误传播范式
测试驱动发现两个边界 bug: voice_streaming_service.py:流结束时空 buffer 不发 final,客户端无法可靠判断流结束 改为始终发 final(空文本 OK) ws_router.py:end 帧作为首帧时 chunker 死等导致 hang 改为 end 作为首帧时直接发 final 然后关闭
新增 tests/test_voice_stream_ws.py,6 个测试覆盖: - 无 token:WS close 1008 - 无效 token:WS close 1008 - 有效 token:start + pcm → partial → final - end 帧无数据:直接 final - 无 start 帧直接 PCM:也能正常处理 - service 异常:error 事件 用 TestClient.websocket_connect 模拟浏览器 调短 _INFER_INTERVAL_MS 让测试快(10ms) 不需要真 FunASR / 数据库 同时修复 tests/test_voice_streaming_service.py 的 empty_audio 测试以匹配新行为(始终发 final)
新增 frontend/src/utils/voice.ts: - VOICE_ERROR_MESSAGES: DOMException → 中文提示 - getPreferredAudioMimeType: 浏览器支持的 audio MIME 选择 - getAudioFileExtension: MIME → 文件扩展名 - cleanTranscript: 文本空白归一化 - PCM_SAMPLE_RATE: 16kHz(与后端 streaming_stt_sample_rate 一致) InterviewPage.tsx:删除本地 4 个 const 定义,改为从 ../utils/voice 导入 行为不变(纯函数搬迁) 下一步:commit 8 AudioWorklet / commit 9 WS 客户端 / commit 10 useVoiceInput hook 都会复用这些工具函数
新增 frontend/public/audio-worklets/pcm-capture.js:
在浏览器音频线程里运行,从 MediaStream 抓 Float32 帧,
复制后通过 port.postMessage 转发到主线程(必须复制避免下一帧覆盖)。
主线程负责的逻辑(不在 worklet 里做):
- Float32 → Int16 转换
- 48kHz → 16kHz 重采样(AudioContext 默认 48kHz 设备率)
- 按 200ms 切片
- WebSocket 推送
为什么不放 worklet:
- AudioContext sampleRate 跟设备/浏览器有关
- 重采样放主线程更易调试 / 单测
- worklet 只做'把原始帧搬出音频线程'这一件事
用法(主线程):
```ts
const ctx = new AudioContext();
await ctx.audioWorklet.addModule('/audio-worklets/pcm-capture.js');
const node = new AudioWorkletNode(ctx, 'pcm-capture');
node.port.onmessage = (e) => { /* Float32Array, native rate */ };
```
文件用 vanilla JS(不是 TS)— Vite 通过 public/ 直接 serve,worklet URL 是 /audio-worklets/pcm-capture.js
新增两个文件:
frontend/src/types/voiceStream.ts:
- STTEvent discriminated union(partial / final / error)
- STTStartMessage / STTEndMessage 控制消息
- VoiceStreamListeners 回调接口
- VoiceStreamState 状态枚举
frontend/src/api/voiceStream.ts:
- VoiceStreamClient 类:连接 / 推 PCM / 收事件
- URL:${apiUrl('/api/interview/voice/stream')}?token=<jwt>
- 开发走 Vite 代理
- 生产同源
- 鉴权用 query param(浏览器 WS API 不支持设 header)
- connect 返回 Promise,open 后自动发 start
- sendAudio 接受 ArrayBuffer / Uint8Array
- endStream 发 end 帧触发服务端 final
- 消息分发:partial/final/error 触发对应 callback
- close() 主动断开
不做自动重连 — 上层 hook 决定降级到 batch 模式还是提示用户
不做音频采集 — 上层 hook 负责 AudioContext / AudioWorklet
新增 frontend/src/hooks/useVoiceInput.ts:
设计要点:
- 一套 hook 暴露 batch + stream 两种模式(按 options.mode 切换)
- 状态机:idle → recording/streaming → transcribing(仅 batch)→ idle
↓
error → 1.5s 后回 idle
- onCommit 在用户接受结果时调(流式 final / 批式 transcribe 完成)
- 错误用 ref 缓存(onError / onCommit 不入 useEffect 依赖,避免重连)
stream 模式:
- getUserMedia → AudioContext (默认 48kHz) → MediaStreamSource → AudioWorklet
- 48kHz Float32 → 16kHz Int16 抽取(按 sourceSR/16k 比例取样)
- 200ms 切片 → wsClient.sendAudio(ArrayBuffer)
- wsClient 自动在 onOpen 后发 start
- onFinal 时自动 onCommit + 转回 idle
- onClose 时如果还在 streaming 状态则回 idle
batch 模式:
- getUserMedia → MediaRecorder (webm/opus → webm → ogg → mp4 回退)
- 1s timeslice 累积
- stop 时组装 Blob → interviewApi.transcribeVoice → onCommit
错误处理:
- 鉴权失败 / WS 失败 / ASR 错误 / 设备无权限 → 统一走 enterError
- 1.5s 后自动回 idle,UI 可继续点
清理:
- useEffect 卸载时调 cancel,释放麦克风/AudioContext/WS
- cancel 也清掉所有 timer 和 ref
新增两个 UI 组件,从 InterviewPage 抽出便于复用: VoiceMicButton: - 5 态视觉:idle 灰 / recording 红 / streaming 蓝 / transcribing 黄 / error 红 - 图标:Mic / MicOff / Loader2 (spinning) / AlertCircle - data-voice-state 属性便于 e2e 测试断言 - disabled 支持 VoiceStatusLine: - 紧凑行内显示:放在 textarea 旁边 - recording: '录音中 0:12' - streaming: '正在识别:<partial>' 或 '正在识别 0:05' - transcribing: '正在转写...' (spinning) - error: 错误中文提示 - idle 状态不渲染 - role='status' aria-live='polite' 无障碍支持 下一步 commit 12:在 InterviewPage 两个模式(dynamic/classic)都接入新 hook 和组件
…in InterviewPage InterviewPage.tsx 改造: - 删掉本地 voice state/refs (voiceState / voiceError / recordingSeconds / mediaRecorderRef / mediaStreamRef / audioChunksRef / recordingTimerRef / shouldTranscribeRef) - 删掉老的 voice 函数 (clearRecordingTimer / releaseVoiceStream / transcribeAudioBlob / startVoiceRecording / stopVoiceRecording) - 改用 useVoiceInput hook(mode='stream'),onCommit 直接调 appendTranscript - toggleVoiceInput 简化为:idle/error → start,recording/streaming → stop - 删掉 Mic / MicOff icon 导入(现在由 VoiceMicButton 内部用) - cleanup useEffect 留空(hook 自己管理麦克风/AudioContext/WS 清理) - handleSubmit 守卫从 voiceState 改成 voice.voiceState UI 替换(dynamic + classic 两个模式都做): - 原 5 段式 inline button (Mic/MicOff + '停止录音'/'语音输入' + 转写中 + 录音中 Ns + 错误文字) 全部删 - 替换为 <VoiceMicButton /> + <VoiceStatusLine /> 两个独立组件 - 减少约 70 行重复 voice UI 代码 行为变化: - 之前是整段录音 → 上传 → 转写,首字延迟 5-30s - 现在流式 STT,partial 实时显示在 VoiceStatusLine 上 - 失败时由 hook 处理,UI 仍可继续点 - onCommit 回调让文本直接 append 到答案框
vite.config.ts 在 /api proxy 加 ws: true,让开发环境的 WebSocket
upgrade 能转给后端 :8002。
不加这一行的话,浏览器 new WebSocket('ws://localhost:5173/api/interview/voice/stream')
会被 Vite 当 HTTP 走,连接建立后会一直 hang。
生产 nginx 配类似:在 location /api/ 加
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
(计划中已记录,nginx.conf 改动后续 PR 单独做)
在 LLM Configuration 后新增 'Voice / STT' 小节,说明: - 流式(主用):WS /api/interview/voice/stream + FunASR SenseVoice-Small - 整段(fallback):POST /api/interview/voice/transcribe + faster-whisper - WS 鉴权机制(auth_middleware 不覆盖 WS,handler 内手动 decode) - 引用 docs/research/stt-selection-report.json 选型分析
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
把面试语音输入从 faster-whisper 整段上传 升级为 FunASR SenseVoice-Small + WebSocket 流式分块。
Why
数字人面试场景对实时对话有强需求:
选型报告:docs/research/stt-selection-report.json
How
后端
app/modules/interview/voice_streaming_service.py:FunASR lazy singleton + 滚动窗口推理(最近 5s 音频、1s 推理间隔)app/modules/interview/ws_router.py:项目第一个 WebSocket 端点,JWT 鉴权(query param?token=<jwt>)main.py(prefix=/api/interview,tag '面试 WebSocket')POST /voice/transcribe整段模式作为 fallback前端
useVoiceInputhook(batch + stream 两态合一,状态机 idle → recording/streaming → transcribing → idle)VoiceMicButton/VoiceStatusLine组件,替换 InterviewPage 里 dynamic + classic 两个模式约 70 行重复 voice UIpublic/audio-worklets/pcm-capture.js) 抓 48kHz Float32 → 主线程抽取到 16kHz Int16 → WS 推 200ms 切片vite.config.ts加ws: true让 dev 代理支持 WebSocket upgrade测试
tests/test_voice_streaming_service.py:8 个 unit test(mock FunASR,覆盖 partial/final/错误/超时/标签清理)tests/test_voice_stream_ws.py:6 个 contract test(auth 失败/正常流/end 帧/无 start 帧/service 错误)影响
/voice/transcribe整段接口不破坏既有调用方ws: true生效Checklist