diff --git a/scripts/CMakeLists.txt b/scripts/CMakeLists.txt index a6df4b6d6..03b204939 100644 --- a/scripts/CMakeLists.txt +++ b/scripts/CMakeLists.txt @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2026 Kushview, LLC # SPDX-License-Identifier: GPL-3.0-or-later -file(GLOB ELEMENT_LUA_SCRIPTS "${CMAKE_CURRENT_SOURCE_DIR}/*.lua") +file(GLOB ELEMENT_LUA_SCRIPTS CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/*.lua") # Lua scripts are currently built into the the binaries until the app and plugins # are able to deal with search paths and so forth. if(TRUE) diff --git a/scripts/amp.lua b/scripts/amp.lua index ead1ecb65..e46f1c3f2 100644 --- a/scripts/amp.lua +++ b/scripts/amp.lua @@ -50,7 +50,8 @@ end return { type = 'DSP', layout = amp_layout, - process = amp_process + process = amp_process, + dspName = 'Amp' } -- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. diff --git a/scripts/channelize.lua b/scripts/channelize.lua index e53df9074..8937e9083 100644 --- a/scripts/channelize.lua +++ b/scripts/channelize.lua @@ -64,7 +64,8 @@ return { parameters = parameters, prepare = prepare, process = process, - release = release + release = release, + dspName = 'Channelizer' } -- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. diff --git a/scripts/dial.lua b/scripts/dial.lua index be16d3b95..6436b4f55 100644 --- a/scripts/dial.lua +++ b/scripts/dial.lua @@ -25,7 +25,8 @@ end return { type = 'DSP', layout = layout, - process = process + process = process, + dspName = 'Value' } -- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. diff --git a/scripts/midicc.lua b/scripts/midicc.lua index 77776bfe6..7f815a743 100644 --- a/scripts/midicc.lua +++ b/scripts/midicc.lua @@ -59,7 +59,8 @@ return { type = 'DSP', layout = layout, prepare = prepare, - process = process + process = process, + dspName = 'MIDI CC' } -- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. diff --git a/scripts/miditranspose.lua b/scripts/miditranspose.lua new file mode 100644 index 000000000..85abe59df --- /dev/null +++ b/scripts/miditranspose.lua @@ -0,0 +1,90 @@ +--- MIDI Transposer. +-- +-- This is a MIDI filter which shifts the note number of all Note On/Off +-- messages by a specified number of semitones. Set the transpose parameter +-- to '0' to bypass the filter. +-- +-- @script transpose +-- @type DSP +-- @license GPL v3 +-- @author Buzz Burrowes + +local io = require ('io') +local midiBuffer = require ('el.MidiBuffer') +local midi = require ('el.midi') +local script = require ('el.script') +local round = require ('el.round') + +local lastSemitones = 0 +local lastMidiChannelSeen = 1 + +-- Buffer to render filtered output +local output = midiBuffer.new() + +local function layout() + return { + audio = { 0, 0 }, + midi = { 1, 1 }, + control = {{ + { + name = "Transpose", + symbol = "transpose", + min = -24, + max = 24, + default = 0 + } + }} + } +end + +-- prepare for rendering +local function prepare() + -- reserve 128 bytes of memory and clear the output buffer + output:reserve (128) + output:clear() +end + +local function process (_, m, p) + -- Get MIDI input buffer from the MidiPipe + local input = m:get (1) + + -- Get the transpose amount from the parameter array, and round to integer + local semitones = round.integer (p[1]) + + output:clear() + + -- Send an allNotesOff message is the transposition has changed + if semitones ~= lastSemitones then + output:insertPacked (midi.controller (lastMidiChannelSeen, 123, 0), 0) + lastSemitones = semitones + end + + -- For each input message, shift the note number if it's a note on/off + for msg, frame in input:messages() do + if semitones ~= 0 and (msg:isNoteOn() or msg:isNoteOff()) then + local note = msg:note(msg) + semitones + -- clamp to valid MIDI note range + if note < 0 then note = 0 end + if note > 127 then note = 127 end + msg:setNote (note) + lastMidiChannelSeen = msg:channel() + end + output:insert (msg, frame) + end + + -- DSP scripts use replace processing, so swap in the rendered output + input:swap (output) +end + +return { + type = 'DSP', + layout = layout, + parameters = parameters, + prepare = prepare, + process = process, + release = release, + dspName = 'MIDI Transpose' +} + +-- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. +-- SPDX-License-Identifier: GPL-3.0-or-later diff --git a/scripts/mtc_generator.lua b/scripts/mtc_generator.lua index cb489f453..9dd861bcd 100644 --- a/scripts/mtc_generator.lua +++ b/scripts/mtc_generator.lua @@ -73,7 +73,8 @@ return { type = 'DSP', layout = layout, prepare = prepare, - process = process + process = process, + dspName = 'MIDI Timecode (MTC) Generator' } -- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. diff --git a/scripts/spontonchordchooser.lua b/scripts/spontonchordchooser.lua index 667b6f83f..3fcc5f737 100644 --- a/scripts/spontonchordchooser.lua +++ b/scripts/spontonchordchooser.lua @@ -203,6 +203,7 @@ return { type = 'DSP', layout = layout, process = process, + dspName = 'Spoton Scale Chooser' } -- SPDX-FileCopyrightText: Copyright (C) Lokki. diff --git a/scripts/testtone.lua b/scripts/testtone.lua index 4a571e707..a6788cbc5 100644 --- a/scripts/testtone.lua +++ b/scripts/testtone.lua @@ -65,7 +65,8 @@ return { type = 'DSP', layout = layout, prepare = prepare, - process = process + process = process, + dspName = 'Test Tone' } -- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. diff --git a/scripts/tremolo.lua b/scripts/tremolo.lua index 6d1d0c417..f14aeafbe 100644 --- a/scripts/tremolo.lua +++ b/scripts/tremolo.lua @@ -67,7 +67,8 @@ return { type = 'DSP', layout = layout, prepare = prepare, - process = process + process = process, + dspName = 'Tremolo' } -- SPDX-FileCopyrightText: Copyright (C) Kushview, LLC. diff --git a/src/nodes/scriptnode.cpp b/src/nodes/scriptnode.cpp index e62f53195..48b36e274 100644 --- a/src/nodes/scriptnode.cpp +++ b/src/nodes/scriptnode.cpp @@ -13,6 +13,7 @@ #include "scripting/bindings.hpp" #include "scripting/dspscript.hpp" #include "scripting/scriptloader.hpp" +#include "scripting/scriptregistry.hpp" #define EL_LUA_DBG(x) // #define EL_LUA_DBG(x) DBG(x) @@ -237,86 +238,37 @@ void ScriptNode::setParameter (int index, float value) } //============================================================================== +int ScriptNode::getNumPrograms() const +{ + return (int)ScriptRegistry::instance().getScripts().size(); +} + const String ScriptNode::getProgramName (int index) const { if (! juce::isPositiveAndBelow (index, getNumPrograms())) return {}; - switch (index) - { - case 0: - return "Amp"; - break; - case 1: - return "Channelizer"; - break; - case 2: - return "Spoton Scale Chooser"; - break; - 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"); - name << " " << int (index + 1); - return name; + return ScriptRegistry::instance().getScripts()[index].name; } void ScriptNode::setCurrentProgram (int index) { if (! juce::isPositiveAndBelow (index, getNumPrograms())) return; + _program = index; String newDspCode, newUiCode; + const BuiltInScripts& scriptInfo = ScriptRegistry::instance().getScripts()[index]; - switch (index) + newDspCode = String::fromUTF8 (scriptInfo.dspScript, scriptInfo.dspSize); + if (scriptInfo.uiSize > 0) + { + newUiCode = String::fromUTF8 (scriptInfo.uiScript, scriptInfo.uiSize); + } + else { - case 0: - newDspCode = String::fromUTF8 (scripts::amp_lua, scripts::amp_luaSize); - newUiCode = String::fromUTF8 (scripts::ampui_lua, scripts::ampui_luaSize); - break; - case 1: - newDspCode = String::fromUTF8 (scripts::channelize_lua, scripts::channelize_luaSize); - newUiCode.clear(); - break; - case 2: - newDspCode = String::fromUTF8 (scripts::spontonchordchooser_lua, scripts::spontonchordchooser_luaSize); - newUiCode.clear(); - break; - 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; + newUiCode.clear(); } dspCode.replaceAllContent (newDspCode); diff --git a/src/nodes/scriptnode.hpp b/src/nodes/scriptnode.hpp index ef3d3ccbb..b15403ac7 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 8; } + int getNumPrograms() const override; int getCurrentProgram() const override { return _program; } const String getProgramName (int index) const override; void setCurrentProgram (int index) override; diff --git a/src/scripting/scriptregistry.cpp b/src/scripting/scriptregistry.cpp new file mode 100644 index 000000000..208e008e8 --- /dev/null +++ b/src/scripting/scriptregistry.cpp @@ -0,0 +1,270 @@ +// Copyright 2026. Kushview, LLC +// Author: Buzz Burrowes + +#include +#include +#include +#include +#include "scriptregistry.hpp" +#include "luascripts.hpp" + +namespace element { + +namespace { + +// One raw embedded resource, resolved to its extension-stripped base name. +// `data`/`size` point directly at the static BinaryData buffer -- NOT +// necessarily null-terminated, so always paired with `size`. +struct RawResource +{ + std::string name; // e.g. "amp", "ampui", "channelize"... + const char* data; + int size; +}; + +std::string stripLuaExtension (const std::string& filename) +{ + static const std::string ext = ".lua"; + if (filename.size() > ext.size() + && filename.compare (filename.size() - ext.size(), ext.size(), ext) == 0) + return filename.substr (0, filename.size() - ext.size()); + return filename; +} + +std::string toUpperCopy (std::string s) +{ + for (auto& c : s) + c = static_cast (std::toupper (static_cast (c))); + return s; +} + +// Matches a the LAST line in the script containing ONLY "return {" (ignores +// whitespace). This is what anchors the start of the script's return block +// so the field scan below can't accidentally match an unrelated +// `type`/`dspName` local variable earlier in the file. +// +// NOTE: matched one line at a time via regex_match() rather than using the +// std::regex::multiline flag + ^/$ over the whole file -- MSVC's STL has +// never implemented std::regex::multiline (a long-standing gap versus +// libstdc++/libc++), so ^/$ are used here in their default, per-call meaning +// of "start/end of the string being matched", with that string being a +// single line. +const std::regex kReturnBlockStartLineRegex (R"(^[ \t]*return[ \t]*\{[ \t]*\r?$)"); + +// Matches e.g. type = 'DSP' or type="DSPUI" (either quote style, flexible whitespace). +const std::regex kTypeRegex (R"(\btype\s*=\s*['"]([^'"]+)['"])"); + +// Matches e.g. dspName = 'Amplifier' +const std::regex kDspNameRegex (R"(\bdspName\s*=\s*['"]([^'"]+)['"])"); + +/** Returns the substring of `source` starting right after the LAST line + containing only "return {" (see kReturnBlockStartLineRegex), or an empty + string if no such line is found. + + Scripts commonly contain earlier "return {" lines too (e.g. inside a + layout() helper function) -- only the final, module-level return block is + the one that actually declares this script's type/dspName, so every + matching line is checked and the last one found wins. +*/ +std::string extractReturnBlockRegion (const std::string& source) +{ + size_t pos = 0; + bool found = false; + size_t regionStart = 0; // valid only when found == true + + while (pos <= source.size()) + { + size_t newlinePos = source.find ('\n', pos); + std::string line = (newlinePos == std::string::npos) + ? source.substr (pos) + : source.substr (pos, newlinePos - pos); + + if (std::regex_match (line, kReturnBlockStartLineRegex)) + { + found = true; + regionStart = (newlinePos == std::string::npos) ? source.size() : newlinePos + 1; + // keep scanning -- do NOT return here, a later match should win + } + + if (newlinePos == std::string::npos) + break; + + pos = newlinePos + 1; + } + + return found ? source.substr (regionStart) : std::string(); +} + +/** Scans raw Lua source text for the `type = '...'` declaration in the + script's trailing return block (the text following a stand-alone + "return {" line) -- not anywhere else in the file. Returns the type + string (uppercased, e.g. "DSP" or "DSPUI") via outType, and any + `dspName = '...'` override via outDspNameOverride, if present. + + Returns false if no return block was found, or the return block has no + `type` field at all -- either way, the resource is not a node script + this registry cares about. + + NOTE: this is a lightweight text scan, not a real Lua parse -- it does + not execute the script. Given this codebase's convention of a single + return block at the very end of each script, anchoring on "return {" + is sufficient in practice. If that ever stops holding true, the robust + fix is to actually execute the chunk through sol2/lua_State and inspect + the returned table directly, rather than scanning text. +*/ +bool extractScriptType (const std::string& source, std::string& outType, std::string& outDspNameOverride) +{ + std::string region = extractReturnBlockRegion (source); + if (region.empty()) + return false; // no "return {" line found at all -- not a node script + + std::smatch typeMatch; + if (! std::regex_search (region, typeMatch, kTypeRegex)) + return false; + + outType = toUpperCopy (typeMatch[1].str()); + + std::smatch nameMatch; + if (std::regex_search (region, nameMatch, kDspNameRegex)) + outDspNameOverride = nameMatch[1].str(); + + return true; +} + +} // namespace + +ScriptRegistry& ScriptRegistry::instance() +{ + // Function-local static: constructed thread-safely on first call, + // avoids static initialization order issues entirely. + static ScriptRegistry registry; + return registry; +} + +ScriptRegistry::ScriptRegistry() +{ + // 1. Pull every embedded resource out of the generated BinaryData table + // and resolve it to a base name (original filename minus ".lua"). + std::vector raw; + raw.reserve (static_cast (scripts::namedResourceListSize)); + + for (int i = 0; i < scripts::namedResourceListSize; ++i) + { + const char* resourceName = scripts::namedResourceList[i]; + + int dataSize = 0; + const char* data = scripts::getNamedResource (resourceName, dataSize); + if (data == nullptr) + continue; // malformed entry, skip rather than crash + + const char* originalFilename = scripts::getNamedResourceOriginalFilename (resourceName); + std::string baseName = stripLuaExtension (originalFilename != nullptr ? originalFilename + : resourceName); + + raw.push_back ({ std::move (baseName), data, dataSize }); + } + + // Index by file-derived name for O(1) lookups while pairing DSP <-> UI + // scripts below. + std::unordered_map indexByName; + indexByName.reserve (raw.size()); + for (size_t i = 0; i < raw.size(); ++i) + indexByName.emplace (raw[i].name, i); + + // 2. Determine, up front and independent of iteration order, each raw + // resource's declared `type` (if any) and dspName override. This + // must be computed for EVERY resource before any UI-companion + // pairing decision below, since pairing needs to know whether the + // *candidate companion* positively declares itself as `DSPUI` -- + // not merely that it exists, and not merely that it isn't `DSP`. + std::vector isDspValid (raw.size(), false); + std::vector isUiValid (raw.size(), false); + std::vector dspNameOverride (raw.size()); + + for (size_t i = 0; i < raw.size(); ++i) + { + std::string source (raw[i].data, static_cast (raw[i].size)); + std::string type; + if (! extractScriptType (source, type, dspNameOverride[i])) + continue; // no return block / no type field -- not a node script + + isDspValid[i] = (type == "DSP"); + isUiValid[i] = (type == "DSPUI"); + } + + // 3. Decide UI companions. A resource named "ui" is treated as + // the UI companion of "" only if: + // a) "" is itself a valid DSP script (type == 'DSP'), AND + // b) "ui" is itself a valid UI script (type == 'DSPUI'). + // + // Requiring an explicit `type = 'DSPUI'` on the companion (rather + // than just "isn't DSP") means a same-named resource that happens to + // exist for some unrelated reason, or is malformed, or declares some + // other type entirely, is never mistaken for a real UI companion. + // It also means a genuine standalone DSP script that happens to be + // named e.g. "flexui.lua" is never silently swallowed as someone + // else's UI companion -- it surfaces as its own top-level entry, and + // (per this same rule applied to it) its own potential companion is + // looked up as "flexuiui.lua". + std::vector isUiCompanion (raw.size(), false); + + for (size_t i = 0; i < raw.size(); ++i) + { + if (! isDspValid[i]) + continue; + + auto it = indexByName.find (raw[i].name + "ui"); + if (it == indexByName.end()) + continue; + + size_t j = it->second; + if (isUiValid[j]) + isUiCompanion[j] = true; + } + + // 4. Build the final entry list: every resource that is a valid DSP + // script and is not itself consumed as another entry's UI companion + // becomes a top-level BuiltInScripts entry, with its UI companion + // (if any, per the rules above) attached and its display name + // resolved (dspName override, or filename-derived name as fallback). + names.reserve (raw.size()); // upper bound; guarantees c_str() stability below + scripts.reserve (raw.size()); + + for (size_t i = 0; i < raw.size(); ++i) + { + if (! isDspValid[i] || isUiCompanion[i]) + continue; + + names.push_back (dspNameOverride[i].empty() ? raw[i].name : dspNameOverride[i]); + + const char* uiScript = nullptr; + int uiSize = 0; + + auto it = indexByName.find (raw[i].name + "ui"); + if (it != indexByName.end() && isUiValid[it->second]) + { + uiScript = raw[it->second].data; + uiSize = raw[it->second].size; + } + + // dspSize/uiSize are const members, so the struct must be built in + // one aggregate-initialization step rather than default-constructed + // and assigned to afterward. + scripts.push_back (BuiltInScripts { names.back().c_str(), + raw[i].data, + raw[i].size, + uiScript, + uiSize }); + } +} + +const BuiltInScripts* ScriptRegistry::findByName (const char* name) const noexcept +{ + for (auto& s : scripts) + if (std::strcmp (s.name, name) == 0) + return &s; + + return nullptr; +} + +} // namespace element \ No newline at end of file diff --git a/src/scripting/scriptregistry.hpp b/src/scripting/scriptregistry.hpp new file mode 100644 index 000000000..949c92baf --- /dev/null +++ b/src/scripting/scriptregistry.hpp @@ -0,0 +1,104 @@ +// Copyright 2026. Kushview, LLC +// Author: Buzz Burrowes + +#pragma once + +#include +#include + +namespace element { + +/** A built-in Lua script pair: a DSP script and its optional companion UI script. + + Naming convention: DSP resource is ".lua", UI resource (if present) + is "ui.lua" -- e.g. "amp.lua" pairs with "ampui.lua". + + A script is only exposed here if its source contains a top-level return + block declaring `type = 'DSP'`, e.g.: + + return { + type = 'DSP', + layout = amp_layout, + process = amp_process + } + + That return block may also optionally declare `dspName = '...'`, which + overrides the display name (otherwise the DSP resource's filename, minus + the .lua extension, is used). + + A "ui" resource is only ever treated as 's UI companion if it + itself declares `type = 'DSPUI'` in its own return block. This means a + ui resource that happens to exist for some unrelated reason (or is + itself a standalone DSP script, or declares some other type entirely) is + never mistakenly swallowed as a companion -- see scriptregistry.cpp for + the full pairing rules. +*/ +struct BuiltInScripts +{ + const char* name; + const char* dspScript; + const int dspSize; + const char* uiScript; // nullptr if this script has no companion UI + const int uiSize; // 0 if uiScript is nullptr +}; + +/** Singleton registry of all built-in Lua scripts embedded into the binary via + juce_add_binary_data() (see scripts/CMakeLists.txt, NAMESPACE `scripts`) + that declare themselves as DSP script nodes. + + The script list is discovered entirely at runtime: + 1. Every embedded resource is enumerated from scripts::namedResourceList. + 2. Each resource's declared `type` (if any) is determined by scanning + its trailing return block -- 'DSP', 'DSPUI', or anything else + (which is dropped, e.g. view.lua's `type = 'View'`). + 3. Resources named "ui" are paired to "" as a UI companion + only when is a valid DSP script AND "ui" is itself a + valid DSPUI script -- never merely by name existing. + 4. If a DSP script's return block declares `dspName = '...'`, it is + used as the entry's display name; otherwise the filename-derived + name is used. + + Nothing is hardcoded, so new scripts dropped into scripts/ are picked up + automatically without touching this class. + + Populated lazily on first access. Thread-safe by virtue of C++11 + function-local static initialization guarantees (construction only -- + the underlying vectors are populated once during construction and never + mutated afterward, so concurrent reads via getScripts()/findByName() + after that first call are safe). +*/ +class ScriptRegistry +{ +public: + /** Returns the single shared instance, constructing it on first call. */ + static ScriptRegistry& instance(); + + /** All discovered built-in DSP scripts. */ + const std::vector& getScripts() const noexcept { return scripts; } + + /** Looks up a script by its display name (e.g. "amp", or its dspName + override if one was declared). Returns nullptr if not found. + + The returned pointer refers to storage owned by the registry and + remains valid for the lifetime of the application (the registry is + a function-local static that is never destroyed until program exit). + */ + const BuiltInScripts* findByName (const char* name) const noexcept; + + // Non-copyable, non-movable: there is exactly one registry. + ScriptRegistry (const ScriptRegistry&) = delete; + ScriptRegistry& operator= (const ScriptRegistry&) = delete; + +private: + ScriptRegistry(); + ~ScriptRegistry() = default; + + std::vector scripts; + + // Owns the display-name strings that BuiltInScripts::name points into. + // Capacity is reserved up front in the constructor so push_back never + // reallocates and invalidates c_str(). + std::vector names; +}; + +} // namespace element \ No newline at end of file