Source code for jdxi_editor.ui.editors.analog.editor

"""
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 sys
from pathlib import Path

from jdxi_editor.midi.control_change.parameter import CCParameter
from jdxi_editor.midi.nrpn.parameter import NRPNParameter

# Allow running this file directly from project root: python jdxi_editor/ui/editors/analog/editor.py
if __name__ == "__main__" and __package__ is None:
[docs] _root = Path(__file__).resolve().parents[3]
if str(_root) not in sys.path: sys.path.insert(0, str(_root)) from typing import Optional from decologr import Decologr as log from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtWidgets import ( QWidget, ) from jdxi_editor.core.synth.type import JDXiSynth from jdxi_editor.midi.data.base.oscillator import OscillatorWidgetTypes from jdxi_editor.midi.data.parameter.analog.spec import JDXiMidiAnalog as Analog from jdxi_editor.midi.io.helper import MidiIOHelper from jdxi_editor.ui.editors.analog.amp.section import AnalogAmpSection from jdxi_editor.ui.editors.analog.common.section import AnalogCommonSection from jdxi_editor.ui.editors.analog.filter.section import AnalogFilterSection from jdxi_editor.ui.editors.analog.lfo.section import AnalogLFOSection from jdxi_editor.ui.editors.analog.oscillator.section import AnalogOscillatorSection from jdxi_editor.ui.editors.base.editor import BaseSynthEditor
[docs] class AnalogSynthEditor(BaseSynthEditor): """Analog Synth Editor UI."""
[docs] SUB_OSC_TYPE_MAP = {0: 0, 1: 1, 2: 2}
[docs] SYNTH_SPEC = Analog
def __init__( self, midi_helper: Optional[MidiIOHelper] = None, preset_helper: Optional["JDXiPresetHelper"] = None, # type: ignore[name-defined] parent: Optional[QWidget] = None, ): """ Initialize the AnalogSynthEditor :param midi_helper: MidiIOHelper :param preset_helper: JDXIPresetHelper :param parent: QWidget """ super().__init__(midi_helper=midi_helper, parent=parent)
[docs] self.preset_helper = preset_helper
[docs] self.main_window = parent
[docs] self.analog = True
# --- Initialize mappings as empty dicts/lists early to prevent AttributeError # --- These will be populated after sections are created
[docs] self.adsr_mapping = {}
[docs] self.pitch_env_mapping = {}
[docs] self.pwm_mapping = {}
self._init_parameter_mappings() self._init_synth_data(JDXiSynth.ANALOG_SYNTH) self.setup_ui() 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=self.__class__.__name__, message="MIDI signals connected") else: log.message( scope=self.__class__.__name__, message="MIDI signals not connected" )
[docs] self.refresh_shortcut = QShortcut(QKeySequence.StandardKey.Refresh, self)
self.refresh_shortcut.activated.connect(self.data_request) # --- Define mapping dictionaries
[docs] self.filter_switch_map = {0: 0, 1: 1}
[docs] self.osc_waveform_map = { 0: self.SYNTH_SPEC.Wave.Osc.SAW, 1: self.SYNTH_SPEC.Wave.Osc.TRI, 2: self.SYNTH_SPEC.Wave.Osc.SQUARE, }
self._create_sections() self._build_parameter_mappings() # Note: data_request() is called in showEvent() when editor is displayed
[docs] def setup_ui(self): """Set up the Analog Synth Editor UI.""" super().setup_ui()
[docs] def _create_sections(self): """Create the sections for the Analog Synth Editor. Each section in its own try/except so one failure does not prevent others from showing.""" log.message( scope=self.__class__.__name__, message=f"[_create_sections] start [self.controls:] {self.controls}", ) try: log.info( scope=self.__class__.__name__, message="Creating Analog Oscillator section (address=%s, has midi_helper=%s)" % (getattr(self, "address", None), self.midi_helper is not None), ) self.oscillator_section = AnalogOscillatorSection( waveform_selected_callback=self._on_waveform_selected, wave_buttons=self.wave_buttons, midi_helper=self.midi_helper, address=self.address, send_midi_parameter=self.send_midi_parameter, ) self.wave_buttons = self.oscillator_section.widgets.waveform_buttons log.info( scope=self.__class__.__name__, message="Analog Oscillator section created successfully (widgets=%s)" % (getattr(self.oscillator_section, "widgets", None) is not None,), ) except Exception as ex: log.message( scope=self.__class__.__name__, message=f"Error creating oscillator_section: {ex}", ) import traceback log.message(traceback.format_exc()) self.oscillator_section = None try: if self.synth_data and getattr(self.synth_data, "address", None): self.filter_section = AnalogFilterSection( address=self.synth_data.address, send_midi_parameter=self.send_midi_parameter, midi_helper=self.midi_helper, on_filter_mode_changed=self._on_filter_mode_changed, analog=True, ) else: self.filter_section = None except Exception as ex: log.message( scope=self.__class__.__name__, message=f"Error creating filter_section: {ex}", ) import traceback log.message(traceback.format_exc()) self.filter_section = None try: if self.synth_data and getattr(self.synth_data, "address", None): self.amp_section = AnalogAmpSection( address=self.synth_data.address, send_midi_parameter=self.send_midi_parameter, midi_helper=self.midi_helper, ) else: self.amp_section = None except Exception as ex: log.message( scope=self.__class__.__name__, message=f"Error creating amp_section: {ex}", ) import traceback log.message(traceback.format_exc()) self.amp_section = None try: self.common_section = AnalogCommonSection( send_midi_parameter=self.send_midi_parameter, midi_helper=self.midi_helper, ) except Exception as ex: log.message( scope=self.__class__.__name__, message=f"Error creating common_section: {ex}", ) import traceback log.message(traceback.format_exc()) self.common_section = None try: self.lfo_section = AnalogLFOSection( midi_helper=self.midi_helper, send_midi_parameter=self.send_midi_parameter, ) self.lfo_shape_buttons = self.lfo_section.lfo_shape_buttons except Exception as ex: log.message( scope=self.__class__.__name__, message=f"Error creating lfo_section: {ex}", ) import traceback log.message(traceback.format_exc()) self.lfo_section = None self.lfo_shape_buttons = {} for section in ( self.oscillator_section, self.filter_section, self.amp_section, self.common_section, self.lfo_section, ): if ( section is not None and hasattr(section, "controls") and section.controls ): self.controls.update(section.controls) self.add_tabs() log.message( scope=self.__class__.__name__, message=f"[_create_sections] done [self.controls keys:] {list(self.controls.keys()) if self.controls else []}", )
[docs] def _build_parameter_mappings(self): """Populate adsr_mapping, pitch_env_mapping, pwm_mapping only when the corresponding sections exist.""" if ( self.amp_section is not None and getattr(self.amp_section, "adsr_widget", None) is not None ): self.adsr_mapping.update( { self.SYNTH_SPEC.Param.AMP_ENV_ATTACK_TIME: self.amp_section.adsr_widget.attack_control, self.SYNTH_SPEC.Param.AMP_ENV_DECAY_TIME: self.amp_section.adsr_widget.decay_control, self.SYNTH_SPEC.Param.AMP_ENV_SUSTAIN_LEVEL: self.amp_section.adsr_widget.sustain_control, self.SYNTH_SPEC.Param.AMP_ENV_RELEASE_TIME: self.amp_section.adsr_widget.release_control, } ) if ( self.filter_section is not None and getattr(self.filter_section, "adsr_widget", None) is not None ): self.adsr_mapping.update( { self.SYNTH_SPEC.Param.FILTER_ENV_ATTACK_TIME: self.filter_section.adsr_widget.attack_control, self.SYNTH_SPEC.Param.FILTER_ENV_DECAY_TIME: self.filter_section.adsr_widget.decay_control, self.SYNTH_SPEC.Param.FILTER_ENV_SUSTAIN_LEVEL: self.filter_section.adsr_widget.sustain_control, self.SYNTH_SPEC.Param.FILTER_ENV_RELEASE_TIME: self.filter_section.adsr_widget.release_control, } ) if self.oscillator_section is not None: pitch_env = self.oscillator_section.widget_for( OscillatorWidgetTypes.PITCH_ENV ) if pitch_env is not None: self.pitch_env_mapping.update( { self.SYNTH_SPEC.Param.OSC_PITCH_ENV_ATTACK_TIME: lambda: pitch_env.attack_control, self.SYNTH_SPEC.Param.OSC_PITCH_ENV_DECAY_TIME: lambda: pitch_env.decay_control, self.SYNTH_SPEC.Param.OSC_PITCH_ENV_DEPTH: lambda: pitch_env.depth_control, } ) pwm_widget = self.oscillator_section.widget_for(OscillatorWidgetTypes.PWM) if ( pwm_widget is not None and hasattr(pwm_widget, "controls") and pwm_widget.controls ): ctrls = pwm_widget.controls ctrl_pw = ctrls.get(self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH) ctrl_mod = ctrls.get(self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH_MOD_DEPTH) if ctrl_pw is not None: self.pwm_mapping[self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH] = ctrl_pw if ctrl_mod is not None: self.pwm_mapping[ self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH_MOD_DEPTH ] = ctrl_mod
[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=self.__class__.__name__, message=f"update_filter_controls_state: mode={mode} ", ) log.message( scope=self.__class__.__name__, message=f"has filter_section={hasattr(self, 'filter_section')} ", ) log.message( scope=self.__class__.__name__, message=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=self.__class__.__name__, 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=self.__class__.__name__, message=f"_on_filter_mode_changed: mode={mode}", ) self.update_filter_controls_state(mode=mode)
if __name__ == "__main__": # --- Test the AnalogSynthEditor from PySide6.QtWidgets import QApplication from jdxi_editor.core.jdxi import JDXi from jdxi_editor.ui.preset.helper import JDXiPresetHelper
[docs] app = QApplication([])
midi_helper = MidiIOHelper() preset_helper = JDXiPresetHelper(midi_helper, JDXi.UI.Preset.Analog.ENUMERATED) editor = AnalogSynthEditor(midi_helper, preset_helper) editor.show() app.exec()