Synthesis

synth audio synthesis sound

Synthesis

The synthesis subsystem lets .cade configs declare oscillator-based sound effects without shipping audio files. Instead of referencing a .wav or .ogg clip, you define a synth patch — oscillators shaped by ADSR envelopes, optionally filtered and modulated — and trigger it from events. The audio engine generates PCM in real time and feeds it into the same mixer pipeline used by sample-based clips.

This page explains how a patch is put together. For the field-by-field reference — every attribute, type, and default — see Synth in the API docs.

When to Use Synth vs Audio Clips

Use caseApproach
Recorded sound effects, music, voice linesaudio_clip blocks with .wav / .ogg files
Procedural tones, beeps, electronic SFXsynth blocks (this page)
Dynamic pitch or velocity per eventa route source "synth" with from:event.* or expression bindings

Synth patches and audio clips coexist. A single event handler can trigger clips, synths, or both.

Defining a Synth Patch

A synth block declares a named patch at the top level of a .cade file. Each patch specifies one or more oscillators, an optional amplitude envelope, an optional biquad filter, optional modulation routing, and exactly one output block that wires the signal path explicitly:

synth "bumper_hit" {
  oscillator "carrier" {
    wave = "sine"
    freq = 440
  }

  envelope "amp" {
    attack  = 5      # milliseconds
    decay   = 40
    sustain = 0.3
    release = 120
  }

  output {
    in   = node.carrier.out   # the node whose signal becomes the voice
    gain = node.amp.out        # the amp envelope as the amplitude VCA
  }
}

When triggered, the patch allocates a voice from its pool, starts the envelope’s attack phase, and begins producing audio. When the envelope completes its release phase, the voice returns to the pool automatically.

The output block is the one required piece of wiring: in names the node that produces the voice’s sound, and the optional gain names the envelope that acts as its amplitude VCA. Port-wires reference a node’s current-sample output as node.<name>.out — where <name> is an oscillator, filter, or envelope label. (A node.<name>.z1 port reads the node’s previous sample, for feedback paths.)

The smallest patch

An amp envelope is optional. The minimal patch that makes a sound is a single oscillator wired to the output — no envelope, no gain. cade supplies a default amplitude gate (near-instant attack, full sustain, short release) so the voice still plays and releases cleanly:

synth "blip" {
  oscillator "carrier" {
    wave = "sine"
    freq = 660
  }

  output {
    in = node.carrier.out
  }
}

Add an amp envelope (and wire it into output.gain) when you want to shape the amplitude yourself.

Oscillator Waves

Four wave shapes are available:

WaveDescription
sinePure tone, no harmonics. Good for clean beeps and sub-bass.
sawRich harmonic content. Good for buzzy, aggressive tones.
squareOdd harmonics only. Hollow, retro sound.
triangleSoft odd harmonics. Warmer than sine, gentler than saw.

Envelopes (ADSR)

Envelopes shape a signal over time using the classic ADSR (Attack, Decay, Sustain, Release) curve:

Level
1.0 ┤    /\
    │   /  \
    │  /    \___________
0.3 ┤ /     |  sustain  \
    │/      |            \
0.0 ┤ A   D    S       R
    └───────────────────── Time
AttributeTypeDescription
attacknumberMilliseconds to ramp from silence to full level (e.g., 5).
decaynumberMilliseconds to fall from full level to the sustain level (e.g., 40).
sustainfloatHold level while the note is active, 0.0–1.0 (e.g., 0.3).
releasenumberMilliseconds to fade from sustain level to silence after note-off (e.g., 120).
depthfloatModulation range in Hz for pitch or filter targets (optional, default 0).

Envelope times are plain numbers in milliseconds — write attack = 5, not attack = "5ms". (Older string-with-unit values still load, but numbers are the current form.)

Named envelopes

Each envelope has a name specified as the block label. The "amp" envelope controls amplitude; authoring one is optional (see The smallest patch), but a patch has at most one. Additional envelopes can modulate other targets like oscillator frequency or filter cutoff via modulate blocks.

envelope "amp" {
  attack  = 5
  decay   = 40
  sustain = 0.3
  release = 120
}

envelope "pitch" {
  attack  = 1
  decay   = 50
  sustain = 0.0
  release = 10
  depth   = 400
}

The depth attribute specifies the modulation range in the target’s units (Hz for pitch and filter cutoff). The effective modulation value at any instant is envelope_level × depth. The amplitude envelope ignores depth.

The unlabeled envelope

An envelope block with no label is automatically named "amp". An unlabeled envelope { } is therefore treated as the amplitude envelope.

Modulation

The modulate block routes a source signal into a target parameter. Two types of modulation are supported: oscillator-to-oscillator FM synthesis and envelope-driven parameter sweeps.

FM Modulation

A synth patch can route one oscillator’s output into another oscillator’s frequency for FM (frequency modulation) synthesis. This produces complex timbres — metallic hits, bell tones, evolving textures — from just two oscillators.

