"""
ProgramEditor Module
This module defines the `ProgramEditor` class, a PySide6-based GUI for
managing and selecting MIDI programs.
It allows users to browse, filter, and load programs based on bank, genre,
and program number.
The class also facilitates MIDI integration by sending Program Change (PC)
and Bank Select (CC#0, CC#32) messages.
Key Features:
- Graphical UI for selecting and managing MIDI programs.
- Filtering options based on bank and genre.
- MIDI integration for program selection and loading.
- Image digital for program categories.
- Program list population based on predefined program data.
Classes:
ProgramEditor(QMainWindow)
A main window class for handling MIDI program selection and management.
Signals:
program_changed (int, str, int)
Emitted when a program selection changes. Parameters:
- MIDI channel (int)
- Preset name (str)
- Program number (int)
Dependencies:
- PySide6.QtWidgets
- PySide6.QtCore
- MIDIHelper for MIDI message handling
- PresetHandler for managing program presets
- JDXiProgramList.PROGRAM_LIST for predefined program data
"""
from dataclasses import dataclass
from typing import Any, Dict, Optional
from decologr import Decologr as log
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QHBoxLayout, QLabel, QSlider, QTabWidget
from jdxi_editor.core.synth.type import JDXiSynth
from jdxi_editor.midi.channel.channel import MidiChannel
from jdxi_editor.midi.data.address.address import (
JDXiSysExAddressStartMSB,
JDXiSysExOffsetSuperNATURALLMB,
JDXiSysExOffsetTemporaryToneUMB,
)
from jdxi_editor.midi.data.drum.data import DRUM_PARTIAL_MAP
from jdxi_editor.midi.data.parameter.analog.address import AnalogParam
from jdxi_editor.midi.data.parameter.digital import DigitalCommonParam
from jdxi_editor.midi.data.parameter.drum.common import DrumCommonParam
from jdxi_editor.midi.data.parameter.program.common import ProgramCommonParam
from jdxi_editor.midi.io.helper import MidiIOHelper
from jdxi_editor.midi.program.program import JDXiProgram
from jdxi_editor.midi.sysex.partial.switch import PartialSelectState, PartialSwitchState
from jdxi_editor.midi.sysex.request.data import SYNTH_PARTIAL_MAP
from jdxi_editor.midi.sysex.request.midi_requests import MidiRequests
from jdxi_editor.midi.sysex.sections import SysExSection
from jdxi_editor.ui.common import JDXi, QVBoxLayout, QWidget
from jdxi_editor.ui.editors.digital.utils import filter_sysex_keys, get_partial_number
from jdxi_editor.ui.editors.playlist.editor import PlaylistEditor
from jdxi_editor.ui.editors.playlist.table import PlaylistTable
from jdxi_editor.ui.editors.preset.type import PresetTitle
from jdxi_editor.ui.editors.preset.widget import PresetWidget
from jdxi_editor.ui.editors.program.group import ProgramGroup
from jdxi_editor.ui.editors.program.helper import create_placeholder_icon
from jdxi_editor.ui.editors.program.mixer.section import MixerAttrs, ProgramMixer
from jdxi_editor.ui.editors.program.user_programs_widget import UserProgramsWidget
from jdxi_editor.ui.editors.synth.simple import BasicEditor
from jdxi_editor.ui.preset.helper import JDXiPresetHelper
from jdxi_editor.ui.preset.tone.lists import JDXiUIPreset
from jdxi_editor.ui.programs.programs import JDXiUIProgramList
from jdxi_editor.ui.style.dimensions import Dimensions
from jdxi_editor.ui.widgets.combo_box.searchable_filterable import (
SearchableFilterableComboBox,
)
from jdxi_editor.ui.widgets.editor.base import EditorBaseWidget
from picomidi.constant import Midi
from picomidi.sysex.parameter.address import AddressParameter
[docs]
class ProgramEditorDimensions(Dimensions):
"""ProgramEditor Dimensions"""
@dataclass
[docs]
class EditorSpec:
"""Editor Spec"""
[docs]
default_image: str = ""
[docs]
instrument_icon_folder: str = ""
[docs]
dimensions: Dimensions = None
[docs]
class ProgramEditor(BasicEditor):
"""Program Editor Window"""
[docs]
TEMPORARY_AREA_HANDLERS = {
JDXiSysExAddressStartMSB.TEMPORARY_PROGRAM.name: {
SysExSection.PROGRAM_LEVEL: (
ProgramCommonParam.PROGRAM_LEVEL,
MixerAttrs.MASTER,
)
},
# Use (param_constant, slider) like Master/Drums so Analog is as reliable
JDXiSysExOffsetTemporaryToneUMB.ANALOG_SYNTH.name: {
SysExSection.AMP_LEVEL: (AnalogParam.AMP_LEVEL, MixerAttrs.ANALOG),
},
JDXiSysExOffsetTemporaryToneUMB.DRUM_KIT.name: {
SysExSection.KIT_LEVEL: (DrumCommonParam.KIT_LEVEL, MixerAttrs.DRUMS)
},
JDXiSysExOffsetTemporaryToneUMB.DIGITAL_SYNTH_1.name: {
SysExSection.TONE_LEVEL: (
DigitalCommonParam.TONE_LEVEL,
MixerAttrs.DIGITAL1,
)
},
JDXiSysExOffsetTemporaryToneUMB.DIGITAL_SYNTH_2.name: {
SysExSection.TONE_LEVEL: (
DigitalCommonParam.TONE_LEVEL,
MixerAttrs.DIGITAL2,
)
},
}
[docs]
program_changed = Signal(int, str, int) # (channel, preset_name, program_number)
def __init__(
self,
midi_helper: Optional[MidiIOHelper] = None,
parent: Optional[QWidget] = None,
preset_helper: JDXiPresetHelper = None,
):
super().__init__(midi_helper=midi_helper, parent=parent)
[docs]
self.title_right_vlayout = None
[docs]
self.program_list = None
"""
Initialize the ProgramEditor
:param midi_helper: Optional[MidiIOHelper]
:param parent: Optional[QWidget]
:param preset_helper: JDXIPresetHelper
"""
self.setWindowFlag(Qt.Window)
[docs]
self.midi_helper = midi_helper
[docs]
self.preset_helper = preset_helper
[docs]
self.channel = (
MidiChannel.PROGRAM # Default MIDI channel: 16 for programs, 0-based
)
[docs]
self.midi_requests = MidiRequests.PROGRAM_TONE_NAME_PARTIAL
[docs]
self.spec = EditorSpec(
default_image="programs.png",
instrument_icon_folder="programs",
title="Program Editor",
dimensions=ProgramEditorDimensions(),
)
[docs]
self.default_image = self.spec.default_image
[docs]
self.instrument_icon_folder = self.spec.instrument_icon_folder
[docs]
self.instrument_title_label = QLabel() # Just to stop error messages for now
self.midi_requests = MidiRequests.PROGRAM_TONE_NAME_PARTIAL
[docs]
self.midi_channel = 0 # Defaults to DIGITAL 1
[docs]
self.genre_label = None
[docs]
self.bank_combo_box = None
[docs]
self.title_label = None
[docs]
self.program_label = None
[docs]
self.genre_combo_box = None
# program_preset will be set up in setup_ui() via ProgramGroupWidget
[docs]
self.program_preset: Optional[PresetWidget] = None
[docs]
self.preset_type = None
[docs]
self.programs = {} # Maps program names to numbers
[docs]
self._actual_preset_list = (
JDXi.UI.Preset.Digital.PROGRAM_CHANGE # Default preset list for combo box
)
# Initialize widget references before setup_ui() to prevent AttributeError
# if callbacks are triggered during widget creation
[docs]
self.controls: Dict[AddressParameter, QWidget] = {}
self.setup_ui()
self.midi_helper.update_program_name.connect(self.set_current_program_name)
self.midi_helper.midi_sysex_json.connect(self.dispatch_sysex_to_area)
[docs]
def setup_ui(self):
"""set up ui elements"""
self.setWindowTitle(self.spec.title)
self.setMinimumSize(self.spec.dimensions.WIDTH, self.spec.dimensions.HEIGHT)
main_vlayout = QVBoxLayout()
# Create main tab widget for top-level tabs
self.main_tab_widget = QTabWidget()
main_vlayout.addWidget(self.main_tab_widget)
# --- Use EditorBaseWidget for Programs/Presets tab
self.base_widget = EditorBaseWidget(parent=self, analog=False)
self.base_widget.setup_scrollable_content()
# container_layout = self.base_widget.get_container_layout()
# Create centered content widget
centered_content = QWidget()
self.title_vlayout = QVBoxLayout(centered_content)
self.title_vlayout.addStretch()
self.title_hlayout = QHBoxLayout()
self.title_hlayout.addStretch()
self.title_left_vlayout = QVBoxLayout()
self.title_hlayout.addLayout(self.title_left_vlayout)
self.title_right_vlayout = QVBoxLayout()
self.title_hlayout.addLayout(self.title_right_vlayout)
self.title_hlayout.addStretch()
self.title_vlayout.addLayout(self.title_hlayout)
self.title_vlayout.addStretch()
# Add centered content to base widget
self.base_widget.add_centered_content(centered_content)
# Add Programs/Presets tab to main tab widget (base widget contains the scroll area)
try:
programs_presets_icon = JDXi.UI.Icon.get_icon(
JDXi.UI.Icon.MUSIC_NOTE_MULTIPLE, color=JDXi.UI.Style.GREY
)
if programs_presets_icon is None or programs_presets_icon.isNull():
raise ValueError("Icon is null")
except Exception:
programs_presets_icon = JDXi.UI.Icon.get_icon(
JDXi.UI.Icon.MUSIC, color=JDXi.UI.Style.GREY
)
self.main_tab_widget.addTab(
self.base_widget, programs_presets_icon, "Programs & Presets"
)
# Add User Programs tab to main tab widget
try:
log.message(
"🔨Creating User Programs tab for main window...",
scope=self.__class__.__name__,
)
self.user_programs_widget = UserProgramsWidget(
midi_helper=self.midi_helper,
channel=self.channel,
parent=self,
on_program_loaded=self._on_user_program_loaded,
)
user_programs_icon = JDXi.UI.Icon.get_icon(
"mdi.account-music", color=JDXi.UI.Style.GREY
)
self.main_tab_widget.addTab(
self.user_programs_widget, user_programs_icon, "User Programs"
)
log.message(
f"✅ Added 'User Programs' tab to main window (total tabs: {self.main_tab_widget.count()})",
scope=self.__class__.__name__,
)
# Log all tab names for debugging
for i in range(self.main_tab_widget.count()):
log.message(
message=f"Main Tab {i}: '{self.main_tab_widget.tabText(i)}'",
scope=self.__class__.__name__,
)
except Exception as e:
log.error(
f"❌Error creating User Programs tab: {e}",
scope=self.__class__.__name__,
)
import traceback
log.error(traceback.format_exc())
placeholder_widget, user_programs_icon = create_placeholder_icon(
e,
error_message="Error loading user programs:",
icon_name="mdi.account-music",
)
self.main_tab_widget.addTab(
placeholder_widget, user_programs_icon, "User Programs"
)
log.message(
f"✅ Added 'User Programs' tab (placeholder) (total tabs: {self.main_tab_widget.count()})",
scope=self.__class__.__name__,
)
# --- Add Playlist tab to main tab widget
try:
log.message(
"🔨Creating Playlist tab for main window...",
scope=self.__class__.__name__,
)
self.playlist_widget = PlaylistTable(
parent=self, on_playlist_changed=self._on_playlist_changed
)
playlist_icon = JDXi.UI.Icon.get_icon(
"mdi.playlist-music", color=JDXi.UI.Style.GREY
)
self.main_tab_widget.addTab(self.playlist_widget, playlist_icon, "Playlist")
log.message(
f"✅ Added 'Playlist' tab to main window (total tabs: {self.main_tab_widget.count()})",
scope=self.__class__.__name__,
)
except Exception as e:
log.error(
f"❌Error creating Playlist tab: {e}", scope=self.__class__.__name__
)
import traceback
log.error(traceback.format_exc())
placeholder_widget, playlist_icon = create_placeholder_icon(
e,
error_message="Error loading playlists: ",
icon_name="mdi.playlist-music",
)
self.main_tab_widget.addTab(placeholder_widget, playlist_icon, "Playlist")
log.message(
f"✅ Added 'Playlist' tab (placeholder) (total tabs: {self.main_tab_widget.count()})",
scope=self.__class__.__name__,
)
# Add Playlist Editor tab to main tab widget
try:
log.message(
"🔨Creating Playlist Editor tab for main window...",
scope=self.__class__.__name__,
)
self.playlist_editor_widget = PlaylistEditor(
midi_helper=self.midi_helper,
channel=self.channel,
parent=self,
on_program_loaded=self._on_playlist_program_loaded,
on_refresh_playlist_combo=self._populate_playlist_editor_combo,
get_parent_instrument=lambda: self._get_parent_instrument(),
)
playlist_editor_icon = JDXi.UI.Icon.get_icon(
"mdi.playlist-edit", color=JDXi.UI.Style.GREY
)
self.main_tab_widget.addTab(
self.playlist_editor_widget, playlist_editor_icon, "Playlist Editor"
)
log.message(
f"✅ Added 'Playlist Editor' tab to main window (total tabs: {self.main_tab_widget.count()})",
scope=self.__class__.__name__,
)
except Exception as e:
log.error(
f"❌Error creating Playlist Editor tab: {e}",
scope=self.__class__.__name__,
)
import traceback
log.error(traceback.format_exc())
# Create a placeholder widget so the tab still appears
placeholder_widget, playlist_editor_icon = create_placeholder_icon(
e,
error_message="Error loading playlist editor: ",
icon_name="mdi.playlist-edit",
)
self.main_tab_widget.addTab(
placeholder_widget, playlist_editor_icon, "Playlist Editor"
)
log.message(
f"✅Added 'Playlist Editor' tab (placeholder) (total tabs: {self.main_tab_widget.count()})",
scope=self.__class__.__name__,
)
self.setLayout(main_vlayout)
self.setStyleSheet(JDXi.UI.Style.EDITOR)
program_preset_hlayout = QHBoxLayout()
program_preset_hlayout.addStretch()
# Create ProgramGroupWidget
self.program_group_widget = ProgramGroup(parent=self)
self.program_group_widget.channel = self.channel
# Sync program_preset reference for backward compatibility
self.program_preset = self.program_group_widget.preset
program_preset_hlayout.addStretch()
program_preset_hlayout.addWidget(self.program_group_widget)
program_preset_hlayout.addStretch()
# Create PresetWidget and add it to the tab widget
# Note: The preset widget is already created inside ProgramGroupWidget
# We need to add it to the program_preset_tab_widget
try:
presets_icon = JDXi.UI.Icon.get_icon(
JDXi.UI.Icon.MUSIC_NOTE_MULTIPLE, color=JDXi.UI.Style.GREY
)
if presets_icon.isNull():
raise ValueError("Icon is null")
except Exception:
presets_icon = JDXi.UI.Icon.get_icon(
JDXi.UI.Icon.MUSIC, color=JDXi.UI.Style.GREY
)
self.program_group_widget.program_preset_tab_widget.addTab(
self.program_group_widget.preset, presets_icon, "Presets"
)
log.message(
f"📑Added 'Presets' tab to program_preset_tab_widget (total tabs: {self.program_group_widget.program_preset_tab_widget.count()})",
scope="ProgramEditor ",
)
program_preset_hlayout.addStretch()
self.title_left_vlayout.addLayout(program_preset_hlayout)
self.title_left_vlayout.addStretch()
self.populate_programs()
# Create mixer widget
self.mixer_widget = ProgramMixer(midi_helper=self.midi_helper, parent=self)
mixer_group = self.mixer_widget.create_mixer_widget()
# Merge mixer widget's controls into ProgramEditor's controls dict
self.controls.update(self.mixer_widget.get_controls())
# Wire mixer widget to program group widget
self.program_group_widget.mixer_widget = self.mixer_widget
self.right_hlayout = QHBoxLayout()
self.right_hlayout.addWidget(mixer_group)
self.title_right_vlayout.addLayout(self.right_hlayout)
self.title_hlayout.addStretch()
self.title_vlayout.addStretch()
preset_type = PresetTitle.DIGITAL_SYNTH1
self.set_channel_and_preset_lists(preset_type)
self._populate_presets()
self.midi_helper.update_tone_name.connect(
lambda tone_name, synth_type: self.update_tone_name_for_synth(
tone_name, synth_type
)
)
self.update_instrument_image()
[docs]
def on_category_changed(self, _: int) -> None:
"""Handle category selection change - no longer needed, handled by SearchableFilterableComboBox."""
pass
[docs]
def on_preset_type_changed(self, index: int) -> None:
"""
on_preset_type_changed
:param index: int
Handle preset type selection change
Note: This delegates to PresetWidget's on_preset_type_changed, but also
updates ProgramEditor's internal state.
"""
preset_type = (
self.program_group_widget.preset.digital_preset_type_combo.currentText()
)
log.message(f"preset_type: {preset_type}")
# Update ProgramEditor's channel and preset lists
self.set_channel_and_preset_lists(preset_type)
# PresetWidget handles its own combo box update via its on_preset_type_changed
# But we also need to update ProgramEditor's combo box if it exists
if hasattr(self, "_update_preset_combo_box"):
self._update_preset_combo_box()
[docs]
def set_channel_and_preset_lists(self, preset_type: str) -> None:
"""
set_channel_and_preset_lists
:param preset_type:
:return: None
"""
if preset_type in [PresetTitle.DIGITAL_SYNTH1, PresetTitle.DIGITAL_SYNTH2]:
self.program_group_widget.preset.preset_list = (
JDXiUIPreset.Digital.PROGRAM_CHANGE
)
if preset_type == PresetTitle.DIGITAL_SYNTH1:
self.midi_channel = MidiChannel.DIGITAL_SYNTH_1
elif preset_type == PresetTitle.DIGITAL_SYNTH2:
self.midi_channel = MidiChannel.DIGITAL_SYNTH_2
elif preset_type == PresetTitle.DRUMS:
self.midi_channel = MidiChannel.DRUM_KIT
self.program_group_widget.preset.preset_list = (
JDXiUIPreset.Drum.PROGRAM_CHANGE
)
elif preset_type == PresetTitle.ANALOG_SYNTH:
self.midi_channel = MidiChannel.ANALOG_SYNTH
self.program_group_widget.preset.preset_list = (
JDXiUIPreset.Analog.PROGRAM_CHANGE
)
[docs]
def _update_preset_combo_box(self) -> None:
"""
Update the SearchableFilterableComboBox with current preset list.
Called when preset type changes.
Note: This method is now handled by PresetWidget._update_preset_combo_box(),
but kept here for backward compatibility if needed.
"""
# Delegate to PresetWidget's method
if hasattr(self.program_group_widget.preset, "_update_preset_combo_box"):
self.program_group_widget.preset._update_preset_combo_box()
[docs]
def _populate_programs(self, search_text: str = "") -> None:
"""
Populate the program list with available presets.
Now delegates to populate_programs() which uses _update_program_combo_box().
:param search_text: str
:return: None
"""
# Delegate to populate_programs which handles the combo box update
self.populate_programs(search_text)
[docs]
def _populate_presets(self, search_text: str = "") -> None:
"""
Populate the program list with available presets.
Now handled by SearchableFilterableComboBox, so this just updates the combo box.
:param search_text: str (ignored, handled by SearchableFilterableComboBox)
:return: None
"""
self._update_preset_combo_box()
[docs]
def _init_synth_data(
self,
synth_type: str = JDXiSynth.DIGITAL_SYNTH_1,
partial_number: Optional[int] = 0,
) -> None:
"""
:param synth_type: JDXiSynth
:param partial_number: int
:return: None
Initialize synth-specific data
"""
from jdxi_editor.core.synth.factory import create_synth_data
self.synth_data = create_synth_data(synth_type, partial_number=partial_number)
# Dynamically assign attributes
for attr in [
"address",
"preset_type",
"instrument_default_image",
"instrument_icon_folder",
"presets",
"preset_list",
"midi_requests",
"midi_channel",
]:
setattr(self, attr, getattr(self.synth_data, attr))
[docs]
def update_tone_name_for_synth(self, tone_name: str, synth_type: str) -> None:
"""
Update the tone name.
:param tone_name: str
:param synth_type: str
"""
if self.mixer_widget:
self.mixer_widget.update_tone_name_for_synth(tone_name, synth_type)
[docs]
def set_current_program_name(
self, program_name: str, synth_type: str = None
) -> None:
"""
Set the current program name in the file label
:param program_name: str
:param synth_type: str (optional), discarded for now
:return: None
"""
self.program_name = program_name or "Untitled Program"
# Update program group widget's file label
if self.program_group_widget:
self.program_group_widget.set_current_program_name(program_name)
if self.mixer_widget:
self.mixer_widget.update_program_name(program_name)
[docs]
def start_playback(self):
"""Start playback of the MIDI file."""
self.midi_helper.send_raw_message([Midi.song.START])
[docs]
def stop_playback(self):
"""Stop playback of the MIDI file."""
self.midi_helper.send_raw_message([Midi.song.STOP])
[docs]
def _update_program_combo_box(self) -> None:
"""
Update the SearchableFilterableComboBox with current program list.
Handles both ROM banks (A-D) and user banks (E-H) with SQLite integration.
"""
if not self.preset_helper:
return
# --- Get all programs (ROM + user from database)
all_programs = JDXiUIProgramList.list_rom_and_user_programs()
if self.program_group_widget:
self.program_group_widget._program_list_data = all_programs
# --- Build program options, values, and filter data
program_options = []
program_values = []
program_genres = set()
program_banks = set()
# --- Process ROM programs
for program in all_programs:
if program.id and len(program.id) >= 1:
bank = program.id[0]
program_banks.add(bank)
if program.genre:
program_genres.add(program.genre)
program_options.append(f"{program.id} - {program.name}")
program_values.append(len(program_options) - 1) # Use index as value
# --- Add user bank placeholders (E, F, G, H) - these will be handled dynamically
# but we need to ensure they're in the banks list
program_banks.update(["E", "F", "G", "H"])
# Bank filter function for programs
def program_bank_filter(program_display: str, bank: str) -> bool:
"""Check if a program matches a bank."""
if not bank:
return True
# --- Extract bank from digital string (format: "A01 - Program Name")
if " - " in program_display:
program_id = program_display.split(" - ")[0]
else:
program_id = (
program_display.split()[0] if program_display.split() else ""
)
if program_id and len(program_id) >= 1:
return program_id[0].upper() == bank.upper()
return False
# --- Genre filter function for programs
def program_genre_filter(program_display: str, genre: str) -> bool:
"""Check if a program matches a genre."""
if not genre:
return True
# --- Find the program in the list and check its genre
# --- Extract program ID from digital string
if " - " in program_display:
program_id = program_display.split(" - ")[0]
else:
program_id = (
program_display.split()[0] if program_display.split() else ""
)
# ---Find program in list
program_list_data = (
self.program_group_widget._program_list_data
if self.program_group_widget
else []
)
for program in program_list_data:
if program.id == program_id:
return program.genre == genre if program.genre else False
return False
# --- Update the combo box by recreating it (since SearchableFilterableComboBox doesn't have update methods)
# --- Get parent widget and layout from program_group_widget
if not self.program_group_widget:
return
program_widget = self.program_group_widget.edit_program_name_button.parent()
program_vlayout = program_widget.layout() if program_widget else None
if program_vlayout:
# --- Remove old combo box from layout
program_vlayout.removeWidget(
self.program_group_widget.program_number_combo_box
)
self.program_group_widget.program_number_combo_box.deleteLater()
# ---Create new combo box with updated data
self.program_group_widget.program_number_combo_box = (
SearchableFilterableComboBox(
label="Program",
options=program_options,
values=program_values,
categories=sorted(program_genres),
banks=sorted(program_banks),
bank_filter_func=program_bank_filter,
category_filter_func=program_genre_filter,
show_label=True,
show_search=True,
show_category=True,
show_bank=True,
search_placeholder="Search programs...",
category_label="Genre:",
bank_label="Bank:",
)
)
# --- Insert after edit_program_name_button
index = program_vlayout.indexOf(
self.program_group_widget.edit_program_name_button
)
program_vlayout.insertWidget(
index + 1, self.program_group_widget.program_number_combo_box
)
[docs]
def populate_programs(self, search_text: str = ""):
"""Populate the program list with available presets.
Now handled by SearchableFilterableComboBox, so this just updates the combo box.
Uses SQLite database to ensure all user bank programs are loaded correctly.
"""
if not self.preset_helper:
return
# --- Update the combo box with current program data
self._update_program_combo_box()
[docs]
def add_user_banks(
self, filtered_list: list, bank: str, search_text: str = None
) -> None:
"""Add user banks to the program list.
Only adds generic entries for programs that don't exist in the database.
Uses SQLite database for reliable lookups.
:param search_text:
:param filtered_list: list of programs already loaded from database
:param bank: str
"""
from jdxi_editor.ui.programs.database import get_database
user_banks = ["E", "F", "G", "H"]
# --- Create sets for quick lookup
existing_program_ids_in_filtered = {program.id for program in filtered_list}
# --- Also check what's already in the combo box to avoid duplicates
if (
not self.program_group_widget
or not self.program_group_widget.program_number_combo_box
):
return
existing_combo_items = {
self.program_group_widget.program_number_combo_box.itemText(i)[
:3
] # --- Extract program ID (e.g., "E01")
for i in range(self.program_group_widget.program_number_combo_box.count())
}
# --- Get database instance for direct queries
db = get_database()
for user_bank in user_banks:
if bank in ["No Bank Selected", user_bank]:
for i in range(1, 65):
program_id = f"{user_bank}{i:02}"
# --- Skip if already in combo box (avoid duplicates)
if program_id in existing_combo_items:
continue
# --- Check if program exists in filtered_list (already added)
if program_id in existing_program_ids_in_filtered:
continue
# --- Check database directly using SQLite
# --- Only add programs that exist in the database (single source of truth)
existing_program = db.get_program_by_id(program_id)
if not existing_program:
# --- If program doesn't exist in database, skip it (no placeholders)
continue
# --- Program exists in database, add it with real name
program_name = existing_program.name
if search_text and search_text.lower() not in program_name.lower():
continue
self._add_program_to_database(program_id, program_name)
[docs]
def _add_program_to_database(self, program_id: str, program_name: str):
"""add program to database"""
index = len(self.programs)
if (
self.program_group_widget
and self.program_group_widget.program_number_combo_box
):
self.program_group_widget.program_number_combo_box.addItem(
f"{program_id} - {program_name}", index
)
self.programs[program_name] = index
[docs]
def _get_table_style(self) -> str:
"""
Get custom styling for tables with rounded corners and charcoal embossed cells.
:return: str CSS style string
"""
return JDXi.UI.Style.DATABASE_TABLE_STYLE
[docs]
def _on_user_program_loaded(self, program: JDXiProgram) -> None:
"""
Handle when a user program is loaded from the table.
:param program: JDXiProgram that was loaded
"""
# Request program data
if hasattr(self, "program_helper") and self.program_helper:
self.program_helper.data_request()
elif hasattr(self, "data_request"):
self.data_request()
# Update UI
self.set_current_program_name(program.name)
if hasattr(self, "update_current_synths"):
self.update_current_synths(program)
# Also update the program combo box to reflect the selected program
# Find the program in the combo box and select it
if self.program_group_widget and hasattr(
self.program_group_widget, "program_number_combo_box"
):
for i in range(self.program_group_widget.program_number_combo_box.count()):
item_text = self.program_group_widget.program_number_combo_box.itemText(
i
)
if item_text.startswith(program.id):
self.program_group_widget.program_number_combo_box.setCurrentIndex(
i
)
break
[docs]
def _on_playlist_changed(self) -> None:
"""
Handle when a playlist is created, deleted, or updated.
Refreshes the playlist editor combo if it exists.
"""
if self.playlist_editor_widget:
self.playlist_editor_widget.populate_playlist_combo()
# Clear the programs table if the deleted playlist was selected
if self.playlist_editor_widget.playlist_editor_combo:
# Use .value() for SearchableFilterableComboBox (0 means no playlist selected)
current_value = (
self.playlist_editor_widget.playlist_editor_combo.value()
)
playlist_id = self.playlist_editor_widget._playlist_value_to_id.get(
current_value
)
if (
playlist_id is None
and self.playlist_editor_widget.playlist_programs_table
):
self.playlist_editor_widget.playlist_programs_table.setRowCount(0)
[docs]
def _on_playlist_program_loaded(self, program: JDXiProgram) -> None:
"""
Handle when a program is loaded from the playlist editor.
Updates UI to reflect the loaded program.
:param program: JDXiProgram that was loaded
"""
self.set_current_program_name(program.name)
if hasattr(self, "update_current_synths"):
self.update_current_synths(program)
# Request program data if program_helper exists
if hasattr(self, "program_helper") and self.program_helper:
self.program_helper.data_request()
[docs]
def _populate_playlist_editor_combo(self) -> None:
"""Populate the playlist editor combo (callback for playlist editor widget)."""
if self.playlist_editor_widget:
self.playlist_editor_widget.populate_playlist_combo()
[docs]
def _get_parent_instrument(self) -> Optional[QWidget]:
"""
Get the parent instrument widget for accessing MidiFileEditor.
:return: Optional[QWidget] parent instrument or None
"""
parent_instrument = getattr(self, "parent", None)
# Walk up the parent chain to find JDXiInstrument if needed
while parent_instrument and not hasattr(
parent_instrument, "get_existing_editor"
):
next_parent = getattr(parent_instrument, "parent", None)
if not next_parent:
break
parent_instrument = next_parent
return (
parent_instrument
if hasattr(parent_instrument, "get_existing_editor")
else None
)
[docs]
def on_bank_changed(self, _: int) -> None:
"""Handle bank selection change - no longer needed, handled by SearchableFilterableComboBox."""
pass
[docs]
def on_program_number_changed(self, index: int) -> None:
"""Handle program number selection change.
:param index: int
"""
# self.load_program()
[docs]
def load_program(self):
"""Load the selected program based on bank and number.
Delegates to ProgramGroupWidget's load_program method.
"""
if self.program_group_widget:
self.program_group_widget.load_program()
[docs]
def update_current_synths(self, program_details: JDXiProgram) -> None:
"""Update the current synth labels in the mixer widget.
:param program_details: JDXiProgram
:return: None
"""
if not self.mixer_widget:
log.warning(
"Mixer widget not available, cannot update synth labels",
scope=self.__class__.__name__,
)
return
try:
labels = self.mixer_widget.label_widgets
labels.set_text(
JDXiSynth.DIGITAL_SYNTH_1, program_details.digital_1 or "Unknown"
)
labels.set_text(
JDXiSynth.DIGITAL_SYNTH_2, program_details.digital_2 or "Unknown"
)
labels.set_text(JDXiSynth.DRUM_KIT, program_details.drums or "Unknown")
labels.set_text(JDXiSynth.ANALOG_SYNTH, program_details.analog or "Unknown")
except (AttributeError, KeyError) as e:
log.message(
f"Error updating synth labels: {e}", scope=self.__class__.__name__
)
log.message(
f"Program details: {program_details}", scope=self.__class__.__name__
)
labels = getattr(self.mixer_widget, "label_widgets", None)
if labels:
labels.set_text(JDXiSynth.DIGITAL_SYNTH_1, "Unknown")
labels.set_text(JDXiSynth.DIGITAL_SYNTH_2, "Unknown")
labels.set_text(JDXiSynth.DRUM_KIT, "Unknown")
labels.set_text(JDXiSynth.ANALOG_SYNTH, "Unknown")
[docs]
def load_preset(self, program_number: int) -> None:
"""
Load preset by program change and refresh UI.
:param program_number: Preset ID (e.g., 1 for "001")
:return: None
"""
if not self.preset_helper:
return
preset_type = PresetTitle.DIGITAL_SYNTH1
if self.program_group_widget and hasattr(
self.program_group_widget.preset, "digital_preset_type_combo"
):
preset_type = (
self.program_group_widget.preset.digital_preset_type_combo.currentText()
)
preset_type_to_synth = {
PresetTitle.DIGITAL_SYNTH1: JDXiSynth.DIGITAL_SYNTH_1,
PresetTitle.DIGITAL_SYNTH2: JDXiSynth.DIGITAL_SYNTH_2,
PresetTitle.DRUMS: JDXiSynth.DRUM_KIT,
PresetTitle.ANALOG_SYNTH: JDXiSynth.ANALOG_SYNTH,
}
synth_type = preset_type_to_synth.get(preset_type, JDXiSynth.DIGITAL_SYNTH_1)
self.preset_helper.load_preset_by_program_change(program_number, synth_type)
self.data_request()
[docs]
def _update_program_list(self) -> None:
"""Update the program list with available presets."""
self.populate_programs()
[docs]
def on_genre_changed(self, _: int) -> None:
"""
Handle genre selection change - no longer needed, handled by SearchableFilterableComboBox.
"""
pass
[docs]
def _normalize_program_common(self, sysex_data: dict) -> dict:
"""Normalize special Program Common address case."""
address_hex = sysex_data.get(SysExSection.ADDRESS, "")
temporary_area = sysex_data.get(SysExSection.TEMPORARY_AREA)
synth_tone = sysex_data.get(SysExSection.SYNTH_TONE)
if (
address_hex == "18000000"
and SysExSection.PROGRAM_LEVEL in sysex_data
and temporary_area != JDXiSysExAddressStartMSB.TEMPORARY_PROGRAM.name
):
sysex_data[SysExSection.TEMPORARY_AREA] = (
JDXiSysExAddressStartMSB.TEMPORARY_PROGRAM.name
)
sysex_data[SysExSection.SYNTH_TONE] = (
JDXiSysExOffsetSuperNATURALLMB.COMMON.name
)
return sysex_data
[docs]
def _get_partial_map(self, temporary_area: str):
"""get partial map"""
if temporary_area == JDXiSysExOffsetTemporaryToneUMB.DRUM_KIT.name:
return DRUM_PARTIAL_MAP
return SYNTH_PARTIAL_MAP
[docs]
def dispatch_sysex_to_area(self, json_sysex_data: str) -> None:
"""
Dispatch SysEx data to the appropriate area for processing.
:param json_sysex_data:
:return: None
"""
sysex_data = self._parse_sysex_json(json_sysex_data)
if not sysex_data:
return
sysex_data = self._normalize_program_common(sysex_data)
temporary_area = sysex_data.get(SysExSection.TEMPORARY_AREA)
synth_tone = sysex_data.get(SysExSection.SYNTH_TONE)
log.header_message(
scope=self.__class__.__name__,
message=f"Updating UI components from SysEx data for {temporary_area} {synth_tone}",
)
sysex_data = filter_sysex_keys(sysex_data)
successes, failures = [], []
partial_map = self._get_partial_map(temporary_area)
self._handle_sliders(sysex_data, temporary_area, successes, failures)
# ---- Partial routing ----
if synth_tone in partial_map:
pass
# ---- Common routing ----
elif synth_tone == JDXiSysExOffsetSuperNATURALLMB.COMMON.name:
partial_number = get_partial_number(
synth_tone,
partial_map=partial_map,
)
self._update_common_controls(
partial_number,
sysex_data,
successes,
failures,
temporary_area,
)
log.debug_info(successes, failures, scope=self.__class__.__name__)
[docs]
def _handle_sliders(
self,
sysex_data: dict,
temporary_area: str | None,
successes: list[Any],
failures: list[Any],
):
"""Slider handling"""
handler = self.TEMPORARY_AREA_HANDLERS.get(temporary_area)
if not handler:
return
for param_name, param_value in sysex_data.items():
if param_name in handler:
try:
param_resolver, slider_attr = handler.get(param_name)
param = (
param_resolver(param_name)
if callable(param_resolver)
else param_resolver
)
# Use slider_attr for lookup: digital1 and digital2 both use TONE_LEVEL,
# so controls.get(param) would always return digital2's slider
slider = self.get_mixer_slider(slider_attr)
self._update_slider(param, param_value, successes, failures, slider)
except Exception as ex:
log.error(
f"Error handling temporary area {ex}",
scope=self.__class__.__name__,
)
[docs]
def get_mixer_slider_by_param(
self, slider_param: AddressParameter
) -> QWidget | None:
"""get mixer slider"""
if self.mixer_widget:
slider = self.mixer_widget.controls[slider_param]
if slider is not None:
return slider if self.mixer_widget else None
return None
[docs]
def get_mixer_slider(self, slider_name: str) -> QWidget | None:
"""get mixer slider"""
if self.mixer_widget:
slider = getattr(self.mixer_widget, slider_name)
if slider is not None:
return slider if self.mixer_widget else None
return None
[docs]
def _get_partial_tone_names(self) -> list[str]:
"""get partial tone names"""
partial_tone_names = [
JDXiSysExOffsetSuperNATURALLMB.PARTIAL_1.name,
JDXiSysExOffsetSuperNATURALLMB.PARTIAL_2.name,
JDXiSysExOffsetSuperNATURALLMB.PARTIAL_3.name,
]
return partial_tone_names
[docs]
def _update_common_controls(
self,
partial_number: int,
sysex_data: Dict,
successes: list = None,
failures: list = None,
temporary_area: str = None,
) -> None:
"""
Update the UI components for tone common and modify parameters.
:param partial_number: int partial number
:param sysex_data: Dictionary containing SysEx data
:param successes: List of successful parameters
:param failures: List of failed parameters
:param temporary_area: str TEMPORARY_PROGRAM, DIGITAL_SYNTH_1, etc.
:return: None
"""
log.message(
f"Updating controls for partial {partial_number}",
scope=self.__class__.__name__,
)
log.parameter("self.controls", self.controls, scope=self.__class__.__name__)
for control in self.controls:
log.parameter(
"control", control, silent=False, scope=self.__class__.__name__
)
sysex_data.pop(SysExSection.SYNTH_TONE, None)
# Program Common (TEMPORARY_PROGRAM) uses ProgramCommonParam; Digital uses DigitalCommonParam
param_cls = (
ProgramCommonParam
if temporary_area == JDXiSysExAddressStartMSB.TEMPORARY_PROGRAM.name
else DigitalCommonParam
)
for param_name, param_value in sysex_data.items():
log.parameter(
f"{param_name} {param_value}",
param_value,
silent=True,
scope=self.__class__.__name__,
)
param = param_cls.get_by_name(param_name)
if not param:
log.parameter(
f"param not found: {param_name} ",
param_value,
silent=True,
scope=self.__class__.__name__,
)
failures.append(param_name)
continue
log.parameter(
f"found {param_name}",
param_name,
silent=True,
scope=self.__class__.__name__,
)
try:
if param.name in [
PartialSwitchState.PARTIAL1_SWITCH,
PartialSwitchState.PARTIAL2_SWITCH,
PartialSwitchState.PARTIAL3_SWITCH,
]:
pass
"""self._update_partial_selection_switch(
param, param_value, successes, failures
)"""
if param.name in [
PartialSelectState.PARTIAL1_SELECT,
PartialSelectState.PARTIAL2_SELECT,
PartialSelectState.PARTIAL3_SELECT,
]:
pass
"""self._update_partial_selected_state(
param, param_value, successes, failures
)"""
elif "SWITCH" in param_name:
self._update_switch(param, param_value, successes, failures)
else:
self._update_slider(param, param_value, successes, failures)
except Exception as ex:
log.error(f"Error {ex} occurred")
[docs]
def _update_slider(
self,
param: AddressParameter,
midi_value: int,
successes: list = None,
failures: list = None,
slider: QSlider = None,
) -> None:
"""
Update slider based on parameter and value.
:param param: AddressParameter
:param midi_value: int value
:param successes: list
:param failures: list
:return: None
"""
if slider is None:
slider = self.controls.get(param)
if slider:
slider.blockSignals(True)
slider.setValue(midi_value)
slider.blockSignals(False)
successes.append(param.name)
else:
failures.append(param.name)