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 case | Approach |
|---|---|
| Recorded sound effects, music, voice lines | audio_clip blocks with .wav / .ogg files |
| Procedural tones, beeps, electronic SFX | synth blocks (this page) |
| Dynamic pitch or velocity per event | synth 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:
| Wave | Description |
|---|---|
sine | Pure tone, no harmonics. Good for clean beeps and sub-bass. |
saw | Rich harmonic content. Good for buzzy, aggressive tones. |
square | Odd harmonics only. Hollow, retro sound. |
triangle | Soft 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
| Attribute | Type | Description |
|---|---|---|
attack | duration | Time to ramp from silence to full level (e.g., "5ms"). |
decay | duration | Time to fall from full level to the sustain level (e.g., "40ms"). |
sustain | float | Hold level while the note is active, 0.0–1.0 (e.g., 0.3). |
release | duration | Time to fade from sustain level to silence after note-off (e.g., "120ms"). |
depth | float | Modulation 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
| Target | Source type | Description |
|---|---|---|
<oscillator>.freq | oscillator or envelope | Modulates the oscillator’s instantaneous frequency. |
filter.cutoff | envelope only | Modulates 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
| Type | Description |
|---|---|
lowpass | Passes frequencies below the cutoff, attenuates above. Useful for taming bright oscillators and creating warm tones. |
highpass | Passes frequencies above the cutoff, attenuates below. Useful for thinning out bass-heavy signals. |
Filter Attributes
| Attribute | Type | Description |
|---|---|---|
type | string | Required. "lowpass" or "highpass". |
cutoff | float | Required. Cutoff frequency in Hz. Must be greater than 0 and no higher than half the sample rate (Nyquist). |
resonance | float | Required. 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
}
| Attribute | Default | Range | Description |
|---|---|---|---|
polyphony | 64 | 1–128 | Maximum 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:
| Namespace | Description |
|---|---|
event.<key> | Fields from the current event’s data payload. |
score | Current 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:
- The dispatcher looks up the compiled synth patch by name
- A voice is allocated from the patch’s pool (or the oldest voice is stolen if the pool is full)
- The voice is triggered with the resolved pitch and velocity
- The voice is registered with the mixer as an audio source
- The mixer includes the voice’s PCM output in its mix
- 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
- Frequency and cutoff modulation only — modulation targets are limited to
<oscillator>.freqandfilter.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
synthchannel.