Lightningbeam/lightningbeam-ui/beamdsp/BEAMDSP.md

13 KiB

BeamDSP Language Reference

BeamDSP is a domain-specific language for writing audio processing scripts in Lightningbeam. Scripts are compiled to bytecode and run on the real-time audio thread with guaranteed bounded execution time and constant memory usage.

Quick Start

name "Simple Gain"
category effect

inputs {
    audio_in: audio
}

outputs {
    audio_out: audio
}

params {
    gain: 1.0 [0.0, 2.0] ""
}

process {
    for i in 0..buffer_size {
        audio_out[i * 2] = audio_in[i * 2] * gain;
        audio_out[i * 2 + 1] = audio_in[i * 2 + 1] * gain;
    }
}

Save this as a .bdsp file or create it directly in the Script Editor pane.

Script Structure

A BeamDSP script is composed of header blocks followed by a process block. All blocks are optional except name, category, and process.

name "Display Name"
category effect|generator|utility

inputs { ... }
outputs { ... }
params { ... }
state { ... }
ui { ... }
process { ... }

name

name "My Effect"

Sets the display name shown in the node graph.

category

category effect

One of:

  • effect — Processes audio (has inputs and outputs)
  • generator — Produces audio or CV (outputs only, no audio inputs)
  • utility — Signal routing, mixing, or other utility functions

inputs

Declares input ports. Each input has a name and signal type.

inputs {
    audio_in: audio
    mod_signal: cv
}

Signal types:

  • audio — Stereo interleaved audio (2 samples per frame: left, right)
  • cv — Mono control voltage (1 sample per frame, NaN when unconnected)

outputs

Declares output ports. Same syntax as inputs.

outputs {
    audio_out: audio
    env_out: cv
}

params

Declares user-adjustable parameters. Each parameter has a default value, range, and unit string.

params {
    frequency: 440.0 [20.0, 20000.0] "Hz"
    gain:      1.0   [0.0, 2.0]      ""
    mix:       0.5   [0.0, 1.0]      ""
}

Format: name: default [min, max] "unit"

Parameters appear as sliders in the node's UI. They are read-only inside the process block.

state

Declares persistent variables that survive across process calls. State is zero-initialized and can be reset.

state {
    phase: f32
    counter: int
    active: bool
    buffer: [44100]f32
    indices: [16]int
    clip: sample
}

Types:

Type Description
f32 32-bit float
int 32-bit signed integer
bool Boolean
[N]f32 Fixed-size float array (N is a constant)
[N]int Fixed-size integer array (N is a constant)
sample Loadable audio sample (stereo interleaved, read-only in process)

State arrays are allocated once at compile time and never resized. The sample type holds audio data loaded through the node's UI.

ui

Declares the layout of controls rendered below the node in the graph editor. If omitted, a default UI is generated with sliders for all parameters and pickers for all samples.

ui {
    sample clip
    param frequency
    param gain
    group "Mix" {
        param mix
    }
}

Elements:

Element Description
param name Slider for the named parameter
sample name Audio clip picker for the named sample state variable
group "label" { ... } Collapsible section containing child elements

process

The process block runs once per audio callback, processing all frames in the current buffer.

process {
    for i in 0..buffer_size {
        audio_out[i * 2] = audio_in[i * 2];
        audio_out[i * 2 + 1] = audio_in[i * 2 + 1];
    }
}

Types

BeamDSP has three scalar types:

Type Description Literal examples
f32 32-bit float 1.0, 0.5, 3.14
int 32-bit signed integer 0, 42, 256
bool Boolean true, false

Type conversions use cast syntax:

  • int(expr) — Convert float to integer (truncates toward zero)
  • float(expr) — Convert integer to float

Arithmetic between int and f32 promotes the result to f32.

Variables

Local variables

let x = 1.0;
let mut counter = 0;

Use let to declare a local variable. Add mut to allow reassignment. Local variables exist only within the current block scope.

Built-in variables

Variable Type Description
sample_rate int Audio sample rate in Hz (e.g., 44100)
buffer_size int Number of frames in the current buffer

Inputs and outputs

Input and output ports are accessed as arrays:

