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("keywords.txt 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)