独立依赖
This commit is contained in:
@@ -14,15 +14,14 @@ TTS_SENTENCE_MAX_LEN=64
|
|||||||
TTS_SEGMENT_PAUSE_MS=90
|
TTS_SEGMENT_PAUSE_MS=90
|
||||||
TTS_FADE_MS=12
|
TTS_FADE_MS=12
|
||||||
|
|
||||||
# Web service
|
# Web service(与当前代码一致,仅 HTTP_HOST / HTTP_PORT)
|
||||||
WEBRTC_HOST=0.0.0.0
|
HTTP_HOST=0.0.0.0
|
||||||
WEBRTC_PORT=8018
|
HTTP_PORT=8018
|
||||||
STUN_URL=stun:stun.l.google.com:19302
|
|
||||||
CORS_ORIGINS=*
|
CORS_ORIGINS=*
|
||||||
|
|
||||||
# HTTPS (for cross-device mic/WebRTC)
|
# HTTPS(跨设备访问麦克风/页面时常需 TLS;路径相对项目根目录)
|
||||||
SSL_CERTFILE=/home/xsl/code/product/certs/dev-cert.pem
|
SSL_CERTFILE=certs/dev-cert.pem
|
||||||
SSL_KEYFILE=/home/xsl/code/product/certs/dev-key.pem
|
SSL_KEYFILE=certs/dev-key.pem
|
||||||
|
|
||||||
# 3D avatar driver mode
|
# 3D avatar driver mode
|
||||||
AVATAR_DRIVER_MODE=blendshape_stream
|
AVATAR_DRIVER_MODE=blendshape_stream
|
||||||
|
|||||||
@@ -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=
|
|
||||||
@@ -32,3 +32,8 @@ INFO: connection open
|
|||||||
INFO: connection closed
|
INFO: connection closed
|
||||||
INFO: connection closed
|
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]
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
3446
|
|
||||||
@@ -77,7 +77,11 @@ export LLM_BASE_URL="https://api.deepseek.com/v1"
|
|||||||
export LLM_MODEL="deepseek-chat"
|
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_SEGMENT_PAUSE_MS`:默认 `90`,给分段之间留一点停顿,但不要太拖。
|
||||||
- `TTS_FADE_MS`:默认 `12`,减轻段间拼接的突兀感。
|
- `TTS_FADE_MS`:默认 `12`,减轻段间拼接的突兀感。
|
||||||
|
|
||||||
|
部署到任意机器时:在项目根目录运行(保证 `models/`、`web/`、`certs/` 等相对路径有效)。证书可在 `.env` 里写相对路径(例如 `certs/dev-cert.pem`),会按项目根目录解析。也可用环境变量 `VISUAL_CHAT_PYTHON` 指定解释器。
|
||||||
|
|
||||||
本地启动:
|
本地启动:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -119,7 +125,7 @@ python scripts/smoke_test.py --text "你好,请做一个简短自我介绍。"
|
|||||||
- `http://127.0.0.1:8080/`
|
- `http://127.0.0.1:8080/`
|
||||||
- `http://127.0.0.1:8080/health`
|
- `http://127.0.0.1:8080/health`
|
||||||
|
|
||||||
如果 `.env` 里已经配置了 `WEBRTC_PORT=8018` 与 `SSL_CERTFILE` / `SSL_KEYFILE`,实际访问地址通常会变成:
|
如果 `.env` 里已经配置了 `HTTP_PORT=8018` 与 `SSL_CERTFILE` / `SSL_KEYFILE`,实际访问地址通常会变成:
|
||||||
|
|
||||||
- `https://<server-ip>:8018/`
|
- `https://<server-ip>:8018/`
|
||||||
- `https://<server-ip>:8018/health`
|
- `https://<server-ip>:8018/health`
|
||||||
@@ -135,7 +141,7 @@ bash scripts/gen-self-signed-cert.sh
|
|||||||
监听规则:
|
监听规则:
|
||||||
|
|
||||||
- 服务监听地址固定按 `0.0.0.0` 规则执行。
|
- 服务监听地址固定按 `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/text`: 文本输入链路,跳过 ASR/VAD。
|
||||||
- `POST /chat/reset`: 清空上下文和运行时状态。
|
- `POST /chat/reset`: 清空上下文和运行时状态。
|
||||||
- `GET /health`: 返回后端运行指标和组件健康状态。
|
- `GET /health`: 返回后端运行指标和组件健康状态。
|
||||||
- `GET /meta`: 返回 STUN、HTTPS、端口和动画协议配置。
|
- `GET /meta`: 返回 HTTPS、监听端口和动画协议配置。
|
||||||
- `GET /events`: Server-Sent Events 运行状态流。
|
- `GET /events`: Server-Sent Events 运行状态流。
|
||||||
- `WS /ws/audio`: 双向语音通道,上传麦克风 PCM 并接收回复语音。
|
- `WS /ws/audio`: 双向语音通道,上传麦克风 PCM 并接收回复语音。
|
||||||
- `WS /ws/subtitles`: 字幕流。
|
- `WS /ws/subtitles`: 字幕流。
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -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
|
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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
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
|
@classmethod
|
||||||
def normalize_listen_host(cls, value: str) -> str:
|
def normalize_listen_host(cls, value: str) -> str:
|
||||||
host = str(value or "0.0.0.0").strip().lower()
|
host = str(value or "0.0.0.0").strip().lower()
|
||||||
@@ -13,19 +37,20 @@ class Settings(BaseSettings):
|
|||||||
return "0.0.0.0"
|
return "0.0.0.0"
|
||||||
return host or "0.0.0.0"
|
return host or "0.0.0.0"
|
||||||
|
|
||||||
hf_token: str = ""
|
@field_validator("ssl_certfile", "ssl_keyfile", mode="after")
|
||||||
llm_api_key: str = Field(
|
@classmethod
|
||||||
default="",
|
def resolve_ssl_path(cls, value: str) -> str:
|
||||||
validation_alias=AliasChoices("LLM_API_KEY", "DEEPSEEK_API_KEY", "OPENAI_API_KEY"),
|
text = (value or "").strip()
|
||||||
)
|
if not text:
|
||||||
llm_base_url: str = Field(
|
return ""
|
||||||
default="https://api.deepseek.com/v1",
|
p = Path(text)
|
||||||
validation_alias=AliasChoices("LLM_BASE_URL", "DEEPSEEK_BASE_URL", "OPENAI_BASE_URL"),
|
if not p.is_absolute():
|
||||||
)
|
p = PROJECT_ROOT / p
|
||||||
llm_model: str = Field(
|
return str(p.resolve())
|
||||||
default="deepseek-chat",
|
|
||||||
validation_alias=AliasChoices("LLM_MODEL", "DEEPSEEK_MODEL", "OPENAI_MODEL"),
|
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_timeout: int = 8
|
||||||
llm_max_tokens: int = 150
|
llm_max_tokens: int = 150
|
||||||
llm_temperature: float = 0.55
|
llm_temperature: float = 0.55
|
||||||
@@ -44,26 +69,15 @@ class Settings(BaseSettings):
|
|||||||
tts_segment_pause_ms: int = 90
|
tts_segment_pause_ms: int = 90
|
||||||
tts_fade_ms: int = 12
|
tts_fade_ms: int = 12
|
||||||
|
|
||||||
webrtc_host: str = "0.0.0.0"
|
http_host: str = Field(default="0.0.0.0", validation_alias="HTTP_HOST")
|
||||||
webrtc_port: int = 8080
|
http_port: int = Field(default=8080, validation_alias="HTTP_PORT")
|
||||||
avatar_fps: int = 25
|
avatar_fps: int = 25
|
||||||
avatar_driver_mode: str = "blendshape_stream"
|
avatar_driver_mode: str = "blendshape_stream"
|
||||||
avatar_control_protocol: str = "ws"
|
avatar_control_protocol: str = "ws"
|
||||||
avatar_blendshape_schema: str = "arkit"
|
avatar_blendshape_schema: str = "arkit"
|
||||||
stun_url: str = "stun:stun.l.google.com:19302"
|
|
||||||
cors_origins: str = "*"
|
cors_origins: str = "*"
|
||||||
ssl_certfile: str = ""
|
ssl_certfile: str = ""
|
||||||
ssl_keyfile: 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()
|
settings = Settings()
|
||||||
|
|||||||
@@ -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_PORT>/`
|
||||||
|
- `http(s)://<服务器局域网IP>:<HTTP_PORT>/`(需防火墙放行该端口)
|
||||||
|
|
||||||
|
## 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/<TTS_VOICE>.pt`。
|
||||||
|
- **麦克风在别的设备上不可用**:多数浏览器要求 **HTTPS** 与用户手势;请启用 HTTPS 并在页面内点击「连接语音通道」等操作。
|
||||||
|
- **旧变量名**:`WEBRTC_HOST`、`WEBRTC_PORT`、`STUN_URL` 已废弃,请只使用 `HTTP_HOST`、`HTTP_PORT`。
|
||||||
|
|
||||||
|
更细的语音/文本流水线时序见 [development-guide.md](development-guide.md)。
|
||||||
+15
-15
@@ -13,13 +13,13 @@ conda env list
|
|||||||
|
|
||||||
| 环境名 | 路径 | 用途 | 磁盘占用 |
|
| 环境名 | 路径 | 用途 | 磁盘占用 |
|
||||||
|--------|------|------|---------|
|
|--------|------|------|---------|
|
||||||
| `LivePortrait` | `/home/xsl/miniconda3/envs/LivePortrait` | 头部动作驱动 | 8.6GB |
|
| `LivePortrait` | `~/miniconda3/envs/LivePortrait` | 头部动作驱动 | 8.6GB |
|
||||||
| `MuseTalk` | `/home/xsl/miniconda3/envs/MuseTalk` | 唇形同步(主力) | 11GB |
|
| `MuseTalk` | `~/miniconda3/envs/MuseTalk` | 唇形同步(主力) | 11GB |
|
||||||
| `latentsync` | `/home/xsl/miniconda3/envs/latentsync` | 唇形同步(备用) | 9.2GB |
|
| `latentsync` | `~/miniconda3/envs/latentsync` | 唇形同步(备用) | 9.2GB |
|
||||||
| `condiff-train-hair` | `/home/xsl/miniconda3/envs/condiff-train-hair` | 其他项目(未动) | — |
|
| `condiff-train-hair` | `~/miniconda3/envs/condiff-train-hair` | 其他项目(未动) | — |
|
||||||
| `onediff` | `/home/xsl/miniconda3/envs/onediff` | 其他项目(未动) | — |
|
| `onediff` | `~/miniconda3/envs/onediff` | 其他项目(未动) | — |
|
||||||
| `vllm-qwen3` | `/home/xsl/miniconda3/envs/vllm-qwen3` | 其他项目(未动) | — |
|
| `vllm-qwen3` | `~/miniconda3/envs/vllm-qwen3` | 其他项目(未动) | — |
|
||||||
| `py310` | `/home/xsl/miniconda3/envs/py310` | 通用 Python 3.10 | — |
|
| `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/
|
pretrained_weights/
|
||||||
@@ -81,7 +81,7 @@ pretrained_weights/
|
|||||||
|
|
||||||
### 已下载权重
|
### 已下载权重
|
||||||
|
|
||||||
位置:`/home/xsl/work/MuseTalk/models/`(总计 **5.5GB**)
|
位置:`~/work/MuseTalk/models/`(总计 **5.5GB**)
|
||||||
|
|
||||||
```
|
```
|
||||||
models/
|
models/
|
||||||
@@ -127,7 +127,7 @@ models/
|
|||||||
|
|
||||||
### 已下载权重
|
### 已下载权重
|
||||||
|
|
||||||
位置:`/home/xsl/work/LatentSync/checkpoints/`(总计 **5.4GB**)
|
位置:`~/work/LatentSync/checkpoints/`(总计 **5.4GB**)
|
||||||
|
|
||||||
```
|
```
|
||||||
checkpoints/
|
checkpoints/
|
||||||
@@ -180,10 +180,10 @@ pip install --pre torch torchvision torchaudio \
|
|||||||
--index-url https://download.pytorch.org/whl/nightly/cu128
|
--index-url https://download.pytorch.org/whl/nightly/cu128
|
||||||
|
|
||||||
# LivePortrait 依赖
|
# LivePortrait 依赖
|
||||||
pip install -r /home/xsl/work/LivePortrait/requirements.txt
|
pip install -r ~/work/LivePortrait/requirements.txt
|
||||||
|
|
||||||
# MuseTalk 依赖(含 MMLab 完整链)
|
# 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 --no-build-isolation chumpy
|
||||||
pip install mmengine
|
pip install mmengine
|
||||||
MMCV_WITH_OPS=1 pip install mmcv==2.1.0 --no-build-isolation
|
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/` — 直接用
|
- LivePortrait:`~/work/LivePortrait/pretrained_weights/` — 直接用
|
||||||
- MuseTalk:`/home/xsl/work/MuseTalk/models/` — 直接用
|
- MuseTalk:`~/work/MuseTalk/models/` — 直接用
|
||||||
- LatentSync(如需):`/home/xsl/work/LatentSync/checkpoints/` — 直接用
|
- LatentSync(如需):`~/work/LatentSync/checkpoints/` — 直接用
|
||||||
|
|||||||
@@ -4,15 +4,13 @@ import asyncio
|
|||||||
import io
|
import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import time
|
import time
|
||||||
import wave
|
import wave
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from aiortc import RTCPeerConnection, RTCSessionDescription
|
|
||||||
from dotenv import load_dotenv
|
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.middleware.cors import CORSMiddleware
|
||||||
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
@@ -26,11 +24,8 @@ from services.avatar import AvatarService
|
|||||||
from services.llm import LLMService
|
from services.llm import LLMService
|
||||||
from services.tts import TTSService
|
from services.tts import TTSService
|
||||||
from services.vad import VADService
|
from services.vad import VADService
|
||||||
from webrtc.tracks import AudioBus, AvatarAudioTrack
|
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
if settings.hf_token:
|
|
||||||
os.environ["HF_TOKEN"] = settings.hf_token
|
|
||||||
|
|
||||||
logging.basicConfig(level=logging.INFO)
|
logging.basicConfig(level=logging.INFO)
|
||||||
app = FastAPI(title="Visual Voice Chat")
|
app = FastAPI(title="Visual Voice Chat")
|
||||||
@@ -50,8 +45,6 @@ tts_service = TTSService()
|
|||||||
vad_service = VADService()
|
vad_service = VADService()
|
||||||
avatar_service = AvatarService()
|
avatar_service = AvatarService()
|
||||||
pipeline = ChatPipeline(arbitrator, asr_service, llm_service, tts_service)
|
pipeline = ChatPipeline(arbitrator, asr_service, llm_service, tts_service)
|
||||||
audio_bus = AudioBus(sample_rate=48000)
|
|
||||||
pcs: set[RTCPeerConnection] = set()
|
|
||||||
subtitle_clients: set[WebSocket] = set()
|
subtitle_clients: set[WebSocket] = set()
|
||||||
animation_clients: set[WebSocket] = set()
|
animation_clients: set[WebSocket] = set()
|
||||||
audio_clients: set[WebSocket] = set()
|
audio_clients: set[WebSocket] = set()
|
||||||
@@ -146,7 +139,6 @@ async def _broadcast_animation(payload: dict) -> None:
|
|||||||
def _runtime_payload() -> dict:
|
def _runtime_payload() -> dict:
|
||||||
return {
|
return {
|
||||||
"state": str(arbitrator.state),
|
"state": str(arbitrator.state),
|
||||||
"peers": len(pcs),
|
|
||||||
"audio_clients": len(audio_clients),
|
"audio_clients": len(audio_clients),
|
||||||
"subtitle_clients": len(subtitle_clients),
|
"subtitle_clients": len(subtitle_clients),
|
||||||
"animation_clients": len(animation_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()
|
barge_t0 = time.perf_counter()
|
||||||
was_avatar_speaking = arbitrator.state == SessionState.AVATAR_SPEAKING
|
was_avatar_speaking = arbitrator.state == SessionState.AVATAR_SPEAKING
|
||||||
await arbitrator.on_speech_start()
|
await arbitrator.on_speech_start()
|
||||||
await audio_bus.clear()
|
|
||||||
await _broadcast_audio_reset("speech-start")
|
await _broadcast_audio_reset("speech-start")
|
||||||
await _broadcast_animation(await avatar_service.build_reset_payload(reason="speech-start"))
|
await _broadcast_animation(await avatar_service.build_reset_payload(reason="speech-start"))
|
||||||
if was_avatar_speaking:
|
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
|
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")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return _runtime_payload()
|
return _runtime_payload()
|
||||||
@@ -377,10 +356,9 @@ async def health():
|
|||||||
@app.get("/meta")
|
@app.get("/meta")
|
||||||
async def meta():
|
async def meta():
|
||||||
return {
|
return {
|
||||||
"stun_url": settings.stun_url,
|
|
||||||
"https_enabled": bool(settings.ssl_certfile and settings.ssl_keyfile),
|
"https_enabled": bool(settings.ssl_certfile and settings.ssl_keyfile),
|
||||||
"host": settings.webrtc_host,
|
"host": settings.http_host,
|
||||||
"port": settings.webrtc_port,
|
"port": settings.http_port,
|
||||||
"avatar_protocol": settings.avatar_control_protocol,
|
"avatar_protocol": settings.avatar_control_protocol,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,7 +452,6 @@ async def ws_audio(websocket: WebSocket):
|
|||||||
elif event_type == "audio_reset":
|
elif event_type == "audio_reset":
|
||||||
_clear_buffered_audio()
|
_clear_buffered_audio()
|
||||||
runtime.pending_voice_turn = False
|
runtime.pending_voice_turn = False
|
||||||
await audio_bus.clear()
|
|
||||||
await _broadcast_audio_reset("client-reset")
|
await _broadcast_audio_reset("client-reset")
|
||||||
elif event_type == "ping":
|
elif event_type == "ping":
|
||||||
await websocket.send_text(json.dumps({"type": "pong"}, ensure_ascii=False))
|
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_start_count = 0
|
||||||
runtime.vad_end_count = 0
|
runtime.vad_end_count = 0
|
||||||
runtime.last_animation_frame_count = 0
|
runtime.last_animation_frame_count = 0
|
||||||
await audio_bus.clear()
|
|
||||||
await _broadcast_audio_reset("chat-reset")
|
await _broadcast_audio_reset("chat-reset")
|
||||||
await _broadcast_animation(await avatar_service.build_reset_payload(reason="chat-reset"))
|
await _broadcast_animation(await avatar_service.build_reset_payload(reason="chat-reset"))
|
||||||
return {"ok": True}
|
return {"ok": True}
|
||||||
@@ -583,60 +559,13 @@ async def root():
|
|||||||
return FileResponse("web/index.html")
|
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__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
uvicorn_kwargs = {
|
uvicorn_kwargs = {
|
||||||
"app": app,
|
"app": app,
|
||||||
"host": settings.webrtc_host,
|
"host": settings.http_host,
|
||||||
"port": settings.webrtc_port,
|
"port": settings.http_port,
|
||||||
}
|
}
|
||||||
if settings.ssl_certfile and settings.ssl_keyfile:
|
if settings.ssl_certfile and settings.ssl_keyfile:
|
||||||
uvicorn_kwargs["ssl_certfile"] = settings.ssl_certfile
|
uvicorn_kwargs["ssl_certfile"] = settings.ssl_certfile
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
fastapi
|
fastapi
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
aiortc
|
|
||||||
numpy
|
numpy
|
||||||
soundfile
|
soundfile
|
||||||
python-dotenv
|
python-dotenv
|
||||||
|
|||||||
@@ -4,7 +4,28 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
|
||||||
cd "$ROOT"
|
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
|
if [[ -f ".env" ]]; then
|
||||||
set -a
|
set -a
|
||||||
@@ -17,7 +38,7 @@ SCHEME="http"
|
|||||||
if [[ -n "${SSL_CERTFILE:-}" && -n "${SSL_KEYFILE:-}" ]]; then
|
if [[ -n "${SSL_CERTFILE:-}" && -n "${SSL_KEYFILE:-}" ]]; then
|
||||||
SCHEME="https"
|
SCHEME="https"
|
||||||
fi
|
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"
|
echo "[1/6] service status"
|
||||||
bash scripts/service.sh status || true
|
bash scripts/service.sh status || true
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ normalize_host() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
HOST="$(normalize_host "${WEBRTC_HOST:-0.0.0.0}")"
|
HOST="$(normalize_host "${HTTP_HOST:-0.0.0.0}")"
|
||||||
PORT="${WEBRTC_PORT:-8018}"
|
PORT="${HTTP_PORT:-8018}"
|
||||||
SSL_CERTFILE="${SSL_CERTFILE:-$ROOT_DIR/certs/dev-cert.pem}"
|
SSL_CERTFILE="${SSL_CERTFILE:-$ROOT_DIR/certs/dev-cert.pem}"
|
||||||
SSL_KEYFILE="${SSL_KEYFILE:-$ROOT_DIR/certs/dev-key.pem}"
|
SSL_KEYFILE="${SSL_KEYFILE:-$ROOT_DIR/certs/dev-key.pem}"
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ start() {
|
|||||||
|
|
||||||
# If stale process still binds configured port, stop it first.
|
# If stale process still binds configured port, stop it first.
|
||||||
local port
|
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}"
|
port="${port:-8080}"
|
||||||
local pids
|
local pids
|
||||||
pids="$(ss -ltnp 2>/dev/null | sed -n "s/.*:${port} .*users:((\"python\",pid=\([0-9]\+\),.*/\1/p" | tr '\n' ' ')"
|
pids="$(ss -ltnp 2>/dev/null | sed -n "s/.*:${port} .*users:((\"python\",pid=\([0-9]\+\),.*/\1/p" | tr '\n' ' ')"
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ pick_python() {
|
|||||||
echo "${VIRTUAL_ENV}/bin/python"
|
echo "${VIRTUAL_ENV}/bin/python"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [[ -x "/home/xsl/miniconda3/envs/MuseTalk/bin/python" ]]; then
|
if [[ -x "$ROOT/.venv/bin/python" ]]; then
|
||||||
echo "/home/xsl/miniconda3/envs/MuseTalk/bin/python"
|
echo "$ROOT/.venv/bin/python"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
@@ -44,8 +44,8 @@ normalize_host() {
|
|||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
HOST="$(normalize_host "${WEBRTC_HOST:-0.0.0.0}")"
|
HOST="$(normalize_host "${HTTP_HOST:-0.0.0.0}")"
|
||||||
PORT="${WEBRTC_PORT:-8080}"
|
PORT="${HTTP_PORT:-8080}"
|
||||||
PY="$(pick_python)"
|
PY="$(pick_python)"
|
||||||
SCHEME="http"
|
SCHEME="http"
|
||||||
ARGS=(main:app --host "$HOST" --port "$PORT")
|
ARGS=(main:app --host "$HOST" --port "$PORT")
|
||||||
|
|||||||
@@ -34,8 +34,8 @@ pick_python() {
|
|||||||
echo "${VIRTUAL_ENV}/bin/python"
|
echo "${VIRTUAL_ENV}/bin/python"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [[ -x "/home/xsl/miniconda3/envs/MuseTalk/bin/python" ]]; then
|
if [[ -x "$ROOT/.venv/bin/python" ]]; then
|
||||||
echo "/home/xsl/miniconda3/envs/MuseTalk/bin/python"
|
echo "$ROOT/.venv/bin/python"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if command -v python3 >/dev/null 2>&1; then
|
if command -v python3 >/dev/null 2>&1; then
|
||||||
@@ -46,8 +46,8 @@ pick_python() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PY="$(pick_python)"
|
PY="$(pick_python)"
|
||||||
HOST="$(normalize_host "${WEBRTC_HOST:-0.0.0.0}")"
|
HOST="$(normalize_host "${HTTP_HOST:-0.0.0.0}")"
|
||||||
PORT="${WEBRTC_PORT:-8080}"
|
PORT="${HTTP_PORT:-8080}"
|
||||||
SCHEME="http"
|
SCHEME="http"
|
||||||
ARGS=(main:app --host "$HOST" --port "$PORT")
|
ARGS=(main:app --host "$HOST" --port "$PORT")
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+23
-3
@@ -1,13 +1,25 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from config import asr_bundle_dir
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class ASRService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._model = None
|
self._model = None
|
||||||
@@ -21,16 +33,26 @@ class ASRService:
|
|||||||
if self._ready or self._attempted:
|
if self._ready or self._attempted:
|
||||||
return
|
return
|
||||||
self._attempted = True
|
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 import AutoModel
|
||||||
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
from funasr.utils.postprocess_utils import rich_transcription_postprocess
|
||||||
|
|
||||||
|
model_ref = str(model_dir)
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for device in ("cuda:0", "cpu"):
|
for device in ("cuda:0", "cpu"):
|
||||||
try:
|
try:
|
||||||
self._model = AutoModel(
|
self._model = AutoModel(
|
||||||
model="FunAudioLLM/SenseVoiceSmall",
|
model=model_ref,
|
||||||
device=device,
|
device=device,
|
||||||
hub="hf",
|
hub="hf",
|
||||||
|
disable_update=True,
|
||||||
)
|
)
|
||||||
self._post = rich_transcription_postprocess
|
self._post = rich_transcription_postprocess
|
||||||
self._ready = True
|
self._ready = True
|
||||||
@@ -56,8 +78,6 @@ class ASRService:
|
|||||||
use_itn=True,
|
use_itn=True,
|
||||||
)
|
)
|
||||||
return self._post(res[0]["text"])
|
return self._post(res[0]["text"])
|
||||||
|
|
||||||
# When ASR is unavailable, avoid emitting fake user text that would trigger a bogus LLM reply.
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
+4
-22
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
import re
|
import re
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from collections import deque
|
from collections import deque
|
||||||
@@ -21,23 +20,6 @@ class LLMService:
|
|||||||
self._history: deque[dict] = deque(maxlen=12) # 6 turns (user+assistant)
|
self._history: deque[dict] = deque(maxlen=12) # 6 turns (user+assistant)
|
||||||
self._system_prompt = (settings.llm_system_prompt or "").strip() or "请用中文口语化简短回复,1-2句。"
|
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:
|
try:
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
|
|
||||||
@@ -50,7 +32,7 @@ class LLMService:
|
|||||||
self._error = None
|
self._error = None
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
self._error = str(exc)
|
self._error = str(exc)
|
||||||
logger.warning("LLM online mode unavailable: %s", exc)
|
logger.warning("LLM client init failed: %s", exc)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_reply_text(text: str) -> str:
|
def _normalize_reply_text(text: str) -> str:
|
||||||
@@ -70,7 +52,7 @@ class LLMService:
|
|||||||
|
|
||||||
async def reply_with_meta(self, text: str) -> tuple[str, str]:
|
async def reply_with_meta(self, text: str) -> tuple[str, str]:
|
||||||
if not self._ready or self._client is None:
|
if not self._ready or self._client is None:
|
||||||
return f"收到:{text}。这是本地兜底回复。", "fallback"
|
return "", "unavailable"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await self._client.chat.completions.create(
|
resp = await self._client.chat.completions.create(
|
||||||
@@ -86,9 +68,9 @@ class LLMService:
|
|||||||
return content, "online"
|
return content, "online"
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
self._error = str(exc)
|
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:
|
async def reply(self, text: str) -> str:
|
||||||
content, _ = await self.reply_with_meta(text)
|
content, _ = await self.reply_with_meta(text)
|
||||||
|
|||||||
+52
-8
@@ -2,22 +2,49 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from config import settings
|
from config import kokoro_bundle_dir, settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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:
|
class TTSService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._pipeline = None
|
self._pipeline = None
|
||||||
|
self._kokoro_root: Optional[Path] = None
|
||||||
self._ready = False
|
self._ready = False
|
||||||
self._attempted = False
|
self._attempted = False
|
||||||
self._init_error: Optional[str] = None
|
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:
|
def _ensure_loaded(self) -> None:
|
||||||
if self._ready or self._attempted:
|
if self._ready or self._attempted:
|
||||||
return
|
return
|
||||||
@@ -25,11 +52,24 @@ class TTSService:
|
|||||||
try:
|
try:
|
||||||
import kokoro
|
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
|
self._ready = True
|
||||||
except Exception as exc: # pragma: no cover
|
except Exception as exc: # pragma: no cover
|
||||||
self._init_error = str(exc)
|
self._init_error = str(exc)
|
||||||
logger.warning("TTS fallback mode: %s", exc)
|
logger.error("TTS init failed: %s", exc)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_text(text: str) -> str:
|
def _normalize_text(text: str) -> str:
|
||||||
@@ -62,20 +102,24 @@ class TTSService:
|
|||||||
if not text:
|
if not text:
|
||||||
return np.zeros(1, dtype=np.float32), 24000
|
return np.zeros(1, dtype=np.float32), 24000
|
||||||
self._ensure_loaded()
|
self._ensure_loaded()
|
||||||
if self._ready and self._pipeline is not None:
|
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 = []
|
chunks = []
|
||||||
for _, _, audio in self._pipeline(
|
for _, _, audio in self._pipeline(
|
||||||
text,
|
text,
|
||||||
voice=settings.tts_voice,
|
voice=voice_path,
|
||||||
speed=max(0.8, min(1.1, settings.tts_speed)),
|
speed=max(0.8, min(1.1, settings.tts_speed)),
|
||||||
split_pattern=r"\n+",
|
split_pattern=r"\n+",
|
||||||
):
|
):
|
||||||
chunks.append(self._apply_edge_fade(np.asarray(audio, dtype=np.float32), 24000))
|
chunks.append(self._apply_edge_fade(np.asarray(audio, dtype=np.float32), 24000))
|
||||||
if chunks:
|
if chunks:
|
||||||
return np.concatenate(chunks), 24000
|
return np.concatenate(chunks), 24000
|
||||||
|
return np.zeros(1, dtype=np.float32), 24000
|
||||||
# 0.5s silence fallback for flow verification
|
|
||||||
return np.zeros(12000, dtype=np.float32), 24000
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def health(self) -> dict:
|
def health(self) -> dict:
|
||||||
|
|||||||
+9
-2
@@ -2,6 +2,8 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
|
from config import vad_bundle_path
|
||||||
|
|
||||||
|
|
||||||
class VADService:
|
class VADService:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
@@ -15,10 +17,15 @@ class VADService:
|
|||||||
if self._ready or self._attempted:
|
if self._ready or self._attempted:
|
||||||
return
|
return
|
||||||
self._attempted = True
|
self._attempted = True
|
||||||
|
path = vad_bundle_path().resolve()
|
||||||
|
if not path.is_file():
|
||||||
|
self._error = f"missing Silero VAD weights: {path}"
|
||||||
|
return
|
||||||
try:
|
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._iterator = VADIterator(
|
||||||
self._model,
|
self._model,
|
||||||
threshold=0.65,
|
threshold=0.65,
|
||||||
|
|||||||
+7
-3
@@ -567,12 +567,16 @@ function renderLoop() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function updateMetrics(data) {
|
function updateMetrics(data) {
|
||||||
mPeers.textContent = String(data.audio_clients ?? data.peers ?? 0);
|
mPeers.textContent = String(data.audio_clients ?? 0);
|
||||||
mBusy.textContent = data.pipeline_busy ? "是" : "否";
|
mBusy.textContent = data.pipeline_busy ? "是" : "否";
|
||||||
mLatency.textContent = `${data.last_latency_ms ?? 0} ms`;
|
mLatency.textContent = `${data.last_latency_ms ?? 0} ms`;
|
||||||
mAsrLatency.textContent = `${data.last_asr_latency_ms ?? 0} ms`;
|
mAsrLatency.textContent = `${data.last_asr_latency_ms ?? 0} ms`;
|
||||||
mLlmLatency.textContent = `${data.last_llm_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);
|
mLlmHistory.textContent = String(data?.llm?.history_items ?? 0);
|
||||||
mTtsLatency.textContent = `${data.last_tts_latency_ms ?? 0} ms`;
|
mTtsLatency.textContent = `${data.last_tts_latency_ms ?? 0} ms`;
|
||||||
mTtsFirst.textContent = `${data.last_tts_first_chunk_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 || "--";
|
mAnimDriver.textContent = data?.avatar?.driver || data.last_animation_mode || "--";
|
||||||
mAnimFrames.textContent = String(data.last_animation_frame_count ?? data?.avatar?.last_frame_count ?? 0);
|
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(dVad, (data.vad_start_count ?? 0) > 0 ? "ok" : "warn");
|
||||||
setDot(dAsr, data?.asr?.ready ? "ok" : "warn");
|
setDot(dAsr, data?.asr?.ready ? "ok" : "warn");
|
||||||
setDot(dLlm, data?.llm?.ready ? "ok" : "bad");
|
setDot(dLlm, data?.llm?.ready ? "ok" : "bad");
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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)
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
|
||||||
@@ -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.")
|
|
||||||
Reference in New Issue
Block a user