"""Sound management module for PygStormGames. Handles all audio functionality including: - Background music playback - Sound effects with 2D/3D positional audio - Volume control for master, BGM, and SFX - Audio loading and resource management """ import os import random import re import pyglet from os.path import isfile, join from pyglet.window import key class Sound: """Handles audio playback and management.""" def __init__(self, game): """Initialize sound system. Args: game (PygStormGames): Reference to main game object """ # Game reference for component access self.game = game # Volume control (0.0 - 1.0) self.bgmVolume = 0.75 # Background music self.sfxVolume = 1.0 # Sound effects self.masterVolume = 1.0 # Master volume # Current background music self.currentBgm = None # Load sound resources self.sounds = self._load_sounds() self.activeSounds = [] # Track playing sounds def _load_sounds(self): """Load all sound files from sounds directory. Returns: dict: Dictionary of loaded sound objects """ sounds = {} try: soundFiles = [f for f in os.listdir("sounds/") if isfile(join("sounds/", f)) and f.lower().endswith(('.wav', '.ogg'))] for f in soundFiles: name = os.path.splitext(f)[0] sounds[name] = pyglet.media.load(f"sounds/{f}", streaming=False) except FileNotFoundError: print("No sounds directory found") return {} except Exception as e: print(f"Error loading sounds: {e}") return sounds def play_bgm(self, music_file): """Play background music with proper volume. Args: music_file (str): Path to music file """ try: if self.currentBgm: self.currentBgm.pause() # Load and play new music music = pyglet.media.load(music_file, streaming=True) player = pyglet.media.Player() player.queue(music) player.loop = True player.volume = self.bgmVolume * self.masterVolume player.play() self.currentBgm = player except Exception as e: print(f"Error playing background music: {e}") def pause_bgm(self): """Pause background music.""" if self.currentBgm and self.currentBgm.playing: self.currentBgm.pause() def resume_bgm(self): """Resume background music from paused state.""" if self.currentBgm and not self.currentBgm.playing: self.currentBgm.play() def play_sound(self, soundName, volume=1.0): """Play a sound effect with volume settings. Args: soundName (str): Name of sound to play volume (float): Base volume for sound (0.0-1.0) Returns: pyglet.media.Player: Sound player object """ if soundName not in self.sounds: return None player = pyglet.media.Player() player.queue(self.sounds[soundName]) player.volume = volume * self.sfxVolume * self.masterVolume player.play() self.activeSounds.append(player) return player def play_random(self, base_name, pause=False, interrupt=False): """Play random variation of a sound. Args: base_name (str): Base name of sound pause (bool): Wait for sound to finish interrupt (bool): Stop other sounds """ matches = [name for name in self.sounds.keys() if re.match(f"^{base_name}.*", name)] if not matches: return None if interrupt: self.stop_all_sounds() soundName = random.choice(matches) player = self.play_sound(soundName) if pause and player: player.on_player_eos = lambda: None # Wait for completion def calculate_positional_audio(self, source_pos, listener_pos, mode='2d'): """Calculate position for 3D audio. Args: source_pos: Either float (2D x-position) or tuple (3D x,y,z position) listener_pos: Either float (2D x-position) or tuple (3D x,y,z position) mode: '2d' or '3d' to specify positioning mode Returns: tuple: (x, y, z) position for sound source, or None if out of range """ if mode == '2d': distance = abs(source_pos - listener_pos) max_distance = 12 if distance > max_distance: return None return (source_pos - listener_pos, 0, -1) else: x = source_pos[0] - listener_pos[0] y = source_pos[1] - listener_pos[1] z = source_pos[2] - listener_pos[2] distance = (x*x + y*y + z*z) ** 0.5 max_distance = 20 # Larger for 3D space if distance > max_distance: return None return (x, y, z) def play_positional(self, soundName, source_pos, listener_pos, mode='2d', direction=None, cone_angles=None): """Play sound with positional audio. Args: soundName (str): Name of sound to play source_pos: Position of sound source (float for 2D, tuple for 3D) listener_pos: Position of listener (float for 2D, tuple for 3D) mode: '2d' or '3d' to specify positioning mode direction: Optional tuple (x,y,z) for directional sound cone_angles: Optional tuple (inner, outer) angles for sound cone Returns: pyglet.media.Player: Sound player object """ if soundName not in self.sounds: return None position = self.calculate_positional_audio(source_pos, listener_pos, mode) if position is None: # Too far to hear return None player = pyglet.media.Player() player.queue(self.sounds[soundName]) player.position = position player.volume = self.sfxVolume * self.masterVolume # Set up directional audio if specified if direction and mode == '3d': player.cone_orientation = direction if cone_angles: player.cone_inner_angle, player.cone_outer_angle = cone_angles player.cone_outer_gain = 0.5 # Reduced volume outside cone player.play() self.activeSounds.append(player) return player def update_positional(self, player, source_pos, listener_pos, mode='2d', direction=None): """Update position of a playing sound. Args: player: Sound player to update source_pos: New source position listener_pos: New listener position mode: '2d' or '3d' positioning mode direction: Optional new direction for directional sound """ if not player or not player.playing: return position = self.calculate_positional_audio(source_pos, listener_pos, mode) if position is None: player.pause() return player.position = position if direction and mode == '3d': player.cone_orientation = direction def cut_scene(self, soundName): """Play a sound as a cut scene, stopping other sounds and waiting for completion.""" # Stop all current sounds self.stop_all_sounds() if self.currentBgm: self.currentBgm.pause() if soundName not in self.sounds: return # Create and configure the player player = pyglet.media.Player() player.queue(self.sounds[soundName]) player.volume = self.sfxVolume * self.masterVolume # Start playback player.play() # Wait for completion or skip using game's wait_for_completion method interrupted = self.game.wait_for_completion( lambda: not player.playing ) # Ensure cleanup player.pause() player.delete() # Resume background music if it was playing if self.currentBgm: self.currentBgm.play() def adjust_master_volume(self, change): """Adjust master volume. Args: change (float): Volume change (-1.0 to 1.0) """ if not -1.0 <= change <= 1.0: return self.masterVolume = max(0.0, min(1.0, self.masterVolume + change)) # Update BGM if self.currentBgm: self.currentBgm.volume = self.bgmVolume * self.masterVolume # Update active sounds for sound in self.activeSounds: if sound.playing: sound.volume *= self.masterVolume def adjust_bgm_volume(self, change): """Adjust background music volume. Args: change (float): Volume change (-1.0 to 1.0) """ if not -1.0 <= change <= 1.0: return self.bgmVolume = max(0.0, min(1.0, self.bgmVolume + change)) if self.currentBgm: self.currentBgm.volume = self.bgmVolume * self.masterVolume def adjust_sfx_volume(self, change): """Adjust sound effects volume. Args: change (float): Volume change (-1.0 to 1.0) """ if not -1.0 <= change <= 1.0: return self.sfxVolume = max(0.0, min(1.0, self.sfxVolume + change)) for sound in self.activeSounds: if sound.playing: sound.volume *= self.sfxVolume def get_volumes(self): """Get current volume levels. Returns: tuple: (masterVolume, bgmVolume, sfxVolume) """ return (self.masterVolume, self.bgmVolume, self.sfxVolume) def pause(self): """Pause all audio.""" if self.currentBgm: self.currentBgm.pause() for sound in self.activeSounds: if sound.playing: sound.pause() def resume(self): """Resume all audio.""" if self.currentBgm: self.currentBgm.play() for sound in self.activeSounds: sound.play() def stop_all_sounds(self): """Stop all playing sounds.""" for sound in self.activeSounds: sound.pause() self.activeSounds.clear() def cleanup(self): """Clean up sound resources.""" if self.currentBgm: self.currentBgm.pause() self.stop_all_sounds()