libstormgames/sound.py

1170 lines
43 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Enhanced sound handling for Storm Games.
Provides functionality for:
- Playing background music and sound effects
- Positional audio (horizontal and vertical)
- Volume controls
- Sound looping with proper channel control
"""
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()
self.activeLoops = {} # Track active looping sounds by ID
# 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)
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: The channel the sound is playing on
"""
if soundName not in self.sounds:
return None
sound = self.sounds[soundName]
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = sound.play(loopParam)
if channel:
channel.set_volume(volume * self.volumeService.get_sfx_volume())
# Store in active loops if looping
if loop:
self.activeLoops[soundName] = {
'channel': channel,
'sound': sound,
'volume': volume
}
return channel
def stop_sound(self, soundName=None, channel=None):
"""Stop a specific sound or channel.
Args:
soundName (str, optional): Name of sound to stop
channel (pygame.mixer.Channel, optional): Channel to stop
Returns:
bool: True if sound was stopped, False otherwise
"""
if channel:
channel.stop()
# Remove from active loops if present
for key in list(self.activeLoops.keys()):
if self.activeLoops[key]['channel'] == channel:
del self.activeLoops[key]
return True
elif soundName:
if soundName in self.activeLoops:
self.activeLoops[soundName]['channel'].stop()
del self.activeLoops[soundName]
return True
return False
def calculate_volume_and_pan(self, playerPos, objPos, playerY=0, objY=0, maxDistance=12, maxYDistance=20):
"""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
playerY (float): Player's position on y-axis (default: 0)
objY (float): Object's position on y-axis (default: 0)
maxDistance (float): Maximum audible horizontal distance (default: 12)
maxYDistance (float): Maximum audible vertical distance (default: 20)
Returns:
tuple: (volume, left_vol, right_vol) values between 0 and 1
"""
# Calculate horizontal distance
hDistance = abs(playerPos - objPos)
# Calculate vertical distance
vDistance = abs(playerY - objY)
# If either distance exceeds maximum, no sound
if hDistance > maxDistance or vDistance > maxYDistance:
return 0, 0, 0 # No sound if out of range
# Calculate horizontal volume factor (non-linear scaling)
hVolume = ((maxDistance - hDistance) / maxDistance) ** 1.5
# Calculate vertical volume factor (linear is fine for y-axis)
vVolume = (maxYDistance - vDistance) / maxYDistance
# Combine horizontal and vertical volumes
volume = hVolume * vVolume * 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, playerY=0, objY=0, loop=False):
"""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
playerY (float): Player's Y position (default: 0)
objY (float): Object's Y position (default: 0)
loop (bool): Whether to loop the sound (default: False)
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, playerY, objY)
if volume <= 0:
return None # Don't play if out of range
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
# Play the sound on a new channel
channel = self.sounds[soundName].play(loopParam)
if channel:
channel.set_volume(
volume * left * self.volumeService.sfxVolume,
volume * right * self.volumeService.sfxVolume
)
# Store in active loops if looping
if loop:
loopId = f"{soundName}_{objPos}_{objY}"
self.activeLoops[loopId] = {
'channel': channel,
'sound': self.sounds[soundName],
'playerPos': playerPos,
'objPos': objPos,
'playerY': playerY,
'objY': objY
}
return channel
def obj_update(self, channel, playerPos, objPos, playerY=0, objY=0):
"""Update positional audio for a playing sound.
Args:
channel: Sound channel to update
playerPos (float): New player position
objPos (float): New object position
playerY (float): Player's Y position (default: 0)
objY (float): Object's Y position (default: 0)
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, playerY, objY)
if volume <= 0:
channel.stop()
# Remove from active loops if present
for key in list(self.activeLoops.keys()):
if self.activeLoops[key]['channel'] == channel:
del self.activeLoops[key]
return None
# Apply the volume and pan
channel.set_volume(
volume * left * self.volumeService.sfxVolume,
volume * right * self.volumeService.sfxVolume
)
# Update loop tracking if this is an active loop
for key in list(self.activeLoops.keys()):
if self.activeLoops[key]['channel'] == channel:
self.activeLoops[key]['playerPos'] = playerPos
self.activeLoops[key]['objPos'] = objPos
self.activeLoops[key]['playerY'] = playerY
self.activeLoops[key]['objY'] = objY
break
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()
# Remove from active loops if present
for key in list(self.activeLoops.keys()):
if self.activeLoops[key]['channel'] == channel:
del self.activeLoops[key]
return None
except:
return channel
def play_ambiance(self, soundNames, probability, randomLocation=False, loop=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
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: Sound channel if played, None otherwise
"""
# Check if any of the sounds in the list is already playing (for non-loops)
if not loop:
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
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = self.sounds[ambianceSound].play(loopParam)
if channel:
sfxVolume = self.volumeService.get_sfx_volume()
if randomLocation:
leftVolume = random.random() * sfxVolume
rightVolume = random.random() * sfxVolume
channel.set_volume(leftVolume, rightVolume)
else:
channel.set_volume(sfxVolume, sfxVolume)
# Store in active loops if looping
if loop:
loopId = f"ambiance_{ambianceSound}"
self.activeLoops[loopId] = {
'channel': channel,
'sound': self.sounds[ambianceSound],
'randomLocation': randomLocation
}
return channel
def play_random(self, soundPrefix, pause=False, interrupt=False, loop=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
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: Channel of the playing sound or None
"""
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 None
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = self.sounds[randomKey].play(loopParam)
sfxVolume = self.volumeService.get_sfx_volume()
if channel:
channel.set_volume(sfxVolume, sfxVolume)
# Store in active loops if looping
if loop:
loopId = f"random_{soundPrefix}_{randomKey}"
self.activeLoops[loopId] = {
'channel': channel,
'sound': self.sounds[randomKey],
'prefix': soundPrefix
}
if pause and not loop:
time.sleep(self.sounds[randomKey].get_length())
return channel
def play_random_positional(self, soundPrefix, playerX, objectX, playerY=0, objectY=0, loop=False):
"""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
playerY (float): Player's y position (default: 0)
objectY (float): Object's y position (default: 0)
loop (bool): Whether to loop the sound (default: False)
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, playerY, objectY)
if volume <= 0:
return None
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = self.sounds[randomKey].play(loopParam)
if channel:
channel.set_volume(
volume * left * self.volumeService.sfxVolume,
volume * right * self.volumeService.sfxVolume
)
# Store in active loops if looping
if loop:
loopId = f"random_pos_{soundPrefix}_{objectX}_{objectY}"
self.activeLoops[loopId] = {
'channel': channel,
'sound': self.sounds[randomKey],
'prefix': soundPrefix,
'playerX': playerX,
'objectX': objectX,
'playerY': playerY,
'objectY': objectY
}
return channel
def play_directional_sound(self, soundName, playerPos, objPos, playerY=0, objY=0, centerDistance=3, volume=1.0, loop=False):
"""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
playerY (float): Player's y position (default: 0)
objY (float): Object's y position (default: 0)
centerDistance (float): Distance within which sound plays center (default: 3)
volume (float): Base volume multiplier (0.0-1.0, default: 1.0)
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: The channel the sound is playing on
"""
if soundName not in self.sounds:
return None
# Check vertical distance
vDistance = abs(playerY - objY)
maxYDistance = 20 # Maximum audible vertical distance
if vDistance > maxYDistance:
return None # Don't play if out of vertical range
# Calculate vertical volume factor
vVolume = (maxYDistance - vDistance) / maxYDistance
finalVolume = volume * vVolume * self.volumeService.get_sfx_volume()
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = self.sounds[soundName].play(loopParam)
if channel:
# 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)
# Store in active loops if looping
if loop:
loopId = f"directional_{soundName}_{objPos}_{objY}"
self.activeLoops[loopId] = {
'channel': channel,
'sound': self.sounds[soundName],
'playerPos': playerPos,
'objPos': objPos,
'playerY': playerY,
'objY': objY,
'centerDistance': centerDistance,
'volume': volume
}
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()
# Clear active loops
self.activeLoops.clear()
# 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, playerY=0, maxY=20, existingChannel=None, loop=False):
"""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)
playerY (float): Player's Y position (default: 0)
maxY (float): Maximum Y value (default: 20)
existingChannel: Existing sound channel to update (default: None)
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: Sound channel for updating position/volume,
or None if sound should stop
"""
# Calculate horizontal and vertical positioning
volume, left, right = self.calculate_volume_and_pan(playerX, objectX, playerY, currentY)
# 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()
# Remove from active loops if present
for key in list(self.activeLoops.keys()):
if self.activeLoops[key]['channel'] == existingChannel:
del self.activeLoops[key]
return None
existingChannel.set_volume(
finalLeft * self.volumeService.sfxVolume,
finalRight * self.volumeService.sfxVolume
)
# Update loop tracking if this is an active loop
for key in list(self.activeLoops.keys()):
if self.activeLoops[key]['channel'] == existingChannel:
self.activeLoops[key]['playerX'] = playerX
self.activeLoops[key]['objectX'] = objectX
self.activeLoops[key]['playerY'] = playerY
self.activeLoops[key]['currentY'] = currentY
break
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)
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = self.sounds[randomKey].play(loopParam)
if channel:
channel.set_volume(
finalLeft * self.volumeService.sfxVolume,
finalRight * self.volumeService.sfxVolume
)
# Store in active loops if looping
if loop:
loopId = f"falling_{soundPrefix}_{objectX}_{currentY}"
self.activeLoops[loopId] = {
'channel': channel,
'sound': self.sounds[randomKey],
'prefix': soundPrefix,
'playerX': playerX,
'objectX': objectX,
'playerY': playerY,
'currentY': currentY,
'maxY': maxY
}
return channel
def update_all_active_loops(self, playerX, playerY):
"""Update all active looping sounds based on new player position.
This should be called in the game loop to update positional audio for all looping sounds.
Args:
playerX (float): Player's new X position
playerY (float): Player's new Y position
Returns:
int: Number of active loops updated
"""
updatedCount = 0
# Make a copy of keys since we might modify the dictionary during iteration
loopKeys = list(self.activeLoops.keys())
for key in loopKeys:
loop = self.activeLoops[key]
# Skip if channel is no longer active
if not loop['channel'].get_busy():
del self.activeLoops[key]
continue
# Handle different types of loops
if 'objPos' in loop:
# Update positional audio
self.obj_update(
loop['channel'],
playerX,
loop['objPos'],
playerY,
loop.get('objY', 0)
)
updatedCount += 1
elif 'objectX' in loop:
# Falling or positional random sound
if 'currentY' in loop:
# It's a falling sound
self.play_random_falling(
loop['prefix'],
playerX,
loop['objectX'],
0, # startY doesn't matter for updates
loop['currentY'],
playerY,
loop['maxY'],
loop['channel'],
True # Keep it looping
)
else:
# It's a positional random sound
volume, left, right = self.calculate_volume_and_pan(
playerX,
loop['objectX'],
playerY,
loop.get('objectY', 0)
)
if volume <= 0:
loop['channel'].stop()
del self.activeLoops[key]
continue
loop['channel'].set_volume(
volume * left * self.volumeService.sfxVolume,
volume * right * self.volumeService.sfxVolume
)
updatedCount += 1
elif 'randomLocation' in loop and loop['randomLocation']:
# Random location ambiance - update with new random panning
leftVolume = random.random() * self.volumeService.get_sfx_volume()
rightVolume = random.random() * self.volumeService.get_sfx_volume()
loop['channel'].set_volume(leftVolume, rightVolume)
updatedCount += 1
return updatedCount
# Global functions for backward compatibility
def play_bgm(musicFile, loop=True):
"""Play background music with proper volume settings.
Args:
musicFile (str): Path to the music file to play
loop (bool): Whether to loop the music (default: True)
Returns:
bool: True if music started successfully
"""
try:
pygame.mixer.music.stop()
pygame.mixer.music.load(musicFile)
pygame.mixer.music.set_volume(volumeService.get_bgm_volume())
# Loop indefinitely if loop=True, otherwise play once
loops = -1 if loop else 0
pygame.mixer.music.play(loops)
return True
except Exception as e:
print(f"Error playing background music: {e}")
return False
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, playerY=0, objY=0, maxDistance=12, maxYDistance=20):
"""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
playerY (float): Player's position on y-axis (default: 0)
objY (float): Object's position on y-axis (default: 0)
maxDistance (float): Maximum audible horizontal distance (default: 12)
maxYDistance (float): Maximum audible vertical distance (default: 20)
Returns:
tuple: (volume, left_vol, right_vol) values between 0 and 1
"""
# Calculate horizontal distance
hDistance = abs(playerPos - objPos)
# Calculate vertical distance
vDistance = abs(playerY - objY)
# If either distance exceeds maximum, no sound
if hDistance > maxDistance or vDistance > maxYDistance:
return 0, 0, 0 # No sound if out of range
# Calculate horizontal volume factor (non-linear scaling)
hVolume = ((maxDistance - hDistance) / maxDistance) ** 1.5
# Calculate vertical volume factor (linear is fine for y-axis)
vVolume = (maxYDistance - vDistance) / maxYDistance
# Combine horizontal and vertical volumes
volume = hVolume * vVolume * 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)
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: The channel the sound is playing on
"""
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = sound.play(loopParam)
if channel:
channel.set_volume(volume * volumeService.get_sfx_volume())
return channel
def obj_play(sounds, soundName, playerPos, objPos, playerY=0, objY=0, loop=False):
"""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
playerY (float): Player's Y position (default: 0)
objY (float): Object's Y position (default: 0)
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: Sound channel object, or None if out of range
"""
volume, left, right = calculate_volume_and_pan(playerPos, objPos, playerY, objY)
if volume <= 0:
return None # Don't play if out of range
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
# Play the sound on a new channel
channel = sounds[soundName].play(loopParam)
if channel:
channel.set_volume(
volume * left * volumeService.sfxVolume,
volume * right * volumeService.sfxVolume
)
return channel
def obj_update(channel, playerPos, objPos, playerY=0, objY=0):
"""Update positional audio for a playing sound.
Args:
channel: Sound channel to update
playerPos (float): New player position
objPos (float): New object position
playerY (float): Player's Y position (default: 0)
objY (float): Object's Y position (default: 0)
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, playerY, objY)
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, loop=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
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: Sound channel if played, None otherwise
"""
# Check if any of the sounds in the list is already playing (for non-loops)
if not loop:
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)
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = sounds[ambianceSound].play(loopParam)
if channel:
sfxVolume = volumeService.get_sfx_volume()
if randomLocation:
leftVolume = random.random() * sfxVolume
rightVolume = random.random() * sfxVolume
channel.set_volume(leftVolume, rightVolume)
else:
channel.set_volume(sfxVolume, sfxVolume)
return channel
def play_random(sounds, soundName, pause=False, interrupt=False, loop=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
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: Channel of the playing sound or None
"""
key = []
for i in sounds.keys():
if re.match("^" + soundName + ".*", i):
key.append(i)
if not key: # No matching sounds found
return None
randomKey = random.choice(key)
if interrupt:
cut_scene(sounds, randomKey)
return None
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = sounds[randomKey].play(loopParam)
if channel:
sfxVolume = volumeService.get_sfx_volume()
channel.set_volume(sfxVolume, sfxVolume)
if pause and not loop:
time.sleep(sounds[randomKey].get_length())
return channel
def play_random_positional(sounds, soundName, playerX, objectX, playerY=0, objectY=0, loop=False):
"""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
playerY (float): Player's y position (default: 0)
objectY (float): Object's y position (default: 0)
loop (bool): Whether to loop the sound (default: False)
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, playerY, objectY)
if volume <= 0:
return None
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = sounds[randomKey].play(loopParam)
if channel:
channel.set_volume(
volume * left * volumeService.sfxVolume,
volume * right * volumeService.sfxVolume
)
return channel
def play_directional_sound(sounds, soundName, playerPos, objPos, playerY=0, objY=0, centerDistance=3, volume=1.0, loop=False):
"""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
playerY (float): Player's y position (default: 0)
objY (float): Object's y position (default: 0)
centerDistance (float): Distance within which sound plays center (default: 3)
volume (float): Base volume multiplier (0.0-1.0, default: 1.0)
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: The channel the sound is playing on
"""
# Check vertical distance
vDistance = abs(playerY - objY)
maxYDistance = 20 # Maximum audible vertical distance
if vDistance > maxYDistance:
return None # Don't play if out of vertical range
# Calculate vertical volume factor
vVolume = (maxYDistance - vDistance) / maxYDistance
finalVolume = volume * vVolume * volumeService.get_sfx_volume()
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = sounds[soundName].play(loopParam)
if channel:
# 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, playerY=0, maxY=20, existingChannel=None, loop=False):
"""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)
playerY (float): Player's Y position (default: 0)
maxY (float): Maximum Y value (default: 20)
existingChannel: Existing sound channel to update (default: None)
loop (bool): Whether to loop the sound (default: False)
Returns:
pygame.mixer.Channel: Sound channel for updating position/volume,
or None if sound should stop
"""
# Calculate horizontal and vertical positioning
volume, left, right = calculate_volume_and_pan(playerX, objectX, playerY, currentY)
# 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)
# Set loop parameter to -1 for infinite loop or 0 for no loop
loopParam = -1 if loop else 0
channel = sounds[randomKey].play(loopParam)
if channel:
channel.set_volume(
finalLeft * volumeService.sfxVolume,
finalRight * volumeService.sfxVolume
)
return channel