#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Speech handling for Storm Games. Provides functionality for: - Text-to-speech using different speech providers - Speech delay control to prevent stuttering - On-screen text display """ import pygame import textwrap import time from sys import exit class Speech: """Handles text-to-speech functionality.""" _instance = None @classmethod def get_instance(cls): """Get or create the singleton instance.""" if cls._instance is None: cls._instance = Speech() return cls._instance def __init__(self): """Initialize speech system with available provider.""" # Handle speech delays so we don't get stuttering self.lastSpoken = {"text": None, "time": 0} self.speechDelay = 250 # ms # Try to initialize a speech provider self.provider = None self.providerName = None # Try speechd first try: import speechd self.spd = speechd.Client() self.provider = self.spd self.providerName = "speechd" return except ImportError: pass # Try accessible_output2 next try: import accessible_output2.outputs.auto self.ao2 = accessible_output2.outputs.auto.Auto() self.provider = self.ao2 self.providerName = "accessible_output2" return except ImportError: pass # No speech providers found print("No speech providers found.") def speak(self, text, interrupt=True): """Speak text using the configured speech provider and display on screen. Args: text (str): Text to speak and display interrupt (bool): Whether to interrupt current speech (default: True) """ if not self.provider: return currentTime = pygame.time.get_ticks() # Check if this is the same text within the delay window if (self.lastSpoken["text"] == text and currentTime - self.lastSpoken["time"] < self.speechDelay): return # Update last spoken tracking self.lastSpoken["text"] = text self.lastSpoken["time"] = currentTime # Proceed with speech if self.providerName == "speechd": if interrupt: self.spd.cancel() self.spd.say(text) elif self.providerName == "accessible_output2": self.ao2.speak(text, interrupt=interrupt) # Display the text on screen screen = pygame.display.get_surface() if not screen: return font = pygame.font.Font(None, 36) # Wrap the text maxWidth = screen.get_width() - 40 # Leave a 20-pixel margin on each side wrappedText = textwrap.wrap(text, width=maxWidth // font.size('A')[0]) # Render each line textSurfaces = [font.render(line, True, (255, 255, 255)) for line in wrappedText] screen.fill((0, 0, 0)) # Clear screen with black # Calculate total height of text block totalHeight = sum(surface.get_height() for surface in textSurfaces) # Start y-position (centered vertically) currentY = (screen.get_height() - totalHeight) // 2 # Blit each line of text for surface in textSurfaces: textRect = surface.get_rect(center=(screen.get_width() // 2, currentY + surface.get_height() // 2)) screen.blit(surface, textRect) currentY += surface.get_height() pygame.display.flip() def close(self): """Clean up speech resources.""" if self.providerName == "speechd": self.spd.close() # Global instance for backward compatibility _speechInstance = None def speak(text, interrupt=True): """Speak text using the global speech instance. Args: text (str): Text to speak and display interrupt (bool): Whether to interrupt current speech (default: True) """ global _speechInstance if _speechInstance is None: _speechInstance = Speech.get_instance() _speechInstance.speak(text, interrupt) def messagebox(text, sounds=None): """Display a message box with text and optional dialog support. 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: 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() # 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.") while True: event = pygame.event.wait() if event.type == pygame.KEYDOWN: if event.key in (pygame.K_ESCAPE, pygame.K_RETURN): speech.speak(" ") return 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] from .sound import get_available_channel channel = get_available_channel() channel.play(sound_obj) 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] from .sound import get_available_channel channel = get_available_channel() channel.play(sound_obj) 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