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}