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.
Your First Instrument
Section titled “Your First Instrument”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:
| Parameter | Description |
|---|---|
note | MIDI note number — fractional values are allowed, for microtonal pitches |
velocity | How hard the note was struck, normalized to 0.0–1.0 |
gate | 1.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.
Voices and Voice State
Section titled “Voices and Voice State”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;):
Parameters
Section titled “Parameters”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.
A Fuller Synth: Envelope and Filter
Section titled “A Fuller Synth: Envelope and Filter”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.
Buffers
Section titled “Buffers”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.
Helper Functions
Section titled “Helper Functions”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.
Voice Management
Section titled “Voice Management”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,
gatedrops to0.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.
Microtonal Pitches
Section titled “Microtonal Pitches”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.
Controlling and Modulating Parameters
Section titled “Controlling and Modulating Parameters”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 parametersynth.param_set("cutoff", 2000); // set by namesynth.param_set("attack", 0.1).param_set("release", 0.5); // chainable
synth.param("cutoff") << Sine(2).range_exp(200, 8000); // LFO modulationsynth.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:
| Method | Description |
|---|---|
.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 0–1 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.
Declaring Latency
Section titled “Declaring Latency”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.
Next Steps
Section titled “Next Steps”You can now build polyphonic instruments with envelopes, filters, and modulated parameters. From here:
- Signals, Functions & Objects — share DSP code across instruments, and write your own modulation sources
- DSP Effects — process the sound your instruments make
- Signals & Automation — every way to drive a parameter over time