""" EU-Utility - Audio/Sound Service Sound playback for notifications and alerts. Part of core - plugins access via PluginAPI. """ import platform import threading from pathlib import Path from typing import Dict, Optional from dataclasses import dataclass @dataclass class SoundConfig: """Sound configuration.""" enabled: bool = True volume: float = 0.7 sounds_dir: Optional[Path] = None class AudioManager: """ Core audio service for sound playback. Uses QSoundEffect when available, falls back to system tools. """ _instance = None _lock = threading.Lock() def __new__(cls): if cls._instance is None: with cls._lock: if cls._instance is None: cls._instance = super().__new__(cls) cls._instance._initialized = False return cls._instance def __init__(self): if self._initialized: return self._initialized = True self._config = SoundConfig() self._sound_cache: Dict[str, any] = {} self._available = False self._backend = None self._init_backend() def _init_backend(self): """Initialize audio backend.""" # Try QSoundEffect first (best Qt integration) try: from PyQt6.QtMultimedia import QSoundEffect self._available = True self._backend = 'qt' print("[Audio] Using Qt Multimedia backend") return except ImportError: pass # Try playsound try: from playsound import playsound self._available = True self._backend = 'playsound' print("[Audio] Using playsound backend") return except ImportError: pass # Fallback to system tools system = platform.system() if system == "Windows": try: import winsound self._available = True self._backend = 'winsound' print("[Audio] Using Windows winsound backend") return except ImportError: pass print("[Audio] WARNING: No audio backend available") print("[Audio] Install one of: PyQt6.QtMultimedia, playsound") def is_available(self) -> bool: """Check if audio is available.""" return self._available def play_sound(self, sound_key: str = "default"): """Play a sound by key.""" if not self._available or not self._config.enabled: return sound_file = self._get_sound_file(sound_key) if not sound_file: # Use system beep as fallback self._play_beep() return try: if self._backend == 'qt': self._play_qt(sound_file) elif self._backend == 'playsound': self._play_playsound(sound_file) elif self._backend == 'winsound': self._play_winsound(sound_file) except Exception as e: print(f"[Audio] Play error: {e}") def _get_sound_file(self, key: str) -> Optional[Path]: """Get sound file path.""" if not self._config.sounds_dir: return None sound_map = { 'default': 'notification.wav', 'global': 'global.wav', 'hof': 'hof.wav', 'skill_gain': 'skill.wav', 'alert': 'alert.wav', 'error': 'error.wav', 'success': 'success.wav', } filename = sound_map.get(key, f"{key}.wav") sound_file = self._config.sounds_dir / filename # Try alternative extensions if not sound_file.exists(): for ext in ['.mp3', '.ogg', '.wav']: alt = sound_file.with_suffix(ext) if alt.exists(): return alt return sound_file if sound_file.exists() else None def _play_qt(self, sound_file: Path): """Play using Qt.""" from PyQt6.QtMultimedia import QSoundEffect sound_key = str(sound_file) if sound_key not in self._sound_cache: effect = QSoundEffect() effect.setSource(str(sound_file)) effect.setVolume(self._config.volume) self._sound_cache[sound_key] = effect effect = self._sound_cache[sound_key] effect.play() def _play_playsound(self, sound_file: Path): """Play using playsound.""" from playsound import playsound import threading # playsound blocks, so run in thread def play(): try: playsound(str(sound_file), block=False) except: pass threading.Thread(target=play, daemon=True).start() def _play_winsound(self, sound_file: Path): """Play using Windows winsound.""" import winsound try: winsound.PlaySound(str(sound_file), winsound.SND_ASYNC) except: winsound.MessageBeep() def _play_beep(self): """System beep fallback.""" system = platform.system() if system == "Windows": try: import winsound winsound.MessageBeep() except: pass else: print('\a') # Bell character def set_volume(self, volume: float): """Set volume (0.0 - 1.0).""" self._config.volume = max(0.0, min(1.0, volume)) # Update cached effects for effect in self._sound_cache.values(): if hasattr(effect, 'setVolume'): effect.setVolume(self._config.volume) def set_enabled(self, enabled: bool): """Enable/disable sounds.""" self._config.enabled = enabled def is_muted(self) -> bool: """Check if muted.""" return not self._config.enabled def mute(self): """Mute sounds.""" self._config.enabled = False def unmute(self): """Unmute sounds.""" self._config.enabled = True def set_sounds_directory(self, path: Path): """Set directory containing sound files.""" self._config.sounds_dir = Path(path) def get_backend(self) -> Optional[str]: """Get current audio backend name.""" return self._backend def get_volume(self) -> float: """Get current volume (0.0 - 1.0).""" return self._config.volume def shutdown(self): """Shutdown audio manager and cleanup resources.""" # Stop any playing sounds for effect in self._sound_cache.values(): if hasattr(effect, 'stop'): effect.stop() self._sound_cache.clear() def get_audio_manager() -> AudioManager: """Get global AudioManager instance.""" return AudioManager()