"""
Synth Control Base Module
This module defines the `SynthControlBase` class, a Qt-based widget that provides MIDI
control functionality for synthesizer parameters in the JD-Xi editor.
It facilitates:
- Sending and receiving MIDI SysEx messages.
- Handling parameter updates through UI elements (sliders, combo boxes, spin boxes, switches).
- Managing MIDI helper instances for communication.
Dependencies:
- PySide6 for GUI components.
- jdxi_editor.midi for MIDI communication.
- jdxi_editor.ui.widgets for UI elements.
Classes:
- SynthControlBase: A base widget for controlling synth parameters via MIDI.
"""
import threading
from typing import Dict, Optional
import mido
from decologr import Decologr as log
from picomidi.sysex.parameter.address import AddressParameter
from PySide6.QtWidgets import QWidget
from jdxi_editor.jdxi.synth.factory import create_synth_data
from jdxi_editor.jdxi.synth.type import JDXiSynth
from jdxi_editor.log.slider_parameter import log_slider_parameters
from jdxi_editor.midi.data.address.address import RolandSysExAddress
from jdxi_editor.midi.io.delay import send_with_delay
from jdxi_editor.midi.io.helper import MidiIOHelper
from jdxi_editor.midi.sysex.composer import JDXiSysExComposer
from jdxi_editor.ui.widgets.combo_box.combo_box import ComboBox
from jdxi_editor.ui.widgets.slider import Slider
from jdxi_editor.ui.widgets.spin_box.spin_box import SpinBox
from jdxi_editor.ui.widgets.switch.switch import Switch
from jdxi_editor.ui.windows.patch.name_editor import PatchNameEditor
[docs]
class SynthBase(QWidget):
"""base class for all synth editors"""
def __init__(
self, midi_helper: Optional[MidiIOHelper] = None, parent: QWidget = None
):
"""
Initialize the SynthBase editor with MIDI helper and parent widget.
:param midi_helper: Optional[MidiIOHelper] instance for MIDI communication
:param parent: QWidget Parent widget for this editor
"""
super().__init__(parent)
[docs]
self.preset_type = None
# Store all Tone/Preset names for access by Digital Displays
[docs]
self.tone_names = {
JDXiSynth.DIGITAL_SYNTH_1: "",
JDXiSynth.DIGITAL_SYNTH_2: "",
JDXiSynth.ANALOG_SYNTH: "",
JDXiSynth.DRUM_KIT: "",
}
[docs]
self.partial_editors = {}
[docs]
self.partial_number = None
[docs]
self.bipolar_parameters = []
[docs]
self.controls: Dict[AddressParameter, QWidget] = {}
[docs]
self._midi_helper = midi_helper
[docs]
self.midi_requests = []
[docs]
self.sysex_composer = JDXiSysExComposer()
@property
[docs]
def midi_helper(self) -> MidiIOHelper:
return self._midi_helper
@midi_helper.setter
def midi_helper(self, helper: MidiIOHelper) -> None:
"""
Set the MIDI helper for sending and receiving MIDI messages.
:param helper: MidiIOHelper instance to use for MIDI communication
:return: None
"""
self._midi_helper = helper
[docs]
def send_raw_message(self, message: bytes) -> bool:
"""
Send a raw MIDI message using the MIDI helper.
:param message: bytes MIDI message to send
:return: bool True on success, False otherwise
"""
if not self._midi_helper:
log.message("MIDI helper not initialized")
return False
return self._midi_helper.send_raw_message(message)
[docs]
def edit_tone_name(self):
"""
edit_tone_name
:return: None
"""
tone_name = self.tone_names[self.preset_type]
tone_name_dialog = PatchNameEditor(current_name=tone_name)
if hasattr(self, "partial_parameters"):
parameter_cls = self.synth_data.partial_parameters
else:
parameter_cls = self.synth_data.common_parameters
if tone_name_dialog.exec(): # If the user clicks Save
sysex_string = tone_name_dialog.get_sysex_string()
log.message(f"SysEx string: {sysex_string}")
self.send_tone_name(parameter_cls, sysex_string)
self.data_request()
[docs]
def data_request(self, channel=None, program=None):
"""
Request the current value of the NRPN parameter from the device.
:param channel: int MIDI channel to send the request on (discarded)
:param program: int Program number to request data for (discarded)
"""
threading.Thread(
target=send_with_delay,
args=(
self._midi_helper,
self.midi_requests,
),
).start()
[docs]
def _on_midi_message_received(self, message: mido.Message) -> None:
"""
Handle incoming MIDI messages
:param message: mido.Message MIDI message received
:return: None
"""
if not message.type == "clock":
log.message(f"MIDI message: {message}")
self.blockSignals(True)
self.data_request()
self.blockSignals(False)
[docs]
def send_tone_name(self, parameter_cls: AddressParameter, tone_name: str) -> None:
"""
send_tone_name
:param tone_name: str Name of the Tone/preset
:param parameter_cls: AddressParameter
Send the characters of the tone name to SysEx parameters.
"""
# Ensure the tone name is exactly 12 characters (pad with spaces if shorter)
tone_name = tone_name.ljust(12)[:12]
# Iterate over characters and send them to corresponding parameters
for i, char in enumerate(tone_name):
ascii_value = ord(char)
param = getattr(parameter_cls, f"TONE_NAME_{i + 1}")
self.send_midi_parameter(param, ascii_value)
[docs]
def send_midi_parameter(
self, param: AddressParameter, value: int, address: RolandSysExAddress = None
) -> bool:
"""
Send MIDI parameter with error handling
:param address: RolandSysExAddress
:param param: AddressParameter the parameter to send
:param value: int value to send
:return: bool True on success, False otherwise
"""
if not address:
address = self.address
try:
sysex_message = self.sysex_composer.compose_message(
address=address, param=param, value=value
)
result = self._midi_helper.send_midi_message(sysex_message)
return bool(result)
except Exception as ex:
log.error(f"MIDI error setting {param.name}: {ex}")
return False
[docs]
def get_controls_as_dict(self):
"""
Get the current values of self.controls as a dictionary.
:returns: dict A dictionary of control parameter names and their values.
"""
try:
controls_data = {}
for param, widget in self.controls.items():
# Get value from widget - all custom widgets have a value() method
# (Slider, ComboBox, SpinBox, Switch all implement value())
if hasattr(widget, "value"):
controls_data[param.name] = widget.value()
elif hasattr(widget, "isChecked") and hasattr(widget, "waveform"):
# Handle waveform buttons (AnalogWaveformButton, etc.)
# Check if this button is checked, and if so, use its waveform value
if widget.isChecked():
controls_data[param.name] = widget.waveform.STATUS
# If not checked, don't add it - the checked button will be found by the editor's override
elif hasattr(widget, "isChecked"):
# QPushButton or other checkable widgets
controls_data[param.name] = 1 if widget.isChecked() else 0
else:
# Fallback for unexpected widget types
log.warning(
f"Widget for {param.name} has no value() method: {type(widget)}"
)
controls_data[param.name] = 0
return controls_data
except Exception as ex:
log.error(f"Failed to get controls: {ex}")
return {}
[docs]
def _on_parameter_changed(
self,
param: AddressParameter,
display_value: int,
address: RolandSysExAddress = None,
) -> None:
"""
Handle parameter change event, convert display value to MIDI value,
:param param: AddressParameter Parameter that was changed
:param display_value: int Display value from the UI control
:return: None
"""
try:
# Send MIDI message
if not address:
address = self.address
if not self.send_midi_parameter(param, display_value, address):
log.message(f"Failed to send parameter {param.name}")
except Exception as ex:
log.error(f"Error handling parameter {param.name}: {ex}")
[docs]
def _create_parameter_slider(
self,
param: AddressParameter,
label: str,
vertical: bool = False,
initial_value: Optional[int] = 0,
address: RolandSysExAddress = None,
show_value_label: bool = True,
) -> Slider:
"""
Create a slider for an address parameter with proper display conversion.
:param param: AddressParameter Parameter to create slider for
:param label: str label for the slider
:param initial_value: int initial value for the slider
:param vertical: bool whether the slider is vertical
:param address: RolandSysExAddress
:param show_value_label: str whether to show the value label
:return: Slider
"""
if hasattr(param, "get_display_value"):
display_min, display_max = param.get_display_value()
else:
display_min, display_max = param.min_val, param.max_val
if hasattr(param, "get_tooltip"):
tooltip = param.get_tooltip()
else:
tooltip = f"{param.name} ({display_min} to {display_max})"
slider = Slider(
label,
min_value=display_min,
max_value=display_max,
midi_helper=self.midi_helper,
vertical=vertical,
show_value_label=show_value_label,
is_bipolar=param.is_bipolar,
tooltip=tooltip,
)
if not address:
address = self.address
if param.name in self.bipolar_parameters or param.is_bipolar:
slider.setValueDisplayFormat(lambda v: f"{v:+d}" if v != 0 else "0")
slider.setCenterMark(0)
slider.setTickPosition(Slider.TickPosition.TicksBothSides)
slider.setTickInterval((display_max - display_min) // 4)
slider.valueChanged.connect(
lambda v: self._on_parameter_changed(param, v, address)
)
self.controls[param] = slider
return slider
[docs]
def _create_parameter_combo_box(
self,
param: AddressParameter,
label: str = None,
options: list = None,
values: list = None,
show_label: bool = True,
) -> ComboBox:
"""
Create a combo box for an address parameter with options and values.
:param param: AddressParameter
:param label: str label for the combo box
:param options: list of options to display in the combo box
:param values: list of values corresponding to the options
:param show_label: bool whether to show the label
:return: ComboBox
"""
if hasattr(param, "get_display_value"):
display_min, display_max = param.get_display_value()
else:
display_min, display_max = param.min_val, param.max_val
if hasattr(param, "get_tooltip"):
tooltip = param.get_tooltip()
else:
tooltip = f"{param.name} ({display_min} to {display_max})"
combo_box = ComboBox(
label=label,
options=options,
values=values,
show_label=show_label,
tooltip=tooltip,
)
combo_box.valueChanged.connect(lambda v: self._on_parameter_changed(param, v))
self.controls[param] = combo_box
return combo_box
[docs]
def _create_parameter_spin_box(
self, param: AddressParameter, label: str = None
) -> SpinBox:
"""
Create address spin box for address parameter with proper display conversion
:param param: AddressParameter Parameter to create spin box for
:param label: str label for the spin box
:return: SpinBox
"""
if hasattr(param, "get_display_value"):
display_min, display_max = param.get_display_value()
else:
display_min, display_max = param.min_val, param.max_val
if hasattr(param, "get_tooltip"):
tooltip = param.get_tooltip()
else:
tooltip = f"{param.name} ({display_min} to {display_max})"
spin_box = SpinBox(
label=label, low=display_min, high=display_max, tooltip=tooltip
)
# Connect value changed signal
spin_box.valueChanged.connect(lambda v: self._on_parameter_changed(param, v))
# Store control reference
self.controls[param] = spin_box
return spin_box
[docs]
def _create_parameter_switch(
self, param: AddressParameter, label: str, values: list[str]
) -> Switch:
"""
Create a switch for an address parameter with specified label and values.
:param param: AddressParameter Parameter to create switch for
:param label: str label for the switch
:param values: list of values for the switch
:return: Switch
"""
if hasattr(param, "get_display_value"):
display_min, display_max = param.get_display_value()
else:
display_min, display_max = param.min_val, param.max_val
if hasattr(param, "get_tooltip"):
tooltip = param.get_tooltip()
else:
tooltip = f"{param.name} ({display_min} to {display_max})"
switch = Switch(label=label, values=values, tooltip=tooltip)
switch.valueChanged.connect(lambda v: self._on_parameter_changed(param, v))
self.controls[param] = switch
return switch
[docs]
def _init_synth_data(
self,
synth_type: JDXiSynth = JDXiSynth.DIGITAL_SYNTH_1,
partial_number: Optional[int] = 0,
):
"""Initialize synth-specific data."""
from jdxi_editor.jdxi.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",
"common_parameters",
"partial_parameters",
]:
if hasattr(self.synth_data, attr):
setattr(self, attr, getattr(self.synth_data, attr))
[docs]
def _update_slider(
self,
param: AddressParameter,
midi_value: int,
successes: list = None,
failures: list = None,
slider: QWidget = 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:
if hasattr(param, "convert_from_midi"):
slider_value = param.convert_from_midi(midi_value)
else:
slider_value = midi_value
log_slider_parameters(self.address, param, midi_value, slider_value)
slider.blockSignals(True)
slider.setValue(midi_value)
slider.blockSignals(False)
successes.append(param.name)
else:
failures.append(param.name)
[docs]
def _update_switch(
self,
param: AddressParameter,
midi_value: int,
successes: list = None,
failures: list = None,
) -> None:
"""
Update switch based on parameter and value.
:param param: AddressParameter
:param midi_value: int value
:param successes: list
:param failures: list
:return: None
"""
if not midi_value:
return
switch = self.controls.get(param)
try:
midi_value = int(midi_value)
if switch:
switch.blockSignals(True)
switch.setValue(midi_value)
switch.blockSignals(False)
successes.append(param.name)
log.parameter(f"Updated {midi_value} for", param)
else:
failures.append(param.name)
except Exception as ex:
log.error(
f"Error {ex} occurred setting switch {param.name} to {midi_value}"
)
failures.append(param.name)
[docs]
def _update_partial_slider(
self,
partial_no: int,
param: AddressParameter,
value: int,
successes: list = None,
failures: list = None,
) -> None:
"""
Update the slider for a specific partial based on the parameter and value.
:param partial_no: int
:param param: AddressParameter
:param value: int
:param successes: list list of successful updates
:param failures: list list of failed updates
:return: None
"""
if not value:
return
slider = self.partial_editors[partial_no].controls.get(param)
if not slider:
failures.append(param.name)
return
synth_data = create_synth_data(self.synth_data.preset_type, partial_no)
self.address.lmb = synth_data.lmb
slider_value = param.convert_from_midi(value)
log_slider_parameters(self.address, param, value, slider_value)
slider.blockSignals(True)
slider.setValue(slider_value)
slider.blockSignals(False)
successes.append(param.name)