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.
183 lines
8.2 KiB
183 lines
8.2 KiB
"""
|
|
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
|