pygstormgames/sound.py

447 lines
16 KiB
Python

"""Sound management module for PygStormGames.
Handles all audio functionality including:
- Background music playback
- Sound effects with 2D/3D positional audio
- Volume control for master, BGM, and SFX
- Audio loading and resource management
- Support for organizing sounds in subdirectories
"""
import os
import pyglet
import random
import re
import time
from os.path import isfile, join, isdir
from pyglet.window import key
class Sound:
"""Handles audio playback and management."""
# Directories to exclude from the learn sounds menu
excludedLearnDirs = ['music', 'ambiance']
def __init__(self, game):
"""Initialize sound system.
Args:
game (PygStormGames): Reference to main game object
"""
# Game reference for component access
self.game = game
# Volume control (0.0 - 1.0)
self.bgmVolume = 0.75 # Background music
self.sfxVolume = 1.0 # Sound effects
self.masterVolume = 1.0 # Master volume
# Current background music
self.currentBgm = None
# Load sound resources
self.sounds = {} # Dictionary of loaded sound objects
self.soundPaths = {} # Dictionary to track original paths
self.soundDirectories = {} # Track which directory each sound belongs to
self._load_sounds()
self.activeSounds = [] # Track playing sounds
def _load_sounds(self):
"""Load all sound files from sounds directory and subdirectories.
Returns:
dict: Dictionary of loaded sound objects
"""
if not os.path.exists("sounds/"):
print("No sounds directory found")
return
try:
# Walk through all subdirectories
for root, dirs, files in os.walk("sounds/"):
# Process sound files in this directory
for file in files:
if file.lower().endswith(('.wav', '.ogg', '.opus')):
# Get relative path from sounds directory
rel_path = os.path.relpath(os.path.join(root, file), "sounds/")
# Extract name without extension
basename = os.path.splitext(file)[0]
# Get directory relative to sounds folder
subdir = os.path.dirname(rel_path)
# Create a sound key that maintains subdirectory structure if needed
if subdir:
sound_key = f"{subdir}/{basename}"
directory = subdir.split('/')[0] # Get top-level directory
else:
sound_key = basename
directory = ""
# Full path to the sound file
fullPath = f"sounds/{rel_path}"
# Load the sound
try:
self.sounds[sound_key] = pyglet.media.load(fullPath, streaming=False)
self.soundPaths[sound_key] = fullPath
self.soundDirectories[sound_key] = directory
except Exception as e:
print(f"Error loading sound {fullPath}: {e}")
except Exception as e:
print(f"Error loading sounds: {e}")
def get_sound_list(self, excludeDirs=None):
"""Get a list of available sounds, optionally excluding certain directories.
Args:
excludeDirs (list): List of directory names to exclude
Returns:
list: List of sound keys
"""
if excludeDirs is None:
excludeDirs = self.excludedLearnDirs
# Filter sounds based on their directories
return [key for key, directory in self.soundDirectories.items()
if directory not in excludeDirs]
def play_bgm(self, music_file):
"""Play background music with proper volume.
Args:
music_file (str): Path to music file
"""
try:
if self.currentBgm:
self.currentBgm.pause()
# Load and play new music
music = pyglet.media.load(music_file, streaming=True)
player = pyglet.media.Player()
player.queue(music)
player.loop = True
player.volume = self.bgmVolume * self.masterVolume
player.play()
self.currentBgm = player
except Exception as e:
print(f"Error playing background music: {e}")
def pause_bgm(self):
"""Pause background music."""
if self.currentBgm and self.currentBgm.playing:
self.currentBgm.pause()
def resume_bgm(self):
"""Resume background music from paused state."""
if self.currentBgm and not self.currentBgm.playing:
self.currentBgm.play()
def play_sound(self, soundName, volume=1.0):
"""Play a sound effect with volume settings.
Args:
sound_name (str): Name of sound to play, can include subdirectory path
e.g. "explosion" or "monsters/growl"
volume (float): Base volume for sound (0.0-1.0)
Returns:
pyglet.media.Player: Sound player object
"""
if soundName not in self.sounds:
# Try adding .ogg extension for direct file paths
if not soundName.endswith(('.wav', '.ogg', '.opus')):
# Try to find the sound with various extensions
for ext in ['.ogg', '.wav', '.opus']:
testName = f"{soundName}{ext}"
if os.path.exists(f"sounds/{testName}"):
try:
sound = pyglet.media.load(f"sounds/{testName}", streaming=False)
player = pyglet.media.Player()
player.queue(sound)
player.volume = volume * self.sfxVolume * self.masterVolume
player.play()
self.activeSounds.append(player)
return player
except Exception:
pass
print(f"Sound not found: {soundName}")
return None
player = pyglet.media.Player()
player.queue(self.sounds[soundName])
player.volume = volume * self.sfxVolume * self.masterVolume
player.play()
self.activeSounds.append(player)
return player
def play_random(self, baseName, pause=False, interrupt=False):
"""Play random variation of a sound.
Args:
baseName (str): Base name of sound, can include subdirectory
pause (bool): Wait for sound to finish
interrupt (bool): Stop other sounds
"""
# Check if baseName includes a directory
if '/' in baseName:
dirPart = os.path.dirname(baseName)
namePart = os.path.basename(baseName)
pattern = f"^{dirPart}/.*{namePart}.*"
else:
pattern = f"^{baseName}.*"
matches = [name for name in self.sounds.keys()
if re.match(pattern, name)]
if not matches:
return None
if interrupt:
self.stop_all_sounds()
soundName = random.choice(matches)
player = self.play_sound(soundName)
if pause and player:
player.on_player_eos = lambda: None # Wait for completion
def calculate_positional_audio(self, source_pos, listener_pos, mode='2d'):
"""Calculate position for 3D audio.
Args:
source_pos: Either float (2D x-position) or tuple (3D x,y,z position)
listener_pos: Either float (2D x-position) or tuple (3D x,y,z position)
mode: '2d' or '3d' to specify positioning mode
Returns:
tuple: (x, y, z) position for sound source, or None if out of range
"""
if mode == '2d':
distance = abs(source_pos - listener_pos)
max_distance = 12
if distance > max_distance:
return None
return (source_pos - listener_pos, 0, -1)
else:
x = source_pos[0] - listener_pos[0]
y = source_pos[1] - listener_pos[1]
z = source_pos[2] - listener_pos[2]
distance = (x*x + y*y + z*z) ** 0.5
max_distance = 20 # Larger for 3D space
if distance > max_distance:
return None
return (x, y, z)
def play_positional(self, soundName, sourcePos, listenerPos, mode='2d',
direction=None, coneAngles=None):
"""Play sound with positional audio.
Args:
soundName (str): Name of sound to play, can include subdirectory
sourcePos: Position of sound source (float for 2D, tuple for 3D)
listenerPos: Position of listener (float for 2D, tuple for 3D)
mode: '2d' or '3d' to specify positioning mode
direction: Optional tuple (x,y,z) for directional sound
coneAngles: Optional tuple (inner, outer) angles for sound cone
Returns:
pyglet.media.Player: Sound player object
"""
if soundName not in self.sounds:
print(f"Sound not found for positional audio: {soundName}")
return None
position = self.calculate_positional_audio(sourcePos, listenerPos, mode)
if position is None: # Too far to hear
return None
player = pyglet.media.Player()
player.queue(self.sounds[soundName])
player.position = position
player.volume = self.sfxVolume * self.masterVolume
# Set up directional audio if specified
if direction and mode == '3d':
player.cone_orientation = direction
if coneAngles:
player.cone_inner_angle, player.cone_outer_angle = coneAngles
player.cone_outer_gain = 0.5 # Reduced volume outside cone
player.play()
self.activeSounds.append(player)
return player
def update_positional(self, player, sourcePos, listenerPos, mode='2d',
direction=None):
"""Update position of a playing sound.
Args:
player: Sound player to update
sourcePos: New source position
listenerPos: New listener position
mode: '2d' or '3d' positioning mode
direction: Optional new direction for directional sound
"""
if not player or not player.playing:
return
position = self.calculate_positional_audio(sourcePos, listenerPos, mode)
if position is None:
player.pause()
return
player.position = position
if direction and mode == '3d':
player.cone_orientation = direction
def cut_scene(self, soundName):
"""Play a sound as a cut scene, stopping other sounds and waiting for completion.
Args:
soundName (str): Name of sound to play, can include subdirectory
"""
# Stop all current sounds
self.stop_all_sounds()
if self.currentBgm:
self.currentBgm.pause()
# Find all matching sound variations
if '/' in soundName:
dirPart = os.path.dirname(soundName)
namePart = os.path.basename(soundName)
pattern = f"^{dirPart}/.*{namePart}.*"
else:
pattern = f"^{soundName}.*"
matches = [name for name in self.sounds.keys()
if re.match(pattern, name)]
if not matches:
print(f"No matching sounds found for cut scene: {soundName}")
return
# Pick a random variation
selectedSound = random.choice(matches)
# Create and configure the player
player = pyglet.media.Player()
player.queue(self.sounds[selectedSound])
player.volume = self.sfxVolume * self.masterVolume
# Start playback
player.play()
# Make sure to give pyglet enough cycles to start playing
startTime = time.time()
duration = self.sounds[selectedSound].duration
pyglet.clock.tick()
# Wait for completion or skip
interrupted = self.game.wait_for_completion(
lambda: not player.playing or (time.time() - startTime) >= duration
)
# Ensure cleanup
if interrupted:
player.pause()
player.delete()
# Resume background music if it was playing
if self.currentBgm:
self.currentBgm.play()
def adjust_master_volume(self, change):
"""Adjust master volume.
Args:
change (float): Volume change (-1.0 to 1.0)
"""
if not -1.0 <= change <= 1.0:
return
self.masterVolume = max(0.0, min(1.0, self.masterVolume + change))
# Update BGM
if self.currentBgm:
self.currentBgm.volume = self.bgmVolume * self.masterVolume
# Update active sounds
for sound in self.activeSounds:
if sound.playing:
sound.volume *= self.masterVolume
def adjust_bgm_volume(self, change):
"""Adjust background music volume.
Args:
change (float): Volume change (-1.0 to 1.0)
"""
if not -1.0 <= change <= 1.0:
return
self.bgmVolume = max(0.0, min(1.0, self.bgmVolume + change))
if self.currentBgm:
self.currentBgm.volume = self.bgmVolume * self.masterVolume
def adjust_sfx_volume(self, change):
"""Adjust sound effects volume.
Args:
change (float): Volume change (-1.0 to 1.0)
"""
if not -1.0 <= change <= 1.0:
return
self.sfxVolume = max(0.0, min(1.0, self.sfxVolume + change))
for sound in self.activeSounds:
if sound.playing:
sound.volume *= self.sfxVolume
def get_volumes(self):
"""Get current volume levels.
Returns:
tuple: (masterVolume, bgmVolume, sfxVolume)
"""
return (self.masterVolume, self.bgmVolume, self.sfxVolume)
def pause(self):
"""Pause all audio."""
if self.currentBgm:
self.currentBgm.pause()
for sound in self.activeSounds:
if sound.playing:
sound.pause()
def resume(self):
"""Resume all audio."""
if self.currentBgm:
self.currentBgm.play()
for sound in self.activeSounds:
sound.play()
def stop_all_sounds(self):
"""Stop all playing sounds."""
for sound in self.activeSounds:
sound.pause()
self.activeSounds.clear()
def cleanup(self):
"""Clean up sound resources."""
if self.currentBgm:
self.currentBgm.pause()
self.stop_all_sounds()