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 01/18] =?UTF-8?q?update=EF=BC=9A=E4=B8=BA=20test=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=A2=9E=E5=8A=A0=E5=8F=AF=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=9A=84=E6=9C=AC=E5=9C=B0=E5=94=A4=E9=86=92=E8=AF=8D=E8=BF=90?= =?UTF-8?q?=E8=A1=8C=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 From d5b883817c539ebbf0edc370b89865d93f3c7675 Mon Sep 17 00:00:00 2001 From: 3030332422 <3030332422@qq.com> Date: Tue, 14 Apr 2026 16:54:37 +0800 Subject: [PATCH 02/18] =?UTF-8?q?update=EF=BC=9A=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=8F=AF=E9=85=8D=E7=BD=AE=E7=9A=84=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E5=94=A4=E9=86=92=E8=AF=8D=E8=BF=90=E8=A1=8C=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main/xiaozhi-server/test/css/test_page.css | 14 + main/xiaozhi-server/test/js/config/manager.js | 30 ++ .../test/js/core/network/wakeword-bridge.js | 181 +++++++++-- main/xiaozhi-server/test/js/ui/controller.js | 179 ++++++++--- .../xiaozhi-server/test/start_test_runtime.py | 82 ++++- main/xiaozhi-server/test/test_page.html | 34 ++- .../test/wakeword_runtime/README.md | 250 +++++++++++++++ .../wakeword_runtime/bridge/event_bridge.py | 35 ++- .../test/wakeword_runtime/config.json | 10 +- .../wakeword_runtime/config/config_loader.py | 33 +- .../test/wakeword_runtime/core/detector.py | 23 +- .../wakeword_runtime/core/detector_assets.py | 2 +- .../test/wakeword_runtime/core/microphone.py | 3 + .../test/wakeword_runtime/plugins/audio.py | 3 +- .../wakeword_runtime/plugins/wake_word.py | 3 +- .../test/wakeword_runtime/requirements.txt | 3 + .../test/wakeword_runtime/runtime/app.py | 18 +- .../wakeword_runtime/runtime/http_server.py | 284 ++++++++++++++++-- 18 files changed, 1037 insertions(+), 150 deletions(-) create mode 100644 main/xiaozhi-server/test/wakeword_runtime/README.md create mode 100644 main/xiaozhi-server/test/wakeword_runtime/requirements.txt diff --git a/main/xiaozhi-server/test/css/test_page.css b/main/xiaozhi-server/test/css/test_page.css index 157df9a4..e2e488bf 100644 --- a/main/xiaozhi-server/test/css/test_page.css +++ b/main/xiaozhi-server/test/css/test_page.css @@ -812,6 +812,20 @@ body { font-size: 14px; } +.config-item textarea { + width: 100%; + padding: 12px; + border: 1px solid #40444b; + border-radius: 6px; + background: #40444b; + color: white; + min-height: 100px; + resize: vertical; + box-sizing: border-box; + font-family: inherit; + font-size: 14px; +} + .model-select { width: 100%; padding: 10px 40px 10px 14px; diff --git a/main/xiaozhi-server/test/js/config/manager.js b/main/xiaozhi-server/test/js/config/manager.js index 9bc33122..0c2ef1bf 100644 --- a/main/xiaozhi-server/test/js/config/manager.js +++ b/main/xiaozhi-server/test/js/config/manager.js @@ -19,6 +19,9 @@ export function loadConfig() { const deviceNameInput = document.getElementById('deviceName'); const clientIdInput = document.getElementById('clientId'); const otaUrlInput = document.getElementById('otaUrl'); + const wakewordWsUrlInput = document.getElementById('wakewordWsUrl'); + const wakewordEnabledInput = document.getElementById('wakewordEnabled'); + const wakewordListInput = document.getElementById('wakewordList'); // 从localStorage加载MAC地址,如果没有则生成新的 let savedMac = localStorage.getItem('xz_tester_deviceMac'); @@ -43,6 +46,21 @@ export function loadConfig() { if (savedOtaUrl) { otaUrlInput.value = savedOtaUrl; } + + const savedWakewordWsUrl = localStorage.getItem('xz_tester_wakewordWsUrl'); + if (savedWakewordWsUrl !== null && wakewordWsUrlInput) { + wakewordWsUrlInput.value = savedWakewordWsUrl; + } + + const savedWakewordEnabled = localStorage.getItem('xz_tester_wakewordEnabled'); + if (savedWakewordEnabled !== null && wakewordEnabledInput) { + wakewordEnabledInput.value = savedWakewordEnabled; + } + + const savedWakewordList = localStorage.getItem('xz_tester_wakewordList'); + if (savedWakewordList !== null && wakewordListInput) { + wakewordListInput.value = savedWakewordList; + } } // 保存配置 @@ -50,10 +68,22 @@ export function saveConfig() { const deviceMacInput = document.getElementById('deviceMac'); const deviceNameInput = document.getElementById('deviceName'); const clientIdInput = document.getElementById('clientId'); + const wakewordWsUrlInput = document.getElementById('wakewordWsUrl'); + const wakewordEnabledInput = document.getElementById('wakewordEnabled'); + const wakewordListInput = document.getElementById('wakewordList'); localStorage.setItem('xz_tester_deviceMac', deviceMacInput.value); localStorage.setItem('xz_tester_deviceName', deviceNameInput.value); localStorage.setItem('xz_tester_clientId', clientIdInput.value); + if (wakewordEnabledInput) { + localStorage.setItem('xz_tester_wakewordEnabled', wakewordEnabledInput.value); + } + if (wakewordListInput) { + localStorage.setItem('xz_tester_wakewordList', wakewordListInput.value); + } + if (wakewordWsUrlInput && wakewordWsUrlInput.value.trim()) { + localStorage.setItem('xz_tester_wakewordWsUrl', wakewordWsUrlInput.value.trim()); + } } // 获取配置值 diff --git a/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js b/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js index 7596235e..cf521cad 100644 --- a/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js +++ b/main/xiaozhi-server/test/js/core/network/wakeword-bridge.js @@ -1,39 +1,56 @@ 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 wakewordSocket = null; +let reconnectTimer = null; +let reconnectAttempts = 0; +let shouldReconnect = true; +let wakewordRequestSeq = 0; -let wakewordEventSource = null; +const pendingWakewordRequests = new Map(); export function startWakewordBridgeListener() { - if (wakewordEventSource) { - return wakewordEventSource; + if (wakewordSocket) { + return wakewordSocket; } + shouldReconnect = true; log('正在连接本地唤醒事件桥...', 'info'); - tryConnect(0); - return wakewordEventSource; + tryConnect(); + return wakewordSocket; } -function tryConnect(index) { - if (index >= BRIDGE_URL_CANDIDATES.length) { - log('未能连接到本地唤醒事件桥,请确认 test runtime 已启动', 'warning'); - return null; - } - - const bridgeUrl = BRIDGE_URL_CANDIDATES[index]; +function tryConnect() { + const bridgeUrl = buildWakewordBridgeUrl(); try { - wakewordEventSource = new EventSource(bridgeUrl); - wakewordEventSource.onopen = () => { + wakewordSocket = new WebSocket(bridgeUrl); + wakewordSocket.onopen = () => { + reconnectAttempts = 0; log(`本地唤醒事件桥已连接: ${bridgeUrl}`, 'success'); + // 连接成功后自动保存地址,刷新后仍能记住 + localStorage.setItem('xz_tester_wakewordWsUrl', bridgeUrl); + const urlInput = document.getElementById('wakewordWsUrl'); + if (urlInput) urlInput.value = bridgeUrl; }; - wakewordEventSource.onmessage = async (event) => { + wakewordSocket.onerror = () => { + log(`本地唤醒事件桥连接失败: ${bridgeUrl}`, 'error'); + }; + + wakewordSocket.onmessage = async (event) => { try { - const message = JSON.parse(event.data); + const message = parseWakewordBridgeMessage(event.data); + if (message.requestId && pendingWakewordRequests.has(message.requestId)) { + settleWakewordRequest(message); + return; + } + + if (message.success === false) { + log(`本地唤醒事件桥返回错误: ${message.error || '未知错误'}`, 'error'); + return; + } + if (message.type === 'bridge_connected') { log('本地唤醒监听已就绪', 'info'); return; @@ -44,6 +61,12 @@ function tryConnect(index) { return; } + if (message.type === 'wakeword_config') { + uiController.applyWakewordConfig(message.payload || {}); + log('已同步本地唤醒词配置', 'info'); + return; + } + if (message.type === 'service_stopping') { log('本地唤醒服务正在停止', 'warning'); return; @@ -59,34 +82,126 @@ function tryConnect(index) { } }; - wakewordEventSource.onerror = () => { - log(`本地唤醒事件桥连接异常: ${bridgeUrl}`, 'warning'); - if (wakewordEventSource) { - wakewordEventSource.close(); - wakewordEventSource = null; + wakewordSocket.onclose = () => { + if (wakewordSocket) { + wakewordSocket = null; } - if (index + 1 < BRIDGE_URL_CANDIDATES.length) { - log(`尝试备用地址: ${BRIDGE_URL_CANDIDATES[index + 1]}`, 'info'); - tryConnect(index + 1); + rejectAllWakewordRequests('本地唤醒事件桥已断开'); + + if (!shouldReconnect) { return; } - log('请确认当前页面由 test runtime 启动,并且 /events 可访问', 'warning'); + if (reconnectTimer) { + return; + } + + reconnectAttempts += 1; + const delay = Math.min(1000 * reconnectAttempts, 5000); + log(`本地唤醒事件桥将在 ${delay}ms 后重连: ${bridgeUrl}`, 'warning'); + reconnectTimer = window.setTimeout(() => { + reconnectTimer = null; + tryConnect(); + }, delay); }; - return wakewordEventSource; + return wakewordSocket; } catch (error) { log(`启动本地唤醒监听失败: ${error.message}`, 'error'); - return tryConnect(index + 1); + return null; } } export function stopWakewordBridgeListener() { - if (!wakewordEventSource) { + shouldReconnect = false; + + if (reconnectTimer) { + window.clearTimeout(reconnectTimer); + reconnectTimer = null; + } + + if (!wakewordSocket) { return; } - wakewordEventSource.close(); - wakewordEventSource = null; + wakewordSocket.close(); + wakewordSocket = null; +} + +export function sendWakewordBridgeMessage(type, payload = {}, requestId = null) { + if (!wakewordSocket || wakewordSocket.readyState !== WebSocket.OPEN) { + log('本地唤醒事件桥未连接,无法发送消息', 'warning'); + return false; + } + + wakewordSocket.send(JSON.stringify({ + type, + requestId, + payload, + })); + return true; +} + +export function requestWakewordBridge(type, payload = {}, timeout = 5000) { + const requestId = `wakeword-${Date.now()}-${++wakewordRequestSeq}`; + + return new Promise((resolve, reject) => { + const timer = window.setTimeout(() => { + pendingWakewordRequests.delete(requestId); + reject(new Error('本地唤醒服务响应超时')); + }, timeout); + + pendingWakewordRequests.set(requestId, { resolve, reject, timer }); + + if (!sendWakewordBridgeMessage(type, payload, requestId)) { + window.clearTimeout(timer); + pendingWakewordRequests.delete(requestId); + reject(new Error('本地唤醒事件桥未连接')); + } + }); +} + +function buildWakewordBridgeUrl() { + const configured = localStorage.getItem('xz_tester_wakewordWsUrl'); + if (configured && configured.trim()) { + return configured.trim(); + } + return 'ws://127.0.0.1:8006/wakeword-ws'; +} + +function parseWakewordBridgeMessage(rawData) { + const message = JSON.parse(rawData); + return { + type: message.type || '', + requestId: message.requestId || null, + success: message.success !== false, + payload: message.payload || {}, + error: message.error || null, + }; +} + +function settleWakewordRequest(message) { + const pendingRequest = pendingWakewordRequests.get(message.requestId); + if (!pendingRequest) { + return; + } + + window.clearTimeout(pendingRequest.timer); + pendingWakewordRequests.delete(message.requestId); + + if (message.success === false) { + pendingRequest.reject(new Error(message.error || '本地唤醒服务返回失败')); + return; + } + + pendingRequest.resolve(message); +} + +function rejectAllWakewordRequests(errorMessage) { + pendingWakewordRequests.forEach((pendingRequest) => { + window.clearTimeout(pendingRequest.timer); + pendingRequest.reject(new Error(errorMessage)); + }); + pendingWakewordRequests.clear(); } \ No newline at end of file diff --git a/main/xiaozhi-server/test/js/ui/controller.js b/main/xiaozhi-server/test/js/ui/controller.js index 81265b1f..42c69d7d 100644 --- a/main/xiaozhi-server/test/js/ui/controller.js +++ b/main/xiaozhi-server/test/js/ui/controller.js @@ -2,7 +2,9 @@ import { loadConfig, saveConfig } from '../config/manager.js?v=0205'; import { getAudioPlayer } from '../core/audio/player.js?v=0205'; import { getAudioRecorder } from '../core/audio/recorder.js?v=0205'; +import { requestWakewordBridge } from '../core/network/wakeword-bridge.js?v=0205'; import { getWebSocketHandler } from '../core/network/websocket.js?v=0205'; +import { log } from '../utils/logger.js?v=0205'; // UI controller class class UIController { @@ -14,6 +16,8 @@ class UIController { this.currentBackgroundIndex = localStorage.getItem('backgroundIndex') ? parseInt(localStorage.getItem('backgroundIndex')) : 0; this.backgroundImages = ['1.png', '2.png', '3.png']; this.dialBtnDisabled = false; + this.isConnecting = false; + this.lastWakewordDialTime = 0; // Bind methods this.init = this.init.bind(this); @@ -25,6 +29,9 @@ class UIController { this.showModal = this.showModal.bind(this); this.hideModal = this.hideModal.bind(this); this.switchTab = this.switchTab.bind(this); + this.applyWakewordConfig = this.applyWakewordConfig.bind(this); + this.handleApplyWakeword = this.handleApplyWakeword.bind(this); + this.triggerWakewordDial = this.triggerWakewordDial.bind(this); } // Initialize @@ -262,6 +269,11 @@ class UIController { }); }); + const applyWakewordBtn = document.getElementById('applyWakewordBtn'); + if (applyWakewordBtn) { + applyWakewordBtn.addEventListener('click', this.handleApplyWakeword); + } + // 点击模态框背景关闭(仅对特定模态框禁用此功能) const modals = document.querySelectorAll('.modal'); modals.forEach(modal => { @@ -512,6 +524,74 @@ class UIController { } } + applyWakewordConfig(config = {}) { + const wakewordEnabledInput = document.getElementById('wakewordEnabled'); + const wakewordListInput = document.getElementById('wakewordList'); + + if (!wakewordEnabledInput || !wakewordListInput) { + return; + } + + const wakeWords = Array.isArray(config.wakeWords) + ? config.wakeWords.filter(item => typeof item === 'string' && item.trim()) + : []; + + wakewordEnabledInput.value = config.enabled === false ? 'false' : 'true'; + wakewordListInput.value = wakeWords.join('\n'); + saveConfig(); + } + + async handleApplyWakeword() { + const wakewordEnabledInput = document.getElementById('wakewordEnabled'); + const wakewordListInput = document.getElementById('wakewordList'); + if (!wakewordEnabledInput || !wakewordListInput) { + return; + } + + const wakeWords = wakewordListInput.value + .split(/\r?\n/u) + .map(item => item.trim()) + .filter(Boolean) + .filter((item, index, items) => items.indexOf(item) === index); + + const payload = { + enabled: wakewordEnabledInput.value !== 'false', + wakeWords, + }; + + if (payload.enabled && payload.wakeWords.length === 0) { + this.addChatMessage('启用唤醒词时,至少需要填写一个唤醒词。', false); + return; + } + + const applyWakewordBtn = document.getElementById('applyWakewordBtn'); + if (applyWakewordBtn) { + applyWakewordBtn.disabled = true; + applyWakewordBtn.textContent = '应用中...'; + } + + try { + const response = await requestWakewordBridge('set_wakeword_config', payload); + this.applyWakewordConfig(response.payload || payload); + + const shouldRestart = window.confirm('唤醒词已保存。是否现在重启唤醒词服务以立即生效?'); + if (!shouldRestart) { + this.addChatMessage('唤醒词配置已保存,可稍后手动重启服务后生效。', false); + return; + } + + await requestWakewordBridge('restart_wakeword_service'); + this.addChatMessage('唤醒词配置已保存,唤醒词服务正在重启。', false); + } catch (error) { + this.addChatMessage(`应用唤醒词失败: ${error.message}`, false); + } finally { + if (applyWakewordBtn) { + applyWakewordBtn.disabled = false; + applyWakewordBtn.textContent = '应用唤醒词'; + } + } + } + // Start AI chat session after connection startAIChatSession() { this.addChatMessage('连接成功,开始聊天吧~😊', false); @@ -550,47 +630,51 @@ class UIController { // Handle connect button click async handleConnect() { - console.log('handleConnect called'); - - // Switch to device settings tab - this.switchTab('device'); - - // Wait for DOM update - await new Promise(resolve => setTimeout(resolve, 50)); - - const otaUrlInput = document.getElementById('otaUrl'); - - console.log('otaUrl element:', otaUrlInput); - - if (!otaUrlInput || !otaUrlInput.value) { - this.addChatMessage('请输入OTA服务器地址', false); + const wsHandler = getWebSocketHandler(); + if (this.isConnecting || (wsHandler && wsHandler.isConnected())) { + log('连接已存在或正在进行,忽略本次拨号请求', 'info'); return; } - const otaUrl = otaUrlInput.value; - console.log('otaUrl value:', otaUrl); - - // Update dial button state to connecting - const dialBtn = document.getElementById('dialBtn'); - if (dialBtn) { - dialBtn.classList.add('dial-active'); - dialBtn.querySelector('.btn-text').textContent = '连接中...'; - dialBtn.disabled = true; - } - - // Show connecting message - this.addChatMessage('正在连接服务器...', false); - - const chatIpt = document.getElementById('chatIpt'); - if (chatIpt) { - chatIpt.style.display = 'flex'; - } + this.isConnecting = true; + console.log('handleConnect called'); try { + // Switch to device settings tab + this.switchTab('device'); + + // Wait for DOM update + await new Promise(resolve => setTimeout(resolve, 50)); + + const otaUrlInput = document.getElementById('otaUrl'); + + console.log('otaUrl element:', otaUrlInput); + + if (!otaUrlInput || !otaUrlInput.value) { + this.addChatMessage('请输入OTA服务器地址', false); + return; + } + + const otaUrl = otaUrlInput.value; + console.log('otaUrl value:', otaUrl); + + // Update dial button state to connecting + const dialBtn = document.getElementById('dialBtn'); + if (dialBtn) { + dialBtn.classList.add('dial-active'); + dialBtn.querySelector('.btn-text').textContent = '连接中...'; + dialBtn.disabled = true; + } + + // Show connecting message + this.addChatMessage('正在连接服务器...', false); + + const chatIpt = document.getElementById('chatIpt'); + if (chatIpt) { + chatIpt.style.display = 'flex'; + } // Get WebSocket handler instance - const wsHandler = getWebSocketHandler(); - // Register connection state callback BEFORE connecting wsHandler.onConnectionStateChange = (isConnected) => { this.updateConnectionUI(isConnected); @@ -670,9 +754,36 @@ class UIController { dialBtn.classList.remove('dial-active'); console.log('Dial button state restored successfully'); } + } finally { + this.isConnecting = false; } } + async triggerWakewordDial(wakeWord = '唤醒词') { + const wsHandler = getWebSocketHandler(); + const now = Date.now(); + + if (wsHandler && wsHandler.isConnected()) { + log('页面已连接,忽略自动拨号', 'info'); + return false; + } + + if (this.isConnecting || this.dialBtnDisabled) { + log('页面正在连接中,忽略重复唤醒', 'info'); + return false; + } + + if (now - this.lastWakewordDialTime < 3000) { + log('唤醒触发过于频繁,忽略本次自动拨号', 'warning'); + return false; + } + + this.lastWakewordDialTime = now; + this.addChatMessage(`检测到唤醒词“${wakeWord}”,准备连接服务器...`, false); + await this.handleConnect(); + return true; + } + // Add MCP tool addMCPTool() { const mcpToolsList = document.getElementById('mcpToolsList'); diff --git a/main/xiaozhi-server/test/start_test_runtime.py b/main/xiaozhi-server/test/start_test_runtime.py index 570f69d2..5a0fdf72 100644 --- a/main/xiaozhi-server/test/start_test_runtime.py +++ b/main/xiaozhi-server/test/start_test_runtime.py @@ -1,30 +1,100 @@ +import os +import threading from pathlib import Path +if os.name == "nt": + import msvcrt +else: + import fcntl + from wakeword_runtime.config import load_config, setup_logging from wakeword_runtime.runtime import TestRuntimeApplication, TestRuntimeHttpServer +class RuntimeInstanceLock: + def __init__(self, lock_path: Path) -> None: + self.lock_path = lock_path + self._handle = None + + def acquire(self) -> bool: + self.lock_path.parent.mkdir(parents=True, exist_ok=True) + self._handle = open(self.lock_path, "a+b") + try: + if os.name == "nt": + self._handle.seek(0) + msvcrt.locking(self._handle.fileno(), msvcrt.LK_NBLCK, 1) + else: + fcntl.flock(self._handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) + self._handle.seek(0) + self._handle.truncate() + self._handle.write(str(os.getpid()).encode("ascii")) + self._handle.flush() + return True + except OSError: + self.release() + return False + + def release(self) -> None: + if self._handle is None: + return + try: + if os.name == "nt": + self._handle.seek(0) + msvcrt.locking(self._handle.fileno(), msvcrt.LK_UNLCK, 1) + else: + fcntl.flock(self._handle.fileno(), fcntl.LOCK_UN) + except OSError: + pass + finally: + self._handle.close() + self._handle = None + + def main() -> int: test_root = Path(__file__).resolve().parent - config = load_config(test_root / "wakeword_runtime") + runtime_root = test_root / "wakeword_runtime" + lock = RuntimeInstanceLock(runtime_root / ".runtime.lock") + if not lock.acquire(): + print("failed to start test runtime: another test runtime instance is already running") + print("请先关闭已有的 test runtime 进程,再重新启动。") + return 1 + + config = load_config(runtime_root) setup_logging(config.log_file, config.log_level) http_server = TestRuntimeHttpServer(test_root) - app = TestRuntimeApplication(config, http_server) + app_lock = threading.RLock() + app = TestRuntimeApplication(config, http_server.event_bridge) + + def restart_runtime() -> None: + nonlocal app + with app_lock: + app.shutdown() + next_config = load_config(runtime_root) + setup_logging(next_config.log_file, next_config.log_level) + next_app = TestRuntimeApplication(next_config, http_server.event_bridge) + next_app.setup() + next_app.start() + app = next_app + + http_server.set_restart_handler(restart_runtime) print(f"test runtime started: {http_server.page_url}") - print(f"wakeword events endpoint: {http_server.events_url}") + print(f"wakeword bridge websocket: {http_server.bridge_url}") print(f"wakeword enabled: {config.wakeword_enabled}") print("press Ctrl+C to stop") try: - app.setup() - app.start() + with app_lock: + app.setup() + app.start() http_server.serve_forever() except KeyboardInterrupt: print("test runtime stopped") finally: - app.shutdown() + with app_lock: + app.shutdown() http_server.shutdown() + lock.release() return 0 diff --git a/main/xiaozhi-server/test/test_page.html b/main/xiaozhi-server/test/test_page.html index 5e29e6d4..906d7b2e 100644 --- a/main/xiaozhi-server/test/test_page.html +++ b/main/xiaozhi-server/test/test_page.html @@ -5,7 +5,7 @@ 小智服务器测试页面 - +