From 64c894b673851b520d68382053d6c88d464d479c Mon Sep 17 00:00:00 2001 From: xsl Date: Wed, 8 Apr 2026 14:38:04 +0800 Subject: [PATCH] =?UTF-8?q?=E7=8B=AC=E7=AB=8B=E4=BE=9D=E8=B5=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env | 13 +- .env.example | 28 ---- .run/visual-chat-8018.log | 5 + .run/visual-chat-8018.pid | 1 - README.md | 14 +- __pycache__/config.cpython-312.pyc | Bin 3976 -> 4692 bytes __pycache__/main.cpython-312.pyc | Bin 38786 -> 33836 bytes config.py | 70 ++++++---- docs/deployment.md | 146 ++++++++++++++++++++ environments.md | 30 ++-- main.py | 81 +---------- requirements.txt | 1 - scripts/run_all_checks.sh | 25 +++- scripts/service-8018.sh | 4 +- scripts/service.sh | 2 +- scripts/start-public.sh | 8 +- scripts/start-test.sh | 8 +- scripts/vendor_hf_models.py | 64 +++++++++ services/__pycache__/asr.cpython-312.pyc | Bin 2973 -> 3908 bytes services/__pycache__/llm.cpython-312.pyc | Bin 6489 -> 5626 bytes services/__pycache__/tts.cpython-312.pyc | Bin 4831 -> 7462 bytes services/__pycache__/vad.cpython-312.pyc | Bin 2018 -> 2332 bytes services/asr.py | 26 +++- services/llm.py | 26 +--- services/tts.py | 78 ++++++++--- services/vad.py | 11 +- web/app.js | 10 +- webrtc/__init__.py | 1 - webrtc/__pycache__/__init__.cpython-310.pyc | Bin 129 -> 0 bytes webrtc/__pycache__/__init__.cpython-312.pyc | Bin 138 -> 0 bytes webrtc/__pycache__/tracks.cpython-310.pyc | Bin 4619 -> 0 bytes webrtc/__pycache__/tracks.cpython-312.pyc | Bin 5956 -> 0 bytes webrtc/audio_track.py | 9 -- webrtc/gateway.py | 24 ---- webrtc/tracks.py | 76 ---------- webrtc/video_track.py | 7 - 36 files changed, 431 insertions(+), 337 deletions(-) delete mode 100644 .env.example delete mode 100644 .run/visual-chat-8018.pid create mode 100644 docs/deployment.md create mode 100644 scripts/vendor_hf_models.py delete mode 100644 webrtc/__init__.py delete mode 100644 webrtc/__pycache__/__init__.cpython-310.pyc delete mode 100644 webrtc/__pycache__/__init__.cpython-312.pyc delete mode 100644 webrtc/__pycache__/tracks.cpython-310.pyc delete mode 100644 webrtc/__pycache__/tracks.cpython-312.pyc delete mode 100644 webrtc/audio_track.py delete mode 100644 webrtc/gateway.py delete mode 100644 webrtc/tracks.py delete mode 100644 webrtc/video_track.py diff --git a/.env b/.env index 08af570..2a06d12 100644 --- a/.env +++ b/.env @@ -14,15 +14,14 @@ TTS_SENTENCE_MAX_LEN=64 TTS_SEGMENT_PAUSE_MS=90 TTS_FADE_MS=12 -# Web service -WEBRTC_HOST=0.0.0.0 -WEBRTC_PORT=8018 -STUN_URL=stun:stun.l.google.com:19302 +# Web service(与当前代码一致,仅 HTTP_HOST / HTTP_PORT) +HTTP_HOST=0.0.0.0 +HTTP_PORT=8018 CORS_ORIGINS=* -# HTTPS (for cross-device mic/WebRTC) -SSL_CERTFILE=/home/xsl/code/product/certs/dev-cert.pem -SSL_KEYFILE=/home/xsl/code/product/certs/dev-key.pem +# HTTPS(跨设备访问麦克风/页面时常需 TLS;路径相对项目根目录) +SSL_CERTFILE=certs/dev-cert.pem +SSL_KEYFILE=certs/dev-key.pem # 3D avatar driver mode AVATAR_DRIVER_MODE=blendshape_stream diff --git a/.env.example b/.env.example deleted file mode 100644 index 91b52db..0000000 --- a/.env.example +++ /dev/null @@ -1,28 +0,0 @@ -LLM_API_KEY= -LLM_BASE_URL=https://api.deepseek.com/v1 -LLM_MODEL=deepseek-chat -LLM_TIMEOUT=8 -LLM_MAX_TOKENS=150 -LLM_TEMPERATURE=0.55 -LLM_SYSTEM_PROMPT=你是一个自然、友好的中文语音助手。请像真人当面聊天一样回答,口语化、简洁、自然,通常1到2句。不要使用书面公告腔,不要分点,不要长段解释,不要夸张语气词。不要虚构身份关系,不要自称家人、恋人或其他私人身份。避免客服套话,比如“随时为你服务”、“很高兴为你服务”、“请问有什么可以帮您”。优先使用短句和自然停顿,适合直接语音播报。 - -TTS_VOICE=zf_xiaoxiao -TTS_SPEED=0.90 -TTS_SENTENCE_MAX_LEN=64 -TTS_SEGMENT_PAUSE_MS=90 -TTS_FADE_MS=12 - -WEBRTC_HOST=0.0.0.0 -WEBRTC_PORT=8080 -STUN_URL=stun:stun.l.google.com:19302 -CORS_ORIGINS=* - -AVATAR_FPS=25 -AVATAR_DRIVER_MODE=blendshape_stream -AVATAR_CONTROL_PROTOCOL=ws -AVATAR_BLENDSHAPE_SCHEMA=arkit - -SSL_CERTFILE= -SSL_KEYFILE= - -HF_TOKEN= \ No newline at end of file diff --git a/.run/visual-chat-8018.log b/.run/visual-chat-8018.log index fec0d2c..60d6047 100644 --- a/.run/visual-chat-8018.log +++ b/.run/visual-chat-8018.log @@ -32,3 +32,8 @@ INFO: connection open INFO: connection closed INFO: connection closed INFO: connection closed +INFO: Shutting down +INFO: Waiting for connections to close. (CTRL+C to force quit) +INFO: Waiting for application shutdown. +INFO: Application shutdown complete. +INFO: Finished server process [3446] diff --git a/.run/visual-chat-8018.pid b/.run/visual-chat-8018.pid deleted file mode 100644 index 6cf44d4..0000000 --- a/.run/visual-chat-8018.pid +++ /dev/null @@ -1 +0,0 @@ -3446 diff --git a/README.md b/README.md index 74c2d77..0933cde 100644 --- a/README.md +++ b/README.md @@ -77,7 +77,11 @@ export LLM_BASE_URL="https://api.deepseek.com/v1" export LLM_MODEL="deepseek-chat" ``` -也可以直接复制 [.env.example](.env.example) 作为本地配置模板。 +复制 [.env.example](.env.example) 为 `.env`,填入有效的 `LLM_API_KEY`。 + +**本地权重(除 LLM 外全部在仓库内 `models/`)**:首次可用联网环境执行 `python scripts/vendor_hf_models.py`;其中 VAD 的 `silero_vad.jit` 会从已安装的 `silero-vad` 包复制到 `models/vad/`。ASR 使用 `models/asr/SenseVoiceSmall/`,TTS 使用 `models/tts/Kokoro-82M/`。 + +完整部署步骤(新机器、模型打包、HTTPS、防火墙、排错)见 **[docs/deployment.md](docs/deployment.md)**。 语音如果偏快、偏硬,可以优先调这几个参数: @@ -88,6 +92,8 @@ export LLM_MODEL="deepseek-chat" - `TTS_SEGMENT_PAUSE_MS`:默认 `90`,给分段之间留一点停顿,但不要太拖。 - `TTS_FADE_MS`:默认 `12`,减轻段间拼接的突兀感。 +部署到任意机器时:在项目根目录运行(保证 `models/`、`web/`、`certs/` 等相对路径有效)。证书可在 `.env` 里写相对路径(例如 `certs/dev-cert.pem`),会按项目根目录解析。也可用环境变量 `VISUAL_CHAT_PYTHON` 指定解释器。 + 本地启动: ```bash @@ -119,7 +125,7 @@ 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`,实际访问地址通常会变成: +如果 `.env` 里已经配置了 `HTTP_PORT=8018` 与 `SSL_CERTFILE` / `SSL_KEYFILE`,实际访问地址通常会变成: - `https://:8018/` - `https://:8018/health` @@ -135,7 +141,7 @@ bash scripts/gen-self-signed-cert.sh 监听规则: - 服务监听地址固定按 `0.0.0.0` 规则执行。 -- 即使把 `WEBRTC_HOST` 写成 `127.0.0.1`、`localhost` 或 `::1`,启动时也会自动归一化成 `0.0.0.0`,避免局域网访问被误伤。 +- 即使把 `HTTP_HOST` 写成 `127.0.0.1`、`localhost` 或 `::1`,启动时也会自动归一化成 `0.0.0.0`,避免局域网访问被误伤。 ## 核心接口 @@ -143,7 +149,7 @@ bash scripts/gen-self-signed-cert.sh - `POST /chat/text`: 文本输入链路,跳过 ASR/VAD。 - `POST /chat/reset`: 清空上下文和运行时状态。 - `GET /health`: 返回后端运行指标和组件健康状态。 -- `GET /meta`: 返回 STUN、HTTPS、端口和动画协议配置。 +- `GET /meta`: 返回 HTTPS、监听端口和动画协议配置。 - `GET /events`: Server-Sent Events 运行状态流。 - `WS /ws/audio`: 双向语音通道,上传麦克风 PCM 并接收回复语音。 - `WS /ws/subtitles`: 字幕流。 diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc index 6fd8e0f467d18affc5ba261ae506c56e7c2efa37..a9a833bcfde0f25194685f6b619d605aaaa1aaa2 100644 GIT binary patch literal 4692 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 delta 2454 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 delta 13172 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 diff --git a/config.py b/config.py index 3396a8a..ff8c68b 100644 --- a/config.py +++ b/config.py @@ -1,11 +1,35 @@ -from pydantic import AliasChoices, Field, field_validator +from pathlib import Path + +from pydantic import Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict +PROJECT_ROOT = Path(__file__).resolve().parent + + +def asr_bundle_dir() -> Path: + return PROJECT_ROOT / "models" / "asr" / "SenseVoiceSmall" + + +def kokoro_bundle_dir() -> Path: + return PROJECT_ROOT / "models" / "tts" / "Kokoro-82M" + + +def vad_bundle_path() -> Path: + return PROJECT_ROOT / "models" / "vad" / "silero_vad.jit" + class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") - @field_validator("webrtc_host", mode="before") + @field_validator("llm_api_key", "llm_base_url", "llm_model", mode="before") + @classmethod + def llm_required_nonempty(cls, value: str) -> str: + text = str(value or "").strip() + if not text: + raise ValueError("LLM_API_KEY, LLM_BASE_URL and LLM_MODEL must be set (non-empty).") + return text + + @field_validator("http_host", mode="before") @classmethod def normalize_listen_host(cls, value: str) -> str: host = str(value or "0.0.0.0").strip().lower() @@ -13,19 +37,20 @@ class Settings(BaseSettings): return "0.0.0.0" return host or "0.0.0.0" - hf_token: str = "" - llm_api_key: str = Field( - default="", - validation_alias=AliasChoices("LLM_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY"), - ) - llm_base_url: str = Field( - default="https://api.deepseek.com/v1", - validation_alias=AliasChoices("LLM_BASE_URL", "DEEPSEEK_BASE_URL", "OPENAI_BASE_URL"), - ) - llm_model: str = Field( - default="deepseek-chat", - validation_alias=AliasChoices("LLM_MODEL", "DEEPSEEK_MODEL", "OPENAI_MODEL"), - ) + @field_validator("ssl_certfile", "ssl_keyfile", mode="after") + @classmethod + def resolve_ssl_path(cls, value: str) -> str: + text = (value or "").strip() + if not text: + return "" + p = Path(text) + if not p.is_absolute(): + p = PROJECT_ROOT / p + return str(p.resolve()) + + llm_api_key: str = Field(..., validation_alias="LLM_API_KEY") + llm_base_url: str = Field(..., validation_alias="LLM_BASE_URL") + llm_model: str = Field(..., validation_alias="LLM_MODEL") llm_timeout: int = 8 llm_max_tokens: int = 150 llm_temperature: float = 0.55 @@ -44,26 +69,15 @@ class Settings(BaseSettings): tts_segment_pause_ms: int = 90 tts_fade_ms: int = 12 - webrtc_host: str = "0.0.0.0" - webrtc_port: int = 8080 + http_host: str = Field(default="0.0.0.0", validation_alias="HTTP_HOST") + http_port: int = Field(default=8080, validation_alias="HTTP_PORT") avatar_fps: int = 25 avatar_driver_mode: str = "blendshape_stream" avatar_control_protocol: str = "ws" avatar_blendshape_schema: str = "arkit" - stun_url: str = "stun:stun.l.google.com:19302" cors_origins: str = "*" ssl_certfile: str = "" ssl_keyfile: str = "" - # Legacy MuseTalk settings retained for migration reference only. - musetalk_enabled: bool = True - musetalk_repo_dir: str = "/home/xsl/work/MuseTalk" - musetalk_infer_script: str = "/home/xsl/work/MuseTalk/scripts/inference.py" - musetalk_source_video: str = "/home/xsl/work/MuseTalk/data/video/yongen.mp4" - musetalk_unet_model: str = "/home/xsl/work/MuseTalk/models/musetalkV15/unet.pth" - musetalk_unet_config: str = "/home/xsl/work/MuseTalk/models/musetalkV15/musetalk.json" - musetalk_whisper_dir: str = "/home/xsl/work/MuseTalk/models/whisper" - musetalk_result_dir: str = "/home/xsl/work/MuseTalk/results/visual-chat" - settings = Settings() diff --git a/docs/deployment.md b/docs/deployment.md new file mode 100644 index 0000000..56b4444 --- /dev/null +++ b/docs/deployment.md @@ -0,0 +1,146 @@ +# 部署说明 + +本文说明如何把「3D 数字人语音聊天」服务端部署到一台新机器(Linux / WSL / macOS 等类 Unix 环境)。**唯一需要能访问公网的依赖是 LLM API**;语音相关权重应放在项目内 `models/` 目录,不在运行时从公网拉取。 + +## 1. 环境与依赖 + +- **Python**:建议 3.12(与当前 `requirements.txt` 一致)。 +- **系统工具**:`openssl`(生成自签证书);可选 `ffmpeg`(若你后续扩展音视频流程)。 +- **硬件**:CPU 可运行;有 NVIDIA GPU 时 ASR 等会优先用 CUDA(无卡则回退 CPU,可能较慢)。 +- **磁盘**:`models/` 中 ASR + TTS 权重合计约 **1.2GB+**,请预留空间。 + +## 2. 获取代码与虚拟环境 + +```bash +git clone <你的仓库地址> product +cd product + +python3 -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate + +pip install -U pip +pip install -r requirements.txt +``` + +说明:依赖里已包含 `huggingface_hub`(由其它包装带动)。若单独只为下载权重,也可 `pip install huggingface_hub`。 + +## 3. 准备本地模型(`models/`) + +首次在一台**能访问 Hugging Face**的机器上执行(仅需一次,或把整份 `models/` 目录打包拷贝到离线机): + +```bash +source .venv/bin/activate +python scripts/vendor_hf_models.py +``` + +该脚本会: + +- 从已安装的 `silero-vad` 包复制 **`models/vad/silero_vad.jit`** +- 拉取 **ASR**:`models/asr/SenseVoiceSmall/` +- 拉取 **TTS(Kokoro)**:`models/tts/Kokoro-82M/`(含 `voices/` 下各 `.pt` 音色) + +若目标机无外网,可在有网机器上跑完上述命令后,**整体复制 `models/`** 到部署机同一路径(相对于项目根)。 + +仅补全 VAD、不拉 HF(需已 `pip install silero-vad`): + +```bash +python scripts/vendor_hf_models.py --skip-hf +``` + +## 4. 配置环境变量(`.env`) + +```bash +cp .env.example .env +``` + +**必须非空**(见 `config.py` 校验): + +| 变量 | 含义 | +|------|------| +| `LLM_API_KEY` | OpenAI 兼容接口的 API Key | +| `LLM_BASE_URL` | 例如 `https://api.deepseek.com/v1` | +| `LLM_MODEL` | 例如 `deepseek-chat` | + +**HTTP 服务**(不要使用已废弃的 `WEBRTC_*`): + +| 变量 | 含义 | +|------|------| +| `HTTP_HOST` | 监听地址,一般 `0.0.0.0` | +| `HTTP_PORT` | 端口,例如 `8080` 或 `8018` | + +**HTTPS(推荐用于跨设备访问页面与麦克风)**: + +- 证书路径建议写**相对项目根**,便于迁移,例如: + - `SSL_CERTFILE=certs/dev-cert.pem` + - `SSL_KEYFILE=certs/dev-key.pem` +- 程序会把相对路径解析为项目根下的绝对路径。 + +**TTS 音色**:`TTS_VOICE` 必须与 `models/tts/Kokoro-82M/voices/<名称>.pt` 一致(默认示例为 `zf_xiaoxiao`)。 + +完整字段说明见仓库根目录 [`.env.example`](../.env.example)。 + +## 5. 生成自签证书(可选但常需要) + +在**项目根目录**执行: + +```bash +bash scripts/gen-self-signed-cert.sh +``` + +生成 `certs/dev-cert.pem` 与 `certs/dev-key.pem`,并在 `.env` 中配置上一节的 `SSL_CERTFILE`、`SSL_KEYFILE`。 + +默认证书主题为 `CN=localhost`。用局域网 IP 访问时浏览器可能提示证书与地址不符,属自签名常见情况,可在浏览器中选择继续访问;生产环境请改用正规 CA 或内网 PKI 签发的证书。 + +## 6. 启动服务 + +**开发 / 前台运行:** + +```bash +source .venv/bin/activate +python main.py +``` + +或使用脚本(会读 `.env` 中的 `HTTP_HOST`、`HTTP_PORT`): + +```bash +bash scripts/start-public.sh +``` + +**HTTPS**:当 `.env` 中 `SSL_CERTFILE`、`SSL_KEYFILE` 均有效且文件存在时,`main.py` 会通过 uvicorn 加载证书(与 `config` 中解析后的路径一致)。 + +启动后在本机浏览器访问: + +- `http(s)://127.0.0.1:/` +- `http(s)://<服务器局域网IP>:/`(需防火墙放行该端口) + +## 7. 防火墙与端口 + +确保部署机对客户端开放 **`HTTP_PORT`(TCP)**。若前面有云厂商安全组,需同步放行。 + +## 8. 目录与可移植性约定 + +- **项目根**:所有相对路径(SSL、`models/` 布局、`web/` 静态资源)均以**含有 `main.py` 与 `config.py` 的目录**为基准;启动时工作目录应为此目录。 +- **可选**:环境变量 `VISUAL_CHAT_PYTHON` 可指向指定 Python 解释器(部分 `scripts/*.sh` 会优先使用)。 + +## 9. 验证 + +```bash +curl -s "http://127.0.0.1:${HTTP_PORT:-8080}/health" | head +``` + +或用仓库内脚本(需根据实际 URL 调整): + +```bash +python scripts/qa_check.py --base-url "https://127.0.0.1:8080" --rounds 3 +``` + +(若仅 HTTP,把 `base-url` 改成 `http://...`。) + +## 10. 常见问题 + +- **`.env` 里 `LLM_*` 为空**:应用在加载 `config` 时会校验失败,请务必填写有效值。 +- **ASR/TTS 不工作**:检查 `models/asr/SenseVoiceSmall/` 是否含 `configuration.json`;`models/tts/Kokoro-82M/` 是否含 `config.json`、权重 `.pth` 与 `voices/.pt`。 +- **麦克风在别的设备上不可用**:多数浏览器要求 **HTTPS** 与用户手势;请启用 HTTPS 并在页面内点击「连接语音通道」等操作。 +- **旧变量名**:`WEBRTC_HOST`、`WEBRTC_PORT`、`STUN_URL` 已废弃,请只使用 `HTTP_HOST`、`HTTP_PORT`。 + +更细的语音/文本流水线时序见 [development-guide.md](development-guide.md)。 diff --git a/environments.md b/environments.md index 2ce29d2..c7552dc 100644 --- a/environments.md +++ b/environments.md @@ -13,13 +13,13 @@ conda env list | 环境名 | 路径 | 用途 | 磁盘占用 | |--------|------|------|---------| -| `LivePortrait` | `/home/xsl/miniconda3/envs/LivePortrait` | 头部动作驱动 | 8.6GB | -| `MuseTalk` | `/home/xsl/miniconda3/envs/MuseTalk` | 唇形同步(主力) | 11GB | -| `latentsync` | `/home/xsl/miniconda3/envs/latentsync` | 唇形同步(备用) | 9.2GB | -| `condiff-train-hair` | `/home/xsl/miniconda3/envs/condiff-train-hair` | 其他项目(未动) | — | -| `onediff` | `/home/xsl/miniconda3/envs/onediff` | 其他项目(未动) | — | -| `vllm-qwen3` | `/home/xsl/miniconda3/envs/vllm-qwen3` | 其他项目(未动) | — | -| `py310` | `/home/xsl/miniconda3/envs/py310` | 通用 Python 3.10 | — | +| `LivePortrait` | `~/miniconda3/envs/LivePortrait` | 头部动作驱动 | 8.6GB | +| `MuseTalk` | `~/miniconda3/envs/MuseTalk` | 唇形同步(主力) | 11GB | +| `latentsync` | `~/miniconda3/envs/latentsync` | 唇形同步(备用) | 9.2GB | +| `condiff-train-hair` | `~/miniconda3/envs/condiff-train-hair` | 其他项目(未动) | — | +| `onediff` | `~/miniconda3/envs/onediff` | 其他项目(未动) | — | +| `vllm-qwen3` | `~/miniconda3/envs/vllm-qwen3` | 其他项目(未动) | — | +| `py310` | `~/miniconda3/envs/py310` | 通用 Python 3.10 | — | --- @@ -36,7 +36,7 @@ conda env list ### 已下载权重 -位置:`/home/xsl/work/LivePortrait/pretrained_weights/`(总计 **1.2GB**) +位置:`~/work/LivePortrait/pretrained_weights/`(总计 **1.2GB**) ``` pretrained_weights/ @@ -81,7 +81,7 @@ pretrained_weights/ ### 已下载权重 -位置:`/home/xsl/work/MuseTalk/models/`(总计 **5.5GB**) +位置:`~/work/MuseTalk/models/`(总计 **5.5GB**) ``` models/ @@ -127,7 +127,7 @@ models/ ### 已下载权重 -位置:`/home/xsl/work/LatentSync/checkpoints/`(总计 **5.4GB**) +位置:`~/work/LatentSync/checkpoints/`(总计 **5.4GB**) ``` checkpoints/ @@ -180,10 +180,10 @@ pip install --pre torch torchvision torchaudio \ --index-url https://download.pytorch.org/whl/nightly/cu128 # LivePortrait 依赖 -pip install -r /home/xsl/work/LivePortrait/requirements.txt +pip install -r ~/work/LivePortrait/requirements.txt # MuseTalk 依赖(含 MMLab 完整链) -pip install -r /home/xsl/work/MuseTalk/requirements.txt +pip install -r ~/work/MuseTalk/requirements.txt pip install --no-build-isolation chumpy pip install mmengine MMCV_WITH_OPS=1 pip install mmcv==2.1.0 --no-build-isolation @@ -193,6 +193,6 @@ pip install "transformers>=4.45.0" ``` **可复用的模型权重**(无需重新下载): -- LivePortrait:`/home/xsl/work/LivePortrait/pretrained_weights/` — 直接用 -- MuseTalk:`/home/xsl/work/MuseTalk/models/` — 直接用 -- LatentSync(如需):`/home/xsl/work/LatentSync/checkpoints/` — 直接用 +- LivePortrait:`~/work/LivePortrait/pretrained_weights/` — 直接用 +- MuseTalk:`~/work/MuseTalk/models/` — 直接用 +- LatentSync(如需):`~/work/LatentSync/checkpoints/` — 直接用 diff --git a/main.py b/main.py index 99e121b..b90aa20 100644 --- a/main.py +++ b/main.py @@ -4,15 +4,13 @@ import asyncio import io import json import logging -import os import time import wave from dataclasses import dataclass, field import numpy as np -from aiortc import RTCPeerConnection, RTCSessionDescription from dotenv import load_dotenv -from fastapi import FastAPI, Request, WebSocket, WebSocketDisconnect +from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.staticfiles import StaticFiles @@ -26,11 +24,8 @@ from services.avatar import AvatarService from services.llm import LLMService from services.tts import TTSService from services.vad import VADService -from webrtc.tracks import AudioBus, AvatarAudioTrack load_dotenv() -if settings.hf_token: - os.environ["HF_TOKEN"] = settings.hf_token logging.basicConfig(level=logging.INFO) app = FastAPI(title="Visual Voice Chat") @@ -50,8 +45,6 @@ tts_service = TTSService() vad_service = VADService() avatar_service = AvatarService() pipeline = ChatPipeline(arbitrator, asr_service, llm_service, tts_service) -audio_bus = AudioBus(sample_rate=48000) -pcs: set[RTCPeerConnection] = set() subtitle_clients: set[WebSocket] = set() animation_clients: set[WebSocket] = set() audio_clients: set[WebSocket] = set() @@ -146,7 +139,6 @@ async def _broadcast_animation(payload: dict) -> None: def _runtime_payload() -> dict: return { "state": str(arbitrator.state), - "peers": len(pcs), "audio_clients": len(audio_clients), "subtitle_clients": len(subtitle_clients), "animation_clients": len(animation_clients), @@ -341,7 +333,6 @@ async def _process_user_audio_chunk(mono_16k: np.ndarray, vad_buffer: np.ndarray barge_t0 = time.perf_counter() was_avatar_speaking = arbitrator.state == SessionState.AVATAR_SPEAKING await arbitrator.on_speech_start() - await audio_bus.clear() await _broadcast_audio_reset("speech-start") await _broadcast_animation(await avatar_service.build_reset_payload(reason="speech-start")) if was_avatar_speaking: @@ -357,18 +348,6 @@ async def _process_user_audio_chunk(mono_16k: np.ndarray, vad_buffer: np.ndarray return vad_buffer -async def _consume_user_audio(track) -> None: - vad_buffer = np.zeros(0, dtype=np.float32) - while True: - frame = await track.recv() - pcm = frame.to_ndarray() - sample_rate = getattr(frame, "sample_rate", 48000) or 48000 - layout = getattr(frame, "layout", None) - channels = len(layout.channels) if layout and layout.channels else 1 - mono_16k = _resample_to_16k_mono(pcm, sample_rate, channels) - vad_buffer = await _process_user_audio_chunk(mono_16k, vad_buffer) - - @app.get("/health") async def health(): return _runtime_payload() @@ -377,10 +356,9 @@ async def health(): @app.get("/meta") async def meta(): return { - "stun_url": settings.stun_url, "https_enabled": bool(settings.ssl_certfile and settings.ssl_keyfile), - "host": settings.webrtc_host, - "port": settings.webrtc_port, + "host": settings.http_host, + "port": settings.http_port, "avatar_protocol": settings.avatar_control_protocol, } @@ -474,7 +452,6 @@ async def ws_audio(websocket: WebSocket): elif event_type == "audio_reset": _clear_buffered_audio() runtime.pending_voice_turn = False - await audio_bus.clear() await _broadcast_audio_reset("client-reset") elif event_type == "ping": await websocket.send_text(json.dumps({"type": "pong"}, ensure_ascii=False)) @@ -567,7 +544,6 @@ async def chat_reset(): runtime.vad_start_count = 0 runtime.vad_end_count = 0 runtime.last_animation_frame_count = 0 - await audio_bus.clear() await _broadcast_audio_reset("chat-reset") await _broadcast_animation(await avatar_service.build_reset_payload(reason="chat-reset")) return {"ok": True} @@ -583,60 +559,13 @@ async def root(): return FileResponse("web/index.html") -@app.post("/webrtc/offer") -async def webrtc_offer(request: Request): - params = await request.json() - offer = RTCSessionDescription(sdp=params["sdp"], type=params["type"]) - - replaced_previous = len(pcs) > 0 - if replaced_previous: - old_peers = list(pcs) - await asyncio.gather(*(peer.close() for peer in old_peers), return_exceptions=True) - for peer in old_peers: - pcs.discard(peer) - - pc = RTCPeerConnection() - pcs.add(pc) - - @pc.on("connectionstatechange") - async def on_connectionstatechange(): - if pc.connectionState in {"failed", "closed", "disconnected"}: - await pc.close() - pcs.discard(pc) - - @pc.on("track") - def on_track(track): - if track.kind == "audio": - asyncio.create_task(_consume_user_audio(track)) - - pc.addTrack(AvatarAudioTrack(audio_bus=audio_bus)) - - await pc.setRemoteDescription(offer) - answer = await pc.createAnswer() - await pc.setLocalDescription(answer) - - return JSONResponse( - { - "sdp": pc.localDescription.sdp, - "type": pc.localDescription.type, - "replaced_previous": replaced_previous, - } - ) - - -@app.on_event("shutdown") -async def on_shutdown(): - await asyncio.gather(*(pc.close() for pc in list(pcs)), return_exceptions=True) - pcs.clear() - - if __name__ == "__main__": import uvicorn uvicorn_kwargs = { "app": app, - "host": settings.webrtc_host, - "port": settings.webrtc_port, + "host": settings.http_host, + "port": settings.http_port, } if settings.ssl_certfile and settings.ssl_keyfile: uvicorn_kwargs["ssl_certfile"] = settings.ssl_certfile diff --git a/requirements.txt b/requirements.txt index f4aa64c..82d8737 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ fastapi uvicorn[standard] -aiortc numpy soundfile python-dotenv diff --git a/scripts/run_all_checks.sh b/scripts/run_all_checks.sh index 56c1ffe..186c0dc 100755 --- a/scripts/run_all_checks.sh +++ b/scripts/run_all_checks.sh @@ -4,7 +4,28 @@ set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT" -PY="/home/xsl/miniconda3/envs/MuseTalk/bin/python" + +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 "$ROOT/.venv/bin/python" ]]; then + echo "$ROOT/.venv/bin/python" + return 0 + fi + if command -v python3 >/dev/null 2>&1; then + command -v python3 + return 0 + fi + command -v python +} + +PY="$(pick_python)" if [[ -f ".env" ]]; then set -a @@ -17,7 +38,7 @@ SCHEME="http" if [[ -n "${SSL_CERTFILE:-}" && -n "${SSL_KEYFILE:-}" ]]; then SCHEME="https" fi -BASE_URL="${BASE_URL:-${SCHEME}://127.0.0.1:${WEBRTC_PORT:-8080}}" +BASE_URL="${BASE_URL:-${SCHEME}://127.0.0.1:${HTTP_PORT:-8080}}" echo "[1/6] service status" bash scripts/service.sh status || true diff --git a/scripts/service-8018.sh b/scripts/service-8018.sh index 67437d7..74d91d6 100644 --- a/scripts/service-8018.sh +++ b/scripts/service-8018.sh @@ -28,8 +28,8 @@ normalize_host() { esac } -HOST="$(normalize_host "${WEBRTC_HOST:-0.0.0.0}")" -PORT="${WEBRTC_PORT:-8018}" +HOST="$(normalize_host "${HTTP_HOST:-0.0.0.0}")" +PORT="${HTTP_PORT:-8018}" SSL_CERTFILE="${SSL_CERTFILE:-$ROOT_DIR/certs/dev-cert.pem}" SSL_KEYFILE="${SSL_KEYFILE:-$ROOT_DIR/certs/dev-key.pem}" diff --git a/scripts/service.sh b/scripts/service.sh index 79f8216..e1efb95 100755 --- a/scripts/service.sh +++ b/scripts/service.sh @@ -28,7 +28,7 @@ start() { # If stale process still binds configured port, stop it first. local port - port="$(awk -F= '/^WEBRTC_PORT=/{print $2}' "$ROOT/.env" 2>/dev/null || true)" + port="$(awk -F= '/^HTTP_PORT=/{print $2}' "$ROOT/.env" 2>/dev/null || true)" port="${port:-8080}" local pids pids="$(ss -ltnp 2>/dev/null | sed -n "s/.*:${port} .*users:((\"python\",pid=\([0-9]\+\),.*/\1/p" | tr '\n' ' ')" diff --git a/scripts/start-public.sh b/scripts/start-public.sh index 998e331..84d73b0 100755 --- a/scripts/start-public.sh +++ b/scripts/start-public.sh @@ -21,8 +21,8 @@ pick_python() { 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" + if [[ -x "$ROOT/.venv/bin/python" ]]; then + echo "$ROOT/.venv/bin/python" return 0 fi if command -v python3 >/dev/null 2>&1; then @@ -44,8 +44,8 @@ normalize_host() { esac } -HOST="$(normalize_host "${WEBRTC_HOST:-0.0.0.0}")" -PORT="${WEBRTC_PORT:-8080}" +HOST="$(normalize_host "${HTTP_HOST:-0.0.0.0}")" +PORT="${HTTP_PORT:-8080}" PY="$(pick_python)" SCHEME="http" ARGS=(main:app --host "$HOST" --port "$PORT") diff --git a/scripts/start-test.sh b/scripts/start-test.sh index 7b43953..cd9e61c 100644 --- a/scripts/start-test.sh +++ b/scripts/start-test.sh @@ -34,8 +34,8 @@ pick_python() { 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" + if [[ -x "$ROOT/.venv/bin/python" ]]; then + echo "$ROOT/.venv/bin/python" return 0 fi if command -v python3 >/dev/null 2>&1; then @@ -46,8 +46,8 @@ pick_python() { } PY="$(pick_python)" -HOST="$(normalize_host "${WEBRTC_HOST:-0.0.0.0}")" -PORT="${WEBRTC_PORT:-8080}" +HOST="$(normalize_host "${HTTP_HOST:-0.0.0.0}")" +PORT="${HTTP_PORT:-8080}" SCHEME="http" ARGS=(main:app --host "$HOST" --port "$PORT") diff --git a/scripts/vendor_hf_models.py b/scripts/vendor_hf_models.py new file mode 100644 index 0000000..a02c1a4 --- /dev/null +++ b/scripts/vendor_hf_models.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python3 +"""Populate ./models from local silero wheel + Hugging Face snapshots (ASR/TTS only; needs network for HF).""" +from __future__ import annotations + +import argparse +import importlib.resources as ir +import sys +from pathlib import Path + +_ROOT = Path(__file__).resolve().parent.parent +_DEFAULT_ASR = _ROOT / "models" / "asr" / "SenseVoiceSmall" +_DEFAULT_KOKORO = _ROOT / "models" / "tts" / "Kokoro-82M" +_VAD_JIT = _ROOT / "models" / "vad" / "silero_vad.jit" + + +def _copy_silero_vad() -> None: + _VAD_JIT.parent.mkdir(parents=True, exist_ok=True) + src = ir.files("silero_vad.data").joinpath("silero_vad.jit") + _VAD_JIT.write_bytes(src.read_bytes()) + print(f"VAD -> {_VAD_JIT}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--asr-repo", default="FunAudioLLM/SenseVoiceSmall") + parser.add_argument("--kokoro-repo", default="hexgrad/Kokoro-82M") + parser.add_argument("--asr-dir", type=Path, default=_DEFAULT_ASR) + parser.add_argument("--kokoro-dir", type=Path, default=_DEFAULT_KOKORO) + parser.add_argument("--asr-only", action="store_true") + parser.add_argument("--kokoro-only", action="store_true") + parser.add_argument("--skip-hf", action="store_true", help="Only copy Silero VAD from installed wheel") + args = parser.parse_args() + + try: + _copy_silero_vad() + except Exception as exc: + print(f"Silero VAD copy failed (pip install silero-vad): {exc}", file=sys.stderr) + return 1 + + if args.skip_hf: + print("Done (HF skipped).") + return 0 + + try: + from huggingface_hub import snapshot_download + except ImportError: + print("Install huggingface_hub for ASR/TTS downloads.", file=sys.stderr) + return 1 + + if not args.kokoro_only: + args.asr_dir.mkdir(parents=True, exist_ok=True) + print(f"ASR -> {args.asr_dir}") + snapshot_download(repo_id=args.asr_repo, local_dir=str(args.asr_dir)) + if not args.asr_only: + args.kokoro_dir.mkdir(parents=True, exist_ok=True) + print(f"Kokoro -> {args.kokoro_dir}") + snapshot_download(repo_id=args.kokoro_repo, local_dir=str(args.kokoro_dir)) + + print("Done.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/__pycache__/asr.cpython-312.pyc b/services/__pycache__/asr.cpython-312.pyc index dbffe4b545925a45b9b3f9b2a2faa2d535ee3e7e..805d1a85f5d377da71b9cdd03ef30e929115fc4d 100644 GIT binary patch delta 2125 zcma)7TWlLe6rGuUc-POo8oPOJUL-DgRFt++idw2T2^AqFO^MV1vYhNDvAeOY*>zJ} zYZ5_(QmImj7KunzE5siPKTtmS1T{aXKaSEWW=SNZ!hfiPK&ph8S;t8VKU`_=+?hFZ z=FXivcklVv8@!J^9w#7jeD6v0zH`MJfJ-aw9T8UL=J*L70vX6ibxa7P8ck+aj(JR$b?50WqygB@FFk-WXBSag*CK4=S!oYbLEr382ffapAAAwxSZ6Z ziFiaMwl|{T@MJ13t4dgo;;ExMu%yEP01Mv|<1?V|ZwA2HJ}SnT^^O`o3k(qBsM(=; zbw`Oph6cC`?xB*CACkU|GA;mOA_XtJ0A~IR9s@ykBvXHJ?20s(kQG%T4k}Z60;i?9 zsHR2ZGksE~Vk!}zj?ScTgr2E8rX}K;9=fD>C@C7BO3WoyMOUPbctSdY?%#(8`=lU; zdE&O^z>1boZzvWY)o3`Z^1-O8j3o5I1PRdq#tCdW$ka3$CBcJ9a|`w?_`)=Zwu|8; z2{HB(F8XQj6151nh3ZWquq8+vg0v|#FJ8>M+;@6!_1^B=_BQ64FMJQb({fV(rZ@(j($I-{g>=bLv6?9@j1Ug6YR7w|^g)&>rvn)e$kCl1VHRsN<5a?nN z34j4h7ntQ{K$e#sA@Ke%09l7fGSgrT&Tz*>A;s(fX`;pk>ela@BiX0r(Q2G20HZm8hO-N^Z`omxASlw++?nS zdDsGUsdNIeuardwA8RR%9JYqzE|A>@BCm-lof3i)@9n)J{~_-vm7^NPhEqv7qAR$`e8ip{%T&_G zbcCu;YSV(1nARigr;1(~rbW$SG#%pr?H;FHUQNu*DA?i@`{D4@ghj)+(LBYqdL;_0 zqUsn?<~-LjB;ZYqT1xZI{urh7&Hm8PZCTIdt(!OAi`6Jiupm?nl zntyY(L%WCllbuLu5)-B$A>zURyZP}cMeu!Op?`SVkEZ!U)( zyX*3`9m}EH!`tqPJHxkzSF2XF$L%AXQs zaIJY+sDmC;6<*?pXb4nuRk+UNedVSg-hY!u3!fk=$R4>sdRfJ5tD#G1nV0N(V)3&| zMAc_AnwFC-?=Q;~zZQSdyFan)i#clxb z-e(AM^RU1W^LN`UvQPrLFXf>HDOhGX7sBD`l%B#$IE=|3jTCyCn&by!IcULFqmwq& z;`MZrjAB|pEH0j!OQtOpPg;EOw^95_h%<^lY-cvUL^q*679WA_`&+boa2`u|jimEv zN>$F|aWX@LtMw7J%RmTsK=V`J-2s6eQ2P{A{05qS5o?zHTVl11)hhV PKP%3|dH4)aZIk~4Jsaw1 delta 1262 zcmZ8gO>Y}T7=CAVXT0mLq&AIHI|QeMB(BmVrE2O_q!5Av5@`_xfm#V!PIlrr*{rQu zyQw3aa_Aukgc=P8q$=F2a44$!2S|HBoC-*Ug@{W11Exme%FG(Og*iMs^Sm?9`?(JW z_Y%<`B9S1-wKDO2v105-`;oOfIyQ%Ga+P9Wcs;7D|6R=3M{VY+iQ#Rq^qXLjef%#M ztl$(;A#@N@|PV11PognL2o&ussQm2hB zr%TR!cCyTk^8mcB@P83C>)>EI%!L-uCf(*Uitre zVv6dhSDd3m@&1mdVc_WoscY*grFvGs4OXa5Zo^(zAHfx{pg}$*pWkhNx6l?`*RH@8 z%Jf|RP%H%IXSP!dRcEeh6-$eYmnN5(W<@v|`z zP~C8U2Vt72@grmS$aw$pz~B!3t#5Q^p^2lBSAQKGJxrcIObs3O5BwF-Uy9w*zYH~l zV#CbR6ePx)C&e9e5`{!Gcq*kTet$0T<}Chh)|?+92efZ~h#U-&`H_1Y!D)1HR9Zo9(;J?~nd_>FlQGMP{|JrHGb&<7gu)x7Nuf6;YT>Uh z`56*F-HiUMihhmS)#_2t)K4UJBzkm>CJ>xYwzg3%E zDwjJcp{XH+j^N}Ih#o=e3B(?o$(_V6X7Z7FBKcMJCv*5S`Zt|MTj(h$^cDXB Dy~7*S diff --git a/services/__pycache__/llm.cpython-312.pyc b/services/__pycache__/llm.cpython-312.pyc index dff60e578c933bf11e7b59b87290fead04728853..830391889e5c0422d4ed3287c1ce8f45c1b6574b 100644 GIT binary patch delta 1674 zcmZuxTTC2P7(QoiyTi`TQZCB^7KWBZm(oJH6rt@F5UjDVwZUMZTC(mOWa;e1GehZ? z1+<}wmnKbfUTUn-L|+;(F=^5mA1NdzzSL;bI;n|?G3lEt?Sq0Qo-@19P(2U-{O9{` z=ltJ4=cm?2!ZxNtNdwe(kk8)KF!0gp$>qmJ*NQh%11Uk?KLrS>DT#hap?u0xh zL%;#VO_j08oeO!EU>+G$IB*>3(jw4ZOI!i-US0l{dxg7s4)(z^nOb$1;Z3M>kY&t5 zOCn+Z8S=VNw@=%dm+h9_OMD*9g7HE|0CbsTVbwwg>;=}It)O~3S}S|rLCCb=DAGaI|FCHd43ekz>p{MqD^|mJ`$1IcUKJ# z4rwtXj*^xZPsS~6A{sZ4-mL}AP&xTRTC9{(X_Sn{v6q&zJ%*7OkEY|}XHkZ{>T34$ zwqPP-B5&o<`IyP>Og?kH>{6*>{szb&t~oI$C~}x=x_kP<52|)wi+%jud;OPBJWxum zMBb0wsqXx`^qz9`d*$e6PUPh9hFM8tSL%9;UT6Zho8b* zsj6;CBiTZ&;6wGj3*r)tN^h?^F!BtPzI z*IOCjicO~CF=X1V1TxL&TL?eTU@4lW{Oh=uMQTixFj2<@o8OR#_cFbO3R}R)OgnM7 zp6*SN3XLrsBt}U~Lk9y#=s-UNf4&3m`%16OelUAY|J3-%xKq=5zqCFlsB-t`cT3<7h_>wuHH|8H>Gz8b!#blp+9e(_ap=49&ixA_t+abF`NS9OMKkF8_V7o4Q~h^Yc#9&Zlr@ zJZ_M>?vTq9P-z|lf>{JgKcElKT>;=<=`BD zTZ|bfitz}Aw*BiWn&2pjR8*g8qcEK{Tf)ei#z|X_kUj?tH=_w<(bAM}FHnx+$V zn$As=M#USnl{ACjP5k1?+CUs_%()&QE>xc zb{eO7*v&L1*fV2>saOP$xN7TbV!Z delta 2334 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

