From 3d1faef6da66465086f49e17fa1a3d63abeac087 Mon Sep 17 00:00:00 2001 From: David Braun <2096055+DBraun@users.noreply.github.com> Date: Thu, 13 Feb 2025 14:21:57 -0500 Subject: [PATCH] add pickle support --- .gitignore | 1 + README.md | 11 +- build_linux.sh | 7 + examples/multiprocessing_presets/.gitignore | 1 + examples/multiprocessing_presets/README.md | 14 ++ examples/multiprocessing_presets/main.py | 183 ++++++++++++++++++++ src/headless/bindings.cpp | 9 + tests/test_render.py | 6 +- vita/version.py | 2 +- 9 files changed, 227 insertions(+), 7 deletions(-) create mode 100644 examples/multiprocessing_presets/.gitignore create mode 100644 examples/multiprocessing_presets/README.md create mode 100644 examples/multiprocessing_presets/main.py diff --git a/.gitignore b/.gitignore index 33fd251..5b9a6e0 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ dataset *.egg-info* vita/LICENSE dist +headless/builds/VisualStudio2022/x64 \ No newline at end of file diff --git a/README.md b/README.md index 8c72af8..67e2699 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Vita -Vita is a Python module for interacting with the [Vital Synthesizer](https://github.com/mtytel/vital). **It is not an official product related to Vital**. +Vita is a Python module for interacting with the [Vital Synthesizer](https://github.com/mtytel/vital). **It is not an official product related to Vital**. Vita uses [Effort-based versioning](https://jacobtomlinson.dev/effver/). ## Installation @@ -35,8 +35,8 @@ synth.set_bpm(bpm) print("potential sources:", vita.get_modulation_sources()) print("potential destinations:", vita.get_modulation_destinations()) -# "lfo_1" and "filter_1_cutoff" are potential sources and destinations. -# Let's use "lfo_1" as a source and "filter_1_cutoff" as a destination. +# "lfo_1" is a potential source, +# and "filter_1_cutoff" is a potential destination. assert synth.connect_modulation("lfo_1", "filter_1_cutoff") controls = synth.get_controls() @@ -53,13 +53,16 @@ wavfile.write("generated_preset.wav", SAMPLE_RATE, audio.T) preset_path = "generated_preset.vital" json_text = synth.to_json() + with open(preset_path, "w") as f: + f.write(json_text) # Load JSON text with open(preset_path, "r") as f: json_text = f.read() - assert synth.load_json(json_text) + +assert synth.load_json(json_text) # Or load directly from file assert synth.load_preset(preset_path) diff --git a/build_linux.sh b/build_linux.sh index 02d6dac..f510989 100644 --- a/build_linux.sh +++ b/build_linux.sh @@ -1,3 +1,7 @@ +# export LIBDIR=/usr/lib/python3.10 +# export PYTHONLIBPATH=/usr/lib/python3.10 +# export PYTHONINCLUDEPATH=/usr/include/python3.10 + echo "PYTHONLIBPATH: $PYTHONLIBPATH" echo "PYTHONINCLUDEPATH: $PYTHONINCLUDEPATH" echo "LIBDIR: $LIBDIR" @@ -16,3 +20,6 @@ cd ../.. make headless_server echo "build_linux.sh is done!" + +# to build a wheel: +# python3 -m build --wheel \ No newline at end of file diff --git a/examples/multiprocessing_presets/.gitignore b/examples/multiprocessing_presets/.gitignore new file mode 100644 index 0000000..6caf68a --- /dev/null +++ b/examples/multiprocessing_presets/.gitignore @@ -0,0 +1 @@ +output \ No newline at end of file diff --git a/examples/multiprocessing_presets/README.md b/examples/multiprocessing_presets/README.md new file mode 100644 index 0000000..30a26a0 --- /dev/null +++ b/examples/multiprocessing_presets/README.md @@ -0,0 +1,14 @@ +# Vita - Multiprocessing + +This script demonstrates how to use [multiprocessing](https://docs.python.org/3/library/multiprocessing.html) to efficiently generate one-shots. The number of workers is by default `multiprocessing.cpu_count()`. Each worker has a persistent synthesizer instance. Each worker consumes paths of presets from a multiprocessing [Queue](https://docs.python.org/3/library/multiprocessing.html#pipes-and-queues). For each preset, the worker renders out audio for a configurable MIDI pitch range. The output audio path includes the pitch and preset name. + +Example usage: + +```bash +python main.py --preset-dir "path/to/vital_presets" +``` + +To see all available parameters: +```bash +python main.py --help +``` diff --git a/examples/multiprocessing_presets/main.py b/examples/multiprocessing_presets/main.py new file mode 100644 index 0000000..1f341d3 --- /dev/null +++ b/examples/multiprocessing_presets/main.py @@ -0,0 +1,183 @@ +# This file is part of the Vita distribution (https://github.com/DBraun/Vita). +# Copyright (c) 2025 David Braun. + +import argparse +from collections import namedtuple +import logging +import multiprocessing +import os +from pathlib import Path +import time +import traceback + +# extra libraries to install with pip +import vita +import numpy as np +from scipy.io import wavfile +from tqdm import tqdm + + +Item = namedtuple("Item", "preset_path") + + +class Worker: + + def __init__( + self, + queue: multiprocessing.Queue, + bpm: float = 120.0, + note_duration: float = 2.0, + render_duration: float = 5.0, + pitch_low: int = 60, + pitch_high: int = 72, + velocity: int = 100, + output_dir="output", + ): + self.queue = queue + self.bpm = bpm + self.note_duration = note_duration + self.render_duration = render_duration + self.pitch_low, self.pitch_high = pitch_low, pitch_high + self.velocity = velocity + self.output_dir = Path(output_dir) + + def startup(self): + synth = vita.Synth() + synth.set_bpm(self.bpm) + self.synth = synth + + def process_item(self, item: Item): + preset_path = item.preset_path + self.synth.load_preset(preset_path) + basename = os.path.basename(preset_path) + + for pitch in range(self.pitch_low, self.pitch_high + 1): + audio = self.synth.render( + pitch, self.velocity, self.note_duration, self.note_duration + ) + output_path = self.output_dir / f"{pitch}_{basename}.wav" + wavfile.write(str(output_path), 44_100, audio.transpose()) + + def run(self): + try: + self.startup() + while True: + try: + item = self.queue.get_nowait() + self.process_item(item) + except multiprocessing.queues.Empty: + break + except Exception as e: + return traceback.format_exc() + + +def main( + preset_dir, + bpm: float = 120.0, + note_duration: float = 2.0, + render_duration: float = 4.0, + pitch_low: int = 60, + pitch_high: int = 60, + num_workers=None, + output_dir="output", + logging_level="INFO", +): + + # Create logger + logging.basicConfig() + logger = logging.getLogger("vita") + logger.setLevel(logging_level.upper()) + + # Glob all the preset file paths + preset_paths = list(Path(preset_dir).rglob("*.vital")) + + # Get num items so that the progress bar works well + num_items = len(preset_paths) + + # Create a Queue and add items + input_queue = multiprocessing.Manager().Queue() + for preset_path in preset_paths: + input_queue.put(Item(str(preset_path))) + + # Create a list to hold the worker processes + workers = [] + + # The number of workers to spawn + num_processes = num_workers or multiprocessing.cpu_count() + + # Log info + logger.info(f"Note duration: {note_duration}") + logger.info(f"Render duration: {render_duration}") + logger.info(f"Using num workers: {num_processes}") + logger.info(f"Pitch low: {pitch_low}") + logger.info(f"Pitch high: {pitch_high}") + logger.info(f"Output directory: {output_dir}") + + os.makedirs(output_dir, exist_ok=True) + + # Create a multiprocessing Pool + with multiprocessing.Pool(processes=num_processes) as pool: + # Create and start a worker process for each CPU + for i in range(num_processes): + worker = Worker( + input_queue, + bpm=bpm, + note_duration=note_duration, + render_duration=render_duration, + pitch_low=pitch_low, + pitch_high=pitch_high, + output_dir=output_dir, + ) + async_result = pool.apply_async(worker.run) + workers.append(async_result) + + # Use tqdm to track progress. Update the progress bar in each iteration. + pbar = tqdm(total=num_items) + while True: + incomplete_count = sum(1 for w in workers if not w.ready()) + pbar.update( + num_items - input_queue.qsize() - pbar.n + ) # not perfectly accurate. + if incomplete_count == 0: + break + time.sleep(0.1) + pbar.close() + + # Check for exceptions in the worker processes + for i, worker in enumerate(workers): + exception = worker.get() + if exception is not None: + logger.error(f"Exception in worker {i}:\n{exception}") + + logger.info("All done!") + + +if __name__ == "__main__": + # We're using multiprocessing.Pool, so our code MUST be inside __main__. + # See https://docs.python.org/3/library/multiprocessing.html + + # fmt: off + parser = argparse.ArgumentParser() + parser.add_argument("--preset-dir", required=True, help="Directory path of Vital presets.") + parser.add_argument("--bpm", default=120.0, type=float, help="Beats per minute for the Render Engine.") + parser.add_argument("--note-duration", default=1, type=float, help="Note duration in seconds.") + parser.add_argument("--pitch-low", default=60, type=int, help="Lowest MIDI pitch to be used (inclusive).") + parser.add_argument("--pitch-high", default=60, type=int, help="Highest MIDI pitch to be used (inclusive).") + parser.add_argument("--render-duration", default=1, type=float, help="Render duration in seconds.") + parser.add_argument("--num-workers", default=None, type=int, help="Number of workers to use.") + parser.add_argument("--output-dir", default=os.path.join(os.path.dirname(__file__), "output"), help="Output directory.") + parser.add_argument("--log-level", default="INFO", choices=["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL", "NOTSET"], help="Logger level.") + # fmt: on + args = parser.parse_args() + + main( + args.preset_dir, + args.bpm, + args.note_duration, + args.render_duration, + args.pitch_low, + args.pitch_high, + args.num_workers, + args.output_dir, + args.log_level, + ) diff --git a/src/headless/bindings.cpp b/src/headless/bindings.cpp index 9cac6df..2d58433 100644 --- a/src/headless/bindings.cpp +++ b/src/headless/bindings.cpp @@ -379,6 +379,15 @@ NB_MODULE(vita, m) { .def(nb::init<>()) // Ensure there's a default constructor or adjust // accordingly // Bind the first overload of connectModulation + + .def("__getstate__", [](HeadlessSynth &synth) { + return const_cast(synth).pyToJson(); // Removes const safely + }) + .def("__setstate__", [](HeadlessSynth &synth, const std::string &json) { + new (&synth) HeadlessSynth(); + synth.loadFromString(json); + }) + .def("connect_modulation", nb::overload_cast( &HeadlessSynth::pyConnectModulation), diff --git a/tests/test_render.py b/tests/test_render.py index 00f0bfd..5283925 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -53,8 +53,10 @@ def test_render(bpm=120.0, note_dur=1.0, render_dur=3.0, pitch=36, velocity=0.7) # Load JSON text with open(preset_path, "r") as f: - json_text = f.read() - assert synth.load_json(json_text) + json_text1 = f.read() + assert synth.load_json(json_text1) + + assert json_text == json_text1 # Or load directly from file: assert synth.load_preset(preset_path) diff --git a/vita/version.py b/vita/version.py index 27fdca4..81f0fde 100644 --- a/vita/version.py +++ b/vita/version.py @@ -1 +1 @@ -__version__ = "0.0.3" +__version__ = "0.0.4"