// Audio is stereo interleaved: [L0, R0, L1, R1, ...]
let left  = audio_in[i * 2];
let right = audio_in[i * 2 + 1];
audio_out[i * 2]     = left;
audio_out[i * 2 + 1] = right;

// CV is mono: one sample per frame
let mod_value = mod_in[i];
cv_out[i] = mod_value;

Input arrays are read-only. Output arrays are write-only.

Parameters

Parameters are available as read-only f32 variables:

audio_out[i * 2] = audio_in[i * 2] * gain;

State variables

State scalars and arrays are mutable and persist across calls:

state {
    phase: f32
    buffer: [1024]f32
}

process {
    phase = phase + 0.01;
    buffer[0] = phase;
}

Control Flow

if / else

if phase >= 1.0 {
    phase = phase - 1.0;
}

if value > threshold {
    audio_out[i * 2] = 1.0;
} else {
    audio_out[i * 2] = 0.0;
}

for loops

For loops iterate from 0 to an upper bound (exclusive). The loop variable is an immutable int.

for i in 0..buffer_size {
    // i goes from 0 to buffer_size - 1
}

for j in 0..len(buffer) {
    buffer[j] = 0.0;
}

The upper bound must be an integer expression. Typically buffer_size, len(array), or a constant.

There are no while loops, no recursion, and no user-defined functions. This is by design — it guarantees bounded execution time on the audio thread.

Operators

Arithmetic

Operator Description
+ Addition
- Subtraction (binary) or negation (unary)
* Multiplication
/ Division
% Modulo

Comparison

Operator Description
== Equal
!= Not equal
< Less than
> Greater than
<= Less than or equal
>= Greater than or equal

Logical

Operator Description
&& Logical AND
|| Logical OR
! Logical NOT (unary)

Built-in Functions

Trigonometric

Function Description
sin(x) Sine
cos(x) Cosine
tan(x) Tangent
asin(x) Arc sine
acos(x) Arc cosine
atan(x) Arc tangent
atan2(y, x) Two-argument arc tangent

Exponential

Function Description
exp(x) e^x
log(x) Natural logarithm
log2(x) Base-2 logarithm
pow(x, y) x raised to power y
sqrt(x) Square root

Rounding

Function Description
floor(x) Round toward negative infinity
ceil(x) Round toward positive infinity
round(x) Round to nearest integer
trunc(x) Round toward zero
fract(x) Fractional part (x - floor(x))

Clamping and interpolation

Function Description
abs(x) Absolute value
sign(x) Sign (-1.0, 0.0, or 1.0)
min(x, y) Minimum of two values
max(x, y) Maximum of two values
clamp(x, lo, hi) Clamp x to [lo, hi]
mix(a, b, t) Linear interpolation: a*(1-t) + b*t
smoothstep(edge0, edge1, x) Hermite interpolation between 0 and 1

Array

Function Description
len(array) Length of a state array (returns int)

CV

Function Description
cv_or(value, default) Returns default if value is NaN (unconnected CV), otherwise returns value

Sample

Function Description
sample_len(s) Number of frames in sample (0 if unloaded, returns int)
sample_read(s, index) Read sample data at index (0.0 if out of bounds, returns f32)
sample_rate_of(s) Original sample rate of the loaded audio (returns int)

Sample data is stereo interleaved, so frame N has left at index N*2 and right at N*2+1.

Comments

// This is a line comment
let x = 1.0; // Inline comment

Line comments start with // and extend to the end of the line.

Semicolons

Semicolons are optional statement terminators. You can use them or omit them.

let x = 1.0;    // with semicolons
let y = 2.0

audio_out[0] = x + y

Examples

Stereo Delay

name "Stereo Delay"
category effect

inputs {
    audio_in: audio
}

outputs {
    audio_out: audio
}

params {
    delay_time: 0.5 [0.01, 2.0] "s"
    feedback:   0.3 [0.0, 0.95] ""
    mix:        0.5 [0.0, 1.0]  ""
}

state {
    buffer: [88200]f32
    write_pos: int
}

ui {
    param delay_time
    param feedback
    param mix
}

