You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

390 lines
15 KiB

"""
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