测试通过

This commit is contained in:
xsl
2026-03-29 23:33:22 +08:00
parent 4dcae11c1b
commit 66c742864c
10 changed files with 504 additions and 56 deletions
+278 -42
View File
@@ -5,42 +5,284 @@ mode: auto
bind: 0.0.0.0:8018
listen: https://0.0.0.0:8018
browser test page: https://<server-ip>:8018/
INFO: Started server process [17367]
INFO: Started server process [20276]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on https://0.0.0.0:8018 (Press CTRL+C to quit)
INFO: 127.0.0.1:38170 - "WebSocket /ws/animation" [accepted]
INFO: 127.0.0.1:35988 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: 127.0.0.1:38188 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: 127.0.0.1:38202 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:38220 - "GET / HTTP/1.1" 200 OK
INFO: connection closed
INFO: connection closed
INFO: 127.0.0.1:38220 - "GET /web/app.js?v=20260329k HTTP/1.1" 200 OK
INFO: 127.0.0.1:38220 - "GET /web/models/VRM1_Constraint_Twist_Sample.vrm HTTP/1.1" 200 OK
INFO: 127.0.0.1:38256 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:38266 - "GET /events HTTP/1.1" 200 OK
INFO: 127.0.0.1:38286 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: 127.0.0.1:38302 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: 127.0.0.1:40412 - "GET / HTTP/1.1" 200 OK
INFO: connection closed
INFO: connection closed
INFO: 127.0.0.1:40412 - "GET /web/app.js?v=20260329k HTTP/1.1" 304 Not Modified
INFO: 127.0.0.1:40412 - "GET /web/models/VRM1_Constraint_Twist_Sample.vrm HTTP/1.1" 200 OK
INFO: 127.0.0.1:40440 - "GET /events HTTP/1.1" 200 OK
INFO: 127.0.0.1:40436 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:40462 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: 127.0.0.1:40472 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: 127.0.0.1:52378 - "WebSocket /ws/audio" [accepted]
INFO: 127.0.0.1:36002 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:52392 - "WebSocket /ws/audio" [accepted]
INFO: 127.0.0.1:36022 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: 127.0.0.1:36036 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36050 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36072 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36078 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:36104 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36118 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36148 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36168 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36194 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36206 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:36224 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54498 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54524 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54542 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54562 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54584 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54596 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54608 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54628 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54642 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54650 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54664 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54684 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54706 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54718 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54748 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54756 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:54788 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59782 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59802 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59812 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59832 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59860 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59874 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59888 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59902 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59906 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59910 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59922 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59928 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59944 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59962 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59976 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:60002 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58794 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58800 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58830 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58854 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58884 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58906 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58930 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58950 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58974 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:58986 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59002 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59026 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59046 - "GET / HTTP/1.1" 200 OK
INFO: connection closed
INFO: connection closed
INFO: 127.0.0.1:59046 - "GET /web/app.js?v=20260329m HTTP/1.1" 200 OK
INFO: 127.0.0.1:59046 - "GET /web/app.js?v=20260329q HTTP/1.1" 200 OK
INFO: 127.0.0.1:59046 - "GET /web/models/VRM1_Constraint_Twist_Sample.vrm HTTP/1.1" 200 OK
INFO: 127.0.0.1:59114 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:59124 - "GET /events HTTP/1.1" 200 OK
INFO: 127.0.0.1:59114 - "GET /events HTTP/1.1" 200 OK
INFO: 127.0.0.1:59150 - "GET /health HTTP/1.1" 200 OK
INFO: 127.0.0.1:59174 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: 127.0.0.1:59184 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: 127.0.0.1:59186 - "WebSocket /ws/subtitles" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:59190 - "WebSocket /ws/animation" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:44402 - "WebSocket /ws/audio" [accepted]
INFO: connection open
INFO: connection closed
INFO: 127.0.0.1:44446 - "WebSocket /ws/audio" [accepted]
INFO: connection open
INFO: 127.0.0.1:44450 - "WebSocket /ws/audio" [accepted]
INFO: connection open
INFO: 127.0.0.1:44472 - "WebSocket /ws/audio" [accepted]
INFO: connection closed
INFO: connection closed
INFO: connection open
ERROR: Exception in ASGI application
Traceback (most recent call last):
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 329, in asgi_send
await self.send(data) # type: ignore[arg-type]
^^^^^^^^^^^^^^^^^^^^^
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/websockets/legacy/protocol.py", line 618, in send
await self.ensure_open()
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/websockets/legacy/protocol.py", line 916, in ensure_open
raise self.connection_closed_exc()
websockets.exceptions.ConnectionClosedOK: received 1000 (OK) stale-open; then sent 1000 (OK) stale-open
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/websockets.py", line 86, in send
await self._send(message)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 39, in sender
await send(message)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 342, in asgi_send
raise ClientDisconnected from exc
uvicorn.protocols.utils.ClientDisconnected
During handling of the above exception, another exception occurred:
Traceback (most recent call last):
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/uvicorn/protocols/websockets/websockets_impl.py", line 244, in run_asgi
result = await self.app(self.scope, self.asgi_receive, self.asgi_send) # type: ignore[func-returns-value]
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/uvicorn/middleware/proxy_headers.py", line 60, in __call__
return await self.app(scope, receive, send)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/fastapi/applications.py", line 1159, in __call__
await super().__call__(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/applications.py", line 90, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/middleware/errors.py", line 151, in __call__
await self.app(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/middleware/cors.py", line 80, in __call__
await self.app(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/middleware/exceptions.py", line 63, in __call__
await wrap_app_handling_exceptions(self.app, conn)(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
raise exc
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/fastapi/middleware/asyncexitstack.py", line 18, in __call__
await self.app(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/routing.py", line 660, in __call__
await self.middleware_stack(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/routing.py", line 680, in app
await route.handle(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/routing.py", line 350, in handle
await self.app(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 160, in app
await wrap_app_handling_exceptions(app, session)(scope, receive, send)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 53, in wrapped_app
raise exc
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/_exception_handler.py", line 42, in wrapped_app
await app(scope, receive, sender)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 157, in app
await func(session)
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/fastapi/routing.py", line 764, in app
await dependant.call(**solved_result.values)
File "/home/xsl/code/product/main.py", line 446, in ws_audio
await websocket.send_text(
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/websockets.py", line 166, in send_text
await self.send({"type": "websocket.send", "text": data})
File "/home/xsl/code/product/.venv/lib/python3.12/site-packages/starlette/websockets.py", line 89, in send
raise WebSocketDisconnect(code=1006)
starlette.websockets.WebSocketDisconnect
INFO:httpx:HTTP Request: POST https://api.deepseek.com/v1/chat/completions "HTTP/1.1 200 OK"
/home/xsl/code/product/.venv/lib/python3.12/site-packages/torch/cuda/__init__.py:180: UserWarning: CUDA initialization: The NVIDIA driver on your system is too old (found version 12090). Please update your GPU driver by downloading and installing a new version from the URL: http://www.nvidia.com/Download/index.aspx Alternatively, go to: https://pytorch.org to install a PyTorch version that has been compiled with your version of the CUDA driver. (Triggered internally at /pytorch/c10/cuda/CUDAFunctions.cpp:119.)
return torch._C._cuda_getDeviceCount() > 0
@@ -60,20 +302,19 @@ Building prefix dict from the default dictionary ...
DEBUG:jieba:Building prefix dict from the default dictionary ...
Loading model from cache /tmp/jieba.cache
DEBUG:jieba:Loading model from cache /tmp/jieba.cache
Loading model cost 0.341 seconds.
DEBUG:jieba:Loading model cost 0.341 seconds.
Loading model cost 0.356 seconds.
DEBUG:jieba:Loading model cost 0.356 seconds.
Prefix dict has been built successfully.
DEBUG:jieba:Prefix dict has been built successfully.
WARNING: Defaulting repo_id to hexgrad/Kokoro-82M. Pass repo_id='hexgrad/Kokoro-82M' to suppress this warning.
INFO: 127.0.0.1:42956 - "POST /chat/text HTTP/1.1" 200 OK
INFO: 127.0.0.1:42956 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:42956 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO: 127.0.0.1:44470 - "POST /chat/text HTTP/1.1" 200 OK
INFO: 127.0.0.1:44470 - "GET /favicon.ico HTTP/1.1" 404 Not Found
INFO:root:download models from model hub: hf
INFO:httpx:HTTP Request: GET https://huggingface.co/api/models/FunAudioLLM/SenseVoiceSmall/revision/main "HTTP/1.1 200 OK"
funasr version: 1.3.1.
Check update of funasr, and it would cost few times. You may disable it by set `disable_update=True` in AutoModel
You are using the latest version of funasr-1.3.1
Fetching 29 files: 0%| | 0/29 [00:00<?, ?it/s]
Fetching 29 files: 100%|██████████| 29/29 [00:00<00:00, 50935.85it/s]
WARNING:root:trust_remote_code: False
@@ -85,7 +326,7 @@ INFO:httpx:HTTP Request: GET https://huggingface.co/api/models/FunAudioLLM/Sense
INFO:root:download models from model hub: hf
INFO:httpx:HTTP Request: GET https://huggingface.co/api/models/FunAudioLLM/SenseVoiceSmall/revision/main "HTTP/1.1 200 OK"
funasr version: 1.3.1.
Check update of funasr, and it would cost few times. You may disable it by set `disable_update=True` in AutoModel
Check update of funasr, and it would cost few times. You may disable it by set `disable_update=True` in AutoModel
You are using the latest version of funasr-1.3.1
Fetching 29 files: 0%| | 0/29 [00:00<?, ?it/s]
@@ -93,10 +334,5 @@ INFO:root:scope_map: ['module.', 'None']
WARNING:root:trust_remote_code: False
INFO:root:Loading pretrained params from /home/xsl/.cache/huggingface/hub/models--FunAudioLLM--SenseVoiceSmall/snapshots/3eb3b4eeffc2f2dde6051b853983753db33e35c3/model.pt
INFO:root:ckpt: /home/xsl/.cache/huggingface/hub/models--FunAudioLLM--SenseVoiceSmall/snapshots/3eb3b4eeffc2f2dde6051b853983753db33e35c3/model.pt
INFO:root:scope_map: ['module.', 'None']
INFO:root:scope_map: ['module.', 'None']
INFO:root:excludes: None
INFO:root:Loading ckpt: /home/xsl/.cache/huggingface/hub/models--FunAudioLLM--SenseVoiceSmall/snapshots/3eb3b4eeffc2f2dde6051b853983753db33e35c3/model.pt, status: <All keys matched successfully>
WARNING:services.asr:ASR running in degraded mode on cpu
0%| | 0/1 [00:00<?, ?it/s]
100%|██████████| 1/1 [00:00<00:00, 1.04it/s]
+1 -1
View File
@@ -1 +1 @@
17367
20276
+19
View File
@@ -8,6 +8,12 @@
当前形态更接近单机演示和研发验证环境,不是已经拆分完毕的生产化架构。
当前运行约束:
- 服务端按“单客户端接管”模式工作。新的浏览器页面连上后,会主动断开旧页面的音频、字幕、动画 WebSocket。
- 浏览器刷新后,语音通道可能仍受浏览器媒体安全策略限制,需要一次页面点击或点“连接语音通道”来恢复麦克风与播放上下文。
- 前端目前默认保留随机眨眼;更激进的 idle body motion 已回退,避免部分 VRM 模型进入 T pose。
## 当前架构
语音链路:
@@ -90,6 +96,12 @@ python main.py
或使用脚本:
```bash
bash scripts/start-test.sh auto
```
如果你明确要固定走当前 shell/虚拟环境里的 Python,也可以用:
```bash
bash scripts/start-public.sh
```
@@ -107,6 +119,11 @@ python scripts/smoke_test.py --text "你好,请做一个简短自我介绍。"
- `http://127.0.0.1:8080/`
- `http://127.0.0.1:8080/health`
如果 `.env` 里已经配置了 `WEBRTC_PORT=8018``SSL_CERTFILE` / `SSL_KEYFILE`,实际访问地址通常会变成:
- `https://<server-ip>:8018/`
- `https://<server-ip>:8018/health`
如果浏览器与服务端跨机器访问,麦克风采集通常需要 HTTPS。可以先生成自签证书:
```bash
@@ -133,6 +150,8 @@ bash scripts/gen-self-signed-cert.sh
- `WS /ws/animation`: 数字人控制流。
- `GET /avatar/schema`: 当前控制协议 schema。
说明:三个 WebSocket 通道当前都只保留一个活动客户端。新页面接入后,旧页面会收到 `1012 replaced` 并停止接收数据。
## 后续开发建议
优先阅读 [docs/development-guide.md](docs/development-guide.md)。这个文档说明了:
Binary file not shown.
+16 -1
View File
@@ -49,6 +49,14 @@
当前实现假设一次只保留一个主音频 WebSocket 连接。新连接到来时会主动关闭旧连接。这是刻意简化,不是 bug。
现在这个约束已经扩展到三个浏览器通道:
- `WS /ws/audio`
- `WS /ws/subtitles`
- `WS /ws/animation`
也就是说,服务端当前只允许一个浏览器页面完整接管数字人会话。新页面连上后,旧页面会被主动断开,关闭码为 `1012 replaced`
### 3.2 会话状态机
[core/state_machine.py](../core/state_machine.py) 的状态很轻:
@@ -99,6 +107,7 @@
- 维护聊天消息面板和诊断面板。
- 用 Three.js + VRM 加载和渲染模型。
- 把动画控制帧缓存约 `120ms` 后再播放,尽量和音频保持一致。
- 处理刷新后的语音恢复:如果浏览器拦截自动恢复,会等待下一次用户手势再恢复麦克风和播放上下文。
前端当前兼容三种形态:
@@ -106,6 +115,11 @@
- 通用 GLTF morph target。
- 没有表情 rig 时回退到调试头像。
当前前端待机动画状态:
- 已启用随机眨眼。
- 更激进的身体 idle motion 已回退,因为部分 VRM 模型会出现 T pose 或姿态异常。
如果以后想接入真实业务数字人模型,先确认模型是否提供:
- 标准 VRM 表情。
@@ -177,10 +191,11 @@
这些约束需要后续开发明确知晓:
- 当前是进程内单例服务,不是多租户、多 session 设计。
- 浏览器侧只允许一个主音频 WebSocket 会话接管服务端语音输入输出。
- 浏览器侧只允许一个页面完整接管服务端语音输入输出与字幕/动画订阅
- 模型推理基本都直接跑在应用事件循环附近,适合研发验证,不适合高并发。
- 当前动画驱动依赖音频能量和谱质心,不具备严格唇音级别精度。
- 语音链路高度依赖 VAD;如果要做电话式长连接交互,建议补更稳的 turn management。
- 浏览器刷新后不保证零手势恢复语音;这是浏览器媒体策略约束,不是后端故障。
## 9. 推荐扩展方向
+14 -6
View File
@@ -103,6 +103,17 @@ async def _broadcast_json(clients: set[WebSocket], payload: dict) -> None:
clients.discard(ws)
async def _replace_existing_clients(clients: set[WebSocket]) -> bool:
replaced_previous = len(clients) > 0
if not replaced_previous:
return False
old_clients = list(clients)
await asyncio.gather(*(client.close(code=1012, reason="replaced") for client in old_clients), return_exceptions=True)
for client in old_clients:
clients.discard(client)
return True
async def _broadcast_subtitle(
role: str,
text: str,
@@ -392,6 +403,7 @@ async def events():
@app.websocket("/ws/subtitles")
async def ws_subtitles(websocket: WebSocket):
await websocket.accept()
await _replace_existing_clients(subtitle_clients)
subtitle_clients.add(websocket)
try:
while True:
@@ -406,6 +418,7 @@ async def ws_subtitles(websocket: WebSocket):
@app.websocket("/ws/animation")
async def ws_animation(websocket: WebSocket):
await websocket.accept()
await _replace_existing_clients(animation_clients)
animation_clients.add(websocket)
try:
await websocket.send_text(json.dumps({"type": "animation_ready", **avatar_service.schema}, ensure_ascii=False))
@@ -423,12 +436,7 @@ async def ws_animation(websocket: WebSocket):
async def ws_audio(websocket: WebSocket):
await websocket.accept()
replaced_previous = len(audio_clients) > 0
if replaced_previous:
old_clients = list(audio_clients)
await asyncio.gather(*(client.close(code=1012, reason="replaced") for client in old_clients), return_exceptions=True)
for client in old_clients:
audio_clients.discard(client)
replaced_previous = await _replace_existing_clients(audio_clients)
audio_clients.add(websocket)
sample_rate = 48000
+33 -3
View File
@@ -5,8 +5,6 @@ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$ROOT"
PY="/home/xsl/miniconda3/envs/MuseTalk/bin/python"
if [[ -f ".env" ]]; then
set -a
# shellcheck disable=SC1091
@@ -14,6 +12,26 @@ if [[ -f ".env" ]]; then
set +a
fi
pick_python() {
if [[ -n "${VISUAL_CHAT_PYTHON:-}" && -x "${VISUAL_CHAT_PYTHON}" ]]; then
echo "$VISUAL_CHAT_PYTHON"
return 0
fi
if [[ -n "${VIRTUAL_ENV:-}" && -x "${VIRTUAL_ENV}/bin/python" ]]; then
echo "${VIRTUAL_ENV}/bin/python"
return 0
fi
if [[ -x "/home/xsl/miniconda3/envs/MuseTalk/bin/python" ]]; then
echo "/home/xsl/miniconda3/envs/MuseTalk/bin/python"
return 0
fi
if command -v python3 >/dev/null 2>&1; then
command -v python3
return 0
fi
command -v python
}
normalize_host() {
local host="${1:-0.0.0.0}"
case "$host" in
@@ -27,10 +45,22 @@ normalize_host() {
}
HOST="$(normalize_host "${WEBRTC_HOST:-0.0.0.0}")"
ARGS=(main:app --host "$HOST" --port "${WEBRTC_PORT:-8080}")
PORT="${WEBRTC_PORT:-8080}"
PY="$(pick_python)"
SCHEME="http"
ARGS=(main:app --host "$HOST" --port "$PORT")
if [[ -n "${SSL_CERTFILE:-}" && -n "${SSL_KEYFILE:-}" ]]; then
SCHEME="https"
ARGS+=(--ssl-certfile "$SSL_CERTFILE" --ssl-keyfile "$SSL_KEYFILE")
fi
echo "repo: $ROOT"
echo "python: $PY"
echo "bind: ${HOST}:${PORT}"
echo "listen: ${SCHEME}://${HOST}:${PORT}"
echo "browser test page: ${SCHEME}://<server-ip>:${PORT}/"
echo "note: only one browser page is allowed to own the session; a newer client replaces the older one."
echo "note: after browser refresh, one click may still be required to resume mic/audio due to browser media policy."
exec "$PY" -m uvicorn "${ARGS[@]}"
+2
View File
@@ -80,5 +80,7 @@ echo "mode: $MODE"
echo "bind: ${HOST}:${PORT}"
echo "listen: ${SCHEME}://${HOST}:${PORT}"
echo "browser test page: ${SCHEME}://<server-ip>:${PORT}/"
echo "note: only one browser page is allowed to own the session; a newer client replaces the older one."
echo "note: after browser refresh, one click may still be required to resume mic/audio due to browser media policy."
exec "$PY" -m uvicorn "${ARGS[@]}"
+139 -2
View File
@@ -64,6 +64,7 @@ let micProcessorNode = null;
let micSinkGain = null;
let audioPlaybackCursor = 0;
const audioPlaybackSources = new Set();
const recentSubtitleEvents = new Map();
let reconnectOnGestureArmed = false;
let pageGestureBootstrapArmed = false;
@@ -127,6 +128,18 @@ const avatarState = {
debugRig: null,
debugJaw: null,
debugMouth: null,
debugLeftEye: null,
debugRightEye: null,
blink: {
value: 0,
phase: "idle",
phaseStartedAtMs: performance.now(),
nextAtMs: performance.now() + 1800,
closeMs: 90,
holdMs: 50,
openMs: 120,
pendingDoubleBlink: false,
},
};
const gltfLoader = new GLTFLoader();
@@ -160,6 +173,27 @@ function addMessage(role, text) {
return div;
}
function shouldRenderSubtitleEvent(data) {
const key = JSON.stringify({
role: data?.role || "",
text: data?.text || "",
partial: Boolean(data?.partial),
final: Boolean(data?.final),
source: data?.source || "",
});
const now = Date.now();
const lastSeenAt = recentSubtitleEvents.get(key) || 0;
recentSubtitleEvents.set(key, now);
for (const [entryKey, seenAt] of recentSubtitleEvents.entries()) {
if (now - seenAt > 1500) {
recentSubtitleEvents.delete(entryKey);
}
}
return now - lastSeenAt > 350;
}
function setConnBadge(label, kind = "neutral") {
connBadge.textContent = label;
connBadge.className = `badge ${kind}`;
@@ -487,6 +521,8 @@ function createDebugRig() {
avatarState.debugRig = group;
avatarState.debugJaw = jawPivot;
avatarState.debugMouth = mouth;
avatarState.debugLeftEye = leftEye;
avatarState.debugRightEye = rightEye;
avatarState.headBone = neck;
avatarState.neckBone = neck;
}
@@ -505,6 +541,7 @@ function renderLoop() {
const elapsed = CLOCK.getElapsedTime();
controls.update();
flushAnimationQueue();
updateBlinkState(performance.now());
applyProceduralIdlePose(elapsed);
applyAnimationControls(avatarState.activeControls);
avatarState.currentVrm?.update?.(delta);
@@ -618,6 +655,8 @@ function applyAnimationControls(controls) {
setMorphValue("mouthPucker", mouthPucker);
}
applyBlinkControls(avatarState.blink?.value || 0);
// For plain head meshes (no VRM expressions and no morph targets),
// amplify head motion to keep speaking visually obvious.
const yawApplied = hasFacialRig ? headYaw * 1.8 : headYaw * 10.0 + Math.sin(t * 5.0) * jawOpen * 0.09;
@@ -702,6 +741,8 @@ function detachCurrentAvatar() {
avatarState.headBone = avatarState.debugRig.children[1];
avatarState.neckBone = avatarState.debugRig.children[1];
avatarState.debugRig.visible = true;
avatarState.debugLeftEye = avatarState.debugLeftEye || avatarState.debugRig.children[1]?.children?.[2] || null;
avatarState.debugRightEye = avatarState.debugRightEye || avatarState.debugRig.children[1]?.children?.[3] || null;
modelName.textContent = "debug-avatar";
}
@@ -831,6 +872,9 @@ function collectMorphBindings(root) {
viseme_ee: ["viseme_ee", "ee", "ih", "i", "mouthee"],
viseme_oh: ["viseme_oh", "oh", "ou", "o", "mouthoh"],
mouthPucker: ["mouthpucker", "pucker", "ou", "kiss"],
blink: ["blink", "eyeblink", "eye_blink", "blinkboth"],
blinkLeft: ["blinkleft", "blink_left", "eyeblinkleft", "eyelid_l", "eyeblink_l"],
blinkRight: ["blinkright", "blink_right", "eyeblinkright", "eyelid_r", "eyeblink_r"],
};
root.traverse((node) => {
@@ -910,6 +954,82 @@ function applyProceduralIdlePose(time) {
applyBoneRotation(rig.bones.rightHand, handDrift * 0.2, 0, 0.05);
}
function scheduleNextBlink(nowMs, preferSoon = false) {
const quickBlink = Math.random() < 0.18;
const minDelay = preferSoon ? 240 : 1800;
const maxDelay = preferSoon ? 520 : 5200;
avatarState.blink.nextAtMs = nowMs + minDelay + Math.random() * (maxDelay - minDelay);
avatarState.blink.pendingDoubleBlink = !preferSoon && quickBlink;
}
function updateBlinkState(nowMs) {
const blink = avatarState.blink;
if (!blink) return;
if (blink.phase === "idle") {
blink.value = 0;
if (nowMs >= blink.nextAtMs) {
blink.phase = "closing";
blink.phaseStartedAtMs = nowMs;
}
return;
}
if (blink.phase === "closing") {
const progress = Math.min(1, (nowMs - blink.phaseStartedAtMs) / blink.closeMs);
blink.value = progress;
if (progress >= 1) {
blink.phase = "hold";
blink.phaseStartedAtMs = nowMs;
blink.value = 1;
}
return;
}
if (blink.phase === "hold") {
blink.value = 1;
if (nowMs - blink.phaseStartedAtMs >= blink.holdMs) {
blink.phase = "opening";
blink.phaseStartedAtMs = nowMs;
}
return;
}
if (blink.phase === "opening") {
const progress = Math.min(1, (nowMs - blink.phaseStartedAtMs) / blink.openMs);
blink.value = 1 - progress;
if (progress >= 1) {
blink.phase = "idle";
blink.phaseStartedAtMs = nowMs;
blink.value = 0;
scheduleNextBlink(nowMs, blink.pendingDoubleBlink);
}
}
}
function applyBlinkControls(blinkValue) {
const clamped = clampControl(blinkValue);
if (avatarState.currentVrm?.expressionManager) {
setExpressionValue("blink", clamped);
setExpressionValue("blinkLeft", clamped);
setExpressionValue("blinkRight", clamped);
}
setMorphValue("blink", clamped);
setMorphValue("blinkLeft", clamped);
setMorphValue("blinkRight", clamped);
const eyeScaleY = Math.max(0.08, 1 - clamped * 0.92);
const eyeScaleZ = 1 + clamped * 0.35;
if (avatarState.debugLeftEye) {
avatarState.debugLeftEye.scale.set(1, eyeScaleY, eyeScaleZ);
}
if (avatarState.debugRightEye) {
avatarState.debugRightEye.scale.set(1, eyeScaleY, eyeScaleZ);
}
}
function applyBoneRotation(bone, x = 0, y = 0, z = 0) {
if (!bone) return;
const baseQuaternion = avatarState.idleRig?.base?.get(bone);
@@ -956,6 +1076,9 @@ function subscribeSubtitles() {
subtitleWs.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (!shouldRenderSubtitleEvent(data)) {
return;
}
if (data.role === "user") {
aiStreamingEl = null;
addMessage(data.role, data.text || "");
@@ -973,7 +1096,14 @@ function subscribeSubtitles() {
// no-op
}
};
subtitleWs.onclose = () => {
subtitleWs.onclose = (event) => {
if (subtitleWs && subtitleWs.readyState === WebSocket.CLOSED) {
subtitleWs = null;
}
if (event.code === 1012) {
addMessage("system", "字幕通道已被新客户端接管。当前页面停止接收字幕。");
return;
}
setTimeout(subscribeSubtitles, 1000);
};
}
@@ -1001,8 +1131,15 @@ function subscribeAnimation() {
// no-op
}
};
animationWs.onclose = () => {
animationWs.onclose = (event) => {
if (animationWs && animationWs.readyState === WebSocket.CLOSED) {
animationWs = null;
}
animationConn.textContent = "已断开";
if (event.code === 1012) {
animationConn.textContent = "已被新客户端接管";
return;
}
setTimeout(subscribeAnimation, 1000);
};
}
+2 -1
View File
@@ -96,6 +96,7 @@
}
}
</script>
<script type="module" src="/web/app.js?v=20260329k"></script>
<script type="module" src="/web/app.js?v=20260329m"></script>
<script type="module" src="/web/app.js?v=20260329q"></script>
</body>
</html>