614 lines
13 KiB
Markdown
614 lines
13 KiB
Markdown
# 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.
|