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

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