108 lines
4.4 KiB
Python
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()
|