feat(web): 语音输入流式化:边说边出字,松手秒出最终文本(bump 0.49.0)

根因:整段模式按 40ms/帧回放音频给讯飞 = 按录音时长等比例白等(16.8s 实测等 4.6s)。

- 新增 WS /v1/asr/stream 流式代理:首消息 JWT 鉴权(不走 query 防进 access log),
  二进制帧实时转发讯飞(dwa=wpgs),动态修正(sn/pgs/rg)在服务端 XfyunStream 合并,
  回推 {"text","final"} 增量全文;引擎提前收尾(静音/60s)推 final 前端自动结束
- 前端:录音即连 WS,增量降采样(carry 跨片连续)实时上行,录音面板实时字幕;
  WS 失败静默退回整段 POST 兜底。实测 16.8s 音频 end→final 179ms(原 ~2.5s+)
- 整段兜底路径帧间隔 40ms→8ms(实测 4.6s→1.75s,识别文本一致)
- nginx 示例加 /v1/asr/stream WS Upgrade 段;RUN 故障兜底补讯飞 IP 白名单 403 与
  反代挡 WS 两条

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-07 15:42:20 +08:00
parent 67aa87e58d
commit e9e549b606
8 changed files with 364 additions and 31 deletions

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-07 / 0.49.0**:语音输入流式化(边说边出字,消掉松手后停顿)——用户报"录完停顿几秒才出字",根因:整段模式按讯飞文档 40ms/帧节奏回放音频 = 按录音时长等比例白等(16.8s 音频实测等 4.6s)。改双通道:① **流式主通道** `WS /v1/asr/stream`——录音开始即连(首消息 JWT 鉴权,不走 query 防进 access log),前端增量降采样(carry 缓冲跨片连续)实时上行,服务端 `XfyunStream` 转发讯飞(dwa=wpgs)并**在服务端合并动态修正**(sn/pgs/rg),回推 partial 全文,录音面板实时字幕,松手只等最终帧——实测 16.8s 音频 **end→final 179ms**(61 个增量帧,文本与整段一致);引擎提前收尾(静音 10s/60s 上限)推 final 前端自动结束。② **整段 POST 兜底**(WS 连不上/中途挂/超时),帧间隔实测调优 40ms→8ms(4.6s→1.75s,文本一致,已到讯飞处理地板)。nginx 示例加 `location = /v1/asr/stream` WS Upgrade 段(不配不坏,退兜底);踩坑:讯飞控制台 IP 白名单(握手 403 "Your IP address is not allowed"),进 RUN 故障兜底。
- **07-07 / 0.48.1**:语音手势两端统一(用户提出 PC 也要能按住说话)——不再按 pointerType 分流,改按**按下时长**判别,PC 鼠标/手机触屏同一心智模型:按住 ≥300ms = 按住说话(松开即转写,上滑取消);快速点一下 <300ms = 转成连续录音免持(说完 Enter / 再点结束,Esc 取消;鼠标按住 60s 会累且易失手,长口述场景必须保留免持)。实现要点:press 发起的录音松手后合成 click 全压掉("松开转写完又开新录音"/"短按刚转免持被自己 click 停掉"),"点按钮停止连续录音" press 不压(`_pressActive` 区分);getUserMedia 完成前松手按同一时长规则收尾;键盘激活按钮( pointer 事件)兜底走 click 起录 - **07-07 / 0.48.1**:语音手势两端统一(用户提出 PC 也要能按住说话)——不再按 pointerType 分流,改按**按下时长**判别,PC 鼠标/手机触屏同一心智模型:按住 ≥300ms = 按住说话(松开即转写,上滑取消);快速点一下 <300ms = 转成连续录音免持(说完 Enter / 再点结束,Esc 取消;鼠标按住 60s 会累且易失手,长口述场景必须保留免持)。实现要点:press 发起的录音松手后合成 click 全压掉("松开转写完又开新录音"/"短按刚转免持被自己 click 停掉"),"点按钮停止连续录音" press 不压(`_pressActive` 区分);getUserMedia 完成前松手按同一时长规则收尾;键盘激活按钮( pointer 事件)兜底走 click 起录
- **07-07 / 0.48.0**:语音输入手势升级(纯前端,后端零改动)——业界对齐调研后两端分治:① **触屏按住说话**(微信/DeepSeek 式):`pointerdown`(pointerType≠mouse)按住录音、松开转写、上滑 60px 进入"松开取消"(面板变红),pointer capture 保证滑出按钮仍收事件,touch 后合成 click 用时间窗压掉;② **桌面点击开停 + Enter/Esc 收尾**:说完按 Enter 结束转写、Esc 取消(capture 拦截防落回 textarea 触发发送)。**明确不做桌面 VAD 静音自动停**:口述需求"边想边说"停顿 3~5s 是常态,自动截断比多按一下更伤,业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾,自动停只属于 Siri 式短指令场景。③ 录音期 `#voice-rec-panel` 替换 textarea:实时波浪(采集 chunk 顺手算 RMS,1/8 抽样,零额外开销)+ 计时 + 手势提示;iOS Safari 手势内 `ctx.resume()`;授权弹窗期间松手 → 启动完成立即收尾。 - **07-07 / 0.48.0**:语音输入手势升级(纯前端,后端零改动)——业界对齐调研后两端分治:① **触屏按住说话**(微信/DeepSeek 式):`pointerdown`(pointerType≠mouse)按住录音、松开转写、上滑 60px 进入"松开取消"(面板变红),pointer capture 保证滑出按钮仍收事件,touch 后合成 click 用时间窗压掉;② **桌面点击开停 + Enter/Esc 收尾**:说完按 Enter 结束转写、Esc 取消(capture 拦截防落回 textarea 触发发送)。**明确不做桌面 VAD 静音自动停**:口述需求"边想边说"停顿 3~5s 是常态,自动截断比多按一下更伤,业界听写类(ChatGPT/Google Docs/Win+H)也全是手动收尾,自动停只属于 Siri 式短指令场景。③ 录音期 `#voice-rec-panel` 替换 textarea:实时波浪(采集 chunk 顺手算 RMS,1/8 抽样,零额外开销)+ 计时 + 手势提示;iOS Safari 手势内 `ctx.resume()`;授权弹窗期间松手 → 启动完成立即收尾。
- **07-07 / 0.47.0**:web 端语音输入(讯飞语音听写 IAT)——聊天输入区新增「🎙 语音」按钮:点击录音(Web Audio ScriptProcessor 采 PCM → 前端线性插值降采样 16k/16bit/mono)→ 再点停止 → `POST /v1/asr/transcribe`(裸 PCM body,require_user)→ 后端 `core/asr_xfyun.py` 连讯飞 wss(HMAC-SHA256 签名,9600B/帧 40ms 节奏,vad_eos=10000 防句间停顿截尾,未开 dwa 整段拼接)→ 文本 insertText 插输入框光标处(接原生 undo 栈,**不自动发送**)。60s 上限自动停(讯飞单会话限制);录音需 HTTPS/localhost;渠道只读镜像 task 按钮锁定;`XFYUN_APPID/API_KEY/API_SECRET` env 未配返 501(**当前 .env 为测试 key,待换正式**)。诊断 `scripts/diag_asr.py`(静音冒烟 / 带 PCM 文件真测);requirements 显式加 `websockets`。已实测:TTS 合成中文 5.2s 音频经端点转写逐字正确。企微语音消息(AMR 需 ffmpeg 转码)留待二期;将来流式逐字上屏可复用 `build_auth_url` 签临时 URL。 - **07-07 / 0.47.0**:web 端语音输入(讯飞语音听写 IAT)——聊天输入区新增「🎙 语音」按钮:点击录音(Web Audio ScriptProcessor 采 PCM → 前端线性插值降采样 16k/16bit/mono)→ 再点停止 → `POST /v1/asr/transcribe`(裸 PCM body,require_user)→ 后端 `core/asr_xfyun.py` 连讯飞 wss(HMAC-SHA256 签名,9600B/帧 40ms 节奏,vad_eos=10000 防句间停顿截尾,未开 dwa 整段拼接)→ 文本 insertText 插输入框光标处(接原生 undo 栈,**不自动发送**)。60s 上限自动停(讯飞单会话限制);录音需 HTTPS/localhost;渠道只读镜像 task 按钮锁定;`XFYUN_APPID/API_KEY/API_SECRET` env 未配返 501(**当前 .env 为测试 key,待换正式**)。诊断 `scripts/diag_asr.py`(静音冒烟 / 带 PCM 文件真测);requirements 显式加 `websockets`。已实测:TTS 合成中文 5.2s 音频经端点转写逐字正确。企微语音消息(AMR 需 ffmpeg 转码)留待二期;将来流式逐字上屏可复用 `build_auth_url` 签临时 URL。

