#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Sound handling for Storm Games. Provides functionality for: - Playing background music and sound effects - Positional audio - Volume controls """ import pygame import random import re import time from os import listdir from os.path import isfile, join from .services import VolumeService # Global instance for backward compatibility volume_service = VolumeService.get_instance() class Sound: """Handles sound loading and playback.""" def __init__(self, sound_dir="sounds/", volume_service=None): """Initialize sound system. Args: sound_dir (str): Directory containing sound files (default: "sounds/") volume_service (VolumeService): Volume service (default: global instance) """ self.sound_dir = sound_dir self.sounds = {} self.volume_service = volume_service or VolumeService.get_instance() # Initialize pygame mixer if not already done if not pygame.mixer.get_init(): pygame.mixer.pre_init(44100, -16, 2, 1024) pygame.mixer.init() pygame.mixer.set_num_channels(32) pygame.mixer.set_reserved(0) # Reserve channel for cut scenes # Load sounds self.load_sounds() def load_sounds(self): """Load all sound files from the sound directory.""" try: sound_files = [f for f in listdir(self.sound_dir) if isfile(join(self.sound_dir, f)) and (f.split('.')[1].lower() in ["ogg", "wav"])] # Create dictionary of sound objects for f in sound_files: self.sounds[f.split('.')[0]] = pygame.mixer.Sound(join(self.sound_dir, f)) except Exception as e: print(f"Error loading sounds: {e}") def play_intro(self): """Play the game intro sound if available.""" if 'game-intro' in self.sounds: self.cut_scene('game-intro') def get_sounds(self): """Get the dictionary of loaded sound objects. Returns: dict: Dictionary of loaded sound objects """ return self.sounds def play_bgm(self, music_file): """Play background music with proper volume settings. Args: music_file (str): Path to the music file to play """ try: pygame.mixer.music.stop() pygame.mixer.music.load(music_file) pygame.mixer.music.set_volume(self.volume_service.get_bgm_volume()) pygame.mixer.music.play(-1) # Loop indefinitely except Exception as e: pass def play_sound(self, sound_name, volume=1.0): """Play a sound with current volume settings applied. Args: sound_name (str): Name of sound to play volume (float): Base volume for the sound (0.0-1.0, default: 1.0) Returns: pygame.mixer.Channel: The channel the sound is playing on """ if sound_name not in self.sounds: return None sound = self.sounds[sound_name] channel = sound.play() if channel: channel.set_volume(volume * self.volume_service.get_sfx_volume()) return channel def calculate_volume_and_pan(self, player_pos, obj_pos, max_distance=12): """Calculate volume and stereo panning based on relative positions. Args: player_pos (float): Player's position on x-axis obj_pos (float): Object's position on x-axis max_distance (float): Maximum audible distance (default: 12) Returns: tuple: (volume, left_vol, right_vol) values between 0 and 1 """ distance = abs(player_pos - obj_pos) if distance > max_distance: return 0, 0, 0 # No sound if out of range # Calculate volume (non-linear scaling for more noticeable changes) # Apply masterVolume as the maximum possible volume volume = (((max_distance - distance) / max_distance) ** 1.5) * self.volume_service.master_volume # Determine left/right based on relative position if player_pos < obj_pos: # Object is to the right left = max(0, 1 - (obj_pos - player_pos) / max_distance) right = 1 elif player_pos > obj_pos: # Object is to the left left = 1 right = max(0, 1 - (player_pos - obj_pos) / max_distance) else: # Player is on the object left = right = 1 return volume, left, right def obj_play(self, sound_name, player_pos, obj_pos, loop=True): """Play a sound with positional audio. Args: sound_name (str): Name of sound to play player_pos (float): Player's position for audio panning obj_pos (float): Object's position for audio panning loop (bool): Whether to loop the sound (default: True) Returns: pygame.mixer.Channel: Sound channel object, or None if out of range """ if sound_name not in self.sounds: return None volume, left, right = self.calculate_volume_and_pan(player_pos, obj_pos) if volume == 0: return None # Don't play if out of range # Play the sound on a new channel channel = self.sounds[sound_name].play(-1 if loop else 0) if channel: channel.set_volume( volume * left * self.volume_service.sfx_volume, volume * right * self.volume_service.sfx_volume ) return channel def obj_update(self, channel, player_pos, obj_pos): """Update positional audio for a playing sound. Args: channel: Sound channel to update player_pos (float): New player position obj_pos (float): New object position Returns: pygame.mixer.Channel: Updated channel, or None if sound should stop """ if channel is None: return None volume, left, right = self.calculate_volume_and_pan(player_pos, obj_pos) if volume == 0: channel.stop() return None # Apply the volume and pan channel.set_volume( volume * left * self.volume_service.sfx_volume, volume * right * self.volume_service.sfx_volume ) return channel def obj_stop(self, channel): """Stop a playing sound channel. Args: channel: Sound channel to stop Returns: None if stopped successfully, otherwise returns original channel """ try: channel.stop() return None except: return channel def play_ambiance(self, sound_names, probability, random_location=False): """Play random ambient sounds with optional positional audio. Args: sound_names (list): List of possible sound names to choose from probability (int): Chance to play (1-100) random_location (bool): Whether to randomize stereo position Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ # Check if any of the sounds in the list is already playing for sound_name in sound_names: if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy(): return None if random.randint(1, 100) > probability: return None # Choose a random sound from the list ambiance_sound = random.choice(sound_names) if ambiance_sound not in self.sounds: return None channel = self.sounds[ambiance_sound].play() if random_location and channel: left_volume = random.random() * self.volume_service.get_sfx_volume() right_volume = random.random() * self.volume_service.get_sfx_volume() channel.set_volume(left_volume, right_volume) return channel def play_random(self, sound_prefix, pause=False, interrupt=False): """Play a random variation of a sound. Args: sound_prefix (str): Base name of sound (will match all starting with this) pause (bool): Whether to pause execution until sound finishes interrupt (bool): Whether to interrupt other sounds """ keys = [] for i in self.sounds.keys(): if re.match("^" + sound_prefix + ".*", i): keys.append(i) if not keys: # No matching sounds found return None random_key = random.choice(keys) if interrupt: self.cut_scene(random_key) return channel = self.sounds[random_key].play() sfx_volume = self.volume_service.get_sfx_volume() if channel: channel.set_volume(sfx_volume, sfx_volume) if pause: time.sleep(self.sounds[random_key].get_length()) return channel def play_random_positional(self, sound_prefix, player_x, object_x): """Play a random variation of a sound with positional audio. Args: sound_prefix (str): Base name of sound to match player_x (float): Player's x position object_x (float): Object's x position Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ keys = [k for k in self.sounds.keys() if k.startswith(sound_prefix)] if not keys: return None random_key = random.choice(keys) volume, left, right = self.calculate_volume_and_pan(player_x, object_x) if volume == 0: return None channel = self.sounds[random_key].play() if channel: channel.set_volume( volume * left * self.volume_service.sfx_volume, volume * right * self.volume_service.sfx_volume ) return channel def play_directional_sound(self, sound_name, player_pos, obj_pos, center_distance=3, volume=1.0): """Play a sound with simplified directional audio. For sounds that need to be heard clearly regardless of distance, but still provide directional feedback. Sound plays at full volume but pans left/right based on relative position. Args: sound_name (str): Name of sound to play player_pos (float): Player's x position obj_pos (float): Object's x position center_distance (float): Distance within which sound plays center (default: 3) volume (float): Base volume multiplier (0.0-1.0, default: 1.0) Returns: pygame.mixer.Channel: The channel the sound is playing on """ if sound_name not in self.sounds: return None channel = self.sounds[sound_name].play() if channel: # Apply volume settings final_volume = volume * self.volume_service.get_sfx_volume() # If player is within centerDistance tiles of object, play in center if abs(player_pos - obj_pos) <= center_distance: # Equal volume in both speakers (center) channel.set_volume(final_volume, final_volume) elif player_pos > obj_pos: # Object is to the left of player channel.set_volume(final_volume, (final_volume + 0.01) / 2) else: # Object is to the right of player channel.set_volume((final_volume + 0.01) / 2, final_volume) return channel def cut_scene(self, sound_name): """Play a sound as a cut scene, stopping other sounds. Args: sound_name (str): Name of sound to play """ if sound_name not in self.sounds: return pygame.event.clear() pygame.mixer.stop() # Get the reserved channel (0) for cut scenes channel = pygame.mixer.Channel(0) # Apply the appropriate volume settings sfx_volume = self.volume_service.get_sfx_volume() channel.set_volume(sfx_volume, sfx_volume) # Play the sound channel.play(self.sounds[sound_name]) while pygame.mixer.get_busy(): for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: pygame.mixer.stop() return pygame.time.delay(10) def play_random_falling(self, sound_prefix, player_x, object_x, start_y, current_y=0, max_y=20, existing_channel=None): """Play or update a falling sound with positional audio and volume based on height. Args: sound_prefix (str): Base name of sound to match player_x (float): Player's x position object_x (float): Object's x position start_y (float): Starting Y position (0-20, higher = quieter start) current_y (float): Current Y position (0 = ground level) (default: 0) max_y (float): Maximum Y value (default: 20) existing_channel: Existing sound channel to update (default: None) Returns: pygame.mixer.Channel: Sound channel for updating position/volume, or None if sound should stop """ # Calculate horizontal positioning volume, left, right = self.calculate_volume_and_pan(player_x, object_x) # Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0) fall_multiplier = 1 - (current_y / max_y) # Adjust final volumes final_volume = volume * fall_multiplier final_left = left * final_volume final_right = right * final_volume if existing_channel is not None: if volume == 0: # Out of audible range existing_channel.stop() return None existing_channel.set_volume( final_left * self.volume_service.sfx_volume, final_right * self.volume_service.sfx_volume ) return existing_channel else: # Need to create new channel if volume == 0: # Don't start if out of range return None # Find matching sound files keys = [k for k in self.sounds.keys() if k.startswith(sound_prefix)] if not keys: return None random_key = random.choice(keys) channel = self.sounds[random_key].play() if channel: channel.set_volume( final_left * self.volume_service.sfx_volume, final_right * self.volume_service.sfx_volume ) return channel # Global functions for backward compatibility def play_bgm(music_file): """Play background music with proper volume settings. Args: music_file (str): Path to the music file to play """ try: pygame.mixer.music.stop() pygame.mixer.music.load(music_file) pygame.mixer.music.set_volume(volume_service.get_bgm_volume()) pygame.mixer.music.play(-1) # Loop indefinitely except Exception as e: pass def adjust_master_volume(change): """Adjust the master volume for all sounds. Args: change (float): Amount to change volume by (positive or negative) """ volume_service.adjust_master_volume(change, pygame.mixer) def adjust_bgm_volume(change): """Adjust only the background music volume. Args: change (float): Amount to change volume by (positive or negative) """ volume_service.adjust_bgm_volume(change, pygame.mixer) def adjust_sfx_volume(change): """Adjust volume for sound effects only. Args: change (float): Amount to change volume by (positive or negative) """ volume_service.adjust_sfx_volume(change, pygame.mixer) def calculate_volume_and_pan(player_pos, obj_pos): """Calculate volume and stereo panning based on relative positions. Args: player_pos (float): Player's position on x-axis obj_pos (float): Object's position on x-axis Returns: tuple: (volume, left_vol, right_vol) values between 0 and 1 """ distance = abs(player_pos - obj_pos) max_distance = 12 # Maximum audible distance if distance > max_distance: return 0, 0, 0 # No sound if out of range # Calculate volume (non-linear scaling for more noticeable changes) # Apply masterVolume as the maximum possible volume volume = (((max_distance - distance) / max_distance) ** 1.5) * volume_service.master_volume # Determine left/right based on relative position if player_pos < obj_pos: # Object is to the right left = max(0, 1 - (obj_pos - player_pos) / max_distance) right = 1 elif player_pos > obj_pos: # Object is to the left left = 1 right = max(0, 1 - (player_pos - obj_pos) / max_distance) else: # Player is on the object left = right = 1 return volume, left, right def play_sound(sound, volume=1.0): """Play a sound with current volume settings applied. Args: sound: pygame Sound object to play volume: base volume for the sound (0.0-1.0, default: 1.0) Returns: pygame.mixer.Channel: The channel the sound is playing on """ channel = sound.play() if channel: channel.set_volume(volume * volume_service.get_sfx_volume()) return channel def obj_play(sounds, soundName, player_pos, obj_pos, loop=True): """Play a sound with positional audio. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play player_pos (float): Player's position for audio panning obj_pos (float): Object's position for audio panning loop (bool): Whether to loop the sound (default: True) Returns: pygame.mixer.Channel: Sound channel object, or None if out of range """ volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) if volume == 0: return None # Don't play if out of range # Play the sound on a new channel channel = sounds[soundName].play(-1 if loop else 0) if channel: channel.set_volume(volume * left * volume_service.sfx_volume, volume * right * volume_service.sfx_volume) return channel def obj_update(channel, player_pos, obj_pos): """Update positional audio for a playing sound. Args: channel: Sound channel to update player_pos (float): New player position obj_pos (float): New object position Returns: pygame.mixer.Channel: Updated channel, or None if sound should stop """ if channel is None: return None volume, left, right = calculate_volume_and_pan(player_pos, obj_pos) if volume == 0: channel.stop() return None # Apply the volume and pan channel.set_volume(volume * left * volume_service.sfx_volume, volume * right * volume_service.sfx_volume) return channel def obj_stop(channel): """Stop a playing sound channel. Args: channel: Sound channel to stop Returns: None if stopped successfully, otherwise returns original channel """ try: channel.stop() return None except: return channel def play_ambiance(sounds, soundNames, probability, randomLocation=False): """Play random ambient sounds with optional positional audio. Args: sounds (dict): Dictionary of sound objects soundNames (list): List of possible sound names to choose from probability (int): Chance to play (1-100) randomLocation (bool): Whether to randomize stereo position Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ # Check if any of the sounds in the list is already playing for soundName in soundNames: if pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy(): return None if random.randint(1, 100) > probability: return None # Choose a random sound from the list ambianceSound = random.choice(soundNames) channel = sounds[ambianceSound].play() if randomLocation and channel: leftVolume = random.random() * volume_service.get_sfx_volume() rightVolume = random.random() * volume_service.get_sfx_volume() channel.set_volume(leftVolume, rightVolume) return channel def play_random(sounds, soundName, pause=False, interrupt=False): """Play a random variation of a sound. Args: sounds (dict): Dictionary of sound objects soundName (str): Base name of sound (will match all starting with this) pause (bool): Whether to pause execution until sound finishes interrupt (bool): Whether to interrupt other sounds """ key = [] for i in sounds.keys(): if re.match("^" + soundName + ".*", i): key.append(i) if not key: # No matching sounds found return randomKey = random.choice(key) if interrupt: cut_scene(sounds, randomKey) return channel = sounds[randomKey].play() if channel: sfx_volume = volume_service.get_sfx_volume() channel.set_volume(sfx_volume, sfx_volume) if pause: time.sleep(sounds[randomKey].get_length()) def play_random_positional(sounds, soundName, player_x, object_x): """Play a random variation of a sound with positional audio. Args: sounds (dict): Dictionary of sound objects soundName (str): Base name of sound to match player_x (float): Player's x position object_x (float): Object's x position Returns: pygame.mixer.Channel: Sound channel if played, None otherwise """ keys = [k for k in sounds.keys() if k.startswith(soundName)] if not keys: return None randomKey = random.choice(keys) volume, left, right = calculate_volume_and_pan(player_x, object_x) if volume == 0: return None channel = sounds[randomKey].play() if channel: channel.set_volume(volume * left * volume_service.sfx_volume, volume * right * volume_service.sfx_volume) return channel def play_directional_sound(sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0): """Play a sound with simplified directional audio. For sounds that need to be heard clearly regardless of distance, but still provide directional feedback. Sound plays at full volume but pans left/right based on relative position. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play playerPos (float): Player's x position objPos (float): Object's x position centerDistance (float): Distance within which sound plays center (default: 3) volume (float): Base volume multiplier (0.0-1.0, default: 1.0) Returns: pygame.mixer.Channel: The channel the sound is playing on """ channel = sounds[soundName].play() if channel: # Apply volume settings finalVolume = volume * volume_service.get_sfx_volume() # If player is within centerDistance tiles of object, play in center if abs(playerPos - objPos) <= centerDistance: # Equal volume in both speakers (center) channel.set_volume(finalVolume, finalVolume) elif playerPos > objPos: # Object is to the left of player channel.set_volume(finalVolume, (finalVolume + 0.01) / 2) else: # Object is to the right of player channel.set_volume((finalVolume + 0.01) / 2, finalVolume) return channel def cut_scene(sounds, soundName): """Play a sound as a cut scene, stopping other sounds. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play """ pygame.event.clear() pygame.mixer.stop() # Get the reserved channel (0) for cut scenes channel = pygame.mixer.Channel(0) # Apply the appropriate volume settings sfx_volume = volume_service.get_sfx_volume() channel.set_volume(sfx_volume, sfx_volume) # Play the sound channel.play(sounds[soundName]) while pygame.mixer.get_busy(): for event in pygame.event.get(): if event.type == pygame.KEYDOWN and event.key in [pygame.K_ESCAPE, pygame.K_RETURN, pygame.K_SPACE]: pygame.mixer.stop() return pygame.time.delay(10) def play_random_falling(sounds, soundName, player_x, object_x, start_y, currentY=0, max_y=20, existing_channel=None): """Play or update a falling sound with positional audio and volume based on height. Args: sounds (dict): Dictionary of sound objects soundName (str): Base name of sound to match player_x (float): Player's x position object_x (float): Object's x position start_y (float): Starting Y position (0-20, higher = quieter start) currentY (float): Current Y position (0 = ground level) (default: 0) max_y (float): Maximum Y value (default: 20) existing_channel: Existing sound channel to update (default: None) Returns: pygame.mixer.Channel: Sound channel for updating position/volume, or None if sound should stop """ # Calculate horizontal positioning volume, left, right = calculate_volume_and_pan(player_x, object_x) # Calculate vertical fall volume multiplier (0 at max_y, 1 at y=0) fallMultiplier = 1 - (currentY / max_y) # Adjust final volumes finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume if existing_channel is not None: if volume == 0: # Out of audible range existing_channel.stop() return None existing_channel.set_volume(finalLeft * volume_service.sfx_volume, finalRight * volume_service.sfx_volume) return existing_channel else: # Need to create new channel if volume == 0: # Don't start if out of range return None # Find matching sound files keys = [k for k in sounds.keys() if k.startswith(soundName)] if not keys: return None randomKey = random.choice(keys) channel = sounds[randomKey].play() if channel: channel.set_volume(finalLeft * volume_service.sfx_volume, finalRight * volume_service.sfx_volume) return channel