"""Fault-tolerant pygame audio playback isolated from the application.""" from __future__ import annotations import importlib import logging from pathlib import Path from typing import Any from .config_loader import AudioSettings LOGGER = logging.getLogger(__name__) class AudioPlayer: """Load and play one prompt sound without exposing pygame types.""" def __init__(self, config: AudioSettings, backend: Any | None = None) -> None: self.config = config self._backend = backend self._sound: Any | None = None self._channel: Any | None = None self._loaded_path: Path | None = None self._closed = False @property def audio_file(self) -> str | None: return str(self._loaded_path) if self._loaded_path else self.config.file_path or None def load(self, audio_path: Path) -> None: if not self.config.enabled or self.config.backend == "noop": return if not audio_path.is_file(): LOGGER.warning("Audio prompt file does not exist: %s", audio_path) return try: backend = self._backend or importlib.import_module("pygame") self._backend = backend if not backend.mixer.get_init(): backend.mixer.init() self._sound = backend.mixer.Sound(str(audio_path)) self._sound.set_volume(self.config.volume) self._loaded_path = audio_path except Exception as exc: # pygame raises backend-specific exceptions LOGGER.error("Failed to load audio prompt %s: %s", audio_path, exc) self._sound = None def play(self) -> bool: if not self.config.enabled or self._sound is None or self._closed: return False try: if self.is_playing(): if self.config.overlap_policy == "skip": return False if self.config.overlap_policy == "restart": self.stop() self._channel = self._sound.play() return self._channel is not None except Exception as exc: LOGGER.error("Failed to play audio prompt: %s", exc) return False def stop(self) -> None: try: if self._channel is not None: self._channel.stop() except Exception as exc: LOGGER.warning("Failed to stop audio prompt: %s", exc) finally: self._channel = None def is_playing(self) -> bool: try: return bool(self._channel is not None and self._channel.get_busy()) except Exception: return False def close(self) -> None: if self._closed: return self.stop() try: if self._backend is not None and self._backend.mixer.get_init(): self._backend.mixer.quit() except Exception as exc: LOGGER.warning("Failed to close audio backend: %s", exc) self._closed = True