#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Sound handling for Storm Games. Provides functionality for: - Playing background music and sound effects - 2D positional audio (x,y) - Volume controls """ import os import pygame import random import re import time import math from .services import VolumeService # Global instance for backward compatibility volumeService = VolumeService.get_instance() # Global channel manager for explicit channel assignment _next_channel_index = 1 def get_available_channel(): """Get next available channel using explicit assignment instead of pygame's auto-selection.""" global _next_channel_index # Try to find an idle channel starting from next_channel_index for _ in range(47): # Try all available channels (1-47, skip 0 reserved for cutscenes) channel = pygame.mixer.Channel(_next_channel_index) if not channel.get_busy(): result = _next_channel_index _next_channel_index = (_next_channel_index % 47) + 1 # Cycle 1-47 return pygame.mixer.Channel(result) _next_channel_index = (_next_channel_index % 47) + 1 # If all channels busy, force stop the current one and use it channel = pygame.mixer.Channel(_next_channel_index) channel.stop() pygame.event.pump() # Force immediate cleanup result = _next_channel_index _next_channel_index = (_next_channel_index % 47) + 1 return pygame.mixer.Channel(result) def play_sound_with_retry(sound, loop=False, max_retries=3): """Play a sound with explicit channel assignment instead of pygame auto-selection.""" for attempt in range(max_retries): try: # Use explicit channel assignment instead of pygame's automatic selection channel = get_available_channel() channel.play(sound, -1 if loop else 0) return channel, True except Exception as e: if attempt == max_retries - 1: print(f"Sound playback failed after {max_retries} attempts: {e}") return None, False return None, False def find_priority_channel(): """Find an available reserved channel (0-15) for priority sounds like obj_play.""" # Check channels 1-15 first (skip 0 which is for cutscenes specifically) for n in range(1, 16): channel = pygame.mixer.Channel(n) if not channel.get_busy(): return channel # If all busy, stop the highest numbered priority channel (15) and use it channel = pygame.mixer.Channel(15) channel.stop() return channel def get_channel_usage(): """Return channel usage info for debugging.""" total = pygame.mixer.get_num_channels() reserved = 16 busy = sum(1 for n in range(total) if pygame.mixer.Channel(n).get_busy()) priority_busy = sum(1 for n in range(reserved) if pygame.mixer.Channel(n).get_busy()) return f"Channels: {busy}/{total} busy (priority: {priority_busy}/{reserved})" def print_channel_debug(): """Print detailed channel usage for debugging.""" total = pygame.mixer.get_num_channels() print(f"Channel usage: {get_channel_usage()}") busy_channels = [] silent_channels = [] for n in range(total): channel = pygame.mixer.Channel(n) if channel.get_busy(): volume = channel.get_volume() if volume <= 0.001: silent_channels.append(n) else: busy_channels.append(n) if busy_channels: print(f"Active channels: {busy_channels}") if silent_channels: print(f"Silent channels: {silent_channels}") if not busy_channels and not silent_channels: print("No busy channels") class Sound: """Handles sound loading and playback.""" def __init__(self, soundDir="sounds/", volumeService=None): """Initialize sound system.""" self.soundDir = soundDir self.sounds = {} self.volumeService = volumeService or VolumeService.get_instance() if not pygame.mixer.get_init(): pygame.mixer.pre_init(44100, -16, 2, 1024, allowedchanges=0) pygame.mixer.init() pygame.mixer.set_num_channels(48) pygame.mixer.set_reserved(1) # Reserve channel 0 for cutscenes only self.load_sounds() def load_sounds(self): """Load all sound files from the sound directory and its subdirectories.""" try: for dirPath, _, fileNames in os.walk(self.soundDir): relPath = os.path.relpath(dirPath, self.soundDir) for fileName in fileNames: if fileName.lower().endswith(('.ogg', '.wav')): fullPath = os.path.join(dirPath, fileName) baseName = os.path.splitext(fileName)[0] soundKey = baseName if relPath == '.' else os.path.join(relPath, baseName).replace('\\', '/') self.sounds[soundKey] = pygame.mixer.Sound(fullPath) except Exception as e: print(f"Error loading sounds: {e}") def _find_matching_sound(self, pattern): """Find a random sound matching the pattern.""" keys = [k for k in self.sounds.keys() if re.match("^" + pattern + ".*", k)] return random.choice(keys) if keys else None def _handle_cutscene(self, soundName): """Play a sound as a cut scene.""" pygame.event.clear() pygame.mixer.stop() channel = pygame.mixer.Channel(0) sfxVolume = self.volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) 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 None pygame.event.pump() pygame.event.clear() pygame.time.delay(10) return None def _get_stereo_panning(self, playerPos, objPos, centerDistance=None): """Calculate stereo panning based on positions.""" # Extract x-positions playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos # For directional sound with fixed distance if centerDistance is not None: if abs(playerX - objX) <= centerDistance: return (1, 1) # Center elif playerX > objX: return (1, 0.505) # Left else: return (0.505, 1) # Right # Calculate regular panning volume, left, right = self.calculate_volume_and_pan(playerPos, objPos) return (volume * left, volume * right) if volume > 0 else (0, 0) def play_sound(self, soundName, volume=1.0, loop=False, playerPos=None, objPos=None, centerDistance=None, pattern=False, interrupt=False, pause=False, cutScene=False): """Unified method to play sounds with various options.""" # Resolve sound name if pattern matching is requested if pattern: soundName = self._find_matching_sound(soundName) if not soundName: return None # Check if sound exists if soundName not in self.sounds: print(f"Sound not found: {soundName}") return None # Handle cut scene mode if cutScene: return self._handle_cutscene(soundName) # Handle interrupt (stop other sounds) if interrupt: pygame.event.clear() pygame.mixer.stop() # Play the sound with retry logic channel, success = play_sound_with_retry(self.sounds[soundName], loop) if not success: print(f"Failed to play sound: {soundName}") return None # Apply appropriate volume settings sfx_volume = self.volumeService.get_sfx_volume() # Handle positional audio if positions are provided if playerPos is not None and objPos is not None: # Calculate stereo panning left_vol, right_vol = self._get_stereo_panning(playerPos, objPos, centerDistance) # Don't play if out of range if left_vol == 0 and right_vol == 0: channel.stop() return None # Apply positional volume adjustments channel.set_volume(volume * left_vol * sfx_volume, volume * right_vol * sfx_volume) else: # Non-positional sound channel.set_volume(volume * sfx_volume) # Pause execution if requested if pause: time.sleep(self.sounds[soundName].get_length()) return channel def calculate_volume_and_pan(self, playerPos, objPos, maxDistance=12): """Calculate volume and stereo panning based on relative positions.""" # Determine if we're using 2D or 1D positioning if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)): # 2D distance calculation distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2) playerX, objX = playerPos[0], objPos[0] else: # 1D calculation (backward compatible) distance = abs(playerPos - objPos) playerX, objX = playerPos, objPos if distance > maxDistance: return 0, 0, 0 # No sound if out of range # Calculate volume (non-linear scaling for more noticeable changes) volume = (((maxDistance - distance) / maxDistance) ** 1.5) * self.volumeService.masterVolume # Determine left/right based on relative position if playerX < objX: # Object is to the right left = max(0, 1 - (objX - playerX) / maxDistance) right = 1 elif playerX > objX: # Object is to the left left = 1 right = max(0, 1 - (playerX - objX) / maxDistance) else: # Player is on the object left = right = 1 return volume, left, right def update_sound_position(self, channel, playerPos, objPos): """Update positional audio for a playing sound.""" if not channel: return None # Calculate new stereo panning left_vol, right_vol = self._get_stereo_panning(playerPos, objPos) # Stop if out of range if left_vol == 0 and right_vol == 0: channel.stop() return None # Apply the volume and pan channel.set_volume(left_vol * self.volumeService.sfxVolume, right_vol * self.volumeService.sfxVolume) return channel def stop_sound(self, channel): """Stop a playing sound channel.""" if channel: try: channel.stop() except: pass return None def play_falling_sound(self, soundPrefix, playerPos, objPos, startY, currentY=0, maxY=20, existingChannel=None): """Play or update a sound with positional audio that changes with height.""" # Extract positions playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos # Calculate volumes volume, left, right = self.calculate_volume_and_pan(playerX, objX) # Apply vertical fall multiplier (0 at maxY, 1 at y=0) fallMultiplier = 1 - (currentY / maxY) finalVolume = volume * fallMultiplier finalLeft = left * finalVolume finalRight = right * finalVolume # Update existing channel or create new one if existingChannel: if volume == 0: existingChannel.stop() return None existingChannel.set_volume( finalLeft * self.volumeService.sfxVolume, finalRight * self.volumeService.sfxVolume ) return existingChannel else: if volume == 0: return None # Find a matching sound soundName = self._find_matching_sound(soundPrefix) if not soundName: return None # Play the sound with retry logic channel, success = play_sound_with_retry(self.sounds[soundName], False) if not success: print(f"Failed to play falling sound: {soundName}") return None if channel: channel.set_volume( finalLeft * self.volumeService.sfxVolume, finalRight * self.volumeService.sfxVolume ) return channel def play_bgm(self, musicFile): """Play background music with proper volume settings.""" 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) except Exception as e: print(f"Failed to load background music {musicFile}: {e}") def adjust_master_volume(self, change): """Adjust the master volume for all sounds.""" self.volumeService.adjust_master_volume(change, pygame.mixer) def adjust_bgm_volume(self, change): """Adjust only the background music volume.""" self.volumeService.adjust_bgm_volume(change, pygame.mixer) def adjust_sfx_volume(self, change): """Adjust volume for sound effects only.""" self.volumeService.adjust_sfx_volume(change, pygame.mixer) # Optimized helper functions for global use def _get_stereo_panning(playerPos, objPos, centerDistance=None, maxDistance=12): """Simplified panning calculation.""" # Extract x-positions playerX = playerPos[0] if isinstance(playerPos, (tuple, list)) else playerPos objX = objPos[0] if isinstance(objPos, (tuple, list)) else objPos # For directional sound with fixed distance if centerDistance is not None: if abs(playerX - objX) <= centerDistance: return (1, 1) # Center elif playerX > objX: return (1, 0.505) # Left else: return (0.505, 1) # Right # Calculate distance if isinstance(playerPos, (tuple, list)) and isinstance(objPos, (tuple, list)): distance = math.sqrt((playerPos[0] - objPos[0])**2 + (playerPos[1] - objPos[1])**2) else: distance = abs(playerPos - objPos) if distance > maxDistance: return (0, 0) # No sound if out of range # Calculate volume (non-linear scaling for more noticeable changes) volume = (((maxDistance - distance) / maxDistance) ** 1.5) * volumeService.masterVolume # Determine left/right based on relative position if playerX < objX: # Object is to the right left = max(0, 1 - (objX - playerX) / maxDistance) right = 1 elif playerX > objX: # Object is to the left left = 1 right = max(0, 1 - (playerX - objX) / maxDistance) else: # Player is on the object left = right = 1 return (volume * left, volume * right) def _play_cutscene(sound, sounds=None): """Play a sound as a cut scene.""" pygame.event.clear() pygame.mixer.stop() channel = pygame.mixer.Channel(0) sfxVolume = volumeService.get_sfx_volume() channel.set_volume(sfxVolume, sfxVolume) # Determine which sound to play if isinstance(sound, pygame.mixer.Sound): channel.play(sound) elif isinstance(sounds, dict) and sound in sounds: channel.play(sounds[sound]) elif isinstance(sounds, Sound) and sound in sounds.sounds: channel.play(sounds.sounds[sound]) else: return None # Wait for completion or key press 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 None pygame.event.pump() pygame.event.clear() pygame.time.delay(10) return None def _find_matching_sound(soundPattern, sounds): """Find sounds matching a pattern in a dictionary.""" if isinstance(sounds, Sound): keys = [k for k in sounds.sounds.keys() if re.match("^" + soundPattern + ".*", k)] else: keys = [k for k in sounds.keys() if re.match("^" + soundPattern + ".*", k)] return random.choice(keys) if keys else None # Global functions for backward compatibility def play_bgm(musicFile): """Play background music with proper volume settings.""" try: pygame.mixer.music.stop() pygame.mixer.music.load(musicFile) pygame.mixer.music.set_volume(volumeService.get_bgm_volume()) pygame.mixer.music.play(-1) except Exception as e: print(f"Failed to load background music {musicFile}: {e}") def adjust_master_volume(change): """Adjust the master volume.""" volumeService.adjust_master_volume(change, pygame.mixer) def adjust_bgm_volume(change): """Adjust background music volume.""" volumeService.adjust_bgm_volume(change, pygame.mixer) def adjust_sfx_volume(change): """Adjust sound effects volume.""" volumeService.adjust_sfx_volume(change, pygame.mixer) def calculate_volume_and_pan(playerPos, objPos, maxDistance=12): """Calculate volume and stereo panning.""" left_vol, right_vol = _get_stereo_panning(playerPos, objPos, None, maxDistance) # Convert to old format (volume, left, right) if left_vol == 0 and right_vol == 0: return 0, 0, 0 elif left_vol >= right_vol: volume = left_vol return volume, 1, right_vol/left_vol else: volume = right_vol return volume, left_vol/right_vol, 1 def play_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=None, centerDistance=None, pattern=False, interrupt=False, pause=False, cutScene=False, sounds=None): """Unified sound playing function with backward compatibility.""" # Handle cut scene mode if cutScene: return _play_cutscene(sound_or_name, sounds) # Handle pattern matching if pattern and isinstance(sound_or_name, str) and sounds: matched_sound = _find_matching_sound(sound_or_name, sounds) if not matched_sound: return None sound_or_name = matched_sound # Handle interrupt if interrupt: pygame.event.clear() pygame.mixer.stop() # Case 1: Sound instance provided if isinstance(sound_or_name, Sound): return sound_or_name.play_sound(sound_or_name, volume, loop, playerPos, objPos, centerDistance, False, False, pause, False) # Case 2: Sound name with Sound instance elif isinstance(sounds, Sound) and isinstance(sound_or_name, str): return sounds.play_sound(sound_or_name, volume, loop, playerPos, objPos, centerDistance, False, False, pause, False) # Case 3: Direct pygame.Sound elif isinstance(sound_or_name, pygame.mixer.Sound): # Use explicit channel assignment instead of pygame auto-selection channel = get_available_channel() channel.play(sound_or_name, -1 if loop else 0) channel.set_volume(volume * volumeService.get_sfx_volume()) return channel # Case 4: Sound name with dictionary elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds: # Play the sound with retry logic channel, success = play_sound_with_retry(sounds[sound_or_name], loop) if not success: print(f"Failed to play sound: {sound_or_name}") return None # Apply volume settings sfx_vol = volumeService.get_sfx_volume() # Handle positional audio if playerPos is not None and objPos is not None: left_vol, right_vol = _get_stereo_panning(playerPos, objPos, centerDistance) if left_vol == 0 and right_vol == 0: channel.stop() return None channel.set_volume(volume * left_vol * sfx_vol, volume * right_vol * sfx_vol) else: channel.set_volume(volume * sfx_vol) # Pause if requested if pause: time.sleep(sounds[sound_or_name].get_length()) return channel return None def obj_update(channel, playerPos, objPos): """Update positional audio for a playing sound.""" if not channel: return None left_vol, right_vol = _get_stereo_panning(playerPos, objPos) if left_vol == 0 and right_vol == 0: channel.stop() return None channel.set_volume(left_vol * volumeService.sfxVolume, right_vol * volumeService.sfxVolume) return channel def obj_stop(channel): """Stop a sound channel.""" if channel: try: channel.stop() pygame.event.pump() # Force pygame to update channel state immediately except: pass return None def play_priority_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=None, centerDistance=None, sounds=None): """Play a sound using priority (reserved) channels to prevent interruption.""" try: # Get priority channel channel = find_priority_channel() if not channel: return None # Get the sound object sound_obj = None if isinstance(sound_or_name, pygame.mixer.Sound): sound_obj = sound_or_name elif isinstance(sounds, dict) and sound_or_name in sounds: sound_obj = sounds[sound_or_name] elif isinstance(sounds, Sound) and sound_or_name in sounds.sounds: sound_obj = sounds.sounds[sound_or_name] else: return None # Handle positional audio if playerPos is not None and objPos is not None: vol, pan = _get_stereo_panning(playerPos, objPos, centerDistance) volume *= vol if volume <= 0: return None channel.set_volume(volume * pan[0], volume * pan[1]) else: channel.set_volume(volume) # Play the sound channel.play(sound_obj, -1 if loop else 0) return channel except Exception: return None # Extremely concise lambda definitions for legacy functions obj_play = lambda sounds, soundName, playerPos, objPos, loop=True: play_sound( soundName, 1.0, loop, playerPos, objPos, None, False, False, False, False, sounds) play_ambiance = lambda sounds, soundNames, probability, randomLocation=False: play_sound( random.choice(soundNames) if random.randint(1, 100) <= probability and not any( pygame.mixer.find_channel(True) and pygame.mixer.find_channel(True).get_busy() for _ in ([soundNames] if isinstance(soundNames, str) else soundNames)) else None, 1.0, False, None, None, None, False, False, False, False, sounds if not isinstance(sounds, Sound) else None) play_random = lambda sounds, soundName, pause=False, interrupt=False: play_sound( soundName, 1.0, False, None, None, None, True, interrupt, pause, False, sounds) play_random_positional = lambda sounds, soundName, playerX, objectX: play_sound( soundName, 1.0, False, playerX, objectX, None, True, False, False, False, sounds) play_directional_sound = lambda sounds, soundName, playerPos, objPos, centerDistance=3, volume=1.0: play_sound( soundName, volume, False, playerPos, objPos, centerDistance, False, False, False, False, sounds) cut_scene = lambda sounds, soundName: _play_cutscene(soundName, sounds) def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0, maxY=20, existingChannel=None): """Handle falling sound.""" if isinstance(sounds, Sound): return sounds.play_falling_sound(soundName, playerX, objectX, startY, currentY, maxY, existingChannel) # Legacy implementation left_vol, right_vol = _get_stereo_panning(playerX, objectX) if left_vol == 0 and right_vol == 0: if existingChannel: existingChannel.stop() return None # Calculate fall multiplier fallMultiplier = 1 - (currentY / maxY) finalLeft = left_vol * fallMultiplier finalRight = right_vol * fallMultiplier if existingChannel: existingChannel.set_volume( finalLeft * volumeService.sfxVolume, finalRight * volumeService.sfxVolume ) return existingChannel # Find matching sound matched_sound = _find_matching_sound(soundName, sounds) if not matched_sound: return None # Play the sound with retry logic channel, success = play_sound_with_retry(sounds[matched_sound], False) if not success: print(f"Failed to play falling sound: {matched_sound}") return None if channel: channel.set_volume( finalLeft * volumeService.sfxVolume, finalRight * volumeService.sfxVolume ) return channel