"""ASR 语音转写路由:整段 POST + 流式 WebSocket 代理(讯飞 IAT)。""" from __future__ import annotations import asyncio from uuid import UUID from fastapi import Depends, HTTPException, Request, WebSocket from ..auth import AuthConfig, verify_token def register_asr_routes(app, *, require_user, auth_cfg: AuthConfig) -> None: @app.post("/v1/asr/transcribe", tags=["asr"]) async def asr_transcribe( request: Request, user_id: UUID = Depends(require_user), ): """整段语音转写:body 为 raw PCM(16kHz/16bit/单声道/小端),返回 {"text"}。 前端麦克风按钮录音(Web Audio 采样降到 16k)→ 本端点 → 讯飞 IAT websocket (core/asr_xfyun.py)。上限 60 秒(讯飞单会话限制);XFYUN_* env 未配 → 501。 """ from core.asr_xfyun import ( MAX_PCM_BYTES, MAX_SECONDS, XfyunASRError, XfyunASRNotConfigured, transcribe, ) pcm = await request.body() if not pcm: raise HTTPException(400, "empty audio") if len(pcm) > MAX_PCM_BYTES: raise HTTPException(413, f"音频超长:单次最多 {MAX_SECONDS} 秒") try: text = await transcribe(pcm) except XfyunASRNotConfigured as e: raise HTTPException(501, str(e)) except XfyunASRError as e: raise HTTPException(502, str(e)) return {"text": text} @app.websocket("/v1/asr/stream") async def asr_stream(ws: WebSocket): """流式语音转写代理:浏览器边录边推,服务端实时转发讯飞并回推 partial 全文。 协议(浏览器 WebSocket 塞不了 Authorization header;token 走首消息而非 URL query,避免进 access log): 1. client → 文本帧 `{"token": ""}`(连上后 10s 内) 2. client → 二进制帧 = PCM 分片(16kHz/16bit/mono/小端,实时节奏); 文本帧(任意内容,约定 `{"type":"end"}`)= 说完 3. server → `{"text","final"}` 增量全文(wpgs 动态修正已在服务端合并,前端 直接整体替换显示);final=true 为最终结果,随后正常关闭 错误:`{"error": msg}` 后关闭;鉴权失败 close code 4401。引擎提前收尾 (静音超 10s / 60s 上限)同样推 final=true,前端应就此收尾。 """ from core.asr_xfyun import XfyunASRError, XfyunStream await ws.accept() try: first = await asyncio.wait_for(ws.receive_json(), timeout=10) verify_token(auth_cfg, str((first or {}).get("token") or "")) except HTTPException as e: try: await ws.send_json({"error": f"auth: {e.detail}"}) except Exception: pass await ws.close(code=4401) return except Exception: # 非 JSON 首帧 / 超时 / 断开 try: await ws.close(code=4401) except Exception: pass return async def push(text: str, final: bool): await ws.send_json({"text": text, "final": final}) stream = XfyunStream(on_text=push) try: try: await stream.start() # 未配 XFYUN_* / 连不上讯飞 → 直接报错关闭 except XfyunASRError as e: await ws.send_json({"error": str(e)}) await ws.close() return while True: # 90s 上限兜底(讯飞 60s + 静音余量):客户端挂着不说话不结束也不无限占用 msg = await asyncio.wait_for(ws.receive(), timeout=90) if msg.get("type") == "websocket.disconnect": return # 客户端取消/刷新:直接丢弃会话 if msg.get("bytes"): await stream.feed(msg["bytes"]) elif msg.get("text") is not None: break # 唯一文本指令:end try: await stream.finish() # 最终文本已由 recv loop 经 push(final=True) 推出 except XfyunASRError as e: await ws.send_json({"error": str(e)}) await ws.close() except Exception: # 客户端断开 / 发送失败等:静默收尾(recv loop 的错误已尽力 push 过) pass finally: await stream.close()