"""
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.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.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 _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 _on_measure_selected(self, item: QListWidgetItem):
raise NotImplementedError("Should be implemented in subclass")
@property
@property
@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 _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 _toggle_mute(self, row, checked):
pass