Source code for pico_synth_sandbox.keyboard

# pico_synth_sandbox/keyboard.py
# 2023 Cooper Dalrymple - me@dcdalrymple.com
# GPL v3 License

import os, time
from pico_synth_sandbox.tasks import Task

[docs]class Key: """An abstract layer to use physical key objects with the :class:`pico_synth_sandbox.keyboard.Keyboard` class. """ NONE:int = 0 """int: Indicates that the key hasn't been activated in any way """ PRESS:int = 1 """int: Indicates that the key has been pressed """ RELEASE:int = 2 """int: Indicates that the key has been released """ def __init__(self): """Constructor method """ pass
[docs] def check(self) -> int: """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) -> float: """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] :int invert: Whether or not to invert the state of the input. When invert is `False`, the signal is active-high. When it is `True`, the signal is active-low. Defaults to `False`. :type invert: bool """ def __init__(self, io_or_predicate, invert:bool=False): """Constructor method """ from adafruit_debouncer import Debouncer self._debouncer = Debouncer(io_or_predicate) self._inverted = invert
[docs] def check(self) -> int: """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
[docs]class Note: """Object which represents the parameters of a note. Contains note number, velocity, key number (if evoked by a :class:`pico_synth_sandbox.keyboard.Key` object), and timestamp of when the note was created. :param notenum: The MIDI note number representing the frequency of a note. :type notenum: int :param velocity: The strength of which a note was pressed. Ranges from 0.0 to 1.0. Defaults to 1.0. :type velocity: float :param keynum: The index number of the :class:`pico_synth_sandbox.keyboard.Key` object which may have created this :class:`pico_synth_sandbox.keyboard.Note` object. If not applicable, will be `None`. Defaults to `None`. """ def __init__(self, notenum:int, velocity:float=1.0, keynum:int=None): """Constructor method """ self.notenum = notenum self.velocity = velocity self.keynum = keynum self.timestamp = time.monotonic()
[docs] def get_data(self) -> tuple[int, float, int]: """Return all note data as tuple. The data is formatted as: (notenum:int, velocity:float, keynum:int). Keynum may be set as `None` if not applicable. :return: note data :rtype: tuple[int, float, int] """ 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
[docs]class Voice: """Object which represents the parameters of a :class:`pico_synth_sandbox.keyboard.Keyboard` voice. Used to allocate :class:`pico_synth_sandbox.keyboard.Note` objects to a pre-defined number of available slots in a logical manner based on timing and keyboard mode. :param index: The position of the voice in the pre-defined set of keyboard voices. Used for external reference. :type index: int """ def __init__(self, index:int): """Constructor method """ self.index = index self.note = None self.time = time.monotonic()
[docs] def set_note(self, note:Note): """Assign a :class:`pico_synth_sandbox.keyboard.Note` object to a voice. When a note is assigned to a voice, the voice is "active" until the note is cleared. :param note: The :class:`pico_synth_sandbox.keyboard.Note` object :type note: :class:`pico-synth_sandbox.keyboard.Note` """ self.note = note self.time = time.monotonic()
[docs] def is_active(self) -> bool: """Determines whether or not a voice has a :class:`pico_synth_sandbox.keyboard.Note` object assigned to it. If it does, it will return `True`. Otherwise, `False`. :return: the active state of the voice :rtype: bool """ return not self.note is None
[docs] def clear(self): """Remove any assigned :class:`pico_synth_sandbox.keyboard.Note` object from the voice. The voice will be made "inactive". """ 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 notes, voice allocation, arpeggiator assignment, sustain, and relevant callbacks using this class. The default note allocation mode is defined by the `KEYBOARD_MODE` variable in `settings.toml`. :param keys: A list of :class:`pico_synth_sandbox.keyboard.Key` objects used to include physical key inputs as notes during the update routine. :type keys: list :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 """ NUM_MODES = 3 """int: The number of available keyboard note allocation modes. """ MODE_HIGH = 0 """int: When the keyboard is set as this mode, it will prioritize the highest note value. """ MODE_LOW = 1 """int: When the keyboard is set as this mode, it will prioritize the lowest note value. """ MODE_LAST = 2 """int: 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:list[Key]=[], max_voices:int=1, root:int=None): """Constructor method """ 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) -> int: """Get the current note allocation mode of this object. :return: keyboard mode :rtype: int """ return self._mode
[docs] def set_mode(self, value:int): """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) -> bool: """Get the current sustain state of the keyboard. :return: sustain :rtype: bool """ return self._sustain
[docs] def set_sustain(self, value:bool, update:bool=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:bool=True) -> bool: """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:bool=True) -> list[Note]: """Get all active :class:`pico_synth_sandbox.keyboard.Note` objects within the keyboard object. :param include_sustained: If set as `True`, any sustained notes will be included in the returned value. :type include_sustained: bool :returns: list of note objects :rtype: list[:class:`pico_synth_sandbox.keyboard.Note`] """ 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:int|Note, include_sustained:bool=True) -> bool: """Check whether the keyboard has an active note. :param notenum: The MIDI note value or :class:`pico_synth_sandbox.keyboard.Note` to check for. :type notenum: int|:class:`pico_synth_sandbox.keyboard.Note` :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 False
[docs] def get(self, count:int=None) -> list[Note]: """Retrieve a set of active notes according to the keyboard mode setting (`MODE_HIGH`, `MODE_LOW`, or `MODE_LAST`). :param count: The number of notes to return. If left undefined, the max voices setting of the keyboard object will be used instead. :type count: int :returns: list of `pico_synth_sandbox.keyboard.Note` objects :rtype: list[:class:`pico_synth_sandbox.keyboard.Note`] """ 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:int|Note, velocity:float=1.0, keynum:int=None, update:bool=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. A :class:`pico_synth_sandbox.keyboard.Note` object can be used instead of providing notenum, velocity, and keynum parameters directly. :type notenum: int|:class:`pico_synth_sandbox.keyboard.Note` :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:int|Note, update:bool=True, remove_sustained:bool=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 value of the note that you would like to be removed. All notes in the buffer with this value will be removed. Can be defined by MIDI note value, a designated sample index, etc. Can also use a :class:`pico_synth_sandbox.keyboard.Note` object instead. :type notenum: int|:class:`pico_synth_sandbox.keyboard.Note` :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()
[docs] def get_voices(self) -> list[Voice]: """Get all :class:`pico_synth_sandbox.keyboard.Voice` objects used by the :class:`pico_synth_sandbox.keyboard.Keyboard` object. :return: list of voice objects :rtype: list[:class:`pico_synth_sandbox.keyboard.Voice`] """ return self._voices
[docs] def get_max_voices(self) -> int: """Return the maximum number of voices used by this keyboard to allocate notes. :return: max voices :rtype: int """ return self._max_voices
[docs] def set_max_voices(self, value:int): """Change the number of max voices used to allocate notes. Must be greater than 1. When this method is called, it will automatically release and delete any voices or add new voice objects depending on the previous number of voices. Any voice related callbacks may be triggered during this process. :param value: The maximum number of voices to allocate notes :type 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()
[docs] def get_active_voices(self) -> list[Voice]: """Get all keyboard voices that are "active", have been assigned a note. The voices will automatically be sorted by the time they were last assigned a note from oldest to newest. :return: all active voices :rtype: list[:class:`pico_synth_sandbox.keyboard.Voice`] """ voices = [voice for voice in self._voices if voice.is_active()] voices.sort(key=lambda voice: voice.time) return voices
[docs] def has_active_voice(self) -> bool: """Checks to see if any voice is currently "active", has been assigned a note. :return: whether or not at least one voice is active :rtype: bool """ for voice in self._voices: if voice.is_active(): return True return False
[docs] def get_inactive_voices(self) -> list[Voice]: """Get all keyboard voices that are "inactive", do not currently have a note assigned. The voices will automatically be sorted by the time they were last assigned a note from oldest to newest. :return: all inactive voices :rtype: list[:class:`pico_synth_sandbox.keyboard.Voice`] """ voices = [voice for voice in self._voices if not voice.is_active()] voices.sort(key=lambda voice: voice.time) return voices
[docs] def has_inactive_voices(self) -> bool: """Checks to see if any voice is currently "inactive", has not been assigned a note. :return: whether or not at least one voice is inactive :rtype: bool """ 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:int=1, root:int=None) -> Keyboard: """Automatically generate the proper :class:`pico_synth_sandbox.keyboard.Keyboard` object based on the device's settings.toml configuration. :param board: The designated board configuration object. Can be obtained by calling `pico_synth_sandbox.board.get_board()`. :type board: :class:`pico_synth_sandbox.board.Board` :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 :return: a keyboard object for the designated board :rtype: :class:`pico_synth_sandbox.keyboard.Keyboard` """ 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 )