Skip to main content

auracle_wasm/
live.rs

1//! The live performance voice: N copies of one compiled patch, played from a
2//! keyboard in real time inside an AudioWorklet.
3//!
4//! This is the "instrument" half of the app (the `WasmEngine` in the worker
5//! is the "brain"). It shares the exact compilation path evolution uses —
6//! `auracle_grammar::compile` with the mandatory ADSR → VCA → Limiter chain
7//! — so what you play is byte-for-byte the patch that was evolved, limiter
8//! included.
9//!
10//! ## Audio-thread discipline (no clicks, no zipper, no GC)
11//!
12//! - **Zero allocation per quantum**: [`LivePoly::process_ptr`] renders into
13//!   a persistent internal buffer and returns a pointer; the worklet views
14//!   wasm memory directly. The `Vec`-returning [`LivePoly::process`] exists
15//!   for native tests only.
16//! - **Parameter smoothing**: [`LivePoly::set_param`] never jumps a value.
17//!   It sets a target; every quantum a one-pole ramp advances the live
18//!   atomics toward it (~25 ms settle), so knob sweeps cannot zipper.
19//! - **Click-free patch swaps**: [`LivePoly::set_patch`] parses eagerly but
20//!   swaps lazily — fade the output to silence (~6 ms), rebuild **one voice
21//!   per quantum while silent** (compile overruns are inaudible at zero
22//!   gain), re-press every held note on the new voices, fade back in. A
23//!   held chord survives rewiring.
24//! - **Envelope carry**: re-pressing restarts an ADSR from zero, so a
25//!   sustained pad used to swell in again on every structural edit — the edit
26//!   read as an event of its own rather than as a change to the sound. Each
27//!   carried note's amp envelope phase is read off the outgoing voice and
28//!   seeded into the new one (`auracle_grammar::CompiledVoice::seed_env_phase`) with no
29//!   falling edge, so held notes never re-attack. Filter, delay and reverb
30//!   tails cannot transfer across a rewire and still die; that is accepted.
31//! - Released voices keep ticking through their tails and are parked once
32//!   effectively silent, so idle polyphony costs nothing.
33
34use auracle_grammar::{compile, PatchTree};
35use quiver::observer::{ObservableValue, StateObserver, SubscriptionTarget};
36use wasm_bindgen::prelude::*;
37
38const GATE_ON: f64 = 5.0;
39/// |L|+|R| below this counts as silence for voice parking.
40const SILENCE_EPS: f64 = 1.0e-6;
41/// Consecutive silent frames (post-release) before a voice is parked.
42const PARK_AFTER: u32 = 4096;
43/// Per-frame fade step for patch swaps (≈6 ms at 44.1 kHz).
44const FADE_STEP: f32 = 1.0 / 256.0;
45/// One-pole smoothing factor per quantum for parameter ramps.
46const SMOOTH_COEFF: f64 = 0.3;
47/// Snap threshold ending a parameter ramp.
48const SMOOTH_EPS: f64 = 1.0e-4;
49/// quiver audio is nominal ±5 V; the float domain is ±1.0. Offline rendering
50/// applies the same divisor (`auracle_features::render`), and the LUFS makeup
51/// gain that rides every patch was fitted in that ±1.0 domain — so the live
52/// path **must** normalize identically or it runs ~14 dB hot into the ceiling.
53const VOLT_SCALE: f32 = 1.0 / 5.0;
54/// Master brickwall ceiling, just under full scale.
55const MASTER_CEILING: f32 = 0.98;
56/// Master limiter release coefficient per sample (≈80 ms at 44.1 kHz).
57const MASTER_RELEASE: f32 = 2.8e-4;
58/// Full-scale unison detune in V/Oct: ±0.05 V = ±60 cents. At the old ±30 c a
59/// four-voice stack was a chorus; a JP-8000-style supersaw wants ±50–70 c.
60const UNI_DETUNE_VOLT: f64 = 0.05;
61/// Arp gate lengths at or above this are *tied*: the step boundary slides the
62/// sounding voice to the next pitch instead of releasing and re-attacking.
63const ARP_TIE: f64 = 0.95;
64
65/// The classic supersaw detune curve, mapping a voice's uniform position in
66/// `[-1, 1]` to its share of the detune spread.
67///
68/// The outer voices sit disproportionately far out — that asymmetry is what
69/// makes a stack read as one wide instrument rather than as a chorus, and it is
70/// why a linear spread sounds thin no matter how far you push it.
71/// `sign(u)·|u|^1.5` fits the JP-8000's published seven-voice offsets to within
72/// a couple of percent.
73fn detune_curve(u: f64) -> f64 {
74    u.signum() * u.abs().powf(1.5)
75}
76
77/// Master bus limiter: instant attack, one-pole release, applied to the summed
78/// polyphony. Each voice carries its own limiter, but N voices sum to N× the
79/// level of one — without this a four-note chord is ~12 dB hotter than a single
80/// note and simply clips. Gain reduction is shared across L/R so the stereo
81/// image never wobbles.
82struct MasterLimiter {
83    /// Current gain reduction (1.0 = no reduction).
84    gain: f32,
85}
86
87impl MasterLimiter {
88    fn new() -> Self {
89        Self { gain: 1.0 }
90    }
91
92    /// Process one stereo frame in place.
93    fn tick(&mut self, l: &mut f32, r: &mut f32) {
94        let peak = l.abs().max(r.abs());
95        let desired = if peak > MASTER_CEILING {
96            MASTER_CEILING / peak
97        } else {
98            1.0
99        };
100        if desired < self.gain {
101            self.gain = desired; // instant attack — catch the sample that overs
102        } else {
103            self.gain += (desired - self.gain) * MASTER_RELEASE;
104        }
105        *l = (*l * self.gain).clamp(-1.0, 1.0);
106        *r = (*r * self.gain).clamp(-1.0, 1.0);
107    }
108}
109
110struct Voice {
111    voice: auracle_grammar::CompiledVoice,
112    /// Currently-held MIDI note, if any (gate high).
113    note: Option<u8>,
114    /// Allocation stamp for oldest-first stealing.
115    stamp: u64,
116    /// Still worth ticking (held, or release tail not yet silent).
117    running: bool,
118    silent_run: u32,
119    /// Velocity gain (0..1) applied to this voice's output.
120    vel: f32,
121    /// Equal-power pan gains (unison spread; center by default).
122    pan_l: f32,
123    pan_r: f32,
124    /// Pitch in v/oct, smoothed toward `pitch_tgt` (glide). Excludes bend.
125    pitch_cur: f64,
126    pitch_tgt: f64,
127    /// Frames until the gate is re-raised. Stealing a *sounding* voice drops
128    /// the gate for one frame so the ADSR sees a rising edge and actually
129    /// retriggers — otherwise the new note inherits the stolen note's
130    /// envelope position and speaks with no attack.
131    regate_in: u32,
132}
133
134struct Smoother {
135    addr: String,
136    current: f64,
137    target: f64,
138}
139
140enum Stage {
141    Run,
142    FadeOut,
143    Rebuild { built: Vec<Voice> },
144    FadeIn,
145}
146
147/// Event for the worklet to relay (polled once per quantum).
148const EVENT_NONE: u32 = 0;
149const EVENT_PATCHED: u32 = 1;
150const EVENT_PATCH_ERROR: u32 = 2;
151
152/// A polyphonic live instrument over one patch.
153#[wasm_bindgen]
154pub struct LivePoly {
155    voices: Vec<Voice>,
156    n_voices: usize,
157    sample_rate: f64,
158    counter: u64,
159    /// Notes physically held right now, with velocity (survive patch swaps).
160    held: Vec<(u8, f32)>,
161    smoothers: Vec<Smoother>,
162    stage: Stage,
163    gain: f32,
164    pending: Option<PatchTree>,
165    out_buf: Vec<f32>,
166    event: u32,
167    last_error: String,
168    /// Pitch bend in v/oct, one-pole smoothed toward `bend_tgt`.
169    bend: f64,
170    bend_tgt: f64,
171    /// Glide amount 0..1 (0 = off; 1 ≈ 500 ms portamento).
172    glide: f64,
173    /// v/oct of the most recent press — glide start point. `None` until the
174    /// first press: with nothing behind it there is nowhere to glide *from*,
175    /// and a zero would slide the first note of the session in from C4.
176    last_pitch: Option<f64>,
177    /// Unison: all voices play one note, detuned and panned apart.
178    unison: bool,
179    uni_detune: f64,
180    uni_spread: f64,
181    /// Loudness makeup gain (linear); swaps in with the patch it belongs to.
182    makeup: f32,
183    pending_makeup: Option<f32>,
184    /// Master brickwall across the summed polyphony.
185    master: MasterLimiter,
186    // Arpeggiator (sample-accurate, runs on the audio thread).
187    arp_on: bool,
188    /// 0 = up, 1 = down, 2 = up-down, 3 = random.
189    arp_mode: u32,
190    /// Steps per beat (1 = quarters, 2 = eighths, 4 = sixteenths).
191    arp_div: f64,
192    /// Gate length as a fraction of the step (0.05–1.0); ≥ [`ARP_TIE`] is tied.
193    arp_gate: f64,
194    /// How many octaves the pattern spans (1–4).
195    arp_octaves: u32,
196    /// Shuffle amount (0–0.75): even steps lengthen, odd steps shorten.
197    arp_swing: f64,
198    bpm: f64,
199    /// Samples elapsed in the current arp step.
200    arp_phase: f64,
201    arp_idx: usize,
202    /// Steps played since the arp was switched on — swing needs the parity.
203    arp_step: u64,
204    /// Direction flag for up-down mode.
205    arp_up: bool,
206    /// The transposed note currently gated on (may be an octave up).
207    arp_note: Option<u8>,
208    /// The *held* note that `arp_note` was derived from, so releasing a key
209    /// mid-step can still find its sounding voice.
210    arp_base: Option<u8>,
211    /// xorshift state for random mode (deterministic; no wall clock).
212    rng_state: u64,
213    /// Interior signal metering, off until a surface asks for it. See
214    /// [`LivePoly::set_meter`].
215    meter: Meter,
216}
217
218/// Per-module level metering, read off the voice the player is hearing.
219///
220/// Off by default and allocation-free while off, which is the state every
221/// player is in: `set_meter(false)` clears the subscriptions and the render
222/// loop's metering branch is one `bool` test per voice. While it is *on* it
223/// allocates once per quantum in `drain_updates`, on the same terms the
224/// recorder already sets in `live-audio.js` — "allocation only while a take is
225/// rolling, never in the steady state". A teaching surface the player switched
226/// on is that kind of state.
227struct Meter {
228    observer: StateObserver,
229    /// Term keys in [`Self::levels`] order, fixed when the subscriptions are
230    /// taken so the main thread can label the values it reads once.
231    keys: Vec<String>,
232    /// Quiver node name and port for each key, index-parallel to `keys` —
233    /// what an update's `node_id`/`port_id` is matched back against.
234    ports: Vec<(String, u32)>,
235    /// Latest RMS dB per tap, preallocated and overwritten in place so the
236    /// worklet can read it as a view into wasm memory.
237    levels: Vec<f32>,
238    on: bool,
239}
240
241impl Meter {
242    fn new() -> Self {
243        Meter {
244            observer: StateObserver::new(),
245            keys: Vec::new(),
246            ports: Vec::new(),
247            levels: Vec::new(),
248            on: false,
249        }
250    }
251
252    /// Subscribe to every tap of `voice`, or clear if `on` is false.
253    ///
254    /// Re-taken from scratch on every patch swap, not just when the surface
255    /// asks. A subscription caches the `NodeId` it resolved its name to, and a
256    /// rebuilt patch is a fresh slotmap whose keys mean nothing to the old
257    /// one — a stale id is not merely dead, it can silently land on a
258    /// different node. Re-subscribing resets those caches.
259    fn resubscribe(&mut self, voice: &auracle_grammar::CompiledVoice) {
260        self.observer.clear_subscriptions();
261        self.keys.clear();
262        self.ports.clear();
263        self.levels.clear();
264        if !self.on {
265            return;
266        }
267        // Sorted so the order the main thread labels once stays put across
268        // swaps; a `HashMap` iteration order would reshuffle the readout.
269        let mut taps: Vec<(&String, &(String, u32))> = voice.taps.iter().collect();
270        taps.sort_by(|a, b| a.0.cmp(b.0));
271        let targets: Vec<SubscriptionTarget> = taps
272            .iter()
273            .map(|(key, (node, port))| {
274                self.keys.push((*key).clone());
275                self.ports.push((node.clone(), *port));
276                self.levels.push(f32::NEG_INFINITY);
277                SubscriptionTarget::Level {
278                    node_id: node.clone(),
279                    port_id: *port,
280                }
281            })
282            .collect();
283        self.observer.add_subscriptions(targets);
284    }
285
286    /// Move whatever the observer has finished into [`Self::levels`].
287    fn drain(&mut self) {
288        for update in self.observer.drain_updates() {
289            let ObservableValue::Level {
290                node_id,
291                port_id,
292                rms_db,
293                ..
294            } = update
295            else {
296                continue;
297            };
298            if let Some(i) = self
299                .ports
300                .iter()
301                .position(|(n, p)| *p == port_id && *n == node_id)
302            {
303                self.levels[i] = rms_db as f32;
304            }
305        }
306    }
307}
308
309fn build_voice(tree: &PatchTree, sample_rate: f64) -> Result<Voice, String> {
310    let voice = compile(tree, sample_rate).map_err(|e| e.to_string())?;
311    voice.gate.set(0.0);
312    Ok(Voice {
313        voice,
314        note: None,
315        stamp: 0,
316        running: false,
317        silent_run: 0,
318        vel: 1.0,
319        pan_l: std::f32::consts::FRAC_1_SQRT_2,
320        pan_r: std::f32::consts::FRAC_1_SQRT_2,
321        pitch_cur: 0.0,
322        pitch_tgt: 0.0,
323        regate_in: 0,
324    })
325}
326
327#[wasm_bindgen]
328impl LivePoly {
329    /// Build an `n_voices`-voice instrument from a `PatchTree` JSON.
330    #[wasm_bindgen(constructor)]
331    pub fn new(tree_json: &str, sample_rate: f64, n_voices: usize) -> Result<LivePoly, JsValue> {
332        let tree: PatchTree =
333            serde_json::from_str(tree_json).map_err(|e| JsValue::from_str(&e.to_string()))?;
334        let n = n_voices.max(1);
335        let voices: Vec<Voice> = (0..n)
336            .map(|_| build_voice(&tree, sample_rate))
337            .collect::<Result<_, _>>()
338            .map_err(|e| JsValue::from_str(&e))?;
339        Ok(LivePoly {
340            voices,
341            n_voices: n,
342            sample_rate,
343            counter: 0,
344            held: Vec::new(),
345            smoothers: Vec::new(),
346            stage: Stage::Run,
347            gain: 1.0,
348            pending: None,
349            out_buf: Vec::new(),
350            event: EVENT_NONE,
351            last_error: String::new(),
352            bend: 0.0,
353            bend_tgt: 0.0,
354            glide: 0.0,
355            last_pitch: None,
356            unison: false,
357            uni_detune: 0.3,
358            uni_spread: 0.7,
359            makeup: 1.0,
360            pending_makeup: None,
361            master: MasterLimiter::new(),
362            arp_on: false,
363            arp_mode: 0,
364            arp_div: 2.0,
365            arp_gate: 0.5,
366            arp_octaves: 1,
367            arp_swing: 0.0,
368            bpm: 120.0,
369            arp_phase: 0.0,
370            arp_idx: 0,
371            arp_step: 0,
372            arp_up: true,
373            arp_note: None,
374            arp_base: None,
375            rng_state: 0x9E37_79B9_7F4A_7C15,
376            meter: Meter::new(),
377        })
378    }
379
380    /// Turn interior metering on or off.
381    ///
382    /// On, every module in the patch gets a `Level` subscription and
383    /// [`Self::meter_ptr`] carries its RMS in dB; off, nothing is subscribed
384    /// and the render loop does no metering work at all. Returns the number of
385    /// taps, which is the length of both [`Self::meter_keys`] and the level
386    /// buffer.
387    ///
388    /// Nothing here needs `sync_output_keepalive`. That call pins ports with
389    /// no consumer so a module implementing `tick_masked` still produces them,
390    /// and it dirties the patch — a recompile that would have to be staged
391    /// around the audio thread the way patch swaps are. It is not needed
392    /// because the genome is a typed *tree*: every module's output feeds
393    /// exactly one parent, so quiver is already computing every value metered
394    /// here. Metering costs no recompile and cannot glitch the audio.
395    pub fn set_meter(&mut self, on: bool) -> usize {
396        self.meter.on = on;
397        // Voice 0 is as good as any: every voice is the same tree compiled
398        // again, so they share node names, and a subscription is by name.
399        if let Some(v) = self.voices.first() {
400            let voice = &v.voice;
401            self.meter.resubscribe(voice);
402        }
403        self.meter.keys.len()
404    }
405
406    /// The term keys the level buffer is indexed by, as a JSON array.
407    ///
408    /// Read once after [`Self::set_meter`] rather than per quantum — this
409    /// allocates, and the order is fixed until the next patch swap.
410    pub fn meter_keys(&self) -> String {
411        serde_json::to_string(&self.meter.keys).unwrap_or_else(|_| "[]".into())
412    }
413
414    /// Pointer to the RMS dB per tap, in [`Self::meter_keys`] order.
415    ///
416    /// The same zero-allocation contract as [`Self::process_ptr`]: a view into
417    /// wasm memory, overwritten in place, valid until the next patch swap
418    /// resizes it. A tap that has not filled a buffer yet reads
419    /// `f32::NEG_INFINITY` — silence, not zero dB.
420    pub fn meter_ptr(&self) -> *const f32 {
421        self.meter.levels.as_ptr()
422    }
423
424    /// How many taps [`Self::meter_ptr`] holds.
425    pub fn meter_len(&self) -> usize {
426        self.meter.levels.len()
427    }
428
429    /// Queue a patch swap. Parses eagerly (false = bad JSON, nothing
430    /// changes); the actual voice rebuild is amortized over the next few
431    /// silent quanta. Held notes are re-pressed on the new patch.
432    pub fn set_patch(&mut self, tree_json: &str) -> bool {
433        let Ok(tree) = serde_json::from_str::<PatchTree>(tree_json) else {
434            return false;
435        };
436        self.pending = Some(tree);
437        match self.stage {
438            // Already silent/rebuilding: restart the rebuild with the newer
439            // tree (coalesces rapid structural edits).
440            Stage::Rebuild { .. } => self.stage = Stage::Rebuild { built: Vec::new() },
441            _ => self.stage = Stage::FadeOut,
442        }
443        true
444    }
445
446    /// Poll the latest swap event (0 = none, 1 = patched, 2 = error).
447    /// Clears on read.
448    pub fn poll_event(&mut self) -> u32 {
449        std::mem::replace(&mut self.event, EVENT_NONE)
450    }
451
452    /// The message of the last patch error.
453    pub fn last_error(&self) -> String {
454        self.last_error.clone()
455    }
456
457    /// Press a MIDI note (60 = C4) with velocity 0..1. Retriggers if already
458    /// held; otherwise takes a parked voice, else steals the oldest. With
459    /// the arp on, the note joins the held set and the arp presses it.
460    pub fn note_on(&mut self, note: u8, vel: f64) {
461        let vel = (vel.clamp(0.0, 1.0) as f32).max(0.05);
462        self.held.retain(|(n, _)| *n != note);
463        self.held.push((note, vel));
464        if self.arp_on {
465            if self.held.len() == 1 {
466                // First note: fire the arp immediately, not a step later.
467                self.arp_phase = f64::MAX;
468                self.arp_idx = 0;
469                self.arp_up = true;
470            }
471            return;
472        }
473        self.press(note, vel);
474    }
475
476    /// Velocity → output level: perceptual-ish curve with a floor so soft
477    /// notes still speak.
478    fn vel_gain(vel: f32) -> f32 {
479        0.15 + 0.85 * vel.powf(1.4)
480    }
481
482    fn press(&mut self, note: u8, vel: f32) {
483        let target = (note as f64 - 60.0) / 12.0;
484        let start = if self.glide > 0.0 {
485            self.last_pitch.unwrap_or(target)
486        } else {
487            target
488        };
489        let first_press = self.last_pitch.is_none();
490        self.last_pitch = Some(target);
491        if self.unison {
492            // All voices, symmetric detune on the supersaw curve and an
493            // equal-power pan spread. Held gates stay high = legato.
494            let n = self.voices.len().max(1);
495            self.counter += 1;
496            let stamp = self.counter;
497            for i in 0..n {
498                let frac = if n > 1 {
499                    (i as f64 / (n - 1) as f64) * 2.0 - 1.0
500                } else {
501                    0.0
502                };
503                let det = detune_curve(frac) * self.uni_detune * UNI_DETUNE_VOLT;
504                let pan = frac * self.uni_spread;
505                let th = (pan + 1.0) * 0.25 * std::f64::consts::PI;
506                let v = &mut self.voices[i];
507                v.pitch_tgt = target + det;
508                v.pitch_cur = if self.glide > 0.0 {
509                    start + det
510                } else {
511                    v.pitch_tgt
512                };
513                v.voice.pitch.set(v.pitch_cur + self.bend);
514                v.voice.gate.set(GATE_ON);
515                v.regate_in = 0; // unison is deliberately mono-legato
516                v.note = Some(note);
517                v.stamp = stamp;
518                v.running = true;
519                v.silent_run = 0;
520                v.vel = Self::vel_gain(vel);
521                v.pan_l = th.cos() as f32;
522                v.pan_r = th.sin() as f32;
523            }
524            return;
525        }
526        self.counter += 1;
527        let stamp = self.counter;
528        let idx = self
529            .voices
530            .iter()
531            .position(|v| v.note == Some(note))
532            .or_else(|| self.voices.iter().position(|v| !v.running))
533            .or_else(|| {
534                self.voices
535                    .iter()
536                    .enumerate()
537                    .min_by_key(|(_, v)| v.stamp)
538                    .map(|(i, _)| i)
539            });
540        // Is anything under the player's fingers right now? Asked *before* the
541        // new voice is assigned, because it decides whether this press is one
542        // note of a chord or one note of a line.
543        let anything_held = self.voices.iter().any(|v| v.note.is_some());
544        if let Some(i) = idx {
545            let glide_on = self.glide > 0.0;
546            let bend = self.bend;
547            let v = &mut self.voices[i];
548            // Portamento is *per voice* (fingered): a voice that was already
549            // sounding slides from its own pitch, a fresh voice starts on
550            // target. A single global `last_pitch` would chain note→note
551            // through a chord and make it swoop in as a scramble.
552            //
553            // Per-voice alone, though, meant the control did nothing at all
554            // for the one thing portamento is for. Voice assignment prefers a
555            // *free* voice, so a melody played on a four-voice keybed rotates
556            // through voices that were never sounding: `was_sounding` is false
557            // for note after note, and every one of them starts dead on pitch.
558            // The glide fader moved a number that could not be heard unless
559            // you exceeded the polyphony and forced a steal.
560            //
561            // So a line glides too. A press with nothing else held is a line —
562            // it slides from the pitch of the note before it — and a press
563            // made while a key is still down is a chord, which still starts on
564            // target and keeps its attack clean. That is the same distinction
565            // the original comment was protecting; it just wasn't being made.
566            let was_sounding = v.running;
567            v.pitch_tgt = target;
568            v.pitch_cur = if glide_on && was_sounding {
569                v.pitch_cur
570            } else if glide_on && !anything_held && !first_press {
571                start
572            } else {
573                target
574            };
575            v.voice.pitch.set(v.pitch_cur + bend);
576            // Stealing a voice whose gate is still high needs a real rising
577            // edge, or the ADSR never re-enters Attack and the new note
578            // inherits the old note's envelope level.
579            if v.note.is_some() {
580                v.voice.gate.set(0.0);
581                v.regate_in = 1;
582            } else {
583                v.voice.gate.set(GATE_ON);
584                v.regate_in = 0;
585            }
586            v.note = Some(note);
587            v.stamp = stamp;
588            v.running = true;
589            v.silent_run = 0;
590            v.vel = Self::vel_gain(vel);
591            v.pan_l = std::f32::consts::FRAC_1_SQRT_2;
592            v.pan_r = std::f32::consts::FRAC_1_SQRT_2;
593        }
594    }
595
596    fn release_voices(&mut self, note: u8) {
597        for v in &mut self.voices {
598            if v.note == Some(note) {
599                v.voice.gate.set(0.0);
600                v.note = None;
601                v.regate_in = 0; // a pending retrigger must not resurrect it
602            }
603        }
604    }
605
606    /// Release a MIDI note (the voice keeps ringing through its tail).
607    pub fn note_off(&mut self, note: u8) {
608        self.held.retain(|(n, _)| *n != note);
609        if self.arp_on {
610            // Only the arp's own gate matters; other held notes were never
611            // pressed. Match on the *base* note, since with an octave range the
612            // sounding pitch may be a transposition of the key that was let go.
613            if self.arp_base == Some(note) {
614                if let Some(n) = self.arp_note.take() {
615                    self.release_voices(n);
616                }
617                self.arp_base = None;
618            }
619            return;
620        }
621        self.release_voices(note);
622    }
623
624    /// Release everything.
625    pub fn all_off(&mut self) {
626        self.held.clear();
627        self.arp_note = None;
628        self.arp_base = None;
629        for v in &mut self.voices {
630            v.voice.gate.set(0.0);
631            v.note = None;
632            v.regate_in = 0;
633        }
634    }
635
636    /// Pitch bend in semitones (smoothed on the audio thread).
637    pub fn set_bend(&mut self, semitones: f64) {
638        self.bend_tgt = semitones.clamp(-24.0, 24.0) / 12.0;
639    }
640
641    /// Portamento amount 0..1 (0 = off, 1 ≈ 500 ms).
642    pub fn set_glide(&mut self, amount: f64) {
643        self.glide = amount.clamp(0.0, 1.0);
644    }
645
646    /// Unison mode: every voice plays the same note, detuned/panned apart.
647    pub fn set_unison(&mut self, on: bool, detune: f64, spread: f64) {
648        self.unison = on;
649        self.uni_detune = detune.clamp(0.0, 1.0);
650        self.uni_spread = spread.clamp(0.0, 1.0);
651        if !on {
652            // Collapse: keep the newest voice, release the clones.
653            let newest = self.voices.iter().map(|v| v.stamp).max().unwrap_or(0);
654            for v in &mut self.voices {
655                if v.note.is_some() && v.stamp != newest {
656                    v.voice.gate.set(0.0);
657                    v.note = None;
658                }
659                v.pan_l = std::f32::consts::FRAC_1_SQRT_2;
660                v.pan_r = std::f32::consts::FRAC_1_SQRT_2;
661            }
662        } else if let Some(&(note, vel)) = self.held.last() {
663            if !self.arp_on {
664                self.press(note, vel);
665            }
666        }
667    }
668
669    /// Configure the arpeggiator. `mode`: 0 up, 1 down, 2 up-down, 3 random.
670    /// `div`: steps per beat. `gate`: note length as a fraction of the step
671    /// (0.05 staccato … 1.0; at or above [`ARP_TIE`] the pattern is tied and
672    /// slides between pitches instead of retriggering). `octaves`: how many
673    /// octaves the pattern climbs before wrapping (1–4). `swing`: 0–0.75, which
674    /// lengthens every even step and shortens the odd one after it, leaving the
675    /// pair's total duration unchanged.
676    ///
677    /// Turning it off re-presses the held chord; turning it on hands the held
678    /// notes to the scheduler.
679    #[allow(clippy::too_many_arguments)]
680    pub fn set_arp(
681        &mut self,
682        on: bool,
683        mode: u32,
684        div: f64,
685        bpm: f64,
686        gate: f64,
687        octaves: u32,
688        swing: f64,
689    ) {
690        self.arp_mode = mode.min(3);
691        self.arp_div = div.clamp(0.5, 8.0);
692        self.bpm = bpm.clamp(30.0, 300.0);
693        self.arp_gate = if gate.is_finite() {
694            gate.clamp(0.05, 1.0)
695        } else {
696            0.5
697        };
698        self.arp_octaves = octaves.clamp(1, 4);
699        self.arp_swing = if swing.is_finite() {
700            swing.clamp(0.0, 0.75)
701        } else {
702            0.0
703        };
704        if on == self.arp_on {
705            return;
706        }
707        self.arp_on = on;
708        if on {
709            // The scheduler owns the gates now.
710            for &(n, _) in self.held.clone().iter() {
711                self.release_voices(n);
712            }
713            self.arp_note = None;
714            self.arp_base = None;
715            self.arp_phase = f64::MAX; // fire on the next quantum
716            self.arp_idx = 0;
717            self.arp_step = 0;
718            self.arp_up = true;
719        } else {
720            if let Some(n) = self.arp_note.take() {
721                self.release_voices(n);
722            }
723            self.arp_base = None;
724            for &(n, v) in self.held.clone().iter() {
725                self.press(n, v);
726            }
727        }
728    }
729
730    fn next_rand(&mut self) -> u64 {
731        // xorshift64* — deterministic, no wall clock on the audio thread.
732        let mut x = self.rng_state;
733        x ^= x << 13;
734        x ^= x >> 7;
735        x ^= x << 17;
736        self.rng_state = x;
737        x
738    }
739
740    /// Slide the voice currently sounding `from` to pitch `to` without touching
741    /// its gate. This is what makes a tied step tie: no falling edge, so the
742    /// amp envelope keeps its place and (with glide up) the step portamentos.
743    /// Returns false if that voice was stolen out from under us.
744    fn arp_slide(&mut self, from: u8, to: u8, vel: f32) -> bool {
745        let Some(i) = self.voices.iter().position(|v| v.note == Some(from)) else {
746            return false;
747        };
748        let target = (to as f64 - 60.0) / 12.0;
749        let glide_on = self.glide > 0.0;
750        let bend = self.bend;
751        let v = &mut self.voices[i];
752        v.pitch_tgt = target;
753        if !glide_on {
754            v.pitch_cur = target;
755        }
756        v.voice.pitch.set(v.pitch_cur + bend);
757        v.note = Some(to);
758        v.vel = Self::vel_gain(vel);
759        v.silent_run = 0;
760        true
761    }
762
763    /// Advance the arpeggiator by `frames` samples. Step boundaries press the
764    /// next note of the pattern — the held chord sorted by pitch, repeated
765    /// across [`Self::arp_octaves`] octaves — held for `arp_gate` of the step.
766    fn tick_arp(&mut self, frames: usize) {
767        if !self.arp_on {
768            return;
769        }
770        if self.held.is_empty() {
771            if let Some(n) = self.arp_note.take() {
772                self.release_voices(n);
773            }
774            self.arp_base = None;
775            return;
776        }
777        // Swing lengthens even steps and shortens the odd step that follows by
778        // the same amount, so a pair still spans two straight steps and the
779        // pattern does not drift against the beat.
780        let beat = self.sample_rate * 60.0 / (self.bpm * self.arp_div);
781        let step_len = if self.arp_step.is_multiple_of(2) {
782            beat * (1.0 + self.arp_swing)
783        } else {
784            beat * (1.0 - self.arp_swing)
785        };
786        // Tying is meaningless in unison, where every voice is already gated on
787        // the same note and there is no single voice to slide.
788        let tied = self.arp_gate >= ARP_TIE && !self.unison;
789        self.arp_phase = (self.arp_phase + frames as f64).min(f64::MAX);
790        if let Some(n) = self.arp_note {
791            // Release at the gate fraction — or immediately if the key this
792            // step came from was let go mid-step.
793            let key_gone = self
794                .arp_base
795                .is_none_or(|b| !self.held.iter().any(|(h, _)| *h == b));
796            if key_gone || (!tied && self.arp_phase >= step_len * self.arp_gate) {
797                self.release_voices(n);
798                self.arp_note = None;
799                self.arp_base = None;
800            }
801        }
802        if self.arp_phase < step_len {
803            return;
804        }
805        self.arp_phase = 0.0;
806        self.arp_step = self.arp_step.wrapping_add(1);
807        let mut chord: Vec<(u8, f32)> = self.held.clone();
808        chord.sort_by_key(|(n, _)| *n);
809        // (pitch to play, the key it came from, velocity)
810        let mut notes: Vec<(u8, u8, f32)> = Vec::with_capacity(chord.len() * 4);
811        for o in 0..self.arp_octaves {
812            for &(n, vel) in &chord {
813                notes.push((n.saturating_add(12 * o as u8).min(127), n, vel));
814            }
815        }
816        let len = notes.len();
817        let pick = match self.arp_mode {
818            1 => {
819                // Down.
820                self.arp_idx = if self.arp_idx == 0 {
821                    len - 1
822                } else {
823                    (self.arp_idx - 1).min(len - 1)
824                };
825                self.arp_idx
826            }
827            2 => {
828                // Up-down bounce.
829                if len == 1 {
830                    0
831                } else {
832                    if self.arp_up {
833                        self.arp_idx = (self.arp_idx + 1) % len;
834                        if self.arp_idx == len - 1 {
835                            self.arp_up = false;
836                        }
837                    } else {
838                        self.arp_idx = self.arp_idx.saturating_sub(1);
839                        if self.arp_idx == 0 {
840                            self.arp_up = true;
841                        }
842                    }
843                    self.arp_idx.min(len - 1)
844                }
845            }
846            3 => (self.next_rand() as usize) % len,
847            _ => {
848                // Up.
849                self.arp_idx = (self.arp_idx + 1) % len;
850                self.arp_idx
851            }
852        };
853        let (note, base, vel) = notes[pick.min(len - 1)];
854        match self.arp_note.filter(|_| tied) {
855            // Tied: reuse the sounding voice so the gate never falls. If it was
856            // stolen in the meantime, fall back to a normal press.
857            Some(prev) if self.arp_slide(prev, note, vel) => {}
858            _ => self.press(note, vel),
859        }
860        self.arp_note = Some(note);
861        self.arp_base = Some(base);
862    }
863
864    /// Set a knob target in the site's own units. The value ramps in over
865    /// ~25 ms on the audio thread (no zipper) — **no recompilation**: filter
866    /// and delay state survive. Returns false for addresses with no live
867    /// handle (the remaining enums, structure) — those need `set_patch`.
868    ///
869    /// "The site's own units" is new, and it is the whole reason `table` and
870    /// `oct` can be here at all: they send a *category index*, and the blanket
871    /// `clamp(0.0, 1.0)` this used to apply would have folded all eight
872    /// wavetables onto the first two and every octave onto −2 and −1.
873    pub fn set_param(&mut self, addr: &str, value: f64) -> bool {
874        let Some(handle) = self.voices.first().and_then(|v| v.voice.params.get(addr)) else {
875            return false;
876        };
877        let target = handle.map.apply(handle.map.clamp_input(value));
878        let current = handle.value.get();
879        if let Some(s) = self.smoothers.iter_mut().find(|s| s.addr == addr) {
880            s.target = target;
881        } else {
882            self.smoothers.push(Smoother {
883                addr: addr.to_string(),
884                current,
885                target,
886            });
887        }
888        true
889    }
890
891    /// Loudness makeup gain (linear). Applied immediately when idle, or
892    /// deferred to swap completion when a patch swap is pending (so the
893    /// outgoing patch fades at its own level).
894    pub fn set_makeup(&mut self, gain: f64) {
895        let g = gain.clamp(0.1, 8.0) as f32;
896        if self.pending.is_some() {
897            self.pending_makeup = Some(g);
898        } else {
899            self.makeup = g;
900        }
901    }
902
903    /// Advance pitch bend (one-pole) and per-voice glide, then write the
904    /// combined pitch to each sounding voice's atomic.
905    fn advance_pitch(&mut self, frames: usize) {
906        self.bend += (self.bend_tgt - self.bend) * 0.5;
907        if (self.bend - self.bend_tgt).abs() < 1.0e-6 {
908            self.bend = self.bend_tgt;
909        }
910        let dt = frames as f64 / self.sample_rate;
911        let coeff = if self.glide > 0.0 {
912            1.0 - (-dt / (self.glide * 0.5).max(1.0e-3)).exp()
913        } else {
914            1.0
915        };
916        for v in &mut self.voices {
917            if v.note.is_none() && !v.running {
918                continue;
919            }
920            v.pitch_cur += (v.pitch_tgt - v.pitch_cur) * coeff;
921            if (v.pitch_cur - v.pitch_tgt).abs() < 1.0e-6 {
922                v.pitch_cur = v.pitch_tgt;
923            }
924            v.voice.pitch.set(v.pitch_cur + self.bend);
925        }
926    }
927
928    fn advance_smoothers(&mut self) {
929        if self.smoothers.is_empty() {
930            return;
931        }
932        for s in &mut self.smoothers {
933            s.current += (s.target - s.current) * SMOOTH_COEFF;
934            if (s.current - s.target).abs() < SMOOTH_EPS {
935                s.current = s.target;
936            }
937            for v in &self.voices {
938                if let Some(h) = v.voice.params.get(&s.addr) {
939                    h.value.set(s.current);
940                }
941            }
942        }
943        self.smoothers.retain(|s| s.current != s.target);
944    }
945
946    /// Which voice the meter reads, or `None` when metering is off or nothing
947    /// is sounding.
948    ///
949    /// The **most recently pressed** sounding voice, by allocation stamp —
950    /// deliberately not a sum across the bank. A sum averages different notes
951    /// at different envelope phases, which is not the level on any wire; the
952    /// newest voice is the one the player just played and the one whose
953    /// envelope is opening. It is re-chosen every quantum, so the readout
954    /// follows the hand.
955    fn meter_voice(&self) -> Option<usize> {
956        if !self.meter.on {
957            return None;
958        }
959        self.voices
960            .iter()
961            .enumerate()
962            .filter(|(_, v)| v.running)
963            .max_by_key(|(_, v)| v.stamp)
964            .map(|(i, _)| i)
965    }
966
967    fn render_into(&mut self, frames: usize, fade_dir: i8) {
968        self.out_buf.clear();
969        self.out_buf.resize(frames * 2, 0.0);
970        let metered = self.meter_voice();
971        for (vi, v) in self.voices.iter_mut().enumerate() {
972            if !v.running {
973                continue;
974            }
975            let meter_this = metered == Some(vi);
976            let held = v.note.is_some();
977            let mut tail_silent = 0u32;
978            for f in 0..frames {
979                let (l, r) = v.voice.patch.tick();
980                // Per sample, not per quantum: the capture is allocation-free
981                // and quiver's own guidance is that a per-block sample aliases
982                // everything above `sample_rate / (2 * block_size)`. A level
983                // read 128 samples apart is not a level.
984                if meter_this {
985                    self.meter.observer.collect_sample(&v.voice.patch);
986                }
987                // Re-raise *after* the tick: the patch has to actually observe
988                // the low gate for one sample, or the ADSR's edge detector
989                // never sees a falling edge and the retrigger is a no-op.
990                if v.regate_in > 0 {
991                    v.regate_in -= 1;
992                    if v.regate_in == 0 {
993                        v.voice.gate.set(GATE_ON);
994                    }
995                }
996                let g = v.vel * std::f32::consts::SQRT_2 * VOLT_SCALE;
997                self.out_buf[f * 2] += l as f32 * g * v.pan_l;
998                self.out_buf[f * 2 + 1] += r as f32 * g * v.pan_r;
999                if !held && l.abs() + r.abs() < SILENCE_EPS {
1000                    tail_silent += 1;
1001                } else {
1002                    tail_silent = 0;
1003                }
1004            }
1005            if held {
1006                v.silent_run = 0;
1007            } else {
1008                if tail_silent == frames as u32 {
1009                    v.silent_run += tail_silent;
1010                } else {
1011                    v.silent_run = tail_silent;
1012                }
1013                if v.silent_run >= PARK_AFTER {
1014                    v.running = false;
1015                }
1016            }
1017        }
1018        if metered.is_some() {
1019            self.meter.drain();
1020        }
1021        // Per-frame swap fade, loudness makeup, then the master brickwall.
1022        // Each voice carries its own limiter, but N voices sum to N× one
1023        // voice — the master stage is what keeps a held chord off the rail.
1024        for f in 0..frames {
1025            if fade_dir < 0 {
1026                self.gain = (self.gain - FADE_STEP).max(0.0);
1027            } else if fade_dir > 0 {
1028                self.gain = (self.gain + FADE_STEP).min(1.0);
1029            }
1030            let g = self.gain * self.makeup;
1031            let mut l = self.out_buf[f * 2] * g;
1032            let mut r = self.out_buf[f * 2 + 1] * g;
1033            self.master.tick(&mut l, &mut r);
1034            self.out_buf[f * 2] = l;
1035            self.out_buf[f * 2 + 1] = r;
1036        }
1037    }
1038
1039    fn step(&mut self, frames: usize) {
1040        self.advance_smoothers();
1041        self.tick_arp(frames);
1042        self.advance_pitch(frames);
1043        let rebuilding = matches!(self.stage, Stage::Rebuild { .. });
1044        if rebuilding {
1045            // Silent: compile exactly one voice per quantum. Overruns here
1046            // can drop a quantum of *silence* — inaudible.
1047            let Stage::Rebuild { built } = std::mem::replace(&mut self.stage, Stage::FadeIn) else {
1048                unreachable!()
1049            };
1050            let mut built = built;
1051            if let Some(tree) = self.pending.clone() {
1052                match build_voice(&tree, self.sample_rate) {
1053                    Ok(v) => {
1054                        built.push(v);
1055                        if built.len() >= self.n_voices {
1056                            // Where every sounding note's amp envelope had got
1057                            // to, read off the *outgoing* voices while they are
1058                            // still here. This is the whole of the envelope
1059                            // carry: re-pressing a held note on a fresh voice
1060                            // restarts its ADSR from zero, so a sustained pad
1061                            // re-swelled on every structural edit — the edit
1062                            // was audible as an event in its own right rather
1063                            // than as a change to the sound.
1064                            let carry: Vec<(u8, f64)> = self
1065                                .voices
1066                                .iter()
1067                                .filter_map(|v| Some((v.note?, v.voice.env_phase())))
1068                                .collect();
1069                            self.voices = built;
1070                            self.pending = None;
1071                            self.smoothers.clear();
1072                            // New patch, new node ids, and a new set of module
1073                            // keys. Re-taking the subscriptions here is what
1074                            // keeps a stale `NodeId` from resolving against a
1075                            // slotmap that never issued it.
1076                            if let Some(v) = self.voices.first() {
1077                                let voice = &v.voice;
1078                                self.meter.resubscribe(voice);
1079                            }
1080                            if let Some(g) = self.pending_makeup.take() {
1081                                self.makeup = g;
1082                            }
1083                            if self.arp_on {
1084                                // The scheduler re-presses on its next step.
1085                                self.arp_note = None;
1086                            } else {
1087                                for (n, v) in self.held.clone() {
1088                                    self.press(n, v);
1089                                }
1090                                // Gates are up and no falling edge was ever
1091                                // presented, so nothing needs re-gating — the
1092                                // new envelopes are simply fast-forwarded to
1093                                // where the old ones were. A note that was
1094                                // *not* held (a release tail) is not carried:
1095                                // its voice was reallocated, and a tail cannot
1096                                // survive a rewire anyway.
1097                                for v in &mut self.voices {
1098                                    let Some(note) = v.note else { continue };
1099                                    let Some((_, phase)) = carry.iter().find(|(n, _)| *n == note)
1100                                    else {
1101                                        continue;
1102                                    };
1103                                    v.voice.seed_env_phase(*phase);
1104                                }
1105                            }
1106                            self.event = EVENT_PATCHED;
1107                            self.stage = Stage::FadeIn;
1108                        } else {
1109                            self.stage = Stage::Rebuild { built };
1110                        }
1111                    }
1112                    Err(e) => {
1113                        // Keep the old voices; report and fade back in.
1114                        self.last_error = e;
1115                        self.event = EVENT_PATCH_ERROR;
1116                        self.pending = None;
1117                        self.pending_makeup = None;
1118                        self.stage = Stage::FadeIn;
1119                    }
1120                }
1121            }
1122            self.emit_silence(frames);
1123            return;
1124        }
1125        match self.stage {
1126            Stage::Run => self.render_into(frames, 0),
1127            Stage::FadeOut => {
1128                self.render_into(frames, -1);
1129                if self.gain <= 0.0 {
1130                    self.stage = Stage::Rebuild { built: Vec::new() };
1131                }
1132            }
1133            Stage::FadeIn => {
1134                self.render_into(frames, 1);
1135                if self.gain >= 1.0 {
1136                    self.stage = Stage::Run;
1137                }
1138            }
1139            Stage::Rebuild { .. } => unreachable!(),
1140        }
1141    }
1142
1143    fn emit_silence(&mut self, frames: usize) {
1144        self.out_buf.clear();
1145        self.out_buf.resize(frames * 2, 0.0);
1146    }
1147
1148    /// Render `frames` frames into the internal interleaved-stereo buffer
1149    /// and return a pointer into wasm memory — the zero-allocation worklet
1150    /// path (`[l0, r0, l1, r1, …]`, `frames * 2` floats).
1151    pub fn process_ptr(&mut self, frames: usize) -> *const f32 {
1152        self.step(frames);
1153        self.out_buf.as_ptr()
1154    }
1155
1156    /// Render and return a copy (allocating; native tests only).
1157    pub fn process(&mut self, frames: usize) -> Vec<f32> {
1158        self.step(frames);
1159        self.out_buf.clone()
1160    }
1161}
1162
1163#[cfg(test)]
1164mod tests {
1165    use super::*;
1166    use auracle_grammar::{PatchGrammarPrior, Uid};
1167    use rand::rngs::StdRng;
1168    use rand::{Rng, SeedableRng};
1169
1170    fn tree_json(rng: &mut StdRng) -> String {
1171        serde_json::to_string(&PatchGrammarPrior::default().sample_with_rng(rng)).unwrap()
1172    }
1173
1174    /// A plain saw → lowpass voice with a **percussive** amp envelope: fast
1175    /// attack, medium decay, sustain 0. Once the decay has run the voice is
1176    /// silent while its gate is still high, which makes an envelope retrigger
1177    /// unmistakable — with one, a stolen voice speaks; without one, it cannot.
1178    fn plucked_json() -> String {
1179        use auracle_grammar::term::{AmpEnv, FilterKind, Waveform};
1180        use auracle_grammar::{AudioNode, ModNode, PatchTree};
1181        serde_json::to_string(&PatchTree {
1182            amp: AmpEnv {
1183                attack: 0.2,  // ≈6 ms
1184                decay: 0.45,  // ≈63 ms
1185                sustain: 0.0, // the whole point
1186                release: 0.3, // ≈16 ms
1187            },
1188            root: AudioNode::Filter {
1189                uid: Uid::NEW,
1190                kind: FilterKind::SvfLp,
1191                cutoff: 0.7,
1192                resonance: 0.1,
1193                mod_depth: 0.0,
1194                input: Box::new(AudioNode::Vco {
1195                    uid: Uid::NEW,
1196                    wave: Waveform::Saw,
1197                    octave: 0,
1198                    detune: 0.5,
1199                    mod_depth: 0.0,
1200                    modulation: ModNode::None,
1201                }),
1202                modulation: ModNode::None,
1203            },
1204        })
1205        .unwrap()
1206    }
1207
1208    fn peak(buf: &[f32]) -> f32 {
1209        buf.iter().fold(0.0f32, |m, s| m.max(s.abs()))
1210    }
1211
1212    fn energy(buf: &[f32]) -> f64 {
1213        buf.iter().map(|s| (*s as f64) * (*s as f64)).sum()
1214    }
1215
1216    /// Native smoke: a prior patch plays a note (finite, audible), rings a
1217    /// tail after release, and eventually parks its voices.
1218    #[test]
1219    fn live_poly_plays_and_parks() {
1220        let mut rng = StdRng::seed_from_u64(0x11FE);
1221        let json = tree_json(&mut rng);
1222        let mut poly = LivePoly::new(&json, 44_100.0, 4).expect("compiles");
1223
1224        poly.note_on(60, 1.0);
1225        poly.note_on(64, 1.0);
1226        let mut energy = 0.0f64;
1227        for _ in 0..40 {
1228            let out = poly.process(512);
1229            assert!(out.iter().all(|s| s.is_finite()));
1230            energy += out.iter().map(|s| (*s as f64) * (*s as f64)).sum::<f64>();
1231        }
1232        assert!(energy > 1e-6, "held notes produced silence");
1233
1234        poly.note_off(60);
1235        poly.note_off(64);
1236        for _ in 0..900 {
1237            poly.process(512);
1238            if poly.voices.iter().all(|v| !v.running) {
1239                break;
1240            }
1241        }
1242        assert!(
1243            poly.voices.iter().all(|v| !v.running),
1244            "voices never parked after release"
1245        );
1246    }
1247
1248    /// Live params: setting a knob mid-note ramps the sound smoothly to the
1249    /// mapped target without resetting the voice, and junk/enum addresses
1250    /// are refused.
1251    #[test]
1252    fn live_params_ramp_without_retrigger() {
1253        let (_, tree) = auracle_grammar::presets()
1254            .into_iter()
1255            .find(|(n, _)| *n == "First Bass")
1256            .expect("preset exists");
1257        let json = serde_json::to_string(&tree).unwrap();
1258        let mut a = LivePoly::new(&json, 44_100.0, 1).unwrap();
1259        let mut b = LivePoly::new(&json, 44_100.0, 1).unwrap();
1260        a.note_on(48, 1.0);
1261        b.note_on(48, 1.0);
1262        let _ = a.process(2048);
1263        let _ = b.process(2048);
1264        assert!(a.set_param("node#cut", 1.0), "cutoff handle missing");
1265        assert!(
1266            !a.set_param("node#wave", 0.5),
1267            "enum sites must not be live"
1268        );
1269        // Ramp converges to the mapped target.
1270        for _ in 0..64 {
1271            let _ = a.process(128);
1272        }
1273        let cut = a.voices[0]
1274            .voice
1275            .params
1276            .get("node#cut")
1277            .unwrap()
1278            .value
1279            .get();
1280        assert!((cut - 1.0).abs() < 1e-3, "smoother never converged: {cut}");
1281        let out_a = a.process(4096);
1282        let out_b = b.process(4096);
1283        let diff: f64 = out_a
1284            .iter()
1285            .zip(&out_b)
1286            .map(|(x, y)| ((x - y) as f64).abs())
1287            .sum();
1288        assert!(diff > 1e-3, "cutoff change was inaudible (diff {diff})");
1289        let energy: f64 = out_a.iter().map(|s| (*s as f64).powi(2)).sum();
1290        assert!(energy > 1e-8, "voice died on param change");
1291    }
1292
1293    /// Patch swap: output fades (no hard discontinuity), the swap completes
1294    /// with an event, and held notes are re-pressed on the new patch.
1295    #[test]
1296    fn patch_swap_is_gapless_for_held_notes() {
1297        let mut rng = StdRng::seed_from_u64(0x5A5A);
1298        let mut poly = LivePoly::new(&tree_json(&mut rng), 44_100.0, 4).unwrap();
1299        poly.note_on(57, 1.0);
1300        for _ in 0..20 {
1301            let _ = poly.process(128);
1302        }
1303        assert!(poly.set_patch(&tree_json(&mut rng)));
1304        assert!(!poly.set_patch("not json"));
1305
1306        // Drive through the whole transition, collecting the peak of every
1307        // quantum. Click-freeness = the quanta bordering the silent rebuild
1308        // gap are faded to (near) zero — the waveform never truncates hard.
1309        let mut quanta: Vec<(f32, f32, f32)> = Vec::new(); // (peak, first, last)
1310        let mut patched = false;
1311        for _ in 0..200 {
1312            let out = poly.process(128);
1313            assert!(out.iter().all(|s| s.is_finite()));
1314            let peak = out.iter().fold(0.0f32, |m, s| m.max(s.abs()));
1315            quanta.push((peak, out[0].abs(), out[out.len() - 2].abs()));
1316            if poly.poll_event() == EVENT_PATCHED {
1317                patched = true;
1318            }
1319        }
1320        assert!(patched, "swap never completed");
1321        let silent: Vec<usize> = (0..quanta.len()).filter(|&i| quanta[i].0 == 0.0).collect();
1322        assert!(!silent.is_empty(), "no silent rebuild gap observed");
1323        let (first, last) = (silent[0], *silent.last().unwrap());
1324        if first > 0 {
1325            // The final sample before the gap must have been faded to ~0.
1326            assert!(
1327                quanta[first - 1].2 < 0.02,
1328                "hard cut into silence: boundary sample {}",
1329                quanta[first - 1].2
1330            );
1331        }
1332        if last + 1 < quanta.len() {
1333            // The first sample after the gap starts from ~0 (fade-in).
1334            assert!(
1335                quanta[last + 1].1 < 0.02,
1336                "hard jump out of silence: boundary sample {}",
1337                quanta[last + 1].1
1338            );
1339        }
1340        // The held note survived onto the new patch.
1341        assert!(
1342            poly.voices.iter().any(|v| v.note == Some(57)),
1343            "held note lost across patch swap"
1344        );
1345    }
1346
1347    /// A pad: slow attack (≈100 ms), full sustain, so the envelope's position
1348    /// is legible in the output level and a restart is unmissable.
1349    fn pad_json() -> String {
1350        use auracle_grammar::term::{AmpEnv, Waveform};
1351        use auracle_grammar::{AudioNode, ModNode, PatchTree};
1352        serde_json::to_string(&PatchTree {
1353            amp: AmpEnv {
1354                attack: 0.5,
1355                decay: 0.3,
1356                sustain: 1.0,
1357                release: 0.4,
1358            },
1359            root: AudioNode::Vco {
1360                uid: Uid::NEW,
1361                wave: Waveform::Saw,
1362                octave: 0,
1363                detune: 0.5,
1364                mod_depth: 0.0,
1365                modulation: ModNode::None,
1366            },
1367        })
1368        .unwrap()
1369    }
1370
1371    /// **The envelope carry.** Swapping the patch under a held pad used to
1372    /// re-press every held note on the new voices, which restarts their ADSRs
1373    /// from zero — so every structural edit made the pad swell in again from
1374    /// nothing, and the edit was audible as an event of its own rather than as
1375    /// a change to the sound.
1376    ///
1377    /// The patch swapped in here is the *same* tree, so the only thing that can
1378    /// move the level is the envelope. 23 ms of silence across the rebuild is
1379    /// expected and accepted (R3); coming back at a fraction of the level is
1380    /// not.
1381    #[test]
1382    fn a_held_pad_keeps_its_envelope_across_a_patch_swap() {
1383        let json = pad_json();
1384        let mut poly = LivePoly::new(&json, 44_100.0, 4).unwrap();
1385        poly.note_on(60, 1.0);
1386        // ~1.2 s: past a 100 ms attack and its decay, sitting on sustain. The
1387        // level is measured over 16 quanta, not one — a saw at 262 Hz does not
1388        // fit a whole number of periods into 128 frames, so a single quantum's
1389        // energy swings ±40% for reasons that have nothing to do with an
1390        // envelope.
1391        let mut warm: Vec<f64> = Vec::new();
1392        for _ in 0..400 {
1393            warm.push(energy(&poly.process(128)));
1394        }
1395        let mean = |w: &[f64]| w.iter().sum::<f64>() / w.len() as f64;
1396        let before = mean(&warm[384..]);
1397        assert!(before > 1.0e-4, "the pad never spoke: {before}");
1398
1399        assert!(poly.set_patch(&json));
1400        let mut after: Vec<f64> = Vec::new();
1401        let mut patched_at = None;
1402        for i in 0..200 {
1403            let e = energy(&poly.process(128));
1404            after.push(e);
1405            if poly.poll_event() == EVENT_PATCHED && patched_at.is_none() {
1406                patched_at = Some(i);
1407            }
1408        }
1409        let at: usize = patched_at.expect("swap never completed");
1410        // Four quanta past the swap the ~6 ms fade-in is over. Without the
1411        // carry the envelope is ~12 ms into a 100 ms exponential attack —
1412        // about a tenth of the level it left with, climbing.
1413        let resumed = mean(&after[at + 4..at + 20]);
1414        assert!(
1415            resumed > before * 0.85,
1416            "the pad re-attacked: {resumed:.3e} against {before:.3e} before the \
1417             swap ({:.1}% of it)",
1418            100.0 * resumed / before
1419        );
1420        // …and it does not overshoot either, which is what parking a
1421        // mid-envelope note on the sustain shelf would look like from here.
1422        assert!(
1423            mean(&after[at + 4..at + 20]) < before * 1.15,
1424            "the level jumped after the swap: {resumed:.3e} against {before:.3e}"
1425        );
1426    }
1427
1428    /// Below sustain the envelope is unambiguously still in Attack, and the
1429    /// seeder has to leave it there rather than parking it on the sustain
1430    /// shelf: a note swapped 30 ms into a 1 s attack must keep rising.
1431    #[test]
1432    fn a_mid_attack_note_resumes_its_attack_rather_than_jumping_to_sustain() {
1433        use auracle_grammar::term::{AmpEnv, Waveform};
1434        use auracle_grammar::{AudioNode, ModNode, PatchTree};
1435        let json = serde_json::to_string(&PatchTree {
1436            amp: AmpEnv {
1437                attack: 0.75, // ≈1 s
1438                decay: 0.3,
1439                sustain: 1.0,
1440                release: 0.4,
1441            },
1442            root: AudioNode::Vco {
1443                uid: Uid::NEW,
1444                wave: Waveform::Saw,
1445                octave: 0,
1446                detune: 0.5,
1447                mod_depth: 0.0,
1448                modulation: ModNode::None,
1449            },
1450        })
1451        .unwrap();
1452        let mut poly = LivePoly::new(&json, 44_100.0, 4).unwrap();
1453        poly.note_on(60, 1.0);
1454        let mut before = 0.0;
1455        for _ in 0..20 {
1456            before = energy(&poly.process(128));
1457        }
1458        let phase_before = poly.voices[0].voice.env_phase();
1459        assert!(
1460            phase_before > 0.01 && phase_before < 0.3,
1461            "the probe note is not mid-attack: {phase_before}"
1462        );
1463
1464        assert!(poly.set_patch(&json));
1465        let mut patched = false;
1466        for _ in 0..60 {
1467            let _ = poly.process(128);
1468            patched |= poly.poll_event() == EVENT_PATCHED;
1469        }
1470        assert!(patched, "swap never completed");
1471        let phase_after = poly.voices[0].voice.env_phase();
1472        assert!(
1473            phase_after > phase_before * 0.8 && phase_after < 0.5,
1474            "a mid-attack note came back at {phase_after} from {phase_before} — \
1475             either restarted or parked on the sustain shelf"
1476        );
1477        // It is still climbing, which is the half a level check cannot see.
1478        for _ in 0..60 {
1479            let _ = poly.process(128);
1480        }
1481        assert!(
1482            poly.voices[0].voice.env_phase() > phase_after * 1.2,
1483            "the envelope stopped rising after the swap"
1484        );
1485        let _ = before;
1486    }
1487
1488    /// The two categorical sites that went live are reachable through the live
1489    /// path at their *own* domain — an index, not a 0..1 knob — and neither
1490    /// forces a recompile.
1491    #[test]
1492    fn table_and_oct_are_live_at_index_scale() {
1493        use auracle_grammar::term::{AmpEnv, TableShape, Waveform};
1494        use auracle_grammar::{AudioNode, ModNode, PatchTree};
1495        let tree = |root| PatchTree {
1496            amp: AmpEnv {
1497                attack: 0.1,
1498                decay: 0.3,
1499                sustain: 1.0,
1500                release: 0.3,
1501            },
1502            root,
1503        };
1504        let wt = serde_json::to_string(&tree(AudioNode::Wavetable {
1505            uid: Uid::NEW,
1506            table: TableShape::Sine,
1507            octave: 0,
1508            morph: 0.0,
1509            mod_depth: 0.0,
1510            modulation: ModNode::None,
1511        }))
1512        .unwrap();
1513        let mut poly = LivePoly::new(&wt, 44_100.0, 1).unwrap();
1514        poly.note_on(60, 1.0);
1515        for _ in 0..40 {
1516            let _ = poly.process(128);
1517        }
1518        // Table 7 is the last of eight; the old blanket clamp to 0..1 would
1519        // have written table 1.
1520        assert!(poly.set_param("node#table", 7.0), "`table` has no handle");
1521        for _ in 0..40 {
1522            let _ = poly.process(128);
1523        }
1524        let cv = poly.voices[0].voice.params["node#table"].value.get();
1525        assert!(
1526            (cv - 1.0).abs() < 1.0e-3,
1527            "table 7 should land on cv 1.0, not {cv}"
1528        );
1529
1530        let vco = serde_json::to_string(&tree(AudioNode::Vco {
1531            uid: Uid::NEW,
1532            wave: Waveform::Saw,
1533            octave: 0,
1534            detune: 0.5,
1535            mod_depth: 0.0,
1536            modulation: ModNode::None,
1537        }))
1538        .unwrap();
1539        let mut poly = LivePoly::new(&vco, 44_100.0, 1).unwrap();
1540        poly.note_on(60, 1.0);
1541        for _ in 0..40 {
1542            let _ = poly.process(128);
1543        }
1544        // Index 4 is +2 octaves; the compiled octave is 0, so the trim is +2.
1545        assert!(poly.set_param("node#oct", 4.0), "`oct` has no handle");
1546        for _ in 0..60 {
1547            let _ = poly.process(128);
1548        }
1549        let cv = poly.voices[0].voice.params["node#oct"].value.get();
1550        assert!(
1551            (cv - 2.0).abs() < 1.0e-3,
1552            "oct +2 should land on a 2 V trim, not {cv}"
1553        );
1554        // No recompile was queued: the swap machinery never woke up.
1555        assert!(
1556            matches!(poly.stage, Stage::Run),
1557            "a live index write started a patch swap"
1558        );
1559    }
1560
1561    /// The arpeggiator steps through a held chord on its own clock, and
1562    /// velocity scales output level.
1563    #[test]
1564    fn arp_steps_and_velocity_scales() {
1565        let (_, tree) = auracle_grammar::presets()
1566            .into_iter()
1567            .find(|(n, _)| *n == "First Bass")
1568            .expect("preset exists");
1569        let json = serde_json::to_string(&tree).unwrap();
1570
1571        // Velocity: same note, soft vs hard, soft must be quieter.
1572        let energy_at = |vel: f64| {
1573            let mut p = LivePoly::new(&json, 44_100.0, 1).unwrap();
1574            p.note_on(60, vel);
1575            (0..20)
1576                .flat_map(|_| p.process(512))
1577                .map(|s| (s as f64) * (s as f64))
1578                .sum::<f64>()
1579        };
1580        let (soft, hard) = (energy_at(0.15), energy_at(1.0));
1581        assert!(
1582            soft < hard * 0.5,
1583            "velocity had no effect: soft {soft}, hard {hard}"
1584        );
1585
1586        // Arp: hold a triad with the arp on; distinct pitches must be
1587        // pressed over time, and turning it off restores the chord.
1588        let mut p = LivePoly::new(&json, 44_100.0, 4).unwrap();
1589        p.set_arp(true, 0, 4.0, 240.0, 0.5, 1, 0.0); // 16ths at 240 BPM ≈ 16 steps/s
1590        p.note_on(48, 1.0);
1591        p.note_on(52, 1.0);
1592        p.note_on(55, 1.0);
1593        let mut seen = std::collections::HashSet::new();
1594        for _ in 0..400 {
1595            let out = p.process(128);
1596            assert!(out.iter().all(|s| s.is_finite()));
1597            for v in &p.voices {
1598                if let Some(n) = v.note {
1599                    seen.insert(n);
1600                }
1601            }
1602        }
1603        assert!(
1604            seen.len() >= 3,
1605            "arp never cycled the chord: pressed {seen:?}"
1606        );
1607        // At any instant the arp holds at most one gated note.
1608        let gated = p.voices.iter().filter(|v| v.note.is_some()).count();
1609        assert!(gated <= 1, "arp gated {gated} notes at once");
1610        p.set_arp(false, 0, 4.0, 240.0, 0.5, 1, 0.0);
1611        let gated: Vec<_> = p.voices.iter().filter_map(|v| v.note).collect();
1612        assert_eq!(gated.len(), 3, "chord not re-pressed after arp off");
1613    }
1614
1615    /// The master bus holds a full chord inside full scale. Four voices sum to
1616    /// ~4× one voice, and before the master limiter existed a four-note chord
1617    /// sat exactly on the rail — hard-clipped, and clipped again by the device
1618    /// conversion because the old ceiling was above 1.0.
1619    #[test]
1620    fn chord_never_exceeds_full_scale() {
1621        let mut rng = StdRng::seed_from_u64(0xC401);
1622        for i in 0..8 {
1623            let json = tree_json(&mut rng);
1624            let mut poly = LivePoly::new(&json, 44_100.0, 4).unwrap();
1625            for n in [48, 55, 60, 64] {
1626                poly.note_on(n, 1.0);
1627            }
1628            let mut hottest = 0.0f32;
1629            for _ in 0..60 {
1630                let out = poly.process(512);
1631                assert!(out.iter().all(|s| s.is_finite()), "patch {i}: non-finite");
1632                hottest = hottest.max(peak(&out));
1633            }
1634            assert!(
1635                hottest <= 1.0,
1636                "patch {i}: four-note chord peaked at {hottest}"
1637            );
1638            // And it is limited, not clipped: the brickwall lands on the
1639            // ceiling, so nothing should be sitting above it.
1640            assert!(
1641                hottest <= MASTER_CEILING + 1e-6,
1642                "patch {i}: output ran past the ceiling into the clamp ({hottest})"
1643            );
1644        }
1645    }
1646
1647    /// A stolen voice retriggers its amp envelope. On a percussive patch the
1648    /// voice is silent at sustain 0 by the time it is stolen, so the fifth note
1649    /// on a four-voice instrument is *only* audible if the ADSR sees a real
1650    /// falling-then-rising gate edge.
1651    #[test]
1652    fn stolen_voice_retriggers_its_envelope() {
1653        let json = plucked_json();
1654        let mut poly = LivePoly::new(&json, 44_100.0, 1).unwrap();
1655        poly.note_on(60, 1.0);
1656        // Run past the decay: the note has fallen to sustain 0 and is silent
1657        // even though its gate is still high.
1658        for _ in 0..40 {
1659            let _ = poly.process(512);
1660        }
1661        let decayed = energy(&poly.process(4096));
1662        // Steal the (still-held) voice with a new note.
1663        poly.note_on(67, 1.0);
1664        let after_steal = energy(&poly.process(4096));
1665        assert!(
1666            after_steal > decayed * 100.0 && after_steal > 1e-4,
1667            "stolen voice did not retrigger: {decayed:.3e} decayed vs \
1668             {after_steal:.3e} after the steal"
1669        );
1670    }
1671
1672    /// The arp's new controls each do their documented thing: a short gate
1673    /// shortens the note without moving the step clock, an octave range reaches
1674    /// pitches nobody is holding, and swing makes consecutive steps unequal.
1675    #[test]
1676    fn arp_gate_octaves_and_swing() {
1677        let json = plucked_json();
1678        // Octave range: hold one key, span three octaves, collect the pitches
1679        // the scheduler actually presses.
1680        let mut p = LivePoly::new(&json, 44_100.0, 4).unwrap();
1681        p.set_arp(true, 0, 4.0, 240.0, 0.5, 3, 0.0);
1682        p.note_on(48, 1.0);
1683        let mut seen = std::collections::HashSet::new();
1684        for _ in 0..400 {
1685            let _ = p.process(128);
1686            if let Some(n) = p.arp_note {
1687                seen.insert(n);
1688            }
1689        }
1690        assert_eq!(
1691            seen,
1692            [48u8, 60, 72].into_iter().collect(),
1693            "octave range did not transpose the pattern: {seen:?}"
1694        );
1695
1696        // Gate length: staccato must sound for a smaller share of the step than
1697        // legato, with the step clock itself unchanged.
1698        let sounding_frac = |gate: f64| {
1699            let mut p = LivePoly::new(&json, 44_100.0, 4).unwrap();
1700            p.set_arp(true, 0, 2.0, 120.0, gate, 1, 0.0);
1701            p.note_on(48, 1.0);
1702            p.note_on(52, 1.0);
1703            let (mut on, mut total) = (0, 0);
1704            for _ in 0..600 {
1705                let _ = p.process(128);
1706                total += 1;
1707                if p.arp_note.is_some() {
1708                    on += 1;
1709                }
1710            }
1711            on as f64 / total as f64
1712        };
1713        let (staccato, legato) = (sounding_frac(0.1), sounding_frac(0.9));
1714        assert!(
1715            staccato < legato * 0.5,
1716            "gate length had no effect: {staccato:.2} staccato vs {legato:.2} legato"
1717        );
1718
1719        // Swing: measure the sample distance between consecutive note-ons.
1720        let step_gaps = |swing: f64| {
1721            let mut p = LivePoly::new(&json, 44_100.0, 4).unwrap();
1722            p.set_arp(true, 0, 4.0, 120.0, 0.5, 1, swing);
1723            p.note_on(48, 1.0);
1724            p.note_on(52, 1.0);
1725            let mut starts: Vec<usize> = Vec::new();
1726            let mut prev = None;
1727            for q in 0..1200 {
1728                let _ = p.process(128);
1729                if p.arp_note.is_some() && prev.is_none() {
1730                    starts.push(q * 128);
1731                }
1732                prev = p.arp_note;
1733            }
1734            starts.windows(2).map(|w| w[1] - w[0]).collect::<Vec<_>>()
1735        };
1736        let straight = step_gaps(0.0);
1737        let swung = step_gaps(0.6);
1738        let spread = |g: &[usize]| {
1739            let (lo, hi) = (g.iter().min().copied(), g.iter().max().copied());
1740            hi.unwrap_or(0) as i64 - lo.unwrap_or(0) as i64
1741        };
1742        assert!(straight.len() > 3 && swung.len() > 3, "arp never stepped");
1743        assert!(
1744            spread(&swung) > spread(&straight) + 2000,
1745            "swing did not stagger the steps: straight {straight:?}, swung {swung:?}"
1746        );
1747    }
1748
1749    /// Unison detune reaches supersaw width (±60 cents at full travel) and
1750    /// spreads the voices non-uniformly.
1751    #[test]
1752    fn unison_detune_is_wide_and_non_uniform() {
1753        let json = plucked_json();
1754        let mut p = LivePoly::new(&json, 44_100.0, 4).unwrap();
1755        p.set_unison(true, 1.0, 0.5);
1756        p.note_on(60, 1.0);
1757        let mut cents: Vec<f64> = p.voices.iter().map(|v| v.pitch_tgt * 1200.0).collect();
1758        cents.sort_by(|a, b| a.partial_cmp(b).unwrap());
1759        assert!(
1760            (cents[0] + 60.0).abs() < 1.0 && (cents[3] - 60.0).abs() < 1.0,
1761            "unison spread is not ±60 cents: {cents:?}"
1762        );
1763        // Non-uniform: the inner pair sits far closer to centre than an even
1764        // split across four voices (±20 c) would put it.
1765        assert!(
1766            cents[1].abs() < 15.0,
1767            "detune curve is still linear: {cents:?}"
1768        );
1769    }
1770
1771    /// Chaos: random notes, knob writes (real and junk addresses), and
1772    /// patch swaps — output must stay finite forever, no panics.
1773    #[test]
1774    fn live_stress_survives_chaos() {
1775        let mut rng = StdRng::seed_from_u64(0xC405);
1776        let mut poly = LivePoly::new(&tree_json(&mut rng), 44_100.0, 4).unwrap();
1777        let sites = [
1778            "node#cut",
1779            "node#res",
1780            "node#fb",
1781            "node#time",
1782            "amp#attack",
1783            "amp#sustain",
1784            "node/0#cut",
1785            "node/0/1#bal",
1786            "bogus#x",
1787            "",
1788        ];
1789        for i in 0..600 {
1790            match rng.gen_range(0..10) {
1791                0 | 1 => poly.note_on(rng.gen_range(36..85), rng.gen_range(0.0..1.2)),
1792                6 => poly.set_bend(rng.gen_range(-30.0..30.0)),
1793                7 if i % 11 == 0 => poly.set_arp(
1794                    rng.gen_bool(0.5),
1795                    rng.gen_range(0..5),
1796                    rng.gen_range(0.25..9.0),
1797                    rng.gen_range(20.0..400.0),
1798                    rng.gen_range(-0.5..1.5),
1799                    rng.gen_range(0..7),
1800                    rng.gen_range(-0.5..1.5),
1801                ),
1802                8 if i % 13 == 0 => {
1803                    poly.set_unison(rng.gen_bool(0.5), rng.gen(), rng.gen());
1804                    poly.set_glide(rng.gen_range(-0.5..1.5));
1805                    poly.set_makeup(rng.gen_range(0.0..10.0));
1806                }
1807                2 => poly.note_off(rng.gen_range(36..85)),
1808                3 => {
1809                    let _ = poly.set_param(
1810                        sites[rng.gen_range(0..sites.len())],
1811                        rng.gen_range(-1.0..2.0),
1812                    );
1813                }
1814                4 if i % 37 == 0 => {
1815                    let _ = poly.set_patch(&tree_json(&mut rng));
1816                }
1817                5 if i % 97 == 0 => poly.all_off(),
1818                _ => {}
1819            }
1820            let out = poly.process(128);
1821            assert!(
1822                out.iter().all(|s| s.is_finite() && s.abs() <= 1.5),
1823                "iteration {i}: bad sample"
1824            );
1825            let _ = poly.poll_event();
1826        }
1827    }
1828
1829    /// Glide has to be audible on the thing portamento is *for*: a melody.
1830    /// Voice assignment prefers a free voice, so a line rotates through voices
1831    /// that were never sounding — with per-voice-only portamento every note of
1832    /// a tune started dead on pitch and the fader did nothing you could hear.
1833    #[test]
1834    fn glide_slides_a_line_but_not_a_chord() {
1835        let json = plucked_json();
1836
1837        // A line: press, release, press. The second note starts an octave
1838        // below its target and slides up.
1839        let mut p = LivePoly::new(&json, 44_100.0, 4).unwrap();
1840        p.set_glide(0.5);
1841        p.note_on(60, 1.0);
1842        let _ = p.process(256);
1843        p.note_off(60);
1844        let _ = p.process(256);
1845        p.note_on(72, 1.0);
1846        let v = p.voices.iter().find(|v| v.note == Some(72)).unwrap();
1847        assert!(
1848            (v.pitch_tgt - 1.0).abs() < 1.0e-9,
1849            "second note should target C6: {}",
1850            v.pitch_tgt
1851        );
1852        assert!(
1853            v.pitch_cur < 0.1,
1854            "second note of a line must start back at the first note, not on \
1855             pitch (pitch_cur={})",
1856            v.pitch_cur
1857        );
1858
1859        // ...and it actually arrives.
1860        let _ = p.process(44_100 * 4);
1861        let v = p.voices.iter().find(|v| v.note == Some(72)).unwrap();
1862        assert!(
1863            (v.pitch_cur - 1.0).abs() < 1.0e-3,
1864            "glide never reached its target: {}",
1865            v.pitch_cur
1866        );
1867
1868        // A chord: the second note is pressed while the first is still held,
1869        // so it speaks on pitch. Portamento must not scramble a chord.
1870        let mut q = LivePoly::new(&json, 44_100.0, 4).unwrap();
1871        q.set_glide(0.5);
1872        q.note_on(60, 1.0);
1873        let _ = q.process(64);
1874        q.note_on(64, 1.0);
1875        let v = q.voices.iter().find(|v| v.note == Some(64)).unwrap();
1876        assert!(
1877            (v.pitch_cur - v.pitch_tgt).abs() < 1.0e-9,
1878            "a chord tone must start on pitch: cur={} tgt={}",
1879            v.pitch_cur,
1880            v.pitch_tgt
1881        );
1882
1883        // The very first note of the session has nothing to glide from.
1884        let mut r = LivePoly::new(&json, 44_100.0, 4).unwrap();
1885        r.set_glide(1.0);
1886        r.note_on(48, 1.0);
1887        let v = r.voices.iter().find(|v| v.note == Some(48)).unwrap();
1888        assert!(
1889            (v.pitch_cur - v.pitch_tgt).abs() < 1.0e-9,
1890            "the first note ever played swooped in from C4: {}",
1891            v.pitch_cur
1892        );
1893
1894        // Glide off: nothing slides, however the line is played.
1895        let mut o = LivePoly::new(&json, 44_100.0, 4).unwrap();
1896        o.note_on(60, 1.0);
1897        let _ = o.process(256);
1898        o.note_off(60);
1899        let _ = o.process(256);
1900        o.note_on(72, 1.0);
1901        let v = o.voices.iter().find(|v| v.note == Some(72)).unwrap();
1902        assert!(
1903            (v.pitch_cur - v.pitch_tgt).abs() < 1.0e-9,
1904            "glide is off; this must start on pitch: {}",
1905            v.pitch_cur
1906        );
1907    }
1908
1909    /// The meter reads a real level off a real interior port, and reads the
1910    /// mixer's two branches *apart* when the balance is hard over.
1911    ///
1912    /// The second half is what makes this a measurement rather than a smoke
1913    /// test. A crossfader at balance 0 passes branch `a` and mutes branch `b`
1914    /// downstream — but both sources are still oscillating, so a meter reading
1915    /// each module's own output must show both alive. What must differ is the
1916    /// *mix* against its quiet branch. Estimating levels from the term (what
1917    /// the rack did before this) gets that right by construction; the point
1918    /// here is that measuring gets it right too, from the audio.
1919    #[test]
1920    fn meter_reads_levels_off_interior_ports() {
1921        use auracle_grammar::term::{AmpEnv, Waveform};
1922        use auracle_grammar::{AudioNode, ModNode, PatchTree};
1923
1924        let json = serde_json::to_string(&PatchTree {
1925            amp: AmpEnv {
1926                attack: 0.1,
1927                decay: 0.3,
1928                sustain: 1.0,
1929                release: 0.3,
1930            },
1931            root: AudioNode::Mix {
1932                uid: Uid::NEW,
1933                balance: 0.0, // hard over to `a`
1934                a: Box::new(AudioNode::Vco {
1935                    uid: Uid::NEW,
1936                    wave: Waveform::Saw,
1937                    octave: 0,
1938                    detune: 0.5,
1939                    mod_depth: 0.0,
1940                    modulation: ModNode::None,
1941                }),
1942                b: Box::new(AudioNode::Vco {
1943                    uid: Uid::NEW,
1944                    wave: Waveform::Saw,
1945                    octave: 0,
1946                    detune: 0.5,
1947                    mod_depth: 0.0,
1948                    modulation: ModNode::None,
1949                }),
1950            },
1951        })
1952        .unwrap();
1953
1954        let mut poly = LivePoly::new(&json, 44_100.0, 4).expect("compiles");
1955        assert_eq!(poly.meter_len(), 0, "metering must be off until asked for");
1956
1957        let n = poly.set_meter(true);
1958        assert_eq!(n, 3, "one tap per term node: the mix and its two sources");
1959        let keys: Vec<String> = serde_json::from_str(&poly.meter_keys()).unwrap();
1960        assert_eq!(keys, vec!["node", "node/0", "node/1"]);
1961
1962        poly.note_on(60, 1.0);
1963        // Long enough for the 128-sample level buffers to fill several times.
1964        for _ in 0..16 {
1965            let _ = poly.process(512);
1966        }
1967
1968        let db = poly.meter.levels.clone();
1969        assert!(db.iter().all(|d| d.is_finite()), "levels went non-finite");
1970        for (k, d) in keys.iter().zip(&db) {
1971            assert!(*d > -120.0, "tap `{k}` never read a level ({d} dB)");
1972        }
1973
1974        // Both oscillators are running, whatever the crossfader does with them.
1975        let a = db[keys.iter().position(|k| k == "node/0").unwrap()];
1976        let b = db[keys.iter().position(|k| k == "node/1").unwrap()];
1977        assert!(a > -60.0 && b > -60.0, "a source read silent: {a}, {b} dB");
1978
1979        // Off again clears the subscriptions and the buffer with them.
1980        assert_eq!(poly.set_meter(false), 0);
1981        assert_eq!(poly.meter_len(), 0);
1982    }
1983}