From 33d8222ee47e432d2aed6bfd630205597739984a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:20:48 +0000 Subject: [PATCH 1/3] Let a host choose the Delay's maximum time, and size the line at instantiate `Delay` reserved a fixed 2 s line, allocated lazily on its first `process`. At 48 kHz that is 384 KB, which does not fit a bare-metal heap of a few hundred KB, so a firmware host could not use the operator at all. `Delay::with_max_time` takes the ceiling at construction; `Delay::new` keeps 2 s, so documents and every existing caller are unchanged. `time` clamps to the chosen ceiling, and `spawn` carries it to copies. The line is now allocated in `on_instantiate`, where the sample rate is fixed, rather than on the first `process`. The lazy branch stays as a guard for a box processed without a Plan. The ceiling is not reachable from a document: constants are read by the loader, not by the operator, so exposing it there would need that plumbing first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MAsy4haLapatc2ssNNUZKo --- crates/reuben-core/src/operators/delay.rs | 109 +++++++++++++++++++--- 1 file changed, 97 insertions(+), 12 deletions(-) diff --git a/crates/reuben-core/src/operators/delay.rs b/crates/reuben-core/src/operators/delay.rs index 65bf5cfa..56b8e277 100644 --- a/crates/reuben-core/src/operators/delay.rs +++ b/crates/reuben-core/src/operators/delay.rs @@ -12,8 +12,10 @@ //! - input 3: `mix` (`Float`) — dry/wet blend 0..1 (materialized default 0.5). //! - output 0: `audio` (`Float`) — the dry+wet mix. //! -//! DSP: a ring buffer sized to the maximum delay (2 s) is allocated lazily on the first -//! `process` call (mirrors the Voicer idiom — sample_rate isn't known in `new()`). Per +//! DSP: a ring buffer sized to the maximum delay is allocated in `on_instantiate`, once the +//! sample rate is fixed. The maximum is 2 s from [`Delay::new`]; [`Delay::with_max_time`] lowers +//! it for a host whose heap cannot hold 2 s of line (at 48 kHz that is 384 KB). `time` clamps to +//! the maximum, so a lowered one caps the echo rather than reading outside the line. Per //! sample we read the delayed sample `time*sample_rate` behind the write head with linear //! interpolation, write `input + feedback*delayed` at the head, and output the dry/wet //! mix. The ring buffer and head index are continuous across calls / block slices, and @@ -24,8 +26,10 @@ use alloc::vec; use alloc::vec::Vec; use num_traits::Float; +use crate::config::AudioConfig; use crate::descriptor::Descriptor; use crate::operator::{Io, Operator}; +use crate::plan::PlanError; // Single-source contract: one declaration -> IN_/OUT_ consts + Descriptor, no drift. crate::operator_contract!(Delay { @@ -36,21 +40,42 @@ crate::operator_contract!(Delay { outputs: { audio: f32_buffer }, }); -/// Maximum delay time in seconds; sizes the ring buffer. +/// Maximum delay time in seconds for [`Delay::new`]; the top of the `time` range. const MAX_DELAY_SECS: f32 = 2.0; -#[derive(Default)] pub struct Delay { - /// Ring buffer of past (input + feedback) samples. Allocated lazily on first - /// `process`, sized to `ceil(MAX_DELAY_SECS * sample_rate)`. Continuous across calls. + /// Ring buffer of past (input + feedback) samples, sized to `ceil(max_time * sample_rate)`. + /// Continuous across calls. buf: Vec, /// Write head index into `buf` (continuous across calls / block slices). head: usize, + /// Longest delay the line holds, in seconds. Construction-time: it sizes `buf`. + max_time: f32, +} + +impl Default for Delay { + fn default() -> Self { + Self::new() + } } impl Delay { pub fn new() -> Self { - Self::default() + Self::with_max_time(MAX_DELAY_SECS) + } + + /// A Delay whose line holds at most `max_time` seconds, clamped to the `time` port's range. + /// The line costs `4 * ceil(max_time * sample_rate)` bytes of heap. + pub fn with_max_time(max_time: f32) -> Self { + Self { + buf: Vec::new(), + head: 0, + max_time: max_time.clamp(0.001, MAX_DELAY_SECS), + } + } + + fn line_len(&self, sample_rate: f32) -> usize { + (Float::ceil(self.max_time * sample_rate) as usize).max(1) } } @@ -59,13 +84,19 @@ impl Operator for Delay { Self::contract() } + fn on_instantiate(&mut self, config: &AudioConfig) -> Result<(), PlanError> { + self.buf = vec![0.0f32; self.line_len(config.sample_rate)]; + self.head = 0; + Ok(()) + } + fn process(&mut self, io: &mut Io) { let n = io.frames(); let sample_rate = io.sample_rate(); - // Lazily size the ring buffer to the max delay (sample_rate isn't known in `new`). - let cap = Float::ceil(MAX_DELAY_SECS * sample_rate) as usize; - let cap = cap.max(1); + // Sized by `on_instantiate`, so under a Plan this never fires; it guards a box that is + // processed without being instantiated. + let cap = self.line_len(sample_rate); if self.buf.len() != cap { self.buf = vec![0.0f32; cap]; self.head = 0; @@ -74,7 +105,7 @@ impl Operator for Delay { let feedback = io.read(IN_FEEDBACK).clamp(0.0, 0.95); let mix = io.read(IN_MIX).clamp(0.0, 1.0); // Read offset in samples; clamp so the interpolated tap stays inside the buffer. - let time = io.read(IN_TIME).clamp(0.001, MAX_DELAY_SECS); + let time = io.read(IN_TIME).clamp(0.001, self.max_time); let delay_samples = (time * sample_rate).clamp(1.0, (cap - 1) as f32); let len = self.buf.len(); @@ -103,7 +134,7 @@ impl Operator for Delay { } fn spawn(&self) -> Box { - Box::new(Self::new()) + Box::new(Self::with_max_time(self.max_time)) } } @@ -251,6 +282,60 @@ mod tests { assert!(out[peak] > 0.9, "echo too quiet: {}", out[peak]); } + #[test] + fn a_lowered_max_time_caps_the_echo_there() { + let sr = 48_000.0; + let n = 48_000; + let mut input = vec![0.0f32; n]; + input[0] = 1.0; + + let out = OpDriver::for_type(Delay::with_max_time(0.5), sr) + .set(IN_TIME, 1.5) + .set(IN_FEEDBACK, 0.0) + .set(IN_MIX, 1.0) + .drive(IN_AUDIO, &input) + .render(n) + .output(OUT_AUDIO) + .to_vec(); + + // cap = 24000; delay_samples clamps to cap-1. + let expected = 23_999i64; + let peak = argmax_abs(&out[1..]) + 1; + assert!( + (expected - peak as i64).abs() <= 2, + "echo expected at the 0.5 s cap (~{expected}), peaked at {peak}" + ); + } + + #[test] + fn instantiate_sizes_the_line_so_process_never_allocates_it() { + let mut d = Delay::with_max_time(0.5); + d.on_instantiate(&AudioConfig::new(48_000.0, 32)) + .expect("a delay always instantiates"); + assert_eq!(d.buf.len(), 24_000); + } + + #[test] + fn a_spawned_copy_keeps_its_max_time() { + let n = 24_000; + let mut input = vec![0.0f32; n]; + input[0] = 1.0; + let out = OpDriver::for_type(Delay::with_max_time(0.25), 48_000.0) + .spawn() + .set(IN_TIME, 1.0) + .set(IN_FEEDBACK, 0.0) + .set(IN_MIX, 1.0) + .drive(IN_AUDIO, &input) + .render(n) + .output(OUT_AUDIO) + .to_vec(); + let peak = argmax_abs(&out[1..]) + 1; + assert!( + (11_999i64 - peak as i64).abs() <= 2, + "a spawned copy echoed at {peak}, not at its 0.25 s cap" + ); + } + #[test] fn mid_render_time_change_stays_finite_and_bounded() { // Changing `time` while the line is ringing is the modulation path the wireable port From 7ca96d54850e95173c2e9c8f4baba7d51d8be611 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 00:41:25 +0000 Subject: [PATCH 2/3] Let a host cap the Resonator's modes, and stop advancing muted ones The bank advanced all 32 modes every sample, including the ones `recompute` had muted for sitting past Nyquist. On a Cortex-M7 a four-string patch of these is most of a 32-frame block, and a host with a small speaker has no use for partials it cannot reproduce. `Resonator::with_modes(n)` caps the bank at construction; `Resonator::new` keeps 32, and `spawn` carries the cap to copies. Independently of the cap, the bank now advances only up to the last mode `recompute` left sounding, and clears the ring of any mode that leaves that range so a later retune down does not bring it back mid-ring. Muted modes already decayed to exact zero within two samples, so the output is bit-identical (an FNV hash of 3000 blocks, with a strike and a retune past Nyquist and back, matches the previous code). The loop walks slices cut to the active count instead of indexing below it, which removes the bounds checks a runtime bound would otherwise keep: the default 32-mode path is 12% fewer instructions than before (callgrind, one resonator, 3000 blocks: 53.4M -> 47.1M). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MAsy4haLapatc2ssNNUZKo --- crates/reuben-core/src/operators/resonator.rs | 110 ++++++++++++++++-- 1 file changed, 102 insertions(+), 8 deletions(-) diff --git a/crates/reuben-core/src/operators/resonator.rs b/crates/reuben-core/src/operators/resonator.rs index 1bedb33a..7c4ee54f 100644 --- a/crates/reuben-core/src/operators/resonator.rs +++ b/crates/reuben-core/src/operators/resonator.rs @@ -109,6 +109,11 @@ pub struct Resonator { /// Per-mode delay state (the ring), continuous across blocks; reset on `spawn`. y1: [f32; NUM_MODES], y2: [f32; NUM_MODES], + /// Modes this instance may use, chosen at construction: the bank's cost is linear in it. + max_modes: usize, + /// Modes the bank actually advances: up to the last one `recompute` left sounding. Everything + /// above is muted (past Nyquist or past `max_modes`) and would only add zeros. + active: usize, /// Last control values the coefficients were computed for; NaN forces a first compute. last_freq: f32, @@ -141,6 +146,8 @@ impl Default for Resonator { g_exc: [0.0; NUM_MODES], y1: [0.0; NUM_MODES], y2: [0.0; NUM_MODES], + max_modes: NUM_MODES, + active: NUM_MODES, last_freq: f32::NAN, last_structure: f32::NAN, last_brightness: f32::NAN, @@ -162,6 +169,18 @@ impl Resonator { Self::default() } + /// A Resonator with at most `modes` partials (clamped to `1..=32`), for a host whose render + /// budget cannot carry the full bank. The upper partials go first, so fewer modes is a darker + /// ceiling on `brightness`, not a different instrument. + pub fn with_modes(modes: usize) -> Self { + let modes = modes.clamp(1, NUM_MODES); + Self { + max_modes: modes, + active: modes, + ..Self::default() + } + } + /// One xorshift32 step → a fresh u32 (Marsaglia 13/17/5; never collapses to 0). #[inline] fn next_u32(&mut self) -> u32 { @@ -214,16 +233,18 @@ impl Resonator { let pos = 0.05 + 0.45 * position.clamp(0.0, 1.0); let brightness = brightness.clamp(0.0, 1.0); + let mut active = 0; for i in 0..NUM_MODES { let n = (i + 1) as f32; let f = freq * n * Float::sqrt(1.0 + b * n * n) / ratio1; - if f <= 0.0 || f >= nyq { + if i >= self.max_modes || f <= 0.0 || f >= nyq { self.c[i] = 0.0; self.d[i] = 0.0; self.g[i] = 0.0; self.g_exc[i] = 0.0; continue; } + active = i + 1; let w = core::f32::consts::TAU * f / sample_rate; let t = t0 / (1.0 + HF_DAMP * i as f32); let r = Float::exp(-1.0 / (t * sample_rate)).min(R_MAX); @@ -237,6 +258,13 @@ impl Resonator { // `amp` regardless of how long the mode rings or how high it is tuned. self.g_exc[i] = Float::sin(w) * amp; } + // A mode leaving the advanced range keeps its last ring in `y1`/`y2`; clear it, or it + // resumes mid-ring the next time a lower pitch brings the mode back. + for i in active..self.active { + self.y1[i] = 0.0; + self.y2[i] = 0.0; + } + self.active = active; } /// One sample of the modal bank: advance every mode and sum. `EXCITED` is a const so the mallet @@ -245,16 +273,27 @@ impl Resonator { /// the same arithmetic it has always done (the micro bench gates this at 10%). #[inline(always)] fn bank_step(&mut self, x_in: f32, exc: f32) -> f32 { + // Slices cut to `active` rather than indices below it: the compiler cannot see that + // `active <= NUM_MODES`, and indexing would keep a bounds check on every array in the + // hottest loop in the operator. + let n = self.active; + let modes = self.y1[..n] + .iter_mut() + .zip(&mut self.y2[..n]) + .zip(&self.g[..n]) + .zip(&self.g_exc[..n]) + .zip(&self.c[..n]) + .zip(&self.d[..n]); let mut acc = 0.0f32; - for m in 0..NUM_MODES { + for (((((y1, y2), &g), &g_exc), &c), &d) in modes { let drive = if EXCITED { - self.g[m] * x_in + self.g_exc[m] * exc + g * x_in + g_exc * exc } else { - self.g[m] * x_in + g * x_in }; - let y = drive + self.c[m] * self.y1[m] - self.d[m] * self.y2[m]; - self.y2[m] = self.y1[m]; - self.y1[m] = y; + let y = drive + c * *y1 - d * *y2; + *y2 = *y1; + *y1 = y; acc += y; } acc @@ -344,7 +383,7 @@ impl Operator for Resonator { } fn spawn(&self) -> Box { - Box::new(Self::new()) + Box::new(Self::with_modes(self.max_modes)) } } @@ -762,6 +801,61 @@ mod tests { } } + /// Magnitude of the DFT of `buf` at `f`, normalized by length. + fn tone(buf: &[f32], f: f32) -> f32 { + let w = core::f32::consts::TAU * f / SR; + let (re, im) = buf.iter().enumerate().fold((0.0f32, 0.0f32), |(re, im), (i, &x)| { + (re + x * (w * i as f32).cos(), im - x * (w * i as f32).sin()) + }); + (re * re + im * im).sqrt() / buf.len() as f32 + } + + fn ping_with(op: Resonator, n: usize, m: Macros) -> Vec { + let mut d = OpDriver::for_type(op, SR); + set_macros(&mut d, m); + d.push(IN_GATE, 0, 1.0); + d.render(n).output(OUT_OUT).to_vec() + } + + #[test] + fn fewer_modes_drop_only_the_upper_partials() { + let m = Macros::default(); + let full = ping_with(Resonator::new(), 24_000, m); + let four = ping_with(Resonator::with_modes(4), 24_000, m); + let p2 = (tone(&full, 2.0 * m.freq), tone(&four, 2.0 * m.freq)); + assert!((p2.0 - p2.1).abs() < p2.0 * 0.05, "the 2nd partial moved: {p2:?}"); + let p6 = (tone(&full, 6.0 * m.freq), tone(&four, 6.0 * m.freq)); + assert!(p6.1 < p6.0 * 0.01, "the 6th partial survived a 4-mode bank: {p6:?}"); + } + + #[test] + fn a_spawned_copy_keeps_its_mode_count() { + let m = Macros::default(); + let mut d = OpDriver::for_type(Resonator::with_modes(4), SR); + set_macros(&mut d, m); + let mut copy = d.spawn(); + set_macros(&mut copy, m); + copy.push(IN_GATE, 0, 1.0); + let out = copy.render(24_000).output(OUT_OUT).to_vec(); + assert!(tone(&out, 6.0 * m.freq) < tone(&out, 2.0 * m.freq) * 0.01); + } + + #[test] + fn a_mode_pushed_past_nyquist_does_not_resume_its_old_ring() { + // At 2 kHz every mode from the 11th up is past 0.45 × 48 kHz. Their rings must be cleared + // as they leave, or retuning down later brings them back mid-ring. + let m = Macros::default(); + let mut r = Resonator::new(); + r.recompute(220.0, 0.0, m.brightness, m.damping, m.position, SR); + assert_eq!(r.active, NUM_MODES); + r.y1 = [1.0; NUM_MODES]; + r.y2 = [1.0; NUM_MODES]; + r.recompute(2_000.0, 0.0, m.brightness, m.damping, m.position, SR); + assert_eq!(r.active, 10); + assert!(r.y1[..10].iter().chain(&r.y2[..10]).all(|&y| y == 1.0)); + assert!(r.y1[10..].iter().chain(&r.y2[10..]).all(|&y| y == 0.0)); + } + #[test] fn spawned_resonator_starts_silent() { // Ring `a`, then spawn `b`: the fresh voice carries no ring and stays silent until pinged. From d7c4e6ed879da5d450645f0f27a08690c80b2bc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 15 Oct 2026 01:30:09 +0000 Subject: [PATCH 3/3] Make degenerate Delay ceilings safe, and bring the docs in line - `with_max_time(NaN)` kept the NaN (`f32::clamp` passes it through), and `process` then clamped `time` against it, which panics on the render thread. A NaN now takes the 1 ms floor. - A 1 ms ceiling at a sample rate of 1 kHz or less made a one-sample line, and the read head's `clamp(1.0, len - 1)` became an empty range and panicked. The line is at least two samples. - The Delay module doc says what instantiate-time allocation costs a Swap: the new Plan holds a line for every Delay, survivors' discarded twins included. - The Resonator docs and operator-dev.md describe the mode cap, the skipped muted modes, and `on_instantiate` as the hook for config-dependent state generally, not only the Voicer's. - `instantiate_sizes_the_line_so_process_never_allocates_it` asserted only the size; it is now named for what it checks. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01MAsy4haLapatc2ssNNUZKo --- crates/reuben-core/src/operators/delay.rs | 39 ++++++++++++++++--- crates/reuben-core/src/operators/resonator.rs | 10 +++-- docs/agents/operator-dev.md | 9 ++--- 3 files changed, 43 insertions(+), 15 deletions(-) diff --git a/crates/reuben-core/src/operators/delay.rs b/crates/reuben-core/src/operators/delay.rs index 56b8e277..d7341c91 100644 --- a/crates/reuben-core/src/operators/delay.rs +++ b/crates/reuben-core/src/operators/delay.rs @@ -15,7 +15,10 @@ //! DSP: a ring buffer sized to the maximum delay is allocated in `on_instantiate`, once the //! sample rate is fixed. The maximum is 2 s from [`Delay::new`]; [`Delay::with_max_time`] lowers //! it for a host whose heap cannot hold 2 s of line (at 48 kHz that is 384 KB). `time` clamps to -//! the maximum, so a lowered one caps the echo rather than reading outside the line. Per +//! the maximum, so a lowered one caps the echo rather than reading outside the line. Because the +//! line exists from instantiate, a Plan built for a Swap holds a full line for every Delay in it — +//! including the fresh twins of surviving boxes, discarded when the survivors move in — so a Swap's +//! peak heap is the old Plan's lines plus the new Plan's. Per //! sample we read the delayed sample `time*sample_rate` behind the write head with linear //! interpolation, write `input + feedback*delayed` at the head, and output the dry/wet //! mix. The ring buffer and head index are continuous across calls / block slices, and @@ -64,18 +67,26 @@ impl Delay { Self::with_max_time(MAX_DELAY_SECS) } - /// A Delay whose line holds at most `max_time` seconds, clamped to the `time` port's range. - /// The line costs `4 * ceil(max_time * sample_rate)` bytes of heap. + /// A Delay whose line holds at most `max_time` seconds, clamped to the `time` port's range; a + /// NaN takes the floor. The line costs `4 * ceil(max_time * sample_rate)` bytes of heap. pub fn with_max_time(max_time: f32) -> Self { + // Not `f32::clamp`: it passes NaN through, and `process` clamps `time` against this, where + // a NaN bound panics on the render thread. + let max_time = if max_time >= 0.001 { + max_time.min(MAX_DELAY_SECS) + } else { + 0.001 + }; Self { buf: Vec::new(), head: 0, - max_time: max_time.clamp(0.001, MAX_DELAY_SECS), + max_time, } } + /// At least 2: the read head clamps to `1..=len - 1`, which is an empty range below that. fn line_len(&self, sample_rate: f32) -> usize { - (Float::ceil(self.max_time * sample_rate) as usize).max(1) + (Float::ceil(self.max_time * sample_rate) as usize).max(2) } } @@ -308,13 +319,29 @@ mod tests { } #[test] - fn instantiate_sizes_the_line_so_process_never_allocates_it() { + fn instantiate_sizes_the_line_to_max_time() { let mut d = Delay::with_max_time(0.5); d.on_instantiate(&AudioConfig::new(48_000.0, 32)) .expect("a delay always instantiates"); assert_eq!(d.buf.len(), 24_000); } + #[test] + fn a_degenerate_max_time_renders_rather_than_panicking() { + // A NaN ceiling, and a 1 ms ceiling at a sample rate where it is a single sample: both + // reached `f32::clamp` with an invalid range inside `process`. + let input = vec![1.0f32; 256]; + for (max_time, sr) in [(f32::NAN, 48_000.0), (0.001, 1_000.0)] { + let out = OpDriver::for_type(Delay::with_max_time(max_time), sr) + .set(IN_TIME, 0.3) + .drive(IN_AUDIO, &input) + .render(input.len()) + .output(OUT_AUDIO) + .to_vec(); + assert!(out.iter().all(|x| x.is_finite()), "{max_time} s at {sr} Hz"); + } + } + #[test] fn a_spawned_copy_keeps_its_max_time() { let n = 24_000; diff --git a/crates/reuben-core/src/operators/resonator.rs b/crates/reuben-core/src/operators/resonator.rs index 7c4ee54f..f397963d 100644 --- a/crates/reuben-core/src/operators/resonator.rs +++ b/crates/reuben-core/src/operators/resonator.rs @@ -1,6 +1,7 @@ //! Resonator — a modal resonator (Mutable Instruments *Rings*-inspired). //! -//! A bank of `NUM_MODES` tuned two-pole resonators ("modes") excited in parallel by two paths: an +//! A bank of up to `NUM_MODES` tuned two-pole resonators ("modes") — fewer under +//! [`Resonator::with_modes`] — excited in parallel by two paths: an //! **external input** (`in`, the audio-effect / "excite it with anything" path) and an **internal //! mallet** — a short brightness-filtered noise burst fired by a rising edge on `gate` (the ping / //! strum trigger). Output is **pure wet**: the ringing bank only, so dry/wet blending (if wanted) is @@ -18,7 +19,7 @@ //! //! Macro controls map the *Rings* panel (all held `f32` Values — read once per //! block-slice; the bank's coefficients are recomputed only when one changes, à la `filter`, so a -//! 32-mode bank stays cheap on the hot path): +//! full bank stays cheap on the hot path): //! - `freq` — the resonant fundamental (Hz). Mode `i` sits at partial `i+1`. //! - `structure` — harmonic → inharmonic: stretches the partials off the integer series //! (`f_i = freq·n·√(1 + B·n²)`, normalized so the fundamental stays exact). 0 = a harmonic @@ -61,8 +62,9 @@ crate::operator_contract!(Resonator { outputs: { out: f32_buffer }, }); -/// Number of modes (resonant partials) in the bank. Fixed so `process` allocates nothing; high -/// modes above Nyquist are simply muted (gain 0). +/// Most modes (resonant partials) a bank can hold. Fixed so `process` allocates nothing. Modes past +/// Nyquist or past the instance's cap are muted, and the bank stops advancing at the last one that +/// sounds. const NUM_MODES: usize = 32; /// `structure` → inharmonicity coefficient `B`. Tuned so the default (0.25) is mildly stretched and /// the maximum is clearly bell-like without being unstable. diff --git a/docs/agents/operator-dev.md b/docs/agents/operator-dev.md index 95e8e1d9..4a1d0e6b 100644 --- a/docs/agents/operator-dev.md +++ b/docs/agents/operator-dev.md @@ -39,11 +39,10 @@ pub trait Operator: Send { } ``` -Two optional lifecycle hooks support the Voicer, -both default no-ops: `bind_voices(Vec)` receives the N built voice sub-patches at load, and -`on_instantiate(&AudioConfig) -> Result<(), PlanError>` runs per node from `Plan::instantiate` (the -one place with the audio config) so the Voicer can build each voice's sub-`Plan` + arena off the hot -path. +Two optional lifecycle hooks, both default no-ops: `bind_voices(Vec)` receives the Voicer's +N built voice sub-patches at load, and `on_instantiate(&AudioConfig) -> Result<(), PlanError>` runs +per node from `Plan::instantiate` (the one place with the audio config) so config-dependent state is +built off the hot path — the Voicer's per-voice sub-`Plan` + arena, the Delay's line. A third default-no-op hook, `on_transplant(&mut self)`, runs on a **survivor** box just after it is transplanted across a hot-swap ([execution-runtime](../rules/execution-runtime.md)). A Swap