EU-Utility/core/audio.py

239 lines
6.9 KiB
Python

"""
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()