wangzhibo
2026-08-30 c8515170fd399b4d2dfaebb59128d4674e58915e
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
import json
from dataclasses import dataclass
from pathlib import Path
 
 
@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
 
    def validate(self) -> None:
        if self.wakeword.enabled and not self.wake_words:
            raise ValueError("keywords.txt 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_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()
    wake_words = _load_wake_words_from_keywords_file(model_dir)
 
    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")),
        ),
    )
    config.validate()
    return config
 
 
DEFAULT_WAKE_WORDS = [
    "你好小智",
    "你好小志",
    "小爱同学",
    "你好小鑫",
    "你好小新",
    "小美同学",
    "小龙小龙",
    "喵喵同学",
    "小滨小滨",
    "小冰小冰",
    "嘿你好呀",
]
 
 
def _load_wake_words_from_keywords_file(model_dir: Path) -> list[str]:
    keywords_file = model_dir / "keywords.txt"
    if not keywords_file.exists():
        return DEFAULT_WAKE_WORDS.copy()
 
    wake_words: list[str] = []
    for line in keywords_file.read_text(encoding="utf-8").splitlines():
        text = line.strip()
        if not text or text.startswith("#") or "@" not in text:
            continue
 
        wake_word = text.split("@", 1)[1].strip()
        if wake_word:
            wake_words.append(wake_word)
 
    return wake_words if wake_words else DEFAULT_WAKE_WORDS.copy()