Source code for jdxi_editor.ui.editors.pattern.ui

"""

Module: Pattern Sequencer with MIDI Integration

This module implements address Pattern Sequencer using PySide6, allowing users to toggle
sequence steps using address grid of buttons. It supports MIDI input to control button states
using note keys (e.g., C4, C#4, etc.).

Features:
- 4 rows of buttons labeled as Digital Synth 1, Digital Synth 2, Analog Synth, and Drums.
- MIDI note-to-button mapping for real-time control.
- Toggle button states programmatically or via MIDI.
- Styled buttons with illumination effects.
- Each button stores an associated MIDI note and its on/off state.
- Start/Stop playback buttons for sequence control. ..

"""

import datetime
from typing import Any, Callable, Optional

from decologr import Decologr as log
from mido import MidiFile, MidiTrack
from PySide6.QtCore import QEvent, Qt, QTimer
from PySide6.QtWidgets import (
    QAbstractButton,
    QCheckBox,
    QComboBox,
    QGroupBox,
    QHBoxLayout,
    QLabel,
    QListWidget,
    QListWidgetItem,
    QPushButton,
    QSizePolicy,
    QSpinBox,
    QSplitter,
)

from jdxi_editor.midi.channel.channel import MidiChannel
from jdxi_editor.midi.io.helper import MidiIOHelper
from jdxi_editor.midi.playback.state import MidiPlaybackState
from jdxi_editor.ui.common import JDXi, QVBoxLayout, QWidget
from jdxi_editor.ui.editors.helpers.widgets import (
    create_jdxi_button,
    create_jdxi_button_from_spec,
    create_jdxi_button_with_label_from_spec,
    create_jdxi_row,
)
from jdxi_editor.ui.editors.pattern.models import ClipboardData, SequencerStyle
from jdxi_editor.ui.editors.pattern.options import DIGITAL_OPTIONS, DRUM_OPTIONS
from jdxi_editor.ui.editors.pattern.preset_list_provider import (
    get_analog_options,
    get_digital_options,
    get_drum_options,
    get_preset_signals,
)
from jdxi_editor.ui.editors.pattern.spec import SequencerRowSpec
from jdxi_editor.ui.editors.synth.editor import SynthEditor
from jdxi_editor.ui.preset.helper import JDXiPresetHelper
from jdxi_editor.ui.style import JDXiUIThemeManager
from jdxi_editor.ui.style.factory import generate_sequencer_button_style
from jdxi_editor.ui.widgets.editor.base import EditorBaseWidget
from jdxi_editor.ui.widgets.editor.helper import create_group_with_layout
from jdxi_editor.ui.widgets.pattern_file_group import PatternFileGroup
from jdxi_editor.ui.widgets.transport.transport import PatternTransportWidget
from jdxi_editor.ui.widgets.pattern.measure_widget import PatternMeasureWidget
from jdxi_editor.ui.widgets.pattern.sequencer_button import SequencerButton
from jdxi_editor.ui.widgets.pattern.widget import PatternConfig, PatternWidget
from jdxi_editor.ui.widgets.usb.recording import USBFileRecordingWidget
from picoui.helpers import create_layout_with_items
from picoui.helpers.spinbox import spinbox_with_label_from_spec
from picoui.specs.widgets import ButtonSpec, ComboBoxSpec, SpinBoxSpec
from picoui.widget.helper import create_combo_box