5
RUN.md
View File

@ -232,7 +232,8 @@ curl --noproxy '*' -H "Authorization: Bearer $TOKEN" http://127.0.0.1:8765/v1/ta
| `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 | | `POST /v1/tasks/{id}/cancel` | 协作式 cancel;`run_status != running` → 409;LLM 走 streaming,chunk 间 poll cancel — 延迟 100ms 级,基本秒退 | 必填 |
| `POST /v1/tasks/{id}/clear` | 清空当前 task 全部 messages + reset `tasks.tokens_prompt/completion/cost_cny` 三列累计 + `run_status='idle'`;`usage_events`(账单记账)**不动**,只 `message_id` 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 | 必填 | | `POST /v1/tasks/{id}/clear` | 清空当前 task 全部 messages + reset `tasks.tokens_prompt/completion/cost_cny` 三列累计 + `run_status='idle'`;`usage_events`(账单记账)**不动**,只 `message_id` 列变 NULL;run 活跃中(running/cancelling)→ 409(先 cancel);FS 文件保留 | 必填 |
| `POST /v1/tasks/{id}/optimize_prompt` | body `{text(req, ≤4000), image_model?=""}`;同步调当前 task model 润色草稿,返 `{optimized, model_profile, tokens_in, tokens_out, cost_cny}`;**不**写 messages、**不**累计 task 三列(顶栏数字不污染),只在 `usage_events` 写一行 `kind="prompt_optimize"`(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) | 必填 | | `POST /v1/tasks/{id}/optimize_prompt` | body `{text(req, ≤4000), image_model?=""}`;同步调当前 task model 润色草稿,返 `{optimized, model_profile, tokens_in, tokens_out, cost_cny}`;**不**写 messages、**不**累计 task 三列(顶栏数字不污染),只在 `usage_events` 写一行 `kind="prompt_optimize"`(对账可见);不与主对话 run 互斥(允许 streaming 中并行润色) | 必填 |
| `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。前端「🎙 语音」按钮用 | 必填 | | `WS /v1/asr/stream` | 流式语音转写(前端「🎙 语音」主通道):连上后首条文本帧发 `{"token":"<jwt>"}`(WS 塞不了 header,token 不走 query 防进 access log,失败 close 4401)→ 之后二进制帧 = PCM 分片(16k/16bit/mono)实时转发讯飞;任意文本帧(约定 `{"type":"end"}`)= 说完。服务端回推 `{"text","final"}` 增量全文(wpgs 动态修正已合并),final=true 为最终结果;错误回 `{"error"}` 后关闭。nginx 反代该路径需支持 WS Upgrade(`proxy_set_header Upgrade/Connection`) | 首消息 |
| `POST /v1/asr/transcribe` | body 为裸 PCM(16kHz/16bit/单声道/小端,`application/octet-stream`)→ 讯飞 IAT 整段转写,返 `{text}`;>60s → 413;`XFYUN_*` env 未配 → 501;讯飞侧错误 → 502(带错误码提示)。流式 WS 连不上时前端的兜底通道 | 必填 |
| `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 | | `GET /v1/files?path=` | 列 user_root 下条目 + 面包屑;dotfile 隐藏 | 必填 |
| `GET /v1/files/download?path=` | 下单文件 | 必填 | | `GET /v1/files/download?path=` | 下单文件 | 必填 |
| `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 | | `POST /v1/files/upload` | multipart 上传到 `<user_root>/<path>/`;路径不存在自动 mkdir,重名覆盖 | 必填 |
@ -824,6 +825,8 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) | | `NoSubtaskError: working_dir ... 前缀嵌套` | §7.4 no-subtask:同 user 不允许 working_dir 嵌套(child / parent)。**同项目多对话**用**完全相同**的 working_dir;否则改成 sibling(平级) |
| `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY``curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` | | `main.py web` 启动后 curl 连不上 | 检查 proxy(`HTTP_PROXY` / `HTTPS_PROXY`):本地服务 127.0.0.1,系统 proxy 拦截会 502。临时 `unset HTTP_PROXY HTTPS_PROXY``curl --noproxy '*'`。验通:`curl --noproxy '*' http://127.0.0.1:8765/healthz` |
| 「🎙 语音」点了没反应 / 提示"此环境不支持录音" | 浏览器只在**安全上下文**(HTTPS 或 localhost)开放麦克风;http://IP 访问必现。上 HTTPS 或本机 localhost 试。凭据/链路问题跑 `scripts/diag_asr.py` 定位(11200=讯飞服务未开通或量用完,10105/10313=appid 错) | | 「🎙 语音」点了没反应 / 提示"此环境不支持录音" | 浏览器只在**安全上下文**(HTTPS 或 localhost)开放麦克风;http://IP 访问必现。上 HTTPS 或本机 localhost 试。凭据/链路问题跑 `scripts/diag_asr.py` 定位(11200=讯飞服务未开通或量用完,10105/10313=appid 错) |
| 讯飞握手 403 `Your IP address is not allowed` | 讯飞控制台该应用开了 **IP 白名单**,当前机器出口 IP 不在名单(服务器好用、开发机忽然全挂就是这个)。console.xfyun.cn → 应用 → 语音听写 → IP 白名单加出口 IP 或关闭 |
| 语音输入没有实时字幕 / 松手后仍要等几秒 | 流式 WS(`/v1/asr/stream`)被反代挡了(nginx 未透传 Upgrade 头),前端静默退回整段 POST 兜底 —— 功能不坏但慢。nginx 加 WS 反代段(`deploy/nginx/zcbot.conf.example` 已带 `location = /v1/asr/stream`),`nginx -t && systemctl reload nginx` |
| SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`(nginx ≥1.5.6 默认认)。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` | | SSE 卡住不流(经 nginx) | 反代要关 buffering — 后端响应头已带 `X-Accel-Buffering: no`(nginx ≥1.5.6 默认认)。仍卡看 nginx 配 `proxy_buffering off; proxy_read_timeout 3600s;` |
| platform CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头 | | platform CORS preflight 失败 | 本地 dev `allow_origins=["*"]` 应该没事;部署后看是否按 platform 域名收紧过头 |
| `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完;等流式 done 或点 stop / `POST .../cancel`;服务异常下 `run_status``running`/`cancelling`,启动 reaper 会清 | | `POST /v1/tasks/{id}/messages` 返 409 `task already has an active run` | 上一条消息的 BG run 还没跑完;等流式 done 或点 stop / `POST .../cancel`;服务异常下 `run_status``running`/`cancelling`,启动 reaper 会清 |

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.48.1" __version__ = "0.49.0"

View File

@ -5,9 +5,11 @@
- 音频: 仅收 16kHz/16bit/单声道 PCM(raw),单会话上限 60 - 音频: 仅收 16kHz/16bit/单声道 PCM(raw),单会话上限 60
- 凭据: env XFYUN_APPID / XFYUN_API_KEY / XFYUN_API_SECRET(换正式 key 只改 .env) - 凭据: env XFYUN_APPID / XFYUN_API_KEY / XFYUN_API_SECRET(换正式 key 只改 .env)
本模块只做"整段 PCM → 最终文本"(web 语音输入按钮用);未开 dwa 动态修正 两条使用路径:
那是实时逐字上屏才需要的,整段转写直接按序拼接 result.ws 即可将来做流式 - `XfyunStream`:流式会话(web /v1/asr/stream 代理用) 边录边喂 PCM 分片,
上屏时复用 build_auth_url() 签临时 URL 给前端直连 dwa=wpgs 动态修正,**服务端**合并 sn/pgs/rg 增量,partial 全文经回调推给前端,
松手后只等最终帧,消掉"整段回放"的等待
- `transcribe()`:整段 PCM 最终文本(POST /v1/asr/transcribe,流式失败的兜底)
""" """
from __future__ import annotations from __future__ import annotations
@ -30,10 +32,12 @@ BYTES_PER_SECOND = SAMPLE_RATE * 2 # 16bit mono
MAX_SECONDS = 60 # 讯飞单会话硬上限 MAX_SECONDS = 60 # 讯飞单会话硬上限
MAX_PCM_BYTES = MAX_SECONDS * BYTES_PER_SECOND MAX_PCM_BYTES = MAX_SECONDS * BYTES_PER_SECOND
# 每帧 9600B(=300ms 音频),base64 后 12800B < 讯飞单帧上限 13000B; # 每帧 9600B(=300ms 音频),base64 后 12800B < 讯飞单帧上限 13000B。
# 帧间 40ms 为文档推荐节奏,整段上传比实时快 7.5 倍(60s 音频 ≈ 8s 传完) # 帧间隔:文档推荐 40ms 是给实时麦克风流的节奏;整段转写(transcribe 兜底路径)实测
# 2026-07-07(16.8s 音频):40ms→4.6s / 20ms→2.6s / 8ms→1.75s / 0ms→1.77s(识别文本
# 全一致,0ms 也不报错)—— 取 8ms:已到讯飞处理耗时地板,再压没有收益。
_FRAME_BYTES = 9600 _FRAME_BYTES = 9600
_FRAME_INTERVAL = 0.04 _FRAME_INTERVAL = 0.008
_RECV_TIMEOUT = 30 # 单次等响应上限;讯飞 10s 无数据也会主动断 _RECV_TIMEOUT = 30 # 单次等响应上限;讯飞 10s 无数据也会主动断
# 常见错误码 → 给用户/运维看的中文提示(全量见文档错误码页) # 常见错误码 → 给用户/运维看的中文提示(全量见文档错误码页)
@ -89,6 +93,142 @@ def _load_credentials() -> tuple[str, str, str]:
return appid, api_key, api_secret return appid, api_key, api_secret
class XfyunStream:
"""流式会话:实时喂 PCM 分片,partial 全文经 on_text 异步回调推出(wpgs 已合并)。
用法(web /v1/asr/stream 代理):
st = XfyunStream(on_text=cb) # await cb(text, final) 从内部 recv task 调
await st.start()
await st.feed(chunk); ... # 16k/16bit/mono PCM,任意大小(内部按帧上限分片)
text = await st.finish() # 发 status=2,等最终文本
await st.close() # 幂等清理(finally 里调)
引擎可能先于 finish 收尾(静音超 vad_eos / 60s 上限) on_text(text, True) 已推,
此后 feed 静默丢弃,finish 直接返缓存结果
"""
def __init__(self, on_text, *, language: str = "zh_cn"):
self._on_text = on_text
self._language = language
self._ws = None
self._recv_task: asyncio.Task | None = None
self._segs: dict[int, str] = {} # sn → 该段文本;pgs=rpl 按 rg 区间删旧段
self._done = asyncio.Event()
self._error: XfyunASRError | None = None
self._final_text: str | None = None
self._first = True
self._appid = ""
async def start(self) -> None:
self._appid, api_key, api_secret = _load_credentials()
import websockets
try:
self._ws = await websockets.connect(
build_auth_url(api_key, api_secret), open_timeout=10
)
except Exception as e:
raise XfyunASRError(f"讯飞连接失败:{type(e).__name__}: {e}")
self._recv_task = asyncio.create_task(self._recv_loop())
def _assemble(self, result: dict) -> str:
"""合并一帧 wpgs 结果,返回当前完整文本。"""
sn = result.get("sn") or 0
if result.get("pgs") == "rpl":
a, b = (result.get("rg") or [sn, sn])[:2]
for k in [k for k in self._segs if a <= k <= b]:
del self._segs[k]
self._segs[sn] = "".join(
cw.get("w", "")
for w in (result.get("ws") or [])
for cw in (w.get("cw") or [])
)
return "".join(self._segs[k] for k in sorted(self._segs)).strip()
async def _recv_loop(self) -> None:
try:
while True:
msg = json.loads(await self._ws.recv())
code = msg.get("code")
if code:
hint = _ERR_HINTS.get(code, msg.get("message") or "未知错误")
raise XfyunASRError(f"讯飞识别失败({code}):{hint}", code=code)
data = msg.get("data") or {}
text = self._assemble(data.get("result") or {})
final = data.get("status") == 2
if final:
self._final_text = text
await self._on_text(text, final)
if final:
return
except XfyunASRError as e:
self._error = e
except asyncio.CancelledError:
raise
except Exception as e: # 连接中断 / 回调方(客户端 ws)挂了
self._error = XfyunASRError(f"讯飞连接中断:{type(e).__name__}: {e}")
finally:
self._done.set()
def _frame(self, chunk: bytes) -> str:
frame: dict = {"data": {
"status": 0 if self._first else 1,
"format": f"audio/L16;rate={SAMPLE_RATE}",
"encoding": "raw",
"audio": base64.b64encode(chunk).decode(),
}}
if self._first:
frame["common"] = {"app_id": self._appid}
frame["business"] = {
"language": self._language,
"domain": "iat",
"accent": "mandarin",
"vad_eos": 10000,
"ptt": 1,
"dwa": "wpgs", # 动态修正:边说边出字,回改在服务端 _assemble 消化
}
self._first = False
return json.dumps(frame)
async def feed(self, chunk: bytes) -> None:
if self._done.is_set() or not chunk:
return # 引擎已收尾(提前结束/出错)→ 静默丢弃
try:
for off in range(0, len(chunk), _FRAME_BYTES):
await self._ws.send(self._frame(chunk[off : off + _FRAME_BYTES]))
except Exception:
pass # 发送失败由 recv loop 报连接错误,这里不重复抛
async def finish(self, timeout: float = 15) -> str:
if not self._done.is_set():
try:
if self._first:
await self._ws.send(self._frame(b"")) # 一帧音频都没喂过:先补参数帧
await self._ws.send(json.dumps({"data": {
"status": 2,
"format": f"audio/L16;rate={SAMPLE_RATE}",
"encoding": "raw",
"audio": "",
}}))
except Exception:
pass
try:
await asyncio.wait_for(self._done.wait(), timeout=timeout)
except asyncio.TimeoutError:
raise XfyunASRError("讯飞识别超时(结束后无最终结果)")
if self._error:
raise self._error
return self._final_text or ""
async def close(self) -> None:
if self._recv_task and not self._recv_task.done():
self._recv_task.cancel()
with suppress(asyncio.CancelledError):
await self._recv_task
if self._ws:
with suppress(Exception):
await self._ws.close()
async def transcribe(pcm: bytes, *, language: str = "zh_cn") -> str: async def transcribe(pcm: bytes, *, language: str = "zh_cn") -> str:
"""整段 PCM(16k/16bit/mono, 小端)→ 识别文本。空音频/纯静音返回 """"" """整段 PCM(16k/16bit/mono, 小端)→ 识别文本。空音频/纯静音返回 """""
appid, api_key, api_secret = _load_credentials() appid, api_key, api_secret = _load_credentials()

View File

@ -41,6 +41,23 @@ server {
# 上传接口(/v1/files/upload)会传大文件;与后端无硬限制,给足余量 # 上传接口(/v1/files/upload)会传大文件;与后端无硬限制,给足余量
client_max_body_size 512m; client_max_body_size 512m;
# ★ WebSocket(流式语音转写):必须透传 Upgrade/Connection 握手头,否则前端
# 连不上 WS、自动退回整段 POST 兜底(功能不坏但没了实时字幕 + 松手秒出)。
# 单独 location 精确匹配,不影响主 location 的 keepalive(Connection "")。
location = /v1/asr/stream {
proxy_pass http://zcbot_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
proxy_read_timeout 300s; # 单次录音 ≤60s + 余量;别沿用 3600 占着连接
proxy_send_timeout 300s;
}
location / { location / {
proxy_pass http://zcbot_backend; proxy_pass http://zcbot_backend;
proxy_http_version 1.1; proxy_http_version 1.1;

View File

@ -28,7 +28,16 @@ try:
except ImportError: # pragma: no cover - Windows except ImportError: # pragma: no cover - Windows
resource = None resource = None
from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi import (
Depends,
FastAPI,
File,
Form,
HTTPException,
Request,
UploadFile,
WebSocket,
)
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse from fastapi.responses import FileResponse, RedirectResponse, StreamingResponse
from pydantic import BaseModel from pydantic import BaseModel
@ -57,6 +66,7 @@ from .auth import (
make_require_user, make_require_user,
mint_token, mint_token,
resolve_user_by_email, resolve_user_by_email,
verify_token,
) )
from .admin import register_admin_routes from .admin import register_admin_routes
from .broker import broker from .broker import broker
@ -2883,6 +2893,71 @@ def create_app() -> FastAPI:
raise HTTPException(502, str(e)) raise HTTPException(502, str(e))
return {"text": text} 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()
# ───────────── SSE events ───────────── # ───────────── SSE events ─────────────
@app.get("/v1/tasks/{task_id}/events", tags=["tasks"]) @app.get("/v1/tasks/{task_id}/events", tags=["tasks"])

View File

@ -894,16 +894,21 @@
#chat-mic { touch-action: none; -webkit-user-select: none; user-select: none; } #chat-mic { touch-action: none; -webkit-user-select: none; user-select: none; }
#chat-mic.recording { color: #fff; background: #d33; border-color: #d33; animation: mic-pulse 1.6s ease-in-out infinite; } #chat-mic.recording { color: #fff; background: #d33; border-color: #d33; animation: mic-pulse 1.6s ease-in-out infinite; }
@keyframes mic-pulse { 50% { opacity: .65; } } @keyframes mic-pulse { 50% { opacity: .65; } }
/* 录音面板:录音期替换 textarea(波浪 + 计时 + 手势提示);上滑取消预备时整体变红 */ /* 录音面板:录音期替换 textarea(波浪 + 计时 + 手势提示 + 实时字幕);上滑取消预备时整体变红 */
#voice-rec-panel { display: none; align-items: center; gap: 10px; height: 72px; padding: 0 12px; #voice-rec-panel { display: none; flex-direction: column; gap: 2px; padding: 6px 12px;
border: 1px solid #d33; border-radius: 6px; background: #fff5f5; } border: 1px solid #d33; border-radius: 6px; background: #fff5f5; }
#chat-form.recording #voice-rec-panel { display: flex; } #chat-form.recording #voice-rec-panel { display: flex; }
#chat-form.recording #chat-input { display: none; } #chat-form.recording #chat-input { display: none; }
#voice-rec-panel .vr-row { display: flex; align-items: center; gap: 10px; height: 48px; }
#voice-rec-panel .vr-time { font-variant-numeric: tabular-nums; font-weight: 600; color: #d33; } #voice-rec-panel .vr-time { font-variant-numeric: tabular-nums; font-weight: 600; color: #d33; }
#voice-rec-panel canvas { flex: 1; height: 44px; min-width: 0; } #voice-rec-panel canvas { flex: 1; height: 40px; min-width: 0; }
#voice-rec-panel .vr-hint { font-size: 11px; color: var(--muted); white-space: nowrap; } #voice-rec-panel .vr-hint { font-size: 11px; color: var(--muted); white-space: nowrap; }
/* 实时字幕:流式转写边说边出字;空时不占位(流式不可用退回整段模式时无字幕) */
#voice-rec-panel .vr-live { font-size: 13px; line-height: 1.5; max-height: 60px; overflow-y: auto; }
#voice-rec-panel .vr-live:empty { display: none; }
#voice-rec-panel.cancel-armed { background: #d33; border-color: #b22; } #voice-rec-panel.cancel-armed { background: #d33; border-color: #b22; }
#voice-rec-panel.cancel-armed .vr-time, #voice-rec-panel.cancel-armed .vr-hint { color: #fff; } #voice-rec-panel.cancel-armed .vr-time, #voice-rec-panel.cancel-armed .vr-hint,
#voice-rec-panel.cancel-armed .vr-live { color: #fff; }
#chat-form .hint { font-size: 11px; color: var(--muted); } #chat-form .hint { font-size: 11px; color: var(--muted); }
/* 附件托盘:粘贴 / 拖拽的文件 chip 独占一行,累积展示,不与状态文字抢容器 */ /* 附件托盘:粘贴 / 拖拽的文件 chip 独占一行,累积展示,不与状态文字抢容器 */
#chat-attach { display: none; flex-wrap: wrap; gap: 4px 0; align-items: center; } #chat-attach { display: none; flex-wrap: wrap; gap: 4px 0; align-items: center; }
@ -1574,9 +1579,12 @@
<div id="msg-outline-rail" style="display:none;"></div> <div id="msg-outline-rail" style="display:none;"></div>
<form id="chat-form" style="display:none;"> <form id="chat-form" style="display:none;">
<div id="voice-rec-panel"> <div id="voice-rec-panel">
<span class="vr-time" id="vr-time">0:00</span> <div class="vr-row">
<canvas id="vr-wave"></canvas> <span class="vr-time" id="vr-time">0:00</span>
<span class="vr-hint" id="vr-hint"></span> <canvas id="vr-wave"></canvas>
<span class="vr-hint" id="vr-hint"></span>
</div>
<div class="vr-live" id="vr-live"></div>
</div> </div>
<textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"></textarea> <textarea id="chat-input" placeholder="输入消息…(Enter 发送,Shift+Enter 换行,可粘贴 / 拖拽文件)"></textarea>
<span id="chat-attach"></span> <span id="chat-attach"></span>

View File

@ -1467,10 +1467,16 @@ async function optimizePrompt() {
$("chat-optimize").onclick = optimizePrompt; $("chat-optimize").onclick = optimizePrompt;
// ── 语音输入(讯飞 IAT 整段转写)────────────────────────────────────────────── // ── 语音输入(讯飞 IAT)────────────────────────────────────────────────────────
// 采集:Web Audio ScriptProcessor 采 PCM → 停止后降采样 16k/16bit → POST // 采集:Web Audio ScriptProcessor 采 PCM。转写走双通道:
// /v1/asr/transcribe → 文本插到输入框光标处(insertText 接原生 undo 栈,不自动 // - 流式(主通道):录音开始即连 WS /v1/asr/stream(首消息发 JWT),每片音频增量降采样
// 发送)。手势两端统一(PC 鼠标 / 手机触屏同一心智模型),按下时长判别,共用同一条 // 16k/16bit 实时上行,服务端转发讯飞并回推 partial 全文(wpgs 动态修正在服务端合并),
// 录音面板 #vr-live 实时字幕;停止只需等最终帧(百 ms 级)—— 消掉旧"整段按 40ms/帧
// 回放给讯飞"造成的按时长等比例停顿。
// - 整段(兜底):WS 连不上(老服务端/网络)或中途挂 → 停止后把累积 PCM POST
// /v1/asr/transcribe(原链路保留)。
// 最终文本插到输入框光标处(insertText 接原生 undo 栈,不自动发送)。
// 手势两端统一(PC 鼠标 / 手机触屏同一心智模型),按下时长判别,共用同一条
// 采集/转写链路(录音需 HTTPS / localhost;60s 讯飞上限自动停): // 采集/转写链路(录音需 HTTPS / localhost;60s 讯飞上限自动停):
// - 按住(≥300ms)= 按住说话:松开即转写,上滑超阈值松开取消(微信 / DeepSeek 式)。 // - 按住(≥300ms)= 按住说话:松开即转写,上滑超阈值松开取消(微信 / DeepSeek 式)。
// - 快速点一下(<300ms)= 连续录音(免持,长口述不用一直按着 —— 鼠标按住 60s 会累且 // - 快速点一下(<300ms)= 连续录音(免持,长口述不用一直按着 —— 鼠标按住 60s 会累且
@ -1502,8 +1508,42 @@ function _pcm16kFromChunks(chunks, srcRate) {
return out; return out;
} }
// 增量重采样器:逐片喂 Float32 原始采样,吐 16k Int16;片间用 carry 缓冲保持插值连续
// (每片独立重采样会在拼缝处引入细微断裂,长音频里累积影响识别)。
function _makeResampler16k(srcRate) {
const ratio = srcRate / 16000;
let carry = new Float32Array(0);
return (chunk) => {
let src = chunk;
if (carry.length) {
src = new Float32Array(carry.length + chunk.length);
src.set(carry, 0);
src.set(chunk, carry.length);
}
const n = Math.max(0, Math.floor((src.length - 1) / ratio));
const out = new Int16Array(n);
for (let i = 0; i < n; i++) {
const pos = i * ratio, i0 = Math.floor(pos);
const s = src[i0] + (src[i0 + 1] - src[i0]) * (pos - i0);
out[i] = Math.max(-1, Math.min(1, s)) * 0x7fff;
}
carry = src.slice(Math.floor(n * ratio));
return out;
};
}
function _vrHint(text) { $("vr-hint").textContent = text; } function _vrHint(text) { $("vr-hint").textContent = text; }
// 转写结果统一入口:光标处插入(不覆盖已有草稿);execCommand 接 textarea 原生 undo 栈
function _insertTranscript(text) {
const ta = $("chat-input");
ta.focus();
const ok = document.execCommand("insertText", false, text);
if (!ok) ta.value += text;
syncOptimizeBtn();
$("chat-hint").textContent = "已转写,确认后发送";
}
function _drawWave() { function _drawWave() {
if (!_voice) return; if (!_voice) return;
const cv = $("vr-wave"), g = cv.getContext("2d"); const cv = $("vr-wave"), g = cv.getContext("2d");
@ -1542,17 +1582,50 @@ async function startVoiceInput(mode, startY) {
// ScriptProcessor 已标废弃但全浏览器可用;AudioWorklet 要独立模块文件,纯静态 JS 下不值当 // ScriptProcessor 已标废弃但全浏览器可用;AudioWorklet 要独立模块文件,纯静态 JS 下不值当
const node = ctx.createScriptProcessor(4096, 1, 1); const node = ctx.createScriptProcessor(4096, 1, 1);
const chunks = [], levels = []; const chunks = [], levels = [];
const vo = { stream, ctx, source, node, chunks, levels, rate: ctx.sampleRate,
t0: Date.now(), timer: 0, raf: 0, mode, startY: startY || 0, cancelArmed: false,
// 流式通道状态:wsDead 置位后 stop 走整段 POST 兜底;finalText/finalResolve
// 承接服务端 final 帧(可能先于用户松手 —— 静音超 10s / 60s 引擎自行收尾)
ws: null, wsDead: false, live: "", finalText: null, finalResolve: null,
resample: _makeResampler16k(ctx.sampleRate) };
_voice = vo;
node.onaudioprocess = (ev) => { node.onaudioprocess = (ev) => {
const buf = new Float32Array(ev.inputBuffer.getChannelData(0)); const buf = new Float32Array(ev.inputBuffer.getChannelData(0));
chunks.push(buf); chunks.push(buf); // 全量累积:流式挂了停止时还能整段 POST 兜底
let sum = 0, n = 0; let sum = 0, n = 0;
for (let i = 0; i < buf.length; i += 8) { sum += buf[i] * buf[i]; n++; } // 1/8 抽样够画波浪 for (let i = 0; i < buf.length; i += 8) { sum += buf[i] * buf[i]; n++; } // 1/8 抽样够画波浪
levels.push(Math.sqrt(sum / n)); levels.push(Math.sqrt(sum / n));
if (vo.ws && vo.ws.readyState === 1 && !vo.wsDead) {
const out = vo.resample(buf);
if (out.length) { try { vo.ws.send(out.buffer); } catch (e) { vo.wsDead = true; } }
}
}; };
source.connect(node); source.connect(node);
node.connect(ctx.destination); // 不接 destination 部分浏览器不触发 onaudioprocess;输出恒为静音无回声 node.connect(ctx.destination); // 不接 destination 部分浏览器不触发 onaudioprocess;输出恒为静音无回声
_voice = { stream, ctx, source, node, chunks, levels, rate: ctx.sampleRate, // 流式通道:失败不阻塞录音(采集照跑),只标 wsDead 让停止走兜底
t0: Date.now(), timer: 0, raf: 0, mode, startY: startY || 0, cancelArmed: false }; try {
const ws = new WebSocket((location.protocol === "https:" ? "wss://" : "ws://") + location.host + "/v1/asr/stream");
ws.onopen = () => { try { ws.send(JSON.stringify({ token: state.token })); } catch (e) { vo.wsDead = true; } };
ws.onmessage = (ev) => {
let m = null;
try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.error) { vo.wsDead = true; try { ws.close(); } catch (e) {} return; }
vo.live = m.text || "";
if (_voice === vo) $("vr-live").textContent = vo.live; // 实时字幕
if (m.final) {
vo.finalText = vo.live;
if (vo.finalResolve) vo.finalResolve(vo.finalText);
else if (_voice === vo) stopVoiceInput(false); // 引擎提前收尾 → 自动结束录音
}
};
ws.onerror = () => { vo.wsDead = true; };
ws.onclose = () => {
if (vo.finalText == null) vo.wsDead = true; // 没等到 final 就关了 = 通道废
if (vo.finalResolve) vo.finalResolve(vo.finalText); // 别让 stop 干等超时
};
vo.ws = ws;
} catch (e) { vo.wsDead = true; }
$("vr-live").textContent = "";
$("chat-form").classList.add("recording"); $("chat-form").classList.add("recording");
$("voice-rec-panel").classList.remove("cancel-armed"); $("voice-rec-panel").classList.remove("cancel-armed");
const cv = $("vr-wave"), dpr = window.devicePixelRatio || 1; const cv = $("vr-wave"), dpr = window.devicePixelRatio || 1;
@ -1599,12 +1672,34 @@ async function stopVoiceInput(discard) {
try { v.source.disconnect(); v.node.disconnect(); } catch (e) {} try { v.source.disconnect(); v.node.disconnect(); } catch (e) {}
v.stream.getTracks().forEach((t) => t.stop()); v.stream.getTracks().forEach((t) => t.stop());
try { await v.ctx.close(); } catch (e) {} try { await v.ctx.close(); } catch (e) {}
if (discard) { $("chat-hint").textContent = "已取消"; return; } $("vr-live").textContent = "";
if (discard) {
if (v.ws) { try { v.ws.close(); } catch (e) {} } // 服务端见 disconnect 即丢弃会话
$("chat-hint").textContent = "已取消";
return;
}
const pcm = _pcm16kFromChunks(v.chunks, v.rate); const pcm = _pcm16kFromChunks(v.chunks, v.rate);
if (pcm.length < 16000 / 4) { // < 0.25s:多半是误触 if (pcm.length < 16000 / 4) { // < 0.25s:多半是误触
if (v.ws) { try { v.ws.close(); } catch (e) {} }
$("chat-hint").textContent = "录音太短,已取消"; $("chat-hint").textContent = "录音太短,已取消";
return; return;
} }
// ── 流式通道健在:发 end 只等最终帧(音频早已实时上行)──
if (v.ws && !v.wsDead && (v.finalText != null || v.ws.readyState === 1)) {
$("chat-hint").textContent = "转写中…";
const text = v.finalText != null ? v.finalText : await new Promise((res) => {
v.finalResolve = res; // ws.onmessage(final) / onclose 会 resolve
try { v.ws.send(JSON.stringify({ type: "end" })); } catch (e) { res(null); }
setTimeout(() => res(null), 10000); // 超时兜底 → 落整段 POST
});
try { v.ws.close(); } catch (e) {}
if (text != null) {
if (!text.trim()) { $("chat-hint").textContent = "没有识别到内容,请重试"; return; }
_insertTranscript(text.trim());
return;
}
}
// ── 整段 POST 兜底(WS 没连上 / 中途挂 / 等最终帧超时)──
btn.disabled = true; btn.disabled = true;
$("chat-hint").textContent = "转写中…"; $("chat-hint").textContent = "转写中…";
try { try {
@ -1618,13 +1713,7 @@ async function stopVoiceInput(discard) {
if (!r.ok) throw new Error((data && data.detail) || r.status + " " + r.statusText); if (!r.ok) throw new Error((data && data.detail) || r.status + " " + r.statusText);
const text = ((data && data.text) || "").trim(); const text = ((data && data.text) || "").trim();
if (!text) { $("chat-hint").textContent = "没有识别到内容,请重试"; return; } if (!text) { $("chat-hint").textContent = "没有识别到内容,请重试"; return; }
const ta = $("chat-input"); _insertTranscript(text);
ta.focus();
// 光标处插入(不覆盖已有草稿);execCommand 接 textarea 原生 undo 栈,Ctrl+Z 可撤销
const ok = document.execCommand("insertText", false, text);
if (!ok) ta.value += text;
syncOptimizeBtn();
$("chat-hint").textContent = "已转写,确认后发送";
} catch (e) { } catch (e) {
$("chat-hint").textContent = "转写失败:" + e.message; $("chat-hint").textContent = "转写失败:" + e.message;
} finally { } finally {