Keyboard input, async¶
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 revisit the previous keyboard input example, but
this time we’ll refactor it to make it work with asyncio
. Compared to
the first keyboard input example, this is actually a walk in the park. Now we
get to see why the previous example enforced strict decoupling between logical
components.
Re-used components¶
We get to re-use many of the components from the non-async keyboard input example:
The
NoteOn
andNoteOff
are just event placeholders, so don’t need any change for use withasyncio
.The
PolyphonyManager
acts against a generic write-onlyContext
which makes it concurrency-agnostic.And both the
MidiHandler
andQwertyHandler
classes take callbacks for theirlisten()
context manager, decoupling them from any concurrency-specific logic. While their callbacks can’t be async functions, they can still synchronously interact with code that other async logic relies on, like queues, futures, or even async tasks.We even get to re-use the argument parsing logic, as nothing changes there.
Integration¶
The core difference between the sync and async examples is in the
run()
implementation. The async version
sprinkles the async
keyword into various locations, e.g. around booting,
syncing, quitting, waiting on futures, etc. But it also has some deeper logical
differences. Take a look, and then we’ll discuss:
async def run(input_handler: InputHandler) -> None:
"""
Run the script.
"""
async def on_boot(*args) -> None: # run this during server.boot()
server.add_synthdefs(polyphony.synthdef) # add the polyphony's synthdef
await server.sync() # wait for the synthdef to load before moving on
async def on_quitting(*args) -> None: # run this during server.quit()
polyphony.free_all() # free all the synths
await asyncio.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:
# drop the event onto the queue
loop.call_soon_threadsafe(queue.put_nowait, event)
async def queue_consumer() -> None:
while True: # run forever
# grab an event off the queue and play it
polyphony.perform(await queue.get())
# grab a reference to the current event loop
loop = asyncio.get_event_loop()
# create a future we can wait on to quit the script
exit_future: asyncio.Future[bool] = loop.create_future()
# create a server and polyphony manager
server = supriya.AsyncServer()
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
loop.add_signal_handler(signal.SIGINT, signal_handler)
# boot the server and let the user know we're ready to play
await server.boot()
print("Server online. Press Ctrl-C to exit.")
# setup an event queue, turn on the input handler, and teach the input
# handler to callback against the queue
queue: asyncio.Queue[NoteOn | NoteOff] = asyncio.Queue()
# setup a queue consumer task to run asynchronously
queue_consumer_task = loop.create_task(queue_consumer())
# turn on the input handler and teach it to callback against the queue
with input_handler.listen(callback=input_callback):
await exit_future # wait for Ctrl-C
queue_consumer_task.cancel() # cancel the queue consumer task
# stop the input handler and quit the server
await server.quit()
The key difference is in how the input handling logic - which runs in a
background thread - interacts with the async code running on the event loop in
the main thread. In the previous example the input handlers were directly
connected to the polyphony manager’s
perform()
method, but here
we mediate the interaction via a Queue
. The input handlers
safely drop events onto the queue using
call_soon_threadsafe()
, and a separate coroutine
(queue_consumer
) runs forever in the event loop, waiting on events to pull
off the queue and performing thowe against the polyphony manager.
We create the queue consumer as a task just before input handling starts and hold a reference to it so it doesn’t get garbage collected, and we cancel that task the after the exit future resolves, once we’ve shut down input handling.
Note
There are a lot of different ways of structuring async code (just like any code), and of integrating threaded code with async code. I’ve structured this async example to stay as close as possible to the sync version for the sake of pedagogy. It’s possible to implement this example without the queue as intermediary, but I like how it forces us to think explicitly about the boundaries between background threads and the event loop. As application complexity grows, navigating these thread boundaries becomes crucial, so let’s get used to it a little earlier.
Scripting¶
Like the run()
function, the
main()
function looks very similar to
the previous version. We’re merely wrapping the calls to
run()
in asyncio.run()
to
ensure the resulting coroutines are executed (and completed) in an event loop.
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:
asyncio.run(run(MidiHandler(port=parsed_args.use_midi)))
elif parsed_args.use_qwerty:
asyncio.run(run(QwertyHandler()))
Invocation¶
You can invoke the script with …
josephine@laptop:~/supriya$ python -m examples.keyboard_input_async --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.