"""
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
import logging
from typing import Any, Optional
import qtawesome as qta
from decologr import Decologr as log
from mido import Message, MetaMessage, MidiFile, MidiTrack, bpm2tempo, tempo2bpm
from PySide6.QtCore import Qt, QTimer
from PySide6.QtWidgets import (
QCheckBox,
QComboBox,
QFileDialog,
QGroupBox,
QHBoxLayout,
QLabel,
QListWidget,
QListWidgetItem,
QMessageBox,
QPushButton,
QSizePolicy,
QSpinBox,
QSplitter,
QVBoxLayout,
QWidget,
)
from rtmidi.midiconstants import CONTROL_CHANGE, NOTE_ON
from jdxi_editor.jdxi.preset.helper import JDXiPresetHelper
from jdxi_editor.jdxi.style import JDXiStyle
from jdxi_editor.midi.channel.channel import MidiChannel
from jdxi_editor.midi.io.helper import MidiIOHelper
from jdxi_editor.ui.editors.io.data.options import DIGITAL_OPTIONS, DRUM_OPTIONS
from jdxi_editor.ui.editors.synth.editor import SynthEditor
from jdxi_editor.ui.widgets.pattern.measure import PatternMeasure
[docs]
class PatternSequenceEditor(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)
"""
Initialize the PatternSequencer
:param midi_helper: Optional[MidiIOHelper]
:param preset_helper: Optional[JDXiPresetHelper]
:param parent: Optional[QWidget]
:param midi_file_editor: Optional[MidiFileEditor] Reference to MidiFileEditor for shared MIDI file
"""
[docs]
self.muted_channels = []
[docs]
self.total_measures = 1 # Start with 1 bar by default
[docs]
self.midi_helper = midi_helper
[docs]
self.preset_helper = preset_helper
[docs]
self.midi_file_editor = midi_file_editor # Reference to MidiFileEditor
[docs]
self.measures = [] # Each measure stores its own notes
[docs]
self.current_bar_index = 0 # Currently selected bar (0-indexed)
[docs]
self.total_steps = 16 # Always 16 steps per bar (don't multiply by measures)
[docs]
self.beats_per_pattern = 4
[docs]
self.beats_per_bar = 16 # Number of beats per bar (16 or 12)
[docs]
self.last_tap_time = None
[docs]
self.learned_pattern = [[None] * self.total_steps for _ in range(4)]
[docs]
self.active_notes = {} # Track active notes
[docs]
self.midi_file = MidiFile() # Initialize a new MIDI file
[docs]
self.midi_track = MidiTrack() # Create a new track
self.midi_file.tracks.append(self.midi_track) # Add the track to the file
self._setup_ui()
self._init_midi_file()
self._initialize_default_bar()
from jdxi_editor.jdxi.style.theme_manager import JDXiThemeManager
JDXiThemeManager.apply_editor_style(self)
# If MidiFileEditor is provided and has a loaded file, load it
if self.midi_file_editor and hasattr(self.midi_file_editor, "midi_state"):
if self.midi_file_editor.midi_state.file:
self.load_from_midi_file_editor()
[docs]
def _setup_ui(self):
self.layout = QVBoxLayout()
row_labels = ["Digital Synth 1", "Digital Synth 2", "Analog Synth", "Drums"]
self.buttons = [[] for _ in range(4)]
self.mute_buttons = [] # List to store mute buttons
# Define synth options
self.digital_options = DIGITAL_OPTIONS
self.analog_options = self.digital_options
# Define drum kit options
self.drum_options = DRUM_OPTIONS
# Add transport and file controls at the top
control_panel = QHBoxLayout()
# File operations area
file_group = QGroupBox("Pattern")
file_layout = QHBoxLayout()
self.load_button = QPushButton(
qta.icon("mdi.file-music-outline", color=JDXiStyle.FOREGROUND), "Load"
)
self.load_button.clicked.connect(self._load_pattern_dialog)
self.save_button = QPushButton(
qta.icon("fa5.save", color=JDXiStyle.FOREGROUND), "Save"
)
self.save_button.clicked.connect(self._save_pattern_dialog)
# Add the Clear Learned Pattern button
self.clear_learn_button = QPushButton(
qta.icon("ei.broom", color=JDXiStyle.FOREGROUND), "Clear"
)
self.clear_learn_button.clicked.connect(self._clear_learned_pattern)
self.drum_selector = QComboBox()
self.drum_selector.addItems(self.drum_options)
self.drum_selector.currentIndexChanged.connect(self._update_drum_rows)
file_layout.addWidget(self.load_button)
file_layout.addWidget(self.save_button)
file_layout.addWidget(self.clear_learn_button)
file_group.setLayout(file_layout)
control_panel.addWidget(file_group)
# Bar management area (separate row for Add Bar button and checkbox)
bar_group = QGroupBox("Bars")
bar_layout = QVBoxLayout()
# First row: Add Bar button and Copy checkbox
bar_controls_layout = QHBoxLayout()
self.add_bar_button = QPushButton(
qta.icon("mdi.plus", color=JDXiStyle.FOREGROUND), "Add Bar"
)
self.add_bar_button.clicked.connect(self._add_bar)
self.copy_previous_bar_checkbox = QCheckBox("Copy previous bar")
self.copy_previous_bar_checkbox.setChecked(False)
bar_controls_layout.addWidget(self.add_bar_button)
bar_controls_layout.addWidget(self.copy_previous_bar_checkbox)
bar_controls_layout.addStretch() # Push controls to the left
bar_layout.addLayout(bar_controls_layout)
bar_group.setLayout(bar_layout)
control_panel.addWidget(bar_group)
learn_group = QGroupBox("Learn Pattern")
learn_layout = QHBoxLayout()
# Add the Clear Learned Pattern button
self.learn_button = QPushButton(
qta.icon("ri.play-line", color=JDXiStyle.FOREGROUND), "Start"
)
self.learn_button.clicked.connect(self.on_learn_pattern_button_clicked)
self.stop_learn_button = QPushButton(
qta.icon("ri.stop-line", color=JDXiStyle.FOREGROUND), "Stop"
)
self.stop_learn_button.clicked.connect(
self.on_stop_learn_pattern_button_clicked
)
learn_layout.addWidget(self.learn_button)
learn_layout.addWidget(self.stop_learn_button)
learn_group.setLayout(learn_layout)
# control_panel.addWidget(learn_group)
# Tempo control area
tempo_group = QGroupBox("Tempo")
tempo_layout = QHBoxLayout()
self.tempo_label = QLabel("BPM:")
self.tempo_spinbox = QSpinBox()
self.tempo_spinbox.setRange(20, 300)
self.tempo_spinbox.setValue(120)
self.tempo_spinbox.valueChanged.connect(self._on_tempo_changed)
self.tap_tempo_button = QPushButton(
qta.icon("fa5s.drum", color=JDXiStyle.FOREGROUND), "Tap"
)
self.tap_tempo_button.clicked.connect(self._on_tap_tempo)
tempo_layout.addWidget(self.tempo_label)
tempo_layout.addWidget(self.tempo_spinbox)
tempo_layout.addWidget(self.tap_tempo_button)
tempo_group.setLayout(tempo_layout)
control_panel.addWidget(tempo_group)
# Beats per bar control area
beats_group = QGroupBox("Beats per Bar")
beats_layout = QHBoxLayout()
self.beats_per_bar_combo = QComboBox()
self.beats_per_bar_combo.addItems(["16 beats per bar", "12 beats per bar"])
self.beats_per_bar_combo.setCurrentIndex(0) # Default to 16 beats
self.beats_per_bar_combo.currentIndexChanged.connect(
self._on_beats_per_bar_changed
)
beats_layout.addWidget(self.beats_per_bar_combo)
beats_group.setLayout(beats_layout)
control_panel.addWidget(beats_group)
# Transport controls area
transport_group = QGroupBox("Transport")
transport_layout = QHBoxLayout()
self.start_button = QPushButton(
qta.icon("ri.play-line", color=JDXiStyle.FOREGROUND), "Play"
)
self.stop_button = QPushButton(
qta.icon("ri.stop-line", color=JDXiStyle.FOREGROUND), "Stop"
)
self.start_button.clicked.connect(self.play_pattern)
self.stop_button.clicked.connect(self.stop_pattern)
transport_layout.addWidget(self.start_button)
transport_layout.addWidget(self.stop_button)
transport_group.setLayout(transport_layout)
control_panel.addWidget(transport_group)
self.layout.addLayout(control_panel)
# Create splitter for bars list and sequencer
splitter = QSplitter(Qt.Orientation.Horizontal)
# Bars list widget
bars_group = QGroupBox("Bars")
bars_layout = QVBoxLayout()
self.bars_list = QListWidget()
self.bars_list.setMaximumWidth(150)
self.bars_list.itemClicked.connect(self._on_bar_selected)
bars_layout.addWidget(self.bars_list)
bars_group.setLayout(bars_layout)
splitter.addWidget(bars_group)
# Sequencer area
sequencer_widget = QWidget()
sequencer_layout = QVBoxLayout()
sequencer_widget.setLayout(sequencer_layout)
# Allow sequencer widget to expand vertically in full screen
sequencer_widget.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
# Connect to incoming MIDI messages (from hardware keyboard)
self.midi_helper.midi_message_incoming.connect(self._update_combo_boxes)
# Connect to outgoing MIDI messages (from virtual instrument)
self.midi_helper.midi_message_outgoing.connect(
self._update_combo_boxes_from_outgoing
)
for row_idx, label_text in enumerate(row_labels):
row_layout = QVBoxLayout()
header_layout = QHBoxLayout()
if label_text == "Drums":
icon_name = "fa5s.drum"
else:
icon_name = "msc.piano"
# Create and add label
icon_label = QLabel()
icon_label.setPixmap(
qta.icon(icon_name, color=JDXiStyle.FOREGROUND).pixmap(40, 40)
)
icon_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
header_layout.addWidget(icon_label)
label = QLabel(label_text)
if label_text == "Analog Synth":
color = JDXiStyle.ACCENT_ANALOG
else:
color = JDXiStyle.ACCENT
icon_label.setStyleSheet(f"color: {color}")
label.setStyleSheet(f"font-size: 20px; font-weight: bold; color: {color}")
label.setAlignment(Qt.AlignmentFlag.AlignCenter)
header_layout.addWidget(label)
# Add appropriate selector combo box for each row
if row_idx == 0: # Digital Synth 1
self.digital1_selector = QComboBox()
self.digital1_selector.addItems(self.digital_options)
header_layout.addWidget(self.digital1_selector)
elif row_idx == 1: # Digital Synth 2
self.digital2_selector = QComboBox()
self.digital2_selector.addItems(self.digital_options)
header_layout.addWidget(self.digital2_selector)
elif row_idx == 2: # Analog Synth
self.analog_selector = QComboBox()
self.analog_selector.addItems(self.analog_options)
header_layout.addWidget(self.analog_selector)
elif row_idx == 3: # Drums
header_layout.addWidget(self.drum_selector)
row_layout.addLayout(header_layout)
button_row_layout = QHBoxLayout()
# Add mute button
mute_button = QPushButton("Mute")
mute_button.setCheckable(True)
mute_button.setFixedSize(60, 40)
mute_button.toggled.connect(
lambda checked, row=row_idx: self._toggle_mute(row, checked)
)
self.mute_buttons.append(mute_button)
button_row_layout.addWidget(mute_button)
button_row_layout = self.ui_generate_button_row(row_idx, True)
self.button_layouts.append(button_row_layout) # Store layout reference
row_layout.addLayout(button_row_layout)
sequencer_layout.addLayout(row_layout)
# Add stretch to push content to top, but allow expansion
sequencer_layout.addStretch()
# Set size policy on splitter to allow expansion
splitter.setSizePolicy(
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
)
splitter.addWidget(sequencer_widget)
splitter.setStretchFactor(0, 0) # Bars list doesn't stretch
splitter.setStretchFactor(1, 1) # Sequencer stretches
self.layout.addWidget(splitter)
self.setLayout(self.layout)
[docs]
def _update_combo_boxes_from_outgoing(self, message):
"""
Update combo boxes from outgoing MIDI messages (sent from virtual instrument).
Converts raw message list to mido Message and calls _update_combo_boxes.
:param message: List[int] or mido.Message Raw MIDI message bytes or mido Message
"""
try:
# If it's already a mido Message, use it directly
if isinstance(message, Message):
self._update_combo_boxes(message)
return
# Convert raw message list to mido Message
if isinstance(message, (list, tuple)) and len(message) >= 2:
status_byte = message[0]
note = message[1]
velocity = message[2] if len(message) > 2 else 0
# Extract channel from status byte
# Note On: 0x90-0x9F, Note Off: 0x80-0x8F
if (status_byte & 0xF0) in (0x90, 0x80): # Note On or Note Off
channel = status_byte & 0x0F
msg_type = (
"note_on"
if (status_byte & 0xF0) == 0x90 and velocity > 0
else "note_off"
)
# Create mido Message
mido_msg = Message(
msg_type, note=note, velocity=velocity, channel=channel
)
self._update_combo_boxes(mido_msg)
except Exception as ex:
log.debug(f"Error updating combo boxes from outgoing message: {ex}")
[docs]
def _update_combo_boxes(self, message):
"""Update the combo box index to match the note for each channel."""
if message.type == "note_on" and message.velocity > 0:
note = message.note # mido uses lowercase 'note'
channel = message.channel
log.message(f"message note: {note} channel: {channel}")
# Calculate combo box index (notes start at MIDI note 36 = C2)
combo_index = note - 36
# Ensure index is valid
if combo_index < 0:
log.debug(f"Note {note} is below C2 (36), skipping combo box update")
return
# Update the appropriate combo box based on channel
if channel == MidiChannel.DIGITAL_SYNTH_1:
if combo_index < self.digital1_selector.count():
self.digital1_selector.setCurrentIndex(combo_index)
elif channel == MidiChannel.DIGITAL_SYNTH_2:
if combo_index < self.digital2_selector.count():
self.digital2_selector.setCurrentIndex(combo_index)
elif channel == MidiChannel.ANALOG_SYNTH:
if combo_index < self.analog_selector.count():
self.analog_selector.setCurrentIndex(combo_index)
elif channel == MidiChannel.DRUM_KIT:
if combo_index < self.drum_selector.count():
self.drum_selector.setCurrentIndex(combo_index)
[docs]
def _midi_note_to_combo_index(self, row, midi_note):
"""Convert a MIDI note number to the corresponding combo box index."""
if row in [0, 1]: # Digital Synths
note_list = self.digital_options
elif row == 2: # Analog Synth
note_list = self.analog_options
else: # Drums
return midi_note - 36 # Drum notes start at MIDI note 36
note_name = self._midi_to_note_name(midi_note)
return note_list.index(note_name)
[docs]
def _set_combo_box_index(self, row, index):
"""Set the combo box index for the specified row."""
if row == 0:
self.digital1_selector.setCurrentIndex(index)
elif row == 1:
self.digital2_selector.setCurrentIndex(index)
elif row == 2:
self.analog_selector.setCurrentIndex(index)
elif row == 3:
self.drum_selector.setCurrentIndex(index)
[docs]
def _initialize_default_bar(self):
"""Initialize with one default bar"""
self._add_bar()
[docs]
def _add_bar(self):
"""Add a new bar to the pattern, optionally copying from the previous bar"""
bar_number = len(self.measures) + 1
measure = PatternMeasure()
# Check if we should copy the previous bar
copy_previous = self.copy_previous_bar_checkbox.isChecked()
if copy_previous and len(self.measures) > 0:
# Copy notes from the previous bar (most recently added bar)
previous_measure = self.measures[-1]
for row in range(4):
for step in range(16):
if step < len(previous_measure.buttons[row]) and step < len(
measure.buttons[row]
):
previous_button = previous_measure.buttons[row][step]
new_button = measure.buttons[row][step]
# Copy button state and note
new_button.row = row
new_button.column = step
new_button.setChecked(previous_button.isChecked())
new_button.NOTE = previous_button.NOTE
else:
# Initialize all buttons in the new measure as unchecked with no notes
for row in range(4):
for button in measure.buttons[row]:
button.row = row
button.column = button.column # Keep local column (0-15)
button.NOTE = None
button.setChecked(False)
self.measures.append(measure)
# Add to bars list
item = QListWidgetItem(f"Bar {bar_number}")
item.setData(
Qt.ItemDataRole.UserRole, len(self.measures) - 1
) # Store bar index
self.bars_list.addItem(item)
# Select the new bar and sync sequencer display
self.bars_list.setCurrentItem(item)
self.current_bar_index = len(self.measures) - 1
# Update total measures (but keep total_steps at 16)
self.total_measures = len(self.measures)
self._update_pattern_length()
# Sync sequencer buttons with the new (empty) bar
self._sync_sequencer_with_bar(self.current_bar_index)
log.message(f"Added bar {bar_number}. Total bars: {self.total_measures}")
[docs]
def _on_bar_selected(self, item: QListWidgetItem):
"""Handle bar selection from list"""
bar_index = item.data(Qt.ItemDataRole.UserRole)
if bar_index is not None:
self.current_bar_index = bar_index
# Sync sequencer buttons with the selected bar's notes
self._sync_sequencer_with_bar(bar_index)
log.message(f"Selected bar {bar_index + 1}")
[docs]
def _sync_sequencer_with_bar(self, bar_index: int):
"""
Sync the main sequencer buttons with the notes from the specified bar.
This displays the bar's notes in the sequencer grid.
:param bar_index: int Index of the bar to display (0-indexed)
"""
if bar_index < 0 or bar_index >= len(self.measures):
return
measure = self.measures[bar_index]
# Copy note data from the measure to the main sequencer buttons
for row in range(4):
for step in range(16):
if step < len(self.buttons[row]) and step < len(measure.buttons[row]):
sequencer_button = self.buttons[row][step]
measure_button = measure.buttons[row][step]
# Sync checked state and note
sequencer_button.setChecked(measure_button.isChecked())
sequencer_button.NOTE = measure_button.NOTE
# Update tooltip
if sequencer_button.NOTE is not None:
if row == 3: # Drums
note_name = self._midi_to_note_name(
sequencer_button.NOTE, drums=True
)
else:
note_name = self._midi_to_note_name(sequencer_button.NOTE)
sequencer_button.setToolTip(f"Note: {note_name}")
else:
sequencer_button.setToolTip("")
# Update style
is_current = (self.current_step % self.total_steps) == step
sequencer_button.setStyleSheet(
self.generate_sequencer_button_style(
sequencer_button.isChecked(),
is_current,
is_selected_bar=True, # All displayed buttons are from selected bar
)
)
[docs]
def _highlight_bar(self, bar_index: int):
"""Update button styles to highlight the current step in the selected bar"""
if bar_index < 0 or bar_index >= len(self.measures):
return
# Update all sequencer buttons to show current step
for row in range(4):
for step in range(16):
if step < len(self.buttons[row]):
button = self.buttons[row][step]
is_checked = button.isChecked()
is_current = (self.current_step % self.total_steps) == step
button.setStyleSheet(
self.generate_sequencer_button_style(
is_checked, is_current, is_selected_bar=True
)
)
[docs]
def _clear_learned_pattern(self):
"""Clear the learned pattern and reset button states."""
self.learned_pattern = [[None] * self.total_steps for _ in range(4)]
# Reset the UI buttons
for row in range(4):
for button in self.buttons[row]:
button.setChecked(False)
button.NOTE = None
button.setStyleSheet(self.generate_sequencer_button_style(False))
log.message("Cleared learned pattern.")
[docs]
def _on_measure_count_changed(self, count: int):
"""Handle measure count changes"""
current_count = len(self.measures)
if count > current_count:
# Add new measures
for _ in range(count - current_count):
self._add_measure()
else:
# Remove measures from the end
while len(self.measures) > count:
self.measures.pop()
# the plan is to add more measures via tab 2, 3 & 4
# index = self.tab_widget.indexOf(measure)
# self.tab_widget.removeTab(index)
self.total_measures = count
self._update_pattern_length()
[docs]
def _update_pattern_length(self):
"""Update total pattern length based on measure count"""
# Keep total_steps at 16 (one bar) - sequencer always shows one bar at a time
# Playback will iterate through all bars
self.total_steps = 16
[docs]
def _on_beats_per_bar_changed(self, index: int):
"""Handle beats per bar changes from the combobox"""
if index == 0:
self.beats_per_bar = 16
else:
self.beats_per_bar = 12
# Update button states based on beats per bar
self._update_button_states_for_beats_per_bar()
log.message(f"Beats per bar changed to {self.beats_per_bar}")
[docs]
def _on_tempo_changed(self, bpm: int):
"""Handle tempo changes from the spinbox"""
self.set_tempo(bpm)
if self.timer and self.timer.isActive():
# Update timer interval for running sequence
ms_per_step = (60000 / bpm) / 4 # ms per 16th note
self.timer.setInterval(int(ms_per_step))
[docs]
def _on_tap_tempo(self):
"""Handle tap tempo button clicks"""
current_time = datetime.datetime.now()
if self.last_tap_time is None:
self.last_tap_time = current_time
self.tap_times = []
return
# Calculate interval since last tap
interval = (current_time - self.last_tap_time).total_seconds()
self.last_tap_time = current_time
# Ignore if too long between taps
if interval > 2.0:
self.tap_times = []
return
self.tap_times.append(interval)
# Keep last 4 taps for averaging
if len(self.tap_times) > 4:
self.tap_times.pop(0)
if len(self.tap_times) >= 2:
# Calculate average interval and convert to BPM
avg_interval = sum(self.tap_times) / len(self.tap_times)
bpm = int(60 / avg_interval)
# Constrain to valid range
bpm = max(20, min(300, bpm))
self.tempo_spinbox.setValue(bpm)
[docs]
def _save_pattern_dialog(self):
"""Open save file dialog and save pattern"""
filename, _ = QFileDialog.getSaveFileName(
self, "Save Pattern", "", "MIDI Files (*.mid);;All Files (*.*)"
)
if filename:
if not filename.lower().endswith(".mid"):
filename += ".mid"
try:
self.save_pattern(filename)
log.message(f"Pattern saved to {filename}")
except Exception as ex:
log.error(f"Error saving pattern: {ex}")
QMessageBox.critical(
self, "Error", f"Could not save pattern: {str(ex)}"
)
[docs]
def _load_pattern_dialog(self):
"""Open load file dialog and load pattern"""
filename, _ = QFileDialog.getOpenFileName(
self, "Load Pattern", "", "MIDI Files (*.mid);;All Files (*.*)"
)
if filename:
try:
self.load_pattern(filename)
log.message(f"Pattern loaded from {filename}")
# Update tempo from loaded file (already handled in load_pattern)
# Tempo is set in load_pattern() method
pass
except Exception as ex:
log.error(f"Error loading pattern: {ex}")
QMessageBox.critical(
self, "Error", f"Could not load pattern: {str(ex)}"
)
[docs]
def set_tempo(self, bpm: int):
"""Set the pattern tempo in BPM using mido."""
self.bpm = bpm
# Calculate microseconds per beat
microseconds_per_beat = int(60000000 / bpm)
# Create a set_tempo MetaMessage
tempo_message = MetaMessage("set_tempo", tempo=microseconds_per_beat)
# Add the tempo message to the first track
if self.midi_file.tracks:
self.midi_file.tracks[0].insert(0, tempo_message)
# Update playback speed if sequence is running
if hasattr(self, "timer") and self.timer and self.timer.isActive():
ms_per_step = (60000 / bpm) / 4 # ms per 16th note
self.timer.setInterval(int(ms_per_step))
log.message(f"Tempo set to {bpm} BPM")
[docs]
def _init_midi_file(self):
"""Initialize a new MIDI file with 4 tracks"""
self.midi_file = MidiFile()
for _ in range(4):
track = MidiTrack()
self.midi_file.tracks.append(track)
[docs]
def update_pattern(self):
"""Update the MIDI file with current pattern state"""
self.midi_file = MidiFile()
track = MidiTrack()
self.midi_file.tracks.append(track)
track.append(MetaMessage("set_tempo", tempo=bpm2tempo(self.bpm)))
track.append(MetaMessage("time_signature", numerator=4, denominator=4))
for row in range(4):
channel = row if row < 3 else 9
for measure_index, measure in enumerate(self.measures):
for step in range(16):
button = measure.buttons[row][step]
if button.isChecked() and button.NOTE is not None:
time = int(
(measure_index * 16 + step) * 120
) # Convert to ticks
track.append(
Message(
"note_on",
note=button.NOTE,
velocity=100,
time=time,
channel=channel,
)
)
track.append(
Message(
"note_off",
note=button.NOTE,
velocity=100,
time=time + 120,
channel=channel,
)
)
note_name = (
self._midi_to_note_name(button.NOTE, drums=True)
if row == 3
else self._midi_to_note_name(button.NOTE)
)
button.setToolTip(f"Note: {note_name}")
[docs]
def set_midi_file_editor(self, midi_file_editor: Any) -> None:
"""
Set reference to MidiFileEditor for shared MIDI file editing.
:param midi_file_editor: MidiFileEditor instance
"""
self.midi_file_editor = midi_file_editor
# If MidiFileEditor already has a loaded file, load it
if self.midi_file_editor and hasattr(self.midi_file_editor, "midi_state"):
if self.midi_file_editor.midi_state.file:
self.load_from_midi_file_editor()
[docs]
def load_from_midi_file_editor(
self, midi_file_editor: Optional[Any] = None
) -> None:
"""
Load pattern from the MidiFileEditor's current MIDI file.
:param midi_file_editor: Optional MidiFileEditor instance. If not provided, uses self.midi_file_editor
"""
try:
# Use provided instance or fall back to stored reference
editor = midi_file_editor or self.midi_file_editor
if not editor:
log.debug(
"MidiFileEditor not available - no reference provided and self.midi_file_editor is None"
)
return
if not hasattr(editor, "midi_state"):
log.debug("MidiFileEditor missing midi_state attribute")
return
# Store the reference if it wasn't set before
if not self.midi_file_editor:
self.midi_file_editor = editor
log.debug("Stored MidiFileEditor reference in Pattern Sequencer")
midi_file = editor.midi_state.file
if not midi_file:
log.debug("No MIDI file loaded in MidiFileEditor")
return
# Try to get filename from multiple possible locations
filename = None
if hasattr(midi_file, "filename"):
filename = midi_file.filename
elif hasattr(editor.midi_state, "file") and hasattr(
editor.midi_state.file, "filename"
):
filename = editor.midi_state.file.filename
if filename:
log.message(f"Loading pattern from MidiFileEditor file: {filename}")
self.load_pattern(filename)
else:
# Load from the MidiFile object directly
log.message(
"Loading pattern from MidiFileEditor's MidiFile object (no filename available)"
)
self._load_from_midi_file_object(midi_file)
except Exception as ex:
log.error(f"Error loading from MidiFileEditor: {ex}")
import traceback
log.debug(traceback.format_exc())
[docs]
def _load_from_midi_file_object(self, midi_file: MidiFile) -> None:
"""Load pattern from a MidiFile object (internal method)."""
try:
ppq = midi_file.ticks_per_beat
beats_per_bar = 4
ticks_per_bar = ppq * beats_per_bar
# Detect number of bars
num_bars = self._detect_bars_from_midi(midi_file)
log.message(f"Detected {num_bars} bars in MIDI file")
# Clear existing bars and bars list
self.bars_list.clear()
self.measures.clear()
# Create new bars
for bar_num in range(num_bars):
measure = PatternMeasure()
for row in range(4):
for button in measure.buttons[row]:
button.row = row
button.column = button.column
button.NOTE = None
button.setChecked(False)
self.measures.append(measure)
item = QListWidgetItem(f"Bar {bar_num + 1}")
item.setData(Qt.ItemDataRole.UserRole, bar_num)
self.bars_list.addItem(item)
self.total_measures = len(self.measures)
self._update_pattern_length()
# Load notes from all tracks, mapping by MIDI channel
# Channel mapping: 0 -> Digital Synth 1 (row 0), 1 -> Digital Synth 2 (row 1),
# 2 -> Analog Synth (row 2), 9 -> Drums (row 3)
notes_loaded = 0
channel_to_row = {
0: 0, # Channel 0 -> Digital Synth 1 (row 0)
1: 1, # Channel 1 -> Digital Synth 2 (row 1)
2: 2, # Channel 2 -> Analog Synth (row 2)
9: 3, # Channel 9 -> Drums (row 3)
}
for track in midi_file.tracks:
absolute_time = 0
for msg in track:
absolute_time += msg.time
# Check if message is a note_on with velocity > 0 and has a channel attribute
if msg.type == "note_on" and msg.velocity > 0:
# Get channel - note messages always have channel attribute
if not hasattr(msg, "channel"):
continue
channel = msg.channel
if channel not in channel_to_row:
continue
row = channel_to_row[channel]
bar_index = int(absolute_time / ticks_per_bar)
step_in_bar = int(
(absolute_time % ticks_per_bar) / (ticks_per_bar / 16)
)
while bar_index >= len(self.measures):
measure = PatternMeasure()
for r in range(4):
for button in measure.buttons[r]:
button.row = r
button.column = button.column
button.NOTE = None
button.setChecked(False)
self.measures.append(measure)
item = QListWidgetItem(f"Bar {len(self.measures)}")
item.setData(
Qt.ItemDataRole.UserRole, len(self.measures) - 1
)
self.bars_list.addItem(item)
if bar_index < len(self.measures) and step_in_bar < 16:
measure = self.measures[bar_index]
if step_in_bar < len(measure.buttons[row]):
button = measure.buttons[row][step_in_bar]
button.setChecked(True)
button.NOTE = msg.note
notes_loaded += 1
# Update tempo - search all tracks for tempo events
tempo_found = False
for track in midi_file.tracks:
for event in track:
if event.type == "set_tempo":
bpm = int(tempo2bpm(event.tempo))
self.tempo_spinbox.setValue(bpm)
tempo_found = True
break
if tempo_found:
break
# Select first bar and sync
if self.bars_list.count() > 0:
self.current_bar_index = 0
self.bars_list.setCurrentRow(0)
self._sync_sequencer_with_bar(0)
log.message(
f"Loaded {notes_loaded} notes from MidiFileEditor's MIDI file in {len(self.measures)} bars"
)
else:
log.warning("No bars were created from MIDI file")
except Exception as ex:
log.error(f"Error loading from MidiFileEditor: {ex}")
import traceback
log.debug(traceback.format_exc())
[docs]
def save_pattern(self, filename: str):
"""Save the current pattern to a MIDI file using mido."""
midi_file = MidiFile()
# Create tracks for each row
for row in range(4):
track = MidiTrack()
midi_file.tracks.append(track)
# Add track name and program change
track.append(Message("program_change", program=0, time=0))
# Add notes from all bars to the track
for bar_index, measure in enumerate(self.measures):
for step in range(16):
if step < len(measure.buttons[row]):
measure_button = measure.buttons[row][step]
if (
measure_button.isChecked()
and measure_button.NOTE is not None
):
# Calculate the time for the note_on event (across all bars)
global_step = bar_index * 16 + step
time = global_step * 480 # Assuming 480 ticks per beat
track.append(
Message(
"note_on",
note=measure_button.NOTE,
velocity=100,
time=time,
)
)
# Add a note_off event after a short duration
track.append(
Message(
"note_off",
note=measure_button.NOTE,
velocity=0,
time=time + 120,
)
)
# Save the MIDI file
midi_file.save(filename)
log.message(f"Pattern saved to {filename}")
# If MidiFileEditor is connected, update its file too
if self.midi_file_editor and hasattr(self.midi_file_editor, "midi_state"):
try:
# Reload the saved file into MidiFileEditor
self.midi_file_editor.midi_load_file_from_path(filename)
log.message("Updated MidiFileEditor with saved pattern")
except Exception as ex:
log.warning(f"Could not update MidiFileEditor: {ex}")
[docs]
def clear_pattern(self):
"""Clear the current bar's pattern, resetting all steps in the selected bar."""
if self.current_bar_index < len(self.measures):
measure = self.measures[self.current_bar_index]
for row in range(4):
for step in range(16):
if step < len(measure.buttons[row]):
measure.buttons[row][step].setChecked(False)
measure.buttons[row][step].NOTE = None
# Sync sequencer display
self._sync_sequencer_with_bar(self.current_bar_index)
[docs]
def _detect_bars_from_midi(self, midi_file: MidiFile) -> int:
"""Detect number of bars in MIDI file"""
ppq = midi_file.ticks_per_beat
beats_per_bar = 4 # Assuming 4/4 time signature
ticks_per_bar = ppq * beats_per_bar
max_time = 0
for track in midi_file.tracks:
absolute_time = 0
for msg in track:
absolute_time += msg.time
if not msg.is_meta:
max_time = max(max_time, absolute_time)
# Calculate number of bars (round up)
num_bars = int((max_time / ticks_per_bar) + 1) if max_time > 0 else 1
return max(1, num_bars) # At least 1 bar
[docs]
def load_pattern(self, filename: str):
"""Load a pattern from a MIDI file"""
try:
midi_file = MidiFile(filename)
ppq = midi_file.ticks_per_beat
beats_per_bar = 4
ticks_per_bar = ppq * beats_per_bar
# Detect number of bars
num_bars = self._detect_bars_from_midi(midi_file)
log.message(f"Detected {num_bars} bars in MIDI file")
# Clear existing bars and bars list
self.bars_list.clear()
self.measures.clear()
# Create new bars without selecting them (to avoid UI flicker)
for bar_num in range(num_bars):
measure = PatternMeasure()
# Initialize all buttons in the new measure as unchecked with no notes
for row in range(4):
for button in measure.buttons[row]:
button.row = row
button.column = button.column # Keep local column (0-15)
button.NOTE = None
button.setChecked(False)
self.measures.append(measure)
# Add to bars list
item = QListWidgetItem(f"Bar {bar_num + 1}")
item.setData(Qt.ItemDataRole.UserRole, bar_num) # Store bar index
self.bars_list.addItem(item)
# Update total measures
self.total_measures = len(self.measures)
self._update_pattern_length()
# Load notes from ALL tracks, mapping by MIDI channel (like Midi Editor)
# Channel mapping: 0 -> Digital Synth 1 (row 0), 1 -> Digital Synth 2 (row 1),
# 2 -> Analog Synth (row 2), 9 -> Drums (row 3)
notes_loaded = 0
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
}
for track in midi_file.tracks:
absolute_time = 0
for msg in track:
absolute_time += msg.time
# Only process note_on messages with velocity > 0
if (
msg.type == "note_on"
and msg.velocity > 0
and hasattr(msg, "channel")
):
channel = msg.channel
# Map channel to row (skip channels we don't support)
if channel not in channel_to_row:
continue
row = channel_to_row[channel]
# Calculate which bar and step this note belongs to
bar_index = int(absolute_time / ticks_per_bar)
step_in_bar = int(
(absolute_time % ticks_per_bar) / (ticks_per_bar / 16)
)
# Ensure we have enough bars (safety check)
while bar_index >= len(self.measures):
measure = PatternMeasure()
for r in range(4):
for button in measure.buttons[r]:
button.row = r
button.column = button.column
button.NOTE = None
button.setChecked(False)
self.measures.append(measure)
item = QListWidgetItem(f"Bar {len(self.measures)}")
item.setData(
Qt.ItemDataRole.UserRole, len(self.measures) - 1
)
self.bars_list.addItem(item)
if bar_index < len(self.measures) and step_in_bar < 16:
measure = self.measures[bar_index]
if step_in_bar < len(measure.buttons[row]):
button = measure.buttons[row][step_in_bar]
button.setChecked(True)
button.NOTE = msg.note # mido uses lowercase 'note'
notes_loaded += 1
log.message(
f"Loaded {notes_loaded} notes from MIDI file across all tracks and channels"
)
# Update tempo (use first tempo found)
for event in midi_file.tracks[0]:
if event.type == "set_tempo":
bpm = int(tempo2bpm(event.tempo))
self.tempo_spinbox.setValue(bpm)
break # Use first tempo found
# Select first bar and sync sequencer display
if self.bars_list.count() > 0:
self.current_bar_index = 0
self.bars_list.setCurrentRow(0)
self._sync_sequencer_with_bar(0)
log.message(
f"Loaded {num_bars} bars from MIDI file. Bars are displayed in the side panel."
)
except Exception as ex:
log.error(f"Error loading pattern: {ex}")
QMessageBox.critical(self, "Error", f"Could not load pattern: {str(ex)}")
[docs]
def play_pattern(self):
"""Start playing the pattern"""
if hasattr(self, "timer") and self.timer and self.timer.isActive():
return # Already playing
self.current_step = 0
# Calculate interval based on tempo (ms per 16th note)
ms_per_step = (60000 / self.bpm) / 4
# Create and start timer
self.timer = QTimer(self)
self.timer.timeout.connect(self._play_step)
self.timer.start(int(ms_per_step))
# Update button states
self.start_button.setEnabled(False)
self.stop_button.setEnabled(True)
log.message("Pattern playback started")
[docs]
def stop_pattern(self):
"""Stop playing the pattern"""
if hasattr(self, "timer") and self.timer:
self.timer.stop()
self.timer = None
# Reset step counter
self.current_step = 0
# Update button states
self.start_button.setEnabled(True)
self.stop_button.setEnabled(False)
# Send all notes off
if self.midi_helper:
for channel in range(16):
self.midi_helper.send_raw_message([CONTROL_CHANGE | channel, 123, 0])
log.message("Pattern playback stopped")
[docs]
def _note_name_to_midi(self, note_name: str) -> int:
"""Convert note name (e.g., 'C4') to MIDI note number"""
# Note name to semitone mapping
note_to_semitone = {
"C": 0,
"C#": 1,
"D": 2,
"D#": 3,
"E": 4,
"F": 5,
"F#": 6,
"G": 7,
"G#": 8,
"A": 9,
"A#": 10,
"B": 11,
}
# Split note name into note and octave
if "#" in note_name:
note = note_name[:-1] # Everything except last character (octave)
octave = int(note_name[-1])
else:
note = note_name[0]
octave = int(note_name[1])
# Calculate MIDI note number
# MIDI note 60 is middle C (C4)
# Each octave is 12 semitones
# Formula: (octave + 1) * 12 + semitone
midi_note = (octave + 1) * 12 + note_to_semitone[note]
return midi_note
[docs]
def _midi_to_note_name(self, midi_note: int, drums=False) -> str:
"""Convert MIDI note number to note name (e.g., 60 -> 'C4')"""
# Note mapping (reverse of note_to_semitone)
semitone_to_note = [
"C",
"C#",
"D",
"D#",
"E",
"F",
"F#",
"G",
"G#",
"A",
"A#",
"B",
]
# Calculate octave and note
octave = (midi_note // 12) - 1
note = semitone_to_note[midi_note % 12]
if drums:
return self.drum_options[midi_note - 36]
# so not drums
return f"{note}{octave}"
[docs]
def _play_step(self):
"""Plays the current step and advances to the next one."""
# Calculate which bar and step within that bar
# Use beats_per_bar to determine steps per bar
steps_per_bar = self.beats_per_bar
total_pattern_steps = len(self.measures) * steps_per_bar
global_step = (
self.current_step % total_pattern_steps if total_pattern_steps > 0 else 0
)
bar_index = global_step // steps_per_bar
step_in_bar = global_step % steps_per_bar
log.message(
f"Playing step {step_in_bar} in bar {bar_index + 1} (global step {global_step}, {self.beats_per_bar} beats per bar)"
)
# Sync sequencer with the current bar being played
if bar_index < len(self.measures):
self.current_bar_index = bar_index
self._sync_sequencer_with_bar(bar_index)
# Highlight the current bar in the bars list
if bar_index < self.bars_list.count():
self.bars_list.setCurrentRow(bar_index)
# Ensure the item is visible (scroll to it if needed)
item = self.bars_list.item(bar_index)
if item:
self.bars_list.scrollToItem(item)
# Play notes from the current bar
measure = self.measures[bar_index]
for row in range(4):
if step_in_bar < len(measure.buttons[row]):
measure_button = measure.buttons[row][step_in_bar]
if (
measure_button.isChecked()
and hasattr(measure_button, "NOTE")
and measure_button.NOTE is not None
):
# Determine channel based on row
channel = (
row if row < 3 else 9
) # channels 0,1,2 for synths, 9 for drums
# Send Note On message using the stored note
if self.midi_helper:
if channel not in self.muted_channels:
log.message(
f"Row {row} active at step {step_in_bar} in bar {bar_index + 1}, sending note {measure_button.NOTE} on channel {channel}"
)
self.midi_helper.send_raw_message(
[NOTE_ON | channel, measure_button.NOTE, 100]
) # velocity 100
# Note Off message after a short delay
QTimer.singleShot(
100,
lambda ch=channel, n=measure_button.NOTE: self.midi_helper.send_raw_message(
[NOTE_ON | ch, n, 0]
),
)
else:
logging.warning("MIDI helper not available")
# Advance to next step (across all bars)
steps_per_bar = self.beats_per_bar
total_pattern_steps = len(self.measures) * steps_per_bar
self.current_step = (
(self.current_step + 1) % total_pattern_steps
if total_pattern_steps > 0
else 0
)
# Update UI to show current step
for row in range(4):
for col in range(16): # Always 16 steps in sequencer
if col < len(self.buttons[row]):
button = self.buttons[row][col]
is_checked = button.isChecked()
is_current = (
step_in_bar == col
) # Current step within the displayed bar
button.setStyleSheet(
self.generate_sequencer_button_style(
is_checked, is_current, is_selected_bar=True
)
)
[docs]
def _learn_pattern(self, message):
"""Learn the pattern of incoming MIDI notes, preserving rests."""
if message.type == "note_on" and message.velocity > 0:
note = message.note # mido uses lowercase 'note'
# Determine the correct row for the note
for row in range(4):
if note in self._get_note_range_for_row(row):
# Calculate step within current bar (0 to beats_per_bar-1)
step_in_bar = self.current_step % self.beats_per_bar
# Store note in the current bar's measure
if self.current_bar_index < len(self.measures):
measure = self.measures[self.current_bar_index]
if step_in_bar < len(measure.buttons[row]):
measure_button = measure.buttons[row][step_in_bar]
measure_button.setChecked(True)
measure_button.NOTE = note
# Also update sequencer display
if step_in_bar < len(self.buttons[row]):
self.buttons[row][step_in_bar].setChecked(True)
self.buttons[row][step_in_bar].NOTE = note
# Record the note in the learned pattern (for compatibility)
self.learned_pattern[row][step_in_bar] = note
self.active_notes[note] = row # Mark the note as active
# Add the note_on message to the MIDI track
self.midi_track.append(
Message("note_on", note=note, velocity=message.velocity, time=0)
)
break # Stop checking once the note is assigned
elif message.type == "note_off":
note = message.note # mido uses lowercase 'note'
if note in self.active_notes:
# Advance step only if the note was previously turned on
log.message(f"Note off: {note} at step {self.current_step}")
del self.active_notes[note] # Remove the note from active notes
# Add the note_off message to the MIDI track
self.midi_track.append(
Message("note_off", note=note, velocity=0, time=0)
)
# Advance step within current bar (0 to beats_per_bar-1)
self.current_step = (self.current_step + 1) % self.beats_per_bar
[docs]
def _apply_learned_pattern(self):
"""Apply the learned pattern to the sequencer UI."""
for row in range(4):
# Clear current button states for the row
for button in self.buttons[row]:
button.setChecked(False)
button.NOTE = None
button.setStyleSheet(self.generate_sequencer_button_style(False))
if row == 3:
drums_note_name = self._midi_to_note_name(button.NOTE, drums=True)
button.setToolTip(f"Note: {drums_note_name}")
else:
note_name = self._midi_to_note_name(button.NOTE)
button.setToolTip(f"Note: {note_name}")
# Apply the learned pattern
for time, note in enumerate(self.learned_pattern[row]):
# Ensure only one button is activated per note
if note is not None and 0 <= time < len(self.buttons[row]):
button = self.buttons[row][time]
button.setChecked(True)
button.NOTE = note
button.setStyleSheet(self.generate_sequencer_button_style(True))
if row == 3:
drums_note_name = self._midi_to_note_name(
button.NOTE, drums=True
)
button.setToolTip(f"Note: {drums_note_name}")
else:
note_name = self._midi_to_note_name(button.NOTE)
button.setToolTip(f"Note: {note_name}")
[docs]
def _get_note_range_for_row(self, row):
"""Get the note range for a specific row."""
if row in [0, 1]:
return range(60, 72) # C4 to B4
if row == 2:
return range(48, 60) # C3 to B3
return range(36, 48) # C2 to B2
[docs]
def _move_to_next_step(self):
"""Move to the next step in the pattern."""
# Move to the next step
self.current_step = (self.current_step + 1) % self.total_steps
# Stop learning after 16 steps
if self.current_step == 0:
log.message("Learning complete after 16 steps.")
self.on_stop_learn_pattern_button_clicked()
self.timer.stop()
del self.timer
else:
log.message(f"Moved to step {self.current_step}")
[docs]
def save_midi_file(self, filename: str):
"""Save the recorded MIDI messages to a file."""
with open(filename, "wb") as output_file:
self.midi_file.save(output_file)
log.message(f"MIDI file saved to {filename}")
[docs]
def _toggle_mute(self, row, checked):
"""Toggle mute for a specific row."""
channel = row if row < 3 else 9 # channels 0,1,2 for synths, 9 for drums
if checked:
log.message(f"Row {row} muted")
self.muted_channels.append(channel)
else:
log.message(f"Row {row} unmuted")
self.muted_channels.remove(channel)
# Update the UI or internal state to reflect the mute status
# For example, you might want to disable the buttons in the row
for button in self.buttons[row]:
button.setEnabled(not checked)
[docs]
def _update_drum_rows(self):
"""Update displayed buttons based on the selected drum option."""
self.drum_selector.currentText()
"""
for option, layout in self.drum_row_layouts.items():
is_visible = option == selected_option
# Iterate over widgets inside the QHBoxLayout
for i in range(layout.count()):
button = layout.itemAt(i).widget()
if button:
button.setVisible(is_visible)
"""
# Ensure UI updates properly
self.update()