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"