spotify / pedalboard

๐ŸŽ› ๐Ÿ”Š A Python library for audio.
https://spotify.github.io/pedalboard
GNU General Public License v3.0
5.28k stars 265 forks source link

Changing VST parameter throughout time? #6

Open youssefavx opened 3 years ago

youssefavx commented 3 years ago

Hi, the current code example shows how to set a specific parameter to a fixed number. Is it possible to change this throughout time (e.g. at beginning of song VST parameter is set to 0, at the end it's set to 1), or via some math function?

psobot commented 3 years ago

Hi! This would be possible by calling process multiple times, passing a different slice of the audio in each time. Something like:

import numpy
from pedalboard import Pedalboard, Compressor, Reverb

input_audio = ...
output_audio = np.zeros_like(input_audio)
board = Pedalboard([Compressor(), Reverb()])
reverb = board[-1]

step = 100 # smaller = slower, but "smoother" transition
for i in range(0, input_audio.shape[0], step):
    percentage_through_track = i / input_audio.shape[0]
    reverb.wet_level = percentage_through_track
    output_audio[i : i + step] = board.process(input_audio[i : i + step])

Note that at the moment, Pedalboard clears each plugin's state every time process is called - so this technique won't work for plugins that already change over time (i.e.: won't work for Reverb, Chorus, Phaser, etc). I'm working on a branch to allow this, however.

rec commented 3 years ago

Came here to ask this!

This solution still isn't the same as a continuous value, though - for one things, there might be audible tick sounds at the slice boundary when the moving parameter jumps.

What you'd need is an "Envelope", which would be some sort of time-varying function onto [0, 1] for unipolar envelopes or [-1, 1] for bipolar envelopes - maybe even being tolerant of out-of-band values.

Juce has one here: https://github.com/juce-framework/JUCE/blob/master/modules/juce_audio_basics/utilities/juce_ADSR.h

But then you'd have to include that time-changing value into each parameter as you recalculated.

psobot commented 3 years ago

@rec The hard part about a truly continuous value is that changing it on every sample is hugely detrimental to performance, and usually isn't audibly different. A lot of the JUCE code I've worked with uses an explicit modulation rate that is independent of the audio sample rate - usually something like 100-1000x lower. (See this JUCE tutorial about modulating a filter with an LFO, for example, where they suggest changing the parameter values every 100 samples.) I've run code similar to my example above on real-time input and noticed no pops, clicks, or even any audible stair-step as the parameters are changed.

An envelope would be great for determining what the value of the parameter should be at a given point in time, but given the above concerns, I don't think it would really change the logic of how to vary that parameter over time.

rec commented 3 years ago

On Sat, Sep 11, 2021 at 8:56 PM Peter Sobot @.***> wrote:

@rec https://github.com/rec The hard part about a truly continuous value is that changing it on every sample is hugely detrimental to performance.

It depends on the control signal being modified.

Take frequency modulation. In some sense, to get a good result, it requires the modulating frequency to continuously change a "control" parameter of the carrier frequency.

In practice, there is an equation you can plug in and likely do it in a few numpy operations or a few more floating point operations per sample

Another example would be a volume envelope, where a few extra multiplication at sample time/a couple of numpy multiplies would do the trick.

On the other hand, very often the relationship between the input parameter and the output values is not so clearly analyzed that you can do the math.

So there are two different models - the continuous, where you understand the math and can apply it at the per-sample level efficiently; and the granular, where the control signal only changes at a fairly low "LFO" sample rate like 100Hz.

The issue with the granular mechanism is that you're essentially replacing a continuous function by a piecewise constant function with discontinuities every 10ms or whatever. Sometimes this works brilliantly, sometimes you get clicks or aliasing effects.

I have had good success in having slightly overlapping granules which I fade between at transitions periods around 25ms.

[A lot of the JUCE code I've worked with uses an explicit modulation rate]

Ah, there we go, comes of me not reading ahead.

An envelope would be great for determining what the value of the parameter should be at a given point in time, but given the above concerns, I don't think it would really change the logic of how to vary that parameter over time.

As I said, it depends on the nature of the parameter, but I think adding an envelope generator for level/amplitude alone would be fairly entertaining and only add a few ยตs to the calculation of each sample.

Of course, talk is cheap!

I would be tempted to do this to get back into the JUCE game, and to use pybind11, but I'm pretty focused on doing everything with numpy these days and I already have most of my envelope needs covered there.

Honestly, for this sort of offline processing, if I can do it in numpy, it isn't likely to be significantly faster in C++.

-- /t

PGP Key: @.** https://tom.ritchford.com https://tom.ritchford.com https://tom.swirly.com https://tom.swirly.com*

youssefavx commented 3 years ago

@psobot Thank you so much!

Interesting discussion too.

heqi201255 commented 9 months ago

Hi @psobot! I was wondering if it's possible to add the functionality that you can pass the plugin parameter names with their automation values as a dictionary of str & numpy array pairs to the process method as a parameter. This would be very convenience to handle tons of automation envelopes for a single plugin.

Hi! This would be possible by calling process multiple times, passing a different slice of the audio in each time. Something like:

import numpy
from pedalboard import Pedalboard, Compressor, Reverb

input_audio = ...
output_audio = np.zeros_like(input_audio)
board = Pedalboard([Compressor(), Reverb()])
reverb = board[-1]

step = 100 # smaller = slower, but "smoother" transition
for i in range(0, input_audio.shape[0], step):
    percentage_through_track = i / input_audio.shape[0]
    reverb.wet_level = percentage_through_track
    output_audio[i : i + step] = board.process(input_audio[i : i + step])

Note that at the moment, Pedalboard clears each plugin's state every time process is called - so this technique won't work for plugins that already change over time (i.e.: won't work for Reverb, Chorus, Phaser, etc). I'm working on a branch to allow this, however.