Compare commits

...

5 commits
v0.0.3 ... main

Author SHA1 Message Date
Haokun Tian
6795bce6d4
make sample rate configurable (#6)
Some checks failed
Python package / build-macos (x86_64, cp312-macosx_x86_64, macos-13, 3.12) (push) Has been cancelled
Python package / build-macos (x86_64, cp313-macosx_x86_64, macos-13, 3.13) (push) Has been cancelled
Python package / build-macos (x86_64, cp39-macosx_x86_64, macos-13, 3.9) (push) Has been cancelled
Python package / build-windows (cp310*, windows-2022, 3.10) (push) Has been cancelled
Python package / build-windows (cp311*, windows-2022, 3.11) (push) Has been cancelled
Python package / build-windows (cp312*, windows-2022, 3.12) (push) Has been cancelled
Python package / build-windows (cp313*, windows-2022, 3.13) (push) Has been cancelled
Python package / build-windows (cp39*, windows-2022, 3.9) (push) Has been cancelled
Python package / build-ubuntu (cp310-manylinux_x86_64, 3.10, /opt/python/cp310-cp310/include/python3.10, /opt/python/cp310-cp310/lib) (release) Has been cancelled
Python package / build-ubuntu (cp311-manylinux_x86_64, 3.11, /opt/python/cp311-cp311/include/python3.11, /opt/python/cp311-cp311/lib) (release) Has been cancelled
Python package / build-ubuntu (cp312-manylinux_x86_64, 3.12, /opt/python/cp312-cp312/include/python3.12, /opt/python/cp312-cp312/lib) (release) Has been cancelled
Python package / build-ubuntu (cp313-manylinux_x86_64, 3.13, /opt/python/cp313-cp313/include/python3.13, /opt/python/cp313-cp313/lib) (release) Has been cancelled
Python package / build-ubuntu (cp39-manylinux_x86_64, 3.9, /opt/python/cp39-cp39/include/python3.9, /opt/python/cp39-cp39/lib) (release) Has been cancelled
Python package / build-macos (arm64, cp310-macosx_arm64, macos-14, 3.10) (release) Has been cancelled
Python package / build-macos (arm64, cp311-macosx_arm64, macos-14, 3.11) (release) Has been cancelled
Python package / build-macos (arm64, cp312-macosx_arm64, macos-14, 3.12) (release) Has been cancelled
Python package / build-macos (arm64, cp313-macosx_arm64, macos-14, 3.13) (release) Has been cancelled
Python package / build-macos (arm64, cp39-macosx_arm64, macos-14, 3.9) (release) Has been cancelled
Python package / build-macos (x86_64, cp310-macosx_x86_64, macos-13, 3.10) (release) Has been cancelled
Python package / build-macos (x86_64, cp311-macosx_x86_64, macos-13, 3.11) (release) Has been cancelled
Python package / build-macos (x86_64, cp312-macosx_x86_64, macos-13, 3.12) (release) Has been cancelled
Python package / build-macos (x86_64, cp313-macosx_x86_64, macos-13, 3.13) (release) Has been cancelled
Python package / build-macos (x86_64, cp39-macosx_x86_64, macos-13, 3.9) (release) Has been cancelled
Python package / build-windows (cp310*, windows-2022, 3.10) (release) Has been cancelled
Python package / build-windows (cp311*, windows-2022, 3.11) (release) Has been cancelled
Python package / build-windows (cp312*, windows-2022, 3.12) (release) Has been cancelled
Python package / build-windows (cp313*, windows-2022, 3.13) (release) Has been cancelled
Python package / build-windows (cp39*, windows-2022, 3.9) (release) Has been cancelled
Python package / Upload wheels to PyPI (push) Has been cancelled
Python package / Upload wheels to PyPI (release) Has been cancelled
2025-09-10 15:56:21 -04:00
David Braun
bc6763a4e0
Normalized parameters and parameter introspection (#3) 2025-06-03 11:08:12 -04:00
David Braun
b48be9ae87
Merge pull request #1 from DBraun/feature/pickle
add pickle support
2025-02-13 14:44:12 -05:00
David Braun
3d1faef6da add pickle support 2025-02-13 14:21:57 -05:00
David Braun
02223009ba update README 2025-01-09 10:28:11 -05:00
20 changed files with 873 additions and 109 deletions

View file

@ -3,6 +3,8 @@ name: Python package
on: on:
push: push:
branches: [ "main" ] branches: [ "main" ]
paths-ignore:
- "README.md"
pull_request: pull_request:
branches: [ "main" ] branches: [ "main" ]
release: release:

1
.gitignore vendored
View file

@ -39,3 +39,4 @@ dataset
*.egg-info* *.egg-info*
vita/LICENSE vita/LICENSE
dist dist
headless/builds/VisualStudio2022/x64

View file

@ -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 ifndef CONFIG
CONFIG=Release CONFIG=Release
@ -8,7 +10,7 @@ endif
ifndef LIBDIR ifndef LIBDIR
# LIBDIR=/usr/lib/ # 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 endif
BUILD_DATE="$(shell date +'%Y %m %d %H %M')" BUILD_DATE="$(shell date +'%Y %m %d %H %M')"

View file

@ -1,6 +1,6 @@
# Vita # 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 ## Installation
@ -16,8 +16,7 @@ pip install vita
from scipy.io import wavfile from scipy.io import wavfile
import vita import vita
SAMPLE_RATE = 44_100 sample_rate = 44100
bpm = 120.0 bpm = 120.0
note_dur = 1.0 note_dur = 1.0
render_dur = 3.0 render_dur = 3.0
@ -27,6 +26,7 @@ velocity = 0.7 # [0.0 to 1.0]
synth = vita.Synth() synth = vita.Synth()
# The initial preset is loaded by default. # The initial preset is loaded by default.
synth.set_sample_rate(sample_rate)
synth.set_bpm(bpm) synth.set_bpm(bpm)
# Let's make a custom modulation using # Let's make a custom modulation using
@ -35,30 +35,43 @@ synth.set_bpm(bpm)
print("potential sources:", vita.get_modulation_sources()) print("potential sources:", vita.get_modulation_sources())
print("potential destinations:", vita.get_modulation_destinations()) 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") assert synth.connect_modulation("lfo_1", "filter_1_cutoff")
controls = synth.get_controls() controls = synth.get_controls()
controls["modulation_1_amount"].set(1.0) controls["modulation_1_amount"].set(1.0)
controls["filter_1_on"].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) 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) # Render audio to numpy array shaped (2, NUM_SAMPLES)
audio = synth.render(pitch, velocity, note_dur, render_dur) 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 # Dump current state to JSON text
preset_path = "generated_preset.vital" preset_path = "generated_preset.vital"
json_text = synth.to_json() json_text = synth.to_json()
with open(preset_path, "w") as f: with open(preset_path, "w") as f:
f.write(json_text) f.write(json_text)
# Load JSON text # Load JSON text
with open(preset_path, "r") as f: with open(preset_path, "r") as f:
json_text = f.read() json_text = f.read()
assert synth.load_json(json_text)
assert synth.load_json(json_text)
# Or load directly from file # Or load directly from file
assert synth.load_preset(preset_path) assert synth.load_preset(preset_path)
@ -69,6 +82,8 @@ synth.load_init_preset()
synth.clear_modulations() 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 ### Issues
If you find any issues with the code, report them at https://github.com/DBraun/Vita. If you find any issues with the code, report them at https://github.com/DBraun/Vita.

