Source code for unipress.core.sound

"""Comprehensive sound system for Unipress games.

This module provides centralized sound management with volume control,
event-based audio feedback, and game startup synchronization.
"""

import time
from dataclasses import dataclass
from enum import Enum
from typing import Dict, Optional, Any

import arcade
from loguru import logger

from unipress.core.assets import get_sound
from unipress.core.settings import get_setting


[docs] class SoundCategory(Enum): """Sound event categories for volume control and organization.""" GAME_START = "game_start" PLAYER_ACTION = "player_action" SUCCESS = "success" FAILURE = "failure" ACHIEVEMENT = "achievement" UI_FEEDBACK = "ui_feedback" AMBIENT = "ambient"
[docs] @dataclass class SoundEvent: """Definition of a sound event with metadata.""" category: SoundCategory file_path: str volume_multiplier: float = 1.0 global_sound: bool = False
[docs] def __post_init__(self): """Validate sound event parameters.""" if not (0.0 <= self.volume_multiplier <= 2.0): raise ValueError(f"Volume multiplier must be between 0.0 and 2.0, got {self.volume_multiplier}")
[docs] class SoundManager: """Centralized sound management for games with volume control and event handling."""
[docs] def __init__(self, game_name: str, settings: Dict[str, Any]): """Initialize sound manager for a specific game. Args: game_name: Name of the game for asset loading settings: Game settings dictionary """ self.game_name = game_name self.settings = settings self._sound_cache: Dict[str, Optional[arcade.Sound]] = {} self._currently_playing: Dict[str, arcade.Sound] = {} logger.info(f"SoundManager initialized for game: {game_name}")
[docs] def calculate_volume(self, event: SoundEvent, volume_override: Optional[float] = None) -> float: """Calculate final volume for a sound event. Args: event: Sound event definition volume_override: Optional volume override (0.0-1.0) Returns: Final volume level (0.0-1.0) """ if volume_override is not None: return max(0.0, min(1.0, volume_override)) master = get_setting(self.settings, "audio.master_volume", 1.0) # Map categories to settings keys category_settings = { SoundCategory.PLAYER_ACTION: "audio.sfx_volume", SoundCategory.SUCCESS: "audio.sfx_volume", SoundCategory.FAILURE: "audio.sfx_volume", SoundCategory.ACHIEVEMENT: "audio.sfx_volume", SoundCategory.GAME_START: "audio.sfx_volume", SoundCategory.UI_FEEDBACK: "audio.ui_volume", SoundCategory.AMBIENT: "audio.music_volume", } # Default volumes per category category_defaults = { SoundCategory.PLAYER_ACTION: 0.7, SoundCategory.SUCCESS: 0.7, SoundCategory.FAILURE: 0.7, SoundCategory.ACHIEVEMENT: 0.8, SoundCategory.GAME_START: 0.8, SoundCategory.UI_FEEDBACK: 0.6, SoundCategory.AMBIENT: 0.5, } setting_key = category_settings.get(event.category, "audio.sfx_volume") default_volume = category_defaults.get(event.category, 0.7) category_volume = get_setting(self.settings, setting_key, default_volume) final_volume = master * category_volume * event.volume_multiplier return max(0.0, min(1.0, final_volume))
[docs] def load_sound(self, event: SoundEvent) -> Optional[arcade.Sound]: """Load a sound for the given event. Args: event: Sound event definition Returns: Loaded arcade.Sound or None if loading failed """ cache_key = f"{event.global_sound}:{event.file_path}" if cache_key in self._sound_cache: return self._sound_cache[cache_key] # Load sound using existing asset system game_name = None if event.global_sound else self.game_name sound = get_sound(event.file_path, game_name) self._sound_cache[cache_key] = sound if sound: logger.debug(f"Sound loaded: {event.file_path} (global={event.global_sound})") else: logger.warning(f"Failed to load sound: {event.file_path} (global={event.global_sound})") return sound
[docs] def play_sound(self, event: SoundEvent, volume_override: Optional[float] = None) -> Optional[arcade.Sound]: """Play a sound event with appropriate volume. Args: event: Sound event to play volume_override: Optional volume override (0.0-1.0) Returns: Playing arcade.Sound or None if sound unavailable """ sound = self.load_sound(event) if not sound: return None volume = self.calculate_volume(event, volume_override) try: played_sound = arcade.play_sound(sound, volume) # Track currently playing sounds event_key = f"{event.category.value}:{event.file_path}" self._currently_playing[event_key] = played_sound logger.debug(f"Sound played: {event.file_path} at volume {volume:.2f}") return played_sound except Exception as e: logger.error(f"Failed to play sound {event.file_path}: {e}") return None
[docs] def wait_for_sound_completion(self, sound: arcade.Sound, timeout: float = 5.0) -> None: """Wait for a sound to complete playing. Args: sound: Sound to wait for timeout: Maximum wait time in seconds """ if not sound: return start_time = time.time() try: # Note: arcade.Sound doesn't have a direct "is_playing" method # This is a simple time-based approach - could be improved with # actual sound duration detection estimated_duration = min(3.0, timeout) # Assume most UI sounds are < 3 seconds time.sleep(estimated_duration) logger.debug(f"Sound completion wait finished after {time.time() - start_time:.2f}s") except Exception as e: logger.warning(f"Error waiting for sound completion: {e}")
[docs] def stop_all_sounds(self) -> None: """Stop all currently playing sounds.""" try: arcade.stop_sound() self._currently_playing.clear() logger.debug("All sounds stopped") except Exception as e: logger.error(f"Failed to stop sounds: {e}")
[docs] def preload_sounds(self, events: Dict[str, SoundEvent]) -> None: """Preload multiple sound events for better performance. Args: events: Dictionary of event_name -> SoundEvent mappings """ loaded_count = 0 failed_count = 0 for event_name, event in events.items(): sound = self.load_sound(event) if sound: loaded_count += 1 else: failed_count += 1 logger.info(f"Sound preloading complete: {loaded_count} loaded, {failed_count} failed")
# Predefined sound events for common game scenarios STANDARD_SOUND_EVENTS: Dict[str, SoundEvent] = { "game_start": SoundEvent( SoundCategory.GAME_START, "system/game_start.ogg", volume_multiplier=1.0, global_sound=True ), "jump": SoundEvent( SoundCategory.PLAYER_ACTION, "player/jump_01.ogg", volume_multiplier=0.8 ), "success": SoundEvent( SoundCategory.SUCCESS, "success/obstacle_cleared.ogg", volume_multiplier=0.9 ), "failure": SoundEvent( SoundCategory.FAILURE, "player/collision_01.ogg", volume_multiplier=1.0 ), "high_score": SoundEvent( SoundCategory.ACHIEVEMENT, "achievements/new_high_score.ogg", volume_multiplier=1.2, global_sound=True ), "game_over": SoundEvent( SoundCategory.FAILURE, "system/game_over.ogg", volume_multiplier=1.0, global_sound=True ), "ui_cycle": SoundEvent( SoundCategory.UI_FEEDBACK, "ui/menu_cycle.ogg", volume_multiplier=0.7, global_sound=True ), "ui_confirm": SoundEvent( SoundCategory.UI_FEEDBACK, "ui/confirm.ogg", volume_multiplier=0.8, global_sound=True ), }