8&4ba@Ah@1)|M+R-3ji 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 diff --git a/services/asr.py b/services/asr.py index c2cba6b..4602f38 100644 --- a/services/asr.py +++ b/services/asr.py @@ -1,13 +1,25 @@ from __future__ import annotations import logging +from pathlib import Path from typing import Optional import numpy as np +from config import asr_bundle_dir + logger = logging.getLogger(__name__) +def _asr_model_path() -> Path: + p = asr_bundle_dir().resolve() + if not p.is_dir(): + raise FileNotFoundError(f"ASR model directory missing: {p}") + if not (p / "configuration.json").is_file(): + raise FileNotFoundError(f"ASR bundle incomplete (no configuration.json): {p}") + return p + + class ASRService: def __init__(self) -> None: self._model = None @@ -21,16 +33,26 @@ class ASRService: if self._ready or self._attempted: return self._attempted = True + try: + model_dir = _asr_model_path() + except FileNotFoundError as exc: + self._init_error = str(exc) + self._device = "unavailable" + logger.error("%s", exc) + return + from funasr import AutoModel from funasr.utils.postprocess_utils import rich_transcription_postprocess + model_ref = str(model_dir) errors: list[str] = [] for device in ("cuda:0", "cpu"): try: self._model = AutoModel( - model="FunAudioLLM/SenseVoiceSmall", + model=model_ref, device=device, hub="hf", + disable_update=True, ) self._post = rich_transcription_postprocess self._ready = True @@ -56,8 +78,6 @@ class ASRService: use_itn=True, ) return self._post(res[0]["text"]) - - # When ASR is unavailable, avoid emitting fake user text that would trigger a bogus LLM reply. return "" @property diff --git a/services/llm.py b/services/llm.py index c00b6a0..26b616f 100644 --- a/services/llm.py +++ b/services/llm.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import os import re from typing import Optional from collections import deque @@ -21,23 +20,6 @@ class LLMService: self._history: deque[dict] = deque(maxlen=12) # 6 turns (user+assistant) self._system_prompt = (settings.llm_system_prompt or "").strip() or "请用中文口语化简短回复,1-2句。" - if not settings.llm_api_key: - self._error = "missing LLM api key: set LLM_API_KEY or DEEPSEEK_API_KEY" - return - - # If DeepSeek key is provided but no explicit base URL override, - # route to DeepSeek-compatible OpenAI endpoint by default. - if ( - os.getenv("DEEPSEEK_API_KEY") - and not os.getenv("LLM_BASE_URL") - and not os.getenv("DEEPSEEK_BASE_URL") - and (self._base_url.lower() == "https://api.openai.com/v1" or not self._base_url) - ): - self._base_url = "https://api.deepseek.com/v1" - - if not self._model: - base = self._base_url.lower() - self._model = "deepseek-chat" if "deepseek" in base else "gpt-4o-mini" try: from openai import AsyncOpenAI @@ -50,7 +32,7 @@ class LLMService: self._error = None except Exception as exc: # pragma: no cover self._error = str(exc) - logger.warning("LLM online mode unavailable: %s", exc) + logger.warning("LLM client init failed: %s", exc) @staticmethod def _normalize_reply_text(text: str) -> str: @@ -70,7 +52,7 @@ class LLMService: async def reply_with_meta(self, text: str) -> tuple[str, str]: if not self._ready or self._client is None: - return f"收到:{text}。这是本地兜底回复。", "fallback" + return "", "unavailable" try: resp = await self._client.chat.completions.create( @@ -86,9 +68,9 @@ class LLMService: return content, "online" except Exception as exc: # pragma: no cover self._error = str(exc) - logger.warning("LLM request failed, fallback used: %s", exc) + logger.warning("LLM request failed: %s", exc) - return f"收到:{text}。这是本地兜底回复。", "fallback" + return "", "error" async def reply(self, text: str) -> str: content, _ = await self.reply_with_meta(text) diff --git a/services/tts.py b/services/tts.py index 44ed33c..71cafa8 100644 --- a/services/tts.py +++ b/services/tts.py @@ -2,22 +2,49 @@ from __future__ import annotations import logging import re +from pathlib import Path from typing import Optional import numpy as np -from config import settings +from config import kokoro_bundle_dir, settings logger = logging.getLogger(__name__) +def _kokoro_root() -> Path: + p = kokoro_bundle_dir().resolve() + if not p.is_dir(): + raise FileNotFoundError(f"Kokoro model directory missing: {p}") + cfg = p / "config.json" + w0 = p / "kokoro-v1_0.pth" + w1 = p / "kokoro-v1_1-zh.pth" + weight = w0 if w0.is_file() else (w1 if w1.is_file() else None) + if not cfg.is_file() or weight is None: + raise FileNotFoundError(f"Kokoro bundle incomplete (need config.json and weight .pth): {p}") + return p + + class TTSService: def __init__(self) -> None: self._pipeline = None + self._kokoro_root: Optional[Path] = None self._ready = False self._attempted = False self._init_error: Optional[str] = None + def _voice_pt_path(self) -> Path: + root = self._kokoro_root + if root is None: + raise RuntimeError("Kokoro pipeline not initialized") + name = (settings.tts_voice or "").strip() + if not name: + raise ValueError("TTS_VOICE must be set (voice name, e.g. zf_xiaoxiao)") + p = root / "voices" / f"{name}.pt" + if not p.is_file(): + raise FileNotFoundError(f"Voice file missing: {p}") + return p + def _ensure_loaded(self) -> None: if self._ready or self._attempted: return @@ -25,11 +52,24 @@ class TTSService: try: import kokoro - self._pipeline = kokoro.KPipeline(lang_code="z") + root = _kokoro_root() + w0 = root / "kokoro-v1_0.pth" + w1 = root / "kokoro-v1_1-zh.pth" + weight = w0 if w0.is_file() else w1 + hf_repo_id = ( + "hexgrad/Kokoro-82M-v1.1-zh" if weight.name.startswith("kokoro-v1_1") else "hexgrad/Kokoro-82M" + ) + kmodel = kokoro.KModel( + repo_id=hf_repo_id, + config=str(root / "config.json"), + model=str(weight), + ) + self._pipeline = kokoro.KPipeline(lang_code="z", model=kmodel, repo_id=str(root)) + self._kokoro_root = root self._ready = True except Exception as exc: # pragma: no cover self._init_error = str(exc) - logger.warning("TTS fallback mode: %s", exc) + logger.error("TTS init failed: %s", exc) @staticmethod def _normalize_text(text: str) -> str: @@ -62,20 +102,24 @@ class TTSService: if not text: return np.zeros(1, dtype=np.float32), 24000 self._ensure_loaded() - if self._ready and self._pipeline is not None: - chunks = [] - for _, _, audio in self._pipeline( - text, - voice=settings.tts_voice, - speed=max(0.8, min(1.1, settings.tts_speed)), - split_pattern=r"\n+", - ): - chunks.append(self._apply_edge_fade(np.asarray(audio, dtype=np.float32), 24000)) - if chunks: - return np.concatenate(chunks), 24000 - - # 0.5s silence fallback for flow verification - return np.zeros(12000, dtype=np.float32), 24000 + if not self._ready or self._pipeline is None: + return np.zeros(1, dtype=np.float32), 24000 + try: + voice_path = str(self._voice_pt_path()) + except (OSError, ValueError, RuntimeError) as exc: + logger.error("TTS voice: %s", exc) + return np.zeros(1, dtype=np.float32), 24000 + chunks = [] + for _, _, audio in self._pipeline( + text, + voice=voice_path, + speed=max(0.8, min(1.1, settings.tts_speed)), + split_pattern=r"\n+", + ): + chunks.append(self._apply_edge_fade(np.asarray(audio, dtype=np.float32), 24000)) + if chunks: + return np.concatenate(chunks), 24000 + return np.zeros(1, dtype=np.float32), 24000 @property def health(self) -> dict: diff --git a/services/vad.py b/services/vad.py index de75e55..4fb3394 100644 --- a/services/vad.py +++ b/services/vad.py @@ -2,6 +2,8 @@ from __future__ import annotations from typing import Optional +from config import vad_bundle_path + class VADService: def __init__(self) -> None: @@ -15,10 +17,15 @@ class VADService: if self._ready or self._attempted: return self._attempted = True + path = vad_bundle_path().resolve() + if not path.is_file(): + self._error = f"missing Silero VAD weights: {path}" + return try: - from silero_vad import VADIterator, load_silero_vad + from silero_vad import VADIterator + from silero_vad.utils_vad import init_jit_model - self._model = load_silero_vad() + self._model = init_jit_model(str(path)) self._iterator = VADIterator( self._model, threshold=0.65, diff --git a/web/app.js b/web/app.js index f8899a3..d2c311e 100644 --- a/web/app.js +++ b/web/app.js @@ -567,12 +567,16 @@ function renderLoop() { } function updateMetrics(data) { - mPeers.textContent = String(data.audio_clients ?? data.peers ?? 0); + mPeers.textContent = String(data.audio_clients ?? 0); mBusy.textContent = data.pipeline_busy ? "是" : "否"; mLatency.textContent = `${data.last_latency_ms ?? 0} ms`; mAsrLatency.textContent = `${data.last_asr_latency_ms ?? 0} ms`; mLlmLatency.textContent = `${data.last_llm_latency_ms ?? 0} ms`; - mLlmSource.textContent = data.last_llm_source === "online" ? "在线" : "兜底"; + { + const s = data.last_llm_source; + mLlmSource.textContent = + s === "online" ? "在线" : s === "error" ? "失败" : s === "skipped" ? "跳过" : s === "unavailable" ? "不可用" : s || "--"; + } mLlmHistory.textContent = String(data?.llm?.history_items ?? 0); mTtsLatency.textContent = `${data.last_tts_latency_ms ?? 0} ms`; mTtsFirst.textContent = `${data.last_tts_first_chunk_ms ?? 0} ms`; @@ -584,7 +588,7 @@ function updateMetrics(data) { mAnimDriver.textContent = data?.avatar?.driver || data.last_animation_mode || "--"; mAnimFrames.textContent = String(data.last_animation_frame_count ?? data?.avatar?.last_frame_count ?? 0); - setDot(dConn, (data.audio_clients ?? data.peers ?? 0) > 0 ? "ok" : "warn"); + setDot(dConn, (data.audio_clients ?? 0) > 0 ? "ok" : "warn"); setDot(dVad, (data.vad_start_count ?? 0) > 0 ? "ok" : "warn"); setDot(dAsr, data?.asr?.ready ? "ok" : "warn"); setDot(dLlm, data?.llm?.ready ? "ok" : "bad"); diff --git a/webrtc/__init__.py b/webrtc/__init__.py deleted file mode 100644 index 8b13789..0000000 --- a/webrtc/__init__.py +++ /dev/null @@ -1 +0,0 @@ - diff --git a/webrtc/__pycache__/__init__.cpython-310.pyc b/webrtc/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 45d1b7b856a5e14948dcd8f1bce9f4b7423b3f27..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 129 zcmd1j<>g`kg22m%GZ}&OV-N=!fCL?YxR?bWHafpR&ib|BrwOhAH#0RWTs8sz{0 diff --git a/webrtc/__pycache__/__init__.cpython-312.pyc b/webrtc/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 4deb15a3e4bf8c038065573cd3cd569ce2ecc92a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 138 zcmX@j%ge<81Y1OoWikTk#~=v|ImJQp0U4BWA?GpxXu_)e+@!1$s-o?Cg((pNHhiBR%FF? z(>6Ria$>jXj`|Bt5B*M5j7!au;knUVTyB=*O0#0xg{T_OH|OJ8vu4^}bRu48E^u~> z$)YShWU{m`nu}=7$ue5yu(WSA>u6VG7452NpOj*aHRca#=MApsf+Xo?LDudj=_=aZ zO%=4L1=^9}y?zLy_JdHigSAYBLHsWIw;Mv2ZuDili_vkYOLy8b%xTl3*4vp1vaZtQ zwJ=Sw+8RDVD^+gX53)e5h3bC06}D&-v(H3?*TD^0lS>A%os>(`m4$~+(~_rTNzOfl z2<2&WEW7E*GqNU6fal7yazQSFD#$txc}{y~ldtsC!w0`(OwXl3+>1gVdkeMIPBQJP zFzc&C+pFCqY>An@5KZO#q|pl?27JJLmhpXtx5%uaeL$2WiSJ6b#vX~)&G8D2f~tU4 zbRu2wTU-5PJJp3C-AP*QuD0Lmwzjq7NAz<2D7+s=5S^Wd(T17$h^f<<`o)Ent!^By z{49-Dda5h?t!(9ka9w4s6-c?mCHR|!f*G)SOCl8F}m7o z#TcJme%_C}N%xCV(pyespj5DPL;$q&BFbYD!0$nPh=X?66?Pj2{W|;f4GxiXxa8ZG zS{(49km7*vaqz6n?l{tt_9tRbV2(R14A6)1qT@+t+g7)-VrCCZD02fbr19kgF5PoX z78cn7Kj8a(&ypVIR_ZYBtSU7_W`~~ zzaODcPoung$Z?9pFY(-jDcY@WZ$}Ypq_jU>KO}{3K4+f4UK|=wqspTVY(IcLhS}=wu84wdfG7}Rtq%D4SKyWkx*}YH`LBX)D1E;?kUu97$mx2 zSkFDHmT0&Yx08mgPJveEh~XIX(Uyu+V-BC5)W!d^)+6``yzja zFaLYDHZggCZ<(%K`GsB=*s_rs9X8xteV1r})$M1Rx3#?$ z22%6nxDIXLs(c0`Q~DGsu*Pj(h5G9JG(5QqRW69?zpBOBKkf3;KkV{uWuj1;tG}I2gHb z?x0R93W0#+MCyA~9X*J;3f>izlrn(a!#uwH-L^36i8*qkV#8jA$z_JpB%cjP0`_ClfMZT>y-jPjJM18$dG&>sdhx zY3)-l(lY}T1|QT{vB=>sea;$1?WBFcMlN)u3rxLA)oWDUM5S%Nm!*on50zzY=x#ko z!-kXl%u{AO;=1Vjttd!S-=F%E`NB(BCM9Dj6#d7FZ}WPPsA4fvi&&ZE~Ex(B0>3+!f{LdVOBozk}G zeH#H=Y}K~!-traEx^dAw_QpBTVgI@N4Jy5L|( z`}_rGgf z@7{RFUwiwt8*koTy`{?vF+NnInIv>w-ca4xA7xkSHnA+S1wv$9oS1?!-BHNU#mOGD z1x(7FcaBFj`SQ*{lMwxI)=JH=0^T^U()D~zJC zRhy%{NmC3GT?a+*Yhc!6HW!83e-`Ey@mnZIo{@j^bQ$bf93spyC`8vFLZJm&mGih2 zOi^fYj6x<5jZny7iox$gN~C7aJ^^#>85Q`*!zQravS+qMw3Qd0k}RGMG?`7=4^mrB zwr${sAjSpUSOakcZrYz?y(#7(A9)?i=5jRE4}vyLn!4)y=%7xM*paK& z#Db@Y+gPH+WTTIJH4+m=SE?Lwi|`%u*TN_k&b7Kx6plD#F4{@@G45m+iFSi_7uQ@x zHl#&x--y?QGQH61s&Lsru^$Jmtu`>*pjD2`qtqc?&QpmT(hP=O$698OGe?@(z4or= c+0@IFJg3AjGf$KuZK{!!qpn)DZ`Qx|KM|JEKL7v# diff --git a/webrtc/__pycache__/tracks.cpython-312.pyc b/webrtc/__pycache__/tracks.cpython-312.pyc deleted file mode 100644 index f748168a85800c4aeb8059b7763387986eef80b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5956 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 AZvX%Q diff --git a/webrtc/audio_track.py b/webrtc/audio_track.py deleted file mode 100644 index b38f2fc..0000000 --- a/webrtc/audio_track.py +++ /dev/null @@ -1,9 +0,0 @@ -from __future__ import annotations - -from webrtc.tracks import AudioBus, AvatarAudioTrack - -__all__ = ["AvatarAudioTrack", "build_audio_track"] - - -def build_audio_track(audio_bus: AudioBus, sample_rate: int = 48000, frame_ms: int = 20) -> AvatarAudioTrack: - return AvatarAudioTrack(audio_bus=audio_bus, sample_rate=sample_rate, frame_ms=frame_ms) diff --git a/webrtc/gateway.py b/webrtc/gateway.py deleted file mode 100644 index 00fd135..0000000 --- a/webrtc/gateway.py +++ /dev/null @@ -1,24 +0,0 @@ -from __future__ import annotations - -from aiortc import RTCPeerConnection -from fastapi import FastAPI - -from webrtc.audio_track import build_audio_track -from webrtc.tracks import AudioBus - - -def attach_webrtc_tracks( - pc: RTCPeerConnection, - avatar_service, - arbitrator, - audio_bus: AudioBus, -) -> None: - _ = avatar_service - _ = arbitrator - pc.addTrack(build_audio_track(audio_bus)) - - -def register_gateway_routes(app: FastAPI) -> None: - # Gateway endpoints are currently implemented in `main.py`. - # This function exists to keep project structure aligned with task list. - _ = app diff --git a/webrtc/tracks.py b/webrtc/tracks.py deleted file mode 100644 index 1af68d9..0000000 --- a/webrtc/tracks.py +++ /dev/null @@ -1,76 +0,0 @@ -from __future__ import annotations - -import asyncio -from fractions import Fraction -from collections import deque - -import numpy as np -from aiortc import MediaStreamTrack -from av import AudioFrame - - -class AudioBus: - def __init__(self, sample_rate: int = 48000) -> None: - self.sample_rate = sample_rate - self._chunks: deque[np.ndarray] = deque() - self._lock = asyncio.Lock() - self._level = 0.0 - - async def enqueue(self, pcm_int16_mono: np.ndarray) -> None: - async with self._lock: - if pcm_int16_mono.ndim != 1: - pcm_int16_mono = pcm_int16_mono.reshape(-1) - chunk = pcm_int16_mono.astype(np.int16, copy=False) - self._chunks.append(chunk) - if chunk.size > 0: - rms = float(np.sqrt(np.mean((chunk.astype(np.float32) / 32768.0) ** 2))) - self._level = 0.85 * self._level + 0.15 * min(1.0, rms * 10.0) - - async def read(self, samples: int) -> np.ndarray: - out = np.zeros(samples, dtype=np.int16) - async with self._lock: - i = 0 - while i < samples and self._chunks: - head = self._chunks[0] - n = min(samples - i, head.shape[0]) - out[i : i + n] = head[:n] - i += n - if n == head.shape[0]: - self._chunks.popleft() - else: - self._chunks[0] = head[n:] - self._level = 0.92 * self._level - return out - - async def level(self) -> float: - async with self._lock: - return float(self._level) - - async def clear(self) -> None: - async with self._lock: - self._chunks.clear() - self._level = 0.0 - - -class AvatarAudioTrack(MediaStreamTrack): - kind = "audio" - - def __init__(self, audio_bus: AudioBus, sample_rate: int = 48000, frame_ms: int = 20) -> None: - super().__init__() - self.audio_bus = audio_bus - self.sample_rate = sample_rate - self.samples_per_frame = int(sample_rate * frame_ms / 1000) - self._pts = 0 - self._time_base = Fraction(1, sample_rate) - - async def recv(self) -> AudioFrame: - await asyncio.sleep(self.samples_per_frame / self.sample_rate) - mono = await self.audio_bus.read(self.samples_per_frame) - samples = mono.reshape(1, -1) - frame = AudioFrame(format="s16", layout="mono", samples=self.samples_per_frame) - frame.planes[0].update(samples.tobytes()) - frame.sample_rate = self.sample_rate - frame.pts = self._pts - frame.time_base = self._time_base - self._pts += self.samples_per_frame - return frame diff --git a/webrtc/video_track.py b/webrtc/video_track.py deleted file mode 100644 index 54fc003..0000000 --- a/webrtc/video_track.py +++ /dev/null @@ -1,7 +0,0 @@ -from __future__ import annotations - -__all__: list[str] = [] - - -def build_video_track(*_args, **_kwargs): - raise RuntimeError("Server-side video tracks are disabled in 3D avatar mode.")