Compare commits

...

2 commits
v0.0.4 ... 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
14 changed files with 644 additions and 105 deletions

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
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')"

View file

@ -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
@ -42,12 +42,22 @@ 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"
@ -55,7 +65,6 @@ preset_path = "generated_preset.vital"
json_text = synth.to_json()
with open(preset_path, "w") as f:
f.write(json_text)
# Load JSON text

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`
import setuptools
from setuptools import setup, Extension
from setuptools import setup
from setuptools.dist import Distribution
import os
import os.path

View file

@ -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) {

View file

@ -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) :

View file

@ -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,6 +533,18 @@ 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
@ -401,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"),
@ -439,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)
;
}

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,
)
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()
@ -65,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)

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

View file

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

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.4"
__version__ = "0.0.5"