Keyboard input¶
Info
See the full example source code on GitHub.
Important
This example requires additional third-party packages. Install them with:
josephine@laptop:~$ pip install supriya[examples]
josephine@laptop:~$ git clone https://github.com/supriya-project/supriya.git
josephine@laptop:~$ cd supriya
josephine@laptop:~/supriya$ pip install -e .[examples]
Let’s introduce some interactivity.
We can play notes with Supriya using a MIDI keyboard. Don’t have a MIDI keyboard available? We’ll also support using your computer’s QWERTY keyboard just like you can in Ableton Live.
Note events¶
What’s a “note”, anyways?
MIDI represents notes not as durated objects positioned on a timeline, but
instead as pairs of “on” and “off” events. The note starts when an instrument
handles a note “on” event, and it stops when that instrument handles the
corresponding note “off” event. We won’t recreate MIDI here exactly, but we
need some simple classes to represent the idea of starting a note with a given
pitch (or note number) and loudness (or velocity) and stopping a note at a
given pitch. We can do this with a pair of dataclasses
:
@dataclasses.dataclass
class NoteOn:
"""
A note on event.
"""
note_number: int
velocity: int
@dataclasses.dataclass
class NoteOff:
"""
A note off event.
"""
note_number: int
The integer values for note_number
and velocity
will range from 0
to 127
, just like with MIDI. It’s an obvious choice for this examples
because we’ll literally be using MIDI as an input later on. Massaging all input
values into a consistent 0-127
range will remove the need for
special-casing.
Note
We’ll use dataclasses
pervasively in the examples because 1) they
save vertical space by removing boilerplate, and 2) their requirement to
use type hints (in my opinion) aids legibility. Importantly, we don’t
need to write initializers for them.
Managing polyphony¶
While “note” events represent only the start of stop of a note, we need to translate these events into the creation and destruction of synths on the SuperCollider server, and that requires tracking state. We’ll do this with a polyphony manager. Our implementation looks like this:
@dataclasses.dataclass
class PolyphonyManager:
"""
A polyphony manager.
Translates :py:class:`NoteOn` or :py:class:`NoteOff` events into actions
against a :py:class:`~supriya.contexts.core.Context`.
"""
# the server to act on
server: supriya.Context
# a dictionary of MIDI note numbers to synths
notes: dict[int, supriya.Synth] = dataclasses.field(default_factory=dict)
# a synthdef to use when making new synths
synthdef: supriya.SynthDef = dataclasses.field(default=supriya.default)
# target node to add relative to
target_node: supriya.Node | None = None
# add action to use
add_action: supriya.AddAction = supriya.AddAction.ADD_TO_HEAD
def free_all(self) -> None:
"""
Free all currently playing :py:class:`~supriya.contexts.entities.Synth`
instances.
"""
with self.server.at():
for synth in self.notes.values():
synth.free()
def perform(self, event: NoteOn | NoteOff) -> None:
"""
Perform a :py:class:`NoteOn` or :py:class:`NoteOff` event.
"""
# if we're starting a note ...
if isinstance(event, NoteOn):
# bail if we already started this note
if event.note_number in self.notes:
return
print(f"Performing {event}")
# convert MIDI 0-127 to frequency in Hertz
frequency = supriya.conversions.midi_note_number_to_frequency(
event.note_number
)
# convert MIDI 0-127 to amplitude
amplitude = supriya.conversions.midi_velocity_to_amplitude(event.velocity)
# create a synth and store a reference by MIDI note number in the
# dictionary ...
self.notes[event.note_number] = self.server.add_synth(
add_action=self.add_action,
amplitude=amplitude,
frequency=frequency,
synthdef=self.synthdef,
target_node=self.target_node,
)
# if we're stopping a note ...
elif isinstance(event, NoteOff):
# bail if we already stopped this note:
if event.note_number not in self.notes:
return
print(f"Performing {event}")
# pop the synth out of the dictionary and free it ...
self.notes.pop(event.note_number).free()
The polyphony manager’s main work is in its
perform()
method,
translating note on and note off events into actions to create or destroy
synths on the SuperCollider server. When creating synths, we need to translate
the MIDI 0-127
range into Hertz frequency and linear amplitude. We’ll use
some conversion helpers to do this. The manager stores state about the notes
and synths in a dictionary, mapping note numbers to synth instances so the
synths can be freed later, when it handles note “off” events.
The manager also implements a
free_all()
method to free
all the synths at once. We’ll see both
perform()
and
free_all()
in use later on.
Handling input¶
Now let’s build some input handlers.
We have some basic requirements for any input handler:
It needs to translate raw input into
NoteOn
andNoteOff
events.It needs to start up and listen continuously in the background in a non-blocking way.
It needs to be decoupled from the rest of the system. It shouldn’t know about our
PolyphonyManager
, only that it can pass the generatedNoteOn
andNoteOff
events along to some other callable, configured when the input handler is instantiated.
We’ll define a simple base class for our MIDI and QWERTY input handlers, mainly
to ease type hints. Anytime you encounter a setup / do something / teardown
pattern, a context manager is an obvious choice, so we’ll use Python’s
contextlib.contextmanager()
to decorate our
listen()
stub method:
@dataclasses.dataclass
class InputHandler:
"""
Base class for input handlers.
"""
@contextlib.contextmanager
def listen(
self, callback: Callable[[NoteOn | NoteOff], None]
) -> Generator[None, None, None]:
# subclasses must implement this method!
# 1) start the handler's listener
# 2) yield to the with block body
# 3) stop the handler's listener
raise NotImplementedError
She doesn’t actually do anything, so let’s define some concrete implementations.
Handling MIDI¶
MIDI input is relatively simple to setup, at least compared to QWERTY. We’ll use the python-rtmidi library to listen to MIDI messages from attached hardware. Let’s take a look:
@dataclasses.dataclass
class MidiHandler(InputHandler):
"""
A MIDI input handler.
"""
port: int | str
@contextlib.contextmanager
def listen(
self, callback: Callable[[NoteOn | NoteOff], None]
) -> Generator[None, None, None]:
"""
Context manager for listening to MIDI input events.
"""
self.midi_input = rtmidi.MidiIn() # create the MIDI input
# set the MIDI event callback to this class's __call__
self.midi_input.set_callback(functools.partial(self.handle, callback))
self.midi_input.open_port(self.port) # open the port for listening
print("Listening for MIDI keyboard events ...") # let the user know
yield # yield to the with block body
self.midi_input.close_port() # close the port
def handle(
self,
callback: Callable[[NoteOn | NoteOff], None],
event: tuple[tuple[int, int, int], float],
*args,
) -> None:
"""
Handle a MIDI input event.
"""
print(f"MIDI received: {event}")
# the raw MIDI event is a 2-tuple of MIDI data and time delta, so
# unpack it, keep the data and discard the time delta ...
data, _ = event
if data[0] == rtmidi.midiconstants.NOTE_ON: # if we received a note-on ...
# grab the note number and velocity
_, note_number, velocity = data
# perform a "note on" event
callback(NoteOn(note_number=note_number, velocity=velocity))
elif data[0] == rtmidi.midiconstants.NOTE_OFF: # if we received a note-off ...
# grab the note number
_, note_number, _ = data
# perform a "note off" event
callback(NoteOff(note_number=note_number))
The listen()
context manager
creates an rtmidi.MidiIn
instance, points its callback at the
MidiHandler
’s
handle()
method, opens a port and
then yields. When it’s done yielding it just closes the port. Look closer at
how we treat the callback passed into
listen()
. Rather than store a
reference to the callback function on the handler instance, we use
functools.partial()
to “freeze” part of the call to the handler’s
handle()
method. This way, our
custom callback
will always be the first argument to
listen()
as long as we’re inside
its context block.
MIDI note on and note off messages arrive into our
handle()
method from
rtmidi
as integer triples. The first integer (the status) tells us
what kind of MIDI message it is. For note on and note off messages the second
integer represents the note number, from 0
to 127
(more than enough to
represent a grand piano’s keyboard, which should be good enough for anyone,
right?) and the last integer represents the velocity (how fast the key was
pressed, which by convention maps to volume level).
Our handle()
method checks the
first integer to determine if it’s handling a note on or note off, ignoring
other types of messages, and then wraps the data into our
NoteOn
and
NoteOff
dataclasses, calling the
callback
callable against them.
Handling QWERTY¶
Input from a computer keyboard is more complicated because we need to simulate a larger pitch space than we have physical keys available. We’ll use the pynput library to listen for key presses and releases, and mimic Ableton Live’s QWERTY keyboard feature. Take a look and then let’s discuss.
@dataclasses.dataclass
class QwertyHandler(InputHandler):
"""
A QWERTY input handler.
"""
octave: int = 5
presses_to_note_numbers: dict[str, int] = dataclasses.field(default_factory=dict)
@contextlib.contextmanager
def listen(
self, callback: Callable[[NoteOn | NoteOff], None]
) -> Generator[None, None, None]:
"""
Context manager for listening to QWERTY input events.
"""
# setup the QWERTY keybord listener
self.listener = pynput.keyboard.Listener(
on_press=functools.partial(self.on_press, callback),
on_release=functools.partial(self.on_release, callback),
)
self.listener.start() # start the listener
print("Listening for QWERTY keyboard events ...") # let the user know
yield # yield to the with block body
self.listener.stop() # stop the listener
@staticmethod
def qwerty_key_to_pitch_number(key: str) -> int | None:
"""
Translate a QWERTY key event into a pitch number.
"""
# dict lookups are faster, but this is soooo much shorter
try:
return "awsedftgyhujkolp;'".index(key)
except ValueError:
return None
def on_press(
self,
callback: Callable[[NoteOn | NoteOff], None],
key: pynput.keyboard.Key | pynput.keyboard.KeyCode | None,
) -> None:
"""
Handle a QWERTY key press.
"""
if not isinstance(key, pynput.keyboard.KeyCode):
return # bail if we didn't get a keycode object
print(f"QWERTY pressed: {key.char}")
if key.char is None:
return
if key.char == "z": # decrement our octave setting
self.octave = max(self.octave - 1, 0)
return
if key.char == "x": # increment our octave setting
self.octave = min(self.octave + 1, 10)
return
if key in self.presses_to_note_numbers:
return # already pressed
if (pitch := self.qwerty_key_to_pitch_number(key.char)) is None:
return # not a valid key, ignore it
# calculate the note number from the pitch and octave
note_number = pitch + self.octave * 12
# QWERTY keyboards aren't pressure-sensitive, so let's create a random
# velocity to simulate expressivity
velocity = random.randint(32, 128)
# stash the note number with the key for releasing later
# so that changing the octave doesn't prevent releasing
self.presses_to_note_numbers[key.char] = note_number
# perform a "note on" event
callback(NoteOn(note_number=note_number, velocity=velocity))
def on_release(
self,
callback: Callable[[NoteOn | NoteOff], None],
key: pynput.keyboard.Key | pynput.keyboard.KeyCode | None,
) -> None:
"""
Handle a QWERTY key release.
"""
if not isinstance(key, pynput.keyboard.KeyCode):
return # bail if we didn't get a keycode object
print(f"QWERTY released: {key.char}")
# bail if the key isn't currently held down
if key.char not in self.presses_to_note_numbers:
return
# grab the note number out of the stash
note_number = self.presses_to_note_numbers.pop(key.char)
# perform a "note off" event
callback(NoteOff(note_number=note_number))
Like the MidiHandler
, the
listen()
context manager is pretty simple. We create a
pynput.keyboard.Listener
, point it as two different callback
methods (one for pressing keys, and another for releasing them ), start it,
yield to the with block, then stop it when we’re done. Easy.
Handling those key presses and releases is tricky though. We’ll treat
asdfghjkl;'
as our white keys with a
mapping to a C5
on a piano
keyboard and ;
mapping to an F6
, and we tyu op
as black keys
starting from C#5
. That’s about an octave and a half of notes when we’d
like to simulate the ten-plus octaves available on MIDI. To do this, we’ll
store the current octave on the handler, and use the z
and x
keys to
decrement or increment the octave, adding it to the note number mapped from the
other keys.
That’s great, but what happens if you change the octave when you’re in the
middle of changing the note? When releasing the key, we’d send a
NoteOff
event corresponding to a different
pitch than the original NoteOn
event,
resulting in “stuck” synths. To fix this, we’ll add state to the
MidiHandler
, storing the note numbers
calculated at the moment each QWERTY key was pressed. When we release the
QWERTY key, rather than re-calculate what the note number should be based on
the current octave, we look up the note number calculated at key press and
issue a NoteOff
for that instead.
Integration¶
Now we need to stitch together our input handlers, the polyphony manager, and a
server so we can actually hear something. We’ll write one
run()
function with a number of simple nested
functions for the various callbacks our application requires:
def run(input_handler: InputHandler) -> None:
"""
Run the script.
"""
def on_boot(*args) -> None: # run this during server.boot()
server.add_synthdefs(polyphony.synthdef) # add the polyphony's synthdef
server.sync() # wait for the synthdef to load before moving on
def on_quitting(*args) -> None: # run this during server.quit()
polyphony.free_all() # free all the synths
time.sleep(0.5) # wait for them to fade out before moving on
def signal_handler(*args) -> None:
exit_future.set_result(True) # set the exit future flag
def input_callback(event: NoteOn | NoteOff) -> None:
# just play the event via polyphony directly
polyphony.perform(event)
# create a future we can wait on to quit the script
exit_future: concurrent.futures.Future[bool] = concurrent.futures.Future()
# create a server and polyphony manager
server = supriya.Server()
polyphony = PolyphonyManager(server=server)
# setup lifecycle callbacks
server.register_lifecycle_callback("BOOTED", on_boot)
server.register_lifecycle_callback("QUITTING", on_quitting)
# hook up Ctrl-C so we can gracefully shutdown the server
signal.signal(signal.SIGINT, signal_handler)
# boot the server and let the user know we're ready to play
server.boot()
print("Server online. Press Ctrl-C to exit.")
# turn on the input handler and teach it to callback against the polyphony manager
with input_handler.listen(callback=input_callback):
exit_future.result() # wait for Ctrl-C
# stop the input handler and quit the server
server.quit()
A few notes about our run()
function:
It takes an
InputHandler
as its only argument, which means we can use theMidiHandler
andQwertyHandler
classes interchangeably. It doesn’t know anything about their specific implementations, just that they have alisten()
context manager method that accepts acallback
callable.The
InputHandler
’s context block simply waits on aconcurrent.futures.Future
to resolve, nothing more. When the future resolves, we exit the context block and quit the server.We add setup and teardown logic to our server via lifecycle event callbacks. Setup involves loading the
SynthDef
we’ll use to create synths, and to wait for it to load before proceeding. Teardown involves freeing any synths still playing after we stop listening for input events, and then waiting for them to completely fade out before moving on.Without getting too deep into systems programming, we’ll use a
signal.signal()
callback to listen forCtrl-C
presses on the keyboard. When you pressCtrl-C
to end the script, this callback will set the value of the exit future our script is waiting on, allowing it to progress to its shutdown logic.
Scripting¶
Let’s put final touches on our script so we can run it from the command-line.
We’ll use argparse
to define the flags we can pass to the script,
with a required mutually exclusive argument group forcing us to pick at least
one flag:
def parse_args(args: list[str] | None = None) -> argparse.Namespace:
"""
Parse CLI arguments.
"""
parser = argparse.ArgumentParser(
description="Play notes via your QWERTY or MIDI keyboards"
)
group = parser.add_mutually_exclusive_group(required=True)
group.add_argument(
"--list-midi-inputs", action="store_true", help="list available MIDI inputs"
)
group.add_argument(
"--use-midi", help="play via MIDI keyboard", type=int, metavar="PORT_NUMBER"
)
group.add_argument(
"--use-qwerty", action="store_true", help="play via QWERTY keyboard"
)
return parser.parse_args(args)
Then we just parse the arguments, check which flag was set, create the
appropriate InputHandler
and then call
run()
with it:
def main(args: list[str] | None = None) -> None:
"""
The example entry-point function.
"""
parsed_args = parse_args(args)
if parsed_args.list_midi_inputs:
# print out available MIDI input ports
rtmidi.midiutil.list_input_ports()
elif parsed_args.use_midi is not None:
run(MidiHandler(port=parsed_args.use_midi))
elif parsed_args.use_qwerty:
run(QwertyHandler())
Of course, we might not know what port to use when playing with MIDI, so we’ll
use the --list-midi-inputs
flag to print out available MIDI port numbers
you can pass to the script when running it with --use-midi
.
Invocation¶
You can invoke the script with …
josephine@laptop:~/supriya$ python -m examples.keyboard_input --help
usage: __main__.py [-h] (--list-midi-inputs | --use-midi PORT_NUMBER |
--use-qwerty)
Play notes via your QWERTY or MIDI keyboards
options:
-h, --help show this help message and exit
--list-midi-inputs list available MIDI inputs
--use-midi PORT_NUMBER
play via MIDI keyboard
--use-qwerty play via QWERTY keyboard
… and you’ll see the options necessary to properly run it.