# pico_synth_sandbox/keyboard.py
# 2023 Cooper Dalrymple - me@dcdalrymple.com
# GPL v3 License
import os, time
from pico_synth_sandbox.tasks import Task
from adafruit_debouncer import Debouncer
[docs]class Key:
"""An abstract layer to use physical key objects with the :class:`pico_synth_sandbox.keyboard.Keyboard` class.
"""
NONE = 0 #: Indicates that the key hasn't been activated in any way
PRESS = 1 #: Indicates that the key has been pressed
RELEASE = 2 #: Indicates that the key has been released
def __init__(self):
pass
[docs] def check(self):
"""Updates any necessary logic and returns the current state of the key object.
:return: Key state constant
:rtype: int
"""
return self.NONE
[docs] def get_velocity(self):
"""Get the current velocity (0.0-1.0). Typically hard-coded at `1.0`.
:return: Key velocity
:rtype: float
"""
return 1.0
[docs]class DebouncerKey(Key):
"""An abstract layer to debouncer sensor input to use physical key objects with the :class:`pico_synth_sandbox.keyboard.Keyboard` class.
:param io_or_predicate: The input pin or arbitrary predicate to debounce
:type io_or_predicate: ROValueIO | Callable[[], bool]
"""
def __init__(self, io_or_predicate, invert=False):
self._debouncer = Debouncer(io_or_predicate)
self._inverted = invert
[docs] def check(self):
"""Updates the input pin or arbitraary predicate with basic debouncing and returns the current key state.
:return: Key state constant
:rtype: int
"""
self._debouncer.update()
if self._debouncer.rose:
return self.PRESS if not self._inverted else self.RELEASE
elif self._debouncer.fell:
return self.RELEASE if not self._inverted else self.PRESS
else:
return self.NONE
class Note:
def __init__(self, notenum, velocity=1.0, keynum=None):
self.notenum = notenum
self.velocity = velocity
self.keynum = keynum
self.timestamp = time.monotonic()
def get_data(self):
return (self.notenum, self.velocity, self.keynum)
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.notenum == other.notenum
elif isinstance(other, Voice):
if other.note is None:
return False
else:
return self.notenum == other.note.notenum
elif type(other) == int:
return self.notenum == other
elif type(other) == list:
for i in other:
if self.__eq__(i):
return True
return False
else:
return False
def __ne__(self, other):
if isinstance(other, self.__class__):
return self.notenum != other.notenum
elif isinstance(other, Voice):
if other.note is None:
return True
else:
return self.notenum != other.note.notenum
elif type(other) == int:
return self.notenum != other
elif type(other) == list:
for i in other:
if not self.__ne__(i):
return False
return True
else:
return False
def __lt__(self, other):
if isinstance(other, self.__class__):
return self.notenum < other.notenum
elif type(other) == int:
return self.notenum < other
else:
return False
def __gt__(self, other):
if isinstance(other, self.__class__):
return self.notenum > other.notenum
elif type(other) == int:
return self.notenum > other
else:
return False
def __le__(self, other):
if isinstance(other, self.__class__):
return self.notenum <= other.notenum
elif type(other) == int:
return self.notenum <= other
else:
return False
def __ge__(self, other):
if isinstance(other, self.__class__):
return self.notenum >= other.notenum
elif type(other) == int:
return self.notenum >= other
else:
return False
class Voice:
def __init__(self, index):
self.index = index
self.note = None
self.time = time.monotonic()
def set_note(self, note):
self.note = note
self.time = time.monotonic()
def is_active(self):
return not self.note is None
def clear(self):
self.note = None
def __eq__(self, other):
if isinstance(other, self.__class__):
return self.index == other.index
elif isinstance(other, Note) or type(other) == list:
return self.note == other
elif type(other) is int:
return self.index == other # NOTE: Use index or notenum?
else:
return False
def __ne__(self, other):
if isinstance(other, self.__class__):
return self.index != other.index
elif isinstance(other, Note) or type(other) == list:
return self.note != other
elif type(other) is int:
return self.index != other # NOTE: Use index or notenum?
else:
return False
[docs]class Keyboard(Task):
"""Manage note allocation, arpeggiator assignment, sustain, and note callbacks using this class. The root of the keyboard (lowest note) is designated by the `KEYBOARD_ROOT` variable in `settings.toml`. The default note allocation mode is defined by the `KEYBOARD_MODE` variable in `settings.toml`. This class is inherited by the :class:`pico_synth_sandbox.keyboard.TouchKeyboard` class.
:param keys: An array of Key objects used to include physical key inputs as notes during the update routine.
:type keys: array
:param max_voices: The maximum number of voices/notes to be played at once.
:type max_voices: int
:param root: Set the base note number of the physical key inputs. If left as `None`, the `KEYBOARD_ROOT` settings.toml value will be used instead.
:type root: int
:param update_frequency: The rate at which the keyboard keys will be polled.
:type update_frequency: float
"""
NUM_MODES = 3 #: The number of available keyboard note allocation modes.
MODE_HIGH = 0 #: When the keyboard is set as this mode, it will prioritize the highest note value.
MODE_LOW = 1 #: When the keyboard is set as this mode, it will prioritize the lowest note value.
MODE_LAST = 2 #: When the keyboard is set as this mode, it will prioritize notes by the order in when they were played/appended.
def __init__(self, keys=[], max_voices=1, root=None):
if root is None:
self.root = os.getenv("KEYBOARD_ROOT", 48)
else:
self.root = root
self.keys = keys
self._max_voices = max(max_voices, 1)
self._notes = []
self._voices = [Voice(i) for i in range(self._max_voices)]
self._sustain = False
self._sustained = []
self._voice_press = None
self._voice_release = None
self._key_press = None
self._key_release = None
self._arpeggiator = None
self.set_mode(os.getenv("KEYBOARD_MODE", self.MODE_HIGH))
Task.__init__(self, update_frequency=100)
[docs] def set_voice_press(self, callback):
"""Set the callback method you would like to be called when a voice is pressed.
:param callback: The callback method. Must have 4 parameters for voice index, note value, velocity (0.0-1.0), and keynum (if sourced from a :class:`pico_synth_sandbox.keyboard.Key` class). Ie: `def press(voice, notenum, velocity, keynum=None):`.
:type callback: function
"""
self._voice_press = callback
[docs] def set_voice_release(self, callback):
"""Set the callback method you would like to be called when a voice is released.
:param callback: The callback method. Must have 3 parameters for voice index, note value, and keynum (if sourced from a :class:`pico_synth_sandbox.keyboard.Key` class). Velocity is always assumed to be 0.0. Ie: `def release(voice, notenum, keynum=None):`.
:type callback: function
"""
self._voice_release = callback
[docs] def set_key_press(self, callback):
"""Set the callback method you would like to be called when a key is pressed.
:param callback: The callback method. Must have 3 parameters for keynum, note value, velocity (0.0-1.0), and keynum. Ie: `def press(keynum, notenum, velocity):`.
:type callback: function
"""
self._key_press = callback
[docs] def set_key_release(self, callback):
"""Set the callback method you would like to be called when a key is released.
:param callback: The callback method. Must have 2 parameters for keynum and note value. Velocity is always assumed to be 0.0. Ie: `def release(keynum, notenum):`.
:type callback: function
"""
self._key_release = callback
[docs] def set_arpeggiator(self, arpeggiator):
"""Assign an arpeggiator class to the keyboard. Must be of type :class:`pico_synth_sandbox.arpeggiator.Arpeggiator` or a child of that class. When notes are appended to this object, the arpeggiator will automatically be updated. Callbacks from the arpeggiator will also be routed through the press and release callbacks of this object.
:param arpeggiator: The arpeggiator object to be assigned ot the keyboard. If this class is called multiple times, the callbacks of the previously allocated arpeggiator will be unassigned.
:type callback: :class:`pico_synth_sandbox.arpeggiator.Arpeggiator`
"""
if self._arpeggiator:
self._arpeggiator.set_press(None)
self._arpeggiator.set_release(None)
self._arpeggiator = arpeggiator
self._arpeggiator.set_keyboard(self)
self._arpeggiator.set_press(self._timer_press)
self._arpeggiator.set_release(self._timer_release)
[docs] def get_mode(self):
"""Get the current note allocation mode of this object.
:return: keyboard mode
:rtype: int
"""
return self._mode
[docs] def set_mode(self, value):
"""Set the note allocation mode of this object. Use one of the mode constants of this class such as `pico_synth_sandbox.Keyboard.MODE_HIGH`. Note allocation won't be updated until the next update call.
:param value: The desired mode type.
:type value: int
"""
self._mode = value % self.NUM_MODES
[docs] def get_sustain(self):
"""Get the current sustain state of the keyboard.
:return: sustain
:rtype: bool
"""
return self._sustain
[docs] def set_sustain(self, value, update=True):
"""Set the sustain state of the keyboard. If sustain is set as `True`, it will prevent current and future notes from being released until sustain is set as `False`.
:param value: The desired state of sustain. If sustain is set as `False`, any notes that are no longer being held will be released immediately.
:type value: bool
:param update: Whether or not you would like to update the current list notes after changing the sustained state. This may trigger a new note press according to the note allocation rules immediately.
"""
if value != self._sustain:
self._sustain = value
self._sustained = []
if self._sustain:
self._sustained = self._notes.copy()
if update:
self._update()
[docs] def has_notes(self, include_sustained=True):
"""Check whether the keyboard has any active notes.
:param include_sustained: If set as `True`, any sustained notes (if sustain is active) will be included in the check.
:type include_sustained: bool
:returns: has notes
:rtype: bool
"""
if include_sustained and self._sustain and self._sustained:
return True
if self._notes:
return True
return False
[docs] def get_notes(self, include_sustained=True):
"""Get all active notes in the keyboard object. Notes are tuples with 3 elements of `(notenum, velocity, keynum)`. `keynum` may be set as None if note came from an external source instead of a :class:`pico_synth_sandbox.keyboard.Key` object.
:param include_sustained: If set as `True`, any sustained notes will be included in the returned value.
:type include_sustained: bool
:returns: note tuples
:rtype: array
"""
if not self.has_notes(include_sustained):
return []
if include_sustained:
return (self._notes if self._notes else []) + (self._sustained if self._sustain and self._sustained else [])
else:
return self._notes
[docs] def has_note(self, notenum, include_sustained=True):
"""Check whether the keyboard has an active note of a particular note value.
:param include_sustained: If set as `True`, any sustained notes (if sustain is active) will be included in the check.
:type include_sustained: bool
:returns: has note
:rtype: bool
"""
for note in self.get_notes(include_sustained):
if note == notenum:
return True
return
[docs] def get(self, count=None):
"""Retrieve the current note allocated according to the keyboard mode. Only a single monophonic note is currently supported, but polyphony up to the initial `max_voices` value will be added in the future.
:returns: list of `pico_synth_sandbox.keyboard.Note`
:rtype: list
"""
if count is None: count = self._max_voices
notes = self.get_notes()
if self._mode == self.MODE_HIGH or self._mode == self.MODE_LOW:
notes.sort(reverse=(self._mode == self.MODE_HIGH))
else: # self.MODE_LAST
notes.sort(key=lambda note: note.timestamp)
return notes[:count]
[docs] def append(self, notenum, velocity=1.0, keynum=None, update=True):
"""Add a note to the keyboard buffer. Useful when working with MIDI input or another note source. Any previous notes with the same notenum value will be removed automatically.
:param notenum: The number of the note. Can be defined by MIDI notes, a designated sample index, etc. When using MODE_HIGH or MODE_LOW, the value of this parameter will affect the order.
:type notenum: int
:param velocity: The velocity of the note from 0.0 through 1.0.
:type velocity: float
:param keynum: An additional index reference typically used to associate the note with a physical :class:`pico_synth_sandbox.keyboard.Key` object. Not required for use of the keyboard.
:type keynum: int
:param update: Whether or not to update the keyboard logic and potentially trigger any associated callbacks.
:type update: bool
"""
self.remove(notenum, False, True)
note = notenum if isinstance(notenum, Note) else Note(notenum, velocity, keynum)
self._notes.append(note)
if self._sustain:
self._sustained.append(note)
if update:
self._update()
[docs] def remove(self, notenum, update=True, remove_sustained=False):
"""Remove a note from the keyboard buffer. Useful when working with MIDI input or another note source. If the note is found (and the keyboard isn't being sustained or remove_sustained is set as `True`), the release callback will trigger automatically regardless of the `update` parameter.
:param notenum: The number of the note that you would like to removed. All notes in the buffer with this value will be removed. Can be defined by MIDI notes, a designated sample index, etc.
:type notenum: int
:param update: Whether or not to update the keyboard logic and potentially trigger any associated callbacks.
:type update: bool
:param remove_sustained: Whether or not you would like to override the current sustained state of the keyboard and release any notes that are being sustained.
:type remove_sustained: bool
"""
if not self.has_note(notenum):
return
self._notes = [note for note in self._notes if note != notenum]
if remove_sustained and self._sustain and self._sustained:
self._sustained = [note for note in self._sustained if note != notenum]
if update:
self._update()
[docs] async def update(self):
"""Update the keyboard logic and call any pre-defined callbacks if triggered. If any :class:`pico_synth_sandbox.keyboard.Key` objects (during initialization) or an :class:`pico_synth_sandbox.arpeggiator.Arpeggiator` object (using the `set_arpeggiator` method) were associated with this object, it will also be updated in this process.
"""
if self.keys:
for i in range(len(self.keys)):
j = self.keys[i].check()
if j == Key.PRESS:
notenum = self.root + i
velocity = self.keys[i].get_velocity()
self.append(notenum, velocity, i)
if self._key_press:
self._key_press(i, notenum, velocity)
elif j == Key.RELEASE:
notenum = self.root + i
self.remove(notenum)
if self._key_release:
self._key_release(i, notenum)
def _update(self):
if not self._arpeggiator or not self._arpeggiator.is_enabled():
self._update_voices(self.get())
else:
self._arpeggiator.update_notes(self.get_notes() if self.has_notes() else [])
def _timer_press(self, notenum, velocity):
self._update_voices(Note(notenum, velocity))
def _timer_release(self, notenum): # NOTE: notenum is ignored
self._update_voices()
def get_voices(self):
return self._voices
def get_max_voices(self) -> int:
return self._max_voices
def set_max_voices(self, value:int):
self._max_voices = max(value, 1)
if len(self._voices) > self._max_voices:
for i in range(len(self._voices) - 1, self._max_voices - 1, -1):
self._release_voice(self._voices[i])
del self._voices[i]
elif len(self._voices) < self._max_voices:
for i in range(len(self._voices), self._max_voices):
self._voices.append(Voice(i))
self._update_voices()
def get_active_voices(self): # Oldest => Newest
voices = [voice for voice in self._voices if voice.is_active()]
voices.sort(key=lambda voice: voice.time)
return voices
def has_active_voice(self):
for voice in self._voices:
if voice.is_active():
return True
return False
def get_inactive_voices(self): # Oldest => Newest
voices = [voice for voice in self._voices if not voice.is_active()]
voices.sort(key=lambda voice: voice.time)
return voices
def has_inactive_voices(self):
for voice in self._voices:
if not voice.is_active():
return True
return False
def _update_voices(self, notes=None):
if isinstance(notes, Note):
notes = [notes]
# Release all active voices if no available notes
if notes is None or not notes:
if self.has_active_voice():
for voice in self.get_active_voices():
self._release_voice(voice)
return
if self.has_active_voice():
for voice in self.get_active_voices():
# Determine if voice has one of the notes in the buffer
has_note = False
for note in notes:
if voice.note is note:
has_note = True
break
if not has_note:
# Release voices without active notes
self._release_voice(voice)
else:
# Remove currently active notes from buffer
notes.remove(voice.note)
if not notes:
return # No new notes
# Activate new notes
if self.has_inactive_voices(): # If no voices are available, it will ignore remaining notes
voices = self.get_inactive_voices()
voice_index = 0
for note in notes:
self._press_voice(voices[voice_index], note)
voice_index += 1
if voice_index >= len(voices):
break
def _press_voice(self, voice, note):
voice.set_note(note)
if self._voice_press:
self._voice_press(voice.index, voice.note.notenum, voice.note.velocity, voice.note.keynum)
def _release_voice(self, voice:Voice):
if type(voice) is list:
for i in voice:
self._release_voice(i)
elif voice.is_active():
if self._voice_release:
self._voice_release(voice.index, voice.note.notenum, voice.note.keynum)
voice.clear()
[docs]def get_keyboard_driver(board, max_voices=1, root=None):
"""Automatically generate the proper :class:`pico_synth_sandbox.keyboard.Keyboard` object based on the device's settings.toml configuration.
:param max_voices: The maximum number of voices/notes to be played at once.
:type max_voices: int
:param root: Set the base note number of the physical key inputs. If left as `None`, the `KEYBOARD_ROOT` settings.toml value will be used instead.
:type root: int
"""
if board.has_touch_keys():
from pico_synth_sandbox.keyboard.touch import TouchKeyboard
return TouchKeyboard(
board,
max_voices=max_voices,
root=root
)
elif board.has_ttp():
from pico_synth_sandbox.keyboard.ton_touch import TonTouchKeyboard
return TonTouchKeyboard(
board,
max_voices=max_voices,
root=root,
input_mode=TonTouchKeyboard.MODE_8KEY if board.get_ttp_mode() == "TTP8" else TonTouchKeyboard.MODE_16KEY
)
else:
return Keyboard(
max_voices=max_voices,
root=root
)