Source code for jdxi_editor.ui.widgets.pulse_width.pwm

"""
PWM Widget
==========

This widget provides a user interface for controlling Pulse Width Modulation (PWM) parameters,
with a graphical plot to visualize the modulation envelope.
It includes controls for pulse width and modulation depth,
and can communicate with MIDI devices.

"""

from typing import Callable, Optional

from decologr import Decologr as log
from PySide6.QtCore import Signal
from PySide6.QtWidgets import QGridLayout, QSlider, QWidget

from jdxi_editor.core.jdxi import JDXi
from jdxi_editor.midi.data.address.address import JDXiSysExAddress
from jdxi_editor.midi.io.helper import MidiIOHelper
from jdxi_editor.ui.widgets.envelope.base import TOOLTIPS, EnvelopeWidgetBase
from jdxi_editor.ui.widgets.envelope.parameter import EnvelopeParameter
from jdxi_editor.ui.widgets.pitch.pwm_plot import PWMPlot
from jdxi_editor.ui.widgets.pulse_width.slider_spinbox import PWMSliderSpinbox
from picomidi.constant import Midi
from picomidi.sysex.parameter.address import AddressParameter
from picomidi.utils.conversion import midi_value_to_ms, ms_to_midi_value


[docs] class PWMWidget(EnvelopeWidgetBase):
[docs] mod_depth_changed = Signal(dict)
[docs] pulse_width_changed = Signal(dict)
[docs] envelope_changed = Signal(dict)
def __init__( self, pulse_width_param: AddressParameter, mod_depth_param: AddressParameter, midi_helper: Optional[MidiIOHelper] = None, controls: dict[AddressParameter, QWidget] = None, address: Optional[JDXiSysExAddress] = None, create_parameter_slider: Callable = None, parent: Optional[QWidget] = None, analog: bool = False, ): super().__init__( envelope_keys=[EnvelopeParameter.PULSE_WIDTH, EnvelopeParameter.MOD_DEPTH], create_parameter_slider=create_parameter_slider, parameters=[pulse_width_param, mod_depth_param], midi_helper=midi_helper, address=address, controls=controls, parent=parent, )
[docs] self.plot = None
self.setWindowTitle("PWM Widget")
[docs] self.address = address
[docs] self.midi_helper = midi_helper
[docs] self._create_parameter_slider = create_parameter_slider
if controls is not None: self.controls = controls else: self.controls = {}
[docs] self.envelope = { EnvelopeParameter.PULSE_WIDTH: 0.5, EnvelopeParameter.MOD_DEPTH: 0.5, }
[docs] self.pulse_width_control = PWMSliderSpinbox( pulse_width_param, min_value=0, max_value=Midi.value.max.SEVEN_BIT, units=" %", label="Width", value=int( self.envelope[EnvelopeParameter.PULSE_WIDTH] * Midi.value.max.SEVEN_BIT ), # Convert from 0.0–1.0 to 0–100 create_parameter_slider=self._create_parameter_slider, parent=self, )
[docs] self.mod_depth_control = PWMSliderSpinbox( mod_depth_param, min_value=0, max_value=Midi.value.max.SEVEN_BIT, units=" %", label="Mod Depth", value=int( self.envelope[EnvelopeParameter.MOD_DEPTH] * Midi.value.max.SEVEN_BIT ), # Convert from 0.0–1.0 to 0–100 create_parameter_slider=self._create_parameter_slider, parent=self, )
self.controls[pulse_width_param] = self.pulse_width_control self.controls[mod_depth_param] = self.mod_depth_control
[docs] self._control_widgets = [ self.pulse_width_control, self.mod_depth_control, ]
for key, widget in [ (EnvelopeParameter.PULSE_WIDTH, self.pulse_width_control), (EnvelopeParameter.MOD_DEPTH, self.mod_depth_control), ]: if tooltip := TOOLTIPS.get(key): widget.setToolTip(tooltip)
[docs] self.layout = QGridLayout()
self.layout.setColumnStretch(0, 1) # left side stretches self.layout.addWidget(self.mod_depth_control, 0, 1) self.layout.addWidget(self.pulse_width_control, 0, 2) self.setLayout(self.layout) self.plot = PWMPlot( width=JDXi.UI.Dimensions.PWM_WIDGET.WIDTH - 20, height=JDXi.UI.Dimensions.PWM_WIDGET.HEIGHT - 20, parent=self, envelope=self.envelope, ) self.layout.addWidget(self.plot, 0, 3) self.layout.setColumnStretch(4, 1) # right side stretches self.pulse_width_control.slider.valueChanged.connect( self.on_pulse_width_changed ) self.mod_depth_control.slider.valueChanged.connect(self.on_mod_depth_changed) self.pulse_width_control.setValue( self.envelope[EnvelopeParameter.PULSE_WIDTH] * Midi.value.max.SEVEN_BIT ) self.mod_depth_control.setValue( self.envelope[EnvelopeParameter.MOD_DEPTH] * Midi.value.max.SEVEN_BIT ) JDXi.UI.Theme.apply_adsr_style(self, analog=analog)
[docs] def on_envelope_changed(self, envelope: dict) -> None: """ Handle envelope changes from controls :param envelope: dict :return: None """ self.envelope = envelope log.message(f"Envelope changed: {self.envelope}") self.update() # Trigger repaint if needed
[docs] def on_pulse_width_changed(self, val: int) -> None: """ Handle pulse width changes from slider :param val: int :return: None """ self.envelope[EnvelopeParameter.PULSE_WIDTH] = ( val / Midi.value.max.SEVEN_BIT ) # Convert from 0–100 to 0.0–1.0 self.update() # Trigger repaint if needed
[docs] def on_mod_depth_changed(self, val: int) -> None: """ Handle modulation depth changes from slider :param val: int :return: None """ self.envelope[EnvelopeParameter.MOD_DEPTH] = ( val / Midi.value.max.SEVEN_BIT ) # Convert from 0–100 to 0.0–1.0 self.update() # Trigger repaint if needed
[docs] def update_envelope_from_slider(self, slider: QSlider) -> None: """Update envelope with value from a single slider""" for param, ctrl in self.controls.items(): if ctrl is slider: envelope_param_type = param.get_envelope_param_type() if envelope_param_type == EnvelopeParameter.MOD_DEPTH: self.envelope[EnvelopeParameter.MOD_DEPTH] = ( slider.value() / Midi.value.max.SEVEN_BIT ) elif envelope_param_type == EnvelopeParameter.PULSE_WIDTH: self.envelope[EnvelopeParameter.PULSE_WIDTH] = ( slider.value() / Midi.value.max.SEVEN_BIT ) else: pass break
[docs] def update_envelope_from_controls(self) -> None: """Update envelope values from slider controls""" try: for param, slider in self.controls.items(): envelope_param_type = param.get_envelope_param_type() log.message(f"envelope_param_type = {envelope_param_type}") if envelope_param_type == EnvelopeParameter.MOD_DEPTH: self.envelope[EnvelopeParameter.MOD_DEPTH] = ( slider.value() / Midi.value.max.SEVEN_BIT ) if envelope_param_type == EnvelopeParameter.PULSE_WIDTH: self.envelope[EnvelopeParameter.PULSE_WIDTH] = ( slider.value() / Midi.value.max.SEVEN_BIT ) else: self.envelope[envelope_param_type] = midi_value_to_ms( slider.value() ) log.message(f"{self.envelope}") except Exception as ex: log.error(f"Error updating envelope from controls: {ex}") self.plot.set_values(self.envelope)
[docs] def update_controls_from_envelope(self) -> None: """Update slider controls from envelope values.""" try: for param, slider in self.controls.items(): envelope_param_type = param.get_envelope_param_type() if envelope_param_type == EnvelopeParameter.MOD_DEPTH: slider.setValue( int( self.envelope[EnvelopeParameter.MOD_DEPTH] * Midi.value.max.SEVEN_BIT ) ) if envelope_param_type == EnvelopeParameter.PULSE_WIDTH: slider.setValue( int( self.envelope[EnvelopeParameter.PULSE_WIDTH] * Midi.value.max.SEVEN_BIT ) ) else: slider.setValue( int(ms_to_midi_value(self.envelope[envelope_param_type])) ) except Exception as ex: log.error(f"[PWMWidget] Error updating controls from envelope: {ex}") self.plot.set_values(self.envelope)
[docs] def refresh_plot_from_controls(self) -> None: """ Sync envelope from current control values and redraw the plot without emitting. Call after programmatically setting control values (e.g. from incoming SysEx) when blockSignals(True) was used, so the plot reflects the new values. """ try: pw_val = self.pulse_width_control.value() md_val = self.mod_depth_control.value() # value() may be 0-127 or 0.0-1.0 depending on widget if isinstance(pw_val, (int, float)) and pw_val <= 1.0 and pw_val >= 0.0: self.envelope[EnvelopeParameter.PULSE_WIDTH] = float(pw_val) else: self.envelope[EnvelopeParameter.PULSE_WIDTH] = ( pw_val / Midi.value.max.SEVEN_BIT ) if isinstance(md_val, (int, float)) and md_val <= 1.0 and md_val >= 0.0: self.envelope[EnvelopeParameter.MOD_DEPTH] = float(md_val) else: self.envelope[EnvelopeParameter.MOD_DEPTH] = ( md_val / Midi.value.max.SEVEN_BIT ) self.plot.set_values(self.envelope) except Exception as ex: log.error(f"[PWMWidget] Error in refresh_plot_from_controls: {ex}")