From 66c742864c1dfa961d09b48cad8a9a6a7d44f525 Mon Sep 17 00:00:00 2001 From: xsl Date: Sun, 29 Mar 2026 23:33:22 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8B=E8=AF=95=E9=80=9A=E8=BF=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .run/start-test.log | 320 +++++++++++++++++++++++++++---- .run/start-test.pid | 2 +- README.md | 19 ++ __pycache__/main.cpython-312.pyc | Bin 38355 -> 38786 bytes docs/development-guide.md | 17 +- main.py | 20 +- scripts/start-public.sh | 36 +++- scripts/start-test.sh | 2 + web/app.js | 141 +++++++++++++- web/index.html | 3 +- 10 files changed, 504 insertions(+), 56 deletions(-) diff --git a/.run/start-test.log b/.run/start-test.log index 171f66f..b9c6f2b 100644 --- a/.run/start-test.log +++ b/.run/start-test.log @@ -5,42 +5,284 @@ mode: auto bind: 0.0.0.0:8018 listen: https://0.0.0.0:8018 browser test page: https://: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 WARNING:services.asr:ASR running in degraded mode on cpu - 0%| | 0/1 [00:00:8018/` +- `https://: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)。这个文档说明了: diff --git a/__pycache__/main.cpython-312.pyc b/__pycache__/main.cpython-312.pyc index a35129bf6b220f4225a09f3afe45dddf8e1b333b..c71685373591d972560ab82944beb81308695741 100644 GIT binary patch delta 7005 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 x#)LqPW`~83-6ffyi&cbJQED0G 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 diff --git a/scripts/start-public.sh b/scripts/start-public.sh index 97dbe8a..998e331 100755 --- a/scripts/start-public.sh +++ b/scripts/start-public.sh @@ -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}://:${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[@]}" diff --git a/scripts/start-test.sh b/scripts/start-test.sh index e314fb2..7b43953 100644 --- a/scripts/start-test.sh +++ b/scripts/start-test.sh @@ -80,5 +80,7 @@ echo "mode: $MODE" echo "bind: ${HOST}:${PORT}" echo "listen: ${SCHEME}://${HOST}:${PORT}" echo "browser test page: ${SCHEME}://:${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[@]}" \ No newline at end of file diff --git a/web/app.js b/web/app.js index cc4aa43..4abffea 100644 --- a/web/app.js +++ b/web/app.js @@ -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); }; } diff --git a/web/index.html b/web/index.html index f887116..6e9f87a 100644 --- a/web/index.html +++ b/web/index.html @@ -96,6 +96,7 @@ } } - + +