update:为 test 页面增加可配置的本地唤醒词运行时

This commit is contained in:
3030332422
2026-04-10 09:17:22 +08:00
parent c5f82369e5
commit 705b5732a6
23 changed files with 1116 additions and 3 deletions
+3
View File
@@ -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 { getAudioPlayer } from './core/audio/player.js?v=0205';
import { checkMicrophoneAvailability, isHttpNonLocalhost } from './core/audio/recorder.js?v=0205'; import { checkMicrophoneAvailability, isHttpNonLocalhost } from './core/audio/recorder.js?v=0205';
import { initMcpTools } from './core/mcp/tools.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 { uiController } from './ui/controller.js?v=0205';
import { log } from './utils/logger.js?v=0205'; import { log } from './utils/logger.js?v=0205';
@@ -43,6 +44,8 @@ class App {
await this.audioPlayer.start(); await this.audioPlayer.start();
// 初始化MCP工具 // 初始化MCP工具
initMcpTools(); initMcpTools();
// 初始化本地唤醒事件监听
startWakewordBridgeListener();
// 检查麦克风可用性 // 检查麦克风可用性
await this.checkMicrophoneAvailability(); await this.checkMicrophoneAvailability();
// 检查摄像头可用性 // 检查摄像头可用性
@@ -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;
}
@@ -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())
+3 -3
View File
@@ -21,13 +21,13 @@
warningDiv.innerHTML = ` warningDiv.innerHTML = `
<h2>⚠️ 警告:请使用HTTP服务器打开此页面</h2> <h2>⚠️ 警告:请使用HTTP服务器打开此页面</h2>
<p>您当前使用的是本地文件方式打开页面(file://协议),这可能导致页面功能异常。</p> <p>您当前使用的是本地文件方式打开页面(file://协议),这可能导致页面功能异常。</p>
<p>您可以使用nginx映射启动测试页面,也可以请按照以下步骤使用python启动测试http服务</p> <p>您可以使用nginx映射启动测试页面,也可以请按照以下步骤启动测试运行时</p>
<ol> <ol>
<li>打开命令行终端</li> <li>打开命令行终端</li>
<li>命令行进入到 xiaozhi-server/test 目录</li> <li>命令行进入到 xiaozhi-server/test 目录</li>
<li>执行以下命令启动HTTP服务器</li> <li>执行以下命令启动测试页面运行时</li>
</ol> </ol>
<pre>python -m http.server 8006</pre> <pre>python start_test_runtime.py</pre>
<p>然后在浏览器中访问:<strong>http://localhost:8006/test_page.html</strong></p> <p>然后在浏览器中访问:<strong>http://localhost:8006/test_page.html</strong></p>
`; `;
document.body.appendChild(warningDiv); document.body.appendChild(warningDiv);
@@ -0,0 +1 @@
"""Test-side wakeword runtime package."""
@@ -0,0 +1 @@
from .event_bridge import WakewordEventBridge
@@ -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
@@ -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": [
"你好小智"
]
}
@@ -0,0 +1,2 @@
from .config_loader import RuntimeConfig, load_config
from .logging_setup import setup_logging
@@ -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
@@ -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)
@@ -0,0 +1,3 @@
from .detector_assets import DetectorAssets, DetectorAssetsBuilder
from .detector import WakewordDetector
from .microphone import MicrophoneListener
@@ -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)
@@ -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)
@@ -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")
@@ -0,0 +1,4 @@
from .audio import AudioPlugin
from .base import Plugin
from .manager import PluginManager
from .wake_word import WakeWordPlugin
@@ -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
@@ -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
@@ -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()
@@ -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)
@@ -0,0 +1,2 @@
from .app import TestRuntimeApplication
from .http_server import TestRuntimeHttpServer
@@ -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)
@@ -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