Adafruit_Learning_System_Gu.../Knobby_Sequencer/code.py
2025-07-08 19:08:52 -07:00

975 lines
34 KiB
Python

# SPDX-FileCopyrightText: John Park for Adafruit 2025 Knobby MIDI Step Sequencer
# SPDX-License-Identifier: MIT
"""
MIDI Step Sequencer for QT Py RP2040 with NeoRotary 4 encoder seesaw boards
Configurable 4/8/12/16-step sequencer:
- Each encoder controls one step's note pitch
- Encoder buttons toggle steps on/off
- DOUBLE-CLICK first knob to enter settings mode
- In settings mode: control playback, tempo, scales, velocity, randomization
- External NeoPixel strip shows step status and current playback position
- Sends MIDI notes via USB
- I2C reads only after each board's 4 steps for performance
- Uses bulk digital reads for better I2C performance
"""
import time
import random
import busio
import board
import digitalio
import usb_midi
import adafruit_seesaw.digitalio
import adafruit_seesaw.rotaryio
import adafruit_seesaw.seesaw
import neopixel
from scales import get_scale, list_scales, get_scale_info
# USER CONFIGURATION
SCALE_MODE = "major" # Options: see scales.py for all available scales
STEPS = 16 # Options: 4, 8, 12, or 16 steps
BPM = 90 # Beats per minute
# I2C addresses for encoder boards (add/remove as needed)
I2C_ADDRESSES = [0x49, 0x4A, 0x4B, 0x4C] # Up to 4 boards for 16 steps
# Calculate number of NeoPixels based on steps
NEOPIXEL_COUNT = {4: 12, 8: 24, 12: 36, 16: 48}
NUM_NEOPIXELS = NEOPIXEL_COUNT.get(STEPS, 24)
# Validate configuration
if STEPS not in [4, 8, 12, 16]:
raise ValueError("STEPS must be 4, 8, 12, or 16")
REQUIRED_BOARDS = (STEPS + 3) // 4 # Round up division
if len(I2C_ADDRESSES) < REQUIRED_BOARDS:
raise ValueError(f"Need at least {REQUIRED_BOARDS} I2C addresses for {STEPS} steps")
STEP_TIME = 60.0 / BPM / 4 # 16th note timing
# Button pin mappings for bulk reads
BUTTON_PINS_BOARD = [12, 14, 17, 9] # Pin numbers for board buttons
BUTTON_MASK_BOARD = sum(1 << pin for pin in BUTTON_PINS_BOARD) # Bitmask
# Timing constants
DOUBLE_CLICK_TIME = 0.5 # seconds
NOTE_LENGTH_RATIO = 0.25 # Note duration as ratio of step time
# Parameter limits
MIN_BPM, MAX_BPM = 60, 200
MIN_OCTAVE, MAX_OCTAVE = -2, 2
MIN_SEMITONE, MAX_SEMITONE = 0, 12
MIN_VELOCITY, MAX_VELOCITY = 20, 127
MIN_VEL_RANDOM, MAX_VEL_RANDOM = 0, 50
# Colors for settings indicators
COLORS = {
'off': 0x000000, # Black - step off
'playing_off': 0x220000, # Dim red - current position but step off
'play_stop': 0x00FF00, # Green - play/stop control
'bpm': 0x800080, # Purple - BPM control
'octave': 0xFF8000, # Orange - octave control
'semitone': 0xFFFF00, # Yellow - semitone control
'scale_inactive': 0x404040, # Dim white - inactive scale categories
'scale_active': 0x008080, # Cyan - active scale category
'velocity': 0xFF0080, # Hot pink - velocity control
'vel_random': 0x80FF80, # Light green - velocity randomization
'randomize': 0xFF8080, # Light red - randomize all
}
# External NeoPixel strip setup
external_strip = neopixel.NeoPixel(board.MOSI, NUM_NEOPIXELS, brightness=0.06, auto_write=False)
external_strip.fill(0x000000)
external_strip.show()
# Startup animation
for i in range(NUM_NEOPIXELS):
external_strip[i] = 0x201500
time.sleep(0.02)
external_strip.show()
# RAW MIDI Setup - using direct USB MIDI port
midi_out = usb_midi.ports[1]
def midi_panic():
"""Send MIDI panic - All Notes Off and All Sound Off on all channels"""
print("Sending MIDI panic...")
try:
# Send on all 16 MIDI channels (0-15)
for channel in range(16):
# All Notes Off (CC 123): 0xB0 + channel, 123, 0
midi_out.write(bytes([0xB0 + channel, 123, 0]))
# All Sound Off (CC 120): 0xB0 + channel, 120, 0
midi_out.write(bytes([0xB0 + channel, 120, 0]))
# Small delay to ensure messages are sent
time.sleep(0.1)
print("MIDI panic complete")
except OSError as e:
print(f"MIDI panic error: {e}")
# Send MIDI panic on startup to clear any stuck notes
midi_panic()
external_strip.fill(0x000000)
external_strip.show()
# I2C and Seesaw setup
i2c = busio.I2C(board.SCL1, board.SDA1, frequency=400000)
# Initialize seesaw boards based on required count
seesaw_boards = []
encoders_per_board = []
switches_per_board = []
for board_idx in range(REQUIRED_BOARDS):
try:
board_addr = I2C_ADDRESSES[board_idx]
seesaw_board = adafruit_seesaw.seesaw.Seesaw(i2c, board_addr)
seesaw_boards.append(seesaw_board)
# Setup encoders and switches for this board
board_encoders = [
adafruit_seesaw.rotaryio.IncrementalEncoder(seesaw_board, n)
for n in range(4)
]
board_switches = [
adafruit_seesaw.digitalio.DigitalIO(seesaw_board, pin)
for pin in (12, 14, 17, 9)
]
for switch in board_switches:
switch.switch_to_input(digitalio.Pull.UP)
encoders_per_board.append(board_encoders)
switches_per_board.append(board_switches)
print(f"Initialized board {board_idx+1} at address 0x{board_addr:02X}")
except OSError as e:
print(
f"Failed to initialize board {board_idx+1} "
f"at address 0x{I2C_ADDRESSES[board_idx]:02X}: {e}"
)
raise
# Flatten lists for easier access (only up to STEPS count)
encoders = []
switches = []
for board_encoders, board_switches in zip(encoders_per_board, switches_per_board):
encoders.extend(board_encoders)
switches.extend(board_switches)
# Trim to exact step count
encoders = encoders[:STEPS]
switches = switches[:STEPS]
# Step-to-external pixel mapping based on step count
def generate_step_to_pixel_mapping(num_steps, num_pixels):
"""Generate step to pixel mapping based on configuration"""
if num_steps == 4:
return [1, 4, 7, 10]
elif num_steps == 8:
return [1, 4, 7, 10, 13, 16, 19, 22]
elif num_steps == 12:
return [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34]
elif num_steps == 16:
return [1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31, 34, 37, 40, 43, 46]
else:
# Fallback: evenly distribute
step_spacing = num_pixels // num_steps
return [idx * step_spacing + 1 for idx in range(num_steps)]
STEP_TO_PIXEL = generate_step_to_pixel_mapping(STEPS, NUM_NEOPIXELS)
# Get available scales organized by category
SCALE_CATEGORIES = get_scale_info()
AVAILABLE_SCALES = list_scales()
current_scale_index = 0
# Scale category management
SCALE_CATEGORY_NAMES = list(SCALE_CATEGORIES.keys())
current_scale_categories = {} # Track current scale index for each category
# Initialize each category's current scale index
for category in SCALE_CATEGORY_NAMES:
current_scale_categories[category] = 0
# Find the index of the current scale mode
try:
current_scale_index = AVAILABLE_SCALES.index(SCALE_MODE)
except ValueError:
current_scale_index = 0
SCALE_MODE = AVAILABLE_SCALES[0]
# Get the selected scale
SCALE = get_scale(SCALE_MODE)
# Step data - each step has a note and on/off state
class Step:
"""Represents a single step in the sequence"""
def __init__(self, note=60, active=False):
self.note = note # MIDI note number
self.active = active # Whether step plays or not
# Initialize steps with notes from the selected scale
def initialize_steps_from_scale(scale, num_steps):
"""Initialize steps with notes from the selected scale"""
step_list = []
scale_len = len(scale)
# Use notes from the middle of the scale (around the 4th octave)
start_index = scale_len // 3 # Start roughly 1/3 through the scale
# Create the first 8 ascending notes from the scale
first_eight_notes = []
for note_idx in range(8):
# Cycle through scale notes, wrapping around if needed
scale_index = (start_index + note_idx) % scale_len
note = scale[scale_index]
first_eight_notes.append(note)
# Initialize steps based on the pattern
for sequence_step in range(num_steps):
if sequence_step < 8:
# First 8 steps: use ascending notes
note = first_eight_notes[sequence_step]
else:
# Steps 9-16: repeat the first 8 notes
note = first_eight_notes[sequence_step - 8]
active = True # All steps start active
step_list.append(Step(note=note, active=active))
return step_list
# Initialize steps using the selected scale
steps = initialize_steps_from_scale(SCALE, STEPS)
# Sequencer state
current_step = 0
last_step_time = time.monotonic()
expected_step_time = time.monotonic() # When step should have happened
last_positions = [0] * STEPS # Track encoder positions
last_switch_states = [True] * STEPS # Track switch states
currently_playing_note = None # keep track of which note is currently playing
note_off_time = 0 # When to send note off
note_length = STEP_TIME * NOTE_LENGTH_RATIO # Note duration
display_needs_update = False # Flag to update display
# Global sequencer settings
is_playing = True # Play/stop state
current_octave = 0 # Octave offset for entire sequence (-2 to +2)
semitone_transpose = 0 # Semitone offset (0 to 12)
velocity_baseline = 100 # Base MIDI velocity (20-127)
velocity_randomization = 0 # Amount of velocity randomization (0-50)
# Settings mode state
settings_mode = False
first_knob_click_time = 0
first_knob_click_count = 0
def get_current_scale_category():
"""Get the category name of the currently active scale"""
for category_name, scales in SCALE_CATEGORIES.items():
if SCALE_MODE in scales:
return category_name
return None
def get_note_color(note_value):
"""Get color based on note pitch - yellow for low notes, green for high notes"""
# Using extended range: C1 (24) to B6 (95)
min_note = 24 # C1
max_note = 95 # B6
# Clamp note to range
note_value = max(min_note, min(max_note, note_value))
# Calculate ratio (0.0 = lowest note, 1.0 = highest note)
ratio = (note_value - min_note) / (max_note - min_note)
# Interpolate from yellow (0xFFFF00) to green (0x00FF00)
red = int(255 * (1 - ratio)) # 255 -> 0
green = 255 # Always 255
blue = 0 # Always 0
return (red << 16) | (green << 8) | blue
# pylint: disable=global-statement
# Global statements are necessary for CircuitPython embedded programming
# to modify module-level sequencer state variables
def update_step_timing():
"""Update step timing when BPM changes"""
global STEP_TIME, note_length, expected_step_time
STEP_TIME = 60.0 / BPM / 4 # 16th note timing
note_length = STEP_TIME * NOTE_LENGTH_RATIO # Note duration
# Adjust next expected step time
expected_step_time = time.monotonic() + STEP_TIME
def update_scale_by_category(category_name, direction):
"""Update scale within a specific category - preserve notes and mutes"""
global SCALE, SCALE_MODE, steps, display_needs_update, current_scale_categories
if category_name not in SCALE_CATEGORIES:
return
category_scales = SCALE_CATEGORIES[category_name]
current_idx = current_scale_categories[category_name]
# Update index with wrapping
new_idx = (current_idx + direction) % len(category_scales)
current_scale_categories[category_name] = new_idx
# Get new scale name
new_scale_name = category_scales[new_idx]
# Update the scale
old_scale = SCALE_MODE
SCALE_MODE = new_scale_name
SCALE = get_scale(SCALE_MODE)
# Adjust existing notes to fit new scale (preserve mute states)
for step in steps:
if step.note not in SCALE:
# Find the nearest higher note in the new scale
nearest_note = None
for scale_note in sorted(SCALE):
if scale_note >= step.note:
nearest_note = scale_note
break
# If no higher note found, use the highest note in scale
if nearest_note is None:
nearest_note = max(SCALE)
step.note = nearest_note
print(f"Scale changed ({category_name}): {old_scale}{SCALE_MODE}")
print("Adjusted out-of-scale notes to fit new scale")
display_needs_update = True
def randomize_all_steps():
"""Randomize all step notes within current octave range and mute states"""
global steps, display_needs_update
# Calculate the current octave range based on existing notes
current_notes = [step.note for step in steps]
min_current = min(current_notes)
max_current = max(current_notes)
# Find the octave that contains most of our current notes
base_octave = (min_current // 12) * 12
octave_range = [note for note in SCALE if base_octave <= note <= base_octave + 24]
# If we don't have enough notes in that range, expand to include current range
if not octave_range or max(octave_range) < max_current:
octave_range = [
note for note in SCALE
if min_current <= note <= max_current + 12
]
# Fallback: use a reasonable default range if still empty
if not octave_range:
# C3 to C5 range
octave_range = [note for note in SCALE if 48 <= note <= 72]
for step in steps:
# Randomize note within the current octave range
if octave_range:
random_note = random.choice(octave_range)
step.note = random_note
# Randomize mute state (70% chance to be active)
step.active = random.random() > 0.3
print(f"Randomized notes w/in range {min(octave_range)}-{max(octave_range)} and mute states")
display_needs_update = True
def calculate_velocity():
"""Calculate velocity with randomization"""
if velocity_randomization == 0:
return velocity_baseline
# Add random variation
variation = random.randint(-velocity_randomization, velocity_randomization)
final_velocity = velocity_baseline + variation
# Clamp to valid MIDI range
return max(1, min(127, final_velocity))
# Initialize external strip with step indicators
print("Setting up external strip...")
for display_step_idx in range(STEPS):
display_pixel_idx = STEP_TO_PIXEL[display_step_idx]
if steps[display_step_idx].active:
if display_step_idx == current_step:
display_color = 0xFF0000 # Red for currently playing
else:
display_color = get_note_color(steps[display_step_idx].note)
else:
display_color = COLORS['off']
external_strip[display_pixel_idx] = display_color
external_strip.show()
print("External strip setup complete")
def update_display():
"""Update only external strip for speed"""
# Clear all pixels first
external_strip.fill(0x000000)
# Special handling for settings mode
if settings_mode:
# Set settings mode indicators based on knob positions
external_strip[0] = COLORS['play_stop'] # Pixel 0: Play/Stop (knob 1)
external_strip[3] = COLORS['bpm'] # Pixel 3: BPM (knob 2)
external_strip[6] = COLORS['octave'] # Pixel 6: Octave (knob 3)
external_strip[9] = COLORS['semitone'] # Pixel 9: Semitone (knob 4)
# Get current scale category for scale indicators
current_category = get_current_scale_category()
# Scale category indicators - active one is cyan, others dim white
if STEPS >= 8:
# Western scales (knob 5)
western_color = (
COLORS['scale_active'] if current_category == "Western"
else COLORS['scale_inactive']
)
external_strip[12] = western_color
# Middle Eastern scales (knob 6)
me_color = (
COLORS['scale_active'] if current_category == "Middle Eastern"
else COLORS['scale_inactive']
)
external_strip[15] = me_color
if STEPS >= 12:
# Indonesian scales (knob 7)
indonesian_color = (
COLORS['scale_active'] if current_category == "Indonesian"
else COLORS['scale_inactive']
)
external_strip[18] = indonesian_color
# Japanese scales (knob 8)
japanese_color = (
COLORS['scale_active'] if current_category == "Japanese"
else COLORS['scale_inactive']
)
external_strip[21] = japanese_color
# Indian scales (knob 9)
indian_color = (
COLORS['scale_active'] if current_category == "Indian"
else COLORS['scale_inactive']
)
external_strip[24] = indian_color
if STEPS >= 16:
# Chinese scales (knob 10)
chinese_color = (
COLORS['scale_active'] if current_category == "Chinese"
else COLORS['scale_inactive']
)
external_strip[27] = chinese_color
# African scales (knob 11)
african_color = (
COLORS['scale_active'] if current_category == "African"
else COLORS['scale_inactive']
)
external_strip[30] = african_color
# Eastern European scales (knob 12)
ee_color = (
COLORS['scale_active'] if current_category == "Eastern European"
else COLORS['scale_inactive']
)
external_strip[33] = ee_color
# Celtic scales (knob 13)
celtic_color = (
COLORS['scale_active'] if current_category == "Celtic"
else COLORS['scale_inactive']
)
external_strip[36] = celtic_color
# Velocity baseline (knob 14)
external_strip[39] = COLORS['velocity']
# Velocity randomization (knob 15)
external_strip[42] = COLORS['vel_random']
# Randomize all (knob 16)
external_strip[45] = COLORS['randomize']
# Update step indicators (normal step pixels remain unchanged)
for main_step_idx in range(STEPS):
main_pixel_idx = STEP_TO_PIXEL[main_step_idx]
if main_step_idx == current_step and is_playing:
# Current step is always red when playing
main_color = (
0xFF0000 if steps[main_step_idx].active
else COLORS['playing_off']
)
elif main_step_idx == current_step and not is_playing:
# Current step is dim when stopped
main_color = (
0x440000 if steps[main_step_idx].active
else 0x220000
)
else:
# Non-current steps use pitch-based color when active
final_note = (
steps[main_step_idx].note + current_octave * 12 +
semitone_transpose
)
main_color = (
get_note_color(final_note) if steps[main_step_idx].active
else COLORS['off']
)
external_strip[main_pixel_idx] = main_color
external_strip.show()
def handle_basic_settings(step_id, change):
"""Handle basic settings controls (play/stop, BPM, octave, semitones)"""
global BPM, current_octave, semitone_transpose, is_playing
if step_id == 0:
# Knob 1: Play/Stop
if change != 0:
is_playing = not is_playing
playback_status = 'PLAYING' if is_playing else 'STOPPED'
print(f"Playback: {playback_status}")
elif step_id == 1:
# Knob 2: BPM
new_bpm = BPM + change
BPM = max(MIN_BPM, min(MAX_BPM, new_bpm))
update_step_timing()
print(f"BPM: {BPM}")
elif step_id == 2:
# Knob 3: Octave
new_octave = current_octave + change
current_octave = max(MIN_OCTAVE, min(MAX_OCTAVE, new_octave))
print(f"Octave: {current_octave:+d}")
elif step_id == 3:
# Knob 4: Semitones
new_semitone = semitone_transpose + change
semitone_transpose = max(MIN_SEMITONE, min(MAX_SEMITONE, new_semitone))
print(f"Semitones: +{semitone_transpose}")
def handle_scale_settings(step_id, change):
"""Handle scale category controls"""
scale_categories = {
4: "Western", 5: "Middle Eastern", 6: "Indonesian", 7: "Japanese",
8: "Indian", 9: "Chinese", 10: "African", 11: "Eastern European",
12: "Celtic"
}
if step_id in scale_categories:
update_scale_by_category(scale_categories[step_id], change)
def handle_velocity_settings(step_id, change):
"""Handle velocity and randomization controls"""
global velocity_baseline, velocity_randomization
if step_id == 13:
# Knob 14: Velocity baseline
new_velocity = velocity_baseline + change * 5 # Increment by 5
velocity_baseline = max(MIN_VELOCITY, min(MAX_VELOCITY, new_velocity))
print(f"Velocity baseline: {velocity_baseline}")
elif step_id == 14:
# Knob 15: Velocity randomization
new_vel_random = velocity_randomization + change
velocity_randomization = max(
MIN_VEL_RANDOM, min(MAX_VEL_RANDOM, new_vel_random)
)
print(f"Velocity randomization: ±{velocity_randomization}")
elif step_id == 15:
# Knob 16: Randomize all
if change != 0:
randomize_all_steps()
def handle_settings_mode_encoder():
"""Handle encoder input for settings mode with all 16 controls"""
global last_positions, display_needs_update
# Check all available encoders for settings
for settings_board_idx, settings_board_encoders in enumerate(encoders_per_board):
for encoder_idx, encoder in enumerate(settings_board_encoders):
step_id = settings_board_idx * 4 + encoder_idx
if step_id >= STEPS:
continue
current_pos = encoder.position
if current_pos != last_positions[step_id]:
change = current_pos - last_positions[step_id]
# Handle different setting categories
if step_id <= 3:
handle_basic_settings(step_id, change)
elif 4 <= step_id <= 12:
handle_scale_settings(step_id, change)
elif 13 <= step_id <= 15:
handle_velocity_settings(step_id, change)
last_positions[step_id] = current_pos
display_needs_update = True
def handle_encoder_input_board(board_index):
"""Handle rotary encoder input for a specific board"""
global last_positions, display_needs_update
if board_index >= len(encoders_per_board):
return
encoder_board_encoders = encoders_per_board[board_index]
positions = [encoder.position for encoder in encoder_board_encoders]
for encoder_idx, pos in enumerate(positions):
step_id = board_index * 4 + encoder_idx
# Only process if this step exists in our configuration
if step_id >= STEPS:
continue
# Skip all encoders in settings mode (they're handled separately)
if settings_mode:
continue
if pos != last_positions[step_id]:
# Calculate note change
note_change = pos - last_positions[step_id]
# Find current note in scale
try:
current_index = SCALE.index(steps[step_id].note)
except ValueError:
# If note not in scale, find closest
current_index = 0
for scale_idx, note in enumerate(SCALE):
if note >= steps[step_id].note:
current_index = scale_idx
break
# Apply change and constrain
new_index = current_index + note_change
new_index = max(0, min(len(SCALE) - 1, new_index))
steps[step_id].note = SCALE[new_index]
# Calculate final note with octave and semitone transpose
final_note = (
steps[step_id].note + current_octave * 12 + semitone_transpose
)
print(
f"Step {step_id + 1}: Note {steps[step_id].note} "
f"(final: {final_note})"
)
last_positions[step_id] = pos
display_needs_update = True
def handle_switch_input_board(board_index):
"""Handle encoder button presses for a specific board using bulk read"""
global last_switch_states, display_needs_update
if board_index >= len(seesaw_boards):
return
switch_seesaw_board = seesaw_boards[board_index]
# Bulk read all digital pins from this board
try:
digital_bulk = switch_seesaw_board.digital_read_bulk(BUTTON_MASK_BOARD)
# Process each button on this board
for encoder_idx, pin in enumerate(BUTTON_PINS_BOARD):
step_id = board_index * 4 + encoder_idx
# Only process if this step exists in our configuration
if step_id >= STEPS:
continue
# Skip first knob (step 0) - it's handled separately for double-click
if step_id == 0:
continue
# In settings mode, skip ALL settings control knobs for button presses
if settings_mode:
# Still need to update the switch state to prevent false triggers
current_state = bool(digital_bulk & (1 << pin))
last_switch_states[step_id] = current_state
continue
current_state = bool(digital_bulk & (1 << pin))
# Detect button press (transition from high to low)
if not current_state and last_switch_states[step_id]:
# Normal step toggle (only when not in settings mode)
steps[step_id].active = not steps[step_id].active
step_status = 'ON' if steps[step_id].active else 'OFF'
print(f"Step {step_id + 1}: {step_status}")
display_needs_update = True
last_switch_states[step_id] = current_state
except OSError as e:
print(f"Bulk read error board {board_index + 1}: {e}")
# Fallback to individual reads if bulk read fails
switch_board_switches = switches_per_board[board_index]
for encoder_idx, board_switch in enumerate(switch_board_switches):
step_id = board_index * 4 + encoder_idx
if step_id >= STEPS:
continue
# Skip first knob and all settings knobs in settings mode
if step_id == 0:
continue
current_state = board_switch.value
# In settings mode, skip all control knobs for button presses
if settings_mode:
last_switch_states[step_id] = current_state
continue
if not current_state and last_switch_states[step_id]:
steps[step_id].active = not steps[step_id].active
step_status = 'ON' if steps[step_id].active else 'OFF'
print(f"Step {step_id + 1}: {step_status}")
display_needs_update = True
last_switch_states[step_id] = current_state
def check_first_knob_button():
"""Check first knob button state more frequently for double-click detection"""
global last_switch_states, settings_mode, first_knob_click_time
global first_knob_click_count, display_needs_update
if len(seesaw_boards) == 0:
return
try:
# Read just the first button (pin 12 on first board)
first_button_state = seesaw_boards[0].digital_read(12)
# Detect button press (transition from high to low)
if not first_button_state and last_switch_states[0]:
button_time = time.monotonic()
if first_knob_click_count == 0:
# First click
first_knob_click_time = button_time
first_knob_click_count = 1
print("First knob clicked once")
elif first_knob_click_count == 1:
# Check if this is a double-click
time_diff = button_time - first_knob_click_time
print(f"Second click detected, time diff: {time_diff:.3f}s")
if time_diff <= DOUBLE_CLICK_TIME:
# Double-click detected - toggle settings mode
settings_mode = not settings_mode
mode_status = 'ON' if settings_mode else 'OFF'
print(f"DOUBLE-CLICK! Settings mode: {mode_status}")
if settings_mode:
print("Settings mode controls available")
display_needs_update = True
first_knob_click_count = 0
else:
# Too slow, treat as new first click
print("Too slow for double-click, treating as new first click")
first_knob_click_time = button_time
first_knob_click_count = 1
last_switch_states[0] = first_button_state
except OSError as e:
print(f"Error reading first knob button: {e}")
def check_double_click_timeout():
"""Check for double-click timeout in main loop"""
global first_knob_click_count, first_knob_click_time
global settings_mode, display_needs_update
if first_knob_click_count > 0:
if time.monotonic() - first_knob_click_time > DOUBLE_CLICK_TIME:
# Timeout - treat first click as single step toggle (if not in settings mode)
print("Double-click timeout - treating as single click")
if not settings_mode:
steps[0].active = not steps[0].active
step_status = 'ON' if steps[0].active else 'OFF'
print(f"Step 1: {step_status}")
display_needs_update = True
first_knob_click_count = 0
def play_current_step():
"""Play the current step if it's active using raw MIDI"""
global currently_playing_note, note_off_time
# First, turn off any currently playing note
if currently_playing_note is not None:
# Raw MIDI Note Off: 0x80 (channel 1), note, velocity 0
midi_out.write(bytes([0x80, currently_playing_note, 0]))
currently_playing_note = None
# Then play the new note if this step is active and we're playing
if steps[current_step].active and is_playing:
# Apply octave and semitone transpose to the note
final_note = steps[current_step].note + current_octave * 12 + semitone_transpose
# Clamp to valid MIDI range (0-127)
final_note = max(0, min(127, final_note))
# Calculate velocity with randomization
velocity = calculate_velocity()
# Raw MIDI Note On: 0x90 (channel 1), note, velocity
midi_out.write(bytes([0x90, final_note, velocity]))
currently_playing_note = final_note
note_off_time = time.monotonic() + note_length
# Determine which board to check based on current step
# Check I2C inputs only after each board's last step (every 4th step)
board_to_check = current_step // 4
step_in_board = current_step % 4
if step_in_board == 3: # Last step of a board (0,1,2,3 -> check at 3)
if board_to_check < len(seesaw_boards):
# Handle settings mode encoder separately
if settings_mode:
handle_settings_mode_encoder()
# Always handle normal encoder input
handle_encoder_input_board(board_to_check)
handle_switch_input_board(board_to_check)
def handle_note_off():
"""Handle note off after specified duration using raw MIDI"""
global currently_playing_note, note_off_time
if (currently_playing_note is not None and
time.monotonic() >= note_off_time):
# Raw MIDI Note Off: 0x80 (channel 1), note, velocity 0
midi_out.write(bytes([0x80, currently_playing_note, 0]))
currently_playing_note = None
note_off_time = 0
def advance_step():
"""Advance to the next step in the sequence with timing compensation"""
global current_step, last_step_time, expected_step_time, display_needs_update
# Only advance if we're playing
if not is_playing:
return
step_time = time.monotonic()
# Calculate timing error (how late were we?)
timing_error = step_time - expected_step_time
# Advance step
current_step = (current_step + 1) % STEPS
last_step_time = step_time
# Set next expected time, compensating for any drift
expected_step_time += STEP_TIME
# If we're way behind (more than one step), reset to current time
if timing_error > STEP_TIME:
expected_step_time = step_time + STEP_TIME
print(f"Timing reset - was {timing_error*1000:.1f}ms late")
# Always update display when step changes (for current step indicator)
update_display()
# Initialize timing
last_step_time = time.monotonic()
expected_step_time = last_step_time + STEP_TIME
# Initialize display
update_display()
print("MIDI Step Sequencer Started - COMPREHENSIVE SETTINGS MODE")
print(f"{STEPS} steps, single track ({REQUIRED_BOARDS} NeoRotary boards)")
for board_idx, addr in enumerate(I2C_ADDRESSES[:REQUIRED_BOARDS]):
steps_start = board_idx * 4 + 1
steps_end = min((board_idx + 1) * 4, STEPS)
print(f"Board {board_idx+1} (0x{addr:02X}): Steps {steps_start}-{steps_end}")
print("Encoder rotation: Change note pitch for each step")
print("Encoder press: Toggle step on/off")
print("DOUBLE-CLICK first knob: Enter settings mode")
print("Settings mode controls:")
print(" 1. Play/Stop 2. BPM 3. Octave 4. Semitones")
print(" 5. Western 6. Middle Eastern 7. Indonesian 8. Japanese")
print(" 9. Indian 10. Chinese 11. African 12. Eastern European")
print(" 13. Celtic 14. Velocity 15. Vel Random 16. Randomize All")
print(f"BPM: {BPM}")
print(f"Octave: {current_octave:+d}")
print(f"Semitones: +{semitone_transpose}")
print(f"Scale: {SCALE_MODE}")
print(f"Velocity: {velocity_baseline} ±{velocity_randomization}")
print(f"Playing: {is_playing}")
print(f"NeoPixels: {NUM_NEOPIXELS}")
# Main loop - MINIMAL for maximum timing precision with compensation
while True:
current_time = time.monotonic()
# Handle note off timing (critical timing)
handle_note_off()
# Check first knob button frequently for double-click detection
check_first_knob_button()
# Check for double-click timeout in main loop (more frequent than I2C reads)
check_double_click_timeout()
# Check if it's time for the next step (use expected time for compensation)
if current_time >= expected_step_time:
advance_step()
play_current_step() # This will handle I2C reads only after each board's 4th step
# Update display only when needed and only after board transitions
if display_needs_update and (current_step % 4 == 3):
update_display()
display_needs_update = False