Synthesis

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.

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 eventsynth with from:event.* 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, one or more named envelopes, an optional biquad filter, and optional modulation routing.

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

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

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.

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)

Every synth patch requires at least one named envelope. 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
attackdurationTime to ramp from silence to full level (e.g., "5ms").
decaydurationTime to fall from full level to the sustain level (e.g., "40ms").
sustainfloatHold level while the note is active, 0.0–1.0 (e.g., 0.3).
releasedurationTime to fade from sustain level to silence after note-off (e.g., "120ms").
depthfloatModulation range in Hz for pitch or filter targets (optional, default 0).

Durations use Go duration syntax: "5ms", "1s", "100us", etc.

Named envelopes

Each envelope has a name specified as the block label. Every synth must have exactly one envelope named "amp", which controls amplitude. Additional envelopes can modulate other targets like oscillator frequency or filter cutoff via modulate blocks.

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

envelope "pitch" {
  attack = "1ms"  decay = "50ms"  sustain = 0.0  release = "10ms"
  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.

Backwards compatibility

An envelope block with no label is automatically assigned the name "amp". This means existing v1 patches with an unlabeled envelope { } continue to work without changes.

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  = "1ms"
    decay   = "60ms"
    sustain = 0.1
    release = "200ms"
  }
}

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 = "1ms"  decay = "50ms"  sustain = 0.0  release = "10ms"
  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. The filter processes each sample after oscillator mixing but before amplitude envelope application, shaping the timbre of the raw oscillator output.

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

  filter {
    type      = "lowpass"
    cutoff    = 2000
    resonance = 0.5
  }

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

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.

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.

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

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

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

  filter {
    type      = "lowpass"
    cutoff    = 3000
    resonance = 0.7
  }

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

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 = "5ms" decay = "40ms" sustain = 0.3 release = "120ms" }
  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 through on_event blocks. The event handler routes matching events to either the clip playback path or the synth dispatch path.

on_event "bumper_hit" {
  synth {
    name     = "bumper_hit"
    pitch    = 440
    velocity = 0.8
  }
}

Dynamic Pitch and Velocity

Synth parameters can be literals, event-property bindings, or full expressions that reference game state.

Literal values

on_event "bumper_hit" {
  synth {
    name     = "bumper_zap"
    pitch    = 880
    velocity = 0.8
  }
}

Event-property bindings

Read values directly from the event’s data payload using from:event.<field> syntax:

on_event "target_hit" {
  synth {
    name     = "target_tone"
    pitch    = "from:event.pitch"
    velocity = "from:event.velocity"
  }
}

This lets a single synth patch produce different pitches depending on which target was hit or how hard a bumper was struck.

Expression bindings

Any string that isn’t a from:event.* lookup is evaluated as an expression against runtime state. The available namespace:

NamespaceDescription
event.<key>Fields from the current event’s data payload.
scoreCurrent player’s score as a number.
var.<name>Current value of any declared variable.
signal.<name>1.0 if the named signal is active, 0.0 otherwise.

Expressions can mix these with arithmetic, comparisons, and the full expression library:

on_event "combo_shot" {
  synth {
    name     = "combo_tone"
    # Pitch rises with the current combo count
    pitch    = "440 + var.combo_count * 55"
    # Louder when multiball is active
    velocity = "signal.multiball_active ? 1.0 : 0.6"
  }
}

on_event "jackpot" {
  synth {
    name     = "jackpot_stab"
    # Brighter stab for higher scores
    pitch    = "220 + min(score / 10000, 2000)"
    velocity = 1.0
  }
}

Expressions are compiled once at config load and evaluated each time the event fires. Literal numeric values and from:event.* lookups stay on a fast path — they do not go through expression evaluation.

See Runtime Bindings for the full expression namespace reference.

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

The existing clip playback path is unchanged. A handler with a synth block dispatches to the synth path; a handler with a clip or clips block dispatches to the clip path as before.

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 = "2ms"  decay = "30ms"  sustain = 0.2  release = "80ms"
  }

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

  filter {
    type      = "lowpass"
    cutoff    = 3000
    resonance = 0.7
  }

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

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

  polyphony = 64
}

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

on_event "bumper_hit" {
  synth {
    name     = "bumper_zap"
    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