Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6795bce6d4 | ||
|
|
bc6763a4e0 | ||
|
|
b48be9ae87 | ||
|
|
3d1faef6da | ||
|
|
02223009ba | ||
|
|
41603bc837 |
20 changed files with 877 additions and 111 deletions
8
.github/workflows/all.yml
vendored
8
.github/workflows/all.yml
vendored
|
|
@ -3,8 +3,12 @@ name: Python package
|
|||
on:
|
||||
push:
|
||||
branches: [ "main" ]
|
||||
paths-ignore:
|
||||
- "README.md"
|
||||
pull_request:
|
||||
branches: [ "main" ]
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build-ubuntu:
|
||||
|
|
@ -201,8 +205,8 @@ jobs:
|
|||
needs: [build-windows, build-ubuntu, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
name: "Upload wheels to PyPI"
|
||||
# if: github.event_name == 'release' && github.event.action == 'published'
|
||||
if: startsWith(github.ref, 'refs/tags/v')
|
||||
if: github.event_name == 'release' && github.event.action == 'published'
|
||||
# if: startsWith(github.ref, 'refs/tags/v')
|
||||
steps:
|
||||
- uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
|
|
|||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -39,3 +39,4 @@ dataset
|
|||
*.egg-info*
|
||||
vita/LICENSE
|
||||
dist
|
||||
headless/builds/VisualStudio2022/x64
|
||||
8
Makefile
8
Makefile
|
|
@ -1,6 +1,8 @@
|
|||
PYTHONINCLUDEPATH := $(shell python3.10 -c "import sysconfig; print(sysconfig.get_path('include'))")
|
||||
PYTHON := $(shell which python)
|
||||
|
||||
PYTHONLIBPATH := $(shell python3.10 -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR'))")
|
||||
PYTHONINCLUDEPATH := $(shell $(PYTHON) -c "import sysconfig; print(sysconfig.get_path('include'))")
|
||||
|
||||
PYTHONLIBPATH := $(shell $(PYTHON) -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR'))")
|
||||
|
||||
ifndef CONFIG
|
||||
CONFIG=Release
|
||||
|
|
@ -8,7 +10,7 @@ endif
|
|||
|
||||
ifndef LIBDIR
|
||||
# LIBDIR=/usr/lib/
|
||||
LIBDIR=$(shell python3.10 -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR'))")
|
||||
LIBDIR=$(shell $(PYTHON) -c "import sysconfig; print(sysconfig.get_config_var('LIBDIR'))")
|
||||
endif
|
||||
|
||||
BUILD_DATE="$(shell date +'%Y %m %d %H %M')"
|
||||
|
|
|
|||
27
README.md
27
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
|
||||
|
||||
|
|
@ -16,8 +16,7 @@ pip install vita
|
|||
from scipy.io import wavfile
|
||||
import vita
|
||||
|
||||
SAMPLE_RATE = 44_100
|
||||
|
||||
sample_rate = 44100
|
||||
bpm = 120.0
|
||||
note_dur = 1.0
|
||||
render_dur = 3.0
|
||||
|
|
@ -27,6 +26,7 @@ velocity = 0.7 # [0.0 to 1.0]
|
|||
synth = vita.Synth()
|
||||
# The initial preset is loaded by default.
|
||||
|
||||
synth.set_sample_rate(sample_rate)
|
||||
synth.set_bpm(bpm)
|
||||
|
||||
# Let's make a custom modulation using
|
||||
|
|
@ -35,30 +35,43 @@ 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.
|
||||
# "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()
|
||||
controls["modulation_1_amount"].set(1.0)
|
||||
controls["filter_1_on"].set(1.0)
|
||||
val = controls["filter_1_on"].value()
|
||||
controls["lfo_1_tempo"].set(vita.constants.SyncedFrequency.k1_16)
|
||||
|
||||
# Use normalized parameter control (0-1 range, VST-style)
|
||||
controls["filter_1_cutoff"].set_normalized(0.5) # Set knob to 50%
|
||||
print(controls["filter_1_cutoff"].get_normalized()) # Get normalized value
|
||||
|
||||
# Get parameter details and display text
|
||||
info = synth.get_control_details("delay_style")
|
||||
print(f"Options: {info.options}") # ["Mono", "Stereo", "Ping Pong", "Mid Ping Pong"]
|
||||
print(f"Current: {synth.get_control_text('delay_style')}") # e.g., "Stereo"
|
||||
|
||||
# Render audio to numpy array shaped (2, NUM_SAMPLES)
|
||||
audio = synth.render(pitch, velocity, note_dur, render_dur)
|
||||
|
||||
wavfile.write("generated_preset.wav", SAMPLE_RATE, audio.T)
|
||||
wavfile.write("generated_preset.wav", sample_rate, audio.T)
|
||||
|
||||
# Dump current state to JSON text
|
||||
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)
|
||||
|
|
@ -69,6 +82,8 @@ synth.load_init_preset()
|
|||
synth.clear_modulations()
|
||||
```
|
||||
|
||||
Documentation is not yet automated. Please browse [bindings.cpp](https://github.com/DBraun/Vita/blob/main/src/headless/bindings.cpp) to get a sense of how the code works.
|
||||
|
||||
### Issues
|
||||
|
||||
If you find any issues with the code, report them at https://github.com/DBraun/Vita.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
1
examples/multiprocessing_presets/.gitignore
vendored
Normal file
1
examples/multiprocessing_presets/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
output
|
||||
14
examples/multiprocessing_presets/README.md
Normal file
14
examples/multiprocessing_presets/README.md
Normal file
|
|
@ -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
|
||||
```
|
||||
183
examples/multiprocessing_presets/main.py
Normal file
183
examples/multiprocessing_presets/main.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
93
examples/normalized_control_demo.py
Normal file
93
examples/normalized_control_demo.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
"""
|
||||
Demo: VST-style normalized parameter control
|
||||
|
||||
This example shows how to control Vital synthesizer parameters using
|
||||
normalized 0-1 values, similar to how VST/AU plugins work in DAWs.
|
||||
"""
|
||||
|
||||
import vita
|
||||
from scipy.io import wavfile
|
||||
|
||||
SAMPLE_RATE = 44_100
|
||||
|
||||
def main():
|
||||
synth = vita.Synth()
|
||||
synth.set_bpm(120.0)
|
||||
|
||||
# Direct control - linear interpolation of actual values
|
||||
controls = synth.get_controls()
|
||||
# Turn the filter on
|
||||
controls["filter_1_on"].set(1.0)
|
||||
|
||||
# Example 1: Comparing direct vs normalized control (Filter 1 Cutoff)
|
||||
print("=== Comparing Direct vs Normalized Control ===")
|
||||
|
||||
# Get info about the filter 1 cutoff parameter
|
||||
info = synth.get_control_details("filter_1_cutoff")
|
||||
print(f"Parameter: {info.display_name}")
|
||||
print(f"Range: {info.min} to {info.max}")
|
||||
print(f"Scale: {str(info.scale)}")
|
||||
print()
|
||||
|
||||
print("Direct control (linear interpolation):")
|
||||
for pct in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
value = info.min + pct * (info.max - info.min)
|
||||
controls["filter_1_cutoff"].set(value)
|
||||
print(f" {pct*100:3.0f}% → value={value:.3f}")
|
||||
|
||||
print("\nNormalized control (VST-style):")
|
||||
for norm in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
controls["filter_1_cutoff"].set_normalized(norm)
|
||||
actual = controls["filter_1_cutoff"].value()
|
||||
display_text = synth.get_control_text("filter_1_cutoff")
|
||||
print(f" {norm*100:3.0f}% → value={actual:.3f} (displays as {display_text})")
|
||||
# Render a short note
|
||||
audio = synth.render(36, 0.8, 0.9, 1) # C2, velocity 0.8, 0.9s note, 1.0s render
|
||||
|
||||
filename = f"filter_sweep_{int(norm*100):03d}.wav"
|
||||
wavfile.write(filename, SAMPLE_RATE, audio.T)
|
||||
print(f"Rendered {filename} with filter at {norm*100:.0f}%")
|
||||
|
||||
|
||||
# Example 2: Comparing direct vs normalized control (Env 1 Delay)
|
||||
print("\n=== Comparing Direct vs Normalized Control ===")
|
||||
|
||||
# Get info about the Env 1 Delay parameter
|
||||
info = synth.get_control_details("env_1_delay")
|
||||
print(f"Parameter: {info.display_name}")
|
||||
print(f"Range: {info.min} to {info.max}")
|
||||
print(f"Scale: {str(info.scale)}")
|
||||
print()
|
||||
|
||||
print("Direct control (linear interpolation):")
|
||||
for pct in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
value = info.min + pct * (info.max - info.min)
|
||||
controls["env_1_delay"].set(value)
|
||||
print(f" {pct*100:3.0f}% → value={value:.3f}")
|
||||
|
||||
print("\nNormalized control (VST-style):")
|
||||
print("(Note: knob position has quartic relationship to display value)")
|
||||
for norm in [0.0, 0.25, 0.5, 0.75, 1.0]:
|
||||
controls["env_1_delay"].set_normalized(norm)
|
||||
actual = controls["env_1_delay"].value()
|
||||
display_text = synth.get_control_text("env_1_delay")
|
||||
expected_display = 4.0 * (norm ** 4)
|
||||
print(f" {norm*100:3.0f}% → value={actual:.3f} (displays as {display_text}, expected ~{expected_display:.2f}s)")
|
||||
|
||||
# Example 3: Discrete parameter control (Delay Style)
|
||||
print("\n=== Discrete Parameter Control ===")
|
||||
|
||||
info = synth.get_control_details("delay_style")
|
||||
print(f"Delay styles: {info.options}")
|
||||
|
||||
# Cycle through delay styles using normalized values
|
||||
num_styles = len(info.options)
|
||||
for i in range(num_styles):
|
||||
normalized = i / (num_styles - 1) if num_styles > 1 else 0
|
||||
controls["delay_style"].set_normalized(normalized)
|
||||
style = controls["delay_style"].get_text()
|
||||
print(f"Normalized {normalized:.2f} → {style}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup />
|
||||
</Project>
|
||||
2
setup.py
2
setup.py
|
|
@ -5,7 +5,7 @@
|
|||
# Then in the `dist` directory, `pip install vita`
|
||||
|
||||
import setuptools
|
||||
from setuptools import setup, Extension
|
||||
from setuptools import setup
|
||||
from setuptools.dist import Distribution
|
||||
import os
|
||||
import os.path
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
#include "synth_base.h"
|
||||
|
||||
#include <nanobind/nanobind.h>
|
||||
#include "sample_source.h"
|
||||
#include "sound_engine.h"
|
||||
#include "load_save.h"
|
||||
|
|
@ -432,11 +433,12 @@ bool SynthBase::loadFromString(std::string json_text) {
|
|||
|
||||
//setPresetName(preset.getFileNameWithoutExtension()); // todo:
|
||||
|
||||
SynthGuiInterface* gui_interface = getGuiInterface();
|
||||
if (gui_interface) {
|
||||
gui_interface->updateFullGui();
|
||||
gui_interface->notifyFresh();
|
||||
}
|
||||
// note: dbraun commented this out since we're running headless anyway.
|
||||
//SynthGuiInterface* gui_interface = getGuiInterface();
|
||||
//if (gui_interface) {
|
||||
// gui_interface->updateFullGui();
|
||||
// gui_interface->notifyFresh();
|
||||
//}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -446,8 +448,13 @@ void SynthBase::pySetBPM(float bpm) {
|
|||
engine_->setBpm(bpm);
|
||||
};
|
||||
|
||||
// src/plugin/synth_plugin.cpp Line 129-133 prepareToPlay
|
||||
void SynthBase::setSampleRate(double sample_rate) {
|
||||
engine_->setSampleRate(sample_rate);
|
||||
midi_manager_->setSampleRate(sample_rate);
|
||||
}
|
||||
|
||||
void SynthBase::renderAudioToFile(File file, std::vector<int> notes, float velocity, float note_dur, float render_dur, bool render_images) {
|
||||
static constexpr int kSampleRate = 44100;
|
||||
static constexpr int kPreProcessSamples = 44100;
|
||||
static constexpr int kFadeSamples = 200;
|
||||
static constexpr int kBufferSize = 64;
|
||||
|
|
@ -462,11 +469,11 @@ void SynthBase::renderAudioToFile(File file, std::vector<int> notes, float veloc
|
|||
engine_->allSoundsOff(); // note: dbraun added this
|
||||
|
||||
processModulationChanges();
|
||||
engine_->setSampleRate(kSampleRate);
|
||||
// engine_->setBpm(bpm);
|
||||
engine_->updateAllModulationSwitches();
|
||||
int kSampleRate = getSampleRate();
|
||||
|
||||
double sample_time = 1.0 / getSampleRate();
|
||||
double sample_time = 1.0 / kSampleRate;
|
||||
double current_time = -kPreProcessSamples * sample_time;
|
||||
|
||||
for (int samples = 0; samples < kPreProcessSamples; samples += kBufferSize) {
|
||||
|
|
@ -573,21 +580,23 @@ void SynthBase::renderAudioToFile(File file, std::vector<int> notes, float veloc
|
|||
}
|
||||
|
||||
nb::ndarray<float, nb::shape<2, -1>, nb::numpy> SynthBase::renderAudioToNumpy(const int& midi_note, float velocity, float note_dur, float render_dur) {
|
||||
static constexpr int kSampleRate = 44100;
|
||||
static constexpr int kFadeSamples = 200;
|
||||
static constexpr int kBufferSize = 64;
|
||||
static constexpr int kPreProcessSamples = 256; // note: dbraun decreased this from 44100.
|
||||
|
||||
// Release GIL for the performance-critical section
|
||||
// nb::gil_scoped_release gil_release; // TODO:
|
||||
|
||||
ScopedLock lock(getCriticalSection());
|
||||
|
||||
engine_->allSoundsOff(); // note: dbraun added this
|
||||
|
||||
processModulationChanges();
|
||||
engine_->setSampleRate(kSampleRate);
|
||||
engine_->updateAllModulationSwitches();
|
||||
int kSampleRate = getSampleRate();
|
||||
|
||||
// Preprocess modulation
|
||||
double sample_time = 1.0 / getSampleRate();
|
||||
double sample_time = 1.0 / kSampleRate;
|
||||
double current_time = -kPreProcessSamples * sample_time;
|
||||
|
||||
for (int samples = 0; samples < kPreProcessSamples; samples += kBufferSize) {
|
||||
|
|
@ -607,8 +616,6 @@ nb::ndarray<float, nb::shape<2, -1>, nb::numpy> SynthBase::renderAudioToNumpy(co
|
|||
static_cast<size_t>(total_samples * 2); // stereo: 2 channels
|
||||
|
||||
auto* data = new float[total_frames](); // Zero-initialized
|
||||
auto capsule = nb::capsule(
|
||||
data, [](void* p) noexcept { delete[] static_cast<float*>(p); });
|
||||
|
||||
int baseSample = 0;
|
||||
|
||||
|
|
@ -634,9 +641,15 @@ nb::ndarray<float, nb::shape<2, -1>, nb::numpy> SynthBase::renderAudioToNumpy(co
|
|||
}
|
||||
}
|
||||
|
||||
// Re-acquire GIL before creating numpy array
|
||||
// nb::gil_scoped_acquire gil_acquire; // TODO:
|
||||
|
||||
// Create capsule with the data
|
||||
nb::capsule owner(data, [](void* p) noexcept { delete[] (float*)p; });
|
||||
|
||||
// Return the data as a NumPy array
|
||||
return nb::ndarray<float, nb::shape<2, -1>, nb::numpy>(
|
||||
data, {2, static_cast<size_t>(total_samples)}, capsule);
|
||||
data, {2, static_cast<size_t>(total_samples)}, owner);
|
||||
}
|
||||
|
||||
bool SynthBase::renderAudioToFile2(const std::string& output_path, const int& midi_note, float velocity, float note_dur, float render_dur) {
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ class SynthBase : public MidiManager::Listener {
|
|||
Tuning* getTuning() { return &tuning_; }
|
||||
|
||||
void pySetBPM(float bpm);
|
||||
void setSampleRate(double sample_rate);
|
||||
|
||||
struct ValueChangedCallback : public CallbackMessage {
|
||||
ValueChangedCallback(std::shared_ptr<SynthBase*> listener, std::string name, vital::mono_float val) :
|
||||
|
|
|
|||
|
|
@ -18,6 +18,10 @@
|
|||
#include "value.h"
|
||||
#include "voice_handler.h"
|
||||
#include "wave_frame.h"
|
||||
#include <stdexcept>
|
||||
#include "synth_parameters.h"
|
||||
#include <cmath>
|
||||
#include <string>
|
||||
|
||||
namespace nb = nanobind;
|
||||
using namespace vital;
|
||||
|
|
@ -100,6 +104,165 @@ nb::list get_modulation_sources() {
|
|||
return result;
|
||||
}
|
||||
|
||||
// Get formatted display text for a control (with scaling & units).
|
||||
static std::string get_control_text(HeadlessSynth &synth, const std::string &name) {
|
||||
auto &controls = synth.getControls();
|
||||
auto it = controls.find(name);
|
||||
if (it == controls.end())
|
||||
throw std::runtime_error("No control: " + name);
|
||||
mono_float raw = it->second->value();
|
||||
const auto &details = Parameters::getDetails(name);
|
||||
// Discrete/indexed parameters
|
||||
if (details.string_lookup) {
|
||||
int count = static_cast<int>(details.max - details.min + 1);
|
||||
int idx = static_cast<int>(std::lround(raw - details.min));
|
||||
if (idx < 0) idx = 0;
|
||||
else if (idx >= count) idx = count - 1;
|
||||
return details.string_lookup[idx];
|
||||
}
|
||||
// Continuous parameters: apply scaling
|
||||
float skewed;
|
||||
switch (details.value_scale) {
|
||||
case ValueDetails::kQuadratic: skewed = raw * raw; break;
|
||||
case ValueDetails::kCubic: skewed = raw * raw * raw; break;
|
||||
case ValueDetails::kQuartic: skewed = raw * raw; skewed = skewed * skewed; break;
|
||||
case ValueDetails::kExponential:
|
||||
if (details.display_invert)
|
||||
skewed = 1.0f / std::pow(2.0f, raw);
|
||||
else
|
||||
skewed = std::pow(2.0f, raw);
|
||||
break;
|
||||
case ValueDetails::kSquareRoot: skewed = std::sqrt(raw); break;
|
||||
default: skewed = raw; break;
|
||||
}
|
||||
float display_val = details.display_multiply * skewed + details.post_offset;
|
||||
return std::to_string(display_val) + details.display_units;
|
||||
}
|
||||
|
||||
// Wrapper class for Value that knows its parameter name
|
||||
class ControlValue {
|
||||
private:
|
||||
vital::Value* value_;
|
||||
std::string name_;
|
||||
HeadlessSynth* synth_;
|
||||
|
||||
public:
|
||||
ControlValue(vital::Value* value, const std::string& name, HeadlessSynth* synth)
|
||||
: value_(value), name_(name), synth_(synth) {}
|
||||
|
||||
// Delegate existing Value methods
|
||||
float value() const { return value_->value(); }
|
||||
void set(double v) { value_->set(poly_float(static_cast<float>(v))); }
|
||||
void set(int v) { value_->set(poly_float(static_cast<float>(v))); }
|
||||
|
||||
// Normalized control methods
|
||||
void set_normalized(double normalized) {
|
||||
// Clamp to 0-1
|
||||
normalized = std::max(0.0, std::min(1.0, normalized));
|
||||
|
||||
const auto &details = Parameters::getDetails(name_);
|
||||
float value;
|
||||
|
||||
if (details.value_scale == ValueDetails::kIndexed) {
|
||||
// For indexed parameters, quantize to discrete values
|
||||
int num_options = static_cast<int>(details.max - details.min + 1);
|
||||
int index = static_cast<int>(std::round(normalized * (num_options - 1)));
|
||||
value = details.min + index;
|
||||
} else {
|
||||
// For non-linear parameters, normalized represents the display position
|
||||
// We need to convert: normalized -> display value -> internal value
|
||||
float value_normalized = static_cast<float>(normalized);
|
||||
|
||||
switch (details.value_scale) {
|
||||
case ValueDetails::kQuadratic:
|
||||
// Display = internal^2, so internal = sqrt(display)
|
||||
// normalized maps to display range
|
||||
value = details.min + std::sqrt(value_normalized) * (details.max - details.min);
|
||||
break;
|
||||
case ValueDetails::kCubic:
|
||||
// Display = internal^3, so internal = display^(1/3)
|
||||
value = details.min + std::pow(value_normalized, 1.0f/3.0f) * (details.max - details.min);
|
||||
break;
|
||||
case ValueDetails::kQuartic: {
|
||||
// VST behavior: knob position represents quartic of display position
|
||||
// When knob is at 50%, we want display = 4 * 0.5^4 = 0.25 seconds
|
||||
// Since display = internal^4 and max_display = max_internal^4
|
||||
// We want internal = (normalized^4 * max_display)^(1/4) = normalized * max_internal
|
||||
value = details.min + value_normalized * (details.max - details.min);
|
||||
break;
|
||||
}
|
||||
case ValueDetails::kExponential:
|
||||
// For exponential, normalized maps directly to the exponent
|
||||
if (details.display_invert)
|
||||
value = details.min + (1.0f / std::pow(2.0f, value_normalized)) * (details.max - details.min);
|
||||
else
|
||||
value = details.min + std::pow(2.0f, value_normalized) * (details.max - details.min);
|
||||
break;
|
||||
case ValueDetails::kSquareRoot:
|
||||
// Display = sqrt(internal), so internal = display^2
|
||||
value = details.min + (value_normalized * value_normalized) * (details.max - details.min);
|
||||
break;
|
||||
default: // Linear
|
||||
value = details.min + value_normalized * (details.max - details.min);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
value_->set(value);
|
||||
}
|
||||
|
||||
double get_normalized() const {
|
||||
const auto &details = Parameters::getDetails(name_);
|
||||
float raw = value_->value();
|
||||
|
||||
if (details.value_scale == ValueDetails::kIndexed) {
|
||||
// For indexed parameters, normalize based on index
|
||||
int num_options = static_cast<int>(details.max - details.min + 1);
|
||||
int index = static_cast<int>(std::round(raw - details.min));
|
||||
return static_cast<double>(index) / (num_options - 1);
|
||||
} else {
|
||||
// Normalize to 0-1 range within parameter bounds
|
||||
float normalized_internal = (raw - details.min) / (details.max - details.min);
|
||||
float normalized;
|
||||
|
||||
// Convert internal value to display position (0-1)
|
||||
switch (details.value_scale) {
|
||||
case ValueDetails::kQuadratic:
|
||||
// Display = internal^2
|
||||
normalized = normalized_internal * normalized_internal;
|
||||
break;
|
||||
case ValueDetails::kCubic:
|
||||
// Display = internal^3
|
||||
normalized = normalized_internal * normalized_internal * normalized_internal;
|
||||
break;
|
||||
case ValueDetails::kQuartic:
|
||||
// VST behavior: normalized position = internal position
|
||||
normalized = normalized_internal;
|
||||
break;
|
||||
case ValueDetails::kExponential:
|
||||
// For exponential, need to account for the range
|
||||
if (details.display_invert)
|
||||
normalized = -std::log2(normalized_internal + 1e-10f);
|
||||
else
|
||||
normalized = std::log2(normalized_internal + 1e-10f);
|
||||
break;
|
||||
case ValueDetails::kSquareRoot:
|
||||
// Display = sqrt(internal)
|
||||
normalized = std::sqrt(normalized_internal);
|
||||
break;
|
||||
default: // Linear
|
||||
normalized = normalized_internal;
|
||||
break;
|
||||
}
|
||||
|
||||
return std::max(0.0, std::min(1.0, static_cast<double>(normalized)));
|
||||
}
|
||||
}
|
||||
|
||||
std::string get_text() const {
|
||||
return get_control_text(*synth_, name_);
|
||||
}
|
||||
};
|
||||
|
||||
NB_MODULE(vita, m) {
|
||||
|
||||
|
|
@ -112,17 +275,14 @@ NB_MODULE(vita, m) {
|
|||
auto m_constants = m.def_submodule("constants", "Submodule containing constants and enums");
|
||||
|
||||
// Expose Enums
|
||||
nb::enum_<constants::SourceDestination>(m_constants, "SourceDestination")
|
||||
nb::enum_<constants::SourceDestination>(m_constants, "SourceDestination", nb::is_arithmetic())
|
||||
.value("Filter1", constants::SourceDestination::kFilter1)
|
||||
.value("Filter2", constants::SourceDestination::kFilter2)
|
||||
.value("DualFilters", constants::SourceDestination::kDualFilters)
|
||||
.value("Effects", constants::SourceDestination::kEffects)
|
||||
.value("DirectOut", constants::SourceDestination::kDirectOut)
|
||||
.def("__int__", [](constants::SourceDestination self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
.value("DirectOut", constants::SourceDestination::kDirectOut);
|
||||
|
||||
nb::enum_<constants::Effect>(m_constants, "Effect")
|
||||
nb::enum_<constants::Effect>(m_constants, "Effect", nb::is_arithmetic())
|
||||
.value("Chorus", constants::Effect::kChorus)
|
||||
.value("Compressor", constants::Effect::kCompressor)
|
||||
.value("Delay", constants::Effect::kDelay)
|
||||
|
|
@ -131,11 +291,9 @@ NB_MODULE(vita, m) {
|
|||
.value("FilterFx", constants::Effect::kFilterFx)
|
||||
.value("Flanger", constants::Effect::kFlanger)
|
||||
.value("Phaser", constants::Effect::kPhaser)
|
||||
.value("Reverb", constants::Effect::kReverb)
|
||||
.def("__int__",
|
||||
[](constants::Effect self) { return static_cast<int>(self); });
|
||||
.value("Reverb", constants::Effect::kReverb);
|
||||
|
||||
nb::enum_<constants::FilterModel>(m_constants, "FilterModel")
|
||||
nb::enum_<constants::FilterModel>(m_constants, "FilterModel", nb::is_arithmetic())
|
||||
.value("Analog", constants::FilterModel::kAnalog)
|
||||
.value("Dirty", constants::FilterModel::kDirty)
|
||||
.value("Ladder", constants::FilterModel::kLadder)
|
||||
|
|
@ -143,20 +301,51 @@ NB_MODULE(vita, m) {
|
|||
.value("Diode", constants::FilterModel::kDiode)
|
||||
.value("Formant", constants::FilterModel::kFormant)
|
||||
.value("Comb", constants::FilterModel::kComb)
|
||||
.value("Phase", constants::FilterModel::kPhase)
|
||||
.def("__int__", [](constants::FilterModel self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
.value("Phase", constants::FilterModel::kPhase);
|
||||
|
||||
nb::enum_<constants::RetriggerStyle>(m_constants, "RetriggerStyle")
|
||||
nb::enum_<constants::RetriggerStyle>(m_constants, "RetriggerStyle", nb::is_arithmetic())
|
||||
.value("Free", constants::RetriggerStyle::kFree)
|
||||
.value("Retrigger", constants::RetriggerStyle::kRetrigger)
|
||||
.value("SyncToPlayHead", constants::RetriggerStyle::kSyncToPlayHead)
|
||||
.def("__int__", [](constants::RetriggerStyle self) {
|
||||
return static_cast<int>(self);
|
||||
.value("SyncToPlayHead", constants::RetriggerStyle::kSyncToPlayHead);
|
||||
|
||||
// Parameter value scaling types
|
||||
nb::enum_<vital::ValueDetails::ValueScale>(m_constants, "ValueScale", nb::is_arithmetic())
|
||||
.value("Indexed", vital::ValueDetails::kIndexed)
|
||||
.value("Linear", vital::ValueDetails::kLinear)
|
||||
.value("Quadratic", vital::ValueDetails::kQuadratic)
|
||||
.value("Cubic", vital::ValueDetails::kCubic)
|
||||
.value("Quartic", vital::ValueDetails::kQuartic)
|
||||
.value("SquareRoot", vital::ValueDetails::kSquareRoot)
|
||||
.value("Exponential", vital::ValueDetails::kExponential);
|
||||
|
||||
// ControlInfo provides metadata for each parameter
|
||||
nb::class_<vital::ValueDetails>(m, "ControlInfo")
|
||||
.def(nb::init<>())
|
||||
.def_ro("name", &vital::ValueDetails::name)
|
||||
.def_ro("min", &vital::ValueDetails::min)
|
||||
.def_ro("max", &vital::ValueDetails::max)
|
||||
.def_ro("default_value", &vital::ValueDetails::default_value)
|
||||
.def_ro("version_added", &vital::ValueDetails::version_added)
|
||||
.def_ro("post_offset", &vital::ValueDetails::post_offset)
|
||||
.def_ro("display_multiply", &vital::ValueDetails::display_multiply)
|
||||
.def_ro("scale", &vital::ValueDetails::value_scale)
|
||||
.def_ro("display_units", &vital::ValueDetails::display_units)
|
||||
.def_ro("display_name", &vital::ValueDetails::display_name)
|
||||
.def_prop_ro("is_discrete",
|
||||
[](const vital::ValueDetails &d) {
|
||||
return d.value_scale == vital::ValueDetails::kIndexed;
|
||||
})
|
||||
.def_prop_ro("options", [](const vital::ValueDetails &d) {
|
||||
nb::list opts;
|
||||
if (d.value_scale == vital::ValueDetails::kIndexed && d.string_lookup) {
|
||||
int count = static_cast<int>(d.max - d.min + 1);
|
||||
for (int i = 0; i < count; ++i)
|
||||
opts.append(std::string(d.string_lookup[i]));
|
||||
}
|
||||
return opts;
|
||||
});
|
||||
|
||||
nb::enum_<SynthOscillator::SpectralMorph>(m_constants, "SpectralMorph")
|
||||
nb::enum_<SynthOscillator::SpectralMorph>(m_constants, "SpectralMorph", nb::is_arithmetic())
|
||||
.value("NoSpectralMorph", SynthOscillator::SpectralMorph::kNoSpectralMorph)
|
||||
.value("Vocode", SynthOscillator::SpectralMorph::kVocode)
|
||||
.value("FormScale", SynthOscillator::SpectralMorph::kFormScale)
|
||||
|
|
@ -168,12 +357,9 @@ NB_MODULE(vita, m) {
|
|||
.value("HighPass", SynthOscillator::SpectralMorph::kHighPass)
|
||||
.value("PhaseDisperse", SynthOscillator::SpectralMorph::kPhaseDisperse)
|
||||
.value("ShepardTone", SynthOscillator::SpectralMorph::kShepardTone)
|
||||
.value("Skew", SynthOscillator::SpectralMorph::kSkew)
|
||||
.def("__int__", [](SynthOscillator::SpectralMorph self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
.value("Skew", SynthOscillator::SpectralMorph::kSkew);
|
||||
|
||||
nb::enum_<SynthOscillator::DistortionType>(m_constants, "DistortionType")
|
||||
nb::enum_<SynthOscillator::DistortionType>(m_constants, "DistortionType", nb::is_arithmetic())
|
||||
.value("None", SynthOscillator::DistortionType::kNone)
|
||||
.value("Sync", SynthOscillator::DistortionType::kSync)
|
||||
.value("Formant", SynthOscillator::DistortionType::kFormant)
|
||||
|
|
@ -186,12 +372,9 @@ NB_MODULE(vita, m) {
|
|||
.value("FmSample", SynthOscillator::DistortionType::kFmSample)
|
||||
.value("RmOscillatorA", SynthOscillator::DistortionType::kRmOscillatorA)
|
||||
.value("RmOscillatorB", SynthOscillator::DistortionType::kRmOscillatorB)
|
||||
.value("RmSample", SynthOscillator::DistortionType::kRmSample)
|
||||
.def("__int__", [](SynthOscillator::DistortionType self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
.value("RmSample", SynthOscillator::DistortionType::kRmSample);
|
||||
|
||||
nb::enum_<SynthOscillator::UnisonStackType>(m_constants, "UnisonStackType")
|
||||
nb::enum_<SynthOscillator::UnisonStackType>(m_constants, "UnisonStackType", nb::is_arithmetic())
|
||||
.value("Normal", SynthOscillator::UnisonStackType::kNormal)
|
||||
.value("CenterDropOctave", SynthOscillator::UnisonStackType::kCenterDropOctave)
|
||||
.value("CenterDropOctave2", SynthOscillator::UnisonStackType::kCenterDropOctave2)
|
||||
|
|
@ -203,75 +386,54 @@ NB_MODULE(vita, m) {
|
|||
.value("MinorChord", SynthOscillator::UnisonStackType::kMinorChord)
|
||||
.value("HarmonicSeries", SynthOscillator::UnisonStackType::kHarmonicSeries)
|
||||
.value("OddHarmonicSeries",
|
||||
SynthOscillator::UnisonStackType::kOddHarmonicSeries)
|
||||
.def("__int__", [](SynthOscillator::UnisonStackType self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
SynthOscillator::UnisonStackType::kOddHarmonicSeries);
|
||||
|
||||
nb::enum_<RandomLfo::RandomType>(m_constants, "RandomLFOStyle")
|
||||
nb::enum_<RandomLfo::RandomType>(m_constants, "RandomLFOStyle", nb::is_arithmetic())
|
||||
.value("Perlin", RandomLfo::RandomType::kPerlin)
|
||||
.value("SampleAndHold", RandomLfo::RandomType::kSampleAndHold)
|
||||
.value("SinInterpolate", RandomLfo::RandomType::kSinInterpolate)
|
||||
.value("LorenzAttractor", RandomLfo::RandomType::kLorenzAttractor)
|
||||
.def("__int__",
|
||||
[](RandomLfo::RandomType self) { return static_cast<int>(self); });
|
||||
.value("LorenzAttractor", RandomLfo::RandomType::kLorenzAttractor);
|
||||
|
||||
nb::enum_<VoiceHandler::VoicePriority>(m_constants, "VoicePriority")
|
||||
nb::enum_<VoiceHandler::VoicePriority>(m_constants, "VoicePriority", nb::is_arithmetic())
|
||||
.value("Newest", VoiceHandler::VoicePriority::kNewest)
|
||||
.value("Oldest", VoiceHandler::VoicePriority::kOldest)
|
||||
.value("Highest", VoiceHandler::VoicePriority::kHighest)
|
||||
.value("Lowest", VoiceHandler::VoicePriority::kLowest)
|
||||
.value("RoundRobin", VoiceHandler::VoicePriority::kRoundRobin)
|
||||
.def("__int__", [](VoiceHandler::VoicePriority self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
.value("RoundRobin", VoiceHandler::VoicePriority::kRoundRobin);
|
||||
|
||||
nb::enum_<VoiceHandler::VoiceOverride>(m_constants, "VoiceOverride")
|
||||
nb::enum_<VoiceHandler::VoiceOverride>(m_constants, "VoiceOverride", nb::is_arithmetic())
|
||||
.value("Kill",VoiceHandler::VoiceOverride::kKill)
|
||||
.value("Steal", VoiceHandler::VoiceOverride::kSteal)
|
||||
.def("__int__", [](VoiceHandler::VoiceOverride self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
.value("Steal", VoiceHandler::VoiceOverride::kSteal);
|
||||
|
||||
nb::enum_<PredefinedWaveFrames::Shape>(m_constants, "WaveShape")
|
||||
nb::enum_<PredefinedWaveFrames::Shape>(m_constants, "WaveShape", nb::is_arithmetic())
|
||||
.value("Sin", PredefinedWaveFrames::kSin)
|
||||
.value("SaturatedSin", PredefinedWaveFrames::kSaturatedSin)
|
||||
.value("Triangle", PredefinedWaveFrames::kTriangle)
|
||||
.value("Square", PredefinedWaveFrames::kSquare)
|
||||
.value("Pulse", PredefinedWaveFrames::kPulse)
|
||||
.value("Saw", PredefinedWaveFrames::kSaw)
|
||||
.def("__int__", [](PredefinedWaveFrames::Shape self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
.value("Saw", PredefinedWaveFrames::kSaw);
|
||||
|
||||
nb::enum_<SynthLfo::SyncType>(m_constants, "SynthLFOSyncType")
|
||||
nb::enum_<SynthLfo::SyncType>(m_constants, "SynthLFOSyncType", nb::is_arithmetic())
|
||||
.value("Trigger", SynthLfo::SyncType::kTrigger)
|
||||
.value("Sync", SynthLfo::SyncType::kSync)
|
||||
.value("Envelope", SynthLfo::SyncType::kEnvelope)
|
||||
.value("SustainEnvelope", SynthLfo::SyncType::kSustainEnvelope)
|
||||
.value("LoopPoint", SynthLfo::SyncType::kLoopPoint)
|
||||
.value("LoopHold", SynthLfo::SyncType::kLoopHold)
|
||||
.def("__int__",
|
||||
[](SynthLfo::SyncType self) { return static_cast<int>(self); });
|
||||
.value("LoopHold", SynthLfo::SyncType::kLoopHold);
|
||||
|
||||
nb::enum_<MultibandCompressor::BandOptions>(m_constants, "CompressorBandOption")
|
||||
nb::enum_<MultibandCompressor::BandOptions>(m_constants, "CompressorBandOption", nb::is_arithmetic())
|
||||
.value("Multiband", MultibandCompressor::BandOptions::kMultiband)
|
||||
.value("LowBand", MultibandCompressor::BandOptions::kLowBand)
|
||||
.value("HighBand", MultibandCompressor::BandOptions::kHighBand)
|
||||
.value("SingleBand", MultibandCompressor::BandOptions::kSingleBand)
|
||||
.def("__int__", [](MultibandCompressor::BandOptions self) {
|
||||
return static_cast<int>(self);
|
||||
});
|
||||
.value("SingleBand", MultibandCompressor::BandOptions::kSingleBand);
|
||||
|
||||
nb::enum_<SynthFilter::Style>(m_constants, "SynthFilterStyle")
|
||||
nb::enum_<SynthFilter::Style>(m_constants, "SynthFilterStyle", nb::is_arithmetic())
|
||||
.value("k12Db", SynthFilter::Style::k12Db)
|
||||
.value("k24Db", SynthFilter::Style::k24Db)
|
||||
.value("NotchPassSwap", SynthFilter::Style::kNotchPassSwap)
|
||||
.value("DualNotchBand", SynthFilter::Style::kDualNotchBand)
|
||||
.value("BandPeakNotch", SynthFilter::Style::kBandPeakNotch)
|
||||
.value("Shelving", SynthFilter::Style::kShelving)
|
||||
.def("__int__",
|
||||
[](SynthFilter::Style self) { return static_cast<int>(self); });
|
||||
.value("Shelving", SynthFilter::Style::kShelving);
|
||||
|
||||
// https://github.com/mtytel/vital/blob/636ca0ef517a4db087a6a08a6a8a5e704e21f836/src/interface/look_and_feel/synth_strings.h#L174
|
||||
enum SyncedFrequencyName {
|
||||
|
|
@ -289,7 +451,7 @@ NB_MODULE(vita, m) {
|
|||
k1_64
|
||||
};
|
||||
|
||||
nb::enum_<SyncedFrequencyName>(m_constants, "SyncedFrequency")
|
||||
nb::enum_<SyncedFrequencyName>(m_constants, "SyncedFrequency", nb::is_arithmetic())
|
||||
.value("k32_1", SyncedFrequencyName::k32_1)
|
||||
.value("k16_1", SyncedFrequencyName::k16_1)
|
||||
.value("k8_1", SyncedFrequencyName::k8_1)
|
||||
|
|
@ -301,9 +463,7 @@ NB_MODULE(vita, m) {
|
|||
.value("k1_8", SyncedFrequencyName::k1_8)
|
||||
.value("k1_16", SyncedFrequencyName::k1_16)
|
||||
.value("k1_32", SyncedFrequencyName::k1_32)
|
||||
.value("k1_64", SyncedFrequencyName::k1_64)
|
||||
.def("__int__",
|
||||
[](SyncedFrequencyName self) { return static_cast<int>(self); });
|
||||
.value("k1_64", SyncedFrequencyName::k1_64);
|
||||
|
||||
// https://github.com/mtytel/vital/blob/636ca0ef517a4db087a6a08a6a8a5e704e21f836/src/synthesis/modulators/synth_lfo.h#L58C1-L65C9
|
||||
enum SyncOption {
|
||||
|
|
@ -314,13 +474,12 @@ NB_MODULE(vita, m) {
|
|||
kKeytrack,
|
||||
};
|
||||
|
||||
nb::enum_<SyncOption>(m_constants, "SynthLFOSyncOption")
|
||||
nb::enum_<SyncOption>(m_constants, "SynthLFOSyncOption", nb::is_arithmetic())
|
||||
.value("Time", SyncOption::kTime)
|
||||
.value("Tempo", SyncOption::kTempo)
|
||||
.value("DottedTempo", SyncOption::kDottedTempo)
|
||||
.value("TripletTempo", SyncOption::kTripletTempo)
|
||||
.value("Keytrack", SyncOption::kKeytrack)
|
||||
.def("__int__", [](SyncOption self) { return static_cast<int>(self); });
|
||||
.value("Keytrack", SyncOption::kKeytrack);
|
||||
|
||||
// Binding for poly_float
|
||||
nb::class_<vital::poly_float>(m, "poly_float")
|
||||
|
|
@ -374,11 +533,32 @@ NB_MODULE(vita, m) {
|
|||
return "<CRValue value=" + std::to_string(v.value()) + ">";
|
||||
});
|
||||
|
||||
// Bind the ControlValue wrapper class
|
||||
nb::class_<ControlValue>(m, "ControlValue")
|
||||
.def("value", &ControlValue::value)
|
||||
.def("set", nb::overload_cast<double>(&ControlValue::set), nb::arg("value"))
|
||||
.def("set", nb::overload_cast<int>(&ControlValue::set), nb::arg("value"))
|
||||
.def("set_normalized", &ControlValue::set_normalized, nb::arg("value"),
|
||||
"Set control value using normalized 0-1 range")
|
||||
.def("get_normalized", &ControlValue::get_normalized,
|
||||
"Get control value as normalized 0-1 range")
|
||||
.def("get_text", &ControlValue::get_text,
|
||||
"Get formatted display text for the control");
|
||||
|
||||
// Expose the SynthBase class, specifying ProcessorRouter as its base
|
||||
nb::class_<HeadlessSynth>(m, "Synth")
|
||||
.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<HeadlessSynth &>(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<const std::string &, const std::string &>(
|
||||
&HeadlessSynth::pyConnectModulation),
|
||||
|
|
@ -392,6 +572,7 @@ NB_MODULE(vita, m) {
|
|||
"Disconnects a modulation source from a destination by name.")
|
||||
|
||||
.def("set_bpm", &HeadlessSynth::pySetBPM, nb::arg("bpm"))
|
||||
.def("set_sample_rate", &HeadlessSynth::setSampleRate, nb::arg("sample_rate"))
|
||||
|
||||
.def("render_file", &HeadlessSynth::renderAudioToFile2,
|
||||
nb::arg("output_path"), nb::arg("midi_note"),
|
||||
|
|
@ -430,5 +611,21 @@ NB_MODULE(vita, m) {
|
|||
.def("load_init_preset", &HeadlessSynth::loadInitPreset, "Load the initial preset.")
|
||||
|
||||
.def("clear_modulations", &HeadlessSynth::clearModulations)
|
||||
.def("get_controls", &HeadlessSynth::getControls, nb::rv_policy::reference);
|
||||
.def("get_controls", [](HeadlessSynth &synth) {
|
||||
nb::dict result;
|
||||
auto &controls = synth.getControls();
|
||||
for (const auto &[name, value] : controls) {
|
||||
result[name.c_str()] = ControlValue(value, name, &synth);
|
||||
}
|
||||
return result;
|
||||
}, nb::rv_policy::reference_internal)
|
||||
.def("get_control_details", [](HeadlessSynth &synth, const std::string &name) {
|
||||
// Validate control name
|
||||
if (!vital::Parameters::isParameter(name))
|
||||
throw std::runtime_error("No metadata for control: " + name);
|
||||
// Return parameter metadata
|
||||
return vital::Parameters::getDetails(name);
|
||||
}, nb::arg("name"), "Get metadata for a control")
|
||||
.def("get_control_text", get_control_text)
|
||||
;
|
||||
}
|
||||
|
|
|
|||
144
tests/test_normalized_params.py
Normal file
144
tests/test_normalized_params.py
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import pytest
|
||||
import vita
|
||||
from vita.constants import ValueScale
|
||||
|
||||
|
||||
def test_normalized_linear_parameter():
|
||||
"""Test normalized control on a linear parameter"""
|
||||
synth = vita.Synth()
|
||||
|
||||
# Get a linear parameter (most parameters are linear by default)
|
||||
info = synth.get_control_details("filter_1_cutoff")
|
||||
if info.scale != ValueScale.Linear:
|
||||
pytest.skip("filter_1_cutoff is not linear in this version")
|
||||
|
||||
# Test boundary values
|
||||
controls = synth.get_controls()
|
||||
controls["filter_1_cutoff"].set_normalized(0.0)
|
||||
assert abs(controls["filter_1_cutoff"].get_normalized() - 0.0) < 0.001
|
||||
assert abs(controls["filter_1_cutoff"].value() - info.min) < 0.001
|
||||
|
||||
controls["filter_1_cutoff"].set_normalized(1.0)
|
||||
assert abs(controls["filter_1_cutoff"].get_normalized() - 1.0) < 0.001
|
||||
assert abs(controls["filter_1_cutoff"].value() - info.max) < 0.001
|
||||
|
||||
# Test middle value
|
||||
controls["filter_1_cutoff"].set_normalized(0.5)
|
||||
assert abs(controls["filter_1_cutoff"].get_normalized() - 0.5) < 0.001
|
||||
expected_mid = info.min + 0.5 * (info.max - info.min)
|
||||
assert abs(controls["filter_1_cutoff"].value() - expected_mid) < 0.001
|
||||
|
||||
|
||||
def test_normalized_quartic_parameter():
|
||||
"""Test normalized control on a quartic parameter like env_1_delay"""
|
||||
synth = vita.Synth()
|
||||
|
||||
# Verify env_1_delay uses quartic scaling
|
||||
info = synth.get_control_details("env_1_delay")
|
||||
assert info.scale == ValueScale.Quartic
|
||||
|
||||
# Test boundary values
|
||||
controls = synth.get_controls()
|
||||
controls["env_1_delay"].set_normalized(0.0)
|
||||
assert abs(controls["env_1_delay"].get_normalized() - 0.0) < 0.001
|
||||
|
||||
controls["env_1_delay"].set_normalized(1.0)
|
||||
assert abs(controls["env_1_delay"].get_normalized() - 1.0) < 0.001
|
||||
|
||||
# Test that normalized 0.5 maps to 0.25 seconds display (0.5^4 * 4.0)
|
||||
# For quartic parameters in VST, normalized maps linearly to internal range
|
||||
# but the knob DISPLAY position has a quartic relationship
|
||||
controls["env_1_delay"].set_normalized(0.5)
|
||||
expected_value = info.min + 0.5 * (info.max - info.min) # Linear mapping
|
||||
assert abs(controls["env_1_delay"].value() - expected_value) < 0.001
|
||||
assert abs(controls["env_1_delay"].get_normalized() - 0.5) < 0.001
|
||||
|
||||
actual_value = controls["env_1_delay"].value()
|
||||
linear_mid = info.min + 0.5 * (info.max - info.min)
|
||||
|
||||
# With this implementation, normalized maps linearly to internal range
|
||||
assert abs(actual_value - linear_mid) < 0.001
|
||||
|
||||
|
||||
def test_normalized_indexed_parameter():
|
||||
"""Test normalized control on an indexed/discrete parameter"""
|
||||
synth = vita.Synth()
|
||||
|
||||
# delay_style is an indexed parameter
|
||||
info = synth.get_control_details("delay_style")
|
||||
assert info.scale == ValueScale.Indexed
|
||||
assert info.is_discrete
|
||||
|
||||
num_options = len(info.options)
|
||||
|
||||
# Test each discrete value
|
||||
controls = synth.get_controls()
|
||||
for i in range(num_options):
|
||||
normalized = i / (num_options - 1) if num_options > 1 else 0
|
||||
controls["delay_style"].set_normalized(normalized)
|
||||
|
||||
# Check it maps to the correct index
|
||||
actual_index = int(controls["delay_style"].value() - info.min)
|
||||
assert actual_index == i
|
||||
|
||||
# Check text matches
|
||||
assert controls["delay_style"].get_text() == info.options[i]
|
||||
|
||||
|
||||
def test_normalized_vst_style_automation():
|
||||
"""Demonstrate VST-style parameter automation using normalized values"""
|
||||
synth = vita.Synth()
|
||||
|
||||
# Simulate a VST automation curve for filter cutoff
|
||||
# This would be how a DAW sends parameter changes
|
||||
automation_points = [
|
||||
(0.0, 0.0), # Start closed
|
||||
(0.25, 0.5), # Open to middle
|
||||
(0.5, 1.0), # Full open
|
||||
(0.75, 0.3), # Partially close
|
||||
(1.0, 0.0), # Close again
|
||||
]
|
||||
|
||||
controls = synth.get_controls()
|
||||
for time, normalized_value in automation_points:
|
||||
controls["filter_1_cutoff"].set_normalized(normalized_value)
|
||||
|
||||
# Verify the normalized value is preserved
|
||||
retrieved = controls["filter_1_cutoff"].get_normalized()
|
||||
assert abs(retrieved - normalized_value) < 0.001
|
||||
|
||||
# In a real scenario, you would render audio here
|
||||
# audio = synth.render(60, 0.8, 0.1, 0.1)
|
||||
|
||||
|
||||
def test_normalized_clipping():
|
||||
"""Test that normalized values are properly clipped to 0-1 range"""
|
||||
synth = vita.Synth()
|
||||
|
||||
# Test values outside 0-1 range
|
||||
controls = synth.get_controls()
|
||||
controls["filter_1_cutoff"].set_normalized(-0.5)
|
||||
assert controls["filter_1_cutoff"].get_normalized() == 0.0
|
||||
|
||||
controls["filter_1_cutoff"].set_normalized(1.5)
|
||||
assert controls["filter_1_cutoff"].get_normalized() == 1.0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Run basic tests
|
||||
test_normalized_linear_parameter()
|
||||
print("✓ Linear parameter test passed")
|
||||
|
||||
test_normalized_quartic_parameter()
|
||||
print("✓ Quartic parameter test passed")
|
||||
|
||||
test_normalized_indexed_parameter()
|
||||
print("✓ Indexed parameter test passed")
|
||||
|
||||
test_normalized_vst_style_automation()
|
||||
print("✓ VST-style automation test passed")
|
||||
|
||||
test_normalized_clipping()
|
||||
print("✓ Clipping test passed")
|
||||
|
||||
print("\nAll normalized parameter tests passed!")
|
||||
|
|
@ -20,15 +20,14 @@ from vita.constants import (
|
|||
SyncedFrequency,
|
||||
)
|
||||
|
||||
SAMPLE_RATE = 44_100
|
||||
|
||||
|
||||
def test_render(bpm=120.0, note_dur=1.0, render_dur=3.0, pitch=36, velocity=0.7):
|
||||
def test_render(bpm=120.0, sample_rate=48000, note_dur=1.0, render_dur=3.0, pitch=36, velocity=0.7):
|
||||
|
||||
synth = vita.Synth()
|
||||
# The initial preset is laoded by default.
|
||||
|
||||
synth.set_bpm(bpm)
|
||||
synth.set_sample_rate(sample_rate)
|
||||
|
||||
assert vita.get_modulation_sources()
|
||||
assert vita.get_modulation_destinations()
|
||||
|
|
@ -38,12 +37,14 @@ def test_render(bpm=120.0, note_dur=1.0, render_dur=3.0, pitch=36, velocity=0.7)
|
|||
controls = synth.get_controls()
|
||||
controls["modulation_1_amount"].set(1.0)
|
||||
controls["filter_1_on"].set(1.0)
|
||||
assert 1.0 == controls["filter_1_on"].value()
|
||||
controls["lfo_1_tempo"].set(SyncedFrequency.k1_16)
|
||||
|
||||
# Render audio to numpy array shaped (2, NUM_SAMPLES)
|
||||
audio = synth.render(pitch, velocity, note_dur, render_dur)
|
||||
assert sample_rate == int(audio.shape[1] / render_dur) # assume int
|
||||
|
||||
wavfile.write("generated_preset.wav", SAMPLE_RATE, audio.T)
|
||||
wavfile.write("generated_preset.wav", sample_rate, audio.T)
|
||||
|
||||
# Dump current state to json text
|
||||
json_text = synth.to_json()
|
||||
|
|
@ -53,8 +54,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)
|
||||
|
|
@ -63,3 +66,42 @@ def test_render(bpm=120.0, note_dur=1.0, render_dur=3.0, pitch=36, velocity=0.7)
|
|||
synth.load_init_preset()
|
||||
# Or just clear modulations.
|
||||
synth.clear_modulations()
|
||||
|
||||
info = synth.get_control_details("delay_style")
|
||||
print("min:", info.min)
|
||||
print("max:", info.max)
|
||||
print("default_value:", info.default_value)
|
||||
print("scale:", info.scale) # e.g. ValueScale.Indexed
|
||||
print("discrete:", info.is_discrete) # True
|
||||
print("options: ", info.options) # ["Mono","Stereo","Ping Pong","Mid Ping Pong"]
|
||||
print("display_name:", info.display_name)
|
||||
print("display_units:", info.display_units)
|
||||
|
||||
controls["delay_style"].set(0)
|
||||
text = synth.get_control_text("delay_style")
|
||||
assert text == info.options[0]
|
||||
print("text: ", text)
|
||||
controls["delay_style"].set(1)
|
||||
text = synth.get_control_text("delay_style")
|
||||
assert text == info.options[1]
|
||||
print("text: ", text)
|
||||
|
||||
assert info.is_discrete
|
||||
for i in range(int(info.min), int(info.max)+1):
|
||||
controls["delay_style"].set(i)
|
||||
|
||||
info = synth.get_control_details("env_1_delay")
|
||||
print("min:", info.min)
|
||||
print("max:", info.max)
|
||||
print("default_value:", info.default_value)
|
||||
print("scale:", info.scale) # e.g. ValueScale.Quartic
|
||||
print("discrete:", info.is_discrete) # False
|
||||
print("options: ", info.options) # [""]
|
||||
|
||||
assert not info.is_discrete
|
||||
steps = 100
|
||||
for i in range(steps):
|
||||
pct = i/(steps-1)
|
||||
# linearly interpolate
|
||||
y = info.min + pct * (info.max-info.min)
|
||||
controls["env_1_delay"].set(y)
|
||||
|
|
|
|||
2
third_party/nanobind
vendored
2
third_party/nanobind
vendored
|
|
@ -1 +1 @@
|
|||
Subproject commit efbeb8afd95b210d9e2e06f719ddc1336a12b9d7
|
||||
Subproject commit 109af19428a8784aa02fed55a5b3e21617de93ef
|
||||
|
|
@ -1,2 +1,9 @@
|
|||
from .vita import *
|
||||
from .version import *
|
||||
from .vita import Synth, constants, get_modulation_sources, get_modulation_destinations
|
||||
from .version import __version__
|
||||
|
||||
__ALL__ = [
|
||||
"Synth",
|
||||
"constants",
|
||||
"get_modulation_sources",
|
||||
"get_modulation_destinations",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1 +1,39 @@
|
|||
from ..vita.constants import *
|
||||
from ..vita.constants import (
|
||||
SourceDestination,
|
||||
Effect,
|
||||
FilterModel,
|
||||
RetriggerStyle,
|
||||
SpectralMorph,
|
||||
DistortionType,
|
||||
UnisonStackType,
|
||||
RandomLFOStyle,
|
||||
SynthFilterStyle,
|
||||
WaveShape,
|
||||
CompressorBandOption,
|
||||
VoiceOverride,
|
||||
VoicePriority,
|
||||
SynthLFOSyncOption,
|
||||
SynthLFOSyncType,
|
||||
SyncedFrequency,
|
||||
ValueScale,
|
||||
)
|
||||
|
||||
__ALL__ = [
|
||||
"SourceDestination",
|
||||
"Effect",
|
||||
"FilterModel",
|
||||
"RetriggerStyle",
|
||||
"SpectralMorph",
|
||||
"DistortionType",
|
||||
"UnisonStackType",
|
||||
"RandomLFOStyle",
|
||||
"SynthFilterStyle",
|
||||
"WaveShape",
|
||||
"CompressorBandOption",
|
||||
"VoiceOverride",
|
||||
"VoicePriority",
|
||||
"SynthLFOSyncOption",
|
||||
"SynthLFOSyncType",
|
||||
"SyncedFrequency",
|
||||
"ValueScale",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
__version__ = "0.0.2"
|
||||
__version__ = "0.0.5"
|
||||
|
|
|
|||
Loading…
Reference in a new issue