Source code for jdxi_editor.ui.windows.midi.config_dialog

"""
config_dialog module
====================

MIDIConfigDialog is a dialog class that allows users to configure MIDI input and output ports.

It provides the following functionality:
- Display available MIDI input and output ports in combo boxes.
- Allow users to select and change MIDI input and output ports.
- Refresh the list of available MIDI ports.
- Retrieve the selected MIDI port settings.

Attributes:
    input_ports (list): List of available MIDI input ports.
    output_ports (list): List of available MIDI output ports.
    current_in (str): Currently selected MIDI input port (optional).
    current_out (str): Currently selected MIDI output port (optional).
    midi_helper (MidiIOHelper): Instance of the MIDIHelper class to interact with MIDI devices.

Methods:
    refresh_ports() : Refresh the list of available MIDI ports.
    get_input_port() : Returns the currently selected MIDI input port.
    get_output_port() : Returns the currently selected MIDI output port.
    get_settings() : Returns a dictionary containing the selected MIDI input and output ports.

"""

import os

import qtawesome as qta
from decologr import Decologr as log
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
    QCheckBox,
    QComboBox,
    QDialog,
    QDialogButtonBox,
    QFileDialog,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QLineEdit,
    QPushButton,
    QVBoxLayout,
)

from jdxi_editor.jdxi.style import JDXiStyle
from jdxi_editor.midi.io.helper import MidiIOHelper

