diff --git a/scripts/dial.lua b/scripts/dial.lua new file mode 100644 index 000000000..be16d3b95 --- /dev/null +++ b/scripts/dial.lua @@ -0,0 +1,32 @@ +--- Value. +-- +-- A single control output that emits a normalized value (0.0 - 1.0). The value +-- is intended to be driven externally (host automation, MIDI mapping, etc.). +-- +-- @script value +-- @type DSP +-- @license GPL v3 +-- @author Michael Fisher + +local function layout() + return { + audio = { 0, 0 }, + midi = { 0, 0 }, + control = { {}, { + { name = "Value", symbol = "value", min = 0.0, max = 1.0, default = 0.5 } + } } + } +end + +local function process (_, _, _, _) + -- Output value is driven externally; nothing to compute here. +end + +return { + type = 'DSP', + layout = layout, + process = process +} + +-- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +-- SPDX-License-Identifier: GPL-3.0-or-later diff --git a/scripts/midicc.lua b/scripts/midicc.lua new file mode 100644 index 000000000..77776bfe6 --- /dev/null +++ b/scripts/midicc.lua @@ -0,0 +1,66 @@ +--- MIDI CC. +-- +-- Converts a normalized value (0.0 - 1.0) into a 0-127 MIDI Continuous Controller +-- value. Emits a MIDI CC message on MIDI-out and exposes the scaled value as a +-- control output. A message is only sent when the scaled value changes. +-- +-- @script midicc +-- @type DSP +-- @license GPL v3 +-- @author Michael Fisher + +local MidiBuffer = require ('el.MidiBuffer') +local midi = require ('el.midi') +local round = require ('el.round') + +local output = MidiBuffer.new() +local lastValue = -1 + +local function layout() + return { + audio = { 0, 0 }, + midi = { 0, 1 }, + control = { { + { name = "Value", symbol = "value", min = 0.0, max = 1.0, default = 0.0 }, + { name = "CC", symbol = "cc", min = 0, max = 127, default = 1 }, + { name = "Channel", symbol = "channel", min = 1, max = 16, default = 1 } + }, { + { name = "Value", symbol = "value", min = 0, max = 127, default = 0 } + } } + } +end + +local function prepare() + output:reserve (128) + output:clear() +end + +local function process (_, m, p, c) + local out = m:get (1) + output:clear() + + local value = round.integer (p[1] * 127) + local cc = round.integer (p[2]) + local channel = round.integer (p[3]) + + -- Expose the scaled value as a control output. + c[1] = value + + -- Only emit a message when the scaled value changes. + if value ~= lastValue then + output:insertPacked (midi.controller (channel, cc, value), 0) + lastValue = value + end + + out:swap (output) +end + +return { + type = 'DSP', + layout = layout, + prepare = prepare, + process = process +} + +-- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +-- SPDX-License-Identifier: GPL-3.0-or-later diff --git a/scripts/testtone.lua b/scripts/testtone.lua new file mode 100644 index 000000000..4a571e707 --- /dev/null +++ b/scripts/testtone.lua @@ -0,0 +1,72 @@ +--- Test Tone. +-- A simple sine wave generator, handy as a signal source for checking routing, +-- levels and metering. Frequency is in Hz and Level scales the output amplitude. +-- +-- @script testtone +-- @type DSP +-- @license GPL v3 +-- @author Michael Fisher + +-- Oscillator phase (radians) and current sample rate, kept across process blocks. +local phase = 0.0 +local srate = 44100.0 + +local function layout() + return { + audio = { 0, 2 }, + midi = { 0, 0 }, + control = {{ + { + name = "Frequency", + symbol = "freq", + label = "Hz", + min = 20.0, + max = 20000.0, + default = 440.0 + }, + { + name = "Level", + symbol = "level", + min = 0.0, + max = 1.0, + default = 0.5 + } + }} + } +end + +local function prepare (sampleRate, block) + srate = sampleRate + phase = 0.0 +end + +local function process (a, m, p) + local freq = p[1] + local level = p[2] + local nframes = a:length() + local nchans = a:channels() + local inc = (2.0 * math.pi * freq) / srate + + for f = 1, nframes do + local s = level * math.sin (phase) + + for c = 1, nchans do + a:set (c, f, s) + end + + phase = phase + inc + if phase >= 2.0 * math.pi then + phase = phase - 2.0 * math.pi + end + end +end + +return { + type = 'DSP', + layout = layout, + prepare = prepare, + process = process +} + +-- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +-- SPDX-License-Identifier: GPL-3.0-or-later diff --git a/scripts/tremolo.lua b/scripts/tremolo.lua new file mode 100644 index 000000000..6d1d0c417 --- /dev/null +++ b/scripts/tremolo.lua @@ -0,0 +1,74 @@ +--- Tremolo. +-- Amplitude modulation driven by a sine LFO. Rate sets the modulation speed in +-- Hz and Depth controls how far the level drops at the trough of the wave. +-- +-- @script tremolo +-- @type DSP +-- @license GPL v3 +-- @author Michael Fisher + +-- LFO phase (radians) and current sample rate, kept across process blocks. +local phase = 0.0 +local srate = 44100.0 + +local function layout() + return { + audio = { 2, 2 }, + midi = { 0, 0 }, + control = {{ + { + name = "Rate", + symbol = "rate", + label = "Hz", + min = 0.1, + max = 20.0, + default = 4.0 + }, + { + name = "Depth", + symbol = "depth", + min = 0.0, + max = 1.0, + default = 0.5 + } + }} + } +end + +local function prepare (sampleRate, block) + srate = sampleRate + phase = 0.0 +end + +local function process (a, m, p) + local freq = p[1] + local depth = p[2] + local nframes = a:length() + local nchans = a:channels() + local inc = (2.0 * math.pi * freq) / srate + + for f = 1, nframes do + -- LFO ranges 0..1; gain ranges (1 - depth)..1 + local lfo = 0.5 + 0.5 * math.sin (phase) + local gain = 1.0 - depth * (1.0 - lfo) + + for c = 1, nchans do + a:set (c, f, a:get (c, f) * gain) + end + + phase = phase + inc + if phase >= 2.0 * math.pi then + phase = phase - 2.0 * math.pi + end + end +end + +return { + type = 'DSP', + layout = layout, + prepare = prepare, + process = process +} + +-- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +-- SPDX-License-Identifier: GPL-3.0-or-later diff --git a/src/nodes/scriptnode.cpp b/src/nodes/scriptnode.cpp index 7abe6d95f..e62f53195 100644 --- a/src/nodes/scriptnode.cpp +++ b/src/nodes/scriptnode.cpp @@ -8,15 +8,11 @@ #include "luascripts.hpp" -#include "sol/sol.hpp" -#include "element/element.h" -#include "el/factories.hpp" #include "engine/graphnode.hpp" #include "nodes/scriptnode.hpp" #include "scripting/bindings.hpp" #include "scripting/dspscript.hpp" #include "scripting/scriptloader.hpp" -#include "scripting/scriptmanager.hpp" #define EL_LUA_DBG(x) // #define EL_LUA_DBG(x) DBG(x) @@ -260,6 +256,18 @@ const String ScriptNode::getProgramName (int index) const case 3: return "MIDI Timecode (MTC) Generator"; break; + case 4: + return "Value"; + break; + case 5: + return "MIDI CC"; + break; + case 6: + return "Tremolo"; + break; + case 7: + return "Test Tone"; + break; } String name = TRANS ("Program"); @@ -292,6 +300,23 @@ void ScriptNode::setCurrentProgram (int index) case 3: newDspCode = String::fromUTF8 (scripts::mtc_generator_lua, scripts::mtc_generator_luaSize); newUiCode.clear(); + break; + case 4: + newDspCode = String::fromUTF8 (scripts::dial_lua, scripts::dial_luaSize); + newUiCode.clear(); + break; + case 5: + newDspCode = String::fromUTF8 (scripts::midicc_lua, scripts::midicc_luaSize); + newUiCode.clear(); + break; + case 6: + newDspCode = String::fromUTF8 (scripts::tremolo_lua, scripts::tremolo_luaSize); + newUiCode.clear(); + break; + case 7: + newDspCode = String::fromUTF8 (scripts::testtone_lua, scripts::testtone_luaSize); + newUiCode.clear(); + break; } dspCode.replaceAllContent (newDspCode); diff --git a/src/nodes/scriptnode.hpp b/src/nodes/scriptnode.hpp index 1f26eedaa..ef3d3ccbb 100644 --- a/src/nodes/scriptnode.hpp +++ b/src/nodes/scriptnode.hpp @@ -45,7 +45,7 @@ class ScriptNode : public Processor, void setPlayHead (juce::AudioPlayHead*) override; //========================================================================== - int getNumPrograms() const override { return 4; } + int getNumPrograms() const override { return 8; } int getCurrentProgram() const override { return _program; } const String getProgramName (int index) const override; void setCurrentProgram (int index) override; diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index b54d1dc16..3b5418dec 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -41,6 +41,7 @@ add_test(NAME "NodeTests" COMMAND test_element --run_test=NodeTests) add_test(NAME "OversamplerTests" COMMAND test_element --run_test=OversamplerTests) add_test(NAME "PluginManagerTests" COMMAND test_element --run_test=PluginManagerTests) add_test(NAME "PortListTests" COMMAND test_element --run_test=PortListTests) +add_test(NAME "PresetScriptsTest" COMMAND test_element --run_test=PresetScriptsTest) add_test(NAME "PortTypeTests" COMMAND test_element --run_test=PortTypeTests) add_test(NAME "RootGraphTests" COMMAND test_element --run_test=RootGraphTests) add_test(NAME "ScriptInfoTest" COMMAND test_element --run_test=ScriptInfoTest) diff --git a/test/scripting/presetscriptstest.cpp b/test/scripting/presetscriptstest.cpp new file mode 100644 index 000000000..a5fc18188 --- /dev/null +++ b/test/scripting/presetscriptstest.cpp @@ -0,0 +1,192 @@ +// Copyright 2026 Kushview, LLC +// SPDX-License-Identifier: GPL-3.0-or-later + +// Unit tests for the built-in Script node preset scripts shipped in scripts/. +// Loads the real .lua files from the source tree, instantiates them as +// DSPScripts, and exercises their ports and processing. + +#include + +#include +#include + +#include "luatest.hpp" +#include "scripting/dspscript.hpp" +#include "scripting/scriptloader.hpp" +#include "testutil.hpp" + +using namespace element; +using namespace juce; + +namespace { + +/** Returns a File pointing at one of the in-tree preset scripts. */ +static File presetScript (const String& filename) +{ + return File (EL_TEST_SOURCE_ROOT) + .getChildFile ("scripts") + .getChildFile (filename); +} + +/** Casts a script parameter object to its concrete RangedParameter type. */ +static RangedParameter* ranged (const ParameterPtr& p) +{ + return dynamic_cast (p.get()); +} + +} // namespace + +BOOST_AUTO_TEST_SUITE (PresetScriptsTest) + +//============================================================================= +// Dial: a single control output 'value' (0.0 - 1.0), no inputs. +BOOST_AUTO_TEST_CASE (DialPorts) +{ + LuaFixture fix; + + ScriptLoader loader (fix.luaState()); + loader.load (presetScript ("dial.lua")); + BOOST_REQUIRE_MESSAGE (! loader.hasError(), loader.getErrorMessage().toStdString()); + + auto result = loader.call(); + BOOST_REQUIRE (result.get_type() == sol::type::table); + sol::table tbl = result; + + DSPScript dsp (tbl); + BOOST_REQUIRE_MESSAGE (dsp.isValid(), "Could not instantiate dial.lua"); + + const auto& ports = dsp.getPorts(); + BOOST_CHECK_EQUAL (ports.size (PortType::Audio, true), 0); + BOOST_CHECK_EQUAL (ports.size (PortType::Audio, false), 0); + BOOST_CHECK_EQUAL (ports.size (PortType::Midi, true), 0); + BOOST_CHECK_EQUAL (ports.size (PortType::Midi, false), 0); + BOOST_CHECK_EQUAL (ports.size (PortType::Control, true), 0); + BOOST_CHECK_EQUAL (ports.size (PortType::Control, false), 1); + + auto* value = ranged (dsp.getParameterObject (0, false)); + BOOST_REQUIRE (value != nullptr); + BOOST_CHECK_EQUAL (value->getPort().symbol.toStdString(), std::string ("value")); + + const auto& range = value->getNormalisableRange(); + BOOST_CHECK_EQUAL (range.start, 0.0f); + BOOST_CHECK_EQUAL (range.end, 1.0f); + BOOST_CHECK_CLOSE (value->get(), 0.5f, 0.0001); +} + +//============================================================================= +// MIDI CC: inputs value/cc/channel, a 0-127 control output, and a MIDI out. +BOOST_AUTO_TEST_CASE (MidiCCPorts) +{ + LuaFixture fix; + + ScriptLoader loader (fix.luaState()); + loader.load (presetScript ("midicc.lua")); + BOOST_REQUIRE_MESSAGE (! loader.hasError(), loader.getErrorMessage().toStdString()); + + auto result = loader.call(); + BOOST_REQUIRE (result.get_type() == sol::type::table); + sol::table tbl = result; + + DSPScript dsp (tbl); + BOOST_REQUIRE_MESSAGE (dsp.isValid(), "Could not instantiate midicc.lua"); + + const auto& ports = dsp.getPorts(); + BOOST_CHECK_EQUAL (ports.size (PortType::Audio, true), 0); + BOOST_CHECK_EQUAL (ports.size (PortType::Audio, false), 0); + BOOST_CHECK_EQUAL (ports.size (PortType::Midi, true), 0); + BOOST_CHECK_EQUAL (ports.size (PortType::Midi, false), 1); + BOOST_CHECK_EQUAL (ports.size (PortType::Control, true), 3); + BOOST_CHECK_EQUAL (ports.size (PortType::Control, false), 1); +} + +//============================================================================= +// MIDI CC processing: normalized value scales to 0-127 on both the MIDI CC +// message and the control output, and messages are only sent when it changes. +BOOST_AUTO_TEST_CASE (MidiCCProcess) +{ + LuaFixture fix; + + ScriptLoader loader (fix.luaState()); + loader.load (presetScript ("midicc.lua")); + BOOST_REQUIRE_MESSAGE (! loader.hasError(), loader.getErrorMessage().toStdString()); + + auto result = loader.call(); + sol::table tbl = result; + DSPScript dsp (tbl); + BOOST_REQUIRE_MESSAGE (dsp.isValid(), "Could not instantiate midicc.lua"); + + dsp.prepare (44100.0, 64); + + auto setInput = [&] (int index, float human) { + auto* p = ranged (dsp.getParameterObject (index, true)); + BOOST_REQUIRE (p != nullptr); + p->set (human); + }; + + // value = 1.0 (-> 127), cc number = 7, channel = 2. + setInput (0, 1.0f); + setInput (1, 7.0f); + setInput (2, 2.0f); + + AudioSampleBuffer audio (1, 64); + audio.clear(); + + auto runProcess = [&]() -> MidiBuffer { + MidiBuffer buffer; + MidiPipe pipe (buffer); + dsp.process (audio, pipe); + return buffer; + }; + + // First block: a CC message is emitted and the output control reads 127. + { + auto buffer = runProcess(); + + int count = 0; + MidiMessage msg; + for (const auto meta : buffer) + { + msg = meta.getMessage(); + ++count; + } + + BOOST_CHECK_EQUAL (count, 1); + BOOST_CHECK (msg.isController()); + BOOST_CHECK_EQUAL (msg.getChannel(), 2); + BOOST_CHECK_EQUAL (msg.getControllerNumber(), 7); + BOOST_CHECK_EQUAL (msg.getControllerValue(), 127); + + auto* out = ranged (dsp.getParameterObject (0, false)); + BOOST_REQUIRE (out != nullptr); + BOOST_CHECK_CLOSE (out->get(), 127.0f, 0.0001); + } + + // Second block with no change: nothing is emitted. + { + auto buffer = runProcess(); + BOOST_CHECK (buffer.isEmpty()); + } + + // Changing the value emits a new message with the new scaled value. + { + setInput (0, 0.0f); + auto buffer = runProcess(); + + int count = 0; + MidiMessage msg; + for (const auto meta : buffer) + { + msg = meta.getMessage(); + ++count; + } + + BOOST_CHECK_EQUAL (count, 1); + BOOST_CHECK_EQUAL (msg.getControllerValue(), 0); + + auto* out = ranged (dsp.getParameterObject (0, false)); + BOOST_REQUIRE (out != nullptr); + BOOST_CHECK_CLOSE (out->get(), 0.0f, 0.0001); + } +} + +BOOST_AUTO_TEST_SUITE_END()