#!/usr/bin/env python3 """启动本地 CAM++、FunASR 原生实时 WSS 服务和浏览器协议桥接层。""" from __future__ import annotations import json import os import socket import subprocess import sys import time from pathlib import Path from urllib.error import URLError from urllib.request import urlopen from dotenv import load_dotenv PROJECT_ROOT = Path(__file__).resolve().parents[1] # 直接从 backend/ 执行脚本时,该目录会排在搜索路径首位; # 将仓库根目录提前加入,确保后端模块和本地模型清单都能正确导入。 sys.path.insert(0, str(PROJECT_ROOT)) from backend.model_manifest import ( auxiliary_models, load_manifest, model_directory, resolve_auxiliary_model_id, resolve_model_id, ) load_dotenv(PROJECT_ROOT / ".env") def local_model(requested: str, models_dir: Path, kind: str) -> Path: """通过项目模型清单查找本地 ASR 或 VAD 模型。""" name = requested.strip() configured_path = Path(name) direct_candidates = ( [configured_path] if configured_path.is_absolute() else [PROJECT_ROOT / configured_path, models_dir / configured_path] ) for candidate in direct_candidates: if candidate.is_dir() and (candidate / "configuration.json").is_file(): return candidate.resolve() manifest = load_manifest() if kind == "asr": model_id = resolve_model_id(name, manifest) else: model_id = resolve_auxiliary_model_id(name, manifest, kind=kind) candidate = model_directory(model_id, manifest, models_dir) if candidate.is_dir() and (candidate / "configuration.json").is_file(): return candidate.resolve() raise FileNotFoundError( f"Local {kind.upper()} model '{name}' is missing; expected: {candidate}" ) def local_cam_model(models_dir: Path) -> Path: """从模型清单中确认 CAM++ 说话人验证资源完整。""" manifest = load_manifest() override = os.getenv("CAM_MODEL_PATH", "").strip() if override: path = Path(override) if not path.is_absolute(): path = models_dir / path path = path.resolve() configs = [ config for config in auxiliary_models(manifest).values() if config.get("kind") == "speaker_verification" ] if path.is_dir() and configs and all( (path / relative).is_file() for relative in configs[0].get("required_files", []) ): return path raise FileNotFoundError(f"CAM++ model is missing or incomplete: {path}") checked = [] for model_id, config in auxiliary_models(manifest).items(): if config.get("kind") != "speaker_verification": continue path = model_directory(model_id, manifest, models_dir) required = [path / relative for relative in config.get("required_files", [])] if path.is_dir() and all(item.is_file() for item in required): return path.resolve() checked.append(str(path)) raise FileNotFoundError("CAM++ speaker model is missing; checked: " + ", ".join(checked)) def wait_for_health(url: str, process: subprocess.Popen, key: str, seconds: int = 300) -> None: """等待 HTTP 子进程就绪;若进程提前退出则报告错误。""" deadline = time.monotonic() + seconds while time.monotonic() < deadline: code = process.poll() if code is not None: raise RuntimeError(f"Service exited before it became ready ({url}, exit={code})") try: with urlopen(url, timeout=2) as response: data = json.load(response) if data.get(key): return except (OSError, ValueError, URLError): pass time.sleep(0.5) raise TimeoutError(f"Service did not become ready within {seconds}s: {url}") def wait_for_tcp(host: str, port: int, process: subprocess.Popen, seconds: int = 300) -> None: """等待 FunASR 加载模型并打开内部 WebSocket。""" deadline = time.monotonic() + seconds while time.monotonic() < deadline: code = process.poll() if code is not None: raise RuntimeError(f"FunASR native WS exited before readiness (exit={code})") try: with socket.create_connection((host, port), timeout=0.5): pass # 遇到端口占用时明确报错,避免误将其他进程的端口视为本服务。 time.sleep(0.5) code = process.poll() if code is not None: raise RuntimeError(f"FunASR native WS exited before readiness (exit={code})") return except OSError: time.sleep(0.5) raise TimeoutError(f"FunASR native WS did not open {host}:{port} within {seconds}s") def stop_child(process: subprocess.Popen | None) -> None: """启动器关闭时停止受其管理的模型或 WebSocket 进程。""" if process is None or process.poll() is not None: return process.terminate() try: process.wait(timeout=10) except subprocess.TimeoutExpired: process.kill() process.wait() def main() -> None: """确认 ASR、VAD 和 CAM++ 均可用后,再开放公网 WebSocket 桥接服务。""" models_dir = Path(os.getenv("MODEL_DIR", "models")) if not models_dir.is_absolute(): models_dir = PROJECT_ROOT / models_dir models_dir = models_dir.resolve() asr = local_model( os.getenv("FUNASR_ASR_MODEL", "paraformer-zh-streaming"), models_dir, "asr" ) hotword_asr = local_model( os.getenv("FUNASR_HOTWORD_ASR_MODEL", "paraformer-zh-contextual"), models_dir, "asr", ) vad = local_model(os.getenv("FUNASR_VAD_MODEL", "fsmn-vad"), models_dir, "vad") cam = local_cam_model(models_dir) native_host = os.getenv("FUNASR_NATIVE_WS_HOST", "127.0.0.1") native_port = int(os.getenv("FUNASR_NATIVE_WS_PORT", "10095")) native_url = f"ws://{native_host}:{native_port}" aux_url = os.getenv("AUXILIARY_SERVICE_URL", "http://127.0.0.1:8010").rstrip("/") device = os.getenv("FUNASR_DEVICE", "cuda:0") vad_device = os.getenv("FUNASR_VAD_DEVICE", "cpu") ngpu = "1" if device.startswith("cuda") else "0" ncpu = os.getenv("FUNASR_NCPU", str(os.cpu_count() or 4)) web_port = int(os.getenv("WEB_PORT", "8082")) print( f"Local models: streaming ASR={asr}; hotword final ASR={hotword_asr}; " f"VAD={vad}; CAM++={cam}", flush=True, ) print( f"Runtime: ASR device={device}; VAD device={vad_device}; native WS={native_url}", flush=True, ) env = os.environ.copy() preload_kinds = { item.strip() for item in env.get("AUXILIARY_PRELOAD_KINDS", "speaker_verification").split(",") if item.strip() } # 实时 VAD 由 FunASR 原生服务负责;不要在 CAM++ 进程中重复加载。 preload_kinds.discard("vad") preload_kinds.add("speaker_verification") env.update( { "AUXILIARY_PRELOAD_KINDS": ",".join(sorted(preload_kinds)), "MODEL_DIR": str(models_dir), "FUNASR_ASR_MODEL": str(asr), "FUNASR_HOTWORD_ASR_MODEL": str(hotword_asr), "FUNASR_VAD_MODEL": str(vad), "FUNASR_DEVICE": device, "FUNASR_VAD_DEVICE": vad_device, "FUNASR_NATIVE_WS_URL": native_url, "CAM_MODEL_PATH": str(cam), "AUXILIARY_SERVICE_URL": aux_url, } ) auxiliary = None native = None websocket = None try: # 先加载 ASR 主模型,避免说话人辅助模型抢占显存导致识别模型无法启动。 native_args = [ sys.executable, str(PROJECT_ROOT / "backend" / "realtime_websocket" / "funasr_native_wss.py"), "--host", native_host, "--port", str(native_port), "--asr_model", str(hotword_asr), "--asr_model_revision", os.getenv("FUNASR_HOTWORD_ASR_MODEL_REVISION", "v2.0.4"), "--asr_model_online", str(asr), "--vad_model", str(vad), "--punc_model", "", "--device", device, "--vad_device", vad_device, "--ngpu", ngpu, "--ncpu", ncpu, "--certfile", "", "--keyfile", "", ] native = subprocess.Popen(native_args, cwd=PROJECT_ROOT, env=env) probe_host = "127.0.0.1" if native_host in {"0.0.0.0", "::"} else native_host wait_for_tcp(probe_host, native_port, native) auxiliary = subprocess.Popen( [sys.executable, "-m", "backend.auxiliary_server"], cwd=PROJECT_ROOT, env=env, ) wait_for_health(f"{aux_url}/health", auxiliary, "speaker_embedding_ready") websocket = subprocess.Popen( [sys.executable, "-m", "backend.run_funasr_demo", "--no-browser"], cwd=PROJECT_ROOT, env=env, ) wait_for_health( f"http://127.0.0.1:{web_port}/api/config", websocket, "engine" ) print(f"FunASR browser backend ready: ws://127.0.0.1:{web_port}/ws", flush=True) while True: for label, process in ( ("FunASR native WS", native), ("CAM++", auxiliary), ("browser WS adapter", websocket), ): code = process.poll() if code is not None: raise RuntimeError(f"{label} service exited (exit={code})") time.sleep(0.5) except KeyboardInterrupt: pass finally: stop_child(websocket) stop_child(native) stop_child(auxiliary) if __name__ == "__main__": main()