Last active
June 24, 2025 22:14
-
-
Save jedgarpark/65b919084533ad29e586f13515610983 to your computer and use it in GitHub Desktop.
knobby_sequencer.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# SPDX-FileCopyrightText: john park for Adafruit 2025 MIDI Step Sequencer | |
# SPDX-License-Identifier: MIT | |
""" | |
MIDI Step Sequencer for QT Py RP2040 with NeoRotary 4 encoders | |
Single track, 8-step sequencer: | |
- Each encoder controls one step's note pitch | |
- Encoder buttons toggle steps on/off | |
- External 24-pixel NeoPixel strip shows step status and current playback position | |
- Sends MIDI notes via USB | |
- OPTIMIZED: I2C reads only after step 8, no encoder board NeoPixels | |
- BULK READS: Uses bulk digital reads for better I2C performance | |
""" | |
import time | |
import busio | |
import board | |
import digitalio | |
import usb_midi | |
import adafruit_midi | |
from adafruit_midi.note_on import NoteOn | |
from adafruit_midi.note_off import NoteOff | |
from adafruit_midi.control_change import ControlChange | |
import adafruit_seesaw.digitalio | |
import adafruit_seesaw.rotaryio | |
import adafruit_seesaw.seesaw | |
import neopixel | |
# USER variables: | |
# Scale mode selection - change this to pick your scale | |
SCALE_MODE = "pentatonic_major" # Options: see scales.py for all available scales | |
# Sequencer configuration | |
STEPS = 8 # 8 steps, 4 per encoder board | |
BPM = 60 # Beats per minute - CHANGE THIS VALUE TO ADJUST TEMPO | |
STEP_TIME = 60.0 / BPM / 4 # 16th note timing | |
# External NeoPixel strip setup | |
external_strip = neopixel.NeoPixel(board.MOSI, 24, brightness=0.06, auto_write=False) | |
# Initialize all pixels to off | |
external_strip.fill(0x000000) | |
external_strip.show() | |
for i in range(24): | |
external_strip[i] = 0x443300 | |
time.sleep(0.02) | |
external_strip.show() | |
# MIDI Setup | |
midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=0) | |
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) | |
midi.send(ControlChange(123, 0), channel=channel) | |
# All Sound Off (CC 120) - more aggressive, stops all sound immediately | |
midi.send(ControlChange(120, 0), channel=channel) | |
# Small delay to ensure messages are sent | |
time.sleep(0.1) | |
print("MIDI panic complete") | |
except Exception 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) | |
seesaw1 = adafruit_seesaw.seesaw.Seesaw(i2c, 0x49) # First board | |
seesaw2 = adafruit_seesaw.seesaw.Seesaw(i2c, 0x4A) # Second board | |
# Hardware setup - Board 1 (steps 0-3) - ENCODERS AND SWITCHES | |
encoders1 = [adafruit_seesaw.rotaryio.IncrementalEncoder(seesaw1, n) for n in range(4)] | |
switches1 = [adafruit_seesaw.digitalio.DigitalIO(seesaw1, pin) for pin in (12, 14, 17, 9)] | |
for switch in switches1: | |
switch.switch_to_input(digitalio.Pull.UP) | |
# Hardware setup - Board 2 (steps 4-7) - ENCODERS AND SWITCHES | |
encoders2 = [adafruit_seesaw.rotaryio.IncrementalEncoder(seesaw2, n) for n in range(4)] | |
switches2 = [adafruit_seesaw.digitalio.DigitalIO(seesaw2, pin) for pin in (12, 14, 17, 9)] | |
for switch in switches2: | |
switch.switch_to_input(digitalio.Pull.UP) | |
# Combined lists for easier access | |
encoders = encoders1 + encoders2 | |
switches = switches1 + switches2 | |
# Button pin mappings for bulk reads | |
BUTTON_PINS_BOARD = [12, 14, 17, 9] # Pin numbers for board buttons | |
BUTTON_MASK_BOARD1 = sum(1 << pin for pin in BUTTON_PINS_BOARD) # Bitmask for board 1 | |
BUTTON_MASK_BOARD2 = sum(1 << pin for pin in BUTTON_PINS_BOARD) # Bitmask for board 2 | |
# Step to external pixel mapping | |
# Step 1=pixel 1, Step 2=pixel 4, Step 3=pixel 7, Step 4=pixel 10, etc. | |
STEP_TO_PIXEL = [1, 4, 7, 10, 13, 16, 19, 22] # 0-indexed pixel positions | |
# Import scales from scales.py module | |
from scales import SCALES | |
# Get the selected scale | |
SCALE = SCALES[SCALE_MODE] | |
# Step data - each step has a note and on/off state | |
class Step: | |
def __init__(self, note=60, active=False): | |
self.note = note # MIDI note number | |
self.active = active # Whether step plays or not | |
# Initialize steps with default notes | |
steps = [ | |
Step(note=60, active=True), # Step 1: C4 - ON by default | |
Step(note=62, active=False), | |
Step(note=64, active=True), | |
Step(note=67, active=False), | |
Step(note=69, active=True), | |
Step(note=72, active=False), | |
Step(note=74, active=True), | |
Step(note=76, active=False), | |
] | |
# Sequencer state | |
current_step = 0 | |
last_step_time = time.monotonic() | |
expected_step_time = time.monotonic() # When step should have happened | |
last_positions = [0, 0, 0, 0, 0, 0, 0, 0] # 8 encoders | |
last_switch_states = [True, False, True, False, True, False, True, False] # 8 switches | |
currently_playing_note = None # keep track of which note is currently playing | |
note_off_time = 0 # When to send note off | |
# note_length = 0.08 # Note duration in seconds (shorter for faster BPMs) | |
note_length = STEP_TIME * 0.5 # Note duration (50% of step time) | |
display_needs_update = False # Flag to update display | |
# Colors | |
COLORS = { | |
'off': 0x000000, # Black - step off | |
'on': 0x00FF00, # Green - step on | |
'playing_on': 0xFF0000, # Red - currently playing and on | |
'playing_off': 0x220000 # Dim red - current position but step off | |
} | |
# Initialize external strip with step indicators | |
print("Setting up external strip...") | |
for i in range(8): | |
pixel_idx = STEP_TO_PIXEL[i] | |
if steps[i].active: | |
if i == current_step: | |
color = COLORS['playing_on'] | |
else: | |
color = COLORS['on'] | |
else: | |
color = COLORS['off'] | |
external_strip[pixel_idx] = color | |
external_strip.show() | |
print("External strip setup complete") | |
def update_display(): | |
"""Update only external strip for speed""" | |
# Update external strip only | |
for pixel_idx in STEP_TO_PIXEL: | |
external_strip[pixel_idx] = 0x000000 | |
for i in range(8): | |
pixel_idx = STEP_TO_PIXEL[i] | |
if i == current_step: | |
color = COLORS['playing_on'] if steps[i].active else COLORS['playing_off'] | |
else: | |
color = COLORS['on'] if steps[i].active else COLORS['off'] | |
external_strip[pixel_idx] = color | |
external_strip.show() | |
def handle_encoder_input_board1(): | |
"""Handle rotary encoder input for board 1 (steps 1-4)""" | |
global last_positions | |
# Only check board 1 encoders (steps 0-3) | |
positions1 = [encoder.position for encoder in encoders1] | |
for step_id, pos in enumerate(positions1): | |
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 i, note in enumerate(SCALE): | |
if note >= steps[step_id].note: | |
current_index = i | |
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] | |
print(f"Step {step_id + 1}: Note {steps[step_id].note}") | |
last_positions[step_id] = pos | |
def handle_encoder_input_board2(): | |
"""Handle rotary encoder input for board 2 (steps 5-8)""" | |
global last_positions | |
# Only check board 2 encoders (steps 4-7) | |
positions2 = [encoder.position for encoder in encoders2] | |
for i, pos in enumerate(positions2): | |
step_id = i + 4 # Steps 4-7 (5-8 in 1-indexed) | |
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 i, note in enumerate(SCALE): | |
if note >= steps[step_id].note: | |
current_index = i | |
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] | |
print(f"Step {step_id + 1}: Note {steps[step_id].note}") | |
last_positions[step_id] = pos | |
def handle_switch_input_board1(): | |
"""Handle encoder button presses for board 1 (steps 1-4) using bulk read""" | |
global last_switch_states, display_needs_update | |
# Bulk read all digital pins from board 1 | |
try: | |
digital_bulk = seesaw1.digital_read_bulk(BUTTON_MASK_BOARD1) | |
# Process each button (steps 0-3) | |
for i, pin in enumerate(BUTTON_PINS_BOARD1): | |
step_id = i # Steps 0-3 | |
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]: | |
steps[step_id].active = not steps[step_id].active | |
print(f"Step {step_id + 1}: {'ON' if steps[step_id].active else 'OFF'}") | |
display_needs_update = True # Flag for display update | |
last_switch_states[step_id] = current_state | |
except Exception as e: | |
print(f"Bulk read error board 1: {e}") | |
# Fallback to individual reads if bulk read fails | |
for step_id, switch in enumerate(switches1): | |
current_state = switch.value | |
if not current_state and last_switch_states[step_id]: | |
steps[step_id].active = not steps[step_id].active | |
print(f"Step {step_id + 1}: {'ON' if steps[step_id].active else 'OFF'}") | |
display_needs_update = True | |
last_switch_states[step_id] = current_state | |
def handle_switch_input_board2(): | |
"""Handle encoder button presses for board 2 (steps 5-8) using bulk read""" | |
global last_switch_states, display_needs_update | |
# Bulk read all digital pins from board 2 | |
try: | |
digital_bulk = seesaw2.digital_read_bulk(BUTTON_MASK_BOARD2) | |
# Process each button (steps 4-7) | |
for i, pin in enumerate(BUTTON_PINS_BOARD2): | |
step_id = i + 4 # Steps 4-7 (5-8 in 1-indexed) | |
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]: | |
steps[step_id].active = not steps[step_id].active | |
print(f"Step {step_id + 1}: {'ON' if steps[step_id].active else 'OFF'}") | |
display_needs_update = True # Flag for display update | |
last_switch_states[step_id] = current_state | |
except Exception as e: | |
print(f"Bulk read error board 2: {e}") | |
# Fallback to individual reads if bulk read fails | |
for i, switch in enumerate(switches2): | |
step_id = i + 4 # Steps 4-7 (5-8 in 1-indexed) | |
current_state = switch.value | |
if not current_state and last_switch_states[step_id]: | |
steps[step_id].active = not steps[step_id].active | |
print(f"Step {step_id + 1}: {'ON' if steps[step_id].active else 'OFF'}") | |
display_needs_update = True | |
last_switch_states[step_id] = current_state | |
def play_current_step(): | |
"""Play the current step if it's active""" | |
global currently_playing_note, note_off_time | |
# First, turn off any currently playing note | |
if currently_playing_note is not None: | |
midi.send(NoteOff(currently_playing_note, 0)) | |
currently_playing_note = None | |
# Then play the new note if this step is active | |
if steps[current_step].active: | |
midi.send(NoteOn(steps[current_step].note, 100)) | |
currently_playing_note = steps[current_step].note | |
note_off_time = time.monotonic() + note_length | |
# Check I2C inputs based on current step | |
if current_step == 3: # After step 4 (index 3) - check board 1 | |
# print("After step 4 - checking board 1...") | |
handle_encoder_input_board1() | |
handle_switch_input_board1() | |
elif current_step == 7: # After step 8 (index 7) - check board 2 | |
# print("After step 8 - checking board 2...") | |
handle_encoder_input_board2() | |
handle_switch_input_board2() | |
def handle_note_off(): | |
"""Handle note off after specified duration""" | |
global currently_playing_note, note_off_time | |
if (currently_playing_note is not None and | |
time.monotonic() >= note_off_time): | |
midi.send(NoteOff(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 | |
current_time = time.monotonic() | |
# Calculate timing error (how late were we?) | |
timing_error = current_time - expected_step_time | |
# Advance step | |
current_step = (current_step + 1) % STEPS | |
last_step_time = current_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 = current_time + STEP_TIME | |
print(f"Timing reset - was {timing_error*1000:.1f}ms late") | |
display_needs_update = True # Always update display when step changes | |
# Initialize timing | |
last_step_time = time.monotonic() | |
expected_step_time = last_step_time + STEP_TIME | |
# Initialize display | |
update_display() | |
print("MIDI Step Sequencer Started") | |
print("8 steps, single track (2 NeoRotary boards)") | |
print("Board 1 (0x49): Steps 1-4") | |
print("Board 2 (0x4A): Steps 5-8") | |
print("Encoder rotation: Change note pitch for each step") | |
print("Encoder press: Toggle step on/off") | |
print(f"BPM: {BPM}") | |
print(f"Scale: {SCALE_MODE}") | |
# 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 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 | |
if display_needs_update: | |
update_display() | |
display_needs_update = False |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment