Skip to content

DSP Instruments

An effect processes audio that already exists; an instrument makes it. A dsp instrument block is a synthesizer you write yourself — it receives note events from a pattern and turns them into sound, one sample at a time, across as many voices as you play at once. Think of it as building your own software synth, except the oscillator and envelope are just code you can see and change.

If you’ve read DSP Effects, the building blocks here are the same — param, state, buffer, helper functions — with one new idea: a voice.

A sine oscillator is the “hello world” of synthesis. Execute this and you’ll hear four notes play through a synth you just built:

dsp instrument SineOsc {
voice state phase: 0.0;
fn render(note, velocity, gate) -> (out_l, out_r) {
let freq = __native("mtof", note);
phase = __native("fract", phase + freq * INV_SR);
let sample = __native("sin", phase * TWOPI) * velocity * gate;
return (sample, sample);
}
}
let synth = AudioTrack("synth");
synth.load_instrument(SineOsc());
synth << [c4 e4 g4 c5];
PLAY;

Walking through it: fn render(note, velocity, gate) -> (out_l, out_r) is to an instrument what process is to an effect. It runs once per sample, for each active voice, and returns a stereo sample. Instead of audio input it receives the note being played:

ParameterDescription
noteMIDI note number — fractional values are allowed, for microtonal pitches
velocityHow hard the note was struck, normalized to 0.01.0
gate1.0 while the note is held, 0.0 after note-off
let freq = __native("mtof", note);
phase = __native("fract", phase + freq * INV_SR);
let sample = __native("sin", phase * TWOPI) * velocity * gate;

__native("mtof", note) converts the MIDI note number into a frequency in Hz. Each sample we advance phase by freq * INV_SR (one cycle’s worth divided by the sample rate) and wrap it back into 0..1 with fract. Feeding phase * TWOPI into sin gives the waveform; multiplying by velocity and gate shapes the loudness. As with effects, your editor’s LSP lists every __native builtin with its signature on hover — lean on that instead of memorizing them.

Play a chord and three notes sound at once — three voices, each running its own copy of render. A voice state variable like voice state phase: 0.0; is private to one voice and resets to its initial value on every note-on, which is exactly what an oscillator’s phase or an envelope’s level needs: each note starts fresh.

There is also a plain state (for example state master_vol: 1.0;):

param works just as it does in effects — param cutoff: 4000 range(20, 20000); declares a value with a default and optional range, readable inside render and controllable from outside. Parameters are shared across all voices.

Put an oscillator, an amplitude envelope, and a lowpass filter together and you have something playable:

dsp instrument FilteredSynth {
param attack: 0.01 range(0.001, 1.0);
param release: 0.2 range(0.01, 2.0);
param cutoff: 4000 range(20, 20000);
voice state phase: 0.0;
voice state env: 0.0;
fn render(note, velocity, gate) -> (out_l, out_r) {
// Oscillator
let freq = __native("mtof", note);
phase = __native("fract", phase + freq * INV_SR);
let osc = __native("sin", phase * TWOPI);
// Envelope — rises toward velocity while held, falls after note-off
let target = gate * velocity;
if gate > 0.0 {
env = env + (target - env) * attack;
} else {
env = env + (target - env) * release;
}
// Lowpass filter
let filtered = __native("svf_lp", osc * env, cutoff, 1.5);
return (filtered, filtered);
}
}
let lead = AudioTrack("lead");
lead.load_instrument(FilteredSynth());
lead << [c4 _ e4 _ g4 _ c5 _];
PLAY;

The gate parameter does the heavy lifting in the envelope: it’s 1.0 while a note is held and drops to 0.0 on release, so the same env line smoothly fades the tail out. __native("svf_lp", input, cutoff, resonance) is the built-in state variable lowpass filter.

Like effects, instruments can declare a buffer — a fixed-size array in each voice’s state, initialized to zero, indexed with automatic modulo wrapping. Declare it with buffer delay_buf(4800);, then write with buf[write_pos] = sample; and read with let delayed = buf[read_pos];. Every voice gets its own copy, so a per-voice delay or wavetable stays independent across notes.

Break a long render into named pieces with extra fn declarations. They can read and write the instrument’s state, voice state, buffer, and param variables, and they’re inlined with zero overhead:

dsp instrument PulseOsc {
param duty: 0.5 range(0.01, 0.99);
voice state phase: 0.0;
fn pulse(p, d) -> out {
if p < d {
return 1.0;
} else {
return 0.0 - 1.0;
}
}
fn render(note, velocity, gate) -> (out_l, out_r) {
let freq = __native("mtof", note);
phase = __native("fract", phase + freq * INV_SR);
let sample = pulse(phase, duty) * velocity * gate;
return (sample, sample);
}
}
let pulse = AudioTrack("pulse");
pulse.load_instrument(PulseOsc());
pulse << [c4 e4 g4 c5];
PLAY;

To reuse a helper across several instruments, or give it its own state, promote it to a top-level dsp fn or dsp object — see Signals, Functions & Objects.

DSP instruments are polyphonic with 16 voices, and allocation is automatic — you don’t manage voices by hand:

  • Allocation — each new note takes the first inactive voice.
  • Voice stealing — when all 16 voices are busy, the quietest one is stolen for the new note.
  • Release — on note-off, gate drops to 0.0; the voice keeps running until its output falls below the silence threshold, then it’s freed. That’s what lets release tails ring out naturally.

Because note is a plain number — and mtof accepts fractional values — any microtonal pitch flows straight through to your oscillator. Build a scale with fractional semitone steps and the exact frequencies reach the synth:

dsp instrument SineOsc {
voice state phase: 0.0;
fn render(note, velocity, gate) -> (out_l, out_r) {
let freq = __native("mtof", note);
phase = __native("fract", phase + freq * INV_SR);
let sample = __native("sin", phase * TWOPI) * velocity * gate;
return (sample, sample);
}
}
let neutral_scale = Scale(#[0, 1.5, 3.5, 5, 7, 8.5, 10]);
let micro_key = Key(C4, neutral_scale);
let micro = AudioTrack("microtonal");
micro.load_instrument(SineOsc());
micro << <^1 ^2 ^3 ^4 ^5 ^6 ^7 ^1>.in_key(micro_key);
PLAY;

Scale degree 2 here is mtof(61.5) — a quarter-tone between C4 and C#4.

Instrument parameters are reachable from outside the block, the same way effect parameters are. Set them directly, or bind a signal with << to modulate them per sample:

use "std/signals" { Sine, automation };
dsp instrument FilteredSynth {
param attack: 0.01 range(0.001, 1.0);
param release: 0.2 range(0.01, 2.0);
param cutoff: 4000 range(20, 20000);
voice state phase: 0.0;
voice state env: 0.0;
fn render(note, velocity, gate) -> (out_l, out_r) {
let freq = __native("mtof", note);
phase = __native("fract", phase + freq * INV_SR);
let osc = __native("sin", phase * TWOPI);
let target = gate * velocity;
if gate > 0.0 { env = env + (target - env) * attack; }
else { env = env + (target - env) * release; }
let filtered = __native("svf_lp", osc * env, cutoff, 1.5);
return (filtered, filtered);
}
}
let synth = FilteredSynth();
let lead = AudioTrack("lead");
lead.load_instrument(synth);
lead << [c4 _ e4 _ g4 _ c5 _];
synth.params(); // print every parameter
synth.param_set("cutoff", 2000); // set by name
synth.param_set("attack", 0.1).param_set("release", 0.5); // chainable
synth.param("cutoff") << Sine(2).range_exp(200, 8000); // LFO modulation
synth.param("cutoff") << automation(#[0, 0], #[4, 1]).range_exp(200, 8000); // automation
PLAY;

.param(name) returns a reference you bind with <<; any number, signal, or automation works, and rebinding replaces the previous modulation. The handful of parameter methods:

MethodDescription
.params()Print every parameter as a table (range, default, value)
.param(name)Get a parameter reference (bind with <<)
.param_get(name)Read a parameter’s current value
.param_set(name, value)Set a parameter (chainable)
.param_set_norm(name, value)Set using a normalized 01 value (chainable)

This is the same modulation system effects use — see Signals & Automation for the full range of LFOs, ramps, and envelopes you can bind here.

If your instrument introduces a fixed processing delay, declare it with latency: so the engine can keep tracks aligned (plugin delay compensation):

dsp instrument LatentSynth {
latency: 256;
voice state phase: 0.0;
fn render(note, velocity, gate) -> (out_l, out_r) {
let freq = __native("mtof", note);
phase = __native("fract", phase + freq * INV_SR);
let sample = __native("sin", phase * TWOPI) * velocity * gate;
return (sample, sample);
}
}

The declared latency is added to whatever the engine computes from the instrument’s internal graph; it defaults to 0.

You can now build polyphonic instruments with envelopes, filters, and modulated parameters. From here: