"""
Module: analog_synth_editor
===========================
This module defines the `AnalogSynthEditor` class, which provides a PySide6-based
user interface for editing analog synthesizer parameters in the Roland JD-Xi synthesizer.
It extends the `SynthEditor` base class and integrates MIDI communication for real-time
parameter adjustments and preset management.
Key Features:
-------------
- Provides a graphical editor for modifying analog synth parameters, including
oscillator, filter, amp, LFO, and envelope settings.
- Supports MIDI communication to send and receive real-time parameter changes.
- Allows selection of different analog synth presets from a dropdown menu.
- Displays an instrument image that updates based on the selected preset.
- Includes a scrollable layout for managing a variety of parameter controls.
- Implements bipolar parameter handling for proper UI representation.
- Supports waveform selection with custom buttons and icons.
- Provides a "Send Read Request to Synth" button to retrieve current synth settings.
- Enables MIDI-triggered updates via incoming program changes and parameter adjustments.
Dependencies:
-------------
- PySide6 (for UI components and event handling)
- MIDIHelper (for handling MIDI communication)
- PresetHandler (for managing synth presets)
- Various custom enums and helper classes (Analog.Parameter, AnalogCommonParameter, etc.)
Usage:
------
The `AnalogSynthEditor` class can be instantiated as part of a larger PySide6 application.
It requires a `MIDIHelper` instance for proper communication with the synthesizer.
Example:
--------
midi_helper = MIDIHelper()
preset_helper = PresetHandler()
editor = AnalogSynthEditor(midi_helper, preset_helper)
editor.show()
"""
import logging
from typing import TYPE_CHECKING, Literal, Optional
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QGroupBox,
QScrollArea,
QSlider,
QVBoxLayout,
QWidget,
)
from picomidi.sysex.parameter.address import AddressParameter
from jdxi_editor.midi.control_change.parameter import CCParameter
from jdxi_editor.midi.data.base.oscillator import OscillatorWidgetTypes
from jdxi_editor.midi.data.parameter.analog.address import AnalogParam
from jdxi_editor.midi.data.parameter.analog.spec import JDXiMidiAnalog as Analog
from jdxi_editor.midi.nrpn.parameter import NRPNParameter
from jdxi_editor.ui.preset.widget import InstrumentPresetWidget
if TYPE_CHECKING:
from jdxi_editor.ui.preset.helper import JDXiPresetHelper
from dataclasses import dataclass
from typing import Callable
from decologr import Decologr as log
from jdxi_editor.core.jdxi import JDXi
from jdxi_editor.log.slider_parameter import log_slider_parameters
from jdxi_editor.midi.data.analog.oscillator import AnalogWaveOsc
from jdxi_editor.midi.io.helper import MidiIOHelper
from jdxi_editor.ui.editors.analog.amp.section import AnalogAmpSection
from jdxi_editor.ui.editors.analog.filter.section import AnalogFilterSection
from jdxi_editor.ui.editors.analog.oscillator.section import AnalogOscillatorSection
from jdxi_editor.ui.editors.synth.editor import SynthEditor
from jdxi_editor.ui.editors.synth.helper import log_changes
from jdxi_editor.ui.widgets.editor.base import EditorBaseWidget
from picomidi.utils.conversion import (
midi_value_to_fraction,
midi_value_to_ms,
)
@dataclass
[docs]
class ControlBinding:
[docs]
getter: Callable[[], object] # returns the widget
[docs]
setter: Callable[[object, int], None] # how to apply value
[docs]
class BaseSynthEditor(SynthEditor):
"""Base Synth Editor UI."""
def __init__(
self,
midi_helper: Optional[MidiIOHelper] = None,
preset_helper: Optional["JDXiPresetHelper"] = None, # type: ignore[name-defined]
parent: Optional["QWidget"] = None, # type: ignore[name-defined]
):
"""
Initialize the AnalogSynthEditor
:param midi_helper: MidiIOHelper
:param preset_helper: JDXIPresetHelper
"""
super().__init__(midi_helper=midi_helper)
[docs]
self.pitch_env_mapping = {}
[docs]
self.instrument_image_group: QGroupBox | None = None
[docs]
self.instrument_preset_group: QGroupBox | None = None
[docs]
self.instrument_preset: QWidget | None = None
[docs]
self.amp_section: AnalogAmpSection | None = None
[docs]
self.oscillator_section: AnalogOscillatorSection | None = None
[docs]
self.filter_section: AnalogFilterSection | None = None
[docs]
self.lfo_section = None
[docs]
self.preset_helper = preset_helper
[docs]
self.updating_from_spinbox = False
[docs]
self.previous_json_data = None
[docs]
self.main_window = parent
[docs]
self.group_handlers = [
(self._get_adsr_params(), self._handle_adsr),
(self.pitch_env_mapping.keys(), self._handle_pitch_env),
]
[docs]
self.param_handlers = self._build_param_handlers()
# --- Initialize mappings as empty dicts/lists early to prevent AttributeError
# --- These will be populated after sections are created
self.pitch_env_mapping = {}
if self.midi_helper:
self.midi_helper.midi_program_changed.connect(self._handle_program_change)
self.midi_helper.midi_sysex_json.connect(self.dispatch_sysex_to_area)
log.message(scope="BaseSynthEditor", message="MIDI signals connected")
else:
log.message(scope="BaseSynthEditor", message="MIDI signals not connected")
[docs]
self.refresh_shortcut = QShortcut(QKeySequence.StandardKey.Refresh, self)
self.refresh_shortcut.activated.connect(self.data_request)
[docs]
def _set_value(self, widget, value) -> None:
widget.blockSignals(True)
widget.setValue(int(value))
widget.blockSignals(False)
[docs]
def setup_ui(self):
"""Set up the Analog Synth Editor UI."""
self.set_dimensions()
self.set_style()
# --- Use EditorBaseWidget for consistent layout structure (harmonized with Digital)
self.base_widget = EditorBaseWidget(parent=self, analog=self.analog)
self.base_widget.setup_scrollable_content(spacing=5, margins=(5, 5, 5, 5))
# --- Add base widget to editor's layout (if editor has a layout)
if not hasattr(self, "main_layout") or self.main_layout is None:
self.main_layout = QVBoxLayout(self)
self.setLayout(self.main_layout)
self.main_layout.addWidget(self.base_widget)
# --- Store references for backward compatibility
self.scroll = self.base_widget.get_scroll_area()
# --- Set up instrument preset widget
self.instrument_preset = InstrumentPresetWidget(parent=self)
self.instrument_preset.setup_header_layout()
self.instrument_preset.setup()
self.instrument_preset_group = (
self.instrument_preset.create_instrument_preset_group()
)
self.instrument_preset.add_preset_group(self.instrument_preset_group)
self.instrument_preset.add_stretch()
(
self.instrument_image_group,
self.instrument_image_label,
self.instrument_group_layout,
) = self.instrument_preset.create_instrument_image_group()
self.instrument_preset.add_image_group(self.instrument_image_group)
self.instrument_preset.add_stretch()
self.update_instrument_image()
self.build_widgets()
[docs]
def set_style(self):
"""Set style"""
JDXi.UI.Theme.apply_tabs_style(self, analog=self.analog)
JDXi.UI.Theme.apply_editor_style(self, analog=self.analog)
if not self.analog:
JDXi.UI.Theme.apply_tabs_style(self.tab_widget)
JDXi.UI.Theme.apply_editor_style(self.tab_widget)
[docs]
def set_dimensions(self):
"""set dimensions"""
if self.analog:
self.setMinimumSize(
JDXi.UI.Dimensions.EDITOR_ANALOG.MIN_WIDTH,
JDXi.UI.Dimensions.EDITOR_ANALOG.MIN_HEIGHT,
)
self.resize(
JDXi.UI.Dimensions.EDITOR_ANALOG.WIDTH,
JDXi.UI.Dimensions.EDITOR_ANALOG.HEIGHT,
)
else:
self.setMinimumSize(
JDXi.UI.Dimensions.EDITOR_DIGITAL.MIN_WIDTH,
JDXi.UI.Dimensions.EDITOR_DIGITAL.MIN_HEIGHT,
)
self.resize(
JDXi.UI.Dimensions.EDITOR_DIGITAL.INIT_WIDTH,
JDXi.UI.Dimensions.EDITOR_DIGITAL.INIT_HEIGHT,
)
[docs]
def add_tabs(self):
"""Add tabs to tab widget. Only adds a tab when the section exists."""
self._add_tab(key=self.SYNTH_SPEC.Tab.PRESETS, widget=self.instrument_preset)
if getattr(self, "oscillator_section", None) is not None:
self._add_tab(
key=self.SYNTH_SPEC.Tab.OSCILLATOR, widget=self.oscillator_section
)
if getattr(self, "filter_section", None) is not None:
self._add_tab(key=self.SYNTH_SPEC.Tab.FILTER, widget=self.filter_section)
if getattr(self, "amp_section", None) is not None:
self._add_tab(key=self.SYNTH_SPEC.Tab.AMP, widget=self.amp_section)
if getattr(self, "lfo_section", None) is not None:
self._add_tab(key=self.SYNTH_SPEC.Tab.LFO, widget=self.lfo_section)
if getattr(self, "common_section", None) is not None:
self._add_tab(key=self.SYNTH_SPEC.Tab.COMMON, widget=self.common_section)
[docs]
def _init_parameter_mappings(self):
"""Initialize MIDI parameter mappings."""
self.cc_parameters = {
CCParameter.CUTOFF: self.SYNTH_SPEC.ControlChange.CUTOFF,
CCParameter.RESONANCE: self.SYNTH_SPEC.ControlChange.RESONANCE,
CCParameter.LEVEL: self.SYNTH_SPEC.ControlChange.LEVEL,
CCParameter.LFO_RATE: self.SYNTH_SPEC.ControlChange.LFO_RATE,
}
self.nrpn_parameters = {
NRPNParameter.ENVELOPE: self.SYNTH_SPEC.RPN.ENVELOPE.value.msb_lsb, # --- (0, 124),
NRPNParameter.LFO_SHAPE: self.SYNTH_SPEC.RPN.LFO_SHAPE.value.msb_lsb, # --- (0, 3),
NRPNParameter.LFO_PITCH_DEPTH: self.SYNTH_SPEC.RPN.LFO_PITCH_DEPTH.value.msb_lsb, # --- (0, 15),
NRPNParameter.LFO_FILTER_DEPTH: self.SYNTH_SPEC.RPN.LFO_FILTER_DEPTH.value.msb_lsb, # --- (0, 18),
NRPNParameter.LFO_AMP_DEPTH: self.SYNTH_SPEC.RPN.LFO_AMP_DEPTH.value.msb_lsb, # --- (0, 21),
NRPNParameter.PULSE_WIDTH: self.SYNTH_SPEC.RPN.PULSE_WIDTH.value.msb_lsb, # --- (0, 37),
}
# --- Reverse lookup map
self.nrpn_map = {v: k for k, v in self.nrpn_parameters.items()}
[docs]
def update_filter_controls_state(self, mode: int):
"""Update filter controls enabled state (delegate to section, same mechanism as Digital)."""
log.message(
scope="BaseSynthEditor",
message=f"update_filter_controls_state: mode={mode} "
f"has filter_section={hasattr(self, 'filter_section')} "
f"filter_section is not None={getattr(self, 'filter_section', None) is not None}",
)
if hasattr(self, "filter_section") and self.filter_section is not None:
self.filter_section.update_controls_state(mode)
else:
log.warning(
scope="BaseSynthEditor",
message="update_filter_controls_state: no filter_section, skipping",
)
[docs]
def _on_filter_mode_changed(self, mode: int):
"""Handle filter mode changes (callback from filter section when mode button clicked)."""
log.message(
scope="BaseSynthEditor", message=f"_on_filter_mode_changed: mode={mode}"
)
self.update_filter_controls_state(mode)
[docs]
def update_filter_state(self, value: int):
"""
Update the filter state
:param value: int value
:return: None
"""
self._update_filter_mode_buttons(value=value)
self.update_filter_controls_state(value)
[docs]
def get_controls_as_dict(self):
"""
Get the current values of self.controls as a dictionary.
Override to handle waveform buttons and filter mode buttons specially.
:returns: dict A dictionary of control parameter names and their values.
"""
# --- Get base controls
controls_data = super().get_controls_as_dict()
# --- Handle OSC_WAVEFORM specially - find which waveform button is checked
if self.SYNTH_SPEC.Param.OSC_WAVEFORM in self.controls:
# --- Check which waveform button is currently checked
for waveform, btn in self.wave_buttons.items():
if btn.isChecked():
controls_data[self.SYNTH_SPEC.Param.OSC_WAVEFORM.name] = (
waveform.value
)
break
# --- If no button is checked, use default (SAW = 0)
if self.SYNTH_SPEC.Param.OSC_WAVEFORM.name not in controls_data:
controls_data[self.SYNTH_SPEC.Param.OSC_WAVEFORM.name] = (
self.SYNTH_SPEC.Wave.Osc.SAW.value
)
# --- Handle FILTER_MODE_SWITCH specially - find which filter mode button is checked
if hasattr(self, "filter_section") and hasattr(
self.filter_section, "filter_mode_buttons"
):
# --- Check which filter mode button is currently checked
for filter_mode, btn in self.filter_section.filter_mode_buttons.items():
if btn.isChecked():
controls_data[self.SYNTH_SPEC.Param.FILTER_MODE_SWITCH.name] = (
filter_mode.value
)
break
# --- If no button is checked, use default (BYPASS = 0)
if self.SYNTH_SPEC.Param.FILTER_MODE_SWITCH.name not in controls_data:
controls_data[self.SYNTH_SPEC.Param.FILTER_MODE_SWITCH.name] = (
self.SYNTH_SPEC.Filter.FilterType.BYPASS.value
)
return controls_data
[docs]
def _on_lfo_shape_changed(self, value: int):
"""
Handle LFO shape change
:param value: int value
:return: None
"""
if self.midi_helper:
sysex_message = self.sysex_composer.compose_message(
address=self.address, param=self.SYNTH_SPEC.Param.LFO_SHAPE, value=value
)
self.midi_helper.send_midi_message(sysex_message)
# --- Reset all buttons to default style ---
for btn in self.lfo_shape_buttons.values():
btn.setChecked(False)
JDXi.UI.Theme.apply_button_rect(btn, analog=self.analog)
# --- Apply active style to the selected button (harmonised Theme API)
selected_btn = self.lfo_shape_buttons.get(value)
if selected_btn:
selected_btn.setChecked(True)
JDXi.UI.Theme.apply_button_active(selected_btn, analog=self.analog)
[docs]
def _handle_sliders(
self, param: AddressParameter, value: int, successes: list, failures: list
):
"""Handle sliders"""
self.update_slider(
param=param, midi_value=value, successes=successes, failures=failures
)
[docs]
def update_slider(
self,
param: AddressParameter,
midi_value: int,
successes: list = None,
failures: list = None,
) -> None:
"""
Helper function to update sliders safely.
:param param: AddressParameterAnalog value
:param failures: list of failed parameters
:param successes: list of successful parameters
:param midi_value: int value
:return: None
"""
slider = self.controls.get(param)
if slider:
slider_value = param.convert_from_midi(midi_value)
slider.blockSignals(True)
slider.setValue(slider_value)
slider.blockSignals(False)
successes.append(param.name)
log_slider_parameters(self.address, param, midi_value, slider_value)
else:
failures.append(param.name)
[docs]
def _update_controls(
self, partial_no: int, sysex_data: dict, successes: list, failures: list
) -> None:
"""
Update sliders and combo boxes based on parsed SysEx data.
:param sysex_data: dict SysEx data
:param successes: list SysEx data
:param failures: list SysEx data
:return: None
"""
log.message(scope="BaseSynthEditor", message="[_update_controls]")
# Log changes from the previous data and store the current state
self._log_and_store_sysex_data(sysex_data)
for param_name, param_value in sysex_data.items():
self._process_param_update(param_value, param_name, failures, successes)
[docs]
def _build_param_handlers(self):
"""Build param handlers"""
return {
"SUB_OSCILLATOR_TYPE": self._handle_suboscillator,
"OSC_WAVEFORM": self._handle_waveform,
"FILTER_MODE_SWITCH": self._handle_filter_mode_switch,
"LFO_SHAPE": self._handle_lfo_shape,
"LFO_TEMPO_SYNC_SWITCH": self._handle_lfo_tempo_sync_switch,
"LFO_TEMPO_SYNC_NOTE": self._handle_lfo_tempo_sync_note,
}
[docs]
def _process_param_update(
self, param_value, param_name, failures: list, successes: list
):
"""
Process updates for a single parameter.
:param param_name: The name of the parameter
:param param_value: The value of the parameter
:param successes: The list of successes to append to
:param failures: The list of failures to append to
"""
param = self.SYNTH_SPEC.Param.get_by_name(param_name)
if not param:
failures.append(param_name)
return
# 1) direct handler
handler = self.param_handlers.get(param_name)
if handler and handler(param, param_value, successes, failures):
log.message(
f"calling handler {handler} for param {param}",
scope=self.__class__.__name__,
)
successes.append(param_name)
return
# 2) grouped handlers
for group, handler in self.group_handlers:
if param in group:
if handler(param, param_value, successes, failures):
successes.append(param_name)
else:
failures.append(param_name)
return
successes.append(param_name)
# 3) fallback
if self.update_slider(param, param_value, successes, failures):
successes.append(param_name)
else:
failures.append(param_name)
[docs]
def _update_suboscillator(self, param_value):
"""
Update the sub oscillator type switch control.
"""
self.oscillator_section.sub_oscillator_type_switch.blockSignals(True)
self.oscillator_section.sub_oscillator_type_switch.setValue(
self.SUB_OSC_TYPE_MAP[param_value]
)
self.oscillator_section.sub_oscillator_type_switch.blockSignals(False)
[docs]
def _log_and_store_sysex_data(self, sysex_data: dict) -> None:
"""
Compare new and old SysEx data, log differences, and store the current data.
"""
if self.previous_json_data:
log_changes(self.previous_json_data, sysex_data)
self.previous_json_data = sysex_data
[docs]
def _get_adsr_params(self) -> list:
"""
Retrieve the list of ADSR-related parameters.
:return: A list of ADSR parameters
"""
return [
self.SYNTH_SPEC.Param.AMP_ENV_ATTACK_TIME,
self.SYNTH_SPEC.Param.AMP_ENV_DECAY_TIME,
self.SYNTH_SPEC.Param.AMP_ENV_SUSTAIN_LEVEL,
self.SYNTH_SPEC.Param.AMP_ENV_RELEASE_TIME,
self.SYNTH_SPEC.Param.FILTER_ENV_ATTACK_TIME,
self.SYNTH_SPEC.Param.FILTER_ENV_DECAY_TIME,
self.SYNTH_SPEC.Param.FILTER_ENV_SUSTAIN_LEVEL,
self.SYNTH_SPEC.Param.FILTER_ENV_RELEASE_TIME,
]
[docs]
def _update_pw_controls_state(self, waveform: AnalogWaveOsc):
"""
Enable/disable PW controls based on waveform
:param waveform: AnalogOscWave value
:return: None
"""
pw_enabled = waveform == AnalogWaveOsc.SQUARE
log.message(f"Waveform: {waveform} Pulse Width enabled: {pw_enabled}")
# --- Access PWM controls from oscillator_section via widget_for
pwm_widget = (
self.oscillator_section.widget_for(OscillatorWidgetTypes.PWM)
if self.oscillator_section
else None
)
if self.oscillator_section and pwm_widget:
pwm_controls = pwm_widget.controls
if self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH in pwm_controls:
pwm_controls[self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH].setEnabled(
pw_enabled
)
if self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH_MOD_DEPTH in pwm_controls:
pwm_controls[
self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH_MOD_DEPTH
].setEnabled(pw_enabled)
# --- Update the visual state (if controls are sliders)
if self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH in pwm_controls:
control = pwm_controls[self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH]
if hasattr(control, "setStyleSheet"):
control.setStyleSheet(
""
if pw_enabled
else "QSlider::groove:vertical { background: #000000; }"
)
if self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH_MOD_DEPTH in pwm_controls:
control = pwm_controls[self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH_MOD_DEPTH]
if hasattr(control, "setStyleSheet"):
control.setStyleSheet(
""
if pw_enabled
else "QSlider::groove:vertical { background: #000000; }"
)
[docs]
def _handle_adsr(
self, param: AddressParameter, param_value: int, successes: list, failures: list
) -> bool:
"""Dispatch ADSR parameter updates to the ADSR widget."""
self.update_adsr_widget(param, param_value, successes, failures)
return param in self.adsr_mapping
[docs]
def _handle_pitch_env(
self, param, param_value, successes: list, failures: list
) -> bool:
"""Dispatch pitch envelope parameter updates to the pitch env widget."""
self.update_pitch_env_widget(param, param_value, successes, failures)
return param in self.pitch_env_mapping
[docs]
def _handle_lfo_shape(self, param, param_value, successes, failures):
"""Handler for LFO shape; signature matches param_handlers (param, param_value, successes, failures)."""
self._update_lfo_shape_buttons(value=param_value)
return True
[docs]
def _handle_filter_mode_switch(
self, param: AddressParameter, param_value: int, successes: list, failures: list
):
"""handle filter mode switch"""
mode_int = (
int(param_value)
if isinstance(param_value, (int, float))
else getattr(param_value, "value", 0)
)
if not isinstance(mode_int, int):
mode_int = 0
self._update_filter_mode_buttons(partial_number=0, value=mode_int)
self.update_filter_controls_state(mode_int)
[docs]
def _handle_lfo_tempo_sync_switch(
self,
param_name: Literal["LFO_TEMPO_SYNC_SWITCH"],
param_value,
successes: list,
failures: list,
):
"""Handle LFO Tempo Sync Switch"""
control = self.controls.get(self.SYNTH_SPEC.Param.LFO_TEMPO_SYNC_SWITCH)
if control:
control.setValue(param_value)
successes.append(param_name)
else:
failures.append(param_name)
[docs]
def _handle_suboscillator(
self, param_name: str, param_value, successes: list, failures: list
):
"""Handle SubOscillator"""
if self._update_suboscillator(param_value=param_value):
return True
return None
[docs]
def _handle_lfo_tempo_sync_note(
self,
param_name: Literal["LFO_TEMPO_SYNC_NOTE"],
param_value,
successes: list,
failures: list,
):
"""Handle LFO Tempo Sync Note"""
control = self.controls.get(self.SYNTH_SPEC.Param.LFO_TEMPO_SYNC_NOTE)
if control:
control.setValue(param_value)
successes.append(param_name)
else:
failures.append(param_name)
[docs]
def _handle_sub_osc_type(self, param, value, *_):
if (
value in self.SUB_OSC_TYPE_MAP
and self.oscillator_section
and hasattr(self.oscillator_section, "sub_oscillator_type_switch")
):
w = self.oscillator_section.sub_oscillator_type_switch
w.blockSignals(True)
w.setValue(self.SUB_OSC_TYPE_MAP[value])
w.blockSignals(False)
return True
return False
[docs]
def _handle_direct_control(self, param, value, successes, failures):
control = self.controls.get(param)
if not control:
return False
control.setValue(value)
successes.append(param)
return True
[docs]
def _handle_filter_mode(self, param, value, successes, failures):
mode = int(getattr(value, "value", value) or 0)
self._update_filter_mode_buttons(value=mode)
self.update_filter_controls_state(mode)
successes.append(param)
return True