Skip to main content

tono_core/instrument/
mod.rs

1//! instrument — a playable, pitched, polyphonic instrument built from a patch.
2//!
3//! Turns a [`Patch`](crate::patch::Patch) (a graph + named params) into something you *play* like a
4//! GarageBand software instrument: pick the sound, then [`note_on`](Instrument::note_on) /
5//! [`note_off`](Instrument::note_off) pitched notes with velocity. Each note is a
6//! **voice** — the patch graph rendered at that note's pitch by the byte-identical
7//! streaming renderer, shaped by a **gated** amplitude envelope (attack/decay/
8//! sustain-while-held/release, unlike the graph's fixed-duration `Env`). Voices are
9//! pooled with stealing, and the instrument mixes them to stereo.
10//!
11//! `Instrument` implements [`AudioSource`], so it drops straight onto a cpal /
12//! AudioWorklet callback, or into a [`Mixer`](crate::runtime::Mixer) alongside SFX.
13//!
14//! ```
15//! use tono_core::instrument::{Instrument, InstrumentDesign, Note};
16//! use tono_core::patch::Patch;
17//! use tono_core::dsl::{Node, SoundDoc};
18//! use tono_core::runtime::AudioSource;
19//!
20//! let patch = Patch::new(SoundDoc::new("lead", Node::Sine { freq: 440.0.into() }));
21//! let mut inst = Instrument::new(InstrumentDesign::new(patch), 48_000).unwrap();
22//!
23//! inst.note_on(Note::C4, 0.9);           // strike…
24//! let mut out = vec![0.0f32; 512];
25//! inst.fill(&mut out);                   // …and it sounds
26//! assert!(out.iter().any(|s| s.abs() > 0.0));
27//! inst.note_off(Note::C4);               // release
28//! ```
29
30mod design;
31mod envelope;
32mod note;
33
34#[cfg(test)]
35mod tests;
36
37pub use design::{InstrumentDesign, Modulation, PitchMap, PlayMode};
38pub use envelope::EnvGen;
39pub use note::{InstrumentError, Note};
40
41use std::collections::BTreeMap;
42
43use crate::dsl::{Node, SoundDoc, Value, note_to_hz};
44use crate::runtime::AudioSource;
45use crate::streaming::{EffectChain, StreamGraph};
46
47/// Handle to one sounding voice (a single note-on). Stable until the voice is
48/// culled.
49#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
50pub struct VoiceHandle(u64);
51
52/// One detuned, panned copy in a (possibly unison) voice. The detune is baked
53/// into the graph at build; `l`/`r` are its channel gains (already unison-
54/// normalised so a stack isn't louder than a single voice).
55struct UnisonCopy {
56    graph: StreamGraph,
57    l: f32,
58    r: f32,
59}
60
61struct Voice {
62    handle: u64,
63    note: Note,
64    /// The unison stack — one entry unless unison is on. All copies share the
65    /// note pitch (so glide/bend move them together); each is detuned + panned.
66    copies: Vec<UnisonCopy>,
67    /// The frequency the graphs were baked at (the note the voice was built for).
68    /// A live pitch scale of `target.freq() / built_hz` retunes to any other note
69    /// without a rebuild — how mono glide moves between notes.
70    built_hz: f32,
71    env: EnvGen,
72    gain: f32,
73    /// Set once `note_off` has gated the envelope into release.
74    releasing: bool,
75    /// A note-off arrived while the sustain pedal was down — release on pedal-up.
76    sustained: bool,
77}
78
79/// A polyphonic, pitched, gated instrument. Play it with
80/// [`note_on`](Self::note_on) / [`note_off`](Self::note_off); it mixes its live
81/// voices through [`AudioSource::fill`].
82pub struct Instrument {
83    sample_rate: u32,
84    design: InstrumentDesign,
85    /// Current parameter values (name → value); each new voice is built with these.
86    values: BTreeMap<String, f32>,
87    voices: Vec<Voice>,
88    next_handle: u64,
89    /// Sustain-pedal state: while down, note-offs are deferred until pedal-up.
90    sustain: bool,
91    /// Pitch-wheel bend as a frequency ratio (1.0 = centered), applied live to
92    /// every sounding voice and any new one.
93    bend: f32,
94    /// Filter-cutoff (brightness) scale, 1.0 = as designed. Applied live to every
95    /// voice's filters — a mod-wheel / CC74 brightness sweep without a rebuild.
96    brightness: f32,
97    /// Modulation LFO phases in `0..1`, advanced per control block. Phase
98    /// accumulators (not an absolute sample clock) so precision never degrades:
99    /// a `u64` clock cast to `f32` quantizes to whole control blocks after a
100    /// few hours and the LFOs go steppy, then freeze.
101    vib_phase: f32,
102    flt_phase: f32,
103    trem_phase: f32,
104    /// Current tremolo gain (1.0 = no tremolo), updated at control rate.
105    trem: f32,
106    /// Notes physically held, oldest→newest — mono note priority. On a note-off
107    /// the voice falls back to the last still-held note. Unused in poly mode.
108    held: Vec<Note>,
109    /// The shared master effect chain, one instance per stereo channel (identical
110    /// coefficients, independent state) so a reverb/chorus reads as stereo. Both
111    /// are one shared instance for the whole instrument — a tail outlives its note.
112    master: Option<(EffectChain, EffectChain)>,
113    /// Per-copy render scratch (mono).
114    scratch: Vec<f32>,
115    /// Per-voice amp-envelope scratch (one env, shared across its unison copies).
116    env_buf: Vec<f32>,
117    /// Summed-voices stereo bus, fed to the master chains.
118    mix_l: Vec<f32>,
119    mix_r: Vec<f32>,
120}
121
122/// Multiply every pitch-determining frequency (oscillator freqs, seq note
123/// pitches) by `ratio`. Constant and note-name values are transposed; modulated
124/// fundamentals are left as authored.
125///
126/// Looks like `vary::transpose_node` but is deliberately NOT unified with it:
127/// the two walkers invert Modulated/RingMod/Modal handling (live play must
128/// track the key on pitch-determining processors; humanize must not), and
129/// unifying them would change rendered bytes.
130fn transpose(node: &mut Node, ratio: f32) {
131    fn scale(v: &mut Value, ratio: f32) {
132        match v {
133            Value::Const(c) => *c *= ratio,
134            // An unparseable note is left as authored — substituting a silent
135            // A4 (the old unwrap_or(440.0)) would hide the typo.
136            Value::Note(s) => {
137                if let Some(hz) = note_to_hz(s) {
138                    *v = Value::Const(hz * ratio);
139                }
140            }
141            Value::Modulated(_) => {}
142        }
143    }
144    match node {
145        Node::Sine { freq }
146        | Node::Triangle { freq }
147        | Node::Sawtooth { freq }
148        | Node::Square { freq, .. }
149        | Node::Fm { freq, .. }
150        | Node::Super { freq, .. }
151        | Node::Wavetable { freq, .. } => scale(freq, ratio),
152        // Pitch-determining processors: the ring-mod carrier and the modal
153        // body's resonant partials must track the note, or a bell/metallic patch
154        // plays the same pitch for every key.
155        Node::RingMod { freq } => scale(freq, ratio),
156        Node::Modal { modes, .. } => {
157            for m in modes.iter_mut() {
158                m.freq *= ratio;
159            }
160        }
161        Node::Seq { notes, .. } => {
162            for note in notes.iter_mut() {
163                scale(&mut note.pitch, ratio);
164            }
165        }
166        _ => {}
167    }
168    // The recursion goes through the shared `children_mut` traversal — every
169    // nesting spot (mix/mul/chain, a tracks' layers and master chain, a
170    // duck's trigger) is covered by construction.
171    node.children_mut().for_each(|c| transpose(c, ratio));
172}
173
174impl Instrument {
175    /// Build an instrument from a design. Errors if the patch can't instantiate
176    /// or its graph is outside the streamable subset — so every note is
177    /// guaranteed to play in real time.
178    pub fn new(design: InstrumentDesign, sample_rate: u32) -> Result<Self, InstrumentError> {
179        let master = if design.master.is_empty() {
180            None
181        } else {
182            let engine = design.patch.doc.effective_engine();
183            let build = || EffectChain::try_new(&design.master, sample_rate, engine);
184            let (l, r) = (
185                build().ok_or(InstrumentError::NotStreamable)?,
186                build().ok_or(InstrumentError::NotStreamable)?,
187            );
188            Some((l, r))
189        };
190        let values = design.patch.defaults();
191        let inst = Instrument {
192            sample_rate,
193            design,
194            values,
195            voices: Vec::new(),
196            next_handle: 1,
197            sustain: false,
198            bend: 1.0,
199            brightness: 1.0,
200            vib_phase: 0.0,
201            flt_phase: 0.0,
202            trem_phase: 0.0,
203            trem: 1.0,
204            held: Vec::new(),
205            master,
206            scratch: Vec::new(),
207            env_buf: Vec::new(),
208            mix_l: Vec::new(),
209            mix_r: Vec::new(),
210        };
211        inst.build_result(Note::A4, 1.0, 1.0)?; // validate the reference voice
212        Ok(inst)
213    }
214
215    /// Build the streamable graph for one note at the current parameter values.
216    /// `detune` is a frequency multiplier baked into the graph (1.0 = none) — a
217    /// unison copy bakes its slight detune here, so glide/bend (which ride the
218    /// live pitch scale, keyed off the *nominal* note) preserve the spread.
219    fn build_result(
220        &self,
221        note: Note,
222        velocity: f32,
223        detune: f32,
224    ) -> Result<StreamGraph, InstrumentError> {
225        let hz = note.freq() * detune;
226        let mut values = self.values.clone();
227        if let PitchMap::Param(name) = &self.design.pitch {
228            values.insert(name.clone(), hz);
229        }
230        if let Some(vp) = &self.design.velocity_param {
231            // Map velocity across the param's declared [min, max] (a musical
232            // range), not the raw 0..1 — which would clamp to the minimum.
233            if let Some(spec) = self.design.patch.params.iter().find(|p| &p.name == vp) {
234                let (lo, hi) = (spec.min.min(spec.max), spec.min.max(spec.max));
235                values.insert(vp.clone(), lo + velocity.clamp(0.0, 1.0) * (hi - lo));
236            }
237        }
238        let mut doc: SoundDoc = self
239            .design
240            .patch
241            .instantiate(&values)
242            .map_err(|e| InstrumentError::BadPatch(e.to_string()))?;
243        doc.sample_rate = self.sample_rate;
244        if let PitchMap::Transpose { reference } = &self.design.pitch {
245            transpose(&mut doc.root, hz / reference.freq());
246        }
247        StreamGraph::try_from_doc(&doc).ok_or(InstrumentError::NotStreamable)
248    }
249
250    /// Build the unison stack for `note`: `unison` detuned, panned, level-
251    /// normalised copies (one copy, centered, when unison is off). `None` if the
252    /// patch can't build.
253    fn build_copies(&self, note: Note, velocity: f32) -> Option<Vec<UnisonCopy>> {
254        let n = self.design.unison.max(1);
255        let norm = 1.0 / (n as f32).sqrt(); // a stack shouldn't be louder than one
256        let mut copies = Vec::with_capacity(n);
257        for k in 0..n {
258            // Spread copies symmetrically over [-1, 1] × the configured amounts.
259            let spread = if n == 1 {
260                0.0
261            } else {
262                (k as f32 / (n - 1) as f32 - 0.5) * 2.0
263            };
264            let detune = 2f32.powf(spread * self.design.detune_cents / 1200.0);
265            let mut graph = self.build_result(note, velocity, detune).ok()?;
266            if self.bend != 1.0 {
267                graph.set_bend(self.bend);
268            }
269            if self.brightness != 1.0 {
270                graph.set_cutoff(self.brightness); // catch a new note up to the knob
271            }
272            // The builder clamps unison_width to 0..1; a deserialized design
273            // may not have come through it — without the clamp a width > 1
274            // flips a copy's polarity (negative gain).
275            let pan = (spread * self.design.unison_width).clamp(-1.0, 1.0);
276            copies.push(UnisonCopy {
277                graph,
278                l: (1.0 - pan).min(1.0) * norm,
279                r: (1.0 + pan).min(1.0) * norm,
280            });
281        }
282        Some(copies)
283    }
284
285    /// Start a note; `velocity` in `[0, 1]` shapes its level. Returns the voice's
286    /// handle. In poly mode each note is its own voice; if the pool is full the
287    /// **quietest** voice is stolen (the least audible cut). In mono mode the one
288    /// voice is retuned (gliding) to the new note. A patch made un-buildable by a
289    /// bad param yields a silent voice rather than panicking — a control event
290    /// never crashes the audio thread. MIDI convention: a note-on with
291    /// `velocity == 0.0` is a note-off (spawning a silent voice for it would
292    /// leak a voice that never releases) and returns the inert handle.
293    pub fn note_on(&mut self, note: Note, velocity: f32) -> VoiceHandle {
294        // NaN folds to 0.0 → the safe reading (note-off), not a loud surprise.
295        let velocity = if velocity.is_nan() {
296            0.0
297        } else {
298            velocity.clamp(0.0, 1.0)
299        };
300        if velocity == 0.0 {
301            self.note_off(note);
302            return VoiceHandle(0);
303        }
304        if let PlayMode::Mono { legato } = self.design.mode {
305            return self.mono_note_on(note, velocity, legato);
306        }
307        let handle = self.next_handle;
308        self.next_handle += 1;
309        self.spawn_voice(handle, note, velocity);
310        VoiceHandle(handle)
311    }
312
313    /// Build a fresh voice at `note` and add it to the pool, stealing the quietest
314    /// if full. A no-op on an un-buildable patch (a bad param) — the caller still
315    /// gets a handle, just a silent voice.
316    fn spawn_voice(&mut self, handle: u64, note: Note, velocity: f32) {
317        let Some(copies) = self.build_copies(note, velocity) else {
318            return; // un-buildable patch ⇒ the caller keeps its handle, the voice is silent
319        };
320        let mut env = EnvGen::new(&self.design.amp, self.sample_rate);
321        env.gate_on();
322        // Steal by forcing the quietest sounding voice into a ~5 ms release —
323        // never a mid-sample cut (an audible click on every steal). The pool
324        // briefly holds the declicking voices on top of max_voices; a note
325        // flood faster than the declick window falls back to hard removal so
326        // the pool stays bounded.
327        let sounding = self.voices.iter().filter(|v| !v.releasing).count();
328        if sounding >= self.design.max_voices
329            && let Some(victim) = self.quietest(|v| !v.releasing)
330        {
331            self.voices[victim].env.kill();
332            self.voices[victim].releasing = true;
333        }
334        if self.voices.len() >= self.design.max_voices * 2
335            && let Some(victim) = self.quietest(|_| true)
336        {
337            self.voices.remove(victim);
338        }
339        self.voices.push(Voice {
340            handle,
341            note,
342            built_hz: note.freq(),
343            copies,
344            env,
345            gain: velocity,
346            releasing: false,
347            sustained: false,
348        });
349    }
350
351    /// The per-sample one-pole coefficient for the configured glide time (`1.0` =
352    /// instant when glide is off).
353    fn glide_coeff(&self) -> f32 {
354        let secs = self.design.glide_secs;
355        if secs <= 0.0 {
356            1.0
357        } else {
358            1.0 - (-1.0 / (secs * self.sample_rate as f32)).exp()
359        }
360    }
361
362    /// Mono note-on: retune the live voice (gliding) to `note`, or strike a fresh
363    /// one if none is sounding. `legato` keeps the amp envelope running.
364    fn mono_note_on(&mut self, note: Note, velocity: f32, legato: bool) -> VoiceHandle {
365        self.held.retain(|&n| n != note);
366        self.held.push(note);
367        let coeff = self.glide_coeff();
368        if let Some(v) = self.voices.iter_mut().find(|v| !v.releasing) {
369            v.note = note;
370            v.sustained = false;
371            let scale = note.freq() / v.built_hz;
372            for c in v.copies.iter_mut() {
373                c.graph.glide_pitch(scale, coeff);
374            }
375            if !legato {
376                v.env.gate_on(); // re-strike unless we're playing legato
377                v.gain = velocity;
378            }
379            VoiceHandle(v.handle)
380        } else {
381            let handle = self.next_handle;
382            self.next_handle += 1;
383            self.spawn_voice(handle, note, velocity); // fresh attack — no glide
384            VoiceHandle(handle)
385        }
386    }
387
388    /// Mono note-off: fall back to the most-recent still-held note (gliding), or
389    /// release the voice (deferred by the sustain pedal) when nothing is held.
390    fn mono_note_off(&mut self, note: Note) -> bool {
391        let before = self.held.len();
392        self.held.retain(|&n| n != note);
393        if self.held.len() == before {
394            return false; // that note wasn't held
395        }
396        match self.held.last().copied() {
397            Some(prev) => {
398                let coeff = self.glide_coeff();
399                if let Some(v) = self.voices.iter_mut().find(|v| !v.releasing) {
400                    v.note = prev;
401                    let scale = prev.freq() / v.built_hz;
402                    for c in v.copies.iter_mut() {
403                        c.graph.glide_pitch(scale, coeff);
404                    }
405                }
406                true
407            }
408            None => {
409                let sustain = self.sustain;
410                for v in self.voices.iter_mut().filter(|v| !v.releasing) {
411                    if sustain {
412                        v.sustained = true;
413                    } else {
414                        v.env.gate_off();
415                        v.releasing = true;
416                    }
417                }
418                true
419            }
420        }
421    }
422
423    /// Index of the quietest voice among those matching `pick`.
424    fn quietest(&self, pick: impl Fn(&Voice) -> bool) -> Option<usize> {
425        self.voices
426            .iter()
427            .enumerate()
428            .filter(|(_, v)| pick(v))
429            .min_by(|(_, a), (_, b)| a.env.level().total_cmp(&b.env.level()))
430            .map(|(i, _)| i)
431    }
432
433    /// Release the newest still-held voice of `note` (or defer it if the
434    /// sustain pedal is down); returns whether a voice was released/deferred.
435    /// MIDI note-off arrives by pitch, so this is the common path.
436    pub fn note_off(&mut self, note: Note) -> bool {
437        if matches!(self.design.mode, PlayMode::Mono { .. }) {
438            return self.mono_note_off(note);
439        }
440        let sustain = self.sustain;
441        match self
442            .voices
443            .iter_mut()
444            .rev()
445            .find(|v| v.note == note && !v.releasing && !v.sustained)
446        {
447            Some(v) if sustain => {
448                v.sustained = true; // hold until pedal-up
449                true
450            }
451            Some(v) => {
452                v.env.gate_off();
453                v.releasing = true;
454                true
455            }
456            None => false,
457        }
458    }
459
460    /// Set the sustain pedal. While down, note-offs are held; on release, every
461    /// deferred voice enters its release. (MIDI CC64.)
462    pub fn set_sustain(&mut self, down: bool) {
463        self.sustain = down;
464        if !down {
465            for v in self.voices.iter_mut() {
466                if v.sustained {
467                    v.env.gate_off();
468                    v.releasing = true;
469                    v.sustained = false;
470                }
471            }
472        }
473    }
474
475    /// Bend every sounding voice (and any struck later) by `semitones` — the
476    /// pitch wheel. `0.0` is centered; a MIDI pitch wheel maps its ±8192 range to
477    /// your chosen semitone span (commonly ±2). The bend is a pure repitch of the
478    /// oscillators, applied live without rebuilding a voice.
479    pub fn set_bend(&mut self, semitones: f32) {
480        self.bend = 2f32.powf(semitones / 12.0);
481        for v in self.voices.iter_mut() {
482            for c in v.copies.iter_mut() {
483                c.graph.set_bend(self.bend);
484            }
485        }
486    }
487
488    /// Sweep the filter cutoff of every sounding voice (and any struck later) —
489    /// a live brightness control (`scale` multiplies each filter's cutoff, 1.0 =
490    /// as designed). Recomputes coefficients in place, so a knob/CC74 sweep is
491    /// click-free. Voices with no filter are simply unaffected.
492    pub fn set_brightness(&mut self, scale: f32) {
493        self.brightness = scale.max(0.01);
494        for v in self.voices.iter_mut() {
495            for c in v.copies.iter_mut() {
496                c.graph.set_cutoff(self.brightness);
497            }
498        }
499    }
500
501    /// Release a specific voice by handle; returns whether it was found.
502    pub fn release(&mut self, handle: VoiceHandle) -> bool {
503        match self.voices.iter_mut().find(|v| v.handle == handle.0) {
504            Some(v) => {
505                v.env.gate_off();
506                v.releasing = true;
507                true
508            }
509            None => false,
510        }
511    }
512
513    /// Release every held voice.
514    pub fn all_notes_off(&mut self) {
515        self.held.clear();
516        for v in self.voices.iter_mut() {
517            v.env.gate_off();
518            v.releasing = true;
519        }
520    }
521
522    /// Whether a handle still refers to a sounding voice.
523    pub fn is_active(&self, handle: VoiceHandle) -> bool {
524        self.voices.iter().any(|v| v.handle == handle.0)
525    }
526
527    /// The note a live voice is playing.
528    pub fn voice_note(&self, handle: VoiceHandle) -> Option<Note> {
529        self.voices
530            .iter()
531            .find(|v| v.handle == handle.0)
532            .map(|v| v.note)
533    }
534
535    /// The pitch scale a voice is currently sounding at (1.0 = its built note),
536    /// following an in-progress glide, excluding the pitch wheel. Useful for a
537    /// live pitch readout.
538    pub fn voice_pitch_scale(&self, handle: VoiceHandle) -> Option<f32> {
539        self.voices
540            .iter()
541            .find(|v| v.handle == handle.0)
542            .and_then(|v| v.copies.first())
543            .map(|c| c.graph.pitch())
544    }
545
546    /// Set a named parameter for future notes. Returns whether it was accepted —
547    /// rejected (and the previous value kept) if the name is unknown or the value
548    /// would make the patch invalid, so the instrument can never reach an
549    /// un-buildable state.
550    pub fn set_param(&mut self, name: &str, value: f32) -> bool {
551        if !self.design.patch.params.iter().any(|p| p.name == name) {
552            return false;
553        }
554        let prev = self.values.insert(name.to_string(), value);
555        if self.design.patch.instantiate(&self.values).is_ok() {
556            true
557        } else {
558            match prev {
559                Some(p) => self.values.insert(name.to_string(), p),
560                None => self.values.remove(name),
561            };
562            false
563        }
564    }
565
566    /// Number of live voices.
567    pub fn active_voices(&self) -> usize {
568        self.voices.len()
569    }
570}
571
572impl Instrument {
573    /// Update the modulation LFOs at their current phases, apply them to every
574    /// voice (vibrato rides the bend channel, wobble the cutoff, tremolo the
575    /// gain), then advance the phases by this `frames`-long control block.
576    fn apply_modulation(&mut self, frames: usize) {
577        let m = self.design.modulation;
578        let tau = std::f32::consts::TAU;
579        let vib = if m.vibrato_cents > 0.0 {
580            2f32.powf((m.vibrato_cents / 1200.0) * (tau * self.vib_phase).sin())
581        } else {
582            1.0
583        };
584        let flt = if m.filter_octaves > 0.0 {
585            2f32.powf(m.filter_octaves * (tau * self.flt_phase).sin())
586        } else {
587            1.0
588        };
589        self.trem = if m.tremolo_depth > 0.0 {
590            1.0 - m.tremolo_depth * 0.5 * (1.0 - (tau * self.trem_phase).sin())
591        } else {
592            1.0
593        };
594        let step = frames as f32 / self.sample_rate as f32;
595        self.vib_phase = (self.vib_phase + m.vibrato_rate * step).fract();
596        self.flt_phase = (self.flt_phase + m.filter_rate * step).fract();
597        self.trem_phase = (self.trem_phase + m.tremolo_rate * step).fract();
598        let (bend, cutoff) = (self.bend * vib, self.brightness * flt);
599        let wobble = m.filter_octaves > 0.0;
600        for v in self.voices.iter_mut() {
601            for c in v.copies.iter_mut() {
602                c.graph.set_bend(bend);
603                if wobble {
604                    c.graph.set_cutoff(cutoff);
605                }
606            }
607        }
608    }
609
610    /// Render one block at the current modulation state (the tremolo gain is
611    /// baked into the amp envelope). Split out so `fill` can drive it at control
612    /// rate when modulation is active.
613    fn render_block(&mut self, out: &mut [f32]) {
614        let frames = out.len() / 2;
615        out.fill(0.0);
616        if frames == 0 {
617            return;
618        }
619        for buf in [
620            &mut self.scratch,
621            &mut self.env_buf,
622            &mut self.mix_l,
623            &mut self.mix_r,
624        ] {
625            if buf.len() < frames {
626                buf.resize(frames, 0.0);
627            }
628        }
629        let trem = self.trem;
630        let copy = &mut self.scratch[..frames]; // per-copy render
631        let env = &mut self.env_buf[..frames]; // per-voice envelope × gain
632        let (mix_l, mix_r) = (&mut self.mix_l[..frames], &mut self.mix_r[..frames]);
633        mix_l.fill(0.0);
634        mix_r.fill(0.0);
635        for v in self.voices.iter_mut() {
636            // The amp envelope advances once per sample and is shared across the
637            // voice's unison copies (they differ only in detune and pan).
638            for e in env.iter_mut() {
639                *e = v.env.tick() * v.gain * trem;
640            }
641            for c in v.copies.iter_mut() {
642                c.graph.fill(copy);
643                for f in 0..frames {
644                    let s = copy[f] * env[f];
645                    mix_l[f] += s * c.l;
646                    mix_r[f] += s * c.r;
647                }
648            }
649        }
650        // One shared master per channel (a reverb tail is not multiplied per
651        // voice); identical coefficients, independent state ⇒ a stereo image.
652        if let Some((chain_l, chain_r)) = &mut self.master {
653            chain_l.process(mix_l);
654            chain_r.process(mix_r);
655        }
656        for f in 0..frames {
657            out[f * 2] = mix_l[f];
658            out[f * 2 + 1] = mix_r[f];
659        }
660        // Cull voices whose envelope has fully released — or a percussive voice
661        // (sustain ≈ 0) that has decayed to silence but never got a note-off.
662        self.voices.retain(|v| v.env.active() && !v.env.faded());
663    }
664}
665
666impl AudioSource for Instrument {
667    fn fill(&mut self, out: &mut [f32]) -> usize {
668        let frames = out.len() / 2;
669        // No modulation ⇒ render the whole block directly (byte-identical to a
670        // pre-modulation instrument: trem stays 1.0).
671        if !self.design.modulation.is_active() {
672            self.render_block(out);
673            return frames;
674        }
675        // Modulated ⇒ step the LFOs at control rate (64-frame sub-blocks) so
676        // vibrato/wobble/tremolo move smoothly without per-sample coefficient cost.
677        const CTRL: usize = 64;
678        let mut done = 0;
679        while done < frames {
680            let n = CTRL.min(frames - done);
681            self.apply_modulation(n);
682            self.render_block(&mut out[done * 2..(done + n) * 2]);
683            done += n;
684        }
685        frames
686    }
687}