From 705b5732a6c589595fc512abdaa87d62a63c3a2d Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Fri, 10 Apr 2026 09:17:22 +0800 Subject: [PATCH] =?UTF-8?q?update=EF=BC=9A=E4=B8=BA=20test=20=E9=A1=B5?= =?UTF-8?q?=E9=9D=A2=E5=A2=9E=E5=8A=A0=E5=8F=AF=E9=85=8D=E7=BD=AE=E7=9A=84?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=94=A4=E9=86=92=E8=AF=8D=E8=BF=90=E8=A1=8C?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/test/js/app.js | 3 + .../test/js/core/network/wakeword-bridge.js | 92 ++++++++ .../xiaozhi-server/test/start_test_runtime.py | 33 +++ main/xiaozhi-server/test/test_page.html | 6 +- .../test/wakeword_runtime/__init__.py | 1 + .../test/wakeword_runtime/bridge/__init__.py | 1 + .../wakeword_runtime/bridge/event_bridge.py | 97 +++++++++ .../test/wakeword_runtime/config.json | 29 +++ .../test/wakeword_runtime/config/__init__.py | 2 + .../wakeword_runtime/config/config_loader.py | 131 +++++++++++ .../wakeword_runtime/config/logging_setup.py | 23 ++ .../test/wakeword_runtime/core/__init__.py | 3 + .../test/wakeword_runtime/core/detector.py | 206 ++++++++++++++++++ .../wakeword_runtime/core/detector_assets.py | 94 ++++++++ .../test/wakeword_runtime/core/microphone.py | 87 ++++++++ .../test/wakeword_runtime/plugins/__init__.py | 4 + .../test/wakeword_runtime/plugins/audio.py | 36 +++ .../test/wakeword_runtime/plugins/base.py | 18 ++ .../test/wakeword_runtime/plugins/manager.py | 37 ++++ .../wakeword_runtime/plugins/wake_word.py | 52 +++++ .../test/wakeword_runtime/runtime/__init__.py | 2 + .../test/wakeword_runtime/runtime/app.py | 60 +++++ .../wakeword_runtime/runtime/http_server.py | 102 +++++++++ 23 files changed, 1116 insertions(+), 3 deletions(-) create mode 100644 main/xiaozhi-server/test/js/core/network/wakeword-bridge.js create mode 100644 main/xiaozhi-server/test/start_test_runtime.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/__init__.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/bridge/__init__.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/bridge/event_bridge.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/config.json create mode 100644 main/xiaozhi-server/test/wakeword_runtime/config/__init__.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/config/config_loader.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/config/logging_setup.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/core/__init__.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/core/detector.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/core/detector_assets.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/core/microphone.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/plugins/__init__.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/plugins/audio.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/plugins/base.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/plugins/manager.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/plugins/wake_word.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/runtime/__init__.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/runtime/app.py create mode 100644 main/xiaozhi-server/test/wakeword_runtime/runtime/http_server.py diff --git a/main/xiaozhi-server/test/js/app.js b/main/xiaozhi-server/test/js/app.js index d4003637..09ab3506 100644 --- a/main/xiaozhi-server/test/js/app.js +++ b/main/xiaozhi-server/test/js/app.js @@ -3,6 +3,7 @@ import { checkOpusLoaded, initOpusEncoder } from './core/audio/opus-codec.js?v=0 import { getAudioPlayer } from './core/audio/player.js?v=0205'; import { checkMicrophoneAvailability, isHttpNonLocalhost } from './core/audio/recorder.js?v=0205'; import { initMcpTools } from './core/mcp/tools.js?v=0205'; +import { startWakewordBridgeListener } from './core/network/wakeword-bridge.js?v=0205'; import { uiController } from './ui/controller.js?v=0205'; import { log } from './utils/logger.js?v=0205'; @@ -43,6 +44,8 @@ class App { await this.audioPlayer.start(); // 初始化MCP工具 initMcpTools(); + // 初始化本地唤醒事件监听 + startWakewordBridgeListener(); // 检查麦克风可用性 await this.checkMicrophoneAvailability(); // 检查摄像头可用性 diff --git a/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js b/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js new file mode 100644 index 00000000..7596235e --- /dev/null +++ b/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js @@ -0,0 +1,92 @@ +import { uiController } from '../../ui/controller.js?v=0205'; +import { log } from '../../utils/logger.js?v=0205'; + +const BRIDGE_URL_CANDIDATES = [ + `${window.location.origin}/events` +]; + +let wakewordEventSource = null; + +export function startWakewordBridgeListener() { + if (wakewordEventSource) { + return wakewordEventSource; + } + + log('正在连接本地唤醒事件桥...', 'info'); + tryConnect(0); + return wakewordEventSource; +} + +function tryConnect(index) { + if (index >= BRIDGE_URL_CANDIDATES.length) { + log('未能连接到本地唤醒事件桥,请确认 test runtime 已启动', 'warning'); + return null; + } + + const bridgeUrl = BRIDGE_URL_CANDIDATES[index]; + + try { + wakewordEventSource = new EventSource(bridgeUrl); + wakewordEventSource.onopen = () => { + log(`本地唤醒事件桥已连接: ${bridgeUrl}`, 'success'); + }; + + wakewordEventSource.onmessage = async (event) => { + try { + const message = JSON.parse(event.data); + if (message.type === 'bridge_connected') { + log('本地唤醒监听已就绪', 'info'); + return; + } + + if (message.type === 'service_ready') { + log('本地唤醒服务已启动', 'info'); + return; + } + + if (message.type === 'service_stopping') { + log('本地唤醒服务正在停止', 'warning'); + return; + } + + if (message.type === 'wake_word_detected') { + const wakeWord = message.payload?.wake_word || '唤醒词'; + log(`检测到本地唤醒事件: ${wakeWord}`, 'info'); + await uiController.triggerWakewordDial(wakeWord); + } + } catch (error) { + log(`解析本地唤醒事件失败: ${error.message}`, 'error'); + } + }; + + wakewordEventSource.onerror = () => { + log(`本地唤醒事件桥连接异常: ${bridgeUrl}`, 'warning'); + if (wakewordEventSource) { + wakewordEventSource.close(); + wakewordEventSource = null; + } + + if (index + 1 < BRIDGE_URL_CANDIDATES.length) { + log(`尝试备用地址: ${BRIDGE_URL_CANDIDATES[index + 1]}`, 'info'); + tryConnect(index + 1); + return; + } + + log('请确认当前页面由 test runtime 启动,并且 /events 可访问', 'warning'); + }; + + return wakewordEventSource; + } catch (error) { + log(`启动本地唤醒监听失败: ${error.message}`, 'error'); + return tryConnect(index + 1); + } +} + +export function stopWakewordBridgeListener() { + if (!wakewordEventSource) { + return; + } + + wakewordEventSource.close(); + wakewordEventSource = null; +} \ No newline at end of file diff --git a/main/xiaozhi-server/test/start_test_runtime.py b/main/xiaozhi-server/test/start_test_runtime.py new file mode 100644 index 00000000..570f69d2 --- /dev/null +++ b/main/xiaozhi-server/test/start_test_runtime.py @@ -0,0 +1,33 @@ +from pathlib import Path + +from wakeword_runtime.config import load_config, setup_logging +from wakeword_runtime.runtime import TestRuntimeApplication, TestRuntimeHttpServer + + +def main() -> int: + test_root = Path(__file__).resolve().parent + config = load_config(test_root / "wakeword_runtime") + setup_logging(config.log_file, config.log_level) + http_server = TestRuntimeHttpServer(test_root) + app = TestRuntimeApplication(config, http_server) + + print(f"test runtime started: {http_server.page_url}") + print(f"wakeword events endpoint: {http_server.events_url}") + print(f"wakeword enabled: {config.wakeword_enabled}") + print("press Ctrl+C to stop") + + try: + app.setup() + app.start() + http_server.serve_forever() + except KeyboardInterrupt: + print("test runtime stopped") + finally: + app.shutdown() + http_server.shutdown() + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/main/xiaozhi-server/test/test_page.html b/main/xiaozhi-server/test/test_page.html index 30d68073..5e29e6d4 100644 --- a/main/xiaozhi-server/test/test_page.html +++ b/main/xiaozhi-server/test/test_page.html @@ -21,13 +21,13 @@ warningDiv.innerHTML = `

