"""
ComboBox Synchronizer Module
Manages synchronization between MIDI input/output and combo box selectors.
Handles real-time note-to-selector mapping and drum kit selection updates.
"""
from typing import Callable, Dict, List, Optional
import mido
from decologr import Decologr as log
from mido import Message
from PySide6.QtWidgets import QComboBox
from rtmidi.midiconstants import NOTE_OFF, NOTE_ON
from jdxi_editor.globals import silence_midi_note_logging
from jdxi_editor.midi.channel.channel import MidiChannel
from jdxi_editor.midi.conversion.note import MidiNoteConverter
from jdxi_editor.midi.message import MidiMessage
from picomidi import BitMask, MidiNote
from picomidi.message.type import MidoMessageType
[docs]
class ComboBoxUpdateConfig:
"""Configuration for combo box synchronization."""
def __init__(
self,
silence_logging: bool = False,
min_note: int = 36, # C2
update_interval_ms: int = 100,
):
"""
Initialize synchronizer configuration.
:param silence_logging: Whether to suppress MIDI note logging
:param min_note: Minimum MIDI note to process (default C2 = 36)
:param update_interval_ms: Minimum time between updates (milliseconds)
"""
[docs]
self.silence_logging = silence_logging
[docs]
self.min_note = min_note
[docs]
self.update_interval_ms = update_interval_ms
[docs]
class ComboBoxState:
"""Represents the state of a combo box selector."""
def __init__(
self,
row: int,
current_index: int,
current_text: str,
note: Optional[int] = None,
):
"""
Initialize combo box state.
:param row: Row index (0-3)
:param current_index: Currently selected item index
:param current_text: Currently selected item text
:param note: Associated MIDI note (if any)
"""
[docs]
self.current_index = current_index
[docs]
self.current_text = current_text
[docs]
class ComboBoxSynchronizer:
"""
Synchronizes MIDI messages with combo box selectors.
Handles:
- Incoming MIDI note-to-selector mapping
- Outgoing MIDI message parsing
- Drum kit selection updates
- Selector state management
- Channel-to-selector routing
"""
# Channel to row mapping
[docs]
CHANNEL_TO_ROW = {
MidiChannel.DIGITAL_SYNTH_1: 0, # Channel 0
MidiChannel.DIGITAL_SYNTH_2: 1, # Channel 1
MidiChannel.ANALOG_SYNTH: 2, # Channel 2
MidiChannel.DRUM_KIT: 3, # Channel 9
}
[docs]
ROW_TO_CHANNEL = {v: k for k, v in CHANNEL_TO_ROW.items()}
def __init__(
self,
config: Optional[ComboBoxUpdateConfig] = None,
midi_converter: Optional[MidiNoteConverter] = None,
scope: str = "ComboBoxSynchronizer",
):
"""
Initialize the synchronizer.
:param config: Synchronizer configuration
:param midi_converter: MIDI note converter
:param scope: Logging scope name
"""
[docs]
self.config = config or ComboBoxUpdateConfig()
[docs]
self.midi_converter = midi_converter
# Selector mapping: row (0-3) -> QComboBox
[docs]
self.selectors: Dict[int, QComboBox] = {}
# Options for each row
[docs]
self.row_options: Dict[int, List[str]] = {
0: [], # Digital Synth 1
1: [], # Digital Synth 2
2: [], # Analog Synth
3: [], # Drums
}
# Last update times for rate limiting
[docs]
self.last_update_time: Dict[int, float] = {}
# Callbacks
[docs]
self.on_selector_changed: Optional[Callable[[ComboBoxState], None]] = None
[docs]
self.on_drum_kit_changed: Optional[Callable[[str], None]] = None
[docs]
self.on_note_received: Optional[Callable[[int, int], None]] = None
[docs]
def set_selector(self, row: int, combo_box: QComboBox) -> None:
"""
Register a selector combo box for a row.
:param row: Row index (0-3)
:param combo_box: QComboBox widget
"""
if 0 <= row <= 3:
self.selectors[row] = combo_box
log.debug(
message=f"Registered selector for row {row}",
scope=self.scope,
)
[docs]
def set_selector_options(self, row: int, options: List[str]) -> None:
"""
Set the available options for a selector.
:param row: Row index (0-3)
:param options: List of option strings
"""
if 0 <= row <= 3:
self.row_options[row] = options
log.debug(
message=f"Set {len(options)} options for row {row}",
scope=self.scope,
)
[docs]
def set_all_selectors(
self,
digital1: QComboBox,
digital2: QComboBox,
analog: QComboBox,
drums: QComboBox,
) -> None:
"""
Set all selector combo boxes at once.
:param digital1: Digital Synth 1 selector
:param digital2: Digital Synth 2 selector
:param analog: Analog Synth selector
:param drums: Drum kit selector
"""
self.selectors[0] = digital1
self.selectors[1] = digital2
self.selectors[2] = analog
self.selectors[3] = drums
log.message(
message="Set all selectors",
scope=self.scope,
)
[docs]
def process_incoming_midi(self, message) -> None:
"""
Process an incoming MIDI message.
Updates selectors based on incoming notes.
:param message: Mido Message object
"""
try:
if not isinstance(message, Message):
return
if message.type == MidoMessageType.NOTE_ON.value and message.velocity > 0:
self._handle_note_on(message)
except Exception as ex:
log.debug(
message=f"Error processing incoming MIDI: {ex}",
scope=self.scope,
)
[docs]
def process_outgoing_midi(self, message) -> None:
"""
Process an outgoing MIDI message.
Converts raw or partial messages to mido Message and updates selectors.
:param message: List[int], tuple, or mido.Message
"""
try:
if isinstance(message, Message):
self._handle_note_on(message)
return
# Convert raw message list to mido Message
if isinstance(message, (list, tuple)) and len(message) >= 2:
status_byte = message[0]
note = message[1]
velocity = message[2] if len(message) > 2 else 0
# Extract message type and channel from status byte
msg_status = status_byte & MidiMessage.MIDI_STATUS_MASK
if msg_status in (MidiNote.ON, MidiNote.OFF):
channel = status_byte & BitMask.LOW_4_BITS
msg_type = (
MidoMessageType.NOTE_ON.value
if msg_status == NOTE_ON and velocity > 0
else MidoMessageType.NOTE_OFF.value
)
# Create mido Message
mido_msg = Message(
msg_type,
note=note,
velocity=velocity,
channel=channel,
)
self._handle_note_on(mido_msg)
except Exception as ex:
log.debug(
message=f"Error processing outgoing MIDI: {ex}",
scope=self.scope,
)
[docs]
def set_selector_by_note(
self,
row: int,
midi_note: int,
) -> bool:
"""
Update a selector based on a MIDI note.
:param row: Row index (0-3)
:param midi_note: MIDI note number
:return: True if selector was updated
"""
try:
if row not in self.selectors:
return False
combo_index = self._note_to_combo_index(row, midi_note)
if combo_index is None:
return False
selector = self.selectors[row]
if combo_index < selector.count():
self._set_selector_index_silent(selector, combo_index)
return True
except Exception as ex:
log.debug(
message=f"Error setting selector by note: {ex}",
scope=self.scope,
)
return False
[docs]
def set_selector_by_text(
self,
row: int,
text: str,
) -> bool:
"""
Update a selector by text value.
:param row: Row index (0-3)
:param text: Item text to select
:return: True if selector was updated
"""
try:
if row not in self.selectors:
return False
selector = self.selectors[row]
index = selector.findText(text)
if index >= 0:
self._set_selector_index_silent(selector, index)
return True
except Exception as ex:
log.debug(
message=f"Error setting selector by text: {ex}",
scope=self.scope,
)
return False
[docs]
def set_drum_kit(self, kit_name: str) -> bool:
"""
Change the drum kit selection.
:param kit_name: Name of the drum kit
:return: True if drum kit was changed
"""
try:
if 3 not in self.selectors:
return False
return self.set_selector_by_text(3, kit_name)
except Exception as ex:
log.error(
message=f"Error setting drum kit: {ex}",
scope=self.scope,
)
return False
[docs]
def get_selector_state(self, row: int) -> Optional[ComboBoxState]:
"""
Get the current state of a selector.
:param row: Row index (0-3)
:return: ComboBoxState or None
"""
try:
if row not in self.selectors:
return None
selector = self.selectors[row]
return ComboBoxState(
row=row,
current_index=selector.currentIndex(),
current_text=selector.currentText(),
note=self._text_to_note(row, selector.currentText()),
)
except Exception:
return None
[docs]
def get_all_selector_states(self) -> Dict[int, ComboBoxState]:
"""
Get the state of all selectors.
:return: Dictionary mapping row to ComboBoxState
"""
states = {}
for row in range(4):
state = self.get_selector_state(row)
if state:
states[row] = state
return states
[docs]
def get_selected_note(self, row: int) -> Optional[int]:
"""
Get the MIDI note corresponding to the currently selected item in a selector.
:param row: Row index (0-3)
:return: MIDI note number or None
"""
try:
if row not in self.selectors:
return None
selector = self.selectors[row]
note_name = selector.currentText()
return self._text_to_note(row, note_name)
except Exception:
return None
[docs]
def enable_selector(self, row: int, enabled: bool = True) -> None:
"""
Enable or disable a selector.
:param row: Row index (0-3)
:param enabled: Whether to enable the selector
"""
try:
if row in self.selectors:
self.selectors[row].setEnabled(enabled)
except Exception as ex:
log.debug(
message=f"Error enabling selector: {ex}",
scope=self.scope,
)
[docs]
def enable_all_selectors(self, enabled: bool = True) -> None:
"""
Enable or disable all selectors.
:param enabled: Whether to enable selectors
"""
for row in range(4):
self.enable_selector(row, enabled)
[docs]
def reset_selectors(self) -> None:
"""Reset all selectors to their first item."""
try:
for row in range(4):
if row in self.selectors:
self._set_selector_index_silent(self.selectors[row], 0)
log.message(
message="Reset all selectors",
scope=self.scope,
)
except Exception as ex:
log.error(
message=f"Error resetting selectors: {ex}",
scope=self.scope,
)
[docs]
def _handle_note_on(self, message: mido.Message) -> None:
"""
Handle a NOTE_ON message.
Updates the appropriate selector based on channel and note.
:param message: Mido NOTE_ON message with velocity > 0
"""
note = message.note
channel = message.channel
# Log the message if not silenced
if not silence_midi_note_logging():
log.message(
message=f"MIDI note: {note}, channel: {channel}",
scope=self.scope,
)
# Skip notes below minimum
if note < self.config.min_note:
log.debug(
message=f"Note {note} is below minimum {self.config.min_note}, skipping",
scope=self.scope,
)
return
# Map channel to row
if channel not in self.CHANNEL_TO_ROW:
log.debug(
message=f"Channel {channel} not mapped to any row",
scope=self.scope,
)
return
row = self.CHANNEL_TO_ROW[channel]
# Update the selector for this row
if self.set_selector_by_note(row, note):
# Trigger callback
state = self.get_selector_state(row)
if state and self.on_selector_changed:
self.on_selector_changed(state)
# Trigger note received callback
if self.on_note_received:
self.on_note_received(row, note)
[docs]
def _note_to_combo_index(self, row: int, midi_note: int) -> Optional[int]:
"""
Convert a MIDI note to a combo box index.
:param row: Row index (0-3)
:param midi_note: MIDI note number
:return: Combo box index or None
"""
try:
# Use MIDI converter if available
if self.midi_converter:
options = self.row_options.get(row, [])
return self.midi_converter.midi_note_to_combo_index(
row,
midi_note,
options,
)
# Fallback: convert to note name and find in options
if row == 3: # Drums
note_name = self._midi_to_drum_name(midi_note)
else:
note_name = self._midi_to_note_name(midi_note)
options = self.row_options.get(row, [])
try:
return options.index(note_name)
except ValueError:
return None
except Exception:
return None
[docs]
def _text_to_note(self, row: int, text: str) -> Optional[int]:
"""
Convert selector text to a MIDI note.
:param row: Row index
:param text: Text from selector
:return: MIDI note or None
"""
try:
if row == 3: # Drums
# For drums, try to find in options and convert index to MIDI note
options = self.row_options.get(row, [])
try:
index = options.index(text)
return 36 + index # Drums start at MIDI note 36
except ValueError:
return None
# For melodic instruments, use MIDI converter
if self.midi_converter:
return self.midi_converter.note_name_to_midi(text)
# Fallback: basic conversion
return self._basic_note_name_to_midi(text)
except Exception:
return None
[docs]
def _midi_to_note_name(self, midi_note: int) -> str:
"""
Convert MIDI note to note name.
:param midi_note: MIDI note number
:return: Note name (e.g., 'C4')
"""
if self.midi_converter:
return self.midi_converter.midi_to_note_name(midi_note, drums=False)
# Fallback
semitone_to_note = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
]
octave = (midi_note // 12) - 1
semitone = midi_note % 12
note = semitone_to_note[semitone]
return f"{note}{octave}"
[docs]
def _midi_to_drum_name(self, midi_note: int) -> str:
"""
Convert MIDI note to drum name.
:param midi_note: MIDI note number
:return: Drum name or fallback
"""
if self.midi_converter:
return self.midi_converter.midi_to_note_name(midi_note, drums=True)
return f"Drum({midi_note})"
[docs]
def _basic_note_name_to_midi(self, note_name: str) -> Optional[int]:
"""
Basic fallback for note name to MIDI conversion.
:param note_name: Note name (e.g., 'C4')
:return: MIDI note or None
"""
try:
note_to_semitone = {
"C": 0,
"C#": 1,
"D": 2,
"D#": 3,
"E": 4,
"F": 5,
"F#": 6,
"G": 7,
"G#": 8,
"A": 9,
"A#": 10,
"B": 11,
}
if "#" in note_name:
note = note_name[:-1]
octave = int(note_name[-1])
else:
note = note_name[0]
octave = int(note_name[1:])
if note not in note_to_semitone:
return None
return (octave + 1) * 12 + note_to_semitone[note]
except Exception:
return None
[docs]
def _set_selector_index_silent(self, combo_box: QComboBox, index: int) -> None:
"""
Set combo box index without triggering signals.
:param combo_box: QComboBox to update
:param index: Index to set
"""
combo_box.blockSignals(True)
combo_box.setCurrentIndex(index)
combo_box.blockSignals(False)
[docs]
def set_config(self, config: ComboBoxUpdateConfig) -> None:
"""
Update synchronizer configuration.
:param config: New ComboBoxUpdateConfig
"""
self.config = config
[docs]
def set_midi_converter(self, converter: MidiNoteConverter) -> None:
"""
Set the MIDI converter.
:param converter: MidiNoteConverter instance
"""
self.midi_converter = converter