Python, music and black magic

publié le 14 janvier 2017

[fr] Quelques réflexions en mode geek sur le code que j'ai écrit pour le dernier concert des Chemins de Traverse.

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.

Une flûte...

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!