⚠️ 警告:请使用HTTP服务器打开此页面

您当前使用的是本地文件方式打开页面(file://协议),这可能导致页面功能异常。

-

您可以使用nginx映射启动测试页面,也可以请按照以下步骤使用python启动测试http服务:

+

您可以使用nginx映射启动测试页面,也可以请按照以下步骤启动测试运行时:

  1. 打开命令行终端
  2. 命令行进入到 xiaozhi-server/test 目录
  3. -
  4. 执行以下命令启动HTTP服务器:
  5. +
  6. 执行以下命令启动测试页面运行时:
-
python -m http.server 8006
+
python start_test_runtime.py

然后在浏览器中访问:http://localhost:8006/test_page.html

`; document.body.appendChild(warningDiv); diff --git a/main/xiaozhi-server/test/wakeword_runtime/__init__.py b/main/xiaozhi-server/test/wakeword_runtime/__init__.py new file mode 100644 index 00000000..dc29ee23 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/__init__.py @@ -0,0 +1 @@ +"""Test-side wakeword runtime package.""" \ No newline at end of file diff --git a/main/xiaozhi-server/test/wakeword_runtime/bridge/__init__.py b/main/xiaozhi-server/test/wakeword_runtime/bridge/__init__.py new file mode 100644 index 00000000..d4b2cfc3 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/bridge/__init__.py @@ -0,0 +1 @@ +from .event_bridge import WakewordEventBridge diff --git a/main/xiaozhi-server/test/wakeword_runtime/bridge/event_bridge.py b/main/xiaozhi-server/test/wakeword_runtime/bridge/event_bridge.py new file mode 100644 index 00000000..7e490c0d --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/bridge/event_bridge.py @@ -0,0 +1,97 @@ +import json +import logging +import queue +import threading +import time +from typing import Any + +logger = logging.getLogger(__name__) + + +class WakewordEventBridge: + def __init__(self) -> None: + self._clients: list[queue.Queue[str]] = [] + self._clients_lock = threading.Lock() + self._running = True + + @property + def is_running(self) -> bool: + return self._running + + def build_ready_message(self) -> str: + return json.dumps( + { + "type": "bridge_connected", + "payload": {"status": "ready"}, + }, + ensure_ascii=False, + ) + + def publish_detected(self, wake_word: str) -> None: + self.publish( + "wake_word_detected", + { + "wake_word": wake_word, + "timestamp": time.time(), + }, + ) + + def publish(self, event_type: str, payload: dict[str, Any] | None = None) -> None: + if not self._running: + return + + message = json.dumps( + { + "type": event_type, + "payload": payload or {}, + }, + ensure_ascii=False, + ) + with self._clients_lock: + clients = list(self._clients) + + stale_clients: list[queue.Queue[str]] = [] + for client_queue in clients: + try: + client_queue.put_nowait(message) + except queue.Full: + stale_clients.append(client_queue) + + if stale_clients: + with self._clients_lock: + for client_queue in stale_clients: + if client_queue in self._clients: + self._clients.remove(client_queue) + + def add_client(self) -> queue.Queue[str]: + client_queue: queue.Queue[str] = queue.Queue(maxsize=16) + with self._clients_lock: + self._clients.append(client_queue) + return client_queue + + def remove_client(self, client_queue: queue.Queue[str]) -> None: + with self._clients_lock: + if client_queue in self._clients: + self._clients.remove(client_queue) + + def publish_service_ready(self) -> None: + self.publish("service_ready", {"status": "ready"}) + + def publish_service_stopping(self) -> None: + self.publish("service_stopping", {"status": "stopping"}) + + def close(self) -> None: + if not self._running: + return + + self.publish_service_stopping() + self._running = False + with self._clients_lock: + clients = list(self._clients) + self._clients.clear() + + for client_queue in clients: + try: + client_queue.put_nowait("__bridge_closed__") + except queue.Full: + pass diff --git a/main/xiaozhi-server/test/wakeword_runtime/config.json b/main/xiaozhi-server/test/wakeword_runtime/config.json new file mode 100644 index 00000000..40fdbca7 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/config.json @@ -0,0 +1,29 @@ +{ + "wakeword": { + "enabled": false + }, + "wake_word": "你好小智", + "model_dir": "models", + "audio": { + "input_device": null, + "sample_rate": 16000, + "channels": 1 + }, + "detector": { + "num_threads": 4, + "provider": "cpu", + "max_active_paths": 2, + "keywords_score": 1.8, + "keywords_threshold": 0.05, + "num_trailing_blanks": 1, + "cooldown_seconds": 1.5 + }, + "logging": { + "level": "INFO", + "dir": "logs", + "file": "wakeword-runtime.log" + }, + "wake_words": [ + "你好小智" + ] +} diff --git a/main/xiaozhi-server/test/wakeword_runtime/config/__init__.py b/main/xiaozhi-server/test/wakeword_runtime/config/__init__.py new file mode 100644 index 00000000..52fbd4aa --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/config/__init__.py @@ -0,0 +1,2 @@ +from .config_loader import RuntimeConfig, load_config +from .logging_setup import setup_logging diff --git a/main/xiaozhi-server/test/wakeword_runtime/config/config_loader.py b/main/xiaozhi-server/test/wakeword_runtime/config/config_loader.py new file mode 100644 index 00000000..9c37f3b3 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/config/config_loader.py @@ -0,0 +1,131 @@ +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Any + + +@dataclass +class WakewordSettings: + enabled: bool = False + + +@dataclass +class AudioSettings: + input_device: str | int | None = None + sample_rate: int = 16000 + channels: int = 1 + + +@dataclass +class DetectorSettings: + num_threads: int = 4 + provider: str = "cpu" + max_active_paths: int = 2 + keywords_score: float = 1.8 + keywords_threshold: float = 0.2 + num_trailing_blanks: int = 1 + cooldown_seconds: float = 1.5 + + +@dataclass +class LoggingSettings: + level: str = "INFO" + directory: str = "logs" + file_name: str = "wakeword-runtime.log" + + +@dataclass +class RuntimeConfig: + runtime_root: Path + wakeword: WakewordSettings + wake_words: list[str] + model_dir: Path + audio: AudioSettings + detector: DetectorSettings + logging: LoggingSettings + raw: dict[str, Any] + + def validate(self) -> None: + if self.wakeword.enabled and not self.wake_words: + raise ValueError("wake_word or wake_words cannot be empty when wakeword is enabled") + + if self.audio.sample_rate <= 0: + raise ValueError("audio.sample_rate must be greater than 0") + + if self.audio.channels <= 0: + raise ValueError("audio.channels must be greater than 0") + + if self.detector.num_threads <= 0: + raise ValueError("detector.num_threads must be greater than 0") + + if self.detector.cooldown_seconds < 0: + raise ValueError("detector.cooldown_seconds cannot be negative") + + if not self.logging.level: + raise ValueError("logging.level cannot be empty") + + @property + def wakeword_enabled(self) -> bool: + return self.wakeword.enabled + + @property + def log_dir(self) -> Path: + return (self.runtime_root / self.logging.directory).resolve() + + @property + def log_file(self) -> Path: + return self.log_dir / self.logging.file_name + + @property + def log_level(self) -> str: + return self.logging.level.upper() + + +def load_config(runtime_root: Path) -> RuntimeConfig: + config_path = runtime_root / "config.json" + raw = json.loads(config_path.read_text(encoding="utf-8")) + wakeword_cfg = dict(raw.get("wakeword", {})) + raw_words = raw.get("wake_words") + wake_words: list[str] = [] + if isinstance(raw_words, list): + wake_words = [str(item).strip() for item in raw_words if str(item).strip()] + if not wake_words: + wake_word = str(raw.get("wake_word", "")).strip() + if wake_word: + wake_words = [wake_word] + + raw_model_dir = Path(str(raw.get("model_dir", "models"))) + model_dir = raw_model_dir.resolve() if raw_model_dir.is_absolute() else (runtime_root / raw_model_dir).resolve() + + audio_cfg = dict(raw.get("audio", {})) + detector_cfg = dict(raw.get("detector", {})) + logging_cfg = dict(raw.get("logging", {})) + + config = RuntimeConfig( + runtime_root=runtime_root, + wakeword=WakewordSettings(enabled=bool(wakeword_cfg.get("enabled", False))), + wake_words=wake_words, + model_dir=model_dir, + audio=AudioSettings( + input_device=audio_cfg.get("input_device"), + sample_rate=int(audio_cfg.get("sample_rate", 16000)), + channels=int(audio_cfg.get("channels", 1)), + ), + detector=DetectorSettings( + num_threads=int(detector_cfg.get("num_threads", 4)), + provider=str(detector_cfg.get("provider", "cpu")), + max_active_paths=int(detector_cfg.get("max_active_paths", 2)), + keywords_score=float(detector_cfg.get("keywords_score", 1.8)), + keywords_threshold=float(detector_cfg.get("keywords_threshold", 0.2)), + num_trailing_blanks=int(detector_cfg.get("num_trailing_blanks", 1)), + cooldown_seconds=float(detector_cfg.get("cooldown_seconds", 1.5)), + ), + logging=LoggingSettings( + level=str(logging_cfg.get("level", "INFO")).upper(), + directory=str(logging_cfg.get("dir", "logs")), + file_name=str(logging_cfg.get("file", "wakeword-runtime.log")), + ), + raw=raw, + ) + config.validate() + return config diff --git a/main/xiaozhi-server/test/wakeword_runtime/config/logging_setup.py b/main/xiaozhi-server/test/wakeword_runtime/config/logging_setup.py new file mode 100644 index 00000000..4646b3bf --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/config/logging_setup.py @@ -0,0 +1,23 @@ +import logging +from pathlib import Path + + +def setup_logging(log_file: Path, level: str = "INFO") -> None: + log_file.parent.mkdir(parents=True, exist_ok=True) + + logger = logging.getLogger() + logger.setLevel(getattr(logging, level.upper(), logging.INFO)) + logger.handlers.clear() + + formatter = logging.Formatter( + "%(asctime)s [%(levelname)s] %(name)s - %(message)s" + ) + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter(formatter) + + file_handler = logging.FileHandler(log_file, encoding="utf-8") + file_handler.setFormatter(formatter) + + logger.addHandler(stream_handler) + logger.addHandler(file_handler) diff --git a/main/xiaozhi-server/test/wakeword_runtime/core/__init__.py b/main/xiaozhi-server/test/wakeword_runtime/core/__init__.py new file mode 100644 index 00000000..ed1b9019 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/core/__init__.py @@ -0,0 +1,3 @@ +from .detector_assets import DetectorAssets, DetectorAssetsBuilder +from .detector import WakewordDetector +from .microphone import MicrophoneListener diff --git a/main/xiaozhi-server/test/wakeword_runtime/core/detector.py b/main/xiaozhi-server/test/wakeword_runtime/core/detector.py new file mode 100644 index 00000000..53d7e927 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/core/detector.py @@ -0,0 +1,206 @@ +import queue +import threading +import logging +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +import numpy as np + +from ..config import RuntimeConfig +from .detector_assets import DetectorAssetsBuilder + +logger = logging.getLogger(__name__) + + +@dataclass +class DetectorBootstrapReport: + ready: bool + model_root: Path | None + keywords_file: Path | None + wake_words: list[str] + + +class WakewordDetector: + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + self.enabled = config.wakeword_enabled + self.assets_builder = DetectorAssetsBuilder(config) + self.audio_source = None + self.keyword_spotter: Any | None = None + self.stream: Any | None = None + self.is_running_flag = False + self.paused = False + self.on_detected_callback: Callable[[str, str], None] | None = None + self.on_error: Callable[[Exception], None] | None = None + self._audio_queue: queue.Queue[np.ndarray] = queue.Queue(maxsize=100) + self._worker_thread: threading.Thread | None = None + self.last_detection_time = 0.0 + self.detection_cooldown = self.config.detector.cooldown_seconds + + def initialize(self) -> DetectorBootstrapReport: + if not self.enabled: + raise RuntimeError("wakeword detector is disabled") + + try: + import sherpa_onnx + except ImportError as exc: + raise RuntimeError( + "Missing dependency: sherpa-onnx. Install runtime dependencies before initializing detector." + ) from exc + + assets = self.assets_builder.prepare() + detector_cfg = self.config.detector + + self.keyword_spotter = sherpa_onnx.KeywordSpotter( + tokens=str(assets.tokens_file), + encoder=str(assets.encoder_file), + decoder=str(assets.decoder_file), + joiner=str(assets.joiner_file), + keywords_file=str(assets.keywords_file), + num_threads=detector_cfg.num_threads, + sample_rate=self.config.audio.sample_rate, + feature_dim=80, + max_active_paths=detector_cfg.max_active_paths, + keywords_score=detector_cfg.keywords_score, + keywords_threshold=detector_cfg.keywords_threshold, + num_trailing_blanks=detector_cfg.num_trailing_blanks, + provider=detector_cfg.provider, + ) + self.stream = self.keyword_spotter.create_stream() + + report = DetectorBootstrapReport( + ready=True, + model_root=assets.model_root, + keywords_file=assets.keywords_file, + wake_words=self.config.wake_words, + ) + logger.info("detector initialized") + logger.info("detector model root: %s", assets.model_root) + logger.info("detector keywords file: %s", assets.keywords_file) + return report + + def on_detected(self, callback: Callable[[str, str], None]) -> None: + self.on_detected_callback = callback + + def on_audio_data(self, audio_data: np.ndarray) -> None: + if not self.enabled or not self.is_running_flag or self.paused: + return + + try: + self._audio_queue.put_nowait(audio_data.copy()) + except queue.Full: + try: + self._audio_queue.get_nowait() + self._audio_queue.put_nowait(audio_data.copy()) + except queue.Empty: + pass + except Exception as exc: + logger.debug("audio data enqueue failed: %s", exc) + + def start(self, audio_source) -> None: + if not self.enabled: + logger.info("wakeword detector disabled") + return + + if self.keyword_spotter is None or self.stream is None: + self.initialize() + + if self.is_running_flag: + return + + self.audio_source = audio_source + self.audio_source.add_audio_listener(self) + self.is_running_flag = True + self.paused = False + self._worker_thread = threading.Thread( + target=self._detection_loop, + name="wakeword-detector", + daemon=True, + ) + self._worker_thread.start() + logger.info("wakeword detector started") + + def stop(self) -> None: + self.is_running_flag = False + + if self.audio_source is not None: + self.audio_source.remove_audio_listener(self) + self.audio_source = None + + if self._worker_thread is not None: + self._worker_thread.join(timeout=1.0) + self._worker_thread = None + + while not self._audio_queue.empty(): + try: + self._audio_queue.get_nowait() + except queue.Empty: + break + + logger.info("wakeword detector stopped") + + def _detection_loop(self) -> None: + error_count = 0 + max_errors = 5 + + while self.is_running_flag: + try: + if self.paused: + time.sleep(0.1) + continue + + audio_data = self._audio_queue.get(timeout=0.1) + result = self.process_audio_chunk(audio_data) + if result and self.on_detected_callback is not None: + self.on_detected_callback(result, result) + error_count = 0 + except queue.Empty: + continue + except Exception as exc: + error_count += 1 + logger.error("wakeword detection loop error(%s/%s): %s", error_count, max_errors, exc) + if self.on_error is not None: + try: + self.on_error(exc) + except Exception: + logger.exception("wakeword error callback failed") + if error_count >= max_errors: + logger.critical("too many wakeword detection errors, stopping detector") + break + time.sleep(1) + + self.is_running_flag = False + + def process_audio_chunk(self, audio_data: np.ndarray) -> str | None: + if self.keyword_spotter is None or self.stream is None: + raise RuntimeError("detector is not initialized") + + if audio_data is None or len(audio_data) == 0: + return None + + if audio_data.dtype == np.int16: + samples = audio_data.astype(np.float32) / 32768.0 + else: + samples = audio_data.astype(np.float32) + + sample_rate = self.config.audio.sample_rate + self.stream.accept_waveform(sample_rate=sample_rate, waveform=samples) + + if not self.keyword_spotter.is_ready(self.stream): + return None + + self.keyword_spotter.decode_stream(self.stream) + result = self.keyword_spotter.get_result(self.stream) + if not result: + return None + + self.keyword_spotter.reset_stream(self.stream) + + current_time = time.time() + if current_time - self.last_detection_time < self.detection_cooldown: + return None + + self.last_detection_time = current_time + return str(result) diff --git a/main/xiaozhi-server/test/wakeword_runtime/core/detector_assets.py b/main/xiaozhi-server/test/wakeword_runtime/core/detector_assets.py new file mode 100644 index 00000000..60d08eff --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/core/detector_assets.py @@ -0,0 +1,94 @@ +from dataclasses import dataclass +from pathlib import Path + +from ..config import RuntimeConfig + +REQUIRED_MODEL_FILES = ( + "encoder.onnx", + "decoder.onnx", + "joiner.onnx", + "tokens.txt", +) + + +@dataclass +class DetectorAssets: + model_root: Path + tokens_file: Path + encoder_file: Path + decoder_file: Path + joiner_file: Path + keywords_file: Path + + +class DetectorAssetsBuilder: + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + + def prepare(self) -> DetectorAssets: + model_root = self._resolve_model_root() + keywords_file = self._write_keywords_file(model_root) + return DetectorAssets( + model_root=model_root, + tokens_file=model_root / "tokens.txt", + encoder_file=model_root / "encoder.onnx", + decoder_file=model_root / "decoder.onnx", + joiner_file=model_root / "joiner.onnx", + keywords_file=keywords_file, + ) + + def _resolve_model_root(self) -> Path: + preferred = self.config.model_dir + if self._has_required_files(preferred): + return preferred + + raise FileNotFoundError( + "No valid model directory found. Expected configured model files to exist." + ) + + def _write_keywords_file(self, model_root: Path) -> Path: + try: + from pypinyin import Style, pinyin + except ImportError as exc: + raise RuntimeError( + "Missing dependency: pypinyin. Install runtime dependencies before generating keywords." + ) from exc + + wake_words = self.config.wake_words + if not wake_words: + raise ValueError("wake_word or wake_words cannot be empty") + + keywords_path = model_root / "keywords.txt" + lines: list[str] = [] + for keyword_text in wake_words: + initials = pinyin(keyword_text, style=Style.INITIALS, strict=False) + finals = pinyin( + keyword_text, + style=Style.FINALS_TONE, + strict=False, + neutral_tone_with_five=True, + ) + + tokens: list[str] = [] + for initial_parts, final_parts in zip(initials, finals): + initial = initial_parts[0].strip() + final = final_parts[0].strip() + if initial: + tokens.append(initial) + if final: + tokens.append(final) + + if not tokens: + raise ValueError( + f"failed to generate pinyin tokens for wake word: {keyword_text}" + ) + + lines.append(f"{' '.join(tokens)} @{keyword_text}") + + keywords_path.write_text("\n".join(lines) + "\n", encoding="utf-8") + return keywords_path + + def _has_required_files(self, directory: Path) -> bool: + if not directory.exists(): + return False + return all((directory / file_name).exists() for file_name in REQUIRED_MODEL_FILES) diff --git a/main/xiaozhi-server/test/wakeword_runtime/core/microphone.py b/main/xiaozhi-server/test/wakeword_runtime/core/microphone.py new file mode 100644 index 00000000..5a6a954b --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/core/microphone.py @@ -0,0 +1,87 @@ +import logging +from typing import Protocol + +import numpy as np + +from ..config import RuntimeConfig + +logger = logging.getLogger(__name__) + + +class AudioListener(Protocol): + def on_audio_data(self, audio_data: np.ndarray) -> None: + ... + + +class MicrophoneListener: + def __init__(self, config: RuntimeConfig) -> None: + self.config = config + self._stream = None + self._running = False + self._listeners: list[AudioListener] = [] + self._sample_rate = self.config.audio.sample_rate + self._channels = self.config.audio.channels + self._device = self.config.audio.input_device + self._block_duration_ms = 100 + self._block_size = int(self._sample_rate * (self._block_duration_ms / 1000)) + + def add_audio_listener(self, listener: AudioListener) -> None: + if listener not in self._listeners: + self._listeners.append(listener) + + def remove_audio_listener(self, listener: AudioListener) -> None: + if listener in self._listeners: + self._listeners.remove(listener) + + def start(self) -> None: + try: + import sounddevice as sd + except ImportError as exc: + raise RuntimeError( + "Missing dependency: sounddevice. Install runtime dependencies before starting microphone listener." + ) from exc + + self._stream = sd.InputStream( + device=self._device, + samplerate=self._sample_rate, + channels=self._channels, + dtype="int16", + blocksize=self._block_size, + callback=self._input_callback, + latency="low", + ) + self._stream.start() + self._running = True + + logger.info("microphone listener started") + logger.info("microphone sample rate: %s", self._sample_rate) + logger.info("microphone channels: %s", self._channels) + logger.info("microphone device: %s", self._device if self._device is not None else "default") + logger.info("microphone block size: %s", self._block_size) + + def stop(self) -> None: + self._running = False + if self._stream is not None: + try: + self._stream.stop() + finally: + self._stream.close() + self._stream = None + logger.info("microphone listener stopped") + + def _input_callback(self, indata, frames, time_info, status) -> None: + _ = frames, time_info + if status: + logger.warning("microphone status: %s", status) + + audio = np.copy(indata) + if audio.ndim > 1: + audio = audio[:, 0] + else: + audio = audio.reshape(-1) + + for listener in list(self._listeners): + try: + listener.on_audio_data(audio) + except Exception: + logger.exception("audio listener callback failed") diff --git a/main/xiaozhi-server/test/wakeword_runtime/plugins/__init__.py b/main/xiaozhi-server/test/wakeword_runtime/plugins/__init__.py new file mode 100644 index 00000000..9d253d71 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/plugins/__init__.py @@ -0,0 +1,4 @@ +from .audio import AudioPlugin +from .base import Plugin +from .manager import PluginManager +from .wake_word import WakeWordPlugin diff --git a/main/xiaozhi-server/test/wakeword_runtime/plugins/audio.py b/main/xiaozhi-server/test/wakeword_runtime/plugins/audio.py new file mode 100644 index 00000000..019f6a9e --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/plugins/audio.py @@ -0,0 +1,36 @@ +import logging +from typing import Any + +from ..core import MicrophoneListener +from .base import Plugin + +logger = logging.getLogger(__name__) + + +class AudioPlugin(Plugin): + name = "audio" + priority = 10 + + def __init__(self) -> None: + self.app = None + self.source: MicrophoneListener | None = None + + def setup(self, app: Any) -> None: + self.app = app + self.source = MicrophoneListener(app.config) + self.app.audio_source = self.source + + def start(self) -> None: + if self.source is None: + logger.warning("audio source not initialized") + return + self.source.start() + + def stop(self) -> None: + if self.source is not None: + self.source.stop() + + def shutdown(self) -> None: + self.stop() + if self.app is not None: + self.app.audio_source = None diff --git a/main/xiaozhi-server/test/wakeword_runtime/plugins/base.py b/main/xiaozhi-server/test/wakeword_runtime/plugins/base.py new file mode 100644 index 00000000..3ab0c5b1 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/plugins/base.py @@ -0,0 +1,18 @@ +from typing import Any + + +class Plugin: + name: str = "plugin" + priority: int = 50 + + def setup(self, app: Any) -> None: + return None + + def start(self) -> None: + return None + + def stop(self) -> None: + return None + + def shutdown(self) -> None: + return None diff --git a/main/xiaozhi-server/test/wakeword_runtime/plugins/manager.py b/main/xiaozhi-server/test/wakeword_runtime/plugins/manager.py new file mode 100644 index 00000000..43493f24 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/plugins/manager.py @@ -0,0 +1,37 @@ +from typing import Any + +from .base import Plugin + + +class PluginManager: + def __init__(self) -> None: + self._plugins: list[Plugin] = [] + self._by_name: dict[str, Plugin] = {} + + def register(self, *plugins: Plugin) -> None: + for plugin in sorted(plugins, key=lambda item: getattr(item, "priority", 50)): + if plugin in self._plugins: + continue + self._plugins.append(plugin) + name = getattr(plugin, "name", "") + if isinstance(name, str) and name: + self._by_name[name] = plugin + + def get_plugin(self, name: str) -> Plugin | None: + return self._by_name.get(name) + + def setup_all(self, app: Any) -> None: + for plugin in list(self._plugins): + plugin.setup(app) + + def start_all(self) -> None: + for plugin in list(self._plugins): + plugin.start() + + def stop_all(self) -> None: + for plugin in reversed(self._plugins): + plugin.stop() + + def shutdown_all(self) -> None: + for plugin in reversed(self._plugins): + plugin.shutdown() diff --git a/main/xiaozhi-server/test/wakeword_runtime/plugins/wake_word.py b/main/xiaozhi-server/test/wakeword_runtime/plugins/wake_word.py new file mode 100644 index 00000000..3439224b --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/plugins/wake_word.py @@ -0,0 +1,52 @@ +import logging +from typing import Any + +from ..core import WakewordDetector +from .base import Plugin + +logger = logging.getLogger(__name__) + + +class WakeWordPlugin(Plugin): + name = "wake_word" + priority = 30 + + def __init__(self) -> None: + self.app = None + self.detector: WakewordDetector | None = None + + def setup(self, app: Any) -> None: + self.app = app + self.detector = WakewordDetector(app.config) + if not self.detector.enabled: + self.detector = None + return + self.detector.on_detected(self._on_detected) + self.detector.on_error = self._on_error + + def start(self) -> None: + if self.detector is None: + return + + audio_plugin = self.app.plugins.get_plugin("audio") if self.app else None + audio_source = getattr(audio_plugin, "source", None) + if audio_source is None: + logger.warning("audio source unavailable, wakeword plugin not started") + return + + self.detector.start(audio_source) + + def stop(self) -> None: + if self.detector is not None: + self.detector.stop() + + def shutdown(self) -> None: + self.stop() + + def _on_detected(self, wake_word: str, full_text: str) -> None: + if self.app is None: + return + self.app.handle_wake_word_detected(wake_word, full_text) + + def _on_error(self, error: Exception) -> None: + logger.error("wakeword detection error: %s", error) diff --git a/main/xiaozhi-server/test/wakeword_runtime/runtime/__init__.py b/main/xiaozhi-server/test/wakeword_runtime/runtime/__init__.py new file mode 100644 index 00000000..0629fcd2 --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/runtime/__init__.py @@ -0,0 +1,2 @@ +from .app import TestRuntimeApplication +from .http_server import TestRuntimeHttpServer diff --git a/main/xiaozhi-server/test/wakeword_runtime/runtime/app.py b/main/xiaozhi-server/test/wakeword_runtime/runtime/app.py new file mode 100644 index 00000000..9777079e --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/runtime/app.py @@ -0,0 +1,60 @@ +import logging + +from ..config import RuntimeConfig +from ..plugins import AudioPlugin, PluginManager, WakeWordPlugin +from .http_server import TestRuntimeHttpServer + +logger = logging.getLogger(__name__) + + +class TestRuntimeApplication: + def __init__(self, config: RuntimeConfig, http_server: TestRuntimeHttpServer) -> None: + self.config = config + self.http_server = http_server + self.event_bridge = http_server.event_bridge + self.plugins = PluginManager() + self.audio_source = None + self._is_setup = False + self._is_running = False + + def setup(self) -> None: + if self._is_setup: + return + + if self.config.wakeword_enabled: + self.plugins.register( + AudioPlugin(), + WakeWordPlugin(), + ) + self.plugins.setup_all(self) + self._is_setup = True + + def start(self) -> None: + if self._is_running: + return + + self.setup() + self.plugins.start_all() + if self.config.wakeword_enabled: + self.event_bridge.publish_service_ready() + self._is_running = True + logger.info("test runtime application started") + + def stop(self) -> None: + if not self._is_running: + return + + self.event_bridge.publish_service_stopping() + self.plugins.stop_all() + self._is_running = False + logger.info("test runtime application stopped") + + def shutdown(self) -> None: + self.stop() + if self._is_setup: + self.plugins.shutdown_all() + + def handle_wake_word_detected(self, wake_word: str, full_text: str) -> None: + _ = full_text + logger.info("wake word detected: %s", wake_word) + self.event_bridge.publish_detected(wake_word) diff --git a/main/xiaozhi-server/test/wakeword_runtime/runtime/http_server.py b/main/xiaozhi-server/test/wakeword_runtime/runtime/http_server.py new file mode 100644 index 00000000..a9877c5f --- /dev/null +++ b/main/xiaozhi-server/test/wakeword_runtime/runtime/http_server.py @@ -0,0 +1,102 @@ +import json +import queue +from http import HTTPStatus +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path + +from ..bridge import WakewordEventBridge + + +class TestRuntimeHttpServer: + def __init__(self, test_root: Path, host: str = "0.0.0.0", port: int = 8006) -> None: + self.test_root = test_root + self.host = host + self.port = port + self.event_bridge = WakewordEventBridge() + self._server = self._build_server() + + @property + def page_url(self) -> str: + return f"http://127.0.0.1:{self.port}/test_page.html" + + @property + def events_url(self) -> str: + return f"http://127.0.0.1:{self.port}/events" + + def serve_forever(self) -> None: + self._server.serve_forever() + + def shutdown(self) -> None: + self._server.shutdown() + self.event_bridge.close() + self._server.server_close() + + def _build_server(self) -> ThreadingHTTPServer: + test_root = self.test_root + event_bridge = self.event_bridge + + class TestRuntimeHandler(SimpleHTTPRequestHandler): + protocol_version = "HTTP/1.1" + + def __init__(self, *args, **kwargs): + super().__init__(*args, directory=str(test_root), **kwargs) + + def handle(self) -> None: + try: + super().handle() + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass + + def do_GET(self) -> None: + if self.path == "/events": + self._handle_events(event_bridge) + return + + if self.path == "/health": + body = json.dumps({"status": "ok"}).encode("utf-8") + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + return + + super().do_GET() + + def log_message(self, format: str, *args) -> None: + return + + def _handle_events(self, bridge: WakewordEventBridge) -> None: + client_queue = bridge.add_client() + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "text/event-stream; charset=utf-8") + self.send_header("Cache-Control", "no-cache") + self.send_header("Connection", "keep-alive") + self.send_header("X-Accel-Buffering", "no") + self.end_headers() + + try: + ready_message = bridge.build_ready_message() + self.wfile.write(f"data: {ready_message}\n\n".encode("utf-8")) + self.wfile.flush() + + while bridge.is_running: + try: + message = client_queue.get(timeout=15) + if message == "__bridge_closed__": + break + self.wfile.write(f"data: {message}\n\n".encode("utf-8")) + except queue.Empty: + if not bridge.is_running: + break + self.wfile.write(b": keepalive\n\n") + self.wfile.flush() + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + pass + finally: + bridge.remove_client(client_queue) + + server = ThreadingHTTPServer((self.host, self.port), TestRuntimeHandler) + server.daemon_threads = True + return server