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