#!/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 os 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 and its subdirectories. Searches recursively through subdirectories and loads all sound files with .ogg or .wav extensions. Sound names are stored as relative paths from the sound directory, with directory separators replaced by forward slashes. """ try: # Walk through directory tree for dirPath, dirNames, fileNames in os.walk(self.soundDir): # Get relative path from soundDir relPath = os.path.relpath(dirPath, self.soundDir) # Process each file for fileName in fileNames: # Check if file is a valid sound file if fileName.lower().endswith(('.ogg', '.wav')): # Full path to the sound file fullPath = os.path.join(dirPath, fileName) # Create sound key (remove extension) baseName = os.path.splitext(fileName)[0] # If in root sounds dir, just use basename if relPath == '.': soundKey = baseName else: # Otherwise use relative path + basename, normalized with forward slashes soundKey = os.path.join(relPath, baseName).replace('\\', '/') # Load the sound self.sounds[soundKey] = pygame.mixer.Sound(fullPath) except Exception as e: print(f"Error loading sounds: {e}") def play_sound(self, soundName, volume=1.0, loop=False): """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] if loop: channel = sound.play(-1) else: 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, loop=False): """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 """ if loop: channel = sound.play(-1) else: 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