diff --git a/.gitignore b/.gitignore index b0a64e3..2d1a2e4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ # Production config (use *.example.yaml as template) adn-server.yaml adn-parrot.yaml +adn-voice.yaml *.local.yaml *.local.yml diff --git a/adn-voice.example.yaml b/adn-voice.example.yaml new file mode 100644 index 0000000..1b625f0 --- /dev/null +++ b/adn-voice.example.yaml @@ -0,0 +1,84 @@ +# ADN Voice configuration — hot-reloaded every 15 seconds +# Copy to adn-voice.yaml and edit freely. +# +# If adn-voice.yaml does not exist, voice features are disabled (no error). +# This file is re-checked every 15 s; changes take effect without restart. + +VOICE: + # Languages available for voice announcements (comma-separated, e.g. "es_ES,en_GB"). + # Leave empty to disable voice features. + ANNOUNCEMENT_LANGUAGES: "" + + # Base path for AMBE audio files (relative to project root). + AUDIO_PATH: Audio + + # --------------------------------------------------------------------------- + # Scheduled AMBE announcements (array — add as many as needed) + # Pre-recorded .ambe files played on a talkgroup at intervals or hourly. + # FILE: filename without .ambe, looked up in Audio//ondemand/ + # MODE: "hourly" (once per hour) or "interval" (every INTERVAL seconds) + # --------------------------------------------------------------------------- + ANNOUNCEMENTS: [] + # Example (all variables used): + # ANNOUNCEMENTS: + # - ENABLED: false + # FILE: announcement + # TG: 2 + # MODE: hourly + # INTERVAL: 3600 + # LANGUAGE: en_GB + # - ENABLED: false + # FILE: announcement2 + # TG: 2 + # MODE: interval + # INTERVAL: 1800 + # LANGUAGE: en_GB + + # --------------------------------------------------------------------------- + # Recording + # Records voice traffic on a TG/TS and saves as .ambe in Audio//ondemand/ + # Enable on the fly, record, then disable to prevent overwriting. + # Max duration: ~2 min 45 s. + # --------------------------------------------------------------------------- + RECORDING_ENABLED: false + RECORDING_TG: 2 + RECORDING_TIMESLOT: 2 + RECORDING_FILE: recording + RECORDING_LANGUAGE: en_GB + + # --------------------------------------------------------------------------- + # TTS (Text-to-Speech) — array + # Converts .txt files to AMBE via: gTTS -> ffmpeg -> vocoder/AMBEServer + # FILE: filename without .txt, looked up in Audio//ondemand/ + # --------------------------------------------------------------------------- + + # External vocoder command ({wav} and {ambe} are replaced at runtime). + # Example: /usr/local/bin/md380-vocoder -e {wav} {ambe} + TTS_VOCODER_CMD: "" + + # AMBEServer (DV3000 remote via UDP) — alternative to local vocoder. + # See https://github.com/marrold/AMBEServer + TTS_AMBESERVER_HOST: "" + TTS_AMBESERVER_PORT: 2460 + + # Volume in dB before AMBE encoding (-3 = slightly quieter). + TTS_VOLUME: -3 + # Speech rate: 1.0 = normal, 1.2 = ~20% faster (recommended for some languages). + TTS_SPEED: 1.0 + + TTS_ANNOUNCEMENTS: [] + + # Example: + # TTS_ANNOUNCEMENTS: + # - ENABLED: false + # FILE: text1 + # TG: 2 + # MODE: hourly + # INTERVAL: 3600 + # LANGUAGE: en_GB + # - ENABLED: false + # FILE: text2 + # TG: 2 + # MODE: interval + # INTERVAL: 3600 + # LANGUAGE: en_GB diff --git a/src/adn_server/application/ports.py b/src/adn_server/application/ports.py index 70c1dc5..453c70b 100644 --- a/src/adn_server/application/ports.py +++ b/src/adn_server/application/ports.py @@ -19,8 +19,8 @@ class ConfigLoader(ABC): ... @abstractmethod - def reload_voice_config(self, config: dict[str, Any]) -> None: - """Reload voice/announcement config from main config file (optional second arg: path).""" + def reload_voice_config(self, config: dict[str, Any], voice_path: str | None = None) -> None: + """Hot-reload adn-voice.yaml into config["VOICE"].""" ... diff --git a/src/adn_server/application/voice_use_cases.py b/src/adn_server/application/voice_use_cases.py index 601b8f0..97ad220 100644 --- a/src/adn_server/application/voice_use_cases.py +++ b/src/adn_server/application/voice_use_cases.py @@ -50,10 +50,10 @@ class VoiceUseCases: self._defer_to_thread = defer_to_thread self._ann_tasks: dict[int, Any] = {} self._tts_tasks: dict[int, Any] = {} - self._announcement_running: dict[int, bool] = {1: False, 2: False, 3: False, 4: False} - self._tts_running: dict[int, bool] = {1: False, 2: False, 3: False, 4: False} - self._announcement_last_hour: dict[int, int] = {1: -1, 2: -1, 3: -1, 4: -1} - self._tts_last_hour: dict[int, int] = {1: -1, 2: -1, 3: -1, 4: -1} + self._announcement_running: dict[int, bool] = {} + self._tts_running: dict[int, bool] = {} + self._announcement_last_hour: dict[int, int] = {} + self._tts_last_hour: dict[int, int] = {} self._config_file_mtime: float = 0.0 self._broadcast_queue: list[dict[str, Any]] = [] self._broadcast_active: bool = False @@ -170,11 +170,11 @@ class VoiceUseCases: source_id: bytes, dst_id: bytes, tg: int, - ann_num: int, + ann_idx: int, label: str, next_time: float | None = None, ) -> None: - """Send one batch of packets; schedule next via call_later (legacy _announcementSendBroadcast).""" + """Send one batch of packets; schedule next via call_later.""" total = len(pkts_by_ts.get(1, [])) if pkt_idx >= total: for t in targets: @@ -186,7 +186,7 @@ class VoiceUseCases: del obj.STATUS[sid] except Exception: pass - self._announcement_running[ann_num] = False + self._announcement_running[ann_idx] = False logger.info("(%s) Broadcast complete: %s packets sent to %s targets", label, total, len(targets)) self._broadcast_finished() return @@ -229,44 +229,47 @@ class VoiceUseCases: source_id, dst_id, tg, - ann_num, + ann_idx, label, next_time, ) - def scheduled_announcement(self, ann_num: int = 1, _retry: int = 0) -> None: - """Run one scheduled file announcement (legacy scheduledAnnouncement 1–4).""" + def scheduled_announcement(self, ann_idx: int = 0, _retry: int = 0) -> None: + """Run one scheduled file announcement from ANNOUNCEMENTS[ann_idx].""" g = self._config.get("VOICE", {}) - prefix = "ANNOUNCEMENT" if ann_num == 1 else "ANNOUNCEMENT{}".format(ann_num) - label = "ANNOUNCEMENT" if ann_num == 1 else "ANNOUNCEMENT-{}".format(ann_num) - if not g.get("{}_ENABLED".format(prefix)): + announcements = g.get("ANNOUNCEMENTS") or [] + if ann_idx < 0 or ann_idx >= len(announcements): return - if self._announcement_running.get(ann_num): + item = announcements[ann_idx] + if not isinstance(item, dict) or not item.get("ENABLED"): + return + label = "ANNOUNCEMENT-{}".format(ann_idx + 1) + if self._announcement_running.get(ann_idx): if _retry == 0: logger.debug("(%s) Previous announcement still running, skipping", label) return - mode = g.get("{}_MODE".format(prefix), "interval") + mode = item.get("MODE", "interval") if mode == "hourly" and _retry == 0: now = datetime.now() if now.minute != 0: return - if self._announcement_last_hour.get(ann_num) == now.hour: + if self._announcement_last_hour.get(ann_idx) == now.hour: return - self._announcement_last_hour[ann_num] = now.hour + self._announcement_last_hour[ann_idx] = now.hour if self._broadcast_active and _retry < 60: if _retry == 0: logger.debug("(%s) Broadcast queue busy, deferring prep", label) if self._call_later: - self._call_later(3.0 + ann_num * 0.5, self.scheduled_announcement, ann_num, _retry + 1) + self._call_later(3.0 + ann_idx * 0.5, self.scheduled_announcement, ann_idx, _retry + 1) return - _file = g.get("{}_FILE".format(prefix), "") - _tg = int(g.get("{}_TG".format(prefix), 0)) - _lang = g.get("{}_LANGUAGE".format(prefix), "en_GB") + _file = (item.get("FILE") or "").strip() + _tg = int(item.get("TG", 0)) + _lang = item.get("LANGUAGE", "en_GB") if not _file or not _tg: return _dst_id = bytes_3(_tg) _source_id = bytes_3(5000) - server_id = g.get("SERVER_ID", b"\x00\x00\x00\x00") + server_id = self._config.get("GLOBAL", {}).get("SERVER_ID", b"\x00\x00\x00\x00") if not isinstance(server_id, bytes): server_id = bytes_3(int(server_id)) logger.info("(%s) Playing file: %s to TG %s (both TS, mode: %s, lang: %s)", label, _file, _tg, mode, _lang) @@ -297,63 +300,66 @@ class VoiceUseCases: "(%s) Broadcasting %s packets to %s targets (TS1:%s TS2:%s): %s", label, len(pkts_by_ts[1]), len(targets), ts1_count, ts2_count, sys_names, ) - self._announcement_running[ann_num] = True - self._enqueue_broadcast('ann', targets, pkts_by_ts, _source_id, _dst_id, _tg, ann_num, label) + self._announcement_running[ann_idx] = True + self._enqueue_broadcast('ann', targets, pkts_by_ts, _source_id, _dst_id, _tg, ann_idx, label) - def scheduled_tts_announcement(self, tts_num: int = 1, _retry: int = 0) -> None: - """Run one scheduled TTS announcement (legacy scheduledTTSAnnouncement 1–4).""" + def scheduled_tts_announcement(self, tts_idx: int = 0, _retry: int = 0) -> None: + """Run one scheduled TTS announcement from TTS_ANNOUNCEMENTS[tts_idx].""" g = self._config.get("VOICE", {}) - prefix = "TTS_ANNOUNCEMENT{}".format(tts_num) - label = "TTS-{}".format(tts_num) - if not g.get("{}_ENABLED".format(prefix), False): + tts_list = g.get("TTS_ANNOUNCEMENTS") or [] + if tts_idx < 0 or tts_idx >= len(tts_list): return - if self._tts_running.get(tts_num): + item = tts_list[tts_idx] + if not isinstance(item, dict) or not item.get("ENABLED", False): + return + label = "TTS-{}".format(tts_idx + 1) + if self._tts_running.get(tts_idx): if _retry == 0: logger.debug("(%s) Previous TTS announcement still running, skipping", label) return - mode = g.get("{}_MODE".format(prefix), "interval") + mode = item.get("MODE", "interval") if mode == "hourly" and _retry == 0: now = datetime.now() if now.minute != 0: return - if self._tts_last_hour.get(tts_num) == now.hour: + if self._tts_last_hour.get(tts_idx) == now.hour: return - self._tts_last_hour[tts_num] = now.hour + self._tts_last_hour[tts_idx] = now.hour if self._broadcast_active and _retry < 60: if _retry == 0: logger.debug("(%s) Broadcast queue busy, deferring TTS prep", label) if self._call_later: - self._call_later(3.0 + tts_num * 0.5, self.scheduled_tts_announcement, tts_num, _retry + 1) + self._call_later(3.0 + tts_idx * 0.5, self.scheduled_tts_announcement, tts_idx, _retry + 1) return - _file = g.get("{}_FILE".format(prefix), "") - _tg = int(g.get("{}_TG".format(prefix), 0)) - _lang = g.get("{}_LANGUAGE".format(prefix), "en_GB") - self._tts_running[tts_num] = True + _file = (item.get("FILE") or "").strip() + _tg = int(item.get("TG", 0)) + _lang = item.get("LANGUAGE", "en_GB") + self._tts_running[tts_idx] = True logger.info("(%s) Starting TTS conversion in background thread for %s", label, _file) if self._defer_to_thread: - d = self._defer_to_thread(tts_ensure_tts_ambe, self._config, tts_num, self._audio_path) - d.addCallback(self._tts_conversion_done, tts_num, _file, _tg, _lang, mode, label) - d.addErrback(self._tts_conversion_error, tts_num, label) + d = self._defer_to_thread(tts_ensure_tts_ambe, self._config, item, self._audio_path) + d.addCallback(self._tts_conversion_done, tts_idx, _file, _tg, _lang, mode, label) + d.addErrback(self._tts_conversion_error, tts_idx, label) else: try: - ambe_path = tts_ensure_tts_ambe(self._config, tts_num, self._audio_path) - self._tts_conversion_done(ambe_path, tts_num, _file, _tg, _lang, mode, label) + ambe_path = tts_ensure_tts_ambe(self._config, item, self._audio_path) + self._tts_conversion_done(ambe_path, tts_idx, _file, _tg, _lang, mode, label) except Exception as e: - self._tts_conversion_error(e, tts_num, label) + self._tts_conversion_error(e, tts_idx, label) def _tts_conversion_done( - self, ambe_path: str | None, tts_num: int, _file: str, _tg: int, _lang: str, mode: str, label: str, _retry: int = 0 + self, ambe_path: str | None, tts_idx: int, _file: str, _tg: int, _lang: str, mode: str, label: str, _retry: int = 0 ) -> None: - """After TTS conversion: broadcast like scheduled_announcement (legacy _ttsConversionDone).""" + """After TTS conversion: broadcast like scheduled_announcement.""" if not ambe_path: - self._tts_running[tts_num] = False + self._tts_running[tts_idx] = False logger.warning("(%s) No AMBE file available for TTS announcement %s", label, _file) return if self._broadcast_active and _retry < 60: if _retry == 0: - logger.debug('(%s) Broadcast queue busy, deferring TTS packet prep', 'TTS-{}'.format(tts_num)) + logger.debug("(%s) Broadcast queue busy, deferring TTS packet prep", label) if self._call_later: - self._call_later(3.0 + tts_num * 0.5, self._tts_conversion_done, ambe_path, tts_num, _file, _tg, _lang, mode, label, _retry + 1) + self._call_later(3.0 + tts_idx * 0.5, self._tts_conversion_done, ambe_path, tts_idx, _file, _tg, _lang, mode, label, _retry + 1) return logger.info("(%s) Playing TTS file: %s to TG %s (both TS, mode: %s, lang: %s)", label, _file, _tg, mode, _lang) _dst_id = bytes_3(_tg) @@ -365,12 +371,12 @@ class VoiceUseCases: _say = self.read_single_file(self._audio_path, _lang, _file_base) if not _say: logger.warning("(%s) Cannot read AMBE file: %s", label, ambe_path) - self._tts_running[tts_num] = False + self._tts_running[tts_idx] = False return tg_str = str(_tg) targets = self._build_announcement_targets(_tg, tg_str, label) if not targets: - self._tts_running[tts_num] = False + self._tts_running[tts_idx] = False logger.info("(%s) No systems with active bridge for TG %s to send to", label, _tg) return _say_list = [_say] @@ -379,10 +385,10 @@ class VoiceUseCases: 2: list(self.pkt_gen(_source_id, _dst_id, server_id, 1, _say_list)), } logger.info("(%s) Broadcasting %s packets to %s targets", label, len(pkts_by_ts[1]), len(targets)) - self._enqueue_broadcast('tts', targets, pkts_by_ts, _source_id, _dst_id, _tg, tts_num, label) + self._enqueue_broadcast('tts', targets, pkts_by_ts, _source_id, _dst_id, _tg, tts_idx, label) - def _tts_conversion_error(self, failure: Any, tts_num: int, label: str) -> None: - self._tts_running[tts_num] = False + def _tts_conversion_error(self, failure: Any, tts_idx: int, label: str) -> None: + self._tts_running[tts_idx] = False try: msg = failure.getErrorMessage() except Exception: @@ -397,11 +403,11 @@ class VoiceUseCases: source_id: bytes, dst_id: bytes, tg: int, - tts_num: int, + tts_idx: int, label: str, next_time: float | None = None, ) -> None: - """Same as _announcement_send_broadcast but clears _tts_running (legacy _ttsSendBroadcast).""" + """Same as _announcement_send_broadcast but clears _tts_running.""" total = len(pkts_by_ts.get(1, [])) if pkt_idx >= total: for t in targets: @@ -413,7 +419,7 @@ class VoiceUseCases: del obj.STATUS[sid] except Exception: pass - self._tts_running[tts_num] = False + self._tts_running[tts_idx] = False logger.info("(%s) Broadcast complete: %s packets sent to %s targets", label, total, len(targets)) self._broadcast_finished() return @@ -456,7 +462,7 @@ class VoiceUseCases: source_id, dst_id, tg, - tts_num, + tts_idx, label, next_time, ) @@ -554,7 +560,7 @@ class VoiceUseCases: logger.debug("(%s) disconnected voice thread end", system) def check_voice_config_reload(self, config_file_path: str | None = None) -> None: - """Check main config file mtime and reload if changed (15s loop). Start/stop announcement LoopingCalls.""" + """Check adn-voice.yaml mtime and reload if changed (15s loop). Start/stop announcement LoopingCalls.""" if config_file_path and os.path.isfile(config_file_path): try: mtime = os.path.getmtime(config_file_path) @@ -567,47 +573,69 @@ class VoiceUseCases: g = self._config.get("VOICE", {}) if not self._start_looping_call: return - for ann_num in range(1, 5): - prefix = "ANNOUNCEMENT" if ann_num == 1 else "ANNOUNCEMENT{}".format(ann_num) - label = "ANNOUNCEMENT" if ann_num == 1 else "ANNOUNCEMENT-{}".format(ann_num) - enabled = g.get("{}_ENABLED".format(prefix), False) - if ann_num in self._ann_tasks: + announcements = g.get("ANNOUNCEMENTS") or [] + if not isinstance(announcements, list): + announcements = [] + for ann_idx in list(self._ann_tasks.keys()): + if ann_idx >= len(announcements) or not (isinstance(announcements[ann_idx], dict) and announcements[ann_idx].get("ENABLED")): + try: + if getattr(self._ann_tasks[ann_idx], "running", False): + self._ann_tasks[ann_idx].stop() + except Exception: + pass + del self._ann_tasks[ann_idx] + logger.info("(VOICE-RELOAD) ANNOUNCEMENT-%s stopped", ann_idx + 1) + for ann_idx, item in enumerate(announcements): + if not isinstance(item, dict) or not item.get("ENABLED"): + continue + label = "ANNOUNCEMENT-{}".format(ann_idx + 1) + if ann_idx in self._ann_tasks: try: - if getattr(self._ann_tasks[ann_num], "running", False): - self._ann_tasks[ann_num].stop() + if getattr(self._ann_tasks[ann_idx], "running", False): + self._ann_tasks[ann_idx].stop() except Exception: pass - del self._ann_tasks[ann_num] + del self._ann_tasks[ann_idx] logger.info("(VOICE-RELOAD) %s stopped", label) - if enabled: - mode = g.get("{}_MODE".format(prefix), "interval") - interval = 30.0 if mode == "hourly" else float(g.get("{}_INTERVAL".format(prefix), 60)) - lc = self._start_looping_call(lambda an=ann_num: self.scheduled_announcement(an), interval, False) - self._ann_tasks[ann_num] = lc - logger.info( - "(VOICE-RELOAD) %s enabled - mode: %s, file: %s, TG: %s", - label, mode, g.get("{}_FILE".format(prefix)), g.get("{}_TG".format(prefix)), - ) - for tts_num in range(1, 5): - prefix = "TTS_ANNOUNCEMENT{}".format(tts_num) - label = "TTS-{}".format(tts_num) - enabled = g.get("{}_ENABLED".format(prefix), False) - if tts_num in self._tts_tasks: + mode = item.get("MODE", "interval") + interval = 30.0 if mode == "hourly" else float(item.get("INTERVAL", 60)) + lc = self._start_looping_call(lambda ai=ann_idx: self.scheduled_announcement(ai), interval, False) + self._ann_tasks[ann_idx] = lc + logger.info( + "(VOICE-RELOAD) %s enabled - mode: %s, file: %s, TG: %s", + label, mode, item.get("FILE"), item.get("TG"), + ) + tts_list = g.get("TTS_ANNOUNCEMENTS") or [] + if not isinstance(tts_list, list): + tts_list = [] + for tts_idx in list(self._tts_tasks.keys()): + if tts_idx >= len(tts_list) or not (isinstance(tts_list[tts_idx], dict) and tts_list[tts_idx].get("ENABLED")): + try: + if getattr(self._tts_tasks[tts_idx], "running", False): + self._tts_tasks[tts_idx].stop() + except Exception: + pass + del self._tts_tasks[tts_idx] + logger.info("(VOICE-RELOAD) TTS-%s stopped", tts_idx + 1) + for tts_idx, item in enumerate(tts_list): + if not isinstance(item, dict) or not item.get("ENABLED"): + continue + label = "TTS-{}".format(tts_idx + 1) + if tts_idx in self._tts_tasks: try: - if getattr(self._tts_tasks[tts_num], "running", False): - self._tts_tasks[tts_num].stop() + if getattr(self._tts_tasks[tts_idx], "running", False): + self._tts_tasks[tts_idx].stop() except Exception: pass - del self._tts_tasks[tts_num] + del self._tts_tasks[tts_idx] logger.info("(VOICE-RELOAD) %s stopped", label) - if enabled: - mode = g.get("{}_MODE".format(prefix), "interval") - interval = 30.0 if mode == "hourly" else float(g.get("{}_INTERVAL".format(prefix), 60)) - lc = self._start_looping_call(lambda tn=tts_num: self.scheduled_tts_announcement(tn), interval, False) - self._tts_tasks[tts_num] = lc - logger.info( - "(VOICE-RELOAD) %s enabled - mode: %s, file: %s, TG: %s", - label, mode, g.get("{}_FILE".format(prefix)), g.get("{}_TG".format(prefix)), - ) + mode = item.get("MODE", "interval") + interval = 30.0 if mode == "hourly" else float(item.get("INTERVAL", 60)) + lc = self._start_looping_call(lambda ti=tts_idx: self.scheduled_tts_announcement(ti), interval, False) + self._tts_tasks[tts_idx] = lc + logger.info( + "(VOICE-RELOAD) %s enabled - mode: %s, file: %s, TG: %s", + label, mode, item.get("FILE"), item.get("TG"), + ) if config_file_path and self._config_file_mtime: logger.info("(VOICE-RELOAD) config reload completed") diff --git a/src/adn_server/infrastructure/config_loader.py b/src/adn_server/infrastructure/config_loader.py index c2b68fc..f4506bf 100644 --- a/src/adn_server/infrastructure/config_loader.py +++ b/src/adn_server/infrastructure/config_loader.py @@ -97,21 +97,23 @@ class YamlConfigLoader: process_acls(config) return config - def reload_voice_config(self, config: dict[str, Any], config_path: str | None = None) -> None: - """Reload GLOBAL from main config file (voice, announcements, TTS live there). Updates config in place.""" - if not config_path or not os.path.isfile(config_path): - return + def load_voice_config(self, voice_path: str | None = None) -> dict[str, Any]: + """Load voice config from adn-voice.yaml. Returns the VOICE dict (empty if file missing).""" + if not voice_path: + voice_path = str(self._root / "adn-voice.yaml") + if not os.path.isfile(voice_path): + return {} try: - with open(config_path, "r", encoding="utf-8") as f: + with open(voice_path, "r", encoding="utf-8") as f: data = yaml.safe_load(f) except (OSError, yaml.YAMLError): - return + return {} if not data or not isinstance(data, dict): - return - new_global = data.get("GLOBAL", {}) - if new_global: - config["GLOBAL"].update(new_global) - process_acls(config) - new_voice = data.get("VOICE", {}) + return {} + return data.get("VOICE", {}) + + def reload_voice_config(self, config: dict[str, Any], voice_path: str | None = None) -> None: + """Hot-reload adn-voice.yaml into config["VOICE"]. Called every 15 s.""" + new_voice = self.load_voice_config(voice_path) if new_voice: config.setdefault("VOICE", {}).update(new_voice) diff --git a/src/adn_server/infrastructure/voice/tts_engine.py b/src/adn_server/infrastructure/voice/tts_engine.py index 8350c53..a577d68 100644 --- a/src/adn_server/infrastructure/voice/tts_engine.py +++ b/src/adn_server/infrastructure/voice/tts_engine.py @@ -288,36 +288,35 @@ def text_to_ambe( return True -def ensure_tts_ambe(config: dict[str, Any], tts_num: int, audio_path: str) -> str | None: - """Ensure .ambe exists for TTS_ANNOUNCEMENT{tts_num}; create from .txt if needed. Returns path or None.""" - g = config.get("VOICE", {}) - _prefix = "TTS_ANNOUNCEMENT{}".format(tts_num) - if not g.get("{}_ENABLED".format(_prefix), False): +def ensure_tts_ambe(config: dict[str, Any], item: dict[str, Any], audio_path: str) -> str | None: + """Ensure .ambe exists for TTS item; create from .txt if needed. Returns path or None.""" + if not item.get("ENABLED", False): return None - _file = g.get("{}_FILE".format(_prefix), "") - _lang = g.get("{}_LANGUAGE".format(_prefix), "en_GB") + _file = (item.get("FILE") or "").strip() + _lang = item.get("LANGUAGE", "en_GB") if not _file: return None + g = config.get("VOICE", {}) _txt_path = os.path.join(audio_path, _lang, "ondemand", _file + ".txt") _ambe_path = os.path.join(audio_path, _lang, "ondemand", _file + ".ambe") if os.path.isfile(_ambe_path): if not os.path.isfile(_txt_path): - logger.info("(TTS-%d) Using existing AMBE file (no .txt): %s", tts_num, _ambe_path) + logger.info("(TTS) Using existing AMBE file (no .txt): %s", _ambe_path) return _ambe_path if os.path.getmtime(_ambe_path) > os.path.getmtime(_txt_path): - logger.debug("(TTS-%d) Using cached AMBE: %s", tts_num, _ambe_path) + logger.debug("(TTS) Using cached AMBE: %s", _ambe_path) return _ambe_path if not os.path.isfile(_txt_path): - logger.warning("(TTS-%d) Text file not found: %s", tts_num, _txt_path) + logger.warning("(TTS) Text file not found: %s", _txt_path) return None _vocoder_cmd = g.get("TTS_VOCODER_CMD", "") - _ambeserver_host = g.get("TTS_AMBESERVER_HOST", "").strip() + _ambeserver_host = (g.get("TTS_AMBESERVER_HOST") or "").strip() _ambeserver_port = int(g.get("TTS_AMBESERVER_PORT", 2460)) _volume_db = int(g.get("TTS_VOLUME", -3)) _speed = float(g.get("TTS_SPEED", 1.0)) if text_to_ambe(_txt_path, _ambe_path, _lang, _vocoder_cmd, _ambeserver_host, _ambeserver_port, _volume_db, _speed): return _ambe_path if os.path.isfile(_ambe_path): - logger.warning("(TTS-%d) Using previous AMBE (conversion failed): %s", tts_num, _ambe_path) + logger.warning("(TTS) Using previous AMBE (conversion failed): %s", _ambe_path) return _ambe_path return None diff --git a/src/adn_server/main.py b/src/adn_server/main.py index 72efa8a..9e3be12 100644 --- a/src/adn_server/main.py +++ b/src/adn_server/main.py @@ -132,6 +132,12 @@ def main() -> None: loader = YamlConfigLoader(project_root) config = loader.load(config_path) + # Voice config lives in a separate file for hot-reload without restart + voice_config_path = os.path.join(os.path.dirname(os.path.abspath(config_path)), "adn-voice.yaml") + voice_data = loader.load_voice_config(voice_config_path) + if voice_data: + config.setdefault("VOICE", {}).update(voice_data) + if args.LOG_LEVEL: config.setdefault("LOGGER", {})["LOG_LEVEL"] = args.LOG_LEVEL logger = setup_logging(config.get("LOGGER", {})) @@ -231,7 +237,7 @@ def main() -> None: start_looping_call=_start_voice_loop, defer_to_thread=threads.deferToThread, ) - voice_use_cases.check_voice_config_reload(config_path) + voice_use_cases.check_voice_config_reload(voice_config_path) ident_use_cases = IdentUseCases( config, voice_use_cases, @@ -380,11 +386,11 @@ def main() -> None: signal.signal(signal.SIGINT, sig_handler) reactor.addSystemEventTrigger("before", "shutdown", shutdown_handler) - # Voice/announcement config reload (15s): re-read main YAML, start/stop announcement LoopingCalls on change + # Voice config reload (15s): re-read adn-voice.yaml, start/stop announcement LoopingCalls on change def voice_reload_loop(): try: - loader.reload_voice_config(config, config_path) - voice_use_cases.check_voice_config_reload(config_path) + loader.reload_voice_config(config, voice_config_path) + voice_use_cases.check_voice_config_reload(voice_config_path) except Exception as e: logger.debug("(VOICE-RELOAD) %s", e)