Compare commits

..

5 Commits

Author SHA1 Message Date
Storm Dragon
1a6ad65139 Openal turned out to be a bust. Attempt to improve sound playback reliability. 2025-09-18 19:30:24 -04:00
Storm Dragon
f84a115bc6 Experimental sound improvements. I'm about to say to hell with it and try using openal. 2025-09-18 16:02:30 -04:00
Storm Dragon
7555429433 Fix for dialog sound. 2025-09-18 15:17:28 -04:00
Storm Dragon
9996cdc08b Added ability to have character dialogs. 2025-09-18 14:35:28 -04:00
Storm Dragon
8c57afe65b More playing with the mixer for stability and fewer or no clicks. 2025-09-17 17:44:04 -04:00
3 changed files with 279 additions and 19 deletions

View File

@@ -46,9 +46,9 @@ def initialize_gui(gameTitle):
# Initialize audio system if not already done # Initialize audio system if not already done
if not pygame.mixer.get_init(): if not pygame.mixer.get_init():
pygame.mixer.pre_init(44100, -16, 2, 1536) pygame.mixer.pre_init(44100, -16, 2, 4096)
pygame.mixer.init() pygame.mixer.init()
pygame.mixer.set_num_channels(64) pygame.mixer.set_num_channels(48)
pygame.mixer.set_reserved(0) # Reserve channel for cut scenes pygame.mixer.set_reserved(0) # Reserve channel for cut scenes
# Enable key repeat for volume controls # Enable key repeat for volume controls

128
sound.py
View File

