Control how many notes can sound together, how release tails overlap, and what happens when a voice pool is full.
What it is#
The engine has 64 voice slots. Each synth can set a smaller cap with voices = N; voices = 1 is monophonic. At capacity, a new note steals a slot using the policy below. The transport rail shows engine render load (dsp %). Scheduled voices stay silent without advancing their DSP state until onset, including blocks entirely before the startup grace or count-in ends.
Surface#
A per-synth voices = N directive inside the patch. N is a positive integer literal; voices = 1 makes the synth monophonic (each new note evicts the previous one). Omitting it lets the synth share the global ceiling.
bass = patch {
voices = 1, -- monophonic — last note wins
source = saw (freq pitch),
ampEnv = adsr 10ms 200ms 0.6 300ms,
gain = vel * 0.6
}
There is no surface keyword for the global ceiling (64) — it is the engine constant MAX_VOICES (engine/src/voice/mod.rs).
Steal policy: steal = "oldest" | "quiet"#
A per-synth steal directive picks which voice a note takes when the synth is at its voices = N cap, or when the whole arena is full (the policy of the synth whose note needs the slot decides). It is a string literal; omitting it is "oldest".
"oldest"(the default) — a released voice nearest its end, else the oldest voice by onset. The stolen voice fades out over ≈ 2 ms (an exponential offset from its last sample)."quiet"— the least audible loss: the voice with the lowestlevel² × remaining life, wherelevelis a 10 ms peak follower of the voice's output (pulled halfway toward its last sample), a released voice counts ×0.25 and a same-pitch voice ×0.62, and remaining life is capped at 1 s. It never takes the newest held note while there are three or more candidates, nor a note that has not sounded yet while one that has is available, and glide voices go last. The stolen voice settles over ≈ 2.1 ms: its last value eased to zero with zero end slope, plus its last slope carried for ≈ 0.3 ms, so the cut has neither a step nor a corner.
piano = patch {
voices = 8,
steal = "quiet", -- keep the held bass under a fast right hand
source = string (freq pitch) 0.18 (0.45 + vel * 0.35) 0.13 (strike 0.55) 0.25 0.4,
gain = vel * 0.1
}
"quiet" keeps a held note under a fast legato run on the same synth, which "oldest" steals at the run's first steal (the oldest note is the held one). It came from the evolve run volta_steal (program #109). Use it to A/B a dense passage against the default; "oldest" stays the default.
How it's implemented#
lower_synth_decl (compiler/src/lower/expr.rs) removes voices from the patch fields, validates a positive integer literal and stores it in Synth.voices: Option<u32> (ir/src/ir.rs); omission means None. CompiledModule.synth_voice_caps (engine/src/graph.rs) passes these caps to VoiceArena::trigger_limited (engine/src/voice/arena.rs).
The arena enforces the synth cap before searching the global pool. At a synth cap, victims must belong to that synth. steal_slot_for prefers a released voice with the earliest end_sample, then the oldest onset; glide voices are eligible only when nothing else is. Template names preserve synth identity across hot swaps, including voices still using an old template.
Sustaining voices retain their original patch graph across a recompile. Macro reads follow compatible names and unit/range contracts through slot reordering; removed or incompatible controls hold their last value for that voice. Newly triggered notes use the new definition. Transport-position reads stay attached to the current song/section, independently of macro count. See Macros and automation.
Track and auxiliary-bus readers also keep their source identity through a reordering: a tap, follower, pitch tracker or spectral sidechain follows the same kind of source with the same name. A removed source supplies silence rather than another source's reused slot; the reader's own envelope or filter can still ring out. Previous audio, detector histories and last-note lanes move with source names. An old sounding follower or last-note reader retains its supporting storage even when the replacement patch no longer declares that reader.
A stolen slot carries its last bus contribution as a decaying offset (declick_l / declick_r, τ ≈ 2 ms), smoothing the transition. Under steal = "quiet" the offset is instead steal::settle applied to the stolen voice's last two samples (Voice::begin_quiet_settle, settle_k); a slot stolen again mid-settle folds the remainder into the exponential offset. steal is peeled in lower_synth_decl into Synth.steal, carried as CompiledModule.synth_steal beside the cap, and read by VoiceArena::trigger_limited, which builds a stack VoiceView per candidate for steal::choose_victim (O(voices), allocation-free). The level follower, previous sample and sounded flag it reads are kept by the voice output loop only once a "quiet" synth has played in the arena (steal_track, sticky), so a song that never asks pays nothing. A stolen slot's DSP state is fully reset: its new note matches a fresh slot's sample for sample once the settle is over (stolen_slot_matches_a_fresh_slot). The one thing it inherits is the sympathetic bridge's history (string … sympathy), which is shared by every voice of the node rather than per slot. Regular tracks, playOn and shred notes share this entry point. Arena and scheduler tests are in engine/src/voice/arena.rs and engine/src/lib.rs; compiler tests are voices_directive_* and steal_directive_lowers in compiler/src/lower/tests.rs.
Static polyphony bound (Stage 4)#
The compiler emits a PolyphonyBound advisory per synth before playback. compiler/src/refine.rs's module-level check_module pass derives it from tracks and tempo alongside interval refinements. It has no source span and is ignored by @strict, which enforces only user obligations.
How the bound is computed:
- Voice window per triggered event =
duration + release, in seconds.releaseis the max over every ADSRrelease,Decaylength, and a granular grain tail — so it tracks the real voice lifetime, not just theampEnvroot. - Not boundable — a plain
Sample/Stretchsource runs to an unknowable buffer end. A live ADSR release or decay duration also has no proven maximum: its initial value is not its bound. These synths receive no voice-count or dependent track-headroom guarantee. (sound-pattern sample tracks target a fabricated$samplerthat isn't inModule.synths, so they're skipped too.) - Tempo conservatism — beats→seconds via the fastest bpm anywhere in the tempo map (shortest cycles ⇒ densest overlap); constant tempo =
cycle_seconds. - Overlap — the periodic events unroll into half-open windows and a sweep-line takes the max overlap at window starts, over a horizon capped at 64 cycles of the shortest track (no polymetric-LCM chasing). The count is exact only when the tracks share one cycle length and the horizon wasn't capped; otherwise it's a lower bound (
at least N). voices = 1(mono / glide) short-circuits to≤ 1 ✓.- Scene exclusivity — a song's bound is the maximum over co-active track groups: each scene plus all tracks unnamed by any scene, and each overlay transition plus its outgoing scene. Replace transitions form their own sections. The message names the busiest group. Mutually exclusive scenes therefore do not add their shared patch's voices together. With no scenes, or a group containing every track using that patch, the diagnostic retains the "assuming all tracks active" caveat.
Verdict: ok when the bound ≤ the voices cap (or the default pool ceiling 64, mirrored from the engine's MAX_VOICES), warn when it exceeds it (the message names the steal fallout). A companion OutputPeak (voice peak vs 0 dBFS) and per-track TrackHeadroom (peak · voices · gainTrack · velocity, pre-track-effects) ride the same pass — see Refinements §6.2. Envelope peak estimates include punch overshoot (up to 1.5×); a live punch uses that full bound.
Notes & limits#
- The global ceiling counts release tails — a voice still costs CPU while fading, so it holds its slot until the tail is done.
- The cap is per-synth, not per-track: two
plays on the same synth share its cap. - The static bound is a sounding-voice estimate: it counts
duration + release, while the arena holds a slot untilrelease + max_release + 64 samples, so it can slightly under-count arena occupancy. It uses scene co-activity where available and does not model thestealpolicy or a priority annotation (see Polyphony and voice management §5 open questions).
Historical#
Stages 1–3 originally shipped as a TypeScript VoicePool plus a header voice pill over the Web Audio engine, deleted with that workbench 2026-08-09; see git history. runtime/src/voices.ts survives as an unpopulated stub.