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

"""

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.buttons = [] # Main sequencer buttons (always 16 steps, one bar)
[docs] self.button_layouts = [] # Store references to button layouts for each row
[docs] self.measures = [] # Each measure stores its own notes
[docs] self.current_bar_index = 0 # Currently selected bar (0-indexed)
[docs] self.timer = None
[docs] self.current_step = 0
[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.bpm = 120
[docs] self.last_tap_time = None
[docs] self.tap_times = []
[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 ui_generate_button_row(self, row_index: int, visible: bool = False): """generate sequencer button row""" button_row_layout = QHBoxLayout() for i in range(16): button = QPushButton() button.setCheckable(True) button.setFixedSize(40, 40) button.setStyleSheet(self.generate_sequencer_button_style(False)) # Store the row and column indices in the button button.row = row_index button.column = i button.note = None button.clicked.connect( lambda checked, btn=button: self._on_button_clicked(btn, checked) ) self.buttons[row_index].append(button) button.setVisible(visible) # Initially hide all drum buttons button_row_layout.addWidget(button) return button_row_layout
[docs] def on_learn_pattern_button_clicked(self): """Connect the MIDI input to the learn pattern function.""" self.midi_helper.midi_message_incoming.connect(self._learn_pattern) self.midi_helper.midi_message_incoming.disconnect(self._update_combo_boxes)
[docs] def on_stop_learn_pattern_button_clicked(self): """Disconnect the MIDI input from the learn pattern function and update combo boxes.""" self.midi_helper.midi_message_incoming.disconnect(self._learn_pattern) self.midi_helper.midi_message_incoming.connect(self._update_combo_boxes)
[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_button_clicked(self, button, checked): """Handle button clicks and store the selected note""" # Don't allow checking disabled buttons if not button.isEnabled(): return if checked: # Store the currently selected note when button is activated if button.row == 0: # Digital Synth 1 note_name = self.digital1_selector.currentText() button.NOTE = self._note_name_to_midi(note_name) elif button.row == 1: # Digital Synth 2 note_name = self.digital2_selector.currentText() button.NOTE = self._note_name_to_midi(note_name) elif button.row == 2: # Analog Synth note_name = self.analog_selector.currentText() button.NOTE = self._note_name_to_midi(note_name) else: # Drums button.NOTE = 36 + self.drum_selector.currentIndex() note_name = self._midi_to_note_name(button.NOTE) if button.row == 3: drums_note_name = self._midi_to_note_name(button.NOTE, drums=True) button.setToolTip(f"Note: {drums_note_name}") else: button.setToolTip(f"Note: {note_name}") # Store the note in the currently selected bar's measure if len(self.measures) > 0 and self.current_bar_index < len(self.measures): measure = self.measures[self.current_bar_index] step_in_bar = button.column # button.column is 0-15 for sequencer buttons if button.row < len(measure.buttons) and step_in_bar < len( measure.buttons[button.row] ): measure_button = measure.buttons[button.row][step_in_bar] measure_button.setChecked(checked) if checked: measure_button.NOTE = button.NOTE else: measure_button.NOTE = None # Update button style is_current = (self.current_step % self.total_steps) == button.column is_selected_bar = ( len(self.measures) > 0 and (button.column // 16) == self.current_bar_index ) button.setStyleSheet( self.generate_sequencer_button_style( checked, is_current, is_selected_bar=is_selected_bar and checked ) )
[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 _update_button_states_for_beats_per_bar(self): """Enable/disable sequencer buttons based on beats per bar setting""" # Steps 0-11 are always enabled, steps 12-15 are disabled when beats_per_bar is 12 for row in range(4): for step in range(16): if step < len(self.buttons[row]): button = self.buttons[row][step] if self.beats_per_bar == 12: # Disable last 4 buttons (steps 12-15) button.setEnabled(step < 12) if step >= 12: button.setChecked(False) # Uncheck disabled buttons # Also clear notes in measures for disabled steps for measure in self.measures: if step < len(measure.buttons[row]): measure.buttons[row][step].setChecked(False) measure.buttons[row][step].NOTE = None else: # Enable all 16 buttons button.setEnabled(True) # Sync sequencer display after updating button states if self.current_bar_index < len(self.measures): self._sync_sequencer_with_bar(self.current_bar_index)
[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 generate_sequencer_button_style( self, is_checked: bool, is_current: bool = False, is_selected_bar: bool = False ) -> str: """Generate button style based on state and current step""" base_color = "#3498db" if is_checked else "#2c3e50" border_color = "#e74c3c" if is_current else base_color # Add extra highlight for selected bar if is_selected_bar and is_checked: border_color = "#f39c12" # Orange border for selected bar border_width = "3px" else: border_width = "2px" return f""" QPushButton {{ background-color: {base_color}; border: {border_width} solid {border_color}; border-radius: 5px; color: white; padding: 5px; }} QPushButton:hover {{ background-color: {'#2980b9' if is_checked else '#34495e'}; }} QPushButton:pressed {{ background-color: {'#2472a4' if is_checked else '#2c3e50'}; }} """
[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()