239 lines
6.9 KiB
Python
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()
|