View file

@ -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 "PYTHONLIBPATH: $PYTHONLIBPATH"
echo "PYTHONINCLUDEPATH: $PYTHONINCLUDEPATH" echo "PYTHONINCLUDEPATH: $PYTHONINCLUDEPATH"
echo "LIBDIR: $LIBDIR" echo "LIBDIR: $LIBDIR"
@ -16,3 +20,6 @@ cd ../..
make headless_server make headless_server
echo "build_linux.sh is done!" echo "build_linux.sh is done!"
# to build a wheel:
# python3 -m build --wheel

View file

@ -0,0 +1 @@
output

View 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
```

View 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,
)

View 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()

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<PropertyGroup />
</Project>

View file

@ -5,7 +5,7 @@
# Then in the `dist` directory, `pip install vita` # Then in the `dist` directory, `pip install vita`
import setuptools import setuptools
from setuptools import setup, Extension from setuptools import setup
from setuptools.dist import Distribution from setuptools.dist import Distribution
import os import os
import os.path import os.path

View file

@ -16,6 +16,7 @@
#include "synth_base.h" #include "synth_base.h"
#include <nanobind/nanobind.h>
#include "sample_source.h" #include "sample_source.h"
#include "sound_engine.h" #include "sound_engine.h"
#include "load_save.h" #include "load_save.h"
@ -432,11 +433,12 @@ bool SynthBase::loadFromString(std::string json_text) {
//setPresetName(preset.getFileNameWithoutExtension()); // todo: //setPresetName(preset.getFileNameWithoutExtension()); // todo:
SynthGuiInterface* gui_interface = getGuiInterface(); // note: dbraun commented this out since we're running headless anyway.
if (gui_interface) { //SynthGuiInterface* gui_interface = getGuiInterface();
gui_interface->updateFullGui(); //if (gui_interface) {
gui_interface->notifyFresh(); // gui_interface->updateFullGui();
} // gui_interface->notifyFresh();
//}
return true; return true;
} }
@ -446,8 +448,13 @@ void SynthBase::pySetBPM(float bpm) {
engine_->setBpm(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) { 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 kPreProcessSamples = 44100;
static constexpr int kFadeSamples = 200; static constexpr int kFadeSamples = 200;
static constexpr int kBufferSize = 64; 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 engine_->allSoundsOff(); // note: dbraun added this
processModulationChanges(); processModulationChanges();
engine_->setSampleRate(kSampleRate);
// engine_->setBpm(bpm); // engine_->setBpm(bpm);
engine_->updateAllModulationSwitches(); engine_->updateAllModulationSwitches();
int kSampleRate = getSampleRate();
double sample_time = 1.0 / getSampleRate(); double sample_time = 1.0 / kSampleRate;
double current_time = -kPreProcessSamples * sample_time; double current_time = -kPreProcessSamples * sample_time;
for (int samples = 0; samples < kPreProcessSamples; samples += kBufferSize) { 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) { 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 kFadeSamples = 200;
static constexpr int kBufferSize = 64; static constexpr int kBufferSize = 64;
static constexpr int kPreProcessSamples = 256; // note: dbraun decreased this from 44100. 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()); ScopedLock lock(getCriticalSection());
engine_->allSoundsOff(); // note: dbraun added this engine_->allSoundsOff(); // note: dbraun added this
processModulationChanges(); processModulationChanges();
engine_->setSampleRate(kSampleRate);
engine_->updateAllModulationSwitches(); engine_->updateAllModulationSwitches();
int kSampleRate = getSampleRate();
// Preprocess modulation // Preprocess modulation
double sample_time = 1.0 / getSampleRate(); double sample_time = 1.0 / kSampleRate;
double current_time = -kPreProcessSamples * sample_time; double current_time = -kPreProcessSamples * sample_time;
for (int samples = 0; samples < kPreProcessSamples; samples += kBufferSize) { 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 static_cast<size_t>(total_samples * 2); // stereo: 2 channels
auto* data = new float[total_frames](); // Zero-initialized auto* data = new float[total_frames](); // Zero-initialized
auto capsule = nb::capsule(
data, [](void* p) noexcept { delete[] static_cast<float*>(p); });
int baseSample = 0; 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 the data as a NumPy array
return nb::ndarray<float, nb::shape<2, -1>, nb::numpy>( 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) { bool SynthBase::renderAudioToFile2(const std::string& output_path, const int& midi_note, float velocity, float note_dur, float render_dur) {

View file

@ -139,6 +139,7 @@ class SynthBase : public MidiManager::Listener {
Tuning* getTuning() { return &tuning_; } Tuning* getTuning() { return &tuning_; }
void pySetBPM(float bpm); void pySetBPM(float bpm);
void setSampleRate(double sample_rate);
struct ValueChangedCallback : public CallbackMessage { struct ValueChangedCallback : public CallbackMessage {
ValueChangedCallback(std::shared_ptr<SynthBase*> listener, std::string name, vital::mono_float val) : ValueChangedCallback(std::shared_ptr<SynthBase*> listener, std::string name, vital::mono_float val) :

View file

@ -18,6 +18,10 @@
#include "value.h" #include "value.h"
#include "voice_handler.h" #include "voice_handler.h"
#include "wave_frame.h" #include "wave_frame.h"
#include <stdexcept>
#include "synth_parameters.h"
#include <cmath>
#include <string>
namespace nb = nanobind; namespace nb = nanobind;
using namespace vital; using namespace vital;
@ -100,6 +104,165 @@ nb::list get_modulation_sources() {
return result; 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) { NB_MODULE(vita, m) {
@ -112,17 +275,14 @@ NB_MODULE(vita, m) {
auto m_constants = m.def_submodule("constants", "Submodule containing constants and enums"); auto m_constants = m.def_submodule("constants", "Submodule containing constants and enums");
// Expose 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("Filter1", constants::SourceDestination::kFilter1)
.value("Filter2", constants::SourceDestination::kFilter2) .value("Filter2", constants::SourceDestination::kFilter2)
.value("DualFilters", constants::SourceDestination::kDualFilters) .value("DualFilters", constants::SourceDestination::kDualFilters)
.value("Effects", constants::SourceDestination::kEffects) .value("Effects", constants::SourceDestination::kEffects)
.value("DirectOut", constants::SourceDestination::kDirectOut) .value("DirectOut", constants::SourceDestination::kDirectOut);
.def("__int__", [](constants::SourceDestination self) {
return static_cast<int>(self);
});
nb::enum_<constants::Effect>(m_constants, "Effect") nb::enum_<constants::Effect>(m_constants, "Effect", nb::is_arithmetic())
.value("Chorus", constants::Effect::kChorus) .value("Chorus", constants::Effect::kChorus)
.value("Compressor", constants::Effect::kCompressor) .value("Compressor", constants::Effect::kCompressor)
.value("Delay", constants::Effect::kDelay) .value("Delay", constants::Effect::kDelay)
@ -131,11 +291,9 @@ NB_MODULE(vita, m) {
.value("FilterFx", constants::Effect::kFilterFx) .value("FilterFx", constants::Effect::kFilterFx)
.value("Flanger", constants::Effect::kFlanger) .value("Flanger", constants::Effect::kFlanger)
.value("Phaser", constants::Effect::kPhaser) .value("Phaser", constants::Effect::kPhaser)
.value("Reverb", constants::Effect::kReverb) .value("Reverb", constants::Effect::kReverb);
.def("__int__",
[](constants::Effect self) { return static_cast<int>(self); });
nb::enum_<constants::FilterModel>(m_constants, "FilterModel") nb::enum_<constants::FilterModel>(m_constants, "FilterModel", nb::is_arithmetic())
.value("Analog", constants::FilterModel::kAnalog) .value("Analog", constants::FilterModel::kAnalog)
.value("Dirty", constants::FilterModel::kDirty) .value("Dirty", constants::FilterModel::kDirty)
.value("Ladder", constants::FilterModel::kLadder) .value("Ladder", constants::FilterModel::kLadder)
@ -143,20 +301,51 @@ NB_MODULE(vita, m) {
.value("Diode", constants::FilterModel::kDiode) .value("Diode", constants::FilterModel::kDiode)
.value("Formant", constants::FilterModel::kFormant) .value("Formant", constants::FilterModel::kFormant)
.value("Comb", constants::FilterModel::kComb) .value("Comb", constants::FilterModel::kComb)
.value("Phase", constants::FilterModel::kPhase) .value("Phase", constants::FilterModel::kPhase);
.def("__int__", [](constants::FilterModel self) {
return static_cast<int>(self);
});
nb::enum_<constants::RetriggerStyle>(m_constants, "RetriggerStyle") nb::enum_<constants::RetriggerStyle>(m_constants, "RetriggerStyle", nb::is_arithmetic())
.value("Free", constants::RetriggerStyle::kFree) .value("Free", constants::RetriggerStyle::kFree)
.value("Retrigger", constants::RetriggerStyle::kRetrigger) .value("Retrigger", constants::RetriggerStyle::kRetrigger)
.value("SyncToPlayHead", constants::RetriggerStyle::kSyncToPlayHead) .value("SyncToPlayHead", constants::RetriggerStyle::kSyncToPlayHead);
.def("__int__", [](constants::RetriggerStyle self) {
return static_cast<int>(self); // 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("NoSpectralMorph", SynthOscillator::SpectralMorph::kNoSpectralMorph)
.value("Vocode", SynthOscillator::SpectralMorph::kVocode) .value("Vocode", SynthOscillator::SpectralMorph::kVocode)
.value("FormScale", SynthOscillator::SpectralMorph::kFormScale) .value("FormScale", SynthOscillator::SpectralMorph::kFormScale)
@ -168,12 +357,9 @@ NB_MODULE(vita, m) {
.value("HighPass", SynthOscillator::SpectralMorph::kHighPass) .value("HighPass", SynthOscillator::SpectralMorph::kHighPass)
.value("PhaseDisperse", SynthOscillator::SpectralMorph::kPhaseDisperse) .value("PhaseDisperse", SynthOscillator::SpectralMorph::kPhaseDisperse)
.value("ShepardTone", SynthOscillator::SpectralMorph::kShepardTone) .value("ShepardTone", SynthOscillator::SpectralMorph::kShepardTone)
.value("Skew", SynthOscillator::SpectralMorph::kSkew) .value("Skew", SynthOscillator::SpectralMorph::kSkew);
.def("__int__", [](SynthOscillator::SpectralMorph self) {
return static_cast<int>(self);
});
nb::enum_<SynthOscillator::DistortionType>(m_constants, "DistortionType") nb::enum_<SynthOscillator::DistortionType>(m_constants, "DistortionType", nb::is_arithmetic())
.value("None", SynthOscillator::DistortionType::kNone) .value("None", SynthOscillator::DistortionType::kNone)
.value("Sync", SynthOscillator::DistortionType::kSync) .value("Sync", SynthOscillator::DistortionType::kSync)
.value("Formant", SynthOscillator::DistortionType::kFormant) .value("Formant", SynthOscillator::DistortionType::kFormant)
@ -186,12 +372,9 @@ NB_MODULE(vita, m) {
.value("FmSample", SynthOscillator::DistortionType::kFmSample) .value("FmSample", SynthOscillator::DistortionType::kFmSample)
.value("RmOscillatorA", SynthOscillator::DistortionType::kRmOscillatorA) .value("RmOscillatorA", SynthOscillator::DistortionType::kRmOscillatorA)
.value("RmOscillatorB", SynthOscillator::DistortionType::kRmOscillatorB) .value("RmOscillatorB", SynthOscillator::DistortionType::kRmOscillatorB)
.value("RmSample", SynthOscillator::DistortionType::kRmSample) .value("RmSample", SynthOscillator::DistortionType::kRmSample);
.def("__int__", [](SynthOscillator::DistortionType self) {
return static_cast<int>(self);
});
nb::enum_<SynthOscillator::UnisonStackType>(m_constants, "UnisonStackType") nb::enum_<SynthOscillator::UnisonStackType>(m_constants, "UnisonStackType", nb::is_arithmetic())
.value("Normal", SynthOscillator::UnisonStackType::kNormal) .value("Normal", SynthOscillator::UnisonStackType::kNormal)
.value("CenterDropOctave", SynthOscillator::UnisonStackType::kCenterDropOctave) .value("CenterDropOctave", SynthOscillator::UnisonStackType::kCenterDropOctave)
.value("CenterDropOctave2", SynthOscillator::UnisonStackType::kCenterDropOctave2) .value("CenterDropOctave2", SynthOscillator::UnisonStackType::kCenterDropOctave2)
@ -203,75 +386,54 @@ NB_MODULE(vita, m) {
.value("MinorChord", SynthOscillator::UnisonStackType::kMinorChord) .value("MinorChord", SynthOscillator::UnisonStackType::kMinorChord)
.value("HarmonicSeries", SynthOscillator::UnisonStackType::kHarmonicSeries) .value("HarmonicSeries", SynthOscillator::UnisonStackType::kHarmonicSeries)
.value("OddHarmonicSeries", .value("OddHarmonicSeries",
SynthOscillator::UnisonStackType::kOddHarmonicSeries) SynthOscillator::UnisonStackType::kOddHarmonicSeries);
.def("__int__", [](SynthOscillator::UnisonStackType self) {
return static_cast<int>(self);
});
nb::enum_<RandomLfo::RandomType>(m_constants, "RandomLFOStyle") nb::enum_<RandomLfo::RandomType>(m_constants, "RandomLFOStyle", nb::is_arithmetic())
.value("Perlin", RandomLfo::RandomType::kPerlin) .value("Perlin", RandomLfo::RandomType::kPerlin)
.value("SampleAndHold", RandomLfo::RandomType::kSampleAndHold) .value("SampleAndHold", RandomLfo::RandomType::kSampleAndHold)
.value("SinInterpolate", RandomLfo::RandomType::kSinInterpolate) .value("SinInterpolate", RandomLfo::RandomType::kSinInterpolate)
.value("LorenzAttractor", RandomLfo::RandomType::kLorenzAttractor) .value("LorenzAttractor", RandomLfo::RandomType::kLorenzAttractor);
.def("__int__",
[](RandomLfo::RandomType self) { return static_cast<int>(self); });
nb::enum_<VoiceHandler::VoicePriority>(m_constants, "VoicePriority") nb::enum_<VoiceHandler::VoicePriority>(m_constants, "VoicePriority", nb::is_arithmetic())
.value("Newest", VoiceHandler::VoicePriority::kNewest) .value("Newest", VoiceHandler::VoicePriority::kNewest)
.value("Oldest", VoiceHandler::VoicePriority::kOldest) .value("Oldest", VoiceHandler::VoicePriority::kOldest)
.value("Highest", VoiceHandler::VoicePriority::kHighest) .value("Highest", VoiceHandler::VoicePriority::kHighest)
.value("Lowest", VoiceHandler::VoicePriority::kLowest) .value("Lowest", VoiceHandler::VoicePriority::kLowest)
.value("RoundRobin", VoiceHandler::VoicePriority::kRoundRobin) .value("RoundRobin", VoiceHandler::VoicePriority::kRoundRobin);
.def("__int__", [](VoiceHandler::VoicePriority self) {
return static_cast<int>(self);
});
nb::enum_<VoiceHandler::VoiceOverride>(m_constants, "VoiceOverride") nb::enum_<VoiceHandler::VoiceOverride>(m_constants, "VoiceOverride", nb::is_arithmetic())
.value("Kill",VoiceHandler::VoiceOverride::kKill) .value("Kill",VoiceHandler::VoiceOverride::kKill)
.value("Steal", VoiceHandler::VoiceOverride::kSteal) .value("Steal", VoiceHandler::VoiceOverride::kSteal);
.def("__int__", [](VoiceHandler::VoiceOverride self) {
return static_cast<int>(self);
});
nb::enum_<PredefinedWaveFrames::Shape>(m_constants, "WaveShape") nb::enum_<PredefinedWaveFrames::Shape>(m_constants, "WaveShape", nb::is_arithmetic())
.value("Sin", PredefinedWaveFrames::kSin) .value("Sin", PredefinedWaveFrames::kSin)
.value("SaturatedSin", PredefinedWaveFrames::kSaturatedSin) .value("SaturatedSin", PredefinedWaveFrames::kSaturatedSin)
.value("Triangle", PredefinedWaveFrames::kTriangle) .value("Triangle", PredefinedWaveFrames::kTriangle)
.value("Square", PredefinedWaveFrames::kSquare) .value("Square", PredefinedWaveFrames::kSquare)
.value("Pulse", PredefinedWaveFrames::kPulse) .value("Pulse", PredefinedWaveFrames::kPulse)
.value("Saw", PredefinedWaveFrames::kSaw) .value("Saw", PredefinedWaveFrames::kSaw);
.def("__int__", [](PredefinedWaveFrames::Shape self) {
return static_cast<int>(self);
});
nb::enum_<SynthLfo::SyncType>(m_constants, "SynthLFOSyncType") nb::enum_<SynthLfo::SyncType>(m_constants, "SynthLFOSyncType", nb::is_arithmetic())
.value("Trigger", SynthLfo::SyncType::kTrigger) .value("Trigger", SynthLfo::SyncType::kTrigger)
.value("Sync", SynthLfo::SyncType::kSync) .value("Sync", SynthLfo::SyncType::kSync)
.value("Envelope", SynthLfo::SyncType::kEnvelope) .value("Envelope", SynthLfo::SyncType::kEnvelope)
.value("SustainEnvelope", SynthLfo::SyncType::kSustainEnvelope) .value("SustainEnvelope", SynthLfo::SyncType::kSustainEnvelope)
.value("LoopPoint", SynthLfo::SyncType::kLoopPoint) .value("LoopPoint", SynthLfo::SyncType::kLoopPoint)
.value("LoopHold", SynthLfo::SyncType::kLoopHold) .value("LoopHold", SynthLfo::SyncType::kLoopHold);
.def("__int__",
[](SynthLfo::SyncType self) { return static_cast<int>(self); });
nb::enum_<MultibandCompressor::BandOptions>(m_constants, "CompressorBandOption") nb::enum_<MultibandCompressor::BandOptions>(m_constants, "CompressorBandOption", nb::is_arithmetic())
.value("Multiband", MultibandCompressor::BandOptions::kMultiband) .value("Multiband", MultibandCompressor::BandOptions::kMultiband)
.value("LowBand", MultibandCompressor::BandOptions::kLowBand) .value("LowBand", MultibandCompressor::BandOptions::kLowBand)
.value("HighBand", MultibandCompressor::BandOptions::kHighBand) .value("HighBand", MultibandCompressor::BandOptions::kHighBand)
.value("SingleBand", MultibandCompressor::BandOptions::kSingleBand) .value("SingleBand", MultibandCompressor::BandOptions::kSingleBand);
.def("__int__", [](MultibandCompressor::BandOptions self) {
return static_cast<int>(self);
});
nb::enum_<SynthFilter::Style>(m_constants, "SynthFilterStyle") nb::enum_<SynthFilter::Style>(m_constants, "SynthFilterStyle", nb::is_arithmetic())
.value("k12Db", SynthFilter::Style::k12Db) .value("k12Db", SynthFilter::Style::k12Db)
.value("k24Db", SynthFilter::Style::k24Db) .value("k24Db", SynthFilter::Style::k24Db)
.value("NotchPassSwap", SynthFilter::Style::kNotchPassSwap) .value("NotchPassSwap", SynthFilter::Style::kNotchPassSwap)
.value("DualNotchBand", SynthFilter::Style::kDualNotchBand) .value("DualNotchBand", SynthFilter::Style::kDualNotchBand)
.value("BandPeakNotch", SynthFilter::Style::kBandPeakNotch) .value("BandPeakNotch", SynthFilter::Style::kBandPeakNotch)
.value("Shelving", SynthFilter::Style::kShelving) .value("Shelving", SynthFilter::Style::kShelving);
.def("__int__",
[](SynthFilter::Style self) { return static_cast<int>(self); });
// https://github.com/mtytel/vital/blob/636ca0ef517a4db087a6a08a6a8a5e704e21f836/src/interface/look_and_feel/synth_strings.h#L174 // https://github.com/mtytel/vital/blob/636ca0ef517a4db087a6a08a6a8a5e704e21f836/src/interface/look_and_feel/synth_strings.h#L174
enum SyncedFrequencyName { enum SyncedFrequencyName {
@ -289,7 +451,7 @@ NB_MODULE(vita, m) {
k1_64 k1_64
}; };
nb::enum_<SyncedFrequencyName>(m_constants, "SyncedFrequency") nb::enum_<SyncedFrequencyName>(m_constants, "SyncedFrequency", nb::is_arithmetic())
.value("k32_1", SyncedFrequencyName::k32_1) .value("k32_1", SyncedFrequencyName::k32_1)
.value("k16_1", SyncedFrequencyName::k16_1) .value("k16_1", SyncedFrequencyName::k16_1)
.value("k8_1", SyncedFrequencyName::k8_1) .value("k8_1", SyncedFrequencyName::k8_1)
@ -301,9 +463,7 @@ NB_MODULE(vita, m) {
.value("k1_8", SyncedFrequencyName::k1_8) .value("k1_8", SyncedFrequencyName::k1_8)
.value("k1_16", SyncedFrequencyName::k1_16) .value("k1_16", SyncedFrequencyName::k1_16)
.value("k1_32", SyncedFrequencyName::k1_32) .value("k1_32", SyncedFrequencyName::k1_32)
.value("k1_64", SyncedFrequencyName::k1_64) .value("k1_64", SyncedFrequencyName::k1_64);
.def("__int__",
[](SyncedFrequencyName self) { return static_cast<int>(self); });
// https://github.com/mtytel/vital/blob/636ca0ef517a4db087a6a08a6a8a5e704e21f836/src/synthesis/modulators/synth_lfo.h#L58C1-L65C9 // https://github.com/mtytel/vital/blob/636ca0ef517a4db087a6a08a6a8a5e704e21f836/src/synthesis/modulators/synth_lfo.h#L58C1-L65C9
enum SyncOption { enum SyncOption {
@ -314,13 +474,12 @@ NB_MODULE(vita, m) {
kKeytrack, kKeytrack,
}; };
nb::enum_<SyncOption>(m_constants, "SynthLFOSyncOption") nb::enum_<SyncOption>(m_constants, "SynthLFOSyncOption", nb::is_arithmetic())
.value("Time", SyncOption::kTime) .value("Time", SyncOption::kTime)
.value("Tempo", SyncOption::kTempo) .value("Tempo", SyncOption::kTempo)
.value("DottedTempo", SyncOption::kDottedTempo) .value("DottedTempo", SyncOption::kDottedTempo)
.value("TripletTempo", SyncOption::kTripletTempo) .value("TripletTempo", SyncOption::kTripletTempo)
.value("Keytrack", SyncOption::kKeytrack) .value("Keytrack", SyncOption::kKeytrack);
.def("__int__", [](SyncOption self) { return static_cast<int>(self); });
// Binding for poly_float // Binding for poly_float
nb::class_<vital::poly_float>(m, "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()) + ">"; 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 // Expose the SynthBase class, specifying ProcessorRouter as its base
nb::class_<HeadlessSynth>(m, "Synth") nb::class_<HeadlessSynth>(m, "Synth")
.def(nb::init<>()) // Ensure there's a default constructor or adjust .def(nb::init<>()) // Ensure there's a default constructor or adjust
// accordingly // accordingly
// Bind the first overload of connectModulation // 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", .def("connect_modulation",
nb::overload_cast<const std::string &, const std::string &>( nb::overload_cast<const std::string &, const std::string &>(
&HeadlessSynth::pyConnectModulation), &HeadlessSynth::pyConnectModulation),
@ -392,6 +572,7 @@ NB_MODULE(vita, m) {
"Disconnects a modulation source from a destination by name.") "Disconnects a modulation source from a destination by name.")
.def("set_bpm", &HeadlessSynth::pySetBPM, nb::arg("bpm")) .def("set_bpm", &HeadlessSynth::pySetBPM, nb::arg("bpm"))
.def("set_sample_rate", &HeadlessSynth::setSampleRate, nb::arg("sample_rate"))
.def("render_file", &HeadlessSynth::renderAudioToFile2, .def("render_file", &HeadlessSynth::renderAudioToFile2,
nb::arg("output_path"), nb::arg("midi_note"), 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("load_init_preset", &HeadlessSynth::loadInitPreset, "Load the initial preset.")
.def("clear_modulations", &HeadlessSynth::clearModulations) .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)
;
} }

View 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!")

View file

@ -20,15 +20,14 @@ from vita.constants import (
SyncedFrequency, SyncedFrequency,
) )
SAMPLE_RATE = 44_100
def test_render(bpm=120.0, sample_rate=48000, note_dur=1.0, render_dur=3.0, pitch=36, velocity=0.7):
def test_render(bpm=120.0, note_dur=1.0, render_dur=3.0, pitch=36, velocity=0.7):
synth = vita.Synth() synth = vita.Synth()
# The initial preset is laoded by default. # The initial preset is laoded by default.
synth.set_bpm(bpm) synth.set_bpm(bpm)
synth.set_sample_rate(sample_rate)
assert vita.get_modulation_sources() assert vita.get_modulation_sources()
assert vita.get_modulation_destinations() 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 = synth.get_controls()
controls["modulation_1_amount"].set(1.0) controls["modulation_1_amount"].set(1.0)
controls["filter_1_on"].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) controls["lfo_1_tempo"].set(SyncedFrequency.k1_16)
# Render audio to numpy array shaped (2, NUM_SAMPLES) # Render audio to numpy array shaped (2, NUM_SAMPLES)
audio = synth.render(pitch, velocity, note_dur, render_dur) 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 # Dump current state to json text
json_text = synth.to_json() 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 # Load JSON text
with open(preset_path, "r") as f: with open(preset_path, "r") as f:
json_text = f.read() json_text1 = f.read()
assert synth.load_json(json_text) assert synth.load_json(json_text1)
assert json_text == json_text1
# Or load directly from file: # Or load directly from file:
assert synth.load_preset(preset_path) 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() synth.load_init_preset()
# Or just clear modulations. # Or just clear modulations.
synth.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)

@ -1 +1 @@
Subproject commit efbeb8afd95b210d9e2e06f719ddc1336a12b9d7 Subproject commit 109af19428a8784aa02fed55a5b3e21617de93ef

View file

@ -1,2 +1,9 @@
from .vita import * from .vita import Synth, constants, get_modulation_sources, get_modulation_destinations
from .version import * from .version import __version__
__ALL__ = [
"Synth",
"constants",
"get_modulation_sources",
"get_modulation_destinations",
]

View file

@ -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",
]

View file

@ -1 +1 @@
__version__ = "0.0.3" __version__ = "0.0.5"