#!/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 volumeService = VolumeService.get_instance() class Sound: """Handles sound loading and playback.""" def __init__(self, soundDir="sounds/", volumeService=None): """Initialize sound system. Args: soundDir (str): Directory containing sound files (default: "sounds/") volumeService (VolumeService): Volume service (default: global instance) """ self.soundDir = soundDir self.sounds = {} self.volumeService = volumeService 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: soundFiles = [f for f in listdir(self.soundDir) if isfile(join(self.soundDir, f)) and (f.split('.')[1].lower() in ["ogg", "wav"])] # Create dictionary of sound objects for f in soundFiles: self.sounds[f.split('.')[0]] = pygame.mixer.Sound(join(self.soundDir, 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, musicFile): """Play background music with proper volume settings. Args: musicFile (str): Path to the music file to play """ try: pygame.mixer.music.stop() pygame.mixer.music.load(musicFile) pygame.mixer.music.set_volume(self.volumeService.get_bgm_volume()) pygame.mixer.music.play(-1) # Loop indefinitely except Exception as e: pass def play_sound(self, soundName, volume=1.0): """Play a sound with current volume settings applied. Args: soundName (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 soundName not in self.sounds: return None sound = self.sounds[soundName] channel = sound.play() if channel: channel.set_volume(volume * self.volumeService.get_sfx_volume()) return channel def calculate_volume_and_pan(self, playerPos, objPos, maxDistance=12): """Calculate volume and stereo panning based on relative positions. Args: playerPos (float): Player's position on x-axis objPos (float): Object's position on x-axis maxDistance (float): Maximum audible distance (default: 12) Returns: tuple: (volume, left_vol, right_vol) values between 0 and 1 """ distance = abs(playerPos - objPos) if distance > maxDistance: 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 = (((maxDistance - distance) / maxDistance) ** 1.5) * self.volumeService.masterVolume # Determine left/right based on relative position if playerPos < objPos: # Object is to the right left = max(0, 1 - (objPos - playerPos) / maxDistance) right = 1 elif playerPos > objPos: # Object is to the left left = 1 right = max(0, 1 - (playerPos - objPos) / maxDistance) else: # Player is on the object left = right = 1 return volume, left, right def obj_play(self, soundName, playerPos, objPos, loop=True): """Play a sound with positional audio. Args: soundName (str): Name of sound to play playerPos (float): Player's position for audio panning objPos (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 soundName not in self.sounds: return None volume, left, right = self.calculate_volume_and_pan(playerPos, objPos) if volume == 0: return None # Don't play if out of range # Play the sound on a new channel channel = self.sounds[soundName].play(-1 if loop else 0) if channel: channel.set_volume( volume * left * self.volumeService.sfxVolume, volume * right * self.volumeService.sfxVolume ) return channel def obj_update(self, channel, playerPos, objPos): """Update positional audio for a playing sound. Args: channel: Sound channel to update playerPos (float): New player position objPos (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(playerPos, objPos) if volume == 0: channel.stop() return None # Apply the volume and pan channel.set_volume( volume * left * self.volumeService.sfxVolume, volume * right * self.volumeService.sfxVolume ) 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, soundNames, probability, randomLocation=False): """Play random ambient sounds with optional positional audio. Args: 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) if ambianceSound not in self.sounds: return None channel = self.sounds[ambianceSound].play() if randomLocation and channel: leftVolume = random.random() * self.volumeService.get_sfx_volume() rightVolume = random.random() * self.volumeService.get_sfx_volume() channel.set_volume(leftVolume, rightVolume) return channel def play_random(self, soundPrefix, pause=False, interrupt=False): """Play a random variation of a sound. Args: soundPrefix (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("^" + soundPrefix + ".*", i): keys.append(i) if not keys: # No matching sounds found return None randomKey = random.choice(keys) if interrupt: self.cut_scene(randomKey) return channel = self.sounds[randomKey].play() sfxVolume = self.volumeService.get_sfx_volume() if channel: channel.set_volume(sfxVolume, sfxVolume) if pause: time.sleep(self.sounds[randomKey].get_length()) return channel def play_random_positional(self, soundPrefix, playerX, objectX): """Play a random variation of a sound with positional audio. Args: soundPrefix (str): Base name of sound to match playerX (float): Player's x position objectX (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(soundPrefix)] if not keys: return None randomKey = random.choice(keys) volume, left, right = self.calculate_volume_and_pan(playerX, objectX) if volume == 0: return None channel = self.sounds[randomKey].play() if channel: channel.set_volume( volume * left * self.volumeService.sfxVolume, volume * right * self.volumeService.sfxVolume ) return channel def play_directional_sound(self, 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: 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 """ if soundName not in self.sounds: return None channel = self.sounds[soundName].play() if channel: # Apply volume settings finalVolume = volume * self.volumeService.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(self, soundName): """Play a sound as a cut scene, stopping other sounds. Args: soundName (str): Name of sound to play """ if soundName 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 sfxVolume = self.volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) # Play the sound channel.play(self.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(self, soundPrefix, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=None): """Play or update a falling sound with positional audio and volume based on height. Args: soundPrefix (str): Base name of sound to match playerX (float): Player's x position objectX (float): Object's x position startY (float): Starting Y position (0-20, higher = quieter start) currentY (float): Current Y position (0 = ground level) (default: 0) maxY (float): Maximum Y value (default: 20) existingChannel: 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(playerX, objectX) # Calculate vertical fall volume multiplier (0 at maxY, 1 at y=0) fallMultiplier = 1 - (currentY / maxY) # Adjust final volumes finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume if existingChannel is not None: if volume == 0: # Out of audible range existingChannel.stop() return None existingChannel.set_volume( finalLeft * self.volumeService.sfxVolume, finalRight * self.volumeService.sfxVolume ) return existingChannel 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(soundPrefix)] if not keys: return None randomKey = random.choice(keys) channel = self.sounds[randomKey].play() if channel: channel.set_volume( finalLeft * self.volumeService.sfxVolume, finalRight * self.volumeService.sfxVolume ) return channel # Global functions for backward compatibility def play_bgm(musicFile): """Play background music with proper volume settings. Args: musicFile (str): Path to the music file to play """ try: pygame.mixer.music.stop() pygame.mixer.music.load(musicFile) pygame.mixer.music.set_volume(volumeService.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) """ volumeService.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) """ volumeService.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) """ volumeService.adjust_sfx_volume(change, pygame.mixer) def calculate_volume_and_pan(playerPos, objPos): """Calculate volume and stereo panning based on relative positions. Args: playerPos (float): Player's position on x-axis objPos (float): Object's position on x-axis Returns: tuple: (volume, left_vol, right_vol) values between 0 and 1 """ distance = abs(playerPos - objPos) maxDistance = 12 # Maximum audible distance if distance > maxDistance: 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 = (((maxDistance - distance) / maxDistance) ** 1.5) * volumeService.masterVolume # Determine left/right based on relative position if playerPos < objPos: # Object is to the right left = max(0, 1 - (objPos - playerPos) / maxDistance) right = 1 elif playerPos > objPos: # Object is to the left left = 1 right = max(0, 1 - (playerPos - objPos) / maxDistance) 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 * volumeService.get_sfx_volume()) return channel def obj_play(sounds, soundName, playerPos, objPos, loop=True): """Play a sound with positional audio. Args: sounds (dict): Dictionary of sound objects soundName (str): Name of sound to play playerPos (float): Player's position for audio panning objPos (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(playerPos, objPos) 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 * volumeService.sfxVolume, volume * right * volumeService.sfxVolume) return channel def obj_update(channel, playerPos, objPos): """Update positional audio for a playing sound. Args: channel: Sound channel to update playerPos (float): New player position objPos (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(playerPos, objPos) if volume == 0: channel.stop() return None # Apply the volume and pan channel.set_volume(volume * left * volumeService.sfxVolume, volume * right * volumeService.sfxVolume) 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() * volumeService.get_sfx_volume() rightVolume = random.random() * volumeService.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: sfxVolume = volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) if pause: time.sleep(sounds[randomKey].get_length()) def play_random_positional(sounds, soundName, playerX, objectX): """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 playerX (float): Player's x position objectX (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(playerX, objectX) if volume == 0: return None channel = sounds[randomKey].play() if channel: channel.set_volume(volume * left * volumeService.sfxVolume, volume * right * volumeService.sfxVolume) 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 * volumeService.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 sfxVolume = volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) # 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, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=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 playerX (float): Player's x position objectX (float): Object's x position startY (float): Starting Y position (0-20, higher = quieter start) currentY (float): Current Y position (0 = ground level) (default: 0) maxY (float): Maximum Y value (default: 20) existingChannel: 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(playerX, objectX) # Calculate vertical fall volume multiplier (0 at maxY, 1 at y=0) fallMultiplier = 1 - (currentY / maxY) # Adjust final volumes finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume if existingChannel is not None: if volume == 0: # Out of audible range existingChannel.stop() return None existingChannel.set_volume(finalLeft * volumeService.sfxVolume, finalRight * volumeService.sfxVolume) return existingChannel 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 * volumeService.sfxVolume, finalRight * volumeService.sfxVolume) return channel