synth "metallic_hit" {
  oscillator "carrier" {
    wave = "sine"
    freq = 440
  }

  oscillator "mod" {
    wave = "sine"
    freq = 80
    amp  = 120
  }

  modulate {
    source = "mod"
    target = "carrier.freq"
  }

  envelope "amp" {
    attack  = 1
    decay   = 60
    sustain = 0.1
    release = 200
  }

  output {
    in   = node.carrier.out
    gain = node.amp.out
  }
}

The modulator’s amp controls modulation depth in Hz. In this example, the carrier’s instantaneous frequency sweeps ±120 Hz around 440 Hz at a rate of 80 Hz, producing sidebands that give the sound a metallic character.

Envelope Modulation

A named envelope can also modulate oscillator frequency or filter cutoff. The envelope’s depth attribute sets the modulation range in Hz, and the ADSR shape controls how the value changes over time.

envelope "pitch" {
  attack  = 1
  decay   = 50
  sustain = 0.0
  release = 10
  depth   = 400
}

modulate {
  source = "pitch"
  target = "carrier.freq"
}

This produces a pitch sweep: the carrier frequency rises by 400 Hz during the 1 ms attack, then falls back to its base frequency over the 50 ms decay (since sustain is 0.0).

Modulation Targets

TargetSource typeDescription
<oscillator>.freqoscillator or envelopeModulates the oscillator’s instantaneous frequency.
filter.cutoffenvelope onlyModulates the filter’s cutoff frequency (see Filter Cutoff Modulation).

Biquad Filter

An optional filter block adds a biquad filter to the voice signal chain. Wire the oscillator into the filter’s in port, then route the voice output through the filter (output { in = node.filter.out … }), so the raw oscillator signal is shaped before it reaches the amplitude VCA:

synth "filtered_saw" {
  oscillator "osc" {
    wave = "saw"
    freq = 220
  }

  filter {
    type      = "lowpass"
    cutoff    = 2000
    resonance = 0.5
    in        = node.osc.out
  }

  envelope "amp" {
    attack  = 5
    decay   = 40
    sustain = 0.3
    release = 120
  }

  output {
    in   = node.filter.out
    gain = node.amp.out
  }
}

Filter Types

TypeDescription
lowpassPasses frequencies below the cutoff, attenuates above. Useful for taming bright oscillators and creating warm tones.
highpassPasses frequencies above the cutoff, attenuates below. Useful for thinning out bass-heavy signals.

Filter Attributes

AttributeTypeDescription
typestringRequired. "lowpass" or "highpass".
cutofffloatRequired. Cutoff frequency in Hz. Must be greater than 0 and no higher than half the sample rate (Nyquist).
resonancefloatRequired. Resonance amount, 0.0–1.0. Higher values create a sharper peak at the cutoff frequency.
inwireSignal input, node.<name>.out.
cutoff_modwireControl-rate cutoff offset wire, added to the base cutoff.

Filter Cutoff Modulation

A named envelope can drive the filter cutoff frequency, producing expressive filter sweeps — a fundamental technique in subtractive synthesis for plucks, wah effects, and evolving pads. Add the envelope and a modulate block targeting "filter.cutoff":

synth "filter_sweep" {
  oscillator "carrier" {
    wave = "saw"
    freq = 880
  }

  envelope "amp" {
    attack  = 2
    decay   = 30
    sustain = 0.2
    release = 80
  }

  envelope "cutoff_env" {
    attack  = 1
    decay   = 60
    sustain = 0.3
    release = 100
    depth   = 2000
  }

  filter {
    type      = "lowpass"
    cutoff    = 3000
    resonance = 0.7
    in        = node.carrier.out
  }

  modulate {
    source = "cutoff_env"
    target = "filter.cutoff"
  }

  output {
    in   = node.filter.out
    gain = node.amp.out
  }
}

The modulated cutoff at any instant is:

modulatedCutoff = baseCutoff + (envelopeLevel × depth)

With the settings above: during attack, the cutoff sweeps from 3000 to 5000 Hz (3000 + 2000). During sustain (level 0.3), it settles at 3600 Hz (3000 + 2000 × 0.3). The cutoff is clamped to valid frequencies (20 Hz to half the sample rate).

Filter coefficients are recalculated once per audio buffer rather than per sample, keeping the per-sample processing path efficient while maintaining perceptually smooth sweeps.

Polyphony

Each synth patch owns a pre-allocated pool of voices. When multiple events trigger the same patch before earlier voices finish, each trigger gets its own voice from the pool. The default pool size is 64 voices.

synth "bumper_hit" {
  oscillator "carrier" {
    wave = "sine"
    freq = 440
  }

  envelope "amp" {
    attack  = 5
    decay   = 40
    sustain = 0.3
    release = 120
  }

  output {
    in   = node.carrier.out
    gain = node.amp.out
  }

  polyphony = 32
}
AttributeDefaultRangeDescription
polyphony641–128Maximum simultaneous voices for this patch.

