Source code for jdxi_editor.ui.editors.base.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 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."""
[docs] SUB_OSC_TYPE_MAP = {}
[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, # 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.osc_waveform_map = None
[docs] self.instrument_image_group: QGroupBox | None = None
[docs] self.scroll: QScrollArea | None = None
[docs] self.instrument_preset_group: QGroupBox | None = None
[docs] self.instrument_preset: QWidget | None = None
[docs] self.instrument_preset_widget: 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.tab_widget = None
[docs] self.lfo_section = None
[docs] self.preset_helper = preset_helper
[docs] self.wave_buttons = {}
[docs] self.lfo_shape_buttons = {}
[docs] self.updating_from_spinbox = False
[docs] self.previous_json_data = None
[docs] self.main_window = parent
[docs] self.analog = True
[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
[docs] self.adsr_mapping = {}
self.pitch_env_mapping = {}
[docs] self.pwm_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 build_widgets(self): """Create widgets""" self.tab_widget = self.base_widget.create_tab_widget() self._configure_sliders()
[docs] def _configure_sliders(self): """Configure sliders""" for slider in self.controls.values(): if isinstance(slider, QSlider): slider.setTickPosition(QSlider.TickPosition.TicksBothSides) slider.setTickInterval(10)
[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 _update_filter_mode_buttons(self, partial_number: int, value: int): """ Update the filter mode buttons based on the FILTER_MODE_SWITCH value with visual feedback. Uses Filter.Mode (not Filter.FilterType) so the lookup key matches the section's button_widgets keys, which are keyed by the spec.param from generate_wave_shapes (Mode). :param value: int filter mode value (0 = BYPASS, 1 = LPF for Analog) :return: None """ filter_mode_map = { 0: self.SYNTH_SPEC.Filter.Mode.BYPASS, 1: self.SYNTH_SPEC.Filter.Mode.LPF, } selected_filter_mode = filter_mode_map.get(value) if selected_filter_mode is None: log.warning("Unknown filter mode value: %s", value, scope="BaseSynthEditor") return # --- Reset all buttons to default style for btn in self.filter_section.filter_mode_buttons.values(): btn.setChecked(False) JDXi.UI.Theme.apply_button_rect(btn, analog=self.analog) # --- Apply active style to the selected filter mode button (harmonised Theme API) selected_btn = self.filter_section.filter_mode_buttons.get(selected_filter_mode) if selected_btn: selected_btn.setChecked(True) JDXi.UI.Theme.apply_button_active(selected_btn, analog=self.analog) else: log.warning( "Filter mode button not found for: %s", selected_filter_mode, scope=self.__class__.__name__, )
[docs] def _on_waveform_selected(self, waveform: AnalogWaveOsc): """ Handle waveform button selection :param waveform: AnalogOscWave value :return: None """ if self.midi_helper: sysex_message = self.sysex_composer.compose_message( address=self.address, param=self.SYNTH_SPEC.Param.OSC_WAVEFORM, value=waveform.value, ) self.midi_helper.send_midi_message(sysex_message) # --- Use oscillator_section.waveform_buttons if available, fallback to wave_buttons buttons_dict = self.wave_buttons if self.oscillator_section and hasattr( self.oscillator_section, OscillatorWidgetTypes.WAVEFORM_BUTTONS ): buttons_dict = self.oscillator_section.widgets.waveform_buttons # --- Also sync to editor's wave_buttons for consistency self.wave_buttons.update(buttons_dict) # --- Reset all buttons to default style for btn in buttons_dict.values(): btn.setChecked(False) JDXi.UI.Theme.apply_button_rect(btn, analog=self.analog) # --- Apply active style to the selected waveform button (match Digital Filter section mode buttons) selected_btn = buttons_dict.get(waveform) if selected_btn: selected_btn.setChecked(True) JDXi.UI.Theme.apply_button_active(selected_btn, analog=self.analog) self._update_pw_controls_state(waveform)
[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_adsr_widget( self, param: AnalogParam, midi_value: int, successes: list = None, failures: list = None, ) -> None: """ Helper function to update ADSR widgets. :param param: AddressParameterAnalog value :param midi_value: int value :param failures: list of failed parameters :param successes: list of successful parameters :return: None """ slider_value = ( midi_value_to_fraction(midi_value) if param in [ self.SYNTH_SPEC.Param.AMP_ENV_SUSTAIN_LEVEL, self.SYNTH_SPEC.Param.FILTER_ENV_SUSTAIN_LEVEL, ] else midi_value_to_ms(midi_value) ) if param in self.adsr_mapping: control = self.adsr_mapping[param] control.blockSignals(True) control.setValue(slider_value) control.blockSignals(False) # ADSR plot is driven by envelope_changed; refresh after programmatic update amp_env = ( 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, ) if ( param in amp_env and hasattr(self, "amp_section") and self.amp_section and getattr(self.amp_section, "adsr_widget", None) ): self.amp_section.adsr_widget.refresh_plot_from_controls() elif ( param not in amp_env and hasattr(self, "filter_section") and self.filter_section and getattr(self.filter_section, "adsr_widget", None) ): self.filter_section.adsr_widget.refresh_plot_from_controls() successes.append(param.name) log_slider_parameters(self.address, param, midi_value, slider_value) else: failures.append(param.name)
[docs] def update_pitch_env_widget( self, parameter: AnalogParam, value: int, successes: list = None, failures: list = None, ) -> None: """ Helper function to update ADSR widgets. :param parameter: AddressParameterAnalog value :param value: int value :param failures: list of failed parameters :param successes: list of successful parameters :return: None """ new_value = ( midi_value_to_fraction(value) if parameter in [ self.SYNTH_SPEC.Param.OSC_PITCH_ENV_DEPTH, ] else midi_value_to_ms(value, 10, 1000) ) if parameter in self.pitch_env_mapping: control_getter = self.pitch_env_mapping[parameter] control = control_getter() control.blockSignals(True) control.setValue(new_value) control.blockSignals(False) if hasattr(self, "oscillator_section") and self.oscillator_section: pitch_env = self.oscillator_section.widget_for( OscillatorWidgetTypes.PITCH_ENV ) if pitch_env is not None: pitch_env.refresh_plot_from_controls() successes.append(parameter.name) else: failures.append(parameter.name)
[docs] def update_pwm_widget( self, parameter: AnalogParam, value: int, successes: list = None, failures: list = None, ) -> None: """ Helper function to update PWM widgets. :param parameter: AddressParameterAnalog value :param value: int value :param failures: list of failed parameters :param successes: list of successful parameters :return: None """ new_value = ( midi_value_to_fraction(value) if parameter in [ self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH_MOD_DEPTH, self.SYNTH_SPEC.Param.OSC_PULSE_WIDTH, ] else midi_value_to_ms(value, 10, 1000) ) if parameter in self.pwm_mapping: control = self.pwm_mapping[parameter] control.blockSignals(True) control.setValue(new_value) control.blockSignals(False) if hasattr(self, "oscillator_section") and self.oscillator_section: pwm_widget = self.oscillator_section.widget_for( OscillatorWidgetTypes.PWM ) if pwm_widget is not None: pwm_widget.refresh_plot_from_controls() successes.append(parameter.name) else: failures.append(parameter.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_waveform_buttons(self, value: int): """ Update the waveform buttons based on the OSC_WAVE value with visual feedback. :param value: int value :return: None """ waveform_map = { 0: self.SYNTH_SPEC.Wave.Osc.SAW, 1: self.SYNTH_SPEC.Wave.Osc.TRI, 2: self.SYNTH_SPEC.Wave.Osc.SQUARE, } selected_waveform = waveform_map.get(value) if selected_waveform is None: log.message(f"Unknown waveform value: {value}", level=logging.WARNING) return log.message(f"Waveform value {value} found, selecting {selected_waveform}") # --- Retrieve waveform buttons for the given partial wave_buttons = self.wave_buttons # --- Reset all buttons to default style for btn in wave_buttons.values(): btn.setChecked(False) JDXi.UI.Theme.apply_button_rect(btn, analog=self.analog) # --- Apply active style to the selected waveform button (match Digital Filter section mode buttons) selected_btn = wave_buttons.get(selected_waveform) if selected_btn: selected_btn.setChecked(True) JDXi.UI.Theme.apply_button_active(selected_btn, analog=self.analog)
[docs] def _update_lfo_shape_buttons(self, value: int): """ Update the LFO shape buttons with visual feedback. :param value: int value :return: None """ # --- 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) else: log.message(f"Unknown LFO shape value: {value}", level=logging.WARNING)
[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_waveform(self, param, param_value, successes, failures): """Handle waveform; signature matches param_handlers (param, param_value, successes, failures).""" self._update_waveform_buttons(value=param_value) return True
[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