process {
    let delay_samples = int(delay_time * float(sample_rate)) * 2;
    for i in 0..buffer_size {
        let l = audio_in[i * 2];
        let r = audio_in[i * 2 + 1];
        let read_pos = (write_pos - delay_samples + len(buffer)) % len(buffer);
        let dl = buffer[read_pos];
        let dr = buffer[read_pos + 1];
        buffer[write_pos] = l + dl * feedback;
        buffer[write_pos + 1] = r + dr * feedback;
        write_pos = (write_pos + 2) % len(buffer);
        audio_out[i * 2]     = l * (1.0 - mix) + dl * mix;
        audio_out[i * 2 + 1] = r * (1.0 - mix) + dr * mix;
    }
}

Sine Oscillator

name "Sine Oscillator"
category generator

outputs {
    audio_out: audio
}

params {
    frequency: 440.0 [20.0, 20000.0] "Hz"
    amplitude: 0.5   [0.0, 1.0]      ""
}

state {
    phase: f32
}

ui {
    param frequency
    param amplitude
}

process {
    let inc = frequency / float(sample_rate);
    for i in 0..buffer_size {
        let sample = sin(phase * 6.2831853) * amplitude;
        audio_out[i * 2] = sample;
        audio_out[i * 2 + 1] = sample;
        phase = phase + inc;
        if phase >= 1.0 {
            phase = phase - 1.0;
        }
    }
}

Sample Player

name "One-Shot Player"
category generator

outputs {
    audio_out: audio
}

params {
    speed: 1.0 [0.1, 4.0] ""
}

state {
    clip: sample
    phase: f32
}

ui {
    sample clip
    param speed
}

process {
    let frames = sample_len(clip);
    for i in 0..buffer_size {
        let idx = int(phase) * 2;
        audio_out[i * 2] = sample_read(clip, idx);
        audio_out[i * 2 + 1] = sample_read(clip, idx + 1);
        phase = phase + speed;
        if phase >= float(frames) {
            phase = 0.0;
        }
    }
}

CV-Controlled Filter (Tone Control)

name "Tone Control"
category effect

inputs {
    audio_in: audio
    cutoff_cv: cv
}

outputs {
    audio_out: audio
}

params {
    cutoff: 1000.0 [20.0, 20000.0] "Hz"
    resonance: 0.5 [0.0, 1.0] ""
}

state {
    lp_l: f32
    lp_r: f32
}

ui {
    param cutoff
    param resonance
}

process {
    for i in 0..buffer_size {
        let cv_mod = cv_or(cutoff_cv[i], 0.0);
        let freq = clamp(cutoff + cv_mod * 5000.0, 20.0, 20000.0);
        let rc = 1.0 / (6.2831853 * freq);
        let dt = 1.0 / float(sample_rate);
        let alpha = dt / (rc + dt);

        let l = audio_in[i * 2];
        let r = audio_in[i * 2 + 1];

        lp_l = lp_l + alpha * (l - lp_l);
        lp_r = lp_r + alpha * (r - lp_r);

        audio_out[i * 2] = lp_l;
        audio_out[i * 2 + 1] = lp_r;
    }
}

LFO

name "LFO"
category generator

outputs {
    cv_out: cv
}

params {
    rate:  1.0 [0.01, 20.0] "Hz"
    depth: 1.0 [0.0, 1.0]   ""
}

state {
    phase: f32
}

ui {
    param rate
    param depth
}

process {
    let inc = rate / float(sample_rate);
    for i in 0..buffer_size {
        cv_out[i] = sin(phase * 6.2831853) * depth;
        phase = phase + inc;
        if phase >= 1.0 {
            phase = phase - 1.0;
        }
    }
}

Safety Model

BeamDSP scripts run on the real-time audio thread. The language enforces safety through compile-time restrictions:

  • Bounded time: Only for i in 0..N loops with statically bounded N. No while loops, no recursion, no user-defined functions. An instruction counter limit (~10 million) acts as a safety net.
  • Constant memory: All state arrays have compile-time sizes. The VM uses a fixed-size stack (256 slots) and fixed locals (64 slots). No heap allocation occurs during processing.
  • Fail-silent: If the VM encounters a runtime error (stack overflow, instruction limit exceeded), all outputs are zeroed for that buffer. Audio does not glitch — it simply goes silent.

File Format

BeamDSP scripts use the .bdsp file extension. Files are plain UTF-8 text. You can export and import .bdsp files through the Script Editor pane or the node graph's script picker dropdown.