spotify / pedalboard

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

Pedalboard objects are not pickleable or serializable #96

Open adhooge opened 2 years ago

adhooge commented 2 years ago

First of all, thanks a lot for the amazing library, it's a great help!

While toying around, I wanted to store Pedalboard instances by directly dumping them using pickle (https://docs.python.org/3/library/pickle.html). This fails yielding a TypeError: cannot pickle 'Pedalboard' object. After doing some research it might be due to the fact that Pedalboard.__dict__ is an empty dictionary and thus nothing can be pickled. Besides, the __reduce__ method called by pickle for dumping yields the following error:

terminate called after throwing an instance of 'std::runtime_error'
what():  instance allocation failed: new instance has no pybind11-registered base types

I don't know if that is a relevant issue, I think it would be great to be able to save Pedalboard instances in some way. Maybe pickling it is not the correct way to do it and there exists another technique?

I'd love to help fixing that issue if it is considered relevant.

psobot commented 2 years ago

Thanks @adhooge!

Pedalboard objects are definitely not (yet) pickleable; I'm usually a bit wary of pickling (due to the nasty edge-cases that can arise) but I think it'd be reasonable in this case. It'd be possible to add this functionality by:

There's also a couple of complications to consider, like what happens when pickling/unpickling a plugin that references an external file? (i.e.: Convolution or some VSTs) Should we add dict-based serialization as well, so that people can serialize Pedalboard plugins to their own format of choice? (JSON, YAML, etc)

adhooge commented 2 years ago

Thanks for your answer!

I did not think about the possible issue of Plugins with external files, it would indeed require further consideration. Saving the plugins in JSON or other similar format might be useful too. Essentially, I believe it might be an interesting feature to be able to save a Pedalboard in some way when processing data for future use. It could be done manually for each project through some kind of configuration file but maybe a standardized way of doing it through Pedalboard might help?

adhooge commented 2 years ago

Hi!

I did not try changing the entire module since I'm not familiar with C/C++ bindings in Python but I've written simple helper functions for accessing the parameters of plugins. It's kinda ugly and I don't think it works on complex plugins but it is working fine with the default plugins (Distortion, Chorus, Compressor ...) which are the ones I am using right now. I share my code here, hoping it will help a few other people.

def get_fx_settings(fx: pdb.Plugin):
    fx_settings = {}
    items = list(fx.__class__.__dict__.items())
    for item in items:
        if isinstance(item[1], property):
            fx_settings[item[0]] = item[1].__get__(fx, fx.__class__)
    return fx_settings

def set_fx_settings(fx: pdb.Plugin, settings: dict):
    items = list(fx.__class__.__dict__.items())
    for item in items:
        if isinstance(item[1], property):
            if item[0] not in settings.keys():
                warnings.warn(f'{item[0]} not found in settings. Keeping previous value.', UserWarning)
            else:
                item[1].__set__(fx, settings[item[0]])
    return fx

The output of get_fx_settings is a dictionary so it can easily be saved in whichever way you find convenient.

Minimal working example:

import pedalboard as pdb

disto = pdb.Distortion(40)
settings = get_fx_settings(disto)
print(settings)                     # {'drive_db': 40.0}
disto2 = pdb.Distortion()   
print(disto2.drive_db)         # 25.0
disto2 = set_fx_settings(disto2, settings)
print(disto2.drive_db)         # 40.0
hagenw commented 2 years ago

I guess the main goal of serialization is to re-load a given set of transformations, e.g. when stored in a cache together with the audio files. One way is to serialize to YAML, which has the advantage that it is also human readable. You can achieve this by deriving your classes from audobject.Object. If you want to handle random arguments, you also need to have another object handling those.

Let's imagine we have a transform pedalboard.PinkNoise that has a snr_db argument, inherits from audobject.Object and is serializable as well as pedalboard.observe.List which also dervies from audobject.Object and handles drawing numbers randomly from a list:

transform = pedalboard.PinkNoise(snr_db=pedalboard.observe.List([-5, 0, 10, 20]))

You can then store it to a YAML file:

transform.to_yaml('transform.yaml')

and load it from the YAML file:

import audobject

transform = audobject.from_yaml('transform.yaml')

The corresponding YAML file looks like this:

$pedalboard.PinkNoise==0.7.0:
  snr_db:
    $pedalboard.observe.List==0.7.0:
      elements:
      - -5
      - 0
      - 10
      - 20
0xdevalias commented 11 months ago

Here are some other tangentially related notes from another issue, about serialising plugin parameters to json, and loading them back again:


Is there a good reason to use them instead of just pure txt files?

Yes; JSON handles serialization for you, so you don't need to define your own file format, or write your own serialization and deserialization code. JSON is widely compatible with various programming languages, has types (i.e.: string, float, boolean, etc), is human readable, and can be nicely formatted automatically.

json.dump(param_value_dict, f) gives me this error:

Aha, great find - I haven't tested this code snippet with many plugins, and this falls over due to a problem in Pedalboard with boolean parameters. This is a bit longer but should work instead:

my_plugin = load_plugin(...)
my_plugin.show_editor() # make some edits

# Read out the value of every parameter that this plugin exposes:
param_value_dict = {parameter_name: getattr(my_plugin, parameter_name) for parameter_name in 
my_plugin.parameters.keys()}

# Unwrap boolean values (which Pedalboard tries to transparently wrap
# for developer convenience, but the JSON library is unfamiliar with):
from pedalboard.pedalboard import WrappedBool
param_value_dict = {k: (bool(v) if isinstance(v, WrappedBool) else v) for k, v in param_value_dict.items()}

# Do something with param_value_dict, like serializing it to JSON:
with open("params.json", "w") as f:
    json.dump(param_value_dict, f)

# To reload, just iterate over this dictionary and use `setattr` instead:
for parameter_name, serialized_value in param_value_dict.items():
    setattr(my_plugin, parameter_name, serialized_value)

Originally posted by @psobot in https://github.com/spotify/pedalboard/issues/187#issuecomment-1376205304


If you already have a JSON file configured with the parameter values โ€‹โ€‹and you want to use that JSON to assign the values โ€‹โ€‹to the object parameters :

#  Load parameter values โ€‹โ€‹from JSON file
with open("params.json", "r") as f:
    param_value_dict = json.load(f)

#  For each parameter and value in the dictionary, assign the value to the effect object
for parameter_name, serialized_value in param_value_dict.items():
    setattr(effect, parameter_name, serialized_value)

Originally posted by @garraww in https://github.com/spotify/pedalboard/issues/187#issuecomment-1692655527

hagenw commented 7 months ago

We have recently released https://github.com/audeering/auglib for audio augmentations. You can use auglib.transform.Function as a wrapper for pedalboard transforms and then serialize them. For an example see https://audeering.github.io/auglib/external.html#pedalboard, which automatically serializes the transform and caches the results.

You can also include random parameters in a transform:

import auglib
import pedalboard

def pedalboard_transform(signal, sampling_rate, threshold_db, ratio, room_size):
    r"""Custom augmentation using pedalboard."""
    import pedalboard
    board = pedalboard.Pedalboard(
        [   
            pedalboard.Compressor(threshold_db=threshold_db, ratio=ratio),
            pedalboard.Chorus(),
            pedalboard.Phaser(),
            pedalboard.Reverb(room_size=room_size),
        ],  
    )   
    return board(signal, sampling_rate)

transform = auglib.transform.Compose(
    [   
        auglib.transform.Function(
            pedalboard_transform,
            function_args={
                "threshold_db": auglib.observe.IntUni(-55, -45),
                "ratio": auglib.observe.IntUni(20, 30),
                "room_size": auglib.observe.FloatNorm(0.25, 0.02),
            },
        ),  
        auglib.transform.NormalizeByPeak(),
    ]   
)

Serialize it to a YAML file.

transform.to_yaml("transform.yaml")

Inspect the YAML file

with open("transform.yaml", "r") as f:
    print(f.read())

which returns

$auglib.core.transform.Compose==1.0.2:
  transforms:
  - $auglib.core.transform.Function==1.0.2:
      function: "def pedalboard_transform(signal, sampling_rate, threshold_db, ratio,\
        \ room_size):\n    r\"\"\"Custom augmentation using pedalboard.\"\"\"\n  \
        \  import pedalboard\n    board = pedalboard.Pedalboard(\n        [   \n \
        \           pedalboard.Compressor(threshold_db=threshold_db, ratio=ratio),\n\
        \            pedalboard.Chorus(),\n            pedalboard.Phaser(),\n    \
        \        pedalboard.Reverb(room_size=room_size),\n        ],  \n    )   \n\
        \    return board(signal, sampling_rate)\n"
      function_args:
        threshold_db:
          $auglib.core.observe.IntUni==1.0.2:
            low: -55
            high: -45
        ratio:
          $auglib.core.observe.IntUni==1.0.2:
            low: 20
            high: 30
        room_size:
          $auglib.core.observe.FloatNorm==1.0.2:
            mean: 0.25
            std: 0.02
            minimum: -.inf
            maximum: .inf
      preserve_level: false
      bypass_prob: null
  - $auglib.core.transform.NormalizeByPeak==1.0.2:
      peak_db: 0.0
      clip: false
      preserve_level: false
      bypass_prob: null
  preserve_level: false
  bypass_prob: null

Load transform from YAML file

import audobject

transform = audobject.from_yaml("transform.yaml")