Plugin Development Guide
EffeTune plugins have two cooperating parts:
- A JavaScript class under
plugins/owns the UI, parameter validation, preset data, and a reference processor used by compatibility mode and golden-vector generation. - A C++ kernel under
dsp/plugins/is the production audio processor. The generated parameter ABI connects the JavaScript host to the native and WebAssembly builds.
Routing, buses, channel selection, master bypass, and Section gating are host concerns. A DSP kernel processes the channel-major buffer it receives and does not implement those features again.
Files for a DSP Plugin
For a plugin named MyPlugin in category example, add or update:
plugins/example/my_plugin.js # UI and JavaScript reference DSP
plugins/example/my_plugin.css # only when custom UI styling is needed
dsp/plugins/example/my_plugin/params.json # generated ABI source
dsp/plugins/example/my_plugin/cases.json # reviewed parity matrix
dsp/plugins/example/my_plugin/kernel.cpp # production DSP
dsp/plugins/example/my_plugin/golden/ # generated JavaScript outputs
dsp/plugins/example/my_plugin/native_test.cpp # complex state/allocation tests, when needed
dsp/registry.inc # one registration line
js/audio/dsp-rollout.js # enable only after all parity gates pass
Keep the plugin list and user documentation in sync when adding a new plugin. Entries in
plugins/plugins.txt and plugin documentation are ordered alphabetically by category and
plugin name; Others and Control remain last.
1. Build the JavaScript Class
The class still extends PluginBase. It owns user-facing state and must implement
getParameters(), setParameters(), and createUI().
class MyPlugin extends PluginBase {
constructor() {
super('My Plugin', 'Short, factual description');
this.gn = 0;
this.registerProcessor(`
if (!parameters.enabled) return data;
const gain = 10 ** (parameters.gn / 20);
for (let i = 0; i < data.length; ++i) data[i] *= gain;
return data;
`);
}
getParameters() {
return {
type: this.constructor.name,
enabled: this.enabled,
gn: this.gn
};
}
setParameters(params) {
if (params.gn !== undefined) {
this.gn = this.parseFiniteNumber(params.gn, -18, 18, this.gn);
}
if (params.enabled !== undefined) this.enabled = Boolean(params.enabled);
this.updateParameters();
}
createUI() {
const container = document.createElement('div');
container.appendChild(this.createParameterControl(
'Gain', -18, 18, 0.1, this.gn,
value => this.setParameters({ gn: value }), 'dB'
));
return container;
}
}
window.MyPlugin = MyPlugin;
The registered processor is the behavioral reference. Keep it readable and deterministic:
- Return the input unchanged when disabled.
- Store persistent state on
contextand define exactly which changes reset it. - Use
parameters.channelCountandparameters.blockSize; audio is channel-major: all frames for channel 0, then all frames for channel 1, and so on. - Avoid per-sample allocation. Preallocate or reuse typed arrays for stateful reference DSP.
- Use
context.__seededRandom ?? Math.randomwhen the algorithm needs random values so golden generation can reproduce the sequence. - In hot signal-processing code, prefer a comparison or ternary expression to
Math.abs,Math.max, orMath.minwhen it is equally clear.
The reference processor must remain available while ?dsp=off and the documented
fallback policy are supported. Do not delete it merely because the C++ port is enabled.
2. Declare the Parameter ABI
params.json is the source for both the C++ parameter struct and the JavaScript packer.
Field keys must match the DSP-specific values returned by getParameters(). Do not
redeclare host-owned type, enabled, bus, or channel-routing fields in the schema.
{
"type": "MyPlugin",
"tolerance": { "abs": 0.000001, "policy": "per-sample" },
"fields": [
{
"name": "gainDb",
"key": "gn",
"kind": "float",
"min": -18,
"max": 18,
"default": 0,
"unit": "dB"
}
]
}
Supported numeric kinds include float, int, bool, and declared enums. Repeated
structured UI data uses an object-array field instead of handwritten packing logic. For
example, a five-band bands[i].gain parameter declares objectArrayKey, memberKey,
and count: 5. Use the top-level bounded structured descriptor only when the numeric
layout cannot represent the data.
Run code generation after every schema change:
npm run gen:dsp
Generated files under dsp/generated/ and js/audio/dsp-params.generated.js are
committed. Never edit them by hand.
3. Freeze the JavaScript Reference
Create cases.json before writing the kernel. Cases should cover:
- Constructor defaults and every enum or algorithm mode.
- Parameter boundaries and representative fractional values.
- Mono, stereo, supported multichannel routing, and odd block sizes.
- 44.1, 48, 96, and 192 kHz when sample rate affects the algorithm.
- Parameter events that distinguish state preservation from reset behavior.
- Silence, impulse, full-scale, or seeded noise where relevant.
- One-frame blocks for algorithms whose state update order is important.
Generate and self-check the golden set:
node tools/dsp-parity/generate.mjs --type MyPlugin
node tools/dsp-parity/run.mjs --type MyPlugin --self-check
The --type command is for focused iteration and does not update the shared PluginBase
golden guard. If PluginBase or another shared golden digest input changes, regenerate the
entire set atomically before handoff:
node tools/dsp-parity/generate.mjs --all
node tools/dsp-parity/run.mjs --self-check
--all writes and reads back every golden set in a temporary directory on the same
filesystem first. Only after every target and the shared guard are ready does it promote
the complete result. A generation or promotion failure leaves the previously committed
goldens and guard unchanged when filesystem rollback succeeds. Include every resulting
golden and guard change together.
Each plugin’s committed golden/ directory must remain within the 2 MiB budget. A golden
case must contain finite output unless the current plugin contract intentionally specifies
otherwise and the tolerance policy can evaluate it.
4. Implement the C++ Kernel
Use the generated struct and the registration macro:
#include "effetune/kernel.h"
#include "MyPluginParams.h"
#include <cmath>
#include <cstdint>
namespace effetune::plugins::example {
class MyKernel final : public PluginKernel {
EFFETUNE_PARAMS(generated::MyPluginParams)
public:
void prepare(const PrepareInfo& info) override {
max_channels_ = info.maxChannels;
max_frames_ = info.maxFrames;
}
void reset() noexcept override {}
void process(float* audio, std::uint32_t channel_count,
std::uint32_t frame_count, const ProcessInfo&) noexcept override {
if (audio == nullptr || channel_count == 0u || channel_count > max_channels_ ||
frame_count == 0u || frame_count > max_frames_) {
return;
}
const double gain = std::pow(10.0, static_cast<double>(params_.gainDb) / 20.0);
const std::uint32_t samples = channel_count * frame_count;
for (std::uint32_t index = 0u; index < samples; ++index) {
audio[index] = static_cast<float>(static_cast<double>(audio[index]) * gain);
}
}
private:
std::uint32_t max_channels_ = 0u;
std::uint32_t max_frames_ = 0u;
};
} // namespace effetune::plugins::example
EFFETUNE_REGISTER_KERNEL(MyPlugin, effetune::plugins::example::MyKernel)
Add the registry entry in category and plugin order:
EFFETUNE_PLUGIN(MyPlugin, example/my_plugin)
Real-Time Rules
- Allocate all worst-case buffers in
prepare().process()must not allocate, resize, lock, log, perform file I/O, or call into JavaScript. - Size for the ABI limits: up to eight channels, the prepared maximum block size, and the supported sample-rate range. If the legacy implementation overallocates, a smaller ring is acceptable only when it is mathematically equivalent and covered by wraparound tests.
- Validate pointers and prepared bounds before indexing. Invalid process shapes return without modifying audio.
- Preserve the JavaScript numeric boundaries. A
Float32Arraywrite is an explicitstatic_cast<float>in C++; ordinary JavaScript object fields normally retaindouble. - Match the reference update order. Feedback filters, delay lines, and accumulators can diverge quickly when rounding moves across an operation.
- Use shared primitives under
dsp/include/effetune/dsp/where their semantics match. Do not force a shared helper onto a legacy coefficient clamp or state rule that differs. - For hot DSP comparisons, use
ifor a ternary expression instead of generic min/max or absolute-value helpers when doing so remains readable. enabledis host-owned. Disabled instances are skipped before kernel dispatch.- Report algorithmic latency with
latencySamples(); do not hide lookahead in state.
Document large worst-case allocations in dsp/README.md. A tolerance above the
archetype default requires a toleranceNote in cases.json; benchmark results and
performance exceptions belong in tools/dsp-parity/BENCHMARKS.md.
5. Add Native State Tests
Golden parity covers rectangular audio cases. Add native_test.cpp when behavior also
depends on lifecycle transitions that the golden format cannot express cleanly:
reset()replay.- Sample-rate, channel-count, or topology changes.
- Parameters that must preserve state.
- Disabled modes that freeze rather than advance state.
- Maximum 192 kHz, eight-channel capacity.
- Latency changes and telemetry cadence.
Wrap every direct process() call in effetune::allocation_guard::Scope. Register the
test target in dsp/CMakeLists.txt with the same warning-as-error settings as neighboring
DSP tests.
6. Verify Native and WebAssembly Parity
Run the native suite and the plugin’s native parity cases first:
npm run test:dsp
node tools/dsp-parity/run.mjs --type MyPlugin --native
Then build both committed WebAssembly variants and test the actual artifacts:
npm run build:dsp
node tools/dsp-parity/run.mjs --type MyPlugin --wasm
node tools/dsp-parity/run.mjs --type MyPlugin --wasm --simd
Benchmark JavaScript, baseline WebAssembly, and SIMD at the required sample-rate and channel combinations:
node tools/dsp-parity/bench.mjs --type MyPlugin --modes js,wasm,simd
Record reviewed results in tools/dsp-parity/BENCHMARKS.md. A tolerance increase is not a
substitute for locating a state, coefficient, routing, or precision mismatch.
7. Enable the Port
Add the exact type to SHIPPED_ENABLED_TYPES in js/audio/dsp-rollout.js only after:
- Native, baseline WebAssembly, and SIMD parity pass.
- Stateful and allocation tests pass at maximum supported capacity.
- The benchmark and any justified exception are documented.
- The committed artifact metadata contains the matching type and parameter hash.
The host falls back to JavaScript per plugin instance when construction, parameter staging, or processing fails. Module or ABI startup failure disables WebAssembly for the session. Keep both fallback levels working; rollout is not permission to remove them.
Analyzer Telemetry
Analyzers and meters emit bounded binary telemetry frames from the kernel instead of attaching JavaScript measurement objects. Use the shared telemetry writer and assign a versioned frame type. Tests must cover:
- Header, tap ID, payload length, and finite-value validation.
- Cadence at 44.1, 48, 96, and 192 kHz.
- Overflow/drop accounting and malformed-frame rejection.
- The JavaScript adapter that updates the existing visualization state.
Do not post messages or allocate payloads from process().
UI and Parameter Requirements
- Prefer
PluginBasehelpers:createParameterControl(),createSelectControl(),createCheckboxControl(),createRadioGroup(), andcreateGraphContainer(). - Give inputs stable IDs and names containing the plugin instance ID, associate labels
with
htmlFor, and setautocomplete="off". - Validate with
parseFiniteNumber()andisAllowedEnum()or equivalent explicit checks. - Make custom controls usable at a 375 px viewport and with pointer, touch, and pen input.
- Cancel animation frames and remove listeners in
cleanup(). - Keep analyzer drawing on the main thread; only capture and reduction belong in the DSP kernel.
Final Checklist
Before handing the change back:
npm run gen:dsp
npm run test:dsp
npm run build:dsp
npm run assets:web
npm run verify
Also run the plugin’s native/baseline/SIMD parity commands, focused host tests, benchmark,
artifact freshness check, and git diff --check. Review the final diff with
code_review.md. Do not start the Electron app or a local server unless that manual run
was explicitly requested.
Further ABI, build, and parity details are maintained in the DSP README.