"""
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}
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
# --- Initialize mappings as empty dicts/lists early to prevent AttributeError
# --- These will be populated after sections are created
[docs]
self.pitch_env_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}
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
midi_helper = MidiIOHelper()
preset_helper = JDXiPresetHelper(midi_helper, JDXi.UI.Preset.Analog.ENUMERATED)
editor = AnalogSynthEditor(midi_helper, preset_helper)
editor.show()
app.exec()