"""
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 random
from typing import Any, Callable, Optional
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 (
QButtonGroup,
QGroupBox,
QHBoxLayout,
QListWidgetItem,
QMessageBox,
QPushButton,
QWidget,
)
from jdxi_editor.core.jdxi import JDXi
from jdxi_editor.midi.conversion.note import MidiNoteConverter
from jdxi_editor.midi.file.controller import (
MidiFileController,
MidiFileControllerConfig,
)
from jdxi_editor.midi.io.helper import MidiIOHelper
from jdxi_editor.midi.playback.controller import (
PatternPlaybackController,
PlaybackConfig,
)
from jdxi_editor.midi.playback.state import MidiPlaybackState
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.helper import (
get_button_note_spec,
reset_button,
reset_measure,
set_sequencer_style,
sync_button_note_spec,
update_button_state,
)
from jdxi_editor.ui.editors.pattern.learner import (
PatternLearner,
PatternLearnerConfig,
PatternLearnerEvent,
)
from jdxi_editor.ui.editors.pattern.sequencer.row import SequencerRow
from jdxi_editor.ui.editors.pattern.timing.config import TimingConfig
from jdxi_editor.ui.editors.pattern.ui import PatternUI
from jdxi_editor.ui.preset.helper import JDXiPresetHelper
from jdxi_editor.ui.sequencer.button.manager import (
NoteButtonAttrs,
SequencerButtonManager,
)
from jdxi_editor.ui.style.factory import generate_sequencer_button_style
from jdxi_editor.ui.widgets.combo_box.synchronizer import (
ComboBoxSynchronizer,
ComboBoxUpdateConfig,
)
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.usb.recording import USBFileRecordingWidget
from picomidi import Channel, ControlChange, ControlValue, MidiChannel, MidiTempo
from picomidi.core.tempo import (
MeasureBeats,
convert_absolute_time_to_delta_time,
milliseconds_per_note,
ms_to_ticks,
ticks_to_duration_ms,
)
from picomidi.message.type import MidoMessageType, MidoMetaMessageType
from picomidi.messages.note import MidiNote, build_midi_note, note_off, note_on
from picomidi.playback.engine import (
PlaybackEngine,
TransportState,
)
from picomidi.sequencer.event import SequencerEvent
from picomidi.ui.widget.button.note import NoteButtonEvent
from picomidi.ui.widget.transport.spec import TransportSpec
from picoui.helpers import group_with_layout
from picoui.specs.widgets import (
ButtonSpec,
FileSelectionSpec,
)
from picoui.widget.helper import get_file_path_from_spec
from picoui.widget.setters import set_spinbox_value
[docs]
def copy_note_attrs_from_event(button: SequencerButton, event: PatternLearnerEvent):
"""copy note attributes"""
button.note = event.note
button.note_velocity = event.velocity
button.note_duration = event.duration_ms
[docs]
def copy_note_attrs(prev: SequencerButton, new: SequencerButton):
"""copy note attrs"""
new.note = prev.note
for a in NoteButtonAttrs.COPYABLE:
setattr(new, a, getattr(prev, a, None))
[docs]
def get_channel_for_row(row: int) -> int:
"""Get idi channel for each row"""
channel = row if row < 3 else 9
return channel
[docs]
class PatternSequenceEditor(PatternUI):
"""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,
midi_helper=midi_helper,
preset_helper=preset_helper,
midi_file_editor=midi_file_editor,
)
# Use Qt translations: add .ts/.qm for locale (e.g. en_GB "Measure" -> "Bar", "Measures" -> "Bars")
[docs]
self._state: TransportState | None = None
[docs]
self.timing = TimingConfig()
[docs]
self.beats_per_pattern: int = 4 # Number of beats per pattern
[docs]
self.last_tap_time: Optional[datetime] = None # Last tap time
[docs]
self.tap_times: list[float] = [] # List of tap times
# Learned pattern (row -> step -> midi_note)
[docs]
self.learned_pattern: list[list[Optional[int]]] = [
[None] * self.total_steps for _ in range(self.sequencer_rows)
]
[docs]
self.active_notes: dict[int, int] = (
{}
) # Track active notes (midi_note -> row index)
[docs]
self._pattern_paused: bool = False
[docs]
self._pattern_loop_enabled: bool = True # Loop by default; toggle via future UI
[docs]
self._pattern_file_path: Optional[str] = (
None # Last saved/loaded path for USB auto-filename
)
[docs]
self.playback_engine: PlaybackEngine = PlaybackEngine()
self._wire_pattern_widget()
self._init_style()
self._init_playing_controllers()
self._connect_midi_signals()
self.clear_pattern()
# --- Load file
self._load_from_midi_file_editor_if_available()
# 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 _on_pattern_measure_selected(self, measure_index: int) -> None:
"""Called when user selects a measure in PatternWidget."""
self._button_manager.set_buttons(self.buttons)
step_in_measure = self.current_step % self.measure_beats
self._on_playback_step_changed(step_in_measure)
[docs]
def _init_style(self):
"""init style"""
JDXi.UI.Theme.apply_editor_style(self)
[docs]
def _update_timer_interval(self):
"""update timer interval"""
interval = self.timing.ms_per_step
self.timer.setInterval(int(interval))
[docs]
def _load_from_midi_file_editor_if_available(self) -> None:
"""Load from MidiFileEditor if it has a file."""
self.clear_pattern()
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 _on_learner_note_learned(self, event: PatternLearnerEvent) -> None:
"""Update measure/sequencer from learned note; append NOTE_ON/OFF."""
step_in_measure = event.step
row = event.row
for btn in self._get_step_buttons(row, step_in_measure):
update_button_from_learned_event(btn, event)
duration_ticks = ms_to_ticks(event.duration_ms, self.bpm, self.ppq)
self._add_note_on_off_pair(duration_ticks, event)
[docs]
def _add_note_on_off_pair(self, duration_ticks: int, event: PatternLearnerEvent):
"""add note on off pair to track"""
midi_note = MidiNote(
note=event.note, velocity=event.velocity, duration_ms=duration_ticks
)
on_msg, off_msg = midi_note.to_on_off_pair()
self.midi_track.extend([on_msg, off_msg])
[docs]
def _on_learner_step_advance(self, step: int) -> None:
"""Keep editor current_step in sync with the learner."""
self.current_step = step
[docs]
def _on_learner_stopped(self) -> None:
"""When learner stops: disconnect learn handler, reconnect combo updates."""
self.midi_helper.midi_message_incoming.disconnect(self._learn_pattern)
self._reconnect_combo_synchronizer()
self._sync_midi_track_from_learner()
[docs]
def _reconnect_combo_synchronizer(self) -> None:
"""Reconnect MIDI incoming to combo synchronizer."""
if self._combo_synchronizer:
self.midi_helper.midi_message_incoming.connect(
self._combo_synchronizer.process_incoming_midi
)
[docs]
def _sync_midi_track_from_learner(self) -> None:
"""Copy learned MIDI messages into the editor's midi_track for saving."""
self.midi_track.clear()
self.midi_track.extend(self._pattern_learner.get_midi_track())
[docs]
def _init_playing_controllers(self) -> None:
"""Create MIDI/playback/learn controllers and wire to UI (Pattern Playing)."""
self.playback_engine = PlaybackEngine()
self._note_converter = MidiNoteConverter(drum_options=list(self.drum_options))
combo_config = ComboBoxUpdateConfig()
self._combo_synchronizer = ComboBoxSynchronizer(
config=combo_config,
midi_converter=self._note_converter,
scope=self.__class__.__name__,
)
self._combo_synchronizer.set_all_selectors(
self.digital1_selector,
self.digital2_selector,
self.analog_selector,
self.drum_selector,
)
self._combo_synchronizer.set_selector_options(0, list(self.digital_options))
self._combo_synchronizer.set_selector_options(1, list(self.digital_options))
self._combo_synchronizer.set_selector_options(2, list(self.analog_options))
self._combo_synchronizer.set_selector_options(3, list(self.drum_options))
self._init_pattern_learner()
self._init_midi_file_controller()
self._init_playback_controller()
self._init_button_manager()
[docs]
def _init_playback_controller(self):
"""init playback controller"""
ms_per_step = int(self._calculate_step_duration())
playback_config = PlaybackConfig(
ticks_per_beat=self.ppq,
beats_per_measure=4,
measure_beats=self.measure_beats,
default_bpm=self.timing.bpm,
playback_interval_ms=ms_per_step,
)
self._playback_controller = PatternPlaybackController(
config=playback_config,
playback_engine=self.playback_engine,
scope=self.__class__.__name__,
)
self._setup_playback_controller_connections()
[docs]
def _setup_playback_controller_connections(self):
"""setup playback controller connections"""
self._playback_controller.on_playback_started = (
self._on_playback_controller_started
)
self._playback_controller.on_playback_stopped = (
self._on_playback_controller_stopped
)
self._playback_controller.on_playback_paused = (
self._on_playback_controller_paused
)
self._playback_controller.on_bar_changed = self._on_playback_measure_changed
self._playback_controller.on_step_changed = self._on_playback_step_changed
if self.midi_helper:
self._playback_controller.on_midi_event = (
lambda msg: self.midi_helper.send_raw_message(msg.bytes())
)
[docs]
def _init_midi_file_controller(self):
"""init midi file controller"""
midi_controller_config = MidiFileControllerConfig(
ticks_per_beat=self.ppq,
beats_per_measure=4,
default_bpm=self.timing.bpm,
default_velocity=100,
)
self._midi_file_controller = MidiFileController(
config=midi_controller_config,
midi_converter=self._note_converter,
scope=self.__class__.__name__,
)
self._midi_file_controller.create_new_file()
self._sync_from_midi_file_controller()
[docs]
def _init_pattern_learner(self):
"""init pattern learner"""
learner_config = PatternLearnerConfig(
total_steps=self.measure_beats,
total_rows=4,
default_velocity=100,
default_duration_ms=120.0,
)
self._pattern_learner = PatternLearner(
config=learner_config,
midi_converter=self._note_converter,
scope=self.__class__.__name__,
)
self._pattern_learner.on_note_learned = self._on_learner_note_learned
self._pattern_learner.on_step_advance = self._on_learner_step_advance
self._pattern_learner.on_learning_stopped = self._on_learner_stopped
[docs]
def _refresh_preset_options(self) -> None:
"""Override to also update note converter and combo synchronizer."""
super()._refresh_preset_options()
if hasattr(self, "_note_converter"):
self._note_converter.update_drum_options(list(self.drum_options))
if hasattr(self, "_combo_synchronizer"):
self._combo_synchronizer.set_selector_options(0, list(self.digital_options))
self._combo_synchronizer.set_selector_options(1, list(self.digital_options))
self._combo_synchronizer.set_selector_options(2, list(self.analog_options))
self._combo_synchronizer.set_selector_options(3, list(self.drum_options))
[docs]
def _on_playback_controller_started(self) -> None:
"""Update play/stop buttons when playback controller starts."""
self.update_transport_buttons(TransportState.PLAYING)
[docs]
def _on_playback_controller_paused(self) -> None:
"""Update play/pause buttons when playback controller pauses."""
self.update_transport_buttons(TransportState.PAUSED)
self._pattern_paused = True
[docs]
def _on_playback_controller_stopped(self) -> None:
"""Update play/stop buttons when playback controller stops."""
self.update_transport_buttons(TransportState.STOPPED)
self.current_step = 0
self._pattern_paused = False
[docs]
def _apply_transport_state(self, state: TransportState) -> None:
"""Apply transport state to the UI."""
self.update_transport_buttons(state)
if state == TransportState.STOPPED:
self.current_step = 0
self._pattern_paused = False
if state == TransportState.PAUSED:
self._pattern_paused = True
[docs]
def _log_traceback(self) -> None:
"""Log current exception traceback at debug level."""
import traceback
log.debug(traceback.format_exc())
[docs]
def _on_playback_measure_changed(self, measure_index: int) -> None:
"""Sync sequencer and measure list to the current measure during playback."""
if self.pattern_widget and self.pattern_widget.select_measure(measure_index):
self._button_manager.set_buttons(self.buttons)
self._scroll_measures_list_to(measure_index)
[docs]
def _on_playback_step_changed(self, step_in_measure: int) -> None:
"""Update step highlight during playback."""
last_step = getattr(self, "_playback_last_step_in_measure", -1)
for row in range(self.sequencer_rows):
for btn in self._get_step_buttons(row, last_step) if 0 <= last_step else []:
set_sequencer_style(btn=btn, is_current=False, checked=btn.isChecked())
for btn in self._get_step_buttons(row, step_in_measure):
set_sequencer_style(btn=btn, is_current=True, checked=btn.isChecked())
self._playback_last_step_in_measure = step_in_measure
[docs]
def _initialize_default_measure(self):
"""Initialize with one default measure"""
self._add_measure()
[docs]
def _sync_from_midi_file_controller(self) -> None:
"""Sync midi_file, midi_track, bpm from the MIDI file controller."""
self.midi_file = self._midi_file_controller.midi_file
self.midi_track = self._midi_file_controller.midi_file.tracks[0]
self.timing_bpm = self._midi_file_controller.get_tempo()
[docs]
def _add_measure(self):
"""Add measure, optionally copying from the previous one."""
if not self.pattern_widget:
return
copy_previous = self.copy_previous_measure_checkbox.isChecked()
self.pattern_widget.add_measure(copy_previous=copy_previous)
self.total_measures = self.pattern_widget.get_measure_count()
self._sync_pattern_length()
self._button_manager.set_buttons(self.buttons)
log.message(
message=f"Added measure. Total: {self.total_measures}",
scope=self.__class__.__name__,
)
[docs]
def _sync_pattern_length(self):
"""Update total measures (but keep total_steps at 16)"""
self.total_measures = len(self.measure_widgets)
self._update_pattern_length()
[docs]
def _connect_midi_signals(self):
combo = getattr(self, "_combo_synchronizer", None)
if combo:
self._reconnect_combo_synchronizer()
self.midi_helper.midi_message_outgoing.connect(combo.process_outgoing_midi)
[docs]
def _init_model_structures(self):
self.mute_buttons = []
self.specs = self._build_specs()
[docs]
def reset_all_measures(self):
"""reset all measures"""
for measure in self.measure_widgets:
reset_measure(measure)
[docs]
def add_and_reset_new_measure(self):
"""add and reset new measure"""
measure = PatternMeasureWidget()
reset_measure(measure)
self.measure_widgets.append(measure)
return measure
[docs]
def _midi_note_to_combo_index(self, row, midi_note):
"""Convert a MIDI note number to the corresponding combo box index."""
note_list = {
0: self.digital_options,
1: self.digital_options,
2: self.analog_options,
3: self.drum_options,
}
note_list = note_list.get(row)
if note_list is not None:
note_name = self._midi_to_note_name(midi_note)
return note_list.index(note_name)
return None
[docs]
def _set_combo_box_index(self, row, index):
"""Set the combo box index for the specified row."""
self.row_map = self._build_row_map()
selector = self.row_map.get(row)
if selector is not None:
selector.setCurrentIndex(index)
[docs]
def _on_measure_selected(self, item: QListWidgetItem):
"""Handle measure selection from list"""
measure_index = item.data(Qt.ItemDataRole.UserRole)
if measure_index is not None:
self.current_measure_index = measure_index
# Sync sequencer buttons with the selected bar's notes
self._sync_sequencer_with_measure(measure_index)
log.message(
message=f"Selected bar {measure_index + 1}",
scope=self.__class__.__name__,
)
[docs]
def _copy_section(self):
"""Copy a section of notes from the current bar"""
if self.current_measure_index >= len(self.measure_widgets):
QMessageBox.warning(self, "Copy", "No bar selected")
return
start_step = self.start_step_spinbox.value()
end_step = self.end_step_spinbox.value()
if start_step > end_step:
QMessageBox.warning(self, "Copy", "Start step must be <= end step")
return
self.clipboard = self.pattern_widget.copy_measure_section(
self.current_measure_index, start_step, end_step
)
if self.paste_button:
self.paste_button.setEnabled(True)
log.message(
message=f"Copied steps {start_step}-{end_step} from bar {self.current_measure_index + 1}",
scope=self.__class__.__name__,
)
[docs]
def _paste_section(self):
"""Paste copied section to the current bar"""
if self.clipboard is None:
QMessageBox.warning(
self, "Paste", "Nothing copied. Use Copy Section first."
)
return
if self.current_measure_index >= len(self.measure_widgets):
QMessageBox.warning(self, "Paste", "No bar selected")
return
start_step = self.start_step_spinbox.value()
ok = self.pattern_widget.paste_measure_section(
self.current_measure_index, start_step, self.clipboard
)
if not ok:
QMessageBox.warning(self, "Paste", "Invalid clipboard data")
return
num_steps = self.clipboard["end_step"] - self.clipboard["start_step"] + 1
log.message(
message=f"Pasted {num_steps} steps to bar {self.current_measure_index + 1} starting at step {start_step}",
scope=self.__class__.__name__,
)
[docs]
def _sync_sequencer_with_measure(self, bar_index: int):
"""Sync visible sequencer with measure data. No-op when using PatternWidget (display is direct)."""
if self.pattern_widget:
return
if bar_index < 0 or bar_index >= len(self.measure_widgets):
return
measure = self.measure_widgets[bar_index]
self._for_each_button(lambda r, s: self._sync_measure(measure, r, s))
[docs]
def _sync_measure(self, measure: PatternMeasureWidget, row: int, step: int):
if step < len(self.buttons[row]) and step < len(measure.buttons[row]):
sequencer_button = self.buttons[row][step]
measure_button: SequencerButton = measure.buttons[row][step]
# Sync checked state and note
update_button_state(sequencer_button, measure_button.isChecked())
copy_note_attrs(new=sequencer_button, prev=measure_button)
sync_button_note_spec(sequencer_button)
self._update_tooltip(row, sequencer_button)
self._update_sequencer_button_style(sequencer_button, step)
[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.measure_widgets):
return
if self.pattern_widget:
current_step_in_bar = self.current_step % self.total_steps
self.pattern_widget.for_each_button(
lambda r, s, btn: self.pattern_widget.highlight_step(
r, s, btn.isChecked(), current_step_in_bar == s
)
)
else:
self._for_each_button(self._highlight_step)
[docs]
def _highlight_step(self, row: int, step: int):
"""Legacy: used when pattern_widget is None."""
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(
JDXi.UI.Style.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(self.sequencer_rows)
]
self._clear_sequencer()
log.message(message="Cleared learned pattern.", scope=self.__class__.__name__)
[docs]
def _clear_sequencer(self):
if self.pattern_widget:
self.pattern_widget.clear_buttons(reset_button, set_sequencer_style)
else:
for row in range(self.sequencer_rows):
for button in self.buttons[row]:
reset_button(button)
set_sequencer_style(button)
[docs]
def clear_pattern(self):
"""Clear the learned pattern and reset button states."""
self._pattern_file_path = None
self._init_midi_file()
if self.pattern_widget:
self.pattern_widget.clear_and_reset(1)
self._button_manager.set_buttons(self.buttons)
log.message(message="Cleared pattern.", scope=self.__class__.__name__)
[docs]
def _on_measure_count_changed(self, count: int):
"""Handle measure count changes"""
if self.pattern_widget:
self.pattern_widget.ensure_measure_count(count)
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 = MeasureBeats.PER_MEASURE_4_4
[docs]
def _store_note_in_measures(self, button: SequencerButton, checked: bool):
"""sore notes in measures"""
step_in_bar = button.column # button.column is 0-15 for sequencer buttons
step_buttons = self._get_step_buttons(button.row, step_in_bar)
if step_buttons:
measure_button = step_buttons[0]
update_button_state(measure_button, checked)
if checked:
measure_button.note = button.note
# Copy duration if available
if hasattr(button, NoteButtonAttrs.NOTE_DURATION):
measure_button.duration_ms = button.note_duration
# Copy velocity if available
if hasattr(button, NoteButtonAttrs.NOTE_VELOCITY):
measure_button.velocity = button.note_velocity
sync_button_note_spec(measure_button)
else:
reset_button(measure_button)
[docs]
def _on_beats_per_bar_changed(self, index: int):
"""Handle beats per bar changes from the combobox"""
if index == 0:
self.measure_beats = MeasureBeats.PER_MEASURE_4_4
else:
self.measure_beats = MeasureBeats.PER_MEASURE_3_4
# 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.measure_beats}")
[docs]
def _each_measure_beat_row(self, func, row: int):
for step in range(self.measure_beats):
func(row, step)
[docs]
def _on_beats_per_measure_changed(self, index: int):
"""Handle beats per measure changes from the combobox"""
if index == 0:
self._update_measure_beats(MeasureBeats.PER_MEASURE_4_4)
else:
self._update_measure_beats(MeasureBeats.PER_MEASURE_3_4)
# Update button states based on beats per measure
self._update_button_states_for_beats_per_measure()
log.message(f"Beats per measure changed to {self.measure_beats}")
[docs]
def _update_measure_beats(self, beats: int):
self.measure_beats = beats
[docs]
def _get_duration_ms(self) -> float:
"""Get the default duration in milliseconds based on the duration combo selection"""
# Duration multipliers: 16th=1, 8th=2, dotted 8th=3, quarter=4, dotted quarter=6, half=8, dotted half=12, whole=16
duration_multipliers = [1, 2, 3, 4, 6, 8, 12, 16]
index = self.duration_combo.currentIndex()
steps = duration_multipliers[index] if index < len(duration_multipliers) else 1
# Each step is a 16th note, so duration = steps * (beat_duration / 4)
# beat_duration in ms = 60000 / bpm
step_duration_ms = self._calculate_step_duration()
return step_duration_ms * steps
[docs]
def _calculate_step_duration(self) -> float:
"""calculate step duration"""
return float(milliseconds_per_note(self.timing_bpm))
[docs]
def _on_duration_changed(self, index: int):
"""Handle duration changes from the combo box"""
# Duration change doesn't affect existing notes, only new ones
# This is just a placeholder for potential future functionality
pass
[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():
ms_per_step = milliseconds_per_note(bpm)
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(MidiTempo.SECONDS_PER_MINUTE / 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.
When a file was previously loaded (from MidiFileEditor or Load), the dialog
opens with that path pre-filled so the user can save back to the same file.
"""
spec = FileSelectionSpec(
mode="save",
caption="Save Pattern",
default_name="",
filter="MIDI Files (*.mid);;All Files (*.*)",
)
start_path = self._pattern_file_path or ""
filename = get_file_path_from_spec(self, spec, start_dir=start_path)
if filename:
if not filename.lower().endswith(".mid"):
filename += ".mid"
try:
self.save_pattern(filename)
log.message(
message=f"Pattern saved to {filename}",
scope=self.__class__.__name__,
)
except Exception as ex:
log.error(
message=f"Error saving pattern: {ex}", scope=self.__class__.__name__
)
QMessageBox.critical(
self, "Error", f"Could not save pattern: {str(ex)}"
)
[docs]
def load_pattern_dialog(self):
"""Open load file dialog and load pattern"""
spec = FileSelectionSpec(
mode="open",
caption="Load Pattern",
filter="MIDI Files (*.mid);;All Files (*.*)",
)
filename = get_file_path_from_spec(self, spec)
if filename:
try:
self.load_pattern(filename)
log.message(
message=f"Pattern loaded from {filename}",
scope=self.__class__.__name__,
)
# Update tempo from loaded file (already handled in load_pattern)
# Tempo is set in load_pattern() method
pass
except Exception as ex:
log.error(
message=f"Error loading pattern: {ex}",
scope=self.__class__.__name__,
)
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.timing_bpm = bpm
# Calculate microseconds per beat
microseconds_per_beat = int(MidiTempo.MICROSECONDS_PER_MINUTE / bpm)
# Create a set_tempo MetaMessage
tempo_message = MetaMessage(
MidoMessageType.SET_TEMPO.value, 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 = milliseconds_per_note(bpm) # ms per 16th note
self.timer.setInterval(int(ms_per_step))
log.message(message=f"Tempo set to {bpm} BPM", scope=self.__class__.__name__)
[docs]
def _init_midi_file(self):
"""Initialize a new MIDI file with 4 tracks"""
self.midi_file = MidiFile()
for _ in range(self.sequencer_rows):
track = MidiTrack()
self.midi_file.tracks.append(track)
[docs]
def update_pattern(self):
"""update pattern"""
self.midi_file = MidiFile()
track = MidiTrack()
self.midi_file.tracks.append(track)
ppq = self.midi_file.ticks_per_beat
beats_per_bar = 4
ticks_per_bar = ppq * beats_per_bar
ticks_per_step = ppq // 4 # 16th notes
track.append(
MetaMessage(MidoMetaMessageType.SET_TEMPO, tempo=bpm2tempo(self.timing_bpm))
)
track.append(
MetaMessage(MidoMetaMessageType.TIME_SIGNATURE, numerator=4, denominator=4)
)
events = []
for measure_index, measure in enumerate(self.measure_widgets):
for step in range(self.measure_beats):
self._add_event_to_pattern(
events, measure, measure_index, step, ticks_per_bar, ticks_per_step
)
# Sort events by absolute time
events.sort(key=lambda e: e[0])
convert_absolute_time_to_delta_time(events, track)
[docs]
def _add_event_to_pattern(
self,
events: list[Any],
measure: PatternMeasureWidget,
measure_index: int,
step: int,
ticks_per_bar: int,
ticks_per_step: int,
):
absolute_tick = measure_index * ticks_per_bar + step * ticks_per_step
for row in range(self.sequencer_rows):
channel = get_channel_for_row(row)
button = measure.buttons[row][step]
spec = get_button_note_spec(button)
if button.isChecked() and spec.is_active and spec.midi_note is not None:
on_msg, off_msg = spec.midi_note.to_on_off_pair()
on_msg.channel = channel
on_msg.time = 0
off_msg.channel = channel
off_msg.time = 0
events.append((absolute_tick, on_msg))
events.append((absolute_tick + ticks_per_step, off_msg))
[docs]
def set_midi_file_editor(self, midi_file_editor: Any) -> str | 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()
return self.midi_file_editor.midi_state.file.filename
else:
log.debug(
message="No MIDI file loaded in MidiFileEditor",
scope=self.__class__.__name__,
)
return None
return None
[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(
message="Editor not available - no reference provided and self.midi_file_editor is None",
scope=self.__class__.__name__,
)
return
if not hasattr(editor, "midi_state"):
log.debug(
message="Editor missing midi_state attribute",
scope=self.__class__.__name__,
)
return
# Store the reference if it wasn't set before
if not self.midi_file_editor:
self.set_midi_file_editor(editor)
log.debug(
message="Stored MidiFileEditor reference in Pattern Sequencer",
scope=self.__class__.__name__,
)
midi_file = self.midi_file_editor.midi_state.file
if not midi_file:
log.debug(
message="No MIDI file loaded in MidiFileEditor",
scope=self.__class__.__name__,
)
return
self._get_filename_from_available_locations(editor, midi_file)
except Exception as ex:
log.error(
message=f"Error loading from MidiFileEditor: {ex}",
scope=self.__class__.__name__,
)
import traceback
log.debug(traceback.format_exc())
[docs]
def _get_filename_from_available_locations(self, editor: Any | None, midi_file):
"""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(
message=f"Loading pattern from MidiFileEditor file: {filename}",
scope=self.__class__.__name__,
)
self.load_pattern(filename)
else:
# --- Load from the MidiFile object directly
log.message(
message="Loading pattern from MidiFileEditor's MidiFile object (no filename available)",
scope=self.__class__.__name__,
)
self._load_from_midi_file_object(midi_file)
[docs]
def _load_from_midi_file_object(self, midi_file: MidiFile) -> None:
"""Load pattern from a MidiFile object (internal method)."""
try:
log.message(
message="[Phase1] _load_from_midi_file_object: start",
scope=self.__class__.__name__,
)
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(
message=f"Detected {num_bars} bars in MIDI file",
scope=self.__class__.__name__,
)
self._clear_measures_and_measures_list()
self._create_new_measures(num_bars)
log.parameter(
scope=self.__class__.__name__,
message="[Phase1] measure count after create",
parameter=len(self.measure_widgets),
)
self.total_measures = len(self.measure_widgets)
self._update_pattern_length()
notes_loaded = self._load_notes_from_tracks_to_channels(
midi_file, ticks_per_bar
)
self._sync_loaded_buttons_to_measure_data()
self._update_tooltips_for_loaded_notes()
self._sync_midi_state_from_loaded_file(midi_file)
self._update_spinbox_from_file_tempo(midi_file)
self._select_first_measure_and_sync(notes_loaded)
self._update_button_states_for_beats_per_measure()
# Set path for save-back when loaded from MidiFileEditor (Phase 4 T9)
if hasattr(midi_file, "filename") and midi_file.filename:
self._pattern_file_path = midi_file.filename
if self.measure_widgets and self.buttons:
enabled_count = sum(
1 for row in self.buttons for btn in row if btn.isEnabled()
)
log.parameter(
scope=self.__class__.__name__,
message="[Phase1] buttons enabled after load",
parameter=enabled_count,
)
except Exception as ex:
log.error(
message=f"Error loading from MidiFileEditor: {ex}",
scope=self.__class__.__name__,
)
import traceback
log.debug(traceback.format_exc())
[docs]
def _create_new_measures(self, num_bars: int):
"""Create new bars"""
if self.pattern_widget:
self.pattern_widget.ensure_measure_count(num_bars)
self.total_measures = self.pattern_widget.get_measure_count()
[docs]
def _load_notes_from_tracks_to_channels(
self, midi_file: MidiFile, ticks_per_bar: int
) -> int:
"""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
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 == MidoMessageType.NOTE_ON.value 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 self.channel_to_row:
continue
row = self.channel_to_row[channel]
bar_index = int(absolute_time / ticks_per_bar)
step_in_bar = int(
(absolute_time % ticks_per_bar)
/ (ticks_per_bar / MeasureBeats.PER_MEASURE_4_4)
)
if bar_index >= len(self.measure_widgets) and self.pattern_widget:
self.pattern_widget.ensure_measure_count(bar_index + 1)
if (
bar_index < len(self.measure_widgets)
and step_in_bar < MeasureBeats.PER_MEASURE_4_4
):
measure = self.measure_widgets[bar_index]
if step_in_bar < len(measure.buttons[row]):
button = measure.buttons[row][step_in_bar]
update_button_state(button, True)
button.note = msg.note
# Store velocity from MIDI file editor
button.note_velocity = (
msg.velocity
if hasattr(msg, "velocity")
else self.velocity_spinbox.value()
)
# Set default duration for MIDI file editor loaded notes
button.note_duration = self._get_duration_ms()
sync_button_note_spec(button)
notes_loaded += 1
return notes_loaded
[docs]
def _sync_midi_state_from_loaded_file(self, midi_file: MidiFile) -> None:
"""Sync PPQ from loaded file for correct save/playback timing (Phase 3).
Uses loaded file's ticks_per_beat; keeps midi_file/midi_track for learner compatibility.
"""
self.ppq = midi_file.ticks_per_beat
log.message(
message=f"[Phase3] Synced PPQ from loaded file: {self.ppq} ticks/beat",
scope=self.__class__.__name__,
)
[docs]
def _select_first_measure_and_sync(self, notes_loaded: int):
"""Select first bar and sync button manager after measures exist."""
if self.pattern_widget and self.pattern_widget.get_measure_count() > 0:
self.pattern_widget.select_measure(0)
self._button_manager.set_buttons(self.buttons)
log.message(
message=f"Loaded {notes_loaded} notes from MidiFileEditor's MIDI file in {len(self.measure_widgets)} bars",
scope=self.__class__.__name__,
)
else:
log.warning(
message="No bars were created from MIDI file",
scope=self.__class__.__name__,
)
[docs]
def _update_spinbox_from_file_tempo(self, midi_file: MidiFile):
"""update spinbox from file tempo"""
tempo_bpm = None
for track in midi_file.tracks:
for event in track:
if event.type == MidoMessageType.SET_TEMPO.value:
tempo_bpm = int(tempo2bpm(event.tempo))
break
if tempo_bpm is not None:
break
if tempo_bpm is not None:
set_spinbox_value(self.tempo_spinbox, tempo_bpm)
self.set_tempo(tempo_bpm)
[docs]
def _clear_measures_and_measures_list(self):
"""Clear existing bars and bars list"""
if self.pattern_widget:
self.pattern_widget.clear_and_reset(0)
[docs]
def save_pattern(self, filename: str):
"""Save the current pattern to a MIDI file using mido."""
midi_file = MidiFile()
midi_file.ticks_per_beat = self.ppq
self._create_tracks_per_row(midi_file)
self._save_midi_file(filename, midi_file)
self._pattern_file_path = filename
self._update_midi_file_editor_with_file(filename)
[docs]
def _create_tracks_per_row(self, midi_file: MidiFile):
"""Create tracks for each row"""
for row in range(self.sequencer_rows):
track = MidiTrack()
midi_file.tracks.append(track)
# Add track name and program change
track.append(
Message(MidoMessageType.PROGRAM_CHANGE.value, program=0, time=0)
)
self._add_motes_from_all_bars_to_track(row, track)
[docs]
def _add_motes_from_all_bars_to_track(self, row: int, track: MidiTrack[Any]):
"""Add notes from all bars to the track"""
for bar_index, measure in enumerate(self.measure_widgets):
for step in range(MeasureBeats.PER_MEASURE_4_4):
if step < len(measure.buttons[row]):
measure_button = measure.buttons[row][step]
spec = get_button_note_spec(measure_button)
if measure_button.isChecked() and spec.is_active:
time = self._calculate_note_on_time(bar_index, step)
channel = get_channel_for_row(row)
self._append_note_on_and_off(spec, time, channel, track)
[docs]
def _calculate_note_on_time(self, bar_index: int, step: int) -> int:
"""Calculate the time for the note_on event (across all bars).
Uses self.ppq (from loaded file when applicable) for correct timing.
"""
ppq = self.ppq
ticks_per_step = ppq // 4 # 16th note
steps_per_bar = self.measure_beats
global_step = bar_index * steps_per_bar + step
return global_step * ticks_per_step
[docs]
def _update_midi_file_editor_with_file(self, filename: str):
"""f 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(
message="Updated MidiFileEditor with saved pattern",
scope=self.__class__.__name__,
)
except Exception as ex:
log.warning(
message=f"Could not update MidiFileEditor: {ex}",
scope=self.__class__.__name__,
)
[docs]
def _save_midi_file(self, filename: str, midi_file: MidiFile):
"""Save the MIDI file"""
midi_file.save(filename)
log.message(
message=f"Pattern saved to {filename}", scope=self.__class__.__name__
)
[docs]
def _append_note_on_and_off(
self, spec: NoteButtonEvent, time: int, channel: int, track: MidiTrack
):
"""Append Note on and Off using spec.midi_note as canonical source."""
if spec.midi_note is None:
return
on_msg, off_msg = spec.midi_note.to_on_off_pair()
on_msg.channel = channel
on_msg.time = time
off_msg.channel = channel
off_msg.time = time + 120 # Fixed 16th-note grid duration in ticks
track.append(on_msg)
track.append(off_msg)
def clear_pattern(self):
"""Clear the current bar's pattern, resetting all steps in the selected bar."""
if self.current_measure_index < len(self.measure_widgets):
measure = self.measure_widgets[self.current_measure_index]
def reset_if_present(r, s):
if s < len(measure.buttons[r]):
reset_button(measure.buttons[r][s])
self._for_each_button(reset_if_present)
# Sync sequencer digital
self._sync_sequencer_with_measure(self.current_measure_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:
log.message(
message=f"[Phase1] load_pattern: start filename={filename}",
scope=self.__class__.__name__,
)
self._pattern_file_path = filename
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_measures = self._detect_bars_from_midi(midi_file)
log.message(
message=f"Detected {num_measures} bars in MIDI file",
scope=self.__class__.__name__,
)
self._clear_measures_and_measures_list()
if self.pattern_widget:
self.pattern_widget.ensure_measure_count(num_measures)
self.total_measures = len(self.measure_widgets)
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
note_events = self._collect_notes_with_times_and_tempos_from_file(midi_file)
note_durations = self._collect_note_on_and_off_to_calculate_durations(
note_events, ppq
)
self._assign_notes_and_durations_to_buttons(
note_durations,
note_events,
notes_loaded,
ppq,
ticks_per_bar,
)
self._sync_loaded_buttons_to_measure_data()
self._update_tooltips_for_loaded_notes()
self._sync_midi_state_from_loaded_file(midi_file)
self._update_spinbox_from_file_tempo(midi_file)
self._set_to_first_measure(num_measures)
self._update_button_states_for_beats_per_measure()
if self.measure_widgets and self.buttons:
enabled_count = sum(
1 for row in self.buttons for btn in row if btn.isEnabled()
)
log.parameter(
scope=self.__class__.__name__,
message="[Phase1] buttons enabled after load_pattern",
parameter=enabled_count,
)
except Exception as ex:
log.error(
message=f"Error loading pattern: {ex}", scope=self.__class__.__name__
)
QMessageBox.critical(self, "Error", f"Could not load pattern: {str(ex)}")
[docs]
def _set_to_first_measure(self, num_measures: int):
"""Select first bar and sync sequencer"""
if self.pattern_widget and self.pattern_widget.get_measure_count() > 0:
self.pattern_widget.select_measure(0)
self._button_manager.set_buttons(self.buttons)
log.message(
message=f"Loaded {num_measures} measures from MIDI file. Measures are displayed in the side panel.",
scope=self.__class__.__name__,
)
[docs]
def _collect_note_on_and_off_to_calculate_durations(
self, note_events: list[Any], ppq: int
) -> dict[Any, Any]:
# Second pass: match note_on with note_off to calculate durations
# Dictionary to track active notes: (channel, note) -> (on_time, on_tempo)
active_notes = {}
note_durations = {} # (channel, note, on_time) -> duration_ms
for abs_time, msg, channel, tempo in note_events:
note_key = (channel, msg.note)
if msg.type == MidoMessageType.NOTE_ON.value and msg.velocity > 0:
# Store note_on event
active_notes[note_key] = (abs_time, tempo)
elif msg.type == MidoMessageType.NOTE_OFF.value or (
msg.type == MidoMessageType.NOTE_ON.value and msg.velocity == 0
):
# Find matching note_on
if note_key in active_notes:
on_time, on_tempo = active_notes[note_key]
duration_ticks = abs_time - on_time
# Convert ticks to milliseconds using the tempo at note_on time
# tempo is in microseconds per quarter note
# duration_ms = (duration_ticks / ticks_per_beat) * (tempo / 1000)
duration_ms = ticks_to_duration_ms(
ticks=duration_ticks, tempo=on_tempo, ppq=ppq
)
note_durations[(channel, msg.note, on_time)] = duration_ms
del active_notes[note_key]
return note_durations
[docs]
def _collect_notes_with_times_and_tempos_from_file(
self, midi_file: MidiFile
) -> list[Any]:
"""First pass: collect all note events with their absolute times and tempos"""
note_events = [] # List of (absolute_time, msg, channel, tempo_at_time)
current_tempo_us = (
MidiTempo.BPM_120_USEC
) # Default tempo (120 BPM in microseconds)
for track in midi_file.tracks:
absolute_time = 0
for msg in track:
absolute_time += msg.time
# Track tempo changes
if msg.type == MidoMessageType.SET_TEMPO.value:
current_tempo_us = msg.tempo
# Collect note_on and note_off events
if hasattr(msg, "channel") and (
msg.type == MidoMessageType.NOTE_ON.value
or msg.type == MidoMessageType.NOTE_OFF.value
):
note_events.append(
(absolute_time, msg, msg.channel, current_tempo_us)
)
return note_events
[docs]
def _create_transport_control(
self,
spec: TransportSpec,
layout: QHBoxLayout,
button_group: Optional[QButtonGroup],
) -> None:
"""Create a transport button + label row (same pattern as Midi File Player)."""
btn = create_jdxi_button_from_spec(spec, button_group)
setattr(self, f"{spec.name}_button", btn)
layout.addWidget(btn)
pixmap = JDXi.UI.Icon.get_icon_pixmap(
spec.icon, color=JDXi.UI.Style.FOREGROUND, size=20
)
label_row, _ = create_jdxi_row(spec.text, icon_pixmap=pixmap)
layout.addWidget(label_row)
[docs]
def _init_transport_controls(self) -> QGroupBox:
"""Build Transport group with Play, Stop, Pause, Shuffle Play (same style as Midi File Player)."""
group, layout = group_with_layout(label="Transport")
transport_layout = QHBoxLayout()
layout.addStretch()
layout.addLayout(transport_layout)
layout.addStretch()
transport_button_group = QButtonGroup(self)
transport_button_group.setExclusive(True)
for spec in self.specs["transport"]:
self._create_transport_control(
spec, transport_layout, transport_button_group
)
return group
[docs]
def pattern_transport_play(self) -> None:
"""Start pattern playback (delegate to play_pattern)."""
self.play_pattern()
[docs]
def pattern_transport_stop(self) -> None:
"""Stop pattern playback (delegate to stop_pattern)."""
self.stop_pattern()
[docs]
def pattern_transport_pause_toggle(self) -> None:
"""Pause or resume pattern playback."""
if self._pattern_paused:
# Resume: restart engine from current position, then restart timer.
# Must call engine.start(current_tick) to reset _start_time so process_until_now
# does not think a long pause elapsed (which would cause a burst of notes).
if hasattr(self, "playback_engine") and self.playback_engine:
events = self.playback_engine.events
idx = self.playback_engine.event_index
if events and idx < len(events):
current_tick = events[idx].absolute_tick
else:
current_tick = self.playback_engine.start_tick
self.playback_engine.start(current_tick)
if hasattr(self, "timer") and self.timer and not self.timer.isActive():
playback_interval_ms = 20
self.timer.start(playback_interval_ms)
self._pattern_paused = False
log.message(
message="Pattern playback resumed", scope=self.__class__.__name__
)
else:
# Pause: stop timer and pause engine so no events advance during pause.
if hasattr(self, "timer") and self.timer and self.timer.isActive():
self.timer.stop()
if hasattr(self, "playback_engine") and self.playback_engine:
self.playback_engine.pause()
self._pattern_paused = True
log.message(
message="Pattern playback paused", scope=self.__class__.__name__
)
[docs]
def pattern_shuffle_play(self) -> None:
"""Select a random bar and start playback."""
if not self.measure_widgets:
return
idx = random.randint(0, len(self.measure_widgets) - 1)
self.current_measure_index = idx
if self.measures_list and idx < self.measures_list.count():
self.measures_list.setCurrentRow(idx)
self._sync_sequencer_with_measure(idx)
self.play_pattern()
[docs]
def _ms_to_ticks(self, duration_ms: int, ticks_per_beat: int) -> int:
"""
Convert milliseconds to MIDI ticks.
1 beat = 60000 / bpm ms = ticks_per_beat ticks
"""
if duration_ms <= 0:
return 0
ticks = (
duration_ms
* self.timing_bpm
* ticks_per_beat
/ MidiTempo.MILLISECONDS_PER_MINUTE
)
return max(1, int(ticks))
[docs]
def _collect_sequencer_events(self, ticks_per_beat: int) -> list[SequencerEvent]:
"""collect sequencer events"""
ticks_per_step = ticks_per_beat // 4 # 16th
events: list[SequencerEvent] = []
for bar_index, measure in enumerate(self.measure_widgets):
for step in range(min(self.measure_beats, MeasureBeats.PER_MEASURE_4_4)):
tick = (bar_index * self.measure_beats + step) * ticks_per_step
for row in range(self.sequencer_rows):
if step >= len(measure.buttons[row]):
continue
if row in self.muted_channels:
continue
btn = measure.buttons[row][step]
if not btn.isChecked():
continue
spec = get_button_note_spec(btn)
if not spec.is_active:
continue
channel = get_channel_for_row(row)
velocity = max(0, min(127, spec.velocity))
duration_ticks = (
self._ms_to_ticks(spec.duration_ms, ticks_per_beat)
or ticks_per_step
)
events.append(
self._sequencer_event(
channel, duration_ticks, spec, tick, velocity
)
)
return events
[docs]
def _sequencer_event(
self,
channel: int,
duration_ticks: int,
spec: NoteButtonEvent,
tick: int,
velocity: int,
) -> SequencerEvent:
"""sequencer Event"""
return SequencerEvent(
tick=tick,
note=spec.note,
velocity=velocity,
channel=channel,
duration_ticks=duration_ticks,
)
[docs]
def _build_midi_file_for_playback(self) -> MidiFile:
"""Build a MidiFile from the current pattern for PlaybackEngine."""
ticks_per_beat = self.ppq
# Add tempo (use mido.bpm2tempo to match Playback Worker / MIDI editor)
tempo_us = bpm2tempo(self.timing_bpm)
events: list[SequencerEvent] = self._collect_sequencer_events(ticks_per_beat)
midi_file = MidiFile(type=1, ticks_per_beat=ticks_per_beat)
track = MidiTrack()
midi_file.tracks.append(track)
track.append(
MetaMessage(
MidoMessageType.SET_TEMPO.value,
tempo=tempo_us,
time=0,
)
)
if not events:
return midi_file
# Expand note_on / note_off
midi_events = []
for absolute_tick in events:
midi_note = build_midi_note(
event=absolute_tick, channel=absolute_tick.channel, bpm=self.timing_bpm
)
midi_events.append(
(
absolute_tick.tick,
note_on(midi_note),
)
)
midi_events.append(
(
absolute_tick.tick + absolute_tick.duration_ticks,
note_off(midi_note),
)
)
midi_events.sort(key=lambda x: x[0])
prev_tick = 0
for tick, msg in midi_events:
msg.time = tick - prev_tick
track.append(msg)
prev_tick = tick
return midi_file
[docs]
def play_pattern(self, *, restart_loop: bool = False):
"""Start playing the pattern via PlaybackEngine.
:param restart_loop: If True, we're restarting after a loop; skip USB recording
(recording continues from initial Play).
"""
if hasattr(self, "timer") and self.timer and self.timer.isActive():
return # Already playing
if not self.measure_widgets:
return
mid = self._build_midi_file_for_playback()
# Engine needs at least one note event to be useful
if len(mid.tracks[0]) <= 1:
log.message(
message="Pattern has no notes to play", scope=self.__class__.__name__
)
return
# Start USB recording only on initial Play, not when looping
if not restart_loop:
self.midi_state.file = mid
self.midi_state.file.filename = self._pattern_file_path or "pattern"
# Use 1 hour max; user stops recording manually via Stop button
self.midi_state.file_duration_seconds = 3600.0
if self.usb_recorder.file_auto_generate_checkbox.isChecked():
self.usb_recorder.update_auto_wav_filename()
self.usb_recorder.start_recording()
self.playback_engine.load_file(mid)
for ch in range(MeasureBeats.PER_MEASURE_4_4):
self.playback_engine.mute_channel(ch, ch in self.muted_channels)
if self.midi_helper:
self.playback_engine.on_event = (
lambda msg: self.midi_helper.send_raw_message(msg.bytes())
)
self.playback_engine.start(0)
self._playback_last_bar_index = -1
self._playback_last_step_in_bar = -1
# Use a short, precise interval so process_until_now() runs often; the engine
# sends events by wall-clock time, so this reduces stutter from timer jitter.
playback_interval_ms = 20
self.timer = QTimer(self)
self.timer.setTimerType(Qt.TimerType.PreciseTimer)
self.timer.timeout.connect(self._on_playback_tick)
self.timer.start(playback_interval_ms)
if hasattr(self, "play_button") and self.play_button:
update_button_state(
self.play_button, checked_state=True, enabled_state=False
)
if hasattr(self, "stop_button") and self.stop_button:
update_button_state(
self.stop_button, checked_state=False, enabled_state=True
)
log.message(message="Pattern playback started", scope=self.__class__.__name__)
[docs]
def _on_playback_tick(self):
"""Drive PlaybackEngine and sync UI to current position."""
self.playback_engine.process_until_now()
# Sync bar/step highlight from engine position
if self.playback_engine.events:
idx = self.playback_engine.event_index
if idx > 0 and idx <= len(self.playback_engine.events):
tick = self.playback_engine.events[idx - 1].absolute_tick
else:
tick = self.playback_engine.start_tick
else:
tick = 0
ticks_per_step = self.ppq // 4
total_steps = (
len(self.measure_widgets) * self.measure_beats
if self.measure_widgets
else 0
)
global_step = (tick // ticks_per_step) % total_steps if total_steps > 0 else 0
self.current_step = global_step
bar_index = global_step // self.measure_beats
step_in_bar = global_step % self.measure_beats
last_bar = getattr(self, "_playback_last_bar_index", -1)
last_step = getattr(self, "_playback_last_step_in_bar", -1)
if bar_index < len(self.measure_widgets):
self.current_measure_index = bar_index
self._sync_sequencer_on_step_change(bar_index, last_bar)
# Only update step highlight when the current step changes (at most 2 columns)
if step_in_bar != last_step:
for row in range(self.sequencer_rows):
for btn in (
self._get_step_buttons(row, last_step) if 0 <= last_step else []
):
set_sequencer_style(
btn=btn, is_current=False, checked=btn.isChecked()
)
for btn in self._get_step_buttons(row, step_in_bar):
set_sequencer_style(
btn=btn, is_current=True, checked=btn.isChecked()
)
self._playback_last_step_in_bar = step_in_bar
if self.playback_engine.state == TransportState.STOPPED:
log.message(
message="Pattern playback finished", scope=self.__class__.__name__
)
if self._pattern_loop_enabled:
if self.timer:
self.timer.stop()
self.play_pattern(restart_loop=True)
else:
self._apply_transport_state(TransportState.STOPPED)
[docs]
def _sync_sequencer_on_step_change(self, bar_index: int, last_bar: int | Any):
"""Only sync sequencer and bar list when the displayed bar changes"""
if bar_index != last_bar:
self._sync_sequencer_with_measure(bar_index)
self._playback_last_bar_index = bar_index
if self.measures_list and bar_index < self.measures_list.count():
self.measures_list.setCurrentRow(bar_index)
item = self.measures_list.item(bar_index)
if item:
self.measures_list.scrollToItem(item)
[docs]
def _update_transport_ui(self):
"""update ui regarding playing state"""
state = TransportState.STOPPED
self.update_transport_buttons(state)
[docs]
def _sync_ui_to_stopped(self) -> None:
"""Sync UI to stopped state: stop timer, update play/stop buttons."""
if hasattr(self, "timer") and self.timer:
self.timer.stop()
self.timer = None
self._pattern_paused = False
self._apply_transport_state(TransportState.STOPPED)
[docs]
def stop_pattern(self):
"""Stop playing the pattern"""
self.playback_engine.stop()
self._sync_ui_to_stopped()
# Stop USB recording (pattern records until user stops manually)
if hasattr(self.usb_recorder, "recorder") and hasattr(
self.usb_recorder.recorder, "stop_recording"
):
self.usb_recorder.recorder.stop_recording()
# Reset step counter
self.current_step = 0
# Send all notes off
if self.midi_helper:
self._send_notes_off_for_all_channels()
log.message(message="Pattern playback stopped", scope=self.__class__.__name__)
[docs]
def _send_notes_off_for_all_channels(self):
"""send notes off for all channels"""
for channel in range(MidiChannel.NUMBER):
self._send_notes_off_for_channel(channel)
[docs]
def _send_notes_off_for_channel(self, zero_indexed_channel: int):
"""send notes off for a given channel"""
one_indexed_channel = zero_indexed_channel + 1
notes_off = ControlChange(
channel=Channel.from_value(one_indexed_channel),
controller=ControlChange.ALL_NOTES_OFF,
control_value=ControlValue(0),
)
self.midi_helper.send_raw_message(notes_off.to_bytes())
[docs]
def _note_name_to_midi(self, note_name: str) -> int:
"""Convert note name (e.g., 'C4') to MIDI note number."""
return self._note_converter.note_name_to_midi(note_name)
[docs]
def _midi_to_note_name(self, midi_note: int, drums: bool = False) -> str:
"""Convert MIDI note number to note name (e.g., 60 -> 'C4') or drum name."""
return self._note_converter.midi_to_note_name(midi_note, drums=drums)
[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
measure_beats = self.measure_beats
total_pattern_beats = len(self.measure_widgets) * self.measure_beats
global_step = (
self.current_step % total_pattern_beats if total_pattern_beats > 0 else 0
)
measure_index = global_step // measure_beats
step_in_measure = global_step % measure_beats
log.message(
message=f"Playing step {step_in_measure} in bar {measure_index + 1} "
f"(global step {global_step}"
f" {self.measure_beats} beats per bar)",
scope=self.__class__.__name__,
)
# Sync sequencer with the current bar being played
if measure_index < len(self.measure_widgets):
self.current_measure_index = measure_index
self._sync_sequencer_with_measure(measure_index)
# Highlight the current bar in the bars list
if measure_index < self.measures_list.count():
self.measures_list.setCurrentRow(measure_index)
# Ensure the item is visible (scroll to it if needed)
item = self.measures_list.item(measure_index)
if item:
self.measures_list.scrollToItem(item)
self._play_notes_from_current_measure(measure_index, step_in_measure)
self._advance_to_next_step()
self._update_ui_for_current_step(step_in_measure)
[docs]
def _update_ui_for_current_step(self, step_in_measure: int):
"""Update UI to show current step"""
def update_style(r, s):
if s >= len(self.buttons[r]):
return
button = self.buttons[r][s]
is_checked = button.isChecked()
is_current = step_in_measure == s
button.setStyleSheet(
JDXi.UI.Style.generate_sequencer_button_style(
is_checked, is_current, is_selected_bar=True
)
)
self._for_each_button(update_style)
[docs]
def _advance_to_next_step(self):
"""Advance to next step (across all measures)"""
measure_beats = self.measure_beats
total_pattern_beats = len(self.measure_widgets) * measure_beats
self.current_step = (
(self.current_step + 1) % total_pattern_beats
if total_pattern_beats > 0
else 0
)
[docs]
def _play_notes_from_current_measure(
self, measure_index: int, step_in_measure: int
):
"""Play notes from the current measure"""
measure = self.measure_widgets[measure_index]
for row in range(self.sequencer_rows):
if step_in_measure < len(measure.buttons[row]):
measure_button = measure.buttons[row][step_in_measure]
button_event: NoteButtonEvent = get_button_note_spec(measure_button)
if measure_button.isChecked() and button_event.is_active:
# Determine channel based on row
channel = get_channel_for_row(row)
midi_note = build_midi_note(
event=button_event, channel=channel, bpm=self.timing_bpm
)
# Send Note On message using the stored note
self.play_midi_note(midi_note)
[docs]
def play_midi_note(self, midi_note: MidiNote):
"""Send NOTE_ON and NOTE_OFF for a MidiNote."""
if midi_note.channel in self.muted_channels:
return
if not self.midi_helper:
return
note_on_message, note_off_message = midi_note.to_on_off_pair()
self.midi_helper.send_mido_message(note_on_message)
QTimer.singleShot(
midi_note.duration_ms,
lambda: self.midi_helper.send_mido_message(note_off_message),
)
[docs]
def _learn_pattern(self, message):
"""Learn the pattern of incoming MIDI notes, preserving rests."""
if message.type == MidoMessageType.NOTE_ON.value and message.velocity > 0:
note = message.note # mido uses lowercase 'note'
# Determine the correct row for the note
for row in range(self.sequencer_rows):
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.measure_beats
# Store note in the current bar's measure and sequencer
for btn in self._get_step_buttons(row, step_in_bar):
update_button_state(btn, True)
btn.note = note
btn.velocity = message.velocity
btn.duration_ms = self._get_duration_ms()
sync_button_note_spec(btn)
# 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(
MidoMessageType.NOTE_ON.value,
note=note,
velocity=message.velocity,
time=0,
)
)
break # Stop checking once the note is assigned
elif message.type == MidoMessageType.NOTE_OFF.value:
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(
message=f"Note off: {note} at step {self.current_step}",
scope=self.__class__.__name__,
)
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(
MidoMessageType.NOTE_OFF.value, 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.measure_beats
[docs]
def _apply_learned_pattern(self):
"""Apply the learned pattern to the sequencer UI."""
for row in range(self.sequencer_rows):
for button in self.buttons[row]:
reset_button(button)
set_sequencer_style(button)
button.setToolTip("")
# 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]
update_button_state(button, True)
button.note = note
# Set default duration for learned pattern notes
button.duration_ms = self._get_duration_ms()
# Set default velocity for learned pattern notes
button.velocity = self.velocity_spinbox.value()
sync_button_note_spec(button)
button.setStyleSheet(
JDXi.UI.Style.generate_sequencer_button_style(True)
)
self._update_tooltip(row, button)
[docs]
def _get_note_range_for_row(self, row: int) -> range:
"""Get the note range for a specific row."""
return self._note_converter.get_note_range_for_row(row)
[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(
message="Learning complete after 16 steps.",
scope=self.__class__.__name__,
)
self.on_stop_learn_pattern_button_clicked()
self.timer.stop()
del self.timer
else:
log.message(
message=f"Moved to step {self.current_step}",
scope=self.__class__.__name__,
)
[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(
message=f"MIDI file saved to {filename}", scope=self.__class__.__name__
)
[docs]
def _toggle_mute(self, row, checked):
"""Toggle mute for a specific row."""
channel = get_channel_for_row(row)
if checked:
log.message(message=f"Row {row} muted", scope=self.__class__.__name__)
self.muted_channels.append(channel)
else:
log.message(message=f"Row {row} unmuted", scope=self.__class__.__name__)
self.muted_channels.remove(channel)
# Synth-style: unmuted=lit, muted=dark
if row < len(self.mute_buttons):
self.mute_buttons[row].setStyleSheet(
generate_sequencer_button_style(
not checked, checked_means_inactive=True
)
)
# Update the UI or internal state to reflect the mute status
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()
# Ensure UI updates properly
self.update()
[docs]
def _build_row_map(self):
return {
0: self.digital1_selector,
1: self.digital2_selector,
2: self.analog_selector,
3: self.drum_selector,
}