zcbot/web/routers/asr.py

108 lines
4.4 KiB
Python

"""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": "<jwt>"}`(连上后 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()