"""Pronunciation fixes for the simulator's speech. The built-in OS voices are clear and instant, but they mispronounce brand names and initialisms - "AGIBOT" is the obvious one. Rather than abandoning a voice that otherwise sounds good, we respell the problem words phonetically just before they are spoken. The text the *robot* receives is never touched. This rewrite happens at the very last step, inside the PC audio layer, so: * the dashboard, history and API all keep the operator's original wording; * the real AGIBOT A3 always gets the real text - it has its own TTS and its own idea of how its name sounds. Rules live in `pronunciation.json` at the project root so they can be corrected without touching code: { "AGIBOT": "Ah-jee-bot", "A3": "A three" } Matching is case-insensitive and respects word boundaries, so "AGIBOT" will not corrupt a longer word that happens to contain it. """ from __future__ import annotations import json import logging import re from pathlib import Path from typing import Dict, List, Optional, Tuple logger = logging.getLogger(__name__) RULES_FILE = "pronunciation.json" #: Shipped defaults. Deliberately small - guessing at pronunciations nobody #: asked for is worse than leaving a word alone. Extend via pronunciation.json. DEFAULT_RULES: Dict[str, str] = { "AGIBOT": "Ah-jee-bot", "AgiBot": "Ah-jee-bot", } class Pronouncer: """Applies respelling rules to text just before synthesis.""" def __init__(self, rules: Optional[Dict[str, str]] = None) -> None: self._compiled: List[Tuple[re.Pattern, str]] = [] self.rules: Dict[str, str] = {} self.load(rules if rules is not None else DEFAULT_RULES) def load(self, rules: Dict[str, str]) -> None: self.rules = dict(rules or {}) self._compiled = [] # Longest first, so a specific phrase wins over a word inside it. for phrase in sorted(self.rules, key=len, reverse=True): replacement = self.rules[phrase] if not phrase: continue # \b does not anchor against digits/symbols the way we need for # names like "A3", so use explicit look-around on word characters. pattern = re.compile( r"(? str: if not text or not self._compiled: return text result = text for pattern, replacement in self._compiled: result = pattern.sub(replacement, result) return result def __len__(self) -> int: return len(self.rules) def load_rules(project_root: Path) -> Dict[str, str]: """Read pronunciation.json, falling back to the shipped defaults.""" path = Path(project_root) / RULES_FILE if not path.exists(): return dict(DEFAULT_RULES) try: data = json.loads(path.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError) as exc: logger.warning("%s is not valid JSON (%s); using default pronunciations", path, exc) return dict(DEFAULT_RULES) if not isinstance(data, dict): logger.warning("%s must contain a JSON object of {word: respelling}", path) return dict(DEFAULT_RULES) # Keys starting with "_" are comments, so the file can document itself. rules = { str(k): str(v) for k, v in data.items() if str(k).strip() and not str(k).startswith("_") } logger.info("loaded %d pronunciation rule(s) from %s", len(rules), path.name) return rules def build(project_root: Path, enabled: bool = True) -> Optional[Pronouncer]: """A Pronouncer, or None when the feature is switched off.""" if not enabled: return None return Pronouncer(load_rules(project_root))