Python, music and black magic
Note: This post is primarily intended as a basis for discussion on the pyo-discuss mailing list. However, as the techniques described there might interest some other people, I decided to make it a blog post.
From tune to gig…
Pyo is a great python module for real-time audio processing. The latest concert of Les Chemins de Traverse used it extensively for instrument augmentation.
To illustrate this post, let’s consider this example of a simplistic reverb script in the spirit of this other post.
import pyo
s = pyo.Server(audio='jack', nchnls=1).boot()
s.start()
mic = pyo.Input(chnl=0)
rev = pyo.Freeverb(mic).out()
s.gui()
If all you need for your tune is a simple reverb, then you’re ready to gig!
But wait - having a tune ready doesn’t mean your gig is ready: there will be other tunes, and you’ll have to find a way to switch tunes. Of course, you can just write another simple script and simply quit the first one and launch the second one on demand.
But we wanted more. We wanted to be able to switch tunes as seamlessly as possible, and without the slightest sound interruption.
The Problem
Starting and stopping a pyo server causes dropouts (long enough to have audible clicks) on my machine… So I had to find a way to switch tunes without touching the pyo server. Also, I wanted to be able to write the scripts for each tune independently and be able to freely arrange them into gigs after that, so a big, monolithic script with all tunes in it was out of the question.
In principle, this shouldn’t be very complicated: transform the above reverb code into something like
import pyo
def setup(context):
global rev
rev = pyo.Freeverb(context['mic']).stop()
def start():
rev.out()
def stop():
rev.set('mul', 0, 1, tearoff)
def tearoff():
rev.stop()
… and add a master script that will start the server, build an appropriate context
(i.e. connect to the right mics, pedalboards, …), setup()
each tune and start()
/stop()
them as requested.
The problem with this solution is that the variable rev
which originally appeared only once, is now repeated 5 times. Now, this is a simplistic script, but typical real-world tunes will involve one or two dozens of these objects; moreover, when composing the tune, we are likely to try a few dozens of combinations before finding the exact sound we want. Having to modify the code in 5 different places each time is an overload I’d really like to avoid.
So I was willing to spend some time in developer mode in order to alleviate the work to do in composer mode.
In the rest of this post, I’ll expose how I tried to solve this problem for last december’s gig and why I’m not sure this was the best way to do it. I hope very much that someone can come with a better, cleaner solution.
Step 1: get rid of global
Defining the reverb in a function instead of the global scope forced me to use the global
keyword. The code would be simpler if I could define it in the global scope, but I need the context
to get things like my mic
object.
One (strange, but working) solution is to add an empty context.py
module in my project and use it as a kind of blackboard. In my master script I can do something like
import context
context.mic = pyo.Input(chnl=0)
My tune is now already a little bit simpler:
import pyo
import context
rev = pyo.Freeverb(context.mic).stop()
def start():
rev.out()
def stop():
rev.set('mul', 0, 1, tearoff)
def tearoff():
rev.stop()
But rev
is still repeated 4 times…
Step 2: Autoplay
If I think about it, the code of my start()
and stop()
methods is likely to be very similar from tune to tune: just out
‘ing/start
‘ing and stop
‘ping a bunch of PyoObjects… Couldn’t we automate this? Wouldn’t it be nice if my tune could look like
import pyo
import context
rev = pyo.Freeverb(context.mic).auto_out() # Does not work :-(
As it happens, this is possible if we are ready to use a little bit of black magic. Let’s add to the project a autoplay.py
module with the following code:
import inspect
AUTOPLAY_IGNORE = True
def find_module():
''' Finds and returns the first module in the call stack
that does not have the AUTOPLAY_IGNORE attribute '''
stack = inspect.stack()
i = 0
for frm in stack:
mod = inspect.getmodule(frm[0])
if mod is None:
raise RuntimeError('Autoplay: Cannot find module')
if not hasattr(mod, 'AUTOPLAY_IGNORE'):
return mod
else:
raise RuntimeError('Autoplay: Cannot find module')
def add_to_list(mod, name, obj):
l = getattr(mod, name, [])
if not l:
setattr(mod, name, l)
l.append(obj)
def autoplay(self):
self.stop()
mod = find_module()
add_to_list(mod, 'play_on_start', self)
return self
def auto_out(self, channel=0):
self.stop()
mod = find_module()
add_to_list(mod, 'out_on_start', (channel, self))
return self
import pyo
try:
pyo.PyoObject.autoplay
except AttributeError:
pyo.PyoObject.autoplay = autoplay
pyo.PyoObject.auto_out = auto_out
Describing exactly how this code works is out of the scope of this (already too long) post, but the general idea is using some black magic to add auto_out
and auto_play
methods to all PyoObjects, with the following behaviour: calling obj.auto_out()
from module foo
will automagically create the list foo.out_on_start
and add obj
to it. It’s now a piece of cake to update the master script to automatically start/stop PyoObjects and the tune can be simplified to:
from autoplay import pyo # Import the modified version
import context
# Creates the list `out_on_start`
# and adds `rev` to it so that it can be
# automatically started/stopped from the
# master script:
rev = pyo.Freeverb(context.mic).auto_out()
Step 3: Reverb Tail & co
Actually the above code still has a problem: when stopping the song, the reverb will stop abruptly instead of dying slowly. One possible solution is defining an object like
class FxTail:
def __init__(self, fx, time=2):
self.fx = fx
self.input = fx.input
self.time = time
self.ca = pyo.CallAfter(self.fx.stop, self.time).stop()
def out(self, channel=0):
self.ca.stop() # cancel scheduled stop
self.fx.setInput(self.input)
self.fx.out(channel)
def play(self):
self.ca.stop() # cancel scheduled stop
self.fx.setInput(self.input)
self.fx.play()
def stop(self):
self.fx.setInput(context.denorm)
self.ca.play()
auto_play = autoplay.autoplay
auto_out = autoplay.auto_out
I can now wrap my object before auto_out
‘ing it and my final version of the tune will be:
from autoplay import pyo
import context
rev = FxTail(pyo.Freeverb(context.mic)).auto_out()
Note that this solutions works well for a “standalone” Fx like this, but is much less convincing with a chain of objects, because rev
isn’t a PyoObject any more and cannot be fed as input to another PyoObject. Maybe I should modify the FxTail
class to make it a PyoObject, but I’m not completely sure this is the right way to go.
(Temporary) Conclusion
The final version of the tune is as simple as the standalone version of the beginning. But step #3 breaks the nice “chainability” of pyo and steps #1 and #2 feel somewhat fragile and a bit dirty to me.
So, although my code works pretty well, I feel that there must be a better solution, involving less black magic and (much too) clever python tricks.
As I told in the beginning, this post is intended to serve as a basis for discussion on the pyo-discuss mailing list (if I can get the members to read such an endless post!) so if you’d like to join the discussion, I’ll be glad to meet you there!