Skip to main content

quiver/
polyphony.rs

1//! Polyphony Support
2//!
3//! This module provides voice allocation, per-voice processing, and unison
4//! capabilities for building polyphonic synthesizers.
5//!
6//! # Architecture
7//!
8//! - [`VoiceAllocator`] - Manages which voices get assigned to incoming notes
9//! - [`Voice`] - A single voice's allocation state (note, gate, age, envelope)
10//! - [`VoiceInput`] - An **in-graph** control-signal source (the "voice
11//!   controller"): a [`GraphModule`] whose `voct`/`gate`/`trigger`/`velocity`
12//!   outputs are driven from a shared, lock-free [`VoiceControl`] handle. One is
13//!   inserted into every voice patch so the allocator's per-voice control values
14//!   actually reach the DSP graph.
15//! - [`PolyPatch`] - A polyphonic patch that builds one voice graph per voice
16//!   (times the unison count), routes allocator state into each graph via its
17//!   controller, follows each voice's real output level to time release tails,
18//!   and mixes everything down with polyphony gain compensation.
19//! - [`UnisonConfig`] - Stacked, detuned voices for thick unison sounds.
20//!
21//! # Building a polyphonic synth
22//!
23//! ```
24//! use quiver::prelude::*;
25//!
26//! let sr = 48_000.0;
27//! let mut poly = PolyPatch::with_voice_fn(4, sr, |patch, ctrl| {
28//!     let sr = patch.sample_rate();
29//!     let vco = patch.add("vco", Vco::new(sr));
30//!     let adsr = patch.add("adsr", Adsr::new(sr));
31//!     let vca = patch.add("vca", Vca::new());
32//!     let out = patch.add("out", StereoOutput::new());
33//!     // The controller (`ctrl`) exposes voct / gate / trigger / velocity.
34//!     patch.connect(ctrl.out("voct"), vco.in_("voct"))?;
35//!     patch.connect(ctrl.out("gate"), adsr.in_("gate"))?;
36//!     patch.connect(vco.out("saw"), vca.in_("in"))?;
37//!     patch.connect(adsr.out("env"), vca.in_("cv"))?;
38//!     patch.connect(vca.out("out"), out.in_("left"))?;
39//!     patch.set_output(out.id());
40//!     Ok(())
41//! })
42//! .unwrap();
43//!
44//! poly.note_on(60, 100);
45//! let (_l, _r) = poly.tick();
46//! poly.note_off(60);
47//! ```
48
49use crate::graph::{NodeHandle, Patch, PatchError};
50use crate::port::{GraphModule, PortDef, PortSpec, PortValues, SignalKind};
51use alloc::boxed::Box;
52use alloc::collections::VecDeque;
53use alloc::format;
54use alloc::sync::Arc;
55use alloc::vec;
56use alloc::vec::Vec;
57use core::sync::atomic::Ordering;
58// `AtomicU64` from `portable-atomic`, not `core`: the latter is absent on
59// targets with `max_atomic_width < 64` (e.g. `thumbv7em-none-eabihf`).
60use libm::Libm;
61use portable_atomic::AtomicU64;
62
63// ---------------------------------------------------------------------------
64// Tuning constants (all time-based state derives from these + the sample rate)
65// ---------------------------------------------------------------------------
66
67/// Amplitude-follower time constant used to track each voice's real output
68/// level for release-tail detection.
69const FOLLOWER_TAU_S: f64 = 0.010;
70/// Time constant for smoothing the sounding-voice count feeding the polyphony
71/// gain compensation, so the master gain never steps on note on/off.
72const COUNT_TAU_S: f64 = 0.010;
73/// Minimum time a released voice is kept alive before it may be auto-freed,
74/// even if it already looks quiet. Guards quiet-attack / just-released voices
75/// against being freed one sample after note-off.
76const GRACE_S: f64 = 0.005;
77/// Followed amplitude below which a *released* voice is considered finished.
78const RELEASE_THRESHOLD: f64 = 0.001;
79/// Worst-case lifetime of a `Releasing` voice before it is force-freed, even if
80/// its tracked amplitude never falls below [`RELEASE_THRESHOLD`]. Bounds voice
81/// lifetime so a non-decaying released voice (a drone / self-oscillating / DC
82/// voice whose follower never crosses the threshold) cannot pin an allocator
83/// slot forever and starve every subsequent `note_on` (permanent voice
84/// exhaustion under [`AllocationMode::NoSteal`]). Deliberately generous so it
85/// almost never truncates a legitimate release tail.
86const MAX_RELEASE_S: f64 = 10.0;
87/// Length of the one-shot trigger pulse emitted by [`VoiceInput`], in seconds.
88const TRIGGER_S: f64 = 0.001;
89
90/// One-pole smoothing coefficient for a given time constant and sample rate.
91///
92/// Returns `exp(-1 / (tau · fs))`, clamped to a safe range for degenerate
93/// inputs. A value near 1 means slow smoothing, near 0 means no smoothing.
94#[inline]
95fn one_pole_coeff(tau_s: f64, sample_rate: f64) -> f64 {
96    if sample_rate <= 0.0 || tau_s <= 0.0 {
97        return 0.0;
98    }
99    Libm::<f64>::exp(-1.0 / (tau_s * sample_rate))
100}
101
102/// Stereo *balance* gains for a pan position in `[-1, +1]`.
103///
104/// Unlike a constant-power pan, this law is **unity at the center** and only
105/// attenuates the opposite channel as the position moves off-center, so it
106/// preserves an already-stereo signal instead of applying a blanket −3 dB.
107#[inline]
108fn balance_gains(pan: f64) -> (f64, f64) {
109    let p = pan.clamp(-1.0, 1.0);
110    if p <= 0.0 {
111        (1.0, 1.0 + p) // pan left: right channel fades out
112    } else {
113        (1.0 - p, 1.0) // pan right: left channel fades out
114    }
115}
116
117/// Voice allocation algorithm
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
119pub enum AllocationMode {
120    /// Reuse the oldest voice when all voices are active
121    #[default]
122    RoundRobin,
123    /// Steal the quietest voice (based on the tracked envelope level)
124    QuietestSteal,
125    /// Steal the oldest active voice
126    OldestSteal,
127    /// Never steal - new notes are dropped when no free voice is available
128    NoSteal,
129    /// Highest-note priority: a new note may steal the **lowest-pitched**
130    /// sounding voice, but only when the new note is higher than it. When no
131    /// sounding voice is lower than the new note, the new note is **dropped**
132    /// (`note_on` returns `None`).
133    HighestPriority,
134    /// Lowest-note priority: a new note may steal the **highest-pitched**
135    /// sounding voice, but only when the new note is lower than it. When no
136    /// sounding voice is higher than the new note, the new note is **dropped**
137    /// (`note_on` returns `None`).
138    LowestPriority,
139}
140
141/// State of a single voice
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum VoiceState {
144    /// Voice is not playing
145    Free,
146    /// Voice is currently playing a note
147    Active,
148    /// Voice is in release phase (gate off, but envelope still running)
149    Releasing,
150}
151
152/// A single voice in a polyphonic context
153#[derive(Debug, Clone)]
154pub struct Voice {
155    /// Voice index (0 to num_voices - 1)
156    pub index: usize,
157    /// Current state
158    pub state: VoiceState,
159    /// MIDI note number currently playing (if any)
160    pub note: Option<u8>,
161    /// Current velocity (0.0 to 1.0)
162    pub velocity: f64,
163    /// Current V/Oct value
164    pub voct: f64,
165    /// Gate signal (0.0 or 1.0)
166    pub gate: f64,
167    /// Trigger signal (momentary; asserted on the note-on sample)
168    pub trigger: f64,
169    /// Age counter (samples since note on)
170    pub age: u64,
171    /// Samples elapsed since the gate fell (entering `Releasing`)
172    pub release_samples: u64,
173    /// Current tracked envelope level (0..~1), populated by [`PolyPatch`] from
174    /// the voice's real output amplitude. Drives quiet-steal and release-tail
175    /// auto-free decisions.
176    pub envelope_level: f64,
177}
178
179impl Voice {
180    /// Create a new inactive voice
181    pub fn new(index: usize) -> Self {
182        Self {
183            index,
184            state: VoiceState::Free,
185            note: None,
186            velocity: 0.0,
187            voct: 0.0,
188            gate: 0.0,
189            trigger: 0.0,
190            age: 0,
191            release_samples: 0,
192            envelope_level: 0.0,
193        }
194    }
195
196    /// Trigger the voice with a new note
197    pub fn note_on(&mut self, note: u8, velocity: f64) {
198        self.state = VoiceState::Active;
199        self.note = Some(note);
200        self.velocity = velocity;
201        self.voct = midi_note_to_voct(note);
202        self.gate = 1.0;
203        self.trigger = 1.0; // Cleared after one sample; the controller stretches it
204        self.age = 0;
205        self.release_samples = 0;
206    }
207
208    /// Release the voice
209    pub fn note_off(&mut self) {
210        if self.state == VoiceState::Active {
211            self.state = VoiceState::Releasing;
212            self.gate = 0.0;
213            self.release_samples = 0;
214        }
215    }
216
217    /// Mark voice as completely free
218    pub fn free(&mut self) {
219        self.state = VoiceState::Free;
220        self.note = None;
221        self.velocity = 0.0;
222        self.gate = 0.0;
223        self.trigger = 0.0;
224        self.release_samples = 0;
225        self.envelope_level = 0.0;
226    }
227
228    /// Advance the voice's per-sample bookkeeping (age, release counter, clear
229    /// the one-sample trigger). Auto-free is handled by [`VoiceAllocator`],
230    /// which knows the release threshold and grace period.
231    pub fn tick(&mut self) {
232        self.age = self.age.saturating_add(1);
233        self.trigger = 0.0;
234        if self.state == VoiceState::Releasing {
235            self.release_samples = self.release_samples.saturating_add(1);
236        }
237    }
238
239    /// Check if voice is available for allocation
240    pub fn is_free(&self) -> bool {
241        self.state == VoiceState::Free
242    }
243
244    /// Check if voice is playing the given note
245    pub fn is_playing_note(&self, note: u8) -> bool {
246        self.note == Some(note) && self.state != VoiceState::Free
247    }
248}
249
250/// Convert MIDI note number to V/Oct
251/// MIDI note 60 (C4) = 0V
252#[inline]
253pub fn midi_note_to_voct(note: u8) -> f64 {
254    (note as f64 - 60.0) / 12.0
255}
256
257/// Convert V/Oct to MIDI note number
258#[inline]
259pub fn voct_to_midi_note(voct: f64) -> u8 {
260    Libm::<f64>::round(voct * 12.0 + 60.0).clamp(0.0, 127.0) as u8
261}
262
263/// Voice allocator for polyphonic patches
264#[derive(Debug)]
265pub struct VoiceAllocator {
266    /// Number of available voices
267    num_voices: usize,
268    /// Allocation mode
269    mode: AllocationMode,
270    /// Voice states
271    voices: Vec<Voice>,
272    /// LRU queue for round-robin voice allocation
273    lru_queue: VecDeque<usize>,
274    /// Envelope level below which a `Releasing` voice may be auto-freed
275    release_threshold: f64,
276    /// Minimum samples a voice must have spent releasing before it can be freed
277    release_grace_samples: u64,
278    /// Hard cap on samples a voice may spend `Releasing` before it is
279    /// force-freed regardless of its tracked amplitude. `u64::MAX` disables the
280    /// cap (the standalone default); [`PolyPatch`] installs a finite bound.
281    max_release_samples: u64,
282    /// Index of the voice stolen by the most recent `note_on`, if any
283    last_stolen: Option<usize>,
284}
285
286impl VoiceAllocator {
287    /// Create a new voice allocator
288    pub fn new(num_voices: usize) -> Self {
289        let mut voices = Vec::with_capacity(num_voices);
290        for i in 0..num_voices {
291            voices.push(Voice::new(i));
292        }
293
294        let mut lru_queue = VecDeque::with_capacity(num_voices);
295        for i in 0..num_voices {
296            lru_queue.push_back(i);
297        }
298
299        Self {
300            num_voices,
301            mode: AllocationMode::RoundRobin,
302            voices,
303            lru_queue,
304            // Defaults reproduce the "free a released voice as soon as it is
305            // quiet" behavior for standalone use. `PolyPatch` overrides these
306            // with real levels + a grace period so release tails complete.
307            release_threshold: 0.0001,
308            release_grace_samples: 0,
309            // Unbounded by default for the rate-agnostic standalone allocator;
310            // `PolyPatch` installs a real cap (MAX_RELEASE_S x sample_rate).
311            max_release_samples: u64::MAX,
312            last_stolen: None,
313        }
314    }
315
316    /// Set the allocation mode
317    pub fn set_mode(&mut self, mode: AllocationMode) {
318        self.mode = mode;
319    }
320
321    /// Get the allocation mode
322    pub fn mode(&self) -> AllocationMode {
323        self.mode
324    }
325
326    /// Configure when a `Releasing` voice is auto-freed by [`tick`](Self::tick):
327    /// only once its tracked `envelope_level` falls below `threshold` **and** at
328    /// least `grace_samples` have elapsed since the gate fell.
329    pub fn set_release_criteria(&mut self, threshold: f64, grace_samples: u64) {
330        self.release_threshold = threshold;
331        self.release_grace_samples = grace_samples;
332    }
333
334    /// Set the hard cap (in samples) on how long a voice may stay `Releasing`
335    /// before [`tick`](Self::tick) force-frees it, independent of its tracked
336    /// amplitude. Guards against a non-decaying released voice pinning a slot
337    /// forever (permanent voice exhaustion under [`AllocationMode::NoSteal`]).
338    /// Pass `u64::MAX` to disable the cap.
339    pub fn set_max_release_samples(&mut self, max_samples: u64) {
340        self.max_release_samples = max_samples;
341    }
342
343    /// The current hard release cap in samples (`u64::MAX` if disabled).
344    pub fn max_release_samples(&self) -> u64 {
345        self.max_release_samples
346    }
347
348    /// Get the number of voices
349    pub fn num_voices(&self) -> usize {
350        self.num_voices
351    }
352
353    /// Get a voice by index
354    pub fn voice(&self, index: usize) -> Option<&Voice> {
355        self.voices.get(index)
356    }
357
358    /// Get a mutable voice by index
359    pub fn voice_mut(&mut self, index: usize) -> Option<&mut Voice> {
360        self.voices.get_mut(index)
361    }
362
363    /// Get all voices
364    pub fn voices(&self) -> &[Voice] {
365        &self.voices
366    }
367
368    /// Get all voices mutably
369    pub fn voices_mut(&mut self) -> &mut [Voice] {
370        &mut self.voices
371    }
372
373    /// Count active voices (anything not `Free`)
374    pub fn active_count(&self) -> usize {
375        self.voices
376            .iter()
377            .filter(|v| v.state != VoiceState::Free)
378            .count()
379    }
380
381    /// The voice stolen by the most recent [`note_on`](Self::note_on), if that
382    /// allocation had to steal a sounding voice (rather than reuse a free one or
383    /// retrigger). Consumers use this to reset the stolen voice's DSP.
384    pub fn last_stolen(&self) -> Option<usize> {
385        self.last_stolen
386    }
387
388    /// Allocate a voice for a note.
389    ///
390    /// Returns the voice index if successful, or `None` when the note is dropped
391    /// (no free voice and no eligible steal victim — see [`AllocationMode`]).
392    pub fn note_on(&mut self, note: u8, velocity: f64) -> Option<usize> {
393        self.last_stolen = None;
394
395        // Retrigger: this note is already sounding -> reuse its voice.
396        if let Some(idx) = self.voices.iter().position(|v| v.is_playing_note(note)) {
397            self.voices[idx].note_on(note, velocity);
398            self.update_lru(idx); // Q071: keep LRU order correct on retrigger
399            return Some(idx);
400        }
401
402        // Prefer a free voice.
403        if let Some(idx) = self.find_free_voice() {
404            self.voices[idx].note_on(note, velocity);
405            self.update_lru(idx);
406            return Some(idx);
407        }
408
409        // Otherwise steal, if the mode permits an eligible victim.
410        if let Some(idx) = self.find_steal_voice(note) {
411            self.voices[idx].note_on(note, velocity);
412            self.update_lru(idx);
413            self.last_stolen = Some(idx);
414            return Some(idx);
415        }
416
417        // No free voice and nothing eligible to steal: drop the note.
418        None
419    }
420
421    /// Release a note
422    /// Returns the voice index if the note was found
423    pub fn note_off(&mut self, note: u8) -> Option<usize> {
424        for voice in &mut self.voices {
425            if voice.is_playing_note(note) {
426                voice.note_off();
427                return Some(voice.index);
428            }
429        }
430        None
431    }
432
433    /// Release all notes
434    pub fn all_notes_off(&mut self) {
435        for voice in &mut self.voices {
436            voice.note_off();
437        }
438    }
439
440    /// Kill all voices immediately (panic)
441    pub fn panic(&mut self) {
442        for voice in &mut self.voices {
443            voice.free();
444        }
445    }
446
447    /// Advance all voices one sample, then auto-free finished `Releasing`
448    /// voices according to [`set_release_criteria`](Self::set_release_criteria).
449    pub fn tick(&mut self) {
450        for voice in &mut self.voices {
451            voice.tick();
452        }
453
454        let threshold = self.release_threshold;
455        let grace = self.release_grace_samples;
456        let max_release = self.max_release_samples;
457        for voice in &mut self.voices {
458            if voice.state != VoiceState::Releasing {
459                continue;
460            }
461            // Normal path: freed once genuinely quiet (past the grace period).
462            let quiet_done = voice.envelope_level < threshold && voice.release_samples >= grace;
463            // Safety net: force-free a voice whose amplitude never decays so it
464            // cannot pin a slot forever (permanent exhaustion under NoSteal).
465            let timed_out = voice.release_samples >= max_release;
466            if quiet_done || timed_out {
467                voice.free();
468            }
469        }
470    }
471
472    /// Update envelope level for a voice (for quiet-steal and release-tail
473    /// tracking). Populated every sample by [`PolyPatch`].
474    pub fn set_envelope_level(&mut self, voice_index: usize, level: f64) {
475        if let Some(voice) = self.voices.get_mut(voice_index) {
476            voice.envelope_level = level;
477        }
478    }
479
480    fn find_free_voice(&self) -> Option<usize> {
481        // Use LRU queue for round-robin behavior
482        self.lru_queue
483            .iter()
484            .find(|&&idx| self.voices[idx].is_free())
485            .copied()
486    }
487
488    /// Two-pass voice stealing (Q068): prefer voices already in `Releasing`
489    /// (they are on their way out anyway), and only fall back to `Active`
490    /// voices when no releasing voice qualifies.
491    fn find_steal_voice(&self, note: u8) -> Option<usize> {
492        if self.mode == AllocationMode::NoSteal {
493            return None;
494        }
495        self.select_victim(note, VoiceState::Releasing)
496            .or_else(|| self.select_victim(note, VoiceState::Active))
497    }
498
499    /// Pick the best steal victim among voices in a single `state`, per the
500    /// active [`AllocationMode`]. Returns `None` if no voice in that state
501    /// qualifies.
502    fn select_victim(&self, note: u8, state: VoiceState) -> Option<usize> {
503        let candidates = || self.voices.iter().filter(|v| v.state == state);
504        match self.mode {
505            AllocationMode::NoSteal => None,
506            AllocationMode::RoundRobin | AllocationMode::OldestSteal => {
507                candidates().max_by_key(|v| v.age).map(|v| v.index)
508            }
509            AllocationMode::QuietestSteal => candidates()
510                .min_by(|a, b| {
511                    a.envelope_level
512                        .partial_cmp(&b.envelope_level)
513                        .unwrap_or(core::cmp::Ordering::Equal)
514                })
515                .map(|v| v.index),
516            AllocationMode::HighestPriority => candidates()
517                .filter(|v| v.note.map(|n| n < note).unwrap_or(false))
518                .min_by_key(|v| v.note)
519                .map(|v| v.index),
520            AllocationMode::LowestPriority => candidates()
521                .filter(|v| v.note.map(|n| n > note).unwrap_or(false))
522                .max_by_key(|v| v.note)
523                .map(|v| v.index),
524        }
525    }
526
527    fn update_lru(&mut self, used_idx: usize) {
528        // Move used voice to back of LRU queue
529        if let Some(pos) = self.lru_queue.iter().position(|&x| x == used_idx) {
530            self.lru_queue.remove(pos);
531        }
532        self.lru_queue.push_back(used_idx);
533    }
534}
535
536/// Unison configuration
537#[derive(Debug, Clone)]
538pub struct UnisonConfig {
539    /// Number of stacked voices (1 = no unison)
540    pub voices: usize,
541    /// **Total** edge-to-edge detune spread in cents: the span between the
542    /// lowest and highest stacked voices. Each side is detuned by half this
543    /// amount around the center.
544    pub detune_cents: f64,
545    /// Stereo spread (0.0 = mono/centered, 1.0 = full stereo)
546    pub stereo_spread: f64,
547    /// Voice phase randomization (0.0 = all in phase, 1.0 = random)
548    pub phase_random: f64,
549}
550
551impl Default for UnisonConfig {
552    fn default() -> Self {
553        Self {
554            voices: 1,
555            detune_cents: 0.0,
556            stereo_spread: 0.0,
557            phase_random: 0.0,
558        }
559    }
560}
561
562impl UnisonConfig {
563    /// Create a unison configuration with the given voice count and total
564    /// edge-to-edge detune spread (in cents).
565    pub fn new(voices: usize, detune_cents: f64) -> Self {
566        Self {
567            voices: voices.max(1),
568            detune_cents,
569            stereo_spread: 0.5,
570            phase_random: 0.0,
571        }
572    }
573
574    /// Calculate the detune offset for a specific unison voice, in V/Oct.
575    ///
576    /// `detune_cents` is the **total** edge-to-edge spread, so the lowest and
577    /// highest voices sit at ∓`detune_cents`/2 cents around the center and the
578    /// full span between them equals `detune_cents`. (100 cents = 1 semitone =
579    /// 1/12 octave; 2400 cents = 2 octaves = the ±half-span denominator.)
580    pub fn detune_offset(&self, voice_index: usize) -> f64 {
581        if self.voices <= 1 {
582            return 0.0;
583        }
584
585        // Spread voices evenly across the detune range.
586        let normalized = voice_index as f64 / (self.voices - 1) as f64;
587        let centered = normalized * 2.0 - 1.0; // -1 to +1
588
589        // Half the total spread on each side: edges land at ±detune_cents/2.
590        centered * self.detune_cents / 2400.0
591    }
592
593    /// Calculate the stereo pan position for a specific unison voice
594    /// Returns pan value (-1.0 = left, 0.0 = center, 1.0 = right)
595    pub fn pan_position(&self, voice_index: usize) -> f64 {
596        if self.voices <= 1 {
597            return 0.0;
598        }
599
600        let normalized = voice_index as f64 / (self.voices - 1) as f64;
601        let centered = normalized * 2.0 - 1.0; // -1 to +1
602        centered * self.stereo_spread
603    }
604
605    /// Get the gain multiplier per unison voice to keep the summed level roughly
606    /// constant as voices are stacked (equal-power: `1/sqrt(voices)`).
607    pub fn voice_gain(&self) -> f64 {
608        1.0 / Libm::<f64>::sqrt(self.voices.max(1) as f64)
609    }
610}
611
612/// Lock-free shared control handle for a single voice.
613///
614/// Mirrors the atomic-value pattern used by [`crate::io::ExternalInput`]: the
615/// owner ([`PolyPatch`], or any external driver) writes the current control
616/// values, while the in-graph [`VoiceInput`] node reads them from inside the
617/// voice patch on its next `tick`. Interior mutability (via `AtomicU64` bit-
618/// packed `f64`s) is what lets the same values be shared with a node that has
619/// been moved (boxed) into a [`Patch`].
620#[derive(Debug)]
621pub struct VoiceControl {
622    voct: AtomicU64,
623    gate: AtomicU64,
624    trigger: AtomicU64,
625    velocity: AtomicU64,
626}
627
628impl VoiceControl {
629    /// Create a new control handle (all values zero, velocity 1.0).
630    pub fn new() -> Self {
631        Self {
632            voct: AtomicU64::new(0f64.to_bits()),
633            gate: AtomicU64::new(0f64.to_bits()),
634            trigger: AtomicU64::new(0f64.to_bits()),
635            velocity: AtomicU64::new(1f64.to_bits()),
636        }
637    }
638
639    #[inline]
640    fn load(a: &AtomicU64) -> f64 {
641        f64::from_bits(a.load(Ordering::Relaxed))
642    }
643
644    #[inline]
645    fn store(a: &AtomicU64, v: f64) {
646        a.store(v.to_bits(), Ordering::Relaxed);
647    }
648
649    /// Current V/Oct pitch.
650    pub fn voct(&self) -> f64 {
651        Self::load(&self.voct)
652    }
653    /// Current gate value (0 = off, ≥1 = on).
654    pub fn gate(&self) -> f64 {
655        Self::load(&self.gate)
656    }
657    /// Current trigger *request* (a rising edge starts a one-shot pulse).
658    pub fn trigger(&self) -> f64 {
659        Self::load(&self.trigger)
660    }
661    /// Current velocity (0..1).
662    pub fn velocity(&self) -> f64 {
663        Self::load(&self.velocity)
664    }
665
666    /// Set V/Oct pitch.
667    pub fn set_voct(&self, v: f64) {
668        Self::store(&self.voct, v);
669    }
670    /// Set gate value.
671    pub fn set_gate(&self, v: f64) {
672        Self::store(&self.gate, v);
673    }
674    /// Set trigger request (assert `1.0` for one sample to fire a pulse).
675    pub fn set_trigger(&self, v: f64) {
676        Self::store(&self.trigger, v);
677    }
678    /// Set velocity.
679    pub fn set_velocity(&self, v: f64) {
680        Self::store(&self.velocity, v);
681    }
682
683    fn reset(&self) {
684        self.set_voct(0.0);
685        self.set_gate(0.0);
686        self.set_trigger(0.0);
687        self.set_velocity(1.0);
688    }
689}
690
691impl Default for VoiceControl {
692    fn default() -> Self {
693        Self::new()
694    }
695}
696
697/// In-graph voice controller: the per-voice control-signal source.
698///
699/// This [`GraphModule`] is inserted into every voice patch and outputs the
700/// per-voice `voct`, `gate`, `trigger`, and `velocity` signals. Values come from
701/// a shared [`VoiceControl`] handle written by [`PolyPatch`] (or any external
702/// driver), so allocator state genuinely reaches the DSP graph.
703///
704/// The `trigger` output is a proper **one-shot pulse measured in samples**: a
705/// rising edge on the control handle's trigger request emits `5 V` for
706/// `TRIGGER_S` seconds' worth of samples, regardless of how briefly the request
707/// was asserted.
708///
709/// The name is kept as `VoiceInput` for API continuity; conceptually it is the
710/// voice controller.
711pub struct VoiceInput {
712    control: Arc<VoiceControl>,
713    spec: PortSpec,
714    trigger_len: u32,
715    trigger_remaining: u32,
716    prev_trigger_req: f64,
717}
718
719impl VoiceInput {
720    /// Create a new voice input backed by a fresh, private control handle.
721    pub fn new() -> Self {
722        Self::with_control(Arc::new(VoiceControl::new()), 48_000.0)
723    }
724
725    /// Create a voice input driven by a shared control handle at a given sample
726    /// rate (used by [`PolyPatch`] so it can write values from the outside).
727    pub fn with_control(control: Arc<VoiceControl>, sample_rate: f64) -> Self {
728        Self {
729            control,
730            spec: PortSpec {
731                inputs: vec![],
732                outputs: vec![
733                    PortDef::new(0, "voct", SignalKind::VoltPerOctave),
734                    PortDef::new(1, "gate", SignalKind::Gate),
735                    PortDef::new(2, "trigger", SignalKind::Trigger),
736                    PortDef::new(3, "velocity", SignalKind::CvUnipolar),
737                ],
738            },
739            trigger_len: Self::trigger_len_for(sample_rate),
740            trigger_remaining: 0,
741            prev_trigger_req: 0.0,
742        }
743    }
744
745    #[inline]
746    fn trigger_len_for(sample_rate: f64) -> u32 {
747        (Libm::<f64>::round(TRIGGER_S * sample_rate)).max(1.0) as u32
748    }
749
750    /// The shared control handle this input reads from.
751    pub fn control(&self) -> &Arc<VoiceControl> {
752        &self.control
753    }
754
755    /// Copy the allocator voice's control values into the shared handle.
756    pub fn set_from_voice(&mut self, voice: &Voice) {
757        self.control.set_voct(voice.voct);
758        self.control.set_gate(voice.gate);
759        self.control.set_trigger(voice.trigger);
760        self.control.set_velocity(voice.velocity);
761    }
762
763    /// Set V/Oct directly (writes the shared handle).
764    pub fn set_voct(&mut self, voct: f64) {
765        self.control.set_voct(voct);
766    }
767
768    /// Set gate directly.
769    pub fn set_gate(&mut self, gate: f64) {
770        self.control.set_gate(gate);
771    }
772
773    /// Set trigger request directly.
774    pub fn set_trigger(&mut self, trigger: f64) {
775        self.control.set_trigger(trigger);
776    }
777
778    /// Set velocity directly.
779    pub fn set_velocity(&mut self, velocity: f64) {
780        self.control.set_velocity(velocity);
781    }
782
783    /// Current V/Oct value (reads the shared handle).
784    pub fn voct(&self) -> f64 {
785        self.control.voct()
786    }
787    /// Current gate value.
788    pub fn gate(&self) -> f64 {
789        self.control.gate()
790    }
791    /// Current trigger request value.
792    pub fn trigger(&self) -> f64 {
793        self.control.trigger()
794    }
795    /// Current velocity value.
796    pub fn velocity(&self) -> f64 {
797        self.control.velocity()
798    }
799}
800
801impl Default for VoiceInput {
802    fn default() -> Self {
803        Self::new()
804    }
805}
806
807impl GraphModule for VoiceInput {
808    fn port_spec(&self) -> &PortSpec {
809        &self.spec
810    }
811
812    fn tick(&mut self, _inputs: &PortValues, outputs: &mut PortValues) {
813        outputs.set(0, self.control.voct());
814        outputs.set(1, if self.control.gate() > 0.5 { 5.0 } else { 0.0 });
815
816        // Turn a rising edge on the trigger request into a fixed-length pulse.
817        let req = self.control.trigger();
818        if req > 0.5 && self.prev_trigger_req <= 0.5 {
819            self.trigger_remaining = self.trigger_len;
820        }
821        self.prev_trigger_req = req;
822        let trig_out = if self.trigger_remaining > 0 {
823            self.trigger_remaining -= 1;
824            5.0
825        } else {
826            0.0
827        };
828        outputs.set(2, trig_out);
829
830        outputs.set(3, self.control.velocity() * 10.0); // Scale to 0-10V
831    }
832
833    fn reset(&mut self) {
834        self.control.reset();
835        self.trigger_remaining = 0;
836        self.prev_trigger_req = 0.0;
837    }
838
839    fn set_sample_rate(&mut self, sample_rate: f64) {
840        self.trigger_len = Self::trigger_len_for(sample_rate);
841    }
842
843    fn type_id(&self) -> &'static str {
844        "voice_input"
845    }
846}
847
848/// Type of the closure that builds a single voice graph.
849///
850/// It receives a fresh [`Patch`] (already containing the voice controller) and a
851/// [`NodeHandle`] to that controller, wires up the voice's DSP, and sets the
852/// patch output.
853type VoiceBuilder = dyn Fn(&mut Patch, &NodeHandle) -> Result<(), PatchError>;
854
855/// One rendered sub-voice: an independent voice graph plus its control handle.
856///
857/// A monophonic [`PolyPatch`] voice has exactly one sub-voice; a unison voice
858/// has one per unison stack member, each detuned/panned differently.
859struct SubVoice {
860    patch: Patch,
861    control: Arc<VoiceControl>,
862    controller: NodeHandle,
863}
864
865/// A single allocator voice's set of (unison) sub-voice graphs plus its
866/// amplitude follower state.
867struct VoiceSlot {
868    subs: Vec<SubVoice>,
869    /// One-pole follower of this voice's real output amplitude (Q064).
870    follower: f64,
871}
872
873/// Polyphonic patch container.
874///
875/// Owns one voice graph per allocator voice (multiplied by the unison count),
876/// each fed by an in-graph [`VoiceInput`] controller. On every [`tick`](Self::tick), it:
877///
878/// 1. writes each active voice's allocator state into its controller handle(s),
879/// 2. ticks each voice graph exactly once and mixes the results (with unison
880///    detune/balance and equal-power unison gain),
881/// 3. follows each voice's real output level and reports it to the allocator so
882///    release tails complete before the voice is freed,
883/// 4. applies smoothed polyphony gain compensation (`1/sqrt(N)`).
884///
885/// [`PolyPatch::tick`] performs **no heap allocation** in steady state; all
886/// allocation happens at construction / reconfiguration time.
887pub struct PolyPatch {
888    /// Voice allocator
889    allocator: VoiceAllocator,
890    /// Per-voice graphs (one [`VoiceSlot`] per allocator voice)
891    voices: Vec<VoiceSlot>,
892    /// Unison configuration
893    unison: UnisonConfig,
894    /// Sample rate
895    sample_rate: f64,
896    /// Builder used to (re)construct each voice graph.
897    builder: Option<Box<VoiceBuilder>>,
898    /// Smoothed sounding-voice count for gain compensation (Q067).
899    smoothed_count: f64,
900    /// Cached one-pole coefficients / grace (recomputed on sample-rate change).
901    follower_coeff: f64,
902    count_coeff: f64,
903    grace_samples: u64,
904    /// Output buffers (left, right)
905    output_left: f64,
906    output_right: f64,
907}
908
909impl PolyPatch {
910    /// Create a new polyphonic patch whose voice graphs contain only the voice
911    /// controller (no DSP). Use [`with_voice_fn`](Self::with_voice_fn) to build
912    /// real voices; this bare constructor is mostly useful for benchmarking the
913    /// allocation/mixing machinery.
914    pub fn new(num_voices: usize, sample_rate: f64) -> Self {
915        // A controller-only voice graph can never fail to build/compile.
916        Self::build(num_voices, sample_rate, None).expect("empty voice build cannot fail")
917    }
918
919    /// Create a polyphonic patch, building each voice graph with `builder`.
920    ///
921    /// The builder is invoked once per voice (and once per unison sub-voice),
922    /// each time receiving a fresh patch pre-populated with a voice controller
923    /// and a [`NodeHandle`] to it. Wire the controller's `voct`/`gate`/`trigger`/
924    /// `velocity` outputs into your DSP and call `patch.set_output(..)`.
925    pub fn with_voice_fn<F>(
926        num_voices: usize,
927        sample_rate: f64,
928        builder: F,
929    ) -> Result<Self, PatchError>
930    where
931        F: Fn(&mut Patch, &NodeHandle) -> Result<(), PatchError> + 'static,
932    {
933        Self::build(num_voices, sample_rate, Some(Box::new(builder)))
934    }
935
936    fn build(
937        num_voices: usize,
938        sample_rate: f64,
939        builder: Option<Box<VoiceBuilder>>,
940    ) -> Result<Self, PatchError> {
941        let mut poly = Self {
942            allocator: VoiceAllocator::new(num_voices),
943            voices: Vec::new(),
944            unison: UnisonConfig::default(),
945            sample_rate,
946            builder,
947            smoothed_count: 0.0,
948            follower_coeff: 0.0,
949            count_coeff: 0.0,
950            grace_samples: 0,
951            output_left: 0.0,
952            output_right: 0.0,
953        };
954        poly.recompute_coeffs();
955        poly.voices = poly.build_voices()?;
956        Ok(poly)
957    }
958
959    fn recompute_coeffs(&mut self) {
960        self.follower_coeff = one_pole_coeff(FOLLOWER_TAU_S, self.sample_rate);
961        self.count_coeff = one_pole_coeff(COUNT_TAU_S, self.sample_rate);
962        self.grace_samples = (GRACE_S * self.sample_rate).max(1.0) as u64;
963        self.allocator
964            .set_release_criteria(RELEASE_THRESHOLD, self.grace_samples);
965        self.allocator
966            .set_max_release_samples(Self::release_cap_samples(MAX_RELEASE_S, self.sample_rate));
967    }
968
969    /// Convert a max-release time in seconds to a sample count for the allocator
970    /// cap, saturating (a non-positive/non-finite time disables the cap).
971    fn release_cap_samples(seconds: f64, sample_rate: f64) -> u64 {
972        let samples = seconds * sample_rate;
973        if !samples.is_finite() || samples <= 0.0 {
974            u64::MAX
975        } else {
976            (samples as u64).max(1)
977        }
978    }
979
980    /// Set the worst-case time (seconds) a released voice may keep sounding
981    /// before the allocator force-frees it, bounding voice lifetime so a
982    /// non-decaying (drone / self-oscillating) voice cannot permanently pin a
983    /// slot. A non-positive or non-finite value disables the cap.
984    pub fn set_max_release_time(&mut self, seconds: f64) {
985        self.allocator
986            .set_max_release_samples(Self::release_cap_samples(seconds, self.sample_rate));
987    }
988
989    /// Build the full set of voice graphs from the current configuration.
990    ///
991    /// Allocation-heavy; only called at construction / reconfiguration time.
992    fn build_voices(&self) -> Result<Vec<VoiceSlot>, PatchError> {
993        let unison_voices = self.unison.voices.max(1);
994        let mut voices = Vec::with_capacity(self.allocator.num_voices());
995        for _ in 0..self.allocator.num_voices() {
996            let mut subs = Vec::with_capacity(unison_voices);
997            for _ in 0..unison_voices {
998                let control = Arc::new(VoiceControl::new());
999                let mut patch = Patch::new(self.sample_rate);
1000                let controller = patch.add(
1001                    "voice_ctrl",
1002                    VoiceInput::with_control(control.clone(), self.sample_rate),
1003                );
1004                if let Some(builder) = &self.builder {
1005                    builder(&mut patch, &controller)?;
1006                }
1007                patch.compile()?;
1008                subs.push(SubVoice {
1009                    patch,
1010                    control,
1011                    controller,
1012                });
1013            }
1014            voices.push(VoiceSlot {
1015                subs,
1016                follower: 0.0,
1017            });
1018        }
1019        Ok(voices)
1020    }
1021
1022    /// Get the sample rate
1023    pub fn sample_rate(&self) -> f64 {
1024        self.sample_rate
1025    }
1026
1027    /// Number of allocator voices.
1028    pub fn num_voices(&self) -> usize {
1029        self.allocator.num_voices()
1030    }
1031
1032    /// Set the sample rate and rebuild every voice graph at the new rate.
1033    ///
1034    /// Rebuilding re-runs the voice builder so all modules (and the controller's
1035    /// trigger-pulse length, the amplitude follower, and the release grace) pick
1036    /// up the new sample rate (Q069). Voice DSP state is reset as a result,
1037    /// which is acceptable for a sample-rate change.
1038    pub fn set_sample_rate(&mut self, sample_rate: f64) {
1039        self.sample_rate = sample_rate;
1040        self.recompute_coeffs();
1041        if let Ok(voices) = self.build_voices() {
1042            self.voices = voices;
1043        }
1044        self.smoothed_count = 0.0;
1045    }
1046
1047    /// The shared control handle for a voice's first sub-voice, if any.
1048    pub fn voice_control(&self, index: usize) -> Option<&Arc<VoiceControl>> {
1049        self.voices
1050            .get(index)
1051            .and_then(|v| v.subs.first())
1052            .map(|s| &s.control)
1053    }
1054
1055    /// A [`NodeHandle`] to a voice's controller node (first sub-voice), so the
1056    /// controller ports can be referenced after construction.
1057    pub fn voice_controller(&self, index: usize) -> Option<&NodeHandle> {
1058        self.voices
1059            .get(index)
1060            .and_then(|v| v.subs.first())
1061            .map(|s| &s.controller)
1062    }
1063
1064    /// Get the voice allocator
1065    pub fn allocator(&self) -> &VoiceAllocator {
1066        &self.allocator
1067    }
1068
1069    /// Get mutable access to the voice allocator
1070    pub fn allocator_mut(&mut self) -> &mut VoiceAllocator {
1071        &mut self.allocator
1072    }
1073
1074    /// Set unison configuration. Changing the unison **voice count** rebuilds
1075    /// the voice graphs; changing only detune/spread takes effect immediately
1076    /// without a rebuild.
1077    pub fn set_unison(&mut self, config: UnisonConfig) {
1078        let count_changed = config.voices.max(1) != self.unison.voices.max(1);
1079        self.unison = config;
1080        if count_changed {
1081            if let Ok(voices) = self.build_voices() {
1082                self.voices = voices;
1083            }
1084        }
1085    }
1086
1087    /// Get unison configuration
1088    pub fn unison(&self) -> &UnisonConfig {
1089        &self.unison
1090    }
1091
1092    /// Get a voice's (first sub-voice) patch for inspection.
1093    pub fn voice_patch(&self, index: usize) -> Option<&Patch> {
1094        self.voices
1095            .get(index)
1096            .and_then(|v| v.subs.first())
1097            .map(|s| &s.patch)
1098    }
1099
1100    /// Get a voice's (first sub-voice) patch mutably.
1101    pub fn voice_patch_mut(&mut self, index: usize) -> Option<&mut Patch> {
1102        self.voices
1103            .get_mut(index)
1104            .and_then(|v| v.subs.first_mut())
1105            .map(|s| &mut s.patch)
1106    }
1107
1108    /// Handle MIDI note on. When the allocation stole a sounding voice, the
1109    /// stolen voice's DSP is reset to prevent the previous note's tail from
1110    /// bleeding into the new note (Q070). Fresh (free) allocations are **not**
1111    /// reset, preserving oscillator phase continuity for an analog feel.
1112    pub fn note_on(&mut self, note: u8, velocity: u8) {
1113        let velocity_f = velocity as f64 / 127.0;
1114        if let Some(idx) = self.allocator.note_on(note, velocity_f) {
1115            if self.allocator.last_stolen() == Some(idx) {
1116                if let Some(slot) = self.voices.get_mut(idx) {
1117                    for sub in &mut slot.subs {
1118                        sub.patch.reset();
1119                    }
1120                    slot.follower = 0.0;
1121                }
1122            }
1123        }
1124    }
1125
1126    /// Handle MIDI note off
1127    pub fn note_off(&mut self, note: u8) {
1128        self.allocator.note_off(note);
1129    }
1130
1131    /// All notes off
1132    pub fn all_notes_off(&mut self) {
1133        self.allocator.all_notes_off();
1134    }
1135
1136    /// Panic - immediately silence all voices
1137    pub fn panic(&mut self) {
1138        self.allocator.panic();
1139    }
1140
1141    /// Compile all voice graphs.
1142    pub fn compile(&mut self) -> Result<(), PatchError> {
1143        for slot in &mut self.voices {
1144            for sub in &mut slot.subs {
1145                sub.patch.compile()?;
1146            }
1147        }
1148        Ok(())
1149    }
1150
1151    /// The current polyphony gain-compensation factor (`1/sqrt(N)` with the
1152    /// voice count `N` smoothed). Exposed mainly for testing that the factor
1153    /// moves smoothly rather than stepping.
1154    pub fn compensation_gain(&self) -> f64 {
1155        1.0 / Libm::<f64>::sqrt(self.smoothed_count.max(1.0))
1156    }
1157
1158    /// Process one sample and return stereo output.
1159    pub fn tick(&mut self) -> (f64, f64) {
1160        // Snapshot config into locals so the hot loop borrows neither `unison`
1161        // (cheap scalar clone — no heap) nor `self` through a method.
1162        let unison = self.unison.clone();
1163        let unison_voices = unison.voices.max(1);
1164        let unison_gain = unison.voice_gain();
1165        let use_pan = unison_voices > 1 && unison.stereo_spread != 0.0;
1166        let follower_coeff = self.follower_coeff;
1167
1168        // Smooth the sounding-voice count (pre-free) for gain compensation.
1169        let inst_count = self.allocator.active_count() as f64;
1170        self.smoothed_count =
1171            self.count_coeff * self.smoothed_count + (1.0 - self.count_coeff) * inst_count;
1172
1173        let mut left = 0.0;
1174        let mut right = 0.0;
1175
1176        for i in 0..self.voices.len() {
1177            let (state, base_voct, gate, trigger, velocity) = {
1178                let v = &self.allocator.voices()[i];
1179                (v.state, v.voct, v.gate, v.trigger, v.velocity)
1180            };
1181
1182            if state == VoiceState::Free {
1183                self.voices[i].follower = 0.0;
1184                continue;
1185            }
1186
1187            let slot = &mut self.voices[i];
1188            let mut peak = 0.0;
1189            for (u, sub) in slot.subs.iter_mut().enumerate() {
1190                // Unison detune summed into this sub-voice's pitch.
1191                sub.control.set_voct(base_voct + unison.detune_offset(u));
1192                sub.control.set_gate(gate);
1193                sub.control.set_trigger(trigger);
1194                sub.control.set_velocity(velocity);
1195
1196                let (l, r) = sub.patch.tick();
1197
1198                let (lg, rg) = if use_pan {
1199                    balance_gains(unison.pan_position(u))
1200                } else {
1201                    (1.0, 1.0)
1202                };
1203                let sl = l * lg * unison_gain;
1204                let sr = r * rg * unison_gain;
1205                left += sl;
1206                right += sr;
1207
1208                let mag = Libm::<f64>::fabs(sl).max(Libm::<f64>::fabs(sr));
1209                if mag > peak {
1210                    peak = mag;
1211                }
1212            }
1213
1214            // Track this voice's real output level for release-tail detection.
1215            slot.follower = follower_coeff * slot.follower + (1.0 - follower_coeff) * peak;
1216            let level = slot.follower;
1217            self.allocator.set_envelope_level(i, level);
1218        }
1219
1220        // Advance allocator state and free finished release tails (uses the
1221        // envelope levels just written above + the configured grace period).
1222        self.allocator.tick();
1223
1224        // Polyphony gain compensation (smoothed, never steps).
1225        let g = 1.0 / Libm::<f64>::sqrt(self.smoothed_count.max(1.0));
1226        left *= g;
1227        right *= g;
1228
1229        self.output_left = left;
1230        self.output_right = right;
1231        (left, right)
1232    }
1233
1234    /// Get the last output
1235    pub fn output(&self) -> (f64, f64) {
1236        (self.output_left, self.output_right)
1237    }
1238
1239    /// Reset all voice graphs and allocator state.
1240    pub fn reset(&mut self) {
1241        for slot in &mut self.voices {
1242            slot.follower = 0.0;
1243            for sub in &mut slot.subs {
1244                sub.patch.reset();
1245            }
1246        }
1247        self.allocator.panic();
1248        self.smoothed_count = 0.0;
1249        self.output_left = 0.0;
1250        self.output_right = 0.0;
1251    }
1252}
1253
1254/// Voice mixer for summing polyphonic voices
1255pub struct VoiceMixer {
1256    num_voices: usize,
1257    spec: PortSpec,
1258}
1259
1260impl VoiceMixer {
1261    /// Create a voice mixer for the given number of voices
1262    pub fn new(num_voices: usize) -> Self {
1263        let mut inputs = Vec::with_capacity(num_voices * 2);
1264        for i in 0..num_voices {
1265            inputs.push(PortDef::new(
1266                i as u32 * 2,
1267                format!("in{}_l", i),
1268                SignalKind::Audio,
1269            ));
1270            inputs.push(PortDef::new(
1271                i as u32 * 2 + 1,
1272                format!("in{}_r", i),
1273                SignalKind::Audio,
1274            ));
1275        }
1276
1277        Self {
1278            num_voices,
1279            spec: PortSpec {
1280                inputs,
1281                outputs: vec![
1282                    PortDef::new(100, "left", SignalKind::Audio),
1283                    PortDef::new(101, "right", SignalKind::Audio),
1284                ],
1285            },
1286        }
1287    }
1288}
1289
1290impl GraphModule for VoiceMixer {
1291    fn port_spec(&self) -> &PortSpec {
1292        &self.spec
1293    }
1294
1295    fn tick(&mut self, inputs: &PortValues, outputs: &mut PortValues) {
1296        let mut left = 0.0;
1297        let mut right = 0.0;
1298
1299        for i in 0..self.num_voices {
1300            left += inputs.get_or(i as u32 * 2, 0.0);
1301            right += inputs.get_or(i as u32 * 2 + 1, 0.0);
1302        }
1303
1304        outputs.set(100, left);
1305        outputs.set(101, right);
1306    }
1307
1308    fn reset(&mut self) {}
1309
1310    fn set_sample_rate(&mut self, _: f64) {}
1311
1312    fn type_id(&self) -> &'static str {
1313        "voice_mixer"
1314    }
1315}
1316
1317#[cfg(test)]
1318mod tests {
1319    use super::*;
1320    use crate::modules::{Adsr, StereoOutput, Vca, Vco};
1321
1322    // A tiny constant audio source, used to build deterministic "identical"
1323    // voices for gain-compensation tests.
1324    struct DcSource {
1325        value: f64,
1326        spec: PortSpec,
1327    }
1328
1329    impl DcSource {
1330        fn new(value: f64) -> Self {
1331            Self {
1332                value,
1333                spec: PortSpec {
1334                    inputs: vec![],
1335                    outputs: vec![PortDef::new(0, "out", SignalKind::Audio)],
1336                },
1337            }
1338        }
1339    }
1340
1341    impl GraphModule for DcSource {
1342        fn port_spec(&self) -> &PortSpec {
1343            &self.spec
1344        }
1345        fn tick(&mut self, _inputs: &PortValues, outputs: &mut PortValues) {
1346            outputs.set(0, self.value);
1347        }
1348        fn reset(&mut self) {}
1349        fn set_sample_rate(&mut self, _: f64) {}
1350        fn type_id(&self) -> &'static str {
1351            "dc_source"
1352        }
1353    }
1354
1355    // ---- Allocator basics -------------------------------------------------
1356
1357    #[test]
1358    fn test_voice_allocation_basic() {
1359        let mut allocator = VoiceAllocator::new(4);
1360
1361        let voice1 = allocator.note_on(60, 0.8);
1362        assert_eq!(voice1, Some(0));
1363        assert_eq!(allocator.active_count(), 1);
1364
1365        let voice2 = allocator.note_on(64, 0.7);
1366        assert_eq!(voice2, Some(1));
1367        assert_eq!(allocator.active_count(), 2);
1368
1369        allocator.note_off(60);
1370        assert_eq!(allocator.active_count(), 2); // Still active (releasing)
1371
1372        allocator.tick();
1373    }
1374
1375    #[test]
1376    fn test_voice_allocation_retrigger() {
1377        let mut allocator = VoiceAllocator::new(4);
1378
1379        let voice1 = allocator.note_on(60, 0.8);
1380        assert_eq!(voice1, Some(0));
1381
1382        let voice2 = allocator.note_on(60, 0.9);
1383        assert_eq!(voice2, Some(0));
1384        assert_eq!(allocator.active_count(), 1);
1385    }
1386
1387    // Q071: retrigger must move the voice to the back of the LRU queue.
1388    #[test]
1389    fn test_retrigger_updates_lru() {
1390        let mut allocator = VoiceAllocator::new(3);
1391
1392        // Occupy 0, 1, 2 then release them all (still tracked in LRU).
1393        allocator.note_on(60, 1.0); // voice 0
1394        allocator.note_on(62, 1.0); // voice 1
1395        allocator.note_on(64, 1.0); // voice 2
1396        allocator.note_off(60);
1397        allocator.note_off(62);
1398        allocator.note_off(64);
1399        // Free them so find_free_voice uses LRU order.
1400        allocator.panic();
1401
1402        // Retrigger note on voice 0 by re-playing 60 while it's free -> fresh
1403        // alloc picks LRU front (0). Then retrigger 60: LRU must push 0 to back.
1404        assert_eq!(allocator.note_on(60, 1.0), Some(0));
1405        assert_eq!(allocator.note_on(60, 1.0), Some(0)); // retrigger, same voice
1406
1407        // Free voice 0. Next fresh allocation should NOT immediately reuse 0 if
1408        // LRU was updated on retrigger (0 is now at the back).
1409        allocator.voice_mut(0).unwrap().free();
1410        allocator.voice_mut(1).unwrap().free();
1411        allocator.voice_mut(2).unwrap().free();
1412        // LRU order after updates should place 0 last; the earliest free slot in
1413        // LRU order (1 or 2) is chosen before 0.
1414        let next = allocator.note_on(67, 1.0).unwrap();
1415        assert_ne!(next, 0, "retrigger should have pushed voice 0 to LRU back");
1416    }
1417
1418    // ---- Voice stealing (Q068) -------------------------------------------
1419
1420    #[test]
1421    fn test_voice_stealing_oldest_active() {
1422        let mut allocator = VoiceAllocator::new(2);
1423        allocator.set_mode(AllocationMode::OldestSteal);
1424
1425        allocator.note_on(60, 0.8);
1426        allocator.tick();
1427        allocator.note_on(62, 0.7);
1428        allocator.tick();
1429
1430        // Both active; steal the oldest (voice 0).
1431        let stolen = allocator.note_on(64, 0.6);
1432        assert_eq!(stolen, Some(0));
1433        assert_eq!(allocator.last_stolen(), Some(0));
1434    }
1435
1436    #[test]
1437    fn test_voice_stealing_prefers_releasing() {
1438        let mut allocator = VoiceAllocator::new(2);
1439        allocator.set_mode(AllocationMode::OldestSteal);
1440
1441        // Voice 0 active, voice 1 active then released.
1442        allocator.note_on(60, 0.8);
1443        allocator.tick();
1444        allocator.note_on(62, 0.7);
1445        allocator.tick();
1446        allocator.note_off(62); // voice 1 -> Releasing
1447                                // Keep it from being auto-freed: it never ticks below threshold here
1448                                // because we don't call tick() (envelope stays 0 but no free pass runs).
1449
1450        // A new note should steal the RELEASING voice (1), not the active one.
1451        let stolen = allocator.note_on(64, 0.6);
1452        assert_eq!(stolen, Some(1));
1453        assert_eq!(allocator.last_stolen(), Some(1));
1454    }
1455
1456    #[test]
1457    fn test_quietest_steal_uses_real_levels() {
1458        let mut allocator = VoiceAllocator::new(3);
1459        allocator.set_mode(AllocationMode::QuietestSteal);
1460
1461        allocator.note_on(60, 1.0); // voice 0
1462        allocator.note_on(62, 1.0); // voice 1
1463        allocator.note_on(64, 1.0); // voice 2
1464
1465        // Populate real envelope levels; voice 1 is quietest.
1466        allocator.set_envelope_level(0, 0.9);
1467        allocator.set_envelope_level(1, 0.1);
1468        allocator.set_envelope_level(2, 0.7);
1469
1470        let stolen = allocator.note_on(67, 1.0);
1471        assert_eq!(
1472            stolen,
1473            Some(1),
1474            "QuietestSteal should pick the lowest level"
1475        );
1476    }
1477
1478    #[test]
1479    fn test_no_steal_mode_drops_note() {
1480        let mut allocator = VoiceAllocator::new(2);
1481        allocator.set_mode(AllocationMode::NoSteal);
1482
1483        allocator.note_on(60, 0.8);
1484        allocator.note_on(62, 0.7);
1485
1486        // Q072: no free voice, no eligible victim -> note dropped.
1487        let result = allocator.note_on(64, 0.6);
1488        assert_eq!(result, None);
1489        assert_eq!(allocator.last_stolen(), None);
1490    }
1491
1492    // Q072: priority modes drop the note when nothing qualifies.
1493    #[test]
1494    fn test_priority_mode_drop_when_no_victim() {
1495        let mut allocator = VoiceAllocator::new(2);
1496        allocator.set_mode(AllocationMode::HighestPriority);
1497
1498        // Two high notes held. A LOWER new note cannot steal (needs a sounding
1499        // voice lower than it) -> dropped.
1500        allocator.note_on(80, 1.0);
1501        allocator.note_on(84, 1.0);
1502        assert_eq!(allocator.note_on(60, 1.0), None);
1503
1504        // A higher new note CAN steal the lowest sounding voice (80).
1505        let stolen = allocator.note_on(90, 1.0);
1506        assert_eq!(stolen, Some(0));
1507    }
1508
1509    // ---- MIDI / voct conversions -----------------------------------------
1510
1511    #[test]
1512    fn test_midi_note_to_voct() {
1513        assert!((midi_note_to_voct(60) - 0.0).abs() < 0.001);
1514        assert!((midi_note_to_voct(72) - 1.0).abs() < 0.001);
1515        assert!((midi_note_to_voct(48) - (-1.0)).abs() < 0.001);
1516    }
1517
1518    #[test]
1519    fn test_voct_to_midi_note() {
1520        assert_eq!(voct_to_midi_note(0.0), 60);
1521        assert_eq!(voct_to_midi_note(1.0), 72);
1522        assert_eq!(voct_to_midi_note(-1.0), 48);
1523    }
1524
1525    // ---- Unison detune (Q065) --------------------------------------------
1526
1527    #[test]
1528    fn test_unison_detune_total_spread() {
1529        // 3 voices, 10 cents TOTAL edge-to-edge spread.
1530        let config = UnisonConfig::new(3, 10.0);
1531
1532        let d0 = config.detune_offset(0);
1533        let d1 = config.detune_offset(1);
1534        let d2 = config.detune_offset(2);
1535
1536        assert!(d0 < 0.0);
1537        assert!((d1 - 0.0).abs() < 1e-9);
1538        assert!(d2 > 0.0);
1539        assert!((d0 + d2).abs() < 1e-9, "spread must be symmetric");
1540
1541        // Magnitude: edges at ±5 cents, total span 10 cents (Q065). 1 octave in
1542        // V/Oct == 1200 cents.
1543        let d0_cents = d0 * 1200.0;
1544        let d2_cents = d2 * 1200.0;
1545        let span_cents = (d2 - d0) * 1200.0;
1546        assert!((d0_cents + 5.0).abs() < 1e-6, "low edge should be -5 cents");
1547        assert!(
1548            (d2_cents - 5.0).abs() < 1e-6,
1549            "high edge should be +5 cents"
1550        );
1551        assert!(
1552            (span_cents - 10.0).abs() < 1e-6,
1553            "total spread should equal detune_cents (got {span_cents})"
1554        );
1555    }
1556
1557    #[test]
1558    fn test_unison_pan() {
1559        let mut config = UnisonConfig::new(3, 10.0);
1560        config.stereo_spread = 1.0;
1561
1562        assert!((config.pan_position(0) - (-1.0)).abs() < 0.001);
1563        assert!((config.pan_position(1) - 0.0).abs() < 0.001);
1564        assert!((config.pan_position(2) - 1.0).abs() < 0.001);
1565    }
1566
1567    #[test]
1568    fn test_unison_config_voice_gain() {
1569        let config = UnisonConfig::new(4, 10.0);
1570        let gain = config.voice_gain();
1571        assert!((gain - 0.5).abs() < 1e-9); // 1/sqrt(4)
1572    }
1573
1574    // ---- Balance / pan law (Q066) ----------------------------------------
1575
1576    #[test]
1577    fn test_balance_gains_center_unity() {
1578        let (l, r) = balance_gains(0.0);
1579        assert!((l - 1.0).abs() < 1e-9 && (r - 1.0).abs() < 1e-9);
1580    }
1581
1582    #[test]
1583    fn test_balance_gains_partial() {
1584        let (l, r) = balance_gains(-0.5); // left
1585        assert!((l - 1.0).abs() < 1e-9 && (r - 0.5).abs() < 1e-9);
1586        let (l, r) = balance_gains(0.5); // right
1587        assert!((l - 0.5).abs() < 1e-9 && (r - 1.0).abs() < 1e-9);
1588    }
1589
1590    // ---- VoiceInput (in-graph controller) --------------------------------
1591
1592    #[test]
1593    fn test_voice_input_module() {
1594        let mut input = VoiceInput::new();
1595        let mut outputs = PortValues::new();
1596
1597        input.set_voct(0.5);
1598        input.set_gate(1.0);
1599        input.set_velocity(0.8);
1600
1601        input.tick(&PortValues::new(), &mut outputs);
1602
1603        assert!((outputs.get_or(0, 0.0) - 0.5).abs() < 0.001); // V/Oct
1604        assert!((outputs.get_or(1, 0.0) - 5.0).abs() < 0.001); // Gate (5V)
1605        assert!((outputs.get_or(3, 0.0) - 8.0).abs() < 0.001); // Velocity (0.8 * 10V)
1606    }
1607
1608    #[test]
1609    fn test_voice_input_trigger_pulse_in_samples() {
1610        // At 48kHz, a 1ms pulse is ~48 samples; must persist beyond one sample.
1611        let mut input = VoiceInput::with_control(Arc::new(VoiceControl::new()), 48_000.0);
1612        let mut outputs = PortValues::new();
1613
1614        input.set_trigger(1.0); // request a trigger
1615        input.tick(&PortValues::new(), &mut outputs);
1616        assert!(outputs.get_or(2, 0.0) > 2.5, "trigger high on first sample");
1617
1618        // Clear the request; the pulse must still be high (multi-sample).
1619        input.set_trigger(0.0);
1620        input.tick(&PortValues::new(), &mut outputs);
1621        assert!(
1622            outputs.get_or(2, 0.0) > 2.5,
1623            "trigger pulse should last several samples, not one"
1624        );
1625
1626        // Eventually goes low.
1627        for _ in 0..64 {
1628            input.tick(&PortValues::new(), &mut outputs);
1629        }
1630        assert!(outputs.get_or(2, 0.0) < 2.5, "trigger pulse should end");
1631    }
1632
1633    #[test]
1634    fn test_voice_input_default() {
1635        let input = VoiceInput::default();
1636        assert!((input.voct() - 0.0).abs() < 0.001);
1637    }
1638
1639    #[test]
1640    fn test_voice_input_set_from_voice() {
1641        let mut voice = Voice::new(0);
1642        voice.note_on(72, 0.8);
1643
1644        let mut input = VoiceInput::new();
1645        input.set_from_voice(&voice);
1646
1647        assert!((input.voct() - 1.0).abs() < 0.001);
1648        assert!((input.velocity() - 0.8).abs() < 0.001);
1649    }
1650
1651    #[test]
1652    fn test_voice_input_reset_type_id() {
1653        let mut input = VoiceInput::new();
1654        input.set_voct(1.0);
1655        input.reset();
1656        assert!((input.voct() - 0.0).abs() < 0.001);
1657        assert_eq!(input.type_id(), "voice_input");
1658        input.set_sample_rate(48000.0);
1659    }
1660
1661    #[test]
1662    fn test_voice_input_set_trigger() {
1663        let mut input = VoiceInput::new();
1664        input.set_trigger(1.0);
1665        assert!((input.trigger() - 1.0).abs() < 0.001);
1666    }
1667
1668    // ---- Voice state machine ---------------------------------------------
1669
1670    #[test]
1671    fn test_voice_is_free() {
1672        let voice = Voice::new(0);
1673        assert!(voice.is_free());
1674
1675        let mut playing = Voice::new(1);
1676        playing.note_on(60, 1.0);
1677        assert!(!playing.is_free());
1678    }
1679
1680    #[test]
1681    fn test_voice_is_playing_note() {
1682        let mut voice = Voice::new(0);
1683        voice.note_on(60, 1.0);
1684        assert!(voice.is_playing_note(60));
1685        assert!(!voice.is_playing_note(61));
1686    }
1687
1688    #[test]
1689    fn test_voice_note_off_and_free() {
1690        let mut voice = Voice::new(0);
1691        voice.note_on(60, 1.0);
1692        voice.note_off();
1693        assert!(voice.state == VoiceState::Releasing);
1694
1695        voice.free();
1696        assert!(voice.is_free());
1697    }
1698
1699    #[test]
1700    fn test_voice_tick_clears_trigger_and_counts_release() {
1701        let mut voice = Voice::new(0);
1702        voice.note_on(60, 1.0);
1703        voice.tick();
1704        assert!(voice.trigger == 0.0);
1705        assert_eq!(voice.release_samples, 0); // still active
1706
1707        voice.note_off();
1708        voice.tick();
1709        assert_eq!(voice.release_samples, 1); // counting since release
1710    }
1711
1712    // ---- Allocator misc accessors ----------------------------------------
1713
1714    #[test]
1715    fn test_voice_allocator_mode() {
1716        let mut allocator = VoiceAllocator::new(4);
1717        allocator.set_mode(AllocationMode::QuietestSteal);
1718        assert_eq!(allocator.mode(), AllocationMode::QuietestSteal);
1719    }
1720
1721    #[test]
1722    fn test_voice_allocator_num_voices() {
1723        let allocator = VoiceAllocator::new(8);
1724        assert_eq!(allocator.num_voices(), 8);
1725    }
1726
1727    #[test]
1728    fn test_voice_allocator_voice_access() {
1729        let mut allocator = VoiceAllocator::new(4);
1730        assert!(allocator.voice(0).is_some());
1731        assert!(allocator.voice_mut(0).is_some());
1732    }
1733
1734    #[test]
1735    fn test_voice_allocator_voices() {
1736        let allocator = VoiceAllocator::new(4);
1737        assert_eq!(allocator.voices().len(), 4);
1738    }
1739
1740    #[test]
1741    fn test_voice_allocator_voices_mut() {
1742        let mut allocator = VoiceAllocator::new(4);
1743        assert_eq!(allocator.voices_mut().len(), 4);
1744    }
1745
1746    #[test]
1747    fn test_voice_allocator_all_notes_off() {
1748        let mut allocator = VoiceAllocator::new(4);
1749        allocator.note_on(60, 1.0);
1750        allocator.note_on(64, 1.0);
1751        allocator.all_notes_off();
1752        assert!(allocator
1753            .voices()
1754            .iter()
1755            .all(|v| v.state == VoiceState::Releasing || v.state == VoiceState::Free));
1756    }
1757
1758    #[test]
1759    fn test_voice_allocator_tick() {
1760        let mut allocator = VoiceAllocator::new(4);
1761        allocator.note_on(60, 1.0);
1762        allocator.tick();
1763    }
1764
1765    #[test]
1766    fn test_voice_allocator_set_envelope_level() {
1767        let mut allocator = VoiceAllocator::new(4);
1768        if let Some(i) = allocator.note_on(60, 1.0) {
1769            allocator.set_envelope_level(i, 0.5);
1770            assert!((allocator.voice(i).unwrap().envelope_level - 0.5).abs() < 1e-9);
1771        }
1772    }
1773
1774    #[test]
1775    fn test_voice_allocator_release_grace_keeps_voice() {
1776        let mut allocator = VoiceAllocator::new(1);
1777        allocator.set_release_criteria(0.001, 100); // 100-sample grace
1778        allocator.note_on(60, 1.0);
1779        allocator.note_off(60);
1780
1781        // Envelope already quiet, but grace must keep it alive.
1782        allocator.set_envelope_level(0, 0.0);
1783        for _ in 0..50 {
1784            allocator.set_envelope_level(0, 0.0);
1785            allocator.tick();
1786        }
1787        assert_eq!(allocator.voice(0).unwrap().state, VoiceState::Releasing);
1788
1789        // After the grace elapses it frees.
1790        for _ in 0..100 {
1791            allocator.set_envelope_level(0, 0.0);
1792            allocator.tick();
1793        }
1794        assert_eq!(allocator.voice(0).unwrap().state, VoiceState::Free);
1795    }
1796
1797    // ---- PolyPatch basics -------------------------------------------------
1798
1799    #[test]
1800    fn test_poly_patch_basic() {
1801        let mut poly = PolyPatch::new(4, 44100.0);
1802        poly.note_on(60, 100);
1803        assert_eq!(poly.allocator().active_count(), 1);
1804        poly.note_on(64, 90);
1805        assert_eq!(poly.allocator().active_count(), 2);
1806        poly.note_off(60);
1807    }
1808
1809    #[test]
1810    fn test_poly_patch_panic() {
1811        let mut poly = PolyPatch::new(4, 44100.0);
1812        poly.note_on(60, 100);
1813        poly.note_on(64, 90);
1814        poly.note_on(67, 80);
1815        poly.panic();
1816        assert_eq!(poly.allocator().active_count(), 0);
1817    }
1818
1819    #[test]
1820    fn test_poly_patch_sample_rate() {
1821        let poly = PolyPatch::new(4, 48000.0);
1822        assert_eq!(poly.sample_rate(), 48000.0);
1823    }
1824
1825    #[test]
1826    fn test_poly_patch_set_sample_rate() {
1827        let mut poly = PolyPatch::new(4, 44100.0);
1828        poly.set_sample_rate(48000.0);
1829        assert_eq!(poly.sample_rate(), 48000.0);
1830    }
1831
1832    #[test]
1833    fn test_poly_patch_controller_access() {
1834        let poly = PolyPatch::new(4, 44100.0);
1835        assert!(poly.voice_control(0).is_some());
1836        assert!(poly.voice_controller(0).is_some());
1837        assert!(poly.voice_control(99).is_none());
1838    }
1839
1840    #[test]
1841    fn test_poly_patch_allocator_mut() {
1842        let mut poly = PolyPatch::new(4, 44100.0);
1843        poly.allocator_mut().set_mode(AllocationMode::OldestSteal);
1844        assert_eq!(poly.allocator().mode(), AllocationMode::OldestSteal);
1845    }
1846
1847    #[test]
1848    fn test_poly_patch_unison() {
1849        let mut poly = PolyPatch::new(4, 44100.0);
1850        poly.set_unison(UnisonConfig::new(2, 5.0));
1851        assert_eq!(poly.unison().voices, 2);
1852    }
1853
1854    #[test]
1855    fn test_poly_patch_voice_patch_access() {
1856        let mut poly = PolyPatch::new(4, 44100.0);
1857        assert_eq!(poly.num_voices(), 4);
1858        assert!(poly.voice_patch(0).is_some());
1859        assert!(poly.voice_patch_mut(0).is_some());
1860        assert!(poly.voice_patch(99).is_none());
1861    }
1862
1863    #[test]
1864    fn test_poly_patch_all_notes_off() {
1865        let mut poly = PolyPatch::new(4, 44100.0);
1866        poly.note_on(60, 100);
1867        poly.note_on(64, 100);
1868        poly.all_notes_off();
1869    }
1870
1871    #[test]
1872    fn test_poly_patch_compile_tick_output() {
1873        let mut poly = PolyPatch::new(2, 44100.0);
1874        poly.compile().unwrap();
1875        poly.note_on(60, 100);
1876        poly.tick();
1877        let (left, right) = poly.output();
1878        let _ = (left, right);
1879    }
1880
1881    #[test]
1882    fn test_poly_patch_reset() {
1883        let mut poly = PolyPatch::new(4, 44100.0);
1884        poly.note_on(60, 100);
1885        poly.reset();
1886        assert_eq!(poly.allocator().active_count(), 0);
1887    }
1888
1889    // ---- End-to-end: a real polyphonic subtractive voice (Q063/Q064) -----
1890
1891    /// Build a `PolyPatch` whose voices are VoiceController -> Vco -> Vca (gated
1892    /// by an Adsr) -> StereoOutput.
1893    fn build_synth(num_voices: usize, sr: f64) -> PolyPatch {
1894        PolyPatch::with_voice_fn(num_voices, sr, |patch, ctrl| {
1895            let sr = patch.sample_rate();
1896            let vco = patch.add("vco", Vco::new(sr));
1897            let adsr = patch.add("adsr", Adsr::new(sr));
1898            let vca = patch.add("vca", Vca::new());
1899            let out = patch.add("out", StereoOutput::new());
1900            patch.connect(ctrl.out("voct"), vco.in_("voct"))?;
1901            patch.connect(ctrl.out("gate"), adsr.in_("gate"))?;
1902            patch.connect(vco.out("sin"), vca.in_("in"))?;
1903            patch.connect(adsr.out("env"), vca.in_("cv"))?;
1904            patch.connect(vca.out("out"), out.in_("left"))?;
1905            patch.set_output(out.id());
1906            Ok(())
1907        })
1908        .unwrap()
1909    }
1910
1911    /// Average samples-per-cycle from positive-going zero crossings of `left`.
1912    fn measure_period_samples(poly: &mut PolyPatch, warmup: usize, window: usize) -> f64 {
1913        for _ in 0..warmup {
1914            poly.tick();
1915        }
1916        let mut prev = 0.0;
1917        let mut crossings = Vec::new();
1918        for n in 0..window {
1919            let (l, _r) = poly.tick();
1920            if prev <= 0.0 && l > 0.0 {
1921                crossings.push(n);
1922            }
1923            prev = l;
1924        }
1925        assert!(crossings.len() >= 2, "need at least two zero crossings");
1926        let span = (crossings[crossings.len() - 1] - crossings[0]) as f64;
1927        span / (crossings.len() - 1) as f64
1928    }
1929
1930    #[test]
1931    fn test_e2e_pitch_tracks_note() {
1932        let sr = 48_000.0;
1933        let mut poly = build_synth(1, sr);
1934
1935        // C4 (261.63 Hz) then C5 (523.25 Hz): period should roughly halve.
1936        poly.note_on(60, 100);
1937        let p_c4 = measure_period_samples(&mut poly, 4000, 8000);
1938        poly.note_off(60);
1939        for _ in 0..(sr as usize / 5) {
1940            poly.tick(); // let it release + free
1941        }
1942
1943        poly.note_on(72, 100);
1944        let p_c5 = measure_period_samples(&mut poly, 4000, 8000);
1945
1946        // Expected periods.
1947        let expect_c4 = sr / 261.63;
1948        let expect_c5 = sr / 523.25;
1949        assert!(
1950            (p_c4 - expect_c4).abs() / expect_c4 < 0.05,
1951            "C4 period {p_c4} vs expected {expect_c4}"
1952        );
1953        assert!(
1954            (p_c5 - expect_c5).abs() / expect_c5 < 0.05,
1955            "C5 period {p_c5} vs expected {expect_c5}"
1956        );
1957        assert!(
1958            (p_c4 / p_c5 - 2.0).abs() < 0.1,
1959            "octave should halve the period (ratio {})",
1960            p_c4 / p_c5
1961        );
1962    }
1963
1964    #[test]
1965    fn test_e2e_gate_reaches_adsr_and_release_tail_completes() {
1966        let sr = 48_000.0;
1967        let mut poly = build_synth(1, sr);
1968
1969        poly.note_on(60, 127);
1970
1971        // Let attack/decay settle to sustain, then measure sustained amplitude.
1972        let mut sustain_peak = 0.0f64;
1973        for _ in 0..4800 {
1974            poly.tick();
1975        }
1976        for _ in 0..2000 {
1977            let (l, _r) = poly.tick();
1978            sustain_peak = sustain_peak.max(l.abs());
1979        }
1980        assert!(
1981            sustain_peak > 0.1,
1982            "gate should drive the ADSR/VCA (sustain peak {sustain_peak})"
1983        );
1984
1985        // Note off: the voice must NOT free one sample later (Q064).
1986        poly.note_off(60);
1987        poly.tick();
1988        assert_ne!(
1989            poly.allocator().voice(0).unwrap().state,
1990            VoiceState::Free,
1991            "voice freed one sample after note-off (truncated release)"
1992        );
1993
1994        // Output should still be substantial right after release begins (not
1995        // instantly zero), then decay over the release time.
1996        let mut just_after = 0.0f64;
1997        for _ in 0..200 {
1998            let (l, _r) = poly.tick();
1999            just_after = just_after.max(l.abs());
2000        }
2001        assert!(
2002            just_after > 0.02,
2003            "release tail truncated (amp {just_after} right after note-off)"
2004        );
2005
2006        // After the full release + grace + follower decay, the voice frees.
2007        let mut freed = false;
2008        for _ in 0..(sr as usize / 2) {
2009            poly.tick();
2010            if poly.allocator().voice(0).unwrap().state == VoiceState::Free {
2011                freed = true;
2012                break;
2013            }
2014        }
2015        assert!(freed, "released voice should eventually free");
2016    }
2017
2018    // ---- Sample-rate propagation (Q069) ----------------------------------
2019
2020    #[test]
2021    fn test_e2e_sample_rate_propagates_to_voices() {
2022        let sr1 = 48_000.0;
2023        let mut poly = build_synth(1, sr1);
2024        poly.note_on(60, 100);
2025        let p1 = measure_period_samples(&mut poly, 4000, 8000);
2026
2027        // Halve the sample rate: if SR propagates, the period in *samples* halves
2028        // (same Hz, half as many samples per second). If it did NOT propagate,
2029        // the VCO would keep its old rate and the period would be unchanged.
2030        poly.set_sample_rate(sr1 / 2.0);
2031        poly.note_on(60, 100);
2032        let p2 = measure_period_samples(&mut poly, 2000, 4000);
2033
2034        assert!(
2035            (p2 / p1 - 0.5).abs() < 0.1,
2036            "half sample rate should halve the period in samples (p1={p1}, p2={p2})"
2037        );
2038    }
2039
2040    // ---- Polyphony gain compensation (Q067) ------------------------------
2041
2042    fn build_dc_poly(num_voices: usize, value: f64, sr: f64) -> PolyPatch {
2043        PolyPatch::with_voice_fn(num_voices, sr, move |patch, _ctrl| {
2044            let dc = patch.add("dc", DcSource::new(value));
2045            let out = patch.add("out", StereoOutput::new());
2046            patch.connect(dc.out("out"), out.in_("left"))?;
2047            patch.set_output(out.id());
2048            Ok(())
2049        })
2050        .unwrap()
2051    }
2052
2053    #[test]
2054    fn test_single_voice_unity_gain() {
2055        // Q066/Q067: a single mono voice must pass at unity gain.
2056        let sr = 48_000.0;
2057        let mut poly = build_dc_poly(4, 1.0, sr);
2058        poly.note_on(60, 100);
2059        let mut out = (0.0, 0.0);
2060        for _ in 0..2000 {
2061            out = poly.tick();
2062        }
2063        assert!(
2064            (out.0 - 1.0).abs() < 0.01 && (out.1 - 1.0).abs() < 0.01,
2065            "single voice should pass at unity gain, got {out:?}"
2066        );
2067    }
2068
2069    #[test]
2070    fn test_eight_voices_bounded_output() {
2071        let sr = 48_000.0;
2072        let mut poly = build_dc_poly(8, 1.0, sr);
2073
2074        // Single-voice reference.
2075        poly.note_on(60, 100);
2076        let mut single = 0.0;
2077        for _ in 0..2000 {
2078            single = poly.tick().0;
2079        }
2080        poly.panic();
2081        for _ in 0..2000 {
2082            poly.tick();
2083        }
2084
2085        // Eight identical full-scale voices.
2086        for n in 0..8u8 {
2087            poly.note_on(60 + n, 100);
2088        }
2089        let mut eight = 0.0;
2090        for _ in 0..4000 {
2091            eight = poly.tick().0;
2092        }
2093
2094        assert!((single - 1.0).abs() < 0.01);
2095        assert!(
2096            eight < 8.0 * single - 0.5,
2097            "8 voices must be well below 8x single ({eight} vs {})",
2098            8.0 * single
2099        );
2100        // 1/sqrt(8) law => ~2.83x single voice.
2101        assert!(
2102            (eight / single - 8.0f64.sqrt()).abs() < 0.3,
2103            "8-voice sum should follow 1/sqrt(N) (ratio {})",
2104            eight / single
2105        );
2106    }
2107
2108    #[test]
2109    fn test_gain_compensation_is_smooth() {
2110        let sr = 48_000.0;
2111        let mut poly = PolyPatch::new(8, sr);
2112
2113        poly.note_on(60, 100);
2114        for _ in 0..4000 {
2115            poly.tick();
2116        }
2117        let g_before = poly.compensation_gain();
2118        assert!(
2119            (g_before - 1.0).abs() < 0.01,
2120            "one voice => unity comp gain"
2121        );
2122
2123        // Add a second voice: the compensation gain must not jump.
2124        poly.note_on(64, 100);
2125        poly.tick();
2126        let g_step = poly.compensation_gain();
2127        assert!(
2128            (g_before - g_step).abs() < 0.05,
2129            "comp gain stepped discontinuously: {g_before} -> {g_step}"
2130        );
2131
2132        // Over ~10ms it settles toward 1/sqrt(2).
2133        for _ in 0..4000 {
2134            poly.tick();
2135        }
2136        let g_settled = poly.compensation_gain();
2137        assert!(
2138            (g_settled - 1.0 / 2.0f64.sqrt()).abs() < 0.02,
2139            "comp gain should settle to 1/sqrt(2), got {g_settled}"
2140        );
2141    }
2142
2143    // ---- Steal declick (Q070) --------------------------------------------
2144
2145    #[test]
2146    fn test_steal_resets_voice_dsp() {
2147        let sr = 48_000.0;
2148        // One voice so the next note must steal it.
2149        let mut poly = build_dc_poly(1, 1.0, sr);
2150        poly.allocator_mut().set_mode(AllocationMode::OldestSteal);
2151
2152        poly.note_on(60, 100);
2153        for _ in 0..500 {
2154            poly.tick();
2155        }
2156        // Steal it with a new note; the DcSource patch should have been reset.
2157        poly.note_on(72, 100);
2158        assert_eq!(poly.allocator().last_stolen(), Some(0));
2159        // The reset zeroed the voice's follower.
2160        // (Behavioral proof: the voice still produces output after the steal.)
2161        let (l, _r) = poly.tick();
2162        assert!(l.abs() > 0.0, "stolen voice should keep producing audio");
2163    }
2164
2165    // ---- Max-release safety cap ------------------------------------------
2166
2167    // Regression: a released voice whose output never decays below the release
2168    // threshold (a DC / drone / self-oscillating voice) must still be reclaimed
2169    // by the worst-case release cap, otherwise under NoSteal it pins the slot
2170    // forever and every later note_on is dropped (permanent voice exhaustion).
2171    #[test]
2172    fn test_nondecaying_released_voice_force_frees() {
2173        let sr = 48_000.0;
2174        let mut poly = build_dc_poly(1, 1.0, sr);
2175        poly.allocator_mut().set_mode(AllocationMode::NoSteal);
2176
2177        // Play then release the only voice. Its DC output stays at full scale, so
2178        // amplitude-based reaping alone would never free it.
2179        poly.note_on(60, 100);
2180        for _ in 0..200 {
2181            poly.tick();
2182        }
2183        poly.note_off(60);
2184
2185        // Under the generous default cap a short run leaves it stuck Releasing,
2186        // and NoSteal drops a new note -> this is the exhaustion being bounded.
2187        for _ in 0..2000 {
2188            poly.tick();
2189        }
2190        assert_eq!(
2191            poly.allocator().voices()[0].state,
2192            VoiceState::Releasing,
2193            "DC voice does not decay, so it is still releasing"
2194        );
2195        poly.note_on(72, 100);
2196        assert_ne!(
2197            poly.allocator().voices()[0].note,
2198            Some(72),
2199            "NoSteal cannot reuse a still-Releasing voice yet"
2200        );
2201
2202        // Tighten the cap so the worst-case timeout fires and force-frees it.
2203        poly.set_max_release_time(0.01); // ~480 samples at 48 kHz
2204        for _ in 0..1000 {
2205            poly.tick();
2206        }
2207        assert_eq!(
2208            poly.allocator().voices()[0].state,
2209            VoiceState::Free,
2210            "a non-decaying released voice must force-free after the release cap"
2211        );
2212
2213        // The reclaimed slot accepts a new note again: exhaustion resolved.
2214        poly.note_on(72, 100);
2215        assert_eq!(poly.allocator().voices()[0].note, Some(72));
2216        assert_eq!(poly.allocator().voices()[0].state, VoiceState::Active);
2217    }
2218
2219    // A finite cap is installed by default (derived from sample rate), and a
2220    // non-positive request disables it.
2221    #[test]
2222    fn test_release_cap_configuration() {
2223        let sr = 48_000.0;
2224        let mut poly = build_dc_poly(1, 1.0, sr);
2225        // Default: MAX_RELEASE_S * sr samples.
2226        assert_eq!(
2227            poly.allocator().max_release_samples(),
2228            (MAX_RELEASE_S * sr) as u64
2229        );
2230        poly.set_max_release_time(0.0);
2231        assert_eq!(poly.allocator().max_release_samples(), u64::MAX);
2232    }
2233
2234    // ---- VoiceMixer -------------------------------------------------------
2235
2236    #[test]
2237    fn test_voice_mixer() {
2238        let mixer = VoiceMixer::new(4);
2239        let spec = mixer.port_spec();
2240        assert!(!spec.inputs.is_empty());
2241        assert!(!spec.outputs.is_empty());
2242    }
2243
2244    #[test]
2245    fn test_voice_mixer_tick() {
2246        let mut mixer = VoiceMixer::new(2);
2247        let mut inputs = PortValues::new();
2248        let mut outputs = PortValues::new();
2249
2250        inputs.set(0, 1.0);
2251        inputs.set(1, 2.0);
2252        inputs.set(2, 3.0);
2253        inputs.set(3, 4.0);
2254
2255        mixer.tick(&inputs, &mut outputs);
2256
2257        assert!(outputs.get(100).is_some());
2258        assert!(outputs.get(101).is_some());
2259    }
2260
2261    #[test]
2262    fn test_voice_mixer_reset_type_id() {
2263        let mut mixer = VoiceMixer::new(2);
2264        mixer.reset();
2265        mixer.set_sample_rate(48000.0);
2266        assert_eq!(mixer.type_id(), "voice_mixer");
2267    }
2268
2269    // ---- Q163: full voice-count contention stress test ----
2270
2271    /// Drive 16 voices through 12k ticks of interleaved note_on / note_off /
2272    /// retrigger churn, asserting no panic, correct active-voice bookkeeping
2273    /// (`active_count <= num_voices` at all times), and bounded mixed output.
2274    fn poly_stress(mode: AllocationMode) {
2275        let sr = 48_000.0;
2276        let mut poly = build_synth(16, sr);
2277        poly.allocator_mut().set_mode(mode);
2278        assert_eq!(poly.num_voices(), 16);
2279
2280        // Deterministic LCG so the churn is reproducible.
2281        let mut state: u64 = 0x1234_5678_9abc_def0;
2282        let mut next = move || {
2283            state = state
2284                .wrapping_mul(6_364_136_223_846_793_005)
2285                .wrapping_add(1_442_695_040_888_963_407);
2286            (state >> 33) as u32
2287        };
2288
2289        let mut held: Vec<u8> = Vec::new();
2290        for t in 0..12_000 {
2291            // A note event roughly every 30 ticks keeps the allocator churning.
2292            if t % 30 == 0 {
2293                let r = next() % 100;
2294                if r < 55 {
2295                    // New note (or a retrigger if the note is already sounding).
2296                    let note = 36 + (next() % 48) as u8;
2297                    let vel = 1 + (next() % 127) as u8;
2298                    poly.note_on(note, vel);
2299                    if !held.contains(&note) {
2300                        held.push(note);
2301                    }
2302                } else if r < 80 && !held.is_empty() {
2303                    let idx = (next() as usize) % held.len();
2304                    let note = held.remove(idx);
2305                    poly.note_off(note);
2306                } else if !held.is_empty() {
2307                    // Explicit retrigger of a held note.
2308                    let idx = (next() as usize) % held.len();
2309                    let note = held[idx];
2310                    let vel = 1 + (next() % 127) as u8;
2311                    poly.note_on(note, vel);
2312                }
2313            }
2314
2315            let (l, r) = poly.tick();
2316            assert!(
2317                l.is_finite() && r.is_finite(),
2318                "non-finite output at tick {t}"
2319            );
2320            assert!(
2321                l.abs() < 16.0 && r.abs() < 16.0,
2322                "polyphonic output exploded at tick {t}: ({l}, {r})"
2323            );
2324            assert!(
2325                poly.allocator().active_count() <= poly.num_voices(),
2326                "active_count {} exceeded voice count at tick {t}",
2327                poly.allocator().active_count()
2328            );
2329        }
2330
2331        // Release everything and let the amplitude-follower auto-free run out.
2332        poly.all_notes_off();
2333        for _ in 0..48_000 {
2334            let (l, r) = poly.tick();
2335            assert!(l.is_finite() && r.is_finite());
2336        }
2337        assert_eq!(
2338            poly.allocator().active_count(),
2339            0,
2340            "all voices should auto-free after a long release tail"
2341        );
2342    }
2343
2344    #[test]
2345    fn test_poly_stress_16_voices_oldest_steal() {
2346        poly_stress(AllocationMode::OldestSteal);
2347    }
2348
2349    #[test]
2350    fn test_poly_stress_16_voices_no_steal() {
2351        poly_stress(AllocationMode::NoSteal);
2352    }
2353}