""" Simulator Engine Module Internal audio synthesis and visual lighting simulation. Provides audio feedback and visual representation of the lighting installation. """ import pygame import numpy as np import threading import time import math from typing import Dict, List, Optional, Tuple from PyQt5.QtCore import QObject, pyqtSignal class SimulatorEngine(QObject): """ Internal simulator for audio synthesis and lighting visualization. Provides real-time audio feedback and visual representation. """ # Signals audio_initialized = pyqtSignal(bool) note_played = pyqtSignal(int, int, int) # channel, note, velocity note_stopped = pyqtSignal(int, int) # channel, note program_changed = pyqtSignal(int, int) # channel, program volume_changed = pyqtSignal(int, int) # channel, volume lighting_updated = pyqtSignal(dict) # {channel: brightness} # Waveform types WAVEFORMS = ["sine", "square", "sawtooth", "triangle", "noise"] # GM Instrument to waveform mapping (simplified) GM_WAVEFORM_MAP = { # Pianos (0-7) 0: "sine", 1: "sine", 2: "sine", 3: "square", 4: "sine", 5: "sine", 6: "square", 7: "square", # Chromatic Percussion (8-15) 8: "sine", 9: "sine", 10: "sine", 11: "sine", 12: "sine", 13: "sine", 14: "sine", 15: "triangle", # Organs (16-23) 16: "square", 17: "square", 18: "square", 19: "square", 20: "square", 21: "square", 22: "triangle", 23: "square", # Guitars (24-31) 24: "sawtooth", 25: "sawtooth", 26: "sawtooth", 27: "sawtooth", 28: "square", 29: "sawtooth", 30: "sawtooth", 31: "sine", # Bass (32-39) 32: "sawtooth", 33: "sawtooth", 34: "sawtooth", 35: "sawtooth", 36: "sawtooth", 37: "sawtooth", 38: "sawtooth", 39: "sawtooth", # Strings (40-47) 40: "sawtooth", 41: "sawtooth", 42: "sawtooth", 43: "sawtooth", 44: "sawtooth", 45: "sawtooth", 46: "sine", 47: "sine", # Ensemble (48-55) 48: "sawtooth", 49: "sawtooth", 50: "sawtooth", 51: "sawtooth", 52: "sine", 53: "sine", 54: "sawtooth", 55: "sine", # Brass (56-63) 56: "sawtooth", 57: "sawtooth", 58: "sawtooth", 59: "sawtooth", 60: "sawtooth", 61: "sawtooth", 62: "sawtooth", 63: "sawtooth", # Reed (64-71) 64: "sawtooth", 65: "sawtooth", 66: "sawtooth", 67: "sawtooth", 68: "sine", 69: "sine", 70: "sine", 71: "sine", # Pipe (72-79) 72: "sine", 73: "sine", 74: "sine", 75: "sine", 76: "sine", 77: "sine", 78: "sine", 79: "sine", # Synth Lead (80-87) 80: "square", 81: "sawtooth", 82: "square", 83: "square", 84: "sawtooth", 85: "square", 86: "sawtooth", 87: "sawtooth", # Synth Pad (88-95) 88: "sawtooth", 89: "sawtooth", 90: "sawtooth", 91: "sine", 92: "sawtooth", 93: "sawtooth", 94: "sine", 95: "sawtooth", # Synth Effects (96-103) 96: "noise", 97: "sawtooth", 98: "sine", 99: "sawtooth", 100: "sawtooth", 101: "noise", 102: "sine", 103: "sawtooth", # Ethnic (104-111) 104: "sawtooth", 105: "sawtooth", 106: "sawtooth", 107: "sine", 108: "sine", 109: "square", 110: "sawtooth", 111: "sine", # Percussive (112-119) 112: "noise", 113: "noise", 114: "sawtooth", 115: "noise", 116: "noise", 117: "noise", 118: "noise", 119: "noise", # Sound Effects (120-127) 120: "noise", 121: "noise", 122: "noise", 123: "noise", 124: "noise", 125: "noise", 126: "noise", 127: "noise" } def __init__(self): super().__init__() # Audio settings self.sample_rate = 22050 self.buffer_size = 512 self.audio_enabled = True self.master_volume = 0.8 self.stereo_mode = False # Will be set during audio initialization # Channel settings self.channel_volumes = {i: 100 for i in range(1, 17)} # MIDI volume (0-127) self.channel_programs = {i: 0 for i in range(1, 17)} # GM program numbers # Active voices {(channel, note): voice_data} self.active_voices: Dict[Tuple[int, int], Dict] = {} # Lighting state {channel: brightness (0.0-1.0)} self.lighting_state = {i: 0.0 for i in range(1, 17)} # Audio system self.audio_initialized_flag = False self.audio_thread = None self.audio_running = False # Initialize audio self.initialize_audio() def initialize_audio(self): """Initialize pygame audio system""" try: # Try stereo first (most common) try: pygame.mixer.pre_init( frequency=self.sample_rate, size=-16, channels=2, # Stereo buffer=self.buffer_size ) pygame.mixer.init() self.stereo_mode = True except: # Fall back to mono if stereo fails pygame.mixer.pre_init( frequency=self.sample_rate, size=-16, channels=1, # Mono buffer=self.buffer_size ) pygame.mixer.init() self.stereo_mode = False self.audio_initialized_flag = True self.audio_initialized.emit(True) print(f"Audio initialized: {self.sample_rate}Hz, {'Stereo' if self.stereo_mode else 'Mono'}") except Exception as e: print(f"Audio initialization failed: {e}") self.audio_initialized_flag = False self.stereo_mode = False self.audio_initialized.emit(False) def set_audio_enabled(self, enabled: bool): """Enable or disable audio output""" self.audio_enabled = enabled def set_master_volume(self, volume: float): """Set master volume (0.0 to 1.0)""" self.master_volume = max(0.0, min(1.0, volume)) def play_note(self, channel: int, note: int, velocity: int): """Play a note on the specified channel""" if not (1 <= channel <= 16 and 0 <= note <= 127 and 0 <= velocity <= 127): return # Stop any existing note on this channel/note combination self.stop_note(channel, note) # Get program for channel program = self.channel_programs.get(channel, 0) waveform = self.GM_WAVEFORM_MAP.get(program, "sine") # Calculate frequency frequency = self.midi_note_to_frequency(note) # Calculate volume channel_volume = self.channel_volumes.get(channel, 100) / 127.0 velocity_volume = velocity / 127.0 final_volume = channel_volume * velocity_volume * self.master_volume # Create voice data voice_data = { 'frequency': frequency, 'waveform': waveform, 'volume': final_volume, 'phase': 0.0, 'start_time': time.time(), 'velocity': velocity } # Store active voice self.active_voices[(channel, note)] = voice_data # Update lighting self.update_lighting(channel, velocity, channel_volume) # Play audio if enabled if self.audio_enabled and self.audio_initialized_flag: self.play_audio_note(frequency, waveform, final_volume) # Emit signal self.note_played.emit(channel, note, velocity) def stop_note(self, channel: int, note: int): """Stop a note on the specified channel""" voice_key = (channel, note) if voice_key in self.active_voices: del self.active_voices[voice_key] # Update lighting (fade out) self.fade_lighting(channel) self.note_stopped.emit(channel, note) def change_program(self, channel: int, program: int): """Change the program (instrument) for a channel""" if 1 <= channel <= 16 and 0 <= program <= 127: self.channel_programs[channel] = program self.program_changed.emit(channel, program) def set_channel_volume(self, channel: int, volume: int): """Set channel volume (0-127)""" if 1 <= channel <= 16 and 0 <= volume <= 127: self.channel_volumes[channel] = volume # Update active voices volume for (ch, note), voice_data in self.active_voices.items(): if ch == channel: channel_volume = volume / 127.0 velocity_volume = voice_data['velocity'] / 127.0 voice_data['volume'] = channel_volume * velocity_volume * self.master_volume self.volume_changed.emit(channel, volume) def all_notes_off(self, channel: int = None): """Turn off all notes on a channel (or all channels if None)""" if channel: # Turn off notes for specific channel to_remove = [(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel] else: # Turn off all notes to_remove = list(self.active_voices.keys()) for voice_key in to_remove: ch, note = voice_key del self.active_voices[voice_key] self.note_stopped.emit(ch, note) # Update lighting if channel: self.lighting_state[channel] = 0.0 else: for ch in range(1, 17): self.lighting_state[ch] = 0.0 self.lighting_updated.emit(self.lighting_state.copy()) def panic(self): """Emergency stop - turn off everything""" self.all_notes_off() def update_lighting(self, channel: int, velocity: int, channel_volume: float): """Update lighting state based on note activity""" # Calculate brightness from velocity and volume velocity_brightness = velocity / 127.0 brightness = velocity_brightness * channel_volume # Set lighting state self.lighting_state[channel] = brightness self.lighting_updated.emit(self.lighting_state.copy()) def fade_lighting(self, channel: int): """Gradually fade lighting for a channel""" current_brightness = self.lighting_state.get(channel, 0.0) # Check if there are still active notes on this channel active_notes_on_channel = [(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel] if not active_notes_on_channel: # No active notes, fade to zero self.lighting_state[channel] = 0.0 else: # Calculate brightness from remaining active notes total_brightness = 0.0 for (ch, note) in active_notes_on_channel: voice_data = self.active_voices[(ch, note)] total_brightness = max(total_brightness, voice_data['volume']) self.lighting_state[channel] = total_brightness self.lighting_updated.emit(self.lighting_state.copy()) def update_lighting_display(self): """Update lighting display (called regularly from main loop)""" # This method can be used for lighting animations or effects # For now, just emit current state self.lighting_updated.emit(self.lighting_state.copy()) def play_audio_note(self, frequency: float, waveform: str, volume: float): """Generate and play audio for a note""" if not self.audio_initialized_flag: return try: # Generate a short tone duration = 0.5 # seconds sample_count = int(self.sample_rate * duration) # Generate waveform if waveform == "sine": samples = np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate) elif waveform == "square": samples = np.sign(np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate)) elif waveform == "sawtooth": samples = 2 * (np.arange(sample_count) * frequency / self.sample_rate % 1) - 1 elif waveform == "triangle": t = np.arange(sample_count) * frequency / self.sample_rate % 1 samples = 2 * np.abs(2 * t - 1) - 1 elif waveform == "noise": samples = np.random.uniform(-1, 1, sample_count) else: samples = np.sin(2 * np.pi * frequency * np.arange(sample_count) / self.sample_rate) # Apply volume and envelope envelope = np.exp(-np.arange(sample_count) / (self.sample_rate * 0.3)) # Decay envelope samples = samples * envelope * volume * 32767 # Convert to 16-bit integers and ensure proper shape samples = samples.astype(np.int16) # Create appropriate array format based on mixer initialization if self.stereo_mode: # Create stereo samples (duplicate mono to both channels) stereo_samples = np.column_stack((samples, samples)) sound = pygame.sndarray.make_sound(stereo_samples) else: # Use mono samples directly sound = pygame.sndarray.make_sound(samples) sound.play() except Exception as e: print(f"Error playing audio note: {e}") @staticmethod def midi_note_to_frequency(note: int) -> float: """Convert MIDI note number to frequency in Hz""" # A4 (note 69) = 440 Hz return 440.0 * (2.0 ** ((note - 69) / 12.0)) def get_active_voices_count(self, channel: int = None) -> int: """Get count of active voices""" if channel: return len([(ch, note) for (ch, note) in self.active_voices.keys() if ch == channel]) else: return len(self.active_voices) def get_lighting_state(self) -> Dict[int, float]: """Get current lighting state""" return self.lighting_state.copy() def get_status(self) -> Dict: """Get simulator status""" return { 'audio_initialized': self.audio_initialized_flag, 'audio_enabled': self.audio_enabled, 'master_volume': self.master_volume, 'active_voices': len(self.active_voices), 'channel_volumes': self.channel_volumes.copy(), 'channel_programs': self.channel_programs.copy(), 'lighting_state': self.lighting_state.copy() } def cleanup(self): """Clean up resources""" self.all_notes_off() if self.audio_initialized_flag: try: pygame.mixer.quit() except: pass