[docs] def _combo_spec(items, tooltip: str = "") -> ComboBoxSpec: """Build ComboBoxSpec with items list, no slot.""" return ComboBoxSpec(items=list(items), tooltip=tooltip, slot=None)
[docs] _EXPANDING = (QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding)
[docs] class PatternUI(SynthEditor): """Pattern Sequencer with MIDI Integration using mido""" def __init__( self, midi_helper: Optional[MidiIOHelper], preset_helper: Optional[JDXiPresetHelper], parent: Optional[QWidget] = None, midi_file_editor: Optional[Any] = None, ): super().__init__(parent=parent)
[docs] self.paste_button: QPushButton | None = None
[docs] self.pause_button: QPushButton | None = None
[docs] self.stop_button: QPushButton | None = None
[docs] self.play_button: QPushButton | None = None
[docs] self.channel_to_row = { MidiChannel.DIGITAL_SYNTH_1: 0, # Channel 0 MidiChannel.DIGITAL_SYNTH_2: 1, # Channel 1 MidiChannel.ANALOG_SYNTH: 2, # Channel 2 MidiChannel.DRUM_KIT: 3, # Channel 9 }
[docs] self.row_labels = [ "Digital Synth 1", "Digital Synth 2", "Analog Synth", "Drums", ]
self._build_row_specs()
[docs] self.sequencer_rows: int = 4
# Use Qt translations: add .ts/.qm for locale (e.g. en_GB "Measure" -> "Measure", "Measures" -> "Measures")
[docs] self.measure_name: str = self.tr("Measure")
[docs] self.measure_name_plural: str = self.tr("Measures")
[docs] self.muted_channels: list[int] = []
[docs] self.total_measures: int = 1 # Start with 1 bar by default
[docs] self.midi_helper: Optional[MidiIOHelper] = midi_helper
[docs] self.preset_helper: Optional[JDXiPresetHelper] = preset_helper
[docs] self.midi_file_editor: Optional[Any] = ( midi_file_editor # Reference to MidiFileEditor )
[docs] self.measure_beats: int = 16 # Number of beats per bar (16 or 12)
[docs] self.pattern_widget: Optional[PatternWidget] = None # Set in _setup_ui
[docs] self.timer: Optional[QTimer] = None
[docs] self.current_step: int = 0 # Currently selected step (0-indexed)
[docs] self.total_steps: int = ( 16 # Always 16 steps per measure (don't multiply by measures) )
[docs] self.clipboard: Optional[dict[str, Any]] | None = ( None # Store copied notes: {source_bar, rows, start_step, end_step, notes_data} )
[docs] self.beats_per_pattern: int = 4
[docs] self.timing_bpm: int = 120
[docs] self.last_tap_time: Optional[datetime] = None
[docs] self.tap_times: list[float] = []
[docs] self.midi_file: Optional[MidiFile] = ( None # Set in _setup_ui from MidiFileController )
[docs] self.midi_track: Optional[MidiTrack] = ( None # Set in _setup_ui from MidiFileController )
self.clipboard: Optional[dict[str, Any]] | None = ( None # Store copied notes: {source_measure, rows, start_step, end_step, notes_data} )
[docs] self._pattern_paused: bool = False
[docs] self.midi_state: MidiPlaybackState = MidiPlaybackState()
[docs] self.usb_recorder = USBFileRecordingWidget(self.midi_state)
self._setup_ui() JDXi.UI.Theme.apply_editor_style(self)
[docs] def _build_row_specs(self): """build row specs""" self.row_specs: list[SequencerRowSpec] = [ SequencerRowSpec( "Digital Synth 1", JDXi.UI.Icon.PIANO, JDXi.UI.Style.ACCENT ), SequencerRowSpec( "Digital Synth 2", JDXi.UI.Icon.PIANO, JDXi.UI.Style.ACCENT ), SequencerRowSpec( "Analog Synth", JDXi.UI.Icon.PIANO, JDXi.UI.Style.ACCENT_ANALOG ), SequencerRowSpec("Drums", JDXi.UI.Icon.DRUM, JDXi.UI.Style.ACCENT), ]
[docs] def showEvent(self, event: QEvent) -> None: """Refresh preset options when shown (e.g. after MIDI config change).""" super().showEvent(event) self._refresh_preset_options()
[docs] def _refresh_preset_options(self) -> None: """Reload options from provider and update combo boxes if changed.""" new_digital = get_digital_options() new_analog = get_analog_options() new_drum = get_drum_options() if ( new_digital != self.digital_options or new_analog != self.analog_options or new_drum != self.drum_options ): self.digital_options = new_digital self.analog_options = new_analog self.drum_options = new_drum self._reload_combo_options()
[docs] def _setup_ui(self): """Use EditorBaseWidget for consistent scrollable layout structure""" self._init_base_widget() # Create content widget with main layout content_widget = QWidget() self.layout = QVBoxLayout(content_widget) self.mute_buttons = [] # List to store mute buttons # Define synth options (from built-in or SoundFont via MIDI config) self.digital_options = get_digital_options() self.analog_options = get_analog_options() self.drum_options = get_drum_options() # Connect to preset list change signal for dynamic refresh get_preset_signals().soundfont_list_changed.connect(self._refresh_preset_options) # Assemble all specs for buttons and combos (used below) self.specs = self._build_specs() # Add transport and file controls at the top control_panel = QHBoxLayout() self.pattern_file_group = PatternFileGroup(parent=self) self.drum_selector = self.pattern_file_group.drum_selector measure_group = self._create_measure_group() control_panel.addWidget(measure_group) learn_group = self._create_learn_group() # not adding this for now # --- Group 1 : Tempo and Beats tempo_group = self._create_tempo_group() beats_group = self._create_beats_group() tempo_beats_layout = create_layout_with_items( [tempo_group, beats_group], vertical=True ) control_panel.addLayout(tempo_beats_layout) # --- Group 2: Velocity and duration velocity_group = self._create_velocity_group() duration_group = self._create_duration_group() velocity_duration_layout = create_layout_with_items( [velocity_group, duration_group], vertical=True ) control_panel.addLayout(velocity_duration_layout) # --- add usb recorder control_panel.addWidget(self.usb_recorder) self.layout.addLayout(control_panel) # Create splitter for measures list and sequencer (builds measures group + sequencer widget) self._build_splitter_section() self.row_map = { 0: self.digital1_selector, 1: self.digital2_selector, 2: self.analog_selector, 3: self.drum_selector, } # Transport at bottom, centered (stretch on both sides) self.pattern_transport = PatternTransportWidget(parent=self) self.play_button = self.pattern_transport.play_button self.stop_button = self.pattern_transport.stop_button self.pause_button = self.pattern_transport.pause_button self.shuffle_button = self.pattern_transport.shuffle_button transport_bottom_layout = create_layout_with_items( [ self.pattern_transport, self.pattern_file_group, ] ) self.layout.addLayout(transport_bottom_layout) # Add content to scrollable area so the widget tree is retained self._container_layout.addWidget(content_widget)
[docs] def _build_splitter_section(self): """Build splitter section with PatternWidget (measures list + sequencer).""" self.pattern_widget = PatternWidget( config=PatternConfig( rows=4, steps_per_measure=self.measure_beats, initial_measures=1 ) ) self.pattern_widget.set_header_widget(self._create_headers_widget()) self.layout.addWidget(self.pattern_widget)
[docs] def _create_headers_widget(self) -> QWidget: """Create header rows (icon, label, combo, mute) for PatternWidget.""" widget = QWidget() layout = QVBoxLayout(widget) for row_idx, label in enumerate(self.row_labels): layout.addLayout(self._create_header_row(row_idx, label)) return widget
[docs] def _create_header_row(self, row_idx: int, label_text: str) -> QHBoxLayout: """Create one row: [icon, label, combo] [mute] (no step buttons).""" row_layout = QHBoxLayout() row_layout.addLayout(self._create_row_header(row_idx, label_text)) mute_btn_layout = QHBoxLayout() mute_btn_layout.addStretch() mute_btn = self._add_round_action_button( JDXi.UI.Icon.MUTE, self.tr("Mute"), None, mute_btn_layout, checkable=True, append_to=self.mute_buttons, ) # Synth-style: unmuted=lit, muted=dark (checked_means_inactive) mute_btn.setStyleSheet( generate_sequencer_button_style(True, checked_means_inactive=True) ) mute_btn.toggled.connect( lambda checked, row=row_idx: self._toggle_mute(row, checked) ) mute_btn_layout.addStretch() row_layout.addLayout(mute_btn_layout) return row_layout
[docs] def _create_row_header(self, row_idx: int, label_text: str) -> QHBoxLayout: """create row header""" icon_label = QLabel() icon_label.setPixmap(self._get_row_icon(row_idx).pixmap(40, 40)) icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter) label = QLabel(label_text) label.setAlignment(Qt.AlignmentFlag.AlignCenter) label.setStyleSheet(self._get_row_label_style(row_idx)) layout = create_layout_with_items([icon_label, label]) selector = self._create_selector_for_row(row_idx) if selector: layout.addWidget(selector) return layout
[docs] def _get_row_icon(self, row_idx: int): """get row icon""" icon_map = { 0: JDXi.UI.Icon.PIANO, 1: JDXi.UI.Icon.PIANO, 2: JDXi.UI.Icon.PIANO, 3: JDXi.UI.Icon.DRUM, } icon_key = icon_map.get(row_idx, JDXi.UI.Icon.PIANO) return JDXi.UI.Icon.get_icon(icon_key, color=JDXi.UI.Style.FOREGROUND)
[docs] def _get_row_label_style(self, row_idx: int) -> str: spec = self.row_specs[row_idx] return SequencerStyle.row_label(spec.accent_color)
[docs] def _create_selector_for_row(self, row_idx: int): """Create and return the combo selector for this row; assign to self for channel_map / _update_combo_boxes.""" if row_idx == 0: self.digital1_selector = create_combo_box( spec=self.specs["combos"]["digital1"] ) return self.digital1_selector if row_idx == 1: self.digital2_selector = create_combo_box( spec=self.specs["combos"]["digital2"] ) return self.digital2_selector if row_idx == 2: self.analog_selector = create_combo_box(spec=self.specs["combos"]["analog"]) return self.analog_selector if row_idx == 3: return self.drum_selector return None
[docs] def _build_top_controls(self): self.content_widget = QWidget() self.layout = QVBoxLayout(self.content_widget) control_panel = QHBoxLayout() measure_group = self._create_measure_group() tempo_group = self._create_tempo_group() beats_group = self._create_beats_group() velocity_group = self._create_velocity_group() duration_group = self._create_duration_group() for group in ( measure_group, tempo_group, beats_group, velocity_group, duration_group, ): control_panel.addWidget(group) self.layout.addLayout(control_panel)
[docs] def _init_base_widget(self): """init base widget""" self.base_widget = EditorBaseWidget(parent=self, analog=False) self.base_widget.setup_scrollable_content() self._container_layout = self.base_widget.get_container_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)
[docs] def _on_measure_selected(self, item: QListWidgetItem): raise NotImplementedError("Should be implemented in subclass")
@property
[docs] def buttons(self): """Delegate to current measure's buttons for playback/display.""" if self.pattern_widget: w = self.pattern_widget.get_current_measure_widget() return w.buttons if w else [[] for _ in range(4)] return [[] for _ in range(4)]
@property
[docs] def measure_widgets(self): """Delegate to pattern_widget measure widgets.""" if self.pattern_widget: return self.pattern_widget.get_measure_widgets() return []
@property
[docs] def measures_list(self): """Delegate to pattern_widget measures list.""" if self.pattern_widget: return self.pattern_widget.measures_list return None
@property
[docs] def current_measure_index(self): """Delegate to pattern_widget current measure index.""" if self.pattern_widget: return self.pattern_widget.current_measure_index return 0
@current_measure_index.setter def current_measure_index(self, value: int): if self.pattern_widget: self.pattern_widget.current_measure_index = value
[docs] def _create_duration_group(self) -> QGroupBox: """Duration control area""" duration_group = QGroupBox("Duration") duration_layout = QHBoxLayout() self.duration_label = QLabel("Dur:") self.duration_combo = create_combo_box(spec=self.specs["combos"]["duration"]) self.duration_combo.setCurrentIndex(0) # Default to 16th note self.duration_combo.currentIndexChanged.connect(self._on_duration_changed) duration_layout.addWidget(self.duration_label) duration_layout.addWidget(self.duration_combo) duration_group.setLayout(duration_layout) return duration_group
[docs] def _create_velocity_group(self) -> QGroupBox: """Velocity control area""" velocity_group = QGroupBox("Velocity") velocity_layout = QHBoxLayout() self.velocity_label, self.velocity_spinbox = spinbox_with_label_from_spec( self.specs["spinboxes"]["velocity"] ) velocity_layout.addWidget(self.velocity_label) velocity_layout.addWidget(self.velocity_spinbox) velocity_group.setLayout(velocity_layout) return velocity_group
[docs] def _create_beats_group(self) -> QGroupBox: """Beats per measure control area""" beats_group = QGroupBox("Beats per Measure") beats_layout = QHBoxLayout() self.beats_per_measure_combo = create_combo_box( spec=self.specs["combos"]["beats_per_measure"] ) self.beats_per_measure_combo.setCurrentIndex(0) # Default to 16 beats self.beats_per_measure_combo.currentIndexChanged.connect( self._on_beats_per_measure_changed ) beats_layout.addWidget(self.beats_per_measure_combo) beats_group.setLayout(beats_layout) return beats_group
[docs] def _on_beats_per_measure_changed(self, index): raise NotImplementedError
[docs] def _create_tempo_group(self) -> QGroupBox: """Tempo control area""" tempo_group = QGroupBox("Tempo") tempo_layout = QHBoxLayout() self.tempo_label, self.tempo_spinbox = spinbox_with_label_from_spec( self.specs["spinboxes"]["tempo"] ) self.tempo_spinbox.valueChanged.connect(self._on_tempo_changed) tempo_layout.addWidget(self.tempo_label) tempo_layout.addWidget(self.tempo_spinbox) self._add_button_with_label_from_spec( "tap_tempo", self.specs["buttons"]["tap_tempo"], tempo_layout ) tempo_group.setLayout(tempo_layout) return tempo_group
[docs] def _create_spinbox_specs(self) -> dict[str, SpinBoxSpec]: """create spin box specs""" return { "velocity": SpinBoxSpec( label="Vel:", min_val=1, max_val=127, value=100, tooltip="Default velocity for new notes (1-127)", ), "tempo": SpinBoxSpec( label="BPM:", min_val=20, max_val=300, value=120, tooltip=self.tr("Tempo in beats per minute (20–300)"), ), "start": SpinBoxSpec( label=self.tr("Start"), min_val=0, max_val=15, value=0, tooltip=self.tr("Start step (0–15)"), ), "end": SpinBoxSpec( label=self.tr("End"), min_val=0, max_val=15, value=15, tooltip=self.tr("End step (0–15)"), ), }
[docs] def _create_learn_group(self) -> QGroupBox: """create learn group""" learn_group = QGroupBox("Learn Pattern") learn_layout = QHBoxLayout() self._add_button_with_label_from_spec( "learn", self.specs["buttons"]["learn"], learn_layout ) self._add_button_with_label_from_spec( "stop_learn", self.specs["buttons"]["stop_learn"], learn_layout ) learn_group.setLayout(learn_layout) return learn_group
[docs] def _create_measure_group(self) -> QGroupBox: """Measure management area (separate row for Add Measure button and checkbox)""" # First row: Add Measure button and Copy checkbox measure_controls_layout = QHBoxLayout() self._add_button_with_label_from_spec( "add_measure", self.specs["buttons"]["add_measure"], measure_controls_layout ) self.copy_previous_measure_checkbox = QCheckBox( f"Copy previous {self.measure_name.lower()}" ) self.copy_previous_measure_checkbox.setChecked(False) JDXiUIThemeManager.apply_button_mini_style(self.copy_previous_measure_checkbox) measure_controls_layout.addWidget(self.copy_previous_measure_checkbox) measure_controls_layout.addStretch() # Push controls to the left # Copy/Paste controls (round buttons + icon labels) copy_paste_layout = QHBoxLayout() self._add_button_with_label_from_spec( "copy", self.specs["buttons"]["copy"], copy_paste_layout ) self._add_button_with_label_from_spec( "paste", self.specs["buttons"]["paste"], copy_paste_layout ) self.paste_button.setEnabled(False) # Disabled until something is copied # Step range selection (use SpinBoxSpec for both start and end) start_label, self.start_step_spinbox = spinbox_with_label_from_spec( self.specs["spinboxes"]["start"] ) end_label, self.end_step_spinbox = spinbox_with_label_from_spec( self.specs["spinboxes"]["end"] ) step_label = QLabel(self.tr("Steps:")) to_label = QLabel(self.tr("to")) step_layout_widgets = [ step_label, start_label, self.start_step_spinbox, to_label, end_label, self.end_step_spinbox, ] step_range_layout = create_layout_with_items( items=step_layout_widgets, start_stretch=False, end_stretch=False ) measure_group, measure_layout = create_group_with_layout( label=self.measure_name_plural, vertical=True ) measure_group_layouts = [ measure_controls_layout, copy_paste_layout, step_range_layout, ] for layout in measure_group_layouts: measure_layout.addLayout(layout) measure_group.setLayout(measure_layout) return measure_group
[docs] def _log_and_return(self, ok: bool, msg: str) -> bool: """log and return""" if not ok: log.debug(msg, scope=self.__class__.__name__) return ok
[docs] def _reload_combo_options(self) -> None: """Reload combo box options from current digital/analog/drum_options.""" if not hasattr(self, "row_map") or not self.row_map: return row_options = [ self.digital_options, self.digital_options, self.analog_options, self.drum_options, ] for row, selector in self.row_map.items(): if row >= len(row_options): continue opts = row_options[row] prev_text = selector.currentText() selector.blockSignals(True) selector.clear() selector.addItems(opts) idx = selector.findText(prev_text) selector.setCurrentIndex(idx if idx >= 0 else 0) selector.blockSignals(False)
[docs] def _add_button_with_label_from_spec( self, name: str, spec: ButtonSpec, layout: QHBoxLayout, slot: Optional[Callable[[], None]] = None, ) -> QPushButton: """Create a round button + label row from a ButtonSpec and add to layout.""" label_row, btn = create_jdxi_button_with_label_from_spec(spec, checkable=False) setattr(self, f"{name}_button", btn) layout.addWidget(btn) layout.addWidget(label_row) if slot is not None: btn.clicked.connect(slot) return btn
[docs] def _add_round_action_button( self, icon_enum: Any, text: str, slot: Any, layout: QHBoxLayout, *, name: Optional[str] = None, checkable: bool = False, append_to: Optional[list] = None, ) -> QPushButton: """Create a round button with icon + text label (same style as Transport).""" btn = create_jdxi_button("") btn.setCheckable(checkable) if slot is not None: btn.clicked.connect(slot) if name: setattr(self, f"{name}_button", btn) if append_to is not None: append_to.append(btn) layout.addWidget(btn) pixmap = JDXi.UI.Icon.get_icon_pixmap( icon_enum, color=JDXi.UI.Style.FOREGROUND, size=20 ) label_row, _ = create_jdxi_row(text, icon_pixmap=pixmap) layout.addWidget(label_row) return btn
[docs] def _build_specs(self) -> dict[str, Any]: """Assemble all pattern editor button and combo specs for use in _setup_ui.""" return { "buttons": { "add_measure": ButtonSpec( label="Add Measure", icon=JDXi.UI.Icon.ADD, tooltip="Add a new Measure", slot=self._add_measure, ), "copy": ButtonSpec( label="Copy Section", icon=JDXi.UI.Icon.FILE_DOCUMENT, tooltip="Copy selected steps from current measure", slot=self._copy_section, ), "paste": ButtonSpec( label="Paste Section", icon=JDXi.UI.Icon.ADD, tooltip="Paste copied steps to current measure", slot=self._paste_section, ), "learn": ButtonSpec( label="Start", icon=JDXi.UI.Icon.PLAY, tooltip="Start learning pattern", slot=self.on_learn_pattern_button_clicked, ), "stop_learn": ButtonSpec( label="Stop", icon=JDXi.UI.Icon.STOP, tooltip="Stop learning pattern", slot=self.on_stop_learn_pattern_button_clicked, ), "tap_tempo": ButtonSpec( label="Tap", icon=JDXi.UI.Icon.DRUM, tooltip="Tap to set tempo", slot=self._on_tap_tempo, ), }, "combos": { "drum": _combo_spec(self.drum_options), "beats_per_measure": ComboBoxSpec( items=["16 beats per measure", "12 beats per measure"], tooltip="", slot=None, ), "duration": ComboBoxSpec( items=[ "16th (1 step)", "8th (2 steps)", "Dotted 8th (3 steps)", "Quarter (4 steps)", "Dotted Quarter (6 steps)", "Half (8 steps)", "Dotted Half (12 steps)", "Whole (16 steps)", ], tooltip="Default note duration for new notes", slot=None, ), "digital1": _combo_spec(self.digital_options), "digital2": _combo_spec(self.digital_options), "analog": _combo_spec(self.analog_options), }, "spinboxes": self._create_spinbox_specs(), }
[docs] def _on_button_clicked(self, btn, checked): raise NotImplementedError("To be implemented in subclass")
[docs] def _toggle_mute(self, row, checked): pass