From bc6763a4e0dc1c31a22575e190cbe0f425a40b2a Mon Sep 17 00:00:00 2001
From: David Braun <2096055+DBraun@users.noreply.github.com>
Date: Tue, 3 Jun 2025 11:08:12 -0400
Subject: [PATCH] Normalized parameters and parameter introspection (#3)
---
README.md | 13 +-
examples/normalized_control_demo.py | 93 +++++
.../Vita_DynamicLibrary.vcxproj.user | 4 +
setup.py | 2 +-
src/common/synth_base.cpp | 25 +-
src/headless/bindings.cpp | 333 ++++++++++++++----
tests/test_normalized_params.py | 144 ++++++++
tests/test_render.py | 40 +++
third_party/nanobind | 2 +-
vita/__init__.py | 11 +-
vita/constants/__init__.py | 40 ++-
vita/version.py | 2 +-
12 files changed, 620 insertions(+), 89 deletions(-)
create mode 100644 examples/normalized_control_demo.py
create mode 100644 headless/builds/VisualStudio2022/Vita_DynamicLibrary.vcxproj.user
create mode 100644 tests/test_normalized_params.py
diff --git a/README.md b/README.md
index 67e2699..1a28d88 100644
--- a/README.md
+++ b/README.md
@@ -42,8 +42,18 @@ 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)
@@ -54,8 +64,7 @@ preset_path = "generated_preset.vital"
json_text = synth.to_json()
-with open(preset_path, "w") as f:
-
+with open(preset_path, "w") as f:
f.write(json_text)
# Load JSON text
diff --git a/examples/normalized_control_demo.py b/examples/normalized_control_demo.py
new file mode 100644
index 0000000..be28713
--- /dev/null
+++ b/examples/normalized_control_demo.py
@@ -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()
diff --git a/headless/builds/VisualStudio2022/Vita_DynamicLibrary.vcxproj.user b/headless/builds/VisualStudio2022/Vita_DynamicLibrary.vcxproj.user
new file mode 100644
index 0000000..0f14913
--- /dev/null
+++ b/headless/builds/VisualStudio2022/Vita_DynamicLibrary.vcxproj.user
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/setup.py b/setup.py
index 7be2076..a6e857d 100644
--- a/setup.py
+++ b/setup.py
@@ -5,7 +5,7 @@
# Then in the `dist` directory, `pip install vita`
import setuptools
-from setuptools import setup, Extension
+from setuptools import setup
from setuptools.dist import Distribution
import os
import os.path
diff --git a/src/common/synth_base.cpp b/src/common/synth_base.cpp
index c2fc096..60d1c62 100644
--- a/src/common/synth_base.cpp
+++ b/src/common/synth_base.cpp
@@ -16,6 +16,7 @@
#include "synth_base.h"
+#include
#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;
}
@@ -578,6 +580,9 @@ nb::ndarray, nb::numpy> SynthBase::renderAudioToNumpy(co
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
@@ -607,8 +612,6 @@ nb::ndarray, nb::numpy> SynthBase::renderAudioToNumpy(co
static_cast(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(p); });
int baseSample = 0;
@@ -634,9 +637,15 @@ nb::ndarray, 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, nb::numpy>(
- data, {2, static_cast(total_samples)}, capsule);
+ data, {2, static_cast(total_samples)}, owner);
}
bool SynthBase::renderAudioToFile2(const std::string& output_path, const int& midi_note, float velocity, float note_dur, float render_dur) {
diff --git a/src/headless/bindings.cpp b/src/headless/bindings.cpp
index 2d58433..2bb599c 100644
--- a/src/headless/bindings.cpp
+++ b/src/headless/bindings.cpp
@@ -18,6 +18,10 @@
#include "value.h"
#include "voice_handler.h"
#include "wave_frame.h"
+#include
+#include "synth_parameters.h"
+#include
+#include
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(details.max - details.min + 1);
+ int idx = static_cast(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(v))); }
+ void set(int v) { value_->set(poly_float(static_cast(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(details.max - details.min + 1);
+ int index = static_cast(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(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(details.max - details.min + 1);
+ int index = static_cast(std::round(raw - details.min));
+ return static_cast(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(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_(m_constants, "SourceDestination")
+ nb::enum_(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(self);
- });
+ .value("DirectOut", constants::SourceDestination::kDirectOut);
- nb::enum_(m_constants, "Effect")
+ nb::enum_(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(self); });
+ .value("Reverb", constants::Effect::kReverb);
- nb::enum_(m_constants, "FilterModel")
+ nb::enum_(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(self);
- });
+ .value("Phase", constants::FilterModel::kPhase);
- nb::enum_(m_constants, "RetriggerStyle")
+ nb::enum_(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(self);
+ .value("SyncToPlayHead", constants::RetriggerStyle::kSyncToPlayHead);
+
+ // Parameter value scaling types
+ nb::enum_(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_(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(d.max - d.min + 1);
+ for (int i = 0; i < count; ++i)
+ opts.append(std::string(d.string_lookup[i]));
+ }
+ return opts;
});
- nb::enum_(m_constants, "SpectralMorph")
+ nb::enum_(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(self);
- });
+ .value("Skew", SynthOscillator::SpectralMorph::kSkew);
- nb::enum_(m_constants, "DistortionType")
+ nb::enum_(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(self);
- });
+ .value("RmSample", SynthOscillator::DistortionType::kRmSample);
- nb::enum_(m_constants, "UnisonStackType")
+ nb::enum_(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(self);
- });
+ SynthOscillator::UnisonStackType::kOddHarmonicSeries);
- nb::enum_(m_constants, "RandomLFOStyle")
+ nb::enum_(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(self); });
+ .value("LorenzAttractor", RandomLfo::RandomType::kLorenzAttractor);
- nb::enum_(m_constants, "VoicePriority")
+ nb::enum_(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(self);
- });
+ .value("RoundRobin", VoiceHandler::VoicePriority::kRoundRobin);
- nb::enum_(m_constants, "VoiceOverride")
+ nb::enum_(m_constants, "VoiceOverride", nb::is_arithmetic())
.value("Kill",VoiceHandler::VoiceOverride::kKill)
- .value("Steal", VoiceHandler::VoiceOverride::kSteal)
- .def("__int__", [](VoiceHandler::VoiceOverride self) {
- return static_cast(self);
- });
+ .value("Steal", VoiceHandler::VoiceOverride::kSteal);
- nb::enum_(m_constants, "WaveShape")
+ nb::enum_(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(self);
- });
+ .value("Saw", PredefinedWaveFrames::kSaw);
- nb::enum_(m_constants, "SynthLFOSyncType")
+ nb::enum_(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(self); });
+ .value("LoopHold", SynthLfo::SyncType::kLoopHold);
- nb::enum_(m_constants, "CompressorBandOption")
+ nb::enum_(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(self);
- });
+ .value("SingleBand", MultibandCompressor::BandOptions::kSingleBand);
- nb::enum_(m_constants, "SynthFilterStyle")
+ nb::enum_(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(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_(m_constants, "SyncedFrequency")
+ nb::enum_(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(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_(m_constants, "SynthLFOSyncOption")
+ nb::enum_(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(self); });
+ .value("Keytrack", SyncOption::kKeytrack);
// Binding for poly_float
nb::class_(m, "poly_float")
@@ -374,6 +533,18 @@ NB_MODULE(vita, m) {
return "";
});
+ // Bind the ControlValue wrapper class
+ nb::class_(m, "ControlValue")
+ .def("value", &ControlValue::value)
+ .def("set", nb::overload_cast(&ControlValue::set), nb::arg("value"))
+ .def("set", nb::overload_cast(&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_(m, "Synth")
.def(nb::init<>()) // Ensure there's a default constructor or adjust
@@ -439,5 +610,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)
+ ;
}
diff --git a/tests/test_normalized_params.py b/tests/test_normalized_params.py
new file mode 100644
index 0000000..34ea31f
--- /dev/null
+++ b/tests/test_normalized_params.py
@@ -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!")
diff --git a/tests/test_render.py b/tests/test_render.py
index 5283925..4ad59aa 100644
--- a/tests/test_render.py
+++ b/tests/test_render.py
@@ -38,6 +38,7 @@ 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)
@@ -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)
diff --git a/third_party/nanobind b/third_party/nanobind
index efbeb8a..109af19 160000
--- a/third_party/nanobind
+++ b/third_party/nanobind
@@ -1 +1 @@
-Subproject commit efbeb8afd95b210d9e2e06f719ddc1336a12b9d7
+Subproject commit 109af19428a8784aa02fed55a5b3e21617de93ef
diff --git a/vita/__init__.py b/vita/__init__.py
index df6e175..d4dedfe 100644
--- a/vita/__init__.py
+++ b/vita/__init__.py
@@ -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",
+]
diff --git a/vita/constants/__init__.py b/vita/constants/__init__.py
index 93e9a87..9f00c97 100644
--- a/vita/constants/__init__.py
+++ b/vita/constants/__init__.py
@@ -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",
+]
diff --git a/vita/version.py b/vita/version.py
index 81f0fde..b1a19e3 100644
--- a/vita/version.py
+++ b/vita/version.py
@@ -1 +1 @@
-__version__ = "0.0.4"
+__version__ = "0.0.5"