libstormgames/sound.py

781 lines
27 KiB
Python

#!/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