""" MIDI Channel Manager Module Handles MIDI channel management, instrument assignments, and voice allocation. Manages up to 16 synth channels with individual program changes and polyphonic voice tracking. """ import mido from typing import Dict, List, Optional, Tuple from PyQt5.QtCore import QObject, pyqtSignal class MIDIChannelManager(QObject): """ Manages MIDI channels, instruments, and voice allocation for multiple synths. Each synth corresponds to a MIDI channel (1-16). """ # Signals for GUI updates active_synth_count_changed = pyqtSignal(int) channel_instrument_changed = pyqtSignal(int, int) # channel, program voice_allocation_changed = pyqtSignal(int, list) # channel, active_notes # General MIDI Program Names (first 128) GM_PROGRAMS = [ "Acoustic Grand Piano", "Bright Acoustic Piano", "Electric Grand Piano", "Honky-tonk Piano", "Electric Piano 1", "Electric Piano 2", "Harpsichord", "Clavinet", "Celesta", "Glockenspiel", "Music Box", "Vibraphone", "Marimba", "Xylophone", "Tubular Bells", "Dulcimer", "Drawbar Organ", "Percussive Organ", "Rock Organ", "Church Organ", "Reed Organ", "Accordion", "Harmonica", "Tango Accordion", "Acoustic Guitar (nylon)", "Acoustic Guitar (steel)", "Electric Guitar (jazz)", "Electric Guitar (clean)", "Electric Guitar (muted)", "Overdriven Guitar", "Distortion Guitar", "Guitar Harmonics", "Acoustic Bass", "Electric Bass (finger)", "Electric Bass (pick)", "Fretless Bass", "Slap Bass 1", "Slap Bass 2", "Synth Bass 1", "Synth Bass 2", "Violin", "Viola", "Cello", "Contrabass", "Tremolo Strings", "Pizzicato Strings", "Orchestral Harp", "Timpani", "String Ensemble 1", "String Ensemble 2", "Synth Strings 1", "Synth Strings 2", "Choir Aahs", "Voice Oohs", "Synth Voice", "Orchestra Hit", "Trumpet", "Trombone", "Tuba", "Muted Trumpet", "French Horn", "Brass Section", "Synth Brass 1", "Synth Brass 2", "Soprano Sax", "Alto Sax", "Tenor Sax", "Baritone Sax", "Oboe", "English Horn", "Bassoon", "Clarinet", "Piccolo", "Flute", "Recorder", "Pan Flute", "Blown Bottle", "Shakuhachi", "Whistle", "Ocarina", "Lead 1 (square)", "Lead 2 (sawtooth)", "Lead 3 (calliope)", "Lead 4 (chiff)", "Lead 5 (charang)", "Lead 6 (voice)", "Lead 7 (fifths)", "Lead 8 (bass + lead)", "Pad 1 (new age)", "Pad 2 (warm)", "Pad 3 (polysynth)", "Pad 4 (choir)", "Pad 5 (bowed)", "Pad 6 (metallic)", "Pad 7 (halo)", "Pad 8 (sweep)", "FX 1 (rain)", "FX 2 (soundtrack)", "FX 3 (crystal)", "FX 4 (atmosphere)", "FX 5 (brightness)", "FX 6 (goblins)", "FX 7 (echoes)", "FX 8 (sci-fi)", "Sitar", "Banjo", "Shamisen", "Koto", "Kalimba", "Bag pipe", "Fiddle", "Shanai", "Tinkle Bell", "Agogo", "Steel Drums", "Woodblock", "Taiko Drum", "Melodic Tom", "Synth Drum", "Reverse Cymbal", "Guitar Fret Noise", "Breath Noise", "Seashore", "Bird Tweet", "Telephone Ring", "Helicopter", "Applause", "Gunshot" ] def __init__(self): super().__init__() self.active_synth_count = 8 # Default to 8 synths self.max_synths = 16 self.max_voices_per_synth = 3 # Channel instruments (1-based channel numbering) self.channel_instruments: Dict[int, int] = {i: 0 for i in range(1, 17)} # Default to Piano # Voice allocation tracking self.active_voices: Dict[int, List[int]] = {i: [] for i in range(1, 17)} # {channel: [note1, note2, note3]} def set_active_synth_count(self, count: int) -> bool: """Set the number of active synths (1-16)""" if 1 <= count <= self.max_synths: self.active_synth_count = count self.active_synth_count_changed.emit(count) return True return False def get_active_channels(self) -> List[int]: """Get list of currently active channel numbers""" return list(range(1, self.active_synth_count + 1)) def set_channel_instrument(self, channel: int, program: int) -> bool: """Set instrument program for a specific channel""" if 1 <= channel <= self.max_synths and 0 <= program <= 127: self.channel_instruments[channel] = program self.channel_instrument_changed.emit(channel, program) return True return False def set_all_instruments(self, program: int) -> bool: """Set the same instrument for all active channels""" if 0 <= program <= 127: for channel in self.get_active_channels(): self.channel_instruments[channel] = program self.channel_instrument_changed.emit(channel, program) return True return False def get_channel_instrument(self, channel: int) -> Optional[int]: """Get current instrument program for a channel""" if 1 <= channel <= self.max_synths: return self.channel_instruments.get(channel, 0) return None def get_instrument_name(self, program: int) -> str: """Get human-readable name for a GM program number""" if 0 <= program < len(self.GM_PROGRAMS): return self.GM_PROGRAMS[program] return f"Program {program}" def allocate_voice(self, channel: int, note: int) -> bool: """ Allocate a voice for a note on a channel. Returns True if voice was allocated, False if channel is full. Implements voice stealing if necessary. """ if channel not in self.active_voices: return False voices = self.active_voices[channel] # If note is already playing, don't allocate again if note in voices: return True # If we have space, just add it if len(voices) < self.max_voices_per_synth: voices.append(note) self.voice_allocation_changed.emit(channel, voices.copy()) return True # Voice stealing: remove the oldest note and add the new one voices.pop(0) # Remove oldest voices.append(note) self.voice_allocation_changed.emit(channel, voices.copy()) return True def release_voice(self, channel: int, note: int) -> bool: """Release a voice for a note on a channel""" if channel not in self.active_voices: return False voices = self.active_voices[channel] if note in voices: voices.remove(note) self.voice_allocation_changed.emit(channel, voices.copy()) return True return False def get_active_voices(self, channel: int) -> List[int]: """Get list of currently active notes for a channel""" return self.active_voices.get(channel, []).copy() def is_voice_available(self, channel: int) -> bool: """Check if a channel has available voice slots""" if channel not in self.active_voices: return False return len(self.active_voices[channel]) < self.max_voices_per_synth def get_voice_count(self, channel: int) -> int: """Get current number of active voices for a channel""" return len(self.active_voices.get(channel, [])) def clear_all_voices(self): """Clear all active voices on all channels""" for channel in range(1, self.max_synths + 1): if self.active_voices[channel]: self.active_voices[channel].clear() self.voice_allocation_changed.emit(channel, []) def get_channel_status(self) -> Dict[int, Dict]: """Get comprehensive status for all channels""" status = {} for channel in range(1, self.active_synth_count + 1): status[channel] = { 'instrument': self.channel_instruments[channel], 'instrument_name': self.get_instrument_name(self.channel_instruments[channel]), 'active_voices': self.active_voices[channel].copy(), 'voice_count': len(self.active_voices[channel]), 'voices_available': self.max_voices_per_synth - len(self.active_voices[channel]) } return status