@@ -19,6 +19,93 @@ from .services import VolumeService
# Global instance for backward compatibility # Global instance for backward compatibility
volumeService = VolumeService.get_instance() volumeService = VolumeService.get_instance()
def find_silent_channels():
"""Find channels that are playing but with zero volume (effectively silent)."""
silent_channels = []
for n in range(pygame.mixer.get_num_channels()):
channel = pygame.mixer.Channel(n)
if channel.get_busy():
# Get current volume setting (single value for both channels)
volume = channel.get_volume()
# Consider silent if volume is effectively zero
if volume <= 0.001:
silent_channels.append(channel)
return silent_channels
def find_available_channel():
"""Find an available channel, prioritizing silent channels over stopping active ones."""
# Try to find an idle channel first
for n in range(pygame.mixer.get_num_channels()):
channel = pygame.mixer.Channel(n)
if not channel.get_busy():
return channel
# If no idle channels, look for silent ones to reclaim
silent_channels = find_silent_channels()
if silent_channels:
# Stop the first silent channel and return it
channel = silent_channels[0]
channel.stop()
return channel
# If no silent channels, stop channel 1 as last resort (avoid channel 0 - reserved for cutscenes)
# This ensures sounds always play rather than being silently dropped
channel = pygame.mixer.Channel(1)
channel.stop()
return channel
def play_sound_with_retry(sound, loop=False, max_retries=3):
"""Play a sound with retry logic, returns (channel, success)."""
for attempt in range(max_retries):
# Try normal playback first
channel = sound.play(-1 if loop else 0)
if channel:
return channel, True
# If failed, force allocation and try again
try:
channel = find_available_channel()
channel.play(sound, -1 if loop else 0)
if channel.get_busy(): # Verify it's actually playing
return channel, True
except Exception as e:
if attempt == max_retries - 1: # Last attempt
print(f"Sound playback failed after {max_retries} attempts: {e}")
return None, False
return None, False
def get_channel_usage():
"""Return channel usage info for debugging."""
total = pygame.mixer.get_num_channels()
busy = sum(1 for n in range(total) if pygame.mixer.Channel(n).get_busy())
silent = len(find_silent_channels())
return f"Channels: {busy}/{total} busy ({silent} silent)"
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: class Sound:
"""Handles sound loading and playback.""" """Handles sound loading and playback."""
@@ -29,9 +116,10 @@ class Sound:
self.volumeService = volumeService or VolumeService.get_instance() self.volumeService = volumeService or VolumeService.get_instance()
if not pygame.mixer.get_init(): if not pygame.mixer.get_init():
pygame.mixer.pre_init(44100, -16, 2, 4096)
pygame.mixer.init() pygame.mixer.init()
pygame.mixer.set_num_channels(64) pygame.mixer.set_num_channels(48)
pygame.mixer.set_reserved(0) pygame.mixer.set_reserved(1) # Reserve channel 0 for cutscenes
self.load_sounds() self.load_sounds()
@@ -108,6 +196,7 @@ class Sound:
# Check if sound exists # Check if sound exists
if soundName not in self.sounds: if soundName not in self.sounds:
print(f"Sound not found: {soundName}")
return None return None
# Handle cut scene mode # Handle cut scene mode
@@ -119,9 +208,10 @@ class Sound:
pygame.event.clear() pygame.event.clear()
pygame.mixer.stop() pygame.mixer.stop()
# Play the sound # Play the sound with retry logic
channel = self.sounds[soundName].play(-1 if loop else 0) channel, success = play_sound_with_retry(self.sounds[soundName], loop)
if not channel: if not success:
print(f"Failed to play sound: {soundName}")
return None return None
# Apply appropriate volume settings # Apply appropriate volume settings
@@ -239,8 +329,12 @@ class Sound:
if not soundName: if not soundName:
return None return None
# Play the sound # Play the sound with retry logic
channel = self.sounds[soundName].play() 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: if channel:
channel.set_volume( channel.set_volume(
finalLeft * self.volumeService.sfxVolume, finalLeft * self.volumeService.sfxVolume,
@@ -256,7 +350,7 @@ class Sound:
pygame.mixer.music.set_volume(self.volumeService.get_bgm_volume()) pygame.mixer.music.set_volume(self.volumeService.get_bgm_volume())
pygame.mixer.music.play(-1) pygame.mixer.music.play(-1)
except Exception as e: except Exception as e:
print(f"Error playing background music: {e}") print(f"Failed to load background music {musicFile}: {e}")
def adjust_master_volume(self, change): def adjust_master_volume(self, change):
"""Adjust the master volume for all sounds.""" """Adjust the master volume for all sounds."""
@@ -358,7 +452,8 @@ def play_bgm(musicFile):
pygame.mixer.music.load(musicFile) pygame.mixer.music.load(musicFile)
pygame.mixer.music.set_volume(volumeService.get_bgm_volume()) pygame.mixer.music.set_volume(volumeService.get_bgm_volume())
pygame.mixer.music.play(-1) pygame.mixer.music.play(-1)
except: pass except Exception as e:
print(f"Failed to load background music {musicFile}: {e}")
def adjust_master_volume(change): def adjust_master_volume(change):
"""Adjust the master volume.""" """Adjust the master volume."""
@@ -424,9 +519,10 @@ def play_sound(sound_or_name, volume=1.0, loop=False, playerPos=None, objPos=Non
# Case 4: Sound name with dictionary # Case 4: Sound name with dictionary
elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds: elif isinstance(sounds, dict) and isinstance(sound_or_name, str) and sound_or_name in sounds:
# Play the sound # Play the sound with retry logic
channel = sounds[sound_or_name].play(-1 if loop else 0) channel, success = play_sound_with_retry(sounds[sound_or_name], loop)
if not channel: if not success:
print(f"Failed to play sound: {sound_or_name}")
return None return None
# Apply volume settings # Apply volume settings
@@ -521,8 +617,12 @@ def play_random_falling(sounds, soundName, playerX, objectX, startY, currentY=0,
if not matched_sound: if not matched_sound:
return None return None
# Play the sound # Play the sound with retry logic
channel = sounds[matched_sound].play() 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: if channel:
channel.set_volume( channel.set_volume(
finalLeft * volumeService.sfxVolume, finalLeft * volumeService.sfxVolume,

166
speech.py
View File

@@ -130,15 +130,34 @@ def speak(text, interrupt=True):
_speechInstance = Speech.get_instance() _speechInstance = Speech.get_instance()
_speechInstance.speak(text, interrupt) _speechInstance.speak(text, interrupt)
def messagebox(text): def messagebox(text, sounds=None):
"""Display a simple message box with text. """Display a message box with text and optional dialog support.
Shows a message that can be repeated until the user chooses to continue. Shows a message that can be repeated until the user chooses to continue.
Supports both simple text messages and dialog sequences with character speech and sounds.
Args: Args:
text (str): Message to display text (str or dict): Simple string message or dialog configuration dict
sounds (Sound object, optional): Sound system for playing dialog audio
""" """
speech = Speech.get_instance() speech = Speech.get_instance()
# Handle simple string (backward compatibility)
if isinstance(text, str):
_show_simple_message(speech, text)
return
# Handle dialog format
if isinstance(text, dict) and "entries" in text:
_show_dialog_sequence(speech, text, sounds)
return
# Fallback to simple message if format not recognized
_show_simple_message(speech, str(text))
def _show_simple_message(speech, text):
"""Show a simple text message (original messagebox behavior)."""
speech.speak(text + "\nPress any key to repeat or enter to continue.") speech.speak(text + "\nPress any key to repeat or enter to continue.")
while True: while True:
event = pygame.event.wait() event = pygame.event.wait()
@@ -147,3 +166,144 @@ def messagebox(text):
speech.speak(" ") speech.speak(" ")
return return
speech.speak(text + "\nPress any key to repeat or enter to continue.") speech.speak(text + "\nPress any key to repeat or enter to continue.")
def _show_dialog_sequence(speech, dialog_config, sounds):
"""Show a dialog sequence with character speech and optional sounds.
Args:
speech: Speech instance for text-to-speech
dialog_config (dict): Dialog configuration with entries list and optional settings
sounds: Sound system for playing audio files
"""
entries = dialog_config.get("entries", [])
allow_skip = dialog_config.get("allow_skip", False)
dialog_sound = dialog_config.get("sound", None)
if not entries:
return
entry_index = 0
while entry_index < len(entries):
entry = entries[entry_index]
# Play sound before showing dialog
_play_dialog_sound(entry, dialog_config, sounds)
# Format and show the dialog text
formatted_text = _format_dialog_entry(entry)
if not formatted_text:
entry_index += 1
continue
# Show dialog with appropriate controls (only on first entry)
if entry_index == 0:
if allow_skip:
control_text = "\nPress any key to repeat, enter for next, or escape to skip all."
else:
control_text = "\nPress any key to repeat or enter for next."
else:
control_text = "" # No instructions after first entry
speech.speak(formatted_text + control_text)
# Handle user input
while True:
event = pygame.event.wait()
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_ESCAPE:
if allow_skip:
speech.speak(" ")
return # Skip entire dialog sequence
else:
# Escape acts like enter if skip not allowed
speech.speak(" ")
entry_index += 1
break
elif event.key == pygame.K_RETURN:
speech.speak(" ")
entry_index += 1
break
else:
# Repeat current entry (include instructions only on first entry)
repeat_text = formatted_text
if entry_index == 0:
repeat_text += control_text
speech.speak(repeat_text)
def _format_dialog_entry(entry):
"""Format a dialog entry for display.
Args:
entry (dict): Dialog entry with text, optional speaker, and optional narrative flag
Returns:
str: Formatted text for speech
"""
text = entry.get("text", "")
speaker = entry.get("speaker", None)
is_narrative = entry.get("narrative", False)
if not text:
return ""
if is_narrative:
# Narrative text - no speaker name
return text
elif speaker:
# Character dialog - include speaker name
return f"{speaker}: \"{text}\""
else:
# Plain text - no special formatting
return text
def _play_dialog_sound(entry, dialog_config, sounds):
"""Play appropriate sound for a dialog entry and wait for it to complete.
Args:
entry (dict): Dialog entry that may have a sound
dialog_config (dict): Dialog configuration that may have a default sound
sounds: Sound system (either Sound class instance or dictionary of sounds)
"""
if not sounds:
return
sound_to_play = None
# Determine which sound to play (priority order)
if entry.get("sound"):
# Entry-specific sound (highest priority)
sound_to_play = entry["sound"]
elif dialog_config.get("sound"):
# Dialog-specific sound (medium priority)
sound_to_play = dialog_config["sound"]
else:
# Default dialogue.ogg (lowest priority)
sound_to_play = "dialogue" # Will look for dialogue.ogg
if sound_to_play:
try:
# Handle both Sound class instances and sound dictionaries
if hasattr(sounds, 'sounds') and sound_to_play in sounds.sounds:
# Sound class instance (like from libstormgames Sound class)
sound_obj = sounds.sounds[sound_to_play]
channel = sound_obj.play()
sound_duration = sound_obj.get_length()
if sound_duration > 0:
pygame.time.wait(int(sound_duration * 1000))
elif isinstance(sounds, dict) and sound_to_play in sounds:
# Dictionary of pygame sound objects (like from initialize_gui)
sound_obj = sounds[sound_to_play]
channel = sound_obj.play()
sound_duration = sound_obj.get_length()
if sound_duration > 0:
pygame.time.wait(int(sound_duration * 1000))
elif hasattr(sounds, 'play'):
# Try using a play method if available
sounds.play(sound_to_play)
pygame.time.wait(500) # Default delay if can't get duration
except Exception:
# Sound missing or error - continue silently without crashing
pass