# In-app FluidSynth defaults
[docs] HW_PORT_HINT = "Roland JDXi" # adjust if your port name differs
[docs] SF2_PATH = os.path.expanduser("~/SoundFonts/FluidR3_GM.sf2")
[docs] class MIDIConfigDialog(QDialog): def __init__(self, midi_helper=MidiIOHelper, parent=None): super().__init__(parent) self.setWindowTitle("MIDI Configuration") self.setMinimumSize(300, 300) self.setStyleSheet(JDXiStyle.EDITOR)
[docs] self.midi_helper = midi_helper
[docs] self.input_ports = midi_helper.get_input_ports()
[docs] self.output_ports = midi_helper.get_output_ports()
[docs] self.current_in = midi_helper.current_in
[docs] self.current_out = midi_helper.current_out
# FluidSynth runtime state (optional)
[docs] self.fs = None
[docs] self.sfid = None
[docs] self.sf2_path = ""
self._create_ui() # Prefill default SoundFont path if present try: if os.path.isfile(SF2_PATH): self.sf2_edit.setText(SF2_PATH) self.sf2_path = SF2_PATH except Exception: pass # Default to enabling in-app synth option self.fluidsynth_enable.setChecked(True) # Populate soundfont combo and sync with field self._populate_sf2_combo() if self.sf2_edit.text().strip(): self._select_sf2_in_combo(self.sf2_edit.text().strip()) self.setWindowFlag(Qt.WindowStaysOnTopHint, True)
[docs] def showEvent(self, event): super().showEvent(event) QTimer.singleShot(0, self._bring_to_front)
[docs] def _bring_to_front(self): self.raise_() self.activateWindow() self.setFocus()
[docs] def _create_ui(self): """Create the dialog UI""" layout = QVBoxLayout(self) # Input port selection input_group = QGroupBox("MIDI Input") input_layout = QVBoxLayout(input_group) icons_hlayout = QHBoxLayout() for icon in ["mdi6.midi-port"]: icon_label = QLabel() icon = qta.icon(icon, color=JDXiStyle.FOREGROUND) pixmap = icon.pixmap( JDXiStyle.ICON_SIZE, JDXiStyle.ICON_SIZE ) # Set the desired size icon_label.setPixmap(pixmap) icon_label.setAlignment(Qt.AlignHCenter) icons_hlayout.addWidget(icon_label) input_layout.addLayout(icons_hlayout) self.input_combo = QComboBox() self.input_combo.addItems(self.input_ports) if self.current_in and self.current_in in self.input_ports: self.input_combo.setCurrentText(self.current_in) input_layout.addWidget(self.input_combo) layout.addWidget(input_group) # Output port selection output_group = QGroupBox("MIDI Output") output_layout = QVBoxLayout(output_group) icons_hlayout = QHBoxLayout() for icon in ["mdi6.midi-port"]: icon_label = QLabel() icon = qta.icon(icon, color=JDXiStyle.FOREGROUND) pixmap = icon.pixmap( JDXiStyle.ICON_SIZE, JDXiStyle.ICON_SIZE ) # Set the desired size icon_label.setPixmap(pixmap) icon_label.setAlignment(Qt.AlignHCenter) icons_hlayout.addWidget(icon_label) output_layout.addLayout(icons_hlayout) self.output_combo = QComboBox() self.output_combo.addItems(self.output_ports) if self.current_out and self.current_out in self.output_ports: self.output_combo.setCurrentText(self.current_out) output_layout.addWidget(self.output_combo) layout.addWidget(output_group) # Software Synth (FluidSynth) synth_group = QGroupBox("Software Synth (FluidSynth)") synth_layout = QVBoxLayout(synth_group) self.fluidsynth_enable = QCheckBox("Enable FluidSynth for local playback") self.fluidsynth_enable.toggled.connect(self._toggle_fluidsynth_controls) synth_layout.addWidget(self.fluidsynth_enable) sf_row = QHBoxLayout() sf_row.addWidget(QLabel("SoundFont (SF2/SF3):")) self.sf2_edit = QLineEdit() self.sf2_edit.setPlaceholderText("FluidR3_GM.sf2") # default SoundFont sf_row.addWidget(self.sf2_edit) browse_btn = QPushButton("Browse…") browse_btn.clicked.connect(self._browse_sf2) sf_row.addWidget(browse_btn) synth_layout.addLayout(sf_row) # Available SoundFonts selector combo_row = QHBoxLayout() combo_row.addWidget(QLabel("Available:")) self.sf2_combo = QComboBox() self.sf2_combo.currentIndexChanged.connect(self._on_sf2_combo_changed) combo_row.addWidget(self.sf2_combo) synth_layout.addLayout(combo_row) btn_row = QHBoxLayout() self.fs_start_btn = QPushButton("Start") self.fs_start_btn.clicked.connect(self._start_fluidsynth) self.fs_stop_btn = QPushButton("Stop") self.fs_stop_btn.clicked.connect(self._stop_fluidsynth) self.fs_test_btn = QPushButton("Test Note") self.fs_test_btn.clicked.connect(self._test_fluidsynth) btn_row.addWidget(self.fs_start_btn) btn_row.addWidget(self.fs_stop_btn) btn_row.addWidget(self.fs_test_btn) synth_layout.addLayout(btn_row) self.fs_status = QLabel("") synth_layout.addWidget(self.fs_status) layout.addWidget(synth_group) # Initially disable subordinate controls self._toggle_fluidsynth_controls(False) # Refresh button refresh_button = QPushButton("Refresh Ports") refresh_button.clicked.connect(self.refresh_ports) layout.addWidget(refresh_button) # Buttons buttons = QDialogButtonBox( QDialogButtonBox.Ok | QDialogButtonBox.Cancel, Qt.Horizontal ) buttons.accepted.connect(self.accept) buttons.rejected.connect(self.reject) layout.addWidget(buttons)
[docs] def refresh_ports(self): """Refresh the list of MIDI ports""" # Use the MIDIHelper instance to get the updated port lists self.input_ports = self.midi_helper.get_input_ports() self.output_ports = self.midi_helper.get_output_ports() # Update the combo boxes self.input_combo.clear() self.input_combo.addItems(self.input_ports) self.output_combo.clear() self.output_combo.addItems(self.output_ports)
[docs] def _toggle_fluidsynth_controls(self, enabled: bool) -> None: controls_enabled = bool(self.fluidsynth_enable.isChecked()) for w in [self.sf2_edit, self.fs_start_btn, self.fs_stop_btn, self.fs_test_btn]: w.setEnabled(controls_enabled) if not controls_enabled: self.fs_status.setText("") else: # Auto-start if a valid SoundFont is already set and synth not running try: if ( self.fs is None and self.sf2_edit.text().strip() and os.path.isfile(self.sf2_edit.text().strip()) ): self._start_fluidsynth() except Exception: pass
[docs] def _browse_sf2(self) -> None: start_dir = os.path.expanduser("~/SoundFonts") file_path, _ = QFileDialog.getOpenFileName( self, "Select SoundFont", start_dir if os.path.isdir(start_dir) else "", "SoundFonts (*.sf2 *.sf3)", ) if file_path: self.sf2_edit.setText(file_path) self.sf2_path = file_path # Add to combo if missing and select self._ensure_sf2_in_combo(file_path) self._select_sf2_in_combo(file_path)
[docs] def _start_fluidsynth(self) -> None: try: from fluidsynth import Synth except Exception as ex: self.fs_status.setText("FluidSynth not installed: pip install pyfluidsynth") log.warning(f"FluidSynth import failed: {ex}") return try: sf_path = self.sf2_edit.text().strip() if not sf_path: self.fs_status.setText("Please select a SoundFont first.") return if self.fs is None: self.fs = Synth() self.fs.start(driver="coreaudio") # macOS self.sfid = self.fs.sfload(sf_path) self.fs.program_select(0, self.sfid, 0, 0) self.fs_status.setText("FluidSynth: started") except Exception as ex: self.fs_status.setText(f"FluidSynth error: {ex}") log.error(f"FluidSynth error: {ex}")
[docs] def _stop_fluidsynth(self) -> None: try: if self.fs is not None: self.fs.delete() self.fs = None self.fs_status.setText("FluidSynth: stopped") except Exception as ex: self.fs_status.setText(f"Stop error: {ex}") log.error(f"FluidSynth stop error: {ex}")
[docs] def _test_fluidsynth(self) -> None: try: if self.fs is None: self.fs_status.setText("Start FluidSynth first.") return # Middle C test self.fs.noteon(0, 60, 110) self.fs.noteoff(0, 60) self.fs_status.setText("Test note triggered.") except Exception as ex: self.fs_status.setText(f"Test error: {ex}") log.error(f"FluidSynth test error: {ex}")
[docs] def _populate_sf2_combo(self) -> None: """Scan ~/SoundFonts for .sf2/.sf3 files and populate the combo box.""" if not hasattr(self, "sf2_combo"): return self.sf2_combo.blockSignals(True) self.sf2_combo.clear() base_dir = os.path.expanduser("~/SoundFonts") found = [] try: if os.path.isdir(base_dir): for root, dirs, files in os.walk(base_dir): for f in files: if f.lower().endswith((".sf2", ".sf3")): full = os.path.join(root, f) found.append(full) except Exception: pass # Add items (display basename, store full path) for path in sorted(found): self.sf2_combo.addItem(os.path.basename(path), path) self.sf2_combo.blockSignals(False)
[docs] def _on_sf2_combo_changed(self, index: int) -> None: if index < 0: return path = self.sf2_combo.currentData() if isinstance(path, str) and path: self.sf2_edit.setText(path) self.sf2_path = path
[docs] def _ensure_sf2_in_combo(self, path: str) -> None: if not hasattr(self, "sf2_combo"): return # If already present, do nothing for i in range(self.sf2_combo.count()): if self.sf2_combo.itemData(i) == path: return self.sf2_combo.addItem(os.path.basename(path), path)
[docs] def _select_sf2_in_combo(self, path: str) -> None: if not hasattr(self, "sf2_combo"): return for i in range(self.sf2_combo.count()): if self.sf2_combo.itemData(i) == path: self.sf2_combo.setCurrentIndex(i) return
[docs] def accept(self): super().accept() self.midi_helper.close_ports() input_port_text = self.get_input_port() output_port_text = self.get_output_port() log.message(f"Reconnecting to: Midi In:\t'{input_port_text}'") log.message(f"Reconnecting to: Midi Out:\t'{output_port_text}'") success = self.midi_helper.reconnect_port_names( input_port_text, output_port_text ) if not success: log.warning("Failed to reopen both MIDI ports")
[docs] def get_input_port(self) -> str: """Get selected input port name Returns: Selected input port name or empty string if none selected """ return self.input_combo.currentText()
[docs] def get_output_port(self) -> str: """Get selected output port name Returns: Selected output port name or empty string if none selected """ return self.output_combo.currentText()
[docs] def get_settings(self) -> dict: """Get all selected settings Returns: Dictionary containing input_port and output_port selections """ return { "input_port": self.get_input_port(), "output_port": self.get_output_port(), }