When all voices are in use, the oldest active voice is stolen (recycled) for the new trigger. Voice stealing uses LRU (least recently used) ordering, so the voice triggered longest ago is replaced first.

Triggering Synths from Events

Synth patches are triggered by a top-level route whose owners name an event_handler. The route carries a source "synth" whose second label names the patch to play. A handler that owns nothing but its route is valid — the route still fires.

event_handler "bumper_hit_sound" {
  when = device.pop_bumper_1.hit
}

route {
  owners = [event_handler.bumper_hit_sound]

  source "synth" "bumper_hit" {
    weight   = 1
    pitch    = 440
    velocity = 0.8
  }
}

Pitch and Velocity

pitch (frequency in Hz) and velocity (a 0.01.0 amplitude) can each be a literal, an event-property lookup, or an expression evaluated against live game state — the form is chosen automatically from the value you write.

# Literal — fixed values
event_handler "bumper_hit_sound" {
  when = device.pop_bumper_1.hit
}

route {
  owners = [event_handler.bumper_hit_sound]

  source "synth" "bumper_zap" {
    weight   = 1
    pitch    = 880
    velocity = 0.8
  }
}

# Reactive — read off the event and scale with game state
event_handler "target_hit_sound" {
  when = device.target.hit
}

route {
  owners = [event_handler.target_hit_sound]

  source "synth" "target_tone" {
    weight   = 1
    pitch    = "from:event.pitch"
    velocity = min(0.3 + score / 20000, 1.0)
  }
}

By default a routed synth source is one-shot — attack → decay → release with no held note. Add duration = "0.5s" to hold the voice for a fixed time before the tail, or sustain = true to hold it until the triggering switch releases (its device.<name>.released edge). See Runtime Bindings for the binding forms, the namespace (score, var.<name>, signal.<name>, event.<field>), and the evaluation model.

Dispatch Flow

When an event matches a synth-targeted handler:

  1. The dispatcher looks up the compiled synth patch by name
  2. A voice is allocated from the patch’s pool (or the oldest voice is stolen if the pool is full)
  3. The voice is triggered with the resolved pitch and velocity
  4. The voice is registered with the mixer as an audio source
  5. The mixer includes the voice’s PCM output in its mix
  6. When the envelope completes (voice becomes inactive), the mixer removes it and the voice returns to the pool

A route whose source kind is synth dispatches to the synth path; a clip source dispatches to the clip path.

Incomplete patches parse but don’t compile. A partly-wired synth — a filter with no signal input, or an output missing its in — is accepted by the parser and stored, so an editor can build a patch up step by step, and is rejected only at compile time, when the voice must make sound, with a specific reason. A config that parses is not guaranteed to play until it compiles cleanly.

Complete Example

A bumper that plays a synthesized zap on every hit — a sawtooth oscillator through a low-pass filter with a pitch sweep and filter cutoff envelope, triggered with per-event velocity:

synth "bumper_zap" {
  oscillator "carrier" {
    wave = "saw"
    freq = 880
  }

  envelope "amp" {
    attack  = 2
    decay   = 30
    sustain = 0.2
    release = 80
  }

  envelope "pitch" {
    attack  = 1
    decay   = 50
    sustain = 0.0
    release = 10
    depth   = 400
  }

  envelope "cutoff_env" {
    attack  = 1
    decay   = 60
    sustain = 0.3
    release = 100
    depth   = 2000
  }

  filter {
    type      = "lowpass"
    cutoff    = 3000
    resonance = 0.7
    in        = node.carrier.out
  }

  modulate {
    source = "pitch"
    target = "carrier.freq"
  }

  modulate {
    source = "cutoff_env"
    target = "filter.cutoff"
  }

  output {
    in   = node.filter.out
    gain = node.amp.out
  }

  polyphony = 64
}

device "switch" "bumper" "pop_bumper_1" {
  id = 0x20
}

event_handler "bumper_hit_sound" {
  when = device.pop_bumper_1.hit
}

route {
  owners = [event_handler.bumper_hit_sound]

  source "synth" "bumper_zap" {
    weight   = 1
    pitch    = 880
    velocity = 0.8
  }
}

This patch produces a short, punchy zap: the pitch sweeps down 400 Hz from the carrier frequency, the filter cutoff sweeps from 5000 Hz down to 3600 Hz, and the amplitude fades out over 80 ms.

Current Limitations

  • Frequency and cutoff modulation only — modulation targets are limited to <oscillator>.freq and filter.cutoff. Amplitude modulation and phase modulation are not yet supported.
  • Two filter types — only lowpass and highpass biquad filters are available. Bandpass, notch, reverb, delay, and other effects are not yet supported.
  • No hot-reload — synth patches are compiled at engine startup. Changing a synth definition requires restarting the engine.
  • No spatialization — all synth output is mono, mixed into the synth channel.