Skip to content

Instantly share code, notes, and snippets.

@jedgarpark
Last active June 24, 2025 22:14
Show Gist options
  • Save jedgarpark/65b919084533ad29e586f13515610983 to your computer and use it in GitHub Desktop.
Save jedgarpark/65b919084533ad29e586f13515610983 to your computer and use it in GitHub Desktop.
knobby_sequencer.py
# 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