From ec06dfa59cb982dcb8821ef6d3ad2e18e6e59376 Mon Sep 17 00:00:00 2001 From: sandyjmacdonald Date: Sun, 14 Mar 2021 23:58:40 +0000 Subject: [PATCH] Overhauling debounce method and adding MIDI step sequencer example. --- examples/midi-sequencer.py | 459 +++++++++++++++++++++++++++++++++++++ keybow2040.py | 28 ++- 2 files changed, 480 insertions(+), 7 deletions(-) create mode 100644 examples/midi-sequencer.py diff --git a/examples/midi-sequencer.py b/examples/midi-sequencer.py new file mode 100644 index 0000000..fa076ea --- /dev/null +++ b/examples/midi-sequencer.py @@ -0,0 +1,459 @@ +# SPDX-FileCopyrightText: 2021 Sandy Macdonald +# +# SPDX-License-Identifier: MIT + +# A MIDI step sequencer, with four tracks and eight steps per track. + +# The eight steps are on the top two rows of keys. Steps can be toggled on by +# tapping a step's key. Active steps are indicated with a brighter LED, and the +# currently playing step in the sequence is shown with a moving LED across the +# eight steps. + +# Each track is colour-coded: track 1 is orange, track 2 teal, track 3 is pink, +# and track 4 is green. Tracks can be selected by pressing and holding the +# bottom left orange track select key and then tapping one of the four track +# keys on the row above. + +# The sequencer is started and stopped by tapping the bottom right key, which is +# red when the sequencer is stopped, and green when it is playing. + +# The sequencer can be cleared by holding the track selector key (orange, bottom +# left) and then holding the start/stop key (red/green, bottom right). + +# Tempo can be increased or decreased by holding the tempo selector key (teal, +# second from left, on the bottom row) and then tapping teal key on the row +# above to shift tempo down, or the pink key to shift it up. Tempo is increased/ +# decreased by 5 BPM on each press. + +# You'll need to connect Keybow 2040 to a computer running a DAW like Ableton, +# or other software synth, or to a hardware synth that accepts USB MIDI. + +# Currently, all of the notes are C3 with a velocity of 127. + +# Tracks' notes are sent on MIDI channels 1-4. + +# Drop the keybow2040.py file into your `lib` folder on your `CIRCUITPY` drive, +# and then save this code in the `code.py` file + +# NOTE! Requires the adafruit_midi CircuitPython library also! + +import time +import board +from keybow2040 import Keybow2040 + +import usb_midi +import adafruit_midi +from adafruit_midi.note_off import NoteOff +from adafruit_midi.note_on import NoteOn + +# These are the key numbers that represent each step in a track (the top two +# rows of four keys) +TRACK_KEYS = [3, 7, 11, 15, 2, 6, 10, 14] + +# The colours for the LEDs on each track: orange, teal, pink, green +TRACK_COLOURS = [ + (255, 255, 0), + (0, 255, 175), + (255, 0, 255), + (0, 255, 0) +] + +# The MIDI channels for each track in turn: 1, 2, 3, 4 +MIDI_CHANNELS = [0, 1, 2, 3] + +# The bottom left key, orange. When pressed, it brings up the track selector +# keys, the four keys on the row above it. +TRACK_SELECTOR = 0 +TRACK_SELECTOR_KEYS = [1, 5, 9, 13] +TRACK_SELECTOR_COLOUR = (255, 255, 0) + +# The bottom right key. When pressed, it toggles the sequencer on or off. Green +# indicates that it is currently playing, red that it is stopped. +START_STOP = 12 +START_COLOUR = (0, 255, 0) +STOP_COLOUR = (255, 0, 0) + +# The key second from left on the bottom row, teal. When pressed, it brings up +# the tempo down/up buttons on the row above it. The left teal key shifts the +# tempo down, the right pink key shifts the tempo up. +TEMPO_SELECTOR = 4 +TEMPO_SELECTOR_COLOUR = (0, 255, 175) +TEMPO_DOWN = 5 +TEMPO_DOWN_COLOUR = (0, 255, 175) +TEMPO_UP = 9 +TEMPO_UP_COLOUR = (255, 0, 255) + +# The default starting BPM. +BPM = 85 + +# Dictates the time after which a key is "held". +KEY_HOLD_TIME = 0.25 + +# LED brightness settings for the track steps. +PLAY_BRIGHTNESS = 1.0 +ACTIVE_BRIGHTNESS = 0.2 +STEP_BRIGHTNESS = 0.05 + +class Sequencer(Keybow2040): + """ + Represents the sequencer, with a set of Track instances, which in turn have + a set of Step instances. This class is a subclass of the Keybow2040 class, + so it inherits all of its methods and key methods. + + :param i2c: the I2C bus for Keybow 2040 + """ + def __init__(self, *args, **kwargs): + super(Sequencer, self).__init__(*args, **kwargs) + + # Holds the list of MIDI channels for the tracks. + self.midi_channels = [] + + # Set the MIDI channels up. + for channel in MIDI_CHANNELS: + midi = adafruit_midi.MIDI(midi_out=usb_midi.ports[1], out_channel=channel) + self.midi_channels.append(midi) + + # Holds the list of tracks, a set of Track instances. + self.tracks = [] + + # Set the tracks up. + for i in range(4): + track = Track(self, i, i, TRACK_COLOURS[i]) + self.tracks.append(track) + + # Speed attributes. + self.bpm = BPM + self.tempo_selector = self.keys[TEMPO_SELECTOR] + self.tempo_selector.set_led(*TEMPO_SELECTOR_COLOUR) + self.tempo_select_active = False + self.tempo_down = self.keys[TEMPO_DOWN] + self.tempo_up = self.keys[TEMPO_UP] + + # Step related stuff + self.num_steps = 8 + self.this_step_num = 0 + self.last_step_num = 0 + + # Is the sequencer running? + self.running = False + + # Step time assumes the BPM is based on quarter notes. + self.step_time = 60.0 / self.bpm / (self.num_steps / 2) + self.last_step_time = time.monotonic() + + # Set the default starting track to track 0 + self.current_track = 0 + + # The start stop key. + self.start_stop = self.keys[START_STOP] + self.start_stop.set_led(*STOP_COLOUR) + + # The track selector key. + self.track_selector = self.keys[TRACK_SELECTOR] + self.track_selector.set_led(*TRACK_SELECTOR_COLOUR) + self.track_selector_active = False + + # These keys select and change the current track. + self.track_select_keys = [] + + for i in range(len(TRACK_SELECTOR_KEYS)): + track_key = self.keys[TRACK_SELECTOR_KEYS[i]] + track_key.rgb = TRACK_COLOURS[i] + self.track_select_keys.append(track_key) + + # Set the key hold time for all the keys. A little shorter than the + # default for Keybow. Makes controlling the sequencer a bit more fluid. + for key in self.keys: + key.hold_time = KEY_HOLD_TIME + + # Attach step_select function to keys in track steps. If pressed it + # toggles the state of the step. + for i in range(len(TRACK_KEYS)): + key = self.keys[TRACK_KEYS[i]] + + @self.on_press(key) + def step_select(key): + step_num = TRACK_KEYS.index(key.number) + step = self.tracks[self.current_track].steps[step_num] + step.toggle() + + # Attach hold function to track selector key that sets it active and + # lights the track select keys. + @self.on_hold(self.track_selector) + def track_selector_hold(key): + self.track_selector_active = True + + for key in self.track_select_keys: + key.led_on() + + # Attach release function to track selector key that sets it inactive + # and turns track select LEDs off. + @self.on_release(self.track_selector) + def track_selector_release(key): + self.track_selector_active = False + + for key in self.track_select_keys: + key.led_off() + + # Track 0 select. + @self.on_press(self.track_select_keys[0]) + def track_select_0_press(key): + if self.track_selector_active: + self.current_track = 0 + + # Special case to handle track 1 select and tempo down. + # Pressing the tempo down key shifts the tempo down by + # 5 bpm each time it is pressed, with a lower limit of 5 BPM. + @self.on_press(self.track_select_keys[1]) + def track_select_1_press(key): + if self.track_selector_active: + self.current_track = 1 + else: + if self.tempo_select_active: + if self.bpm > 5: + self.bpm -= 5 + + # Special case to handle track 2 select and tempo up. + # Pressing the tempo up key shifts the tempo up by + # 5 bpm each time it is pressed, with an upper limit of 200 BPM. + @self.on_press(self.track_select_keys[2]) + def track_select_2_press(key): + if self.track_selector_active: + self.current_track = 2 + else: + if self.tempo_select_active: + if self.bpm < 200: + self.bpm += 5 + + # Track 3 select. + @self.on_press(self.track_select_keys[3]) + def track_select_3_press(key): + if self.track_selector_active: + self.current_track = 3 + + # Attach press function to start/stop key that toggles whether the + # sequencer is running and toggles its colour between green (running) + # and red (not running). + @self.on_press(self.start_stop) + def start_stop_toggle(key): + if not self.track_selector_active: + if self.running: + self.running = False + key.set_led(*STOP_COLOUR) + else: + self.running = True + key.set_led(*START_COLOUR) + + # Attach hold function, so that when the track selector key is held and + # the start/stop key is also held, clear all of the steps on all of the + # tracks. + @self.on_hold(self.start_stop) + def start_stop_hold(key): + if self.track_selector_active: + self.clear_tracks() + + # Attach hold function that lights the tempo down/up keys when the + # tempo selector key is held. + @self.on_hold(self.tempo_selector) + def tempo_selector_hold(key): + self.tempo_select_active = True + self.tempo_down.set_led(*TEMPO_DOWN_COLOUR) + self.tempo_up.set_led(*TEMPO_UP_COLOUR) + + # Attach release function that furns off the tempo down/up LEDs. + @self.on_release(self.tempo_selector) + def tempo_selector_release(key): + self.tempo_select_active = False + self.tempo_down.led_off() + self.tempo_up.led_off() + + def update(self): + # Update the superclass (Keybow2040). + super(Sequencer, self).update() + + if self.running: + # Keep track of current time. + current_time = time.monotonic() + + # If a step has elapsed... + if current_time - self.last_step_time > self.step_time: + for track in self.tracks: + if track.active: + # Turn last step off. + last_step = track.steps[self.last_step_num] + last_step.playing = False + last_step.update() + last_note = last_step.note + + # If last step is active, send MIDI note off message. + if last_step.active: + self.midi_channels[track.channel].send(NoteOff(last_note, 0)) + + # Turn this step on. + this_step = track.steps[self.this_step_num] + this_step.playing = True + this_step.update() + this_note = this_step.note + this_vel = this_step.velocity + + # If this step is active, send MIDI note on message. + if this_step.active: + self.midi_channels[track.channel].send(NoteOn(this_note, this_vel)) + + # This step is now the last step! + last_step = this_step + self.last_step_num = self.this_step_num + self.this_step_num += 1 + + # If we get to the end of the sequence, go back to the start. + if self.this_step_num == self.num_steps: + self.this_step_num = 0 + + # Keep track of last step time. + self.last_step_time = current_time + + # Update the tracks. + for track in self.tracks: + track.update() + + # Update the step_time, in case the BPM has been changed. + self.step_time = 60.0 / self.bpm / (self.num_steps / 2) + + + def clear_tracks(self): + # Clears the steps on all tracks. + for track in self.tracks: + track.clear_steps() + +class Track: + """ + Represents a track on the sequencer. + + :param sequencer: the parent sequencer instance + :param index: the index of the track, integer + :param channel: the MIDI channel, integer + :param rgb: the RGB colour of the track, tuple of R, G, B, 0-255. + """ + def __init__(self, sequencer, index, channel, rgb): + self.index = index + self.rgb = rgb + self.channel = channel + self.steps = [] + self.sequencer = sequencer + + # For each key in the track, create a Step instance and add to + # self.steps. + for i in range(len(TRACK_KEYS)): + index = i + key = sequencer.keys[TRACK_KEYS[i]] + step = Step(index, key, self) + self.steps.append(step) + + # Default to having the track active. + self.active = True + self.focussed = False + + def set_on(self): + # Toggle the track on. + self.active = True + + def set_off(self): + # Toggle the track off. + self.active = False + + def update(self): + # Make the current track focussed and update its steps. + if sequencer.current_track == self.index: + self.focussed = True + self.update_steps() + else: + self.focussed = False + + def update_steps(self): + # Update a tracks steps. + for step in self.steps: + step.update() + + def clear_steps(self): + # Clear a track's steps by setting them all to inactive. + for step in self.steps: + step.active = False + +class Step: + """ + Represents a step on a track. + + :param index: the index of the step, integer + :param key: the key attached to this step, integer + :param track: the track this step belongs to, Track instance. + """ + def __init__(self, index, key, track): + self.index = index + self.key = key + self.track = track + self.active = False + self.playing = False + self.velocity = 127 + self.note = 60 + self.rgb = self.track.rgb + self.sequencer = self.track.sequencer + + def toggle(self): + # Toggle the step between active and inactive. + self.active = not self.active + + def state(self): + # Returns the state of the track (active/inactve). + return self.active + + def set_led(self, r, g, b, brightness): + # Set the step's LED. Has an additional brightness parameter from 0.0 + # (off) to 1.0 (full brightness for the colour). + r, g, b = [int(c * brightness) for c in (r, g, b)] + self.key.set_led(r, g, b) + + def update(self): + # Update the step. Pretty much just handles the LEDs. + r, g, b = self.rgb + + # If this step's track is focussed... + if self.track.focussed: + # Only update the LEDs when the sequencer is running. + if self.sequencer.running: + # Make an active step that is currently being played full + # brightness. + if self.playing and self.active: + self.set_led(r, g, b, PLAY_BRIGHTNESS) + + # Make an inactive step that is "playing" (the current step) + # the dimmest brightness, but bright enough to indicate the + # step the sequencer is on. + if self.playing and not self.active: + self.set_led(r, g, b, STEP_BRIGHTNESS) + + # Make an active step that is not playing a low-medium + # brightness to indicate that it is toggled active. + if not self.playing and self.active: + self.set_led(r, g, b, ACTIVE_BRIGHTNESS) + + # Turn not playing, not active steps off. + if not self.playing and not self.active: + self.set_led(0, 0, 0, 0) + + # If the sequencer is not running, still show the active steps. + elif not self.sequencer.running: + if self.active: + self.set_led(r, g, b, 0.3) + else: + self.set_led(0, 0, 0, 0) + +# Set up Keybow +i2c = board.I2C() + +# Instatiate the sequencer. +sequencer = Sequencer(i2c) + +while True: + # Always remember to call sequencer.update() on every iteration of the main + # loop, otherwise NOTHING WILL WORK! + sequencer.update() \ No newline at end of file diff --git a/keybow2040.py b/keybow2040.py index bdeb733..37b4020 100755 --- a/keybow2040.py +++ b/keybow2040.py @@ -55,7 +55,7 @@ _PINS = [board.SW0, NUM_KEYS = 16 -class Keybow2040: +class Keybow2040(object): """ Represents a Keybow 2040 and hence a set of Key instances with associated LEDs and key behaviours. @@ -67,6 +67,7 @@ class Keybow2040: self.display = keybow2040.Keybow2040(i2c) self.keys = [] self.time_of_last_press = time.monotonic() + self.time_since_last_press = None self.led_sleep_enabled = False self.led_sleep_time = 60 self.sleeping = False @@ -91,6 +92,8 @@ class Keybow2040: self.time_of_last_press = time.monotonic() self.sleeping = False + self.time_since_last_press = time.monotonic() - self.time_of_last_press + # If LED sleep is enabled, but not engaged, check if enough time # has elapsed to engage sleep. If engaged, record the state of the # LEDs, so it can be restored on wake. @@ -269,7 +272,8 @@ class Key: self.state = 0 self.pressed = 0 self.last_state = None - self.time_pressed = None + self.time_of_last_press = time.monotonic() + self.time_since_last_press = None self.time_held_for = 0 self.held = False self.hold_time = 0.75 @@ -285,6 +289,8 @@ class Key: self.hold_function = None self.press_func_fired = False self.hold_func_fired = False + self.debounce = 0.125 + self.key_locked = False def get_state(self): # Returns the state of the key (0=not pressed, 1=pressed). @@ -295,16 +301,24 @@ class Key: # Updates the state of the key and updates all of its # attributes. + self.time_since_last_press = time.monotonic() - self.time_of_last_press + + # Keys get locked during the debounce time. + if self.time_since_last_press < self.debounce: + self.key_locked = True + else: + self.key_locked = False + self.state = self.get_state() self.pressed = self.state update_time = time.monotonic() # If there's a `press_function` attached, then call it, # returning the key object and the pressed state. - if self.press_function is not None and self.pressed and not self.press_func_fired: + if self.press_function is not None and self.pressed and not self.press_func_fired and not self.key_locked: self.press_function(self) self.press_func_fired = True - time.sleep(0.05) # A little debounce + # time.sleep(0.05) # A little debounce # If the key has been pressed and releases, then call # the `release_function`, if one is attached. @@ -319,15 +333,15 @@ class Key: self.last_state = False # If the key has just been pressed, then record the - # `time_pressed`, and update last_state. + # `time_of_last_press`, and update last_state. elif self.pressed and self.last_state == False: - self.time_pressed = update_time + self.time_of_last_press = update_time self.last_state = True # If the key is pressed and held, then update the # `time_held_for` variable. elif self.pressed and self.last_state == True: - self.time_held_for = update_time - self.time_pressed + self.time_held_for = update_time - self.time_of_last_press self.last_state = True # If the `hold_time` theshold is crossed, then call the