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