You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
88 lines
2.9 KiB
88 lines
2.9 KiB
"""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
|
|
|
|
|