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, .. } => scale(freq, ratio),
151        // Pitch-determining processors: the ring-mod carrier and the modal
152        // body's resonant partials must track the note, or a bell/metallic patch
153        // plays the same pitch for every key.
154        Node::RingMod { freq } => scale(freq, ratio),
155        Node::Modal { modes, .. } => {
156            for m in modes.iter_mut() {
157                m.freq *= ratio;
158            }
159        }
160        Node::Seq { notes, .. } => {
161            for note in notes.iter_mut() {
162                scale(&mut note.pitch, ratio);
163            }
164        }
165        _ => {}
166    }
167    // The recursion goes through the shared `children_mut` traversal — every
168    // nesting spot (mix/mul/chain, a tracks' layers and master chain, a
169    // duck's trigger) is covered by construction.
170    node.children_mut().for_each(|c| transpose(c, ratio));
171}
172
173impl Instrument {
174    /// Build an instrument from a design. Errors if the patch can't instantiate
175    /// or its graph is outside the streamable subset — so every note is
176    /// guaranteed to play in real time.
177    pub fn new(design: InstrumentDesign, sample_rate: u32) -> Result<Self, InstrumentError> {
178        let master = if design.master.is_empty() {
179            None
180        } else {
181            let engine = design.patch.doc.effective_engine();
182            let build = || EffectChain::try_new(&design.master, sample_rate, engine);
183            let (l, r) = (
184                build().ok_or(InstrumentError::NotStreamable)?,
185                build().ok_or(InstrumentError::NotStreamable)?,
186            );
187            Some((l, r))
188        };
189        let values = design.patch.defaults();
190        let inst = Instrument {
191            sample_rate,
192            design,
193            values,
194            voices: Vec::new(),
195            next_handle: 1,
196            sustain: false,
197            bend: 1.0,
198            brightness: 1.0,
199            vib_phase: 0.0,
200            flt_phase: 0.0,
201            trem_phase: 0.0,
202            trem: 1.0,
203            held: Vec::new(),
204            master,
205            scratch: Vec::new(),
206            env_buf: Vec::new(),
207            mix_l: Vec::new(),
208            mix_r: Vec::new(),
209        };
210        inst.build_result(Note::A4, 1.0, 1.0)?; // validate the reference voice
211        Ok(inst)
212    }
213
214    /// Build the streamable graph for one note at the current parameter values.
215    /// `detune` is a frequency multiplier baked into the graph (1.0 = none) — a
216    /// unison copy bakes its slight detune here, so glide/bend (which ride the
217    /// live pitch scale, keyed off the *nominal* note) preserve the spread.
218    fn build_result(
219        &self,
220        note: Note,
221        velocity: f32,
222        detune: f32,
223    ) -> Result<StreamGraph, InstrumentError> {
224        let hz = note.freq() * detune;
225        let mut values = self.values.clone();
226        if let PitchMap::Param(name) = &self.design.pitch {
227            values.insert(name.clone(), hz);
228        }
229        if let Some(vp) = &self.design.velocity_param {
230            // Map velocity across the param's declared [min, max] (a musical
231            // range), not the raw 0..1 — which would clamp to the minimum.
232            if let Some(spec) = self.design.patch.params.iter().find(|p| &p.name == vp) {
233                let (lo, hi) = (spec.min.min(spec.max), spec.min.max(spec.max));
234                values.insert(vp.clone(), lo + velocity.clamp(0.0, 1.0) * (hi - lo));
235            }
236        }
237        let mut doc: SoundDoc = self
238            .design
239            .patch
240            .instantiate(&values)
241            .map_err(|e| InstrumentError::BadPatch(e.to_string()))?;
242        doc.sample_rate = self.sample_rate;
243        if let PitchMap::Transpose { reference } = &self.design.pitch {
244            transpose(&mut doc.root, hz / reference.freq());
245        }
246        StreamGraph::try_from_doc(&doc).ok_or(InstrumentError::NotStreamable)
247    }
248
249    /// Build the unison stack for `note`: `unison` detuned, panned, level-
250    /// normalised copies (one copy, centered, when unison is off). `None` if the
251    /// patch can't build.
252    fn build_copies(&self, note: Note, velocity: f32) -> Option<Vec<UnisonCopy>> {
253        let n = self.design.unison.max(1);
254        let norm = 1.0 / (n as f32).sqrt(); // a stack shouldn't be louder than one
255        let mut copies = Vec::with_capacity(n);
256        for k in 0..n {
257            // Spread copies symmetrically over [-1, 1] × the configured amounts.
258            let spread = if n == 1 {
259                0.0
260            } else {
261                (k as f32 / (n - 1) as f32 - 0.5) * 2.0
262            };
263            let detune = 2f32.powf(spread * self.design.detune_cents / 1200.0);
264            let mut graph = self.build_result(note, velocity, detune).ok()?;
265            if self.bend != 1.0 {
266                graph.set_bend(self.bend);
267            }
268            if self.brightness != 1.0 {
269                graph.set_cutoff(self.brightness); // catch a new note up to the knob
270            }
271            let pan = spread * self.design.unison_width;
272            copies.push(UnisonCopy {
273                graph,
274                l: (1.0 - pan).min(1.0) * norm,
275                r: (1.0 + pan).min(1.0) * norm,
276            });
277        }
278        Some(copies)
279    }
280
281    /// Start a note; `velocity` in `[0, 1]` shapes its level. Returns the voice's
282    /// handle. In poly mode each note is its own voice; if the pool is full the
283    /// **quietest** voice is stolen (the least audible cut). In mono mode the one
284    /// voice is retuned (gliding) to the new note. A patch made un-buildable by a
285    /// bad param yields a silent voice rather than panicking — a control event
286    /// never crashes the audio thread. MIDI convention: a note-on with
287    /// `velocity == 0.0` is a note-off (spawning a silent voice for it would
288    /// leak a voice that never releases) and returns the inert handle.
289    pub fn note_on(&mut self, note: Note, velocity: f32) -> VoiceHandle {
290        // NaN folds to 0.0 → the safe reading (note-off), not a loud surprise.
291        let velocity = if velocity.is_nan() {
292            0.0
293        } else {
294            velocity.clamp(0.0, 1.0)
295        };
296        if velocity == 0.0 {
297            self.note_off(note);
298            return VoiceHandle(0);
299        }
300        if let PlayMode::Mono { legato } = self.design.mode {
301            return self.mono_note_on(note, velocity, legato);
302        }
303        let handle = self.next_handle;
304        self.next_handle += 1;
305        self.spawn_voice(handle, note, velocity);
306        VoiceHandle(handle)
307    }
308
309    /// Build a fresh voice at `note` and add it to the pool, stealing the quietest
310    /// if full. A no-op on an un-buildable patch (a bad param) — the caller still
311    /// gets a handle, just a silent voice.
312    fn spawn_voice(&mut self, handle: u64, note: Note, velocity: f32) {
313        let Some(copies) = self.build_copies(note, velocity) else {
314            return; // un-buildable patch ⇒ the caller keeps its handle, the voice is silent
315        };
316        let mut env = EnvGen::new(&self.design.amp, self.sample_rate);
317        env.gate_on();
318        // Steal by forcing the quietest sounding voice into a ~5 ms release —
319        // never a mid-sample cut (an audible click on every steal). The pool
320        // briefly holds the declicking voices on top of max_voices; a note
321        // flood faster than the declick window falls back to hard removal so
322        // the pool stays bounded.
323        let sounding = self.voices.iter().filter(|v| !v.releasing).count();
324        if sounding >= self.design.max_voices
325            && let Some(victim) = self.quietest(|v| !v.releasing)
326        {
327            self.voices[victim].env.kill();
328            self.voices[victim].releasing = true;
329        }
330        if self.voices.len() >= self.design.max_voices * 2
331            && let Some(victim) = self.quietest(|_| true)
332        {
333            self.voices.remove(victim);
334        }
335        self.voices.push(Voice {
336            handle,
337            note,
338            built_hz: note.freq(),
339            copies,
340            env,
341            gain: velocity,
342            releasing: false,
343            sustained: false,
344        });
345    }
346
347    /// The per-sample one-pole coefficient for the configured glide time (`1.0` =
348    /// instant when glide is off).
349    fn glide_coeff(&self) -> f32 {
350        let secs = self.design.glide_secs;
351        if secs <= 0.0 {
352            1.0
353        } else {
354            1.0 - (-1.0 / (secs * self.sample_rate as f32)).exp()
355        }
356    }
357
358    /// Mono note-on: retune the live voice (gliding) to `note`, or strike a fresh
359    /// one if none is sounding. `legato` keeps the amp envelope running.
360    fn mono_note_on(&mut self, note: Note, velocity: f32, legato: bool) -> VoiceHandle {
361        self.held.retain(|&n| n != note);
362        self.held.push(note);
363        let coeff = self.glide_coeff();
364        if let Some(v) = self.voices.iter_mut().find(|v| !v.releasing) {
365            v.note = note;
366            v.sustained = false;
367            let scale = note.freq() / v.built_hz;
368            for c in v.copies.iter_mut() {
369                c.graph.glide_pitch(scale, coeff);
370            }
371            if !legato {
372                v.env.gate_on(); // re-strike unless we're playing legato
373                v.gain = velocity;
374            }
375            VoiceHandle(v.handle)
376        } else {
377            let handle = self.next_handle;
378            self.next_handle += 1;
379            self.spawn_voice(handle, note, velocity); // fresh attack — no glide
380            VoiceHandle(handle)
381        }
382    }
383
384    /// Mono note-off: fall back to the most-recent still-held note (gliding), or
385    /// release the voice (deferred by the sustain pedal) when nothing is held.
386    fn mono_note_off(&mut self, note: Note) -> bool {
387        let before = self.held.len();
388        self.held.retain(|&n| n != note);
389        if self.held.len() == before {
390            return false; // that note wasn't held
391        }
392        match self.held.last().copied() {
393            Some(prev) => {
394                let coeff = self.glide_coeff();
395                if let Some(v) = self.voices.iter_mut().find(|v| !v.releasing) {
396                    v.note = prev;
397                    let scale = prev.freq() / v.built_hz;
398                    for c in v.copies.iter_mut() {
399                        c.graph.glide_pitch(scale, coeff);
400                    }
401                }
402                true
403            }
404            None => {
405                let sustain = self.sustain;
406                for v in self.voices.iter_mut().filter(|v| !v.releasing) {
407                    if sustain {
408                        v.sustained = true;
409                    } else {
410                        v.env.gate_off();
411                        v.releasing = true;
412                    }
413                }
414                true
415            }
416        }
417    }
418
419    /// Index of the quietest voice among those matching `pick`.
420    fn quietest(&self, pick: impl Fn(&Voice) -> bool) -> Option<usize> {
421        self.voices
422            .iter()
423            .enumerate()
424            .filter(|(_, v)| pick(v))
425            .min_by(|(_, a), (_, b)| a.env.level().total_cmp(&b.env.level()))
426            .map(|(i, _)| i)
427    }
428
429    /// Release the newest still-held voice of `note` (or defer it if the
430    /// sustain pedal is down); returns whether a voice was released/deferred.
431    /// MIDI note-off arrives by pitch, so this is the common path.
432    pub fn note_off(&mut self, note: Note) -> bool {
433        if matches!(self.design.mode, PlayMode::Mono { .. }) {
434            return self.mono_note_off(note);
435        }
436        let sustain = self.sustain;
437        match self
438            .voices
439            .iter_mut()
440            .rev()
441            .find(|v| v.note == note && !v.releasing && !v.sustained)
442        {
443            Some(v) if sustain => {
444                v.sustained = true; // hold until pedal-up
445                true
446            }
447            Some(v) => {
448                v.env.gate_off();
449                v.releasing = true;
450                true
451            }
452            None => false,
453        }
454    }
455
456    /// Set the sustain pedal. While down, note-offs are held; on release, every
457    /// deferred voice enters its release. (MIDI CC64.)
458    pub fn set_sustain(&mut self, down: bool) {
459        self.sustain = down;
460        if !down {
461            for v in self.voices.iter_mut() {
462                if v.sustained {
463                    v.env.gate_off();
464                    v.releasing = true;
465                    v.sustained = false;
466                }
467            }
468        }
469    }
470
471    /// Bend every sounding voice (and any struck later) by `semitones` — the
472    /// pitch wheel. `0.0` is centered; a MIDI pitch wheel maps its ±8192 range to
473    /// your chosen semitone span (commonly ±2). The bend is a pure repitch of the
474    /// oscillators, applied live without rebuilding a voice.
475    pub fn set_bend(&mut self, semitones: f32) {
476        self.bend = 2f32.powf(semitones / 12.0);
477        for v in self.voices.iter_mut() {
478            for c in v.copies.iter_mut() {
479                c.graph.set_bend(self.bend);
480            }
481        }
482    }
483
484    /// Sweep the filter cutoff of every sounding voice (and any struck later) —
485    /// a live brightness control (`scale` multiplies each filter's cutoff, 1.0 =
486    /// as designed). Recomputes coefficients in place, so a knob/CC74 sweep is
487    /// click-free. Voices with no filter are simply unaffected.
488    pub fn set_brightness(&mut self, scale: f32) {
489        self.brightness = scale.max(0.01);
490        for v in self.voices.iter_mut() {
491            for c in v.copies.iter_mut() {
492                c.graph.set_cutoff(self.brightness);
493            }
494        }
495    }
496
497    /// Release a specific voice by handle; returns whether it was found.
498    pub fn release(&mut self, handle: VoiceHandle) -> bool {
499        match self.voices.iter_mut().find(|v| v.handle == handle.0) {
500            Some(v) => {
501                v.env.gate_off();
502                v.releasing = true;
503                true
504            }
505            None => false,
506        }
507    }
508
509    /// Release every held voice.
510    pub fn all_notes_off(&mut self) {
511        self.held.clear();
512        for v in self.voices.iter_mut() {
513            v.env.gate_off();
514            v.releasing = true;
515        }
516    }
517
518    /// Whether a handle still refers to a sounding voice.
519    pub fn is_active(&self, handle: VoiceHandle) -> bool {
520        self.voices.iter().any(|v| v.handle == handle.0)
521    }
522
523    /// The note a live voice is playing.
524    pub fn voice_note(&self, handle: VoiceHandle) -> Option<Note> {
525        self.voices
526            .iter()
527            .find(|v| v.handle == handle.0)
528            .map(|v| v.note)
529    }
530
531    /// The pitch scale a voice is currently sounding at (1.0 = its built note),
532    /// following an in-progress glide, excluding the pitch wheel. Useful for a
533    /// live pitch readout.
534    pub fn voice_pitch_scale(&self, handle: VoiceHandle) -> Option<f32> {
535        self.voices
536            .iter()
537            .find(|v| v.handle == handle.0)
538            .and_then(|v| v.copies.first())
539            .map(|c| c.graph.pitch())
540    }
541
542    /// Set a named parameter for future notes. Returns whether it was accepted —
543    /// rejected (and the previous value kept) if the name is unknown or the value
544    /// would make the patch invalid, so the instrument can never reach an
545    /// un-buildable state.
546    pub fn set_param(&mut self, name: &str, value: f32) -> bool {
547        if !self.design.patch.params.iter().any(|p| p.name == name) {
548            return false;
549        }
550        let prev = self.values.insert(name.to_string(), value);
551        if self.design.patch.instantiate(&self.values).is_ok() {
552            true
553        } else {
554            match prev {
555                Some(p) => self.values.insert(name.to_string(), p),
556                None => self.values.remove(name),
557            };
558            false
559        }
560    }
561
562    /// Number of live voices.
563    pub fn active_voices(&self) -> usize {
564        self.voices.len()
565    }
566}
567
568impl Instrument {
569    /// Update the modulation LFOs at their current phases, apply them to every
570    /// voice (vibrato rides the bend channel, wobble the cutoff, tremolo the
571    /// gain), then advance the phases by this `frames`-long control block.
572    fn apply_modulation(&mut self, frames: usize) {
573        let m = self.design.modulation;
574        let tau = std::f32::consts::TAU;
575        let vib = if m.vibrato_cents > 0.0 {
576            2f32.powf((m.vibrato_cents / 1200.0) * (tau * self.vib_phase).sin())
577        } else {
578            1.0
579        };
580        let flt = if m.filter_octaves > 0.0 {
581            2f32.powf(m.filter_octaves * (tau * self.flt_phase).sin())
582        } else {
583            1.0
584        };
585        self.trem = if m.tremolo_depth > 0.0 {
586            1.0 - m.tremolo_depth * 0.5 * (1.0 - (tau * self.trem_phase).sin())
587        } else {
588            1.0
589        };
590        let step = frames as f32 / self.sample_rate as f32;
591        self.vib_phase = (self.vib_phase + m.vibrato_rate * step).fract();
592        self.flt_phase = (self.flt_phase + m.filter_rate * step).fract();
593        self.trem_phase = (self.trem_phase + m.tremolo_rate * step).fract();
594        let (bend, cutoff) = (self.bend * vib, self.brightness * flt);
595        let wobble = m.filter_octaves > 0.0;
596        for v in self.voices.iter_mut() {
597            for c in v.copies.iter_mut() {
598                c.graph.set_bend(bend);
599                if wobble {
600                    c.graph.set_cutoff(cutoff);
601                }
602            }
603        }
604    }
605
606    /// Render one block at the current modulation state (the tremolo gain is
607    /// baked into the amp envelope). Split out so `fill` can drive it at control
608    /// rate when modulation is active.
609    fn render_block(&mut self, out: &mut [f32]) {
610        let frames = out.len() / 2;
611        out.fill(0.0);
612        if frames == 0 {
613            return;
614        }
615        for buf in [
616            &mut self.scratch,
617            &mut self.env_buf,
618            &mut self.mix_l,
619            &mut self.mix_r,
620        ] {
621            if buf.len() < frames {
622                buf.resize(frames, 0.0);
623            }
624        }
625        let trem = self.trem;
626        let copy = &mut self.scratch[..frames]; // per-copy render
627        let env = &mut self.env_buf[..frames]; // per-voice envelope × gain
628        let (mix_l, mix_r) = (&mut self.mix_l[..frames], &mut self.mix_r[..frames]);
629        mix_l.fill(0.0);
630        mix_r.fill(0.0);
631        for v in self.voices.iter_mut() {
632            // The amp envelope advances once per sample and is shared across the
633            // voice's unison copies (they differ only in detune and pan).
634            for e in env.iter_mut() {
635                *e = v.env.tick() * v.gain * trem;
636            }
637            for c in v.copies.iter_mut() {
638                c.graph.fill(copy);
639                for f in 0..frames {
640                    let s = copy[f] * env[f];
641                    mix_l[f] += s * c.l;
642                    mix_r[f] += s * c.r;
643                }
644            }
645        }
646        // One shared master per channel (a reverb tail is not multiplied per
647        // voice); identical coefficients, independent state ⇒ a stereo image.
648        if let Some((chain_l, chain_r)) = &mut self.master {
649            chain_l.process(mix_l);
650            chain_r.process(mix_r);
651        }
652        for f in 0..frames {
653            out[f * 2] = mix_l[f];
654            out[f * 2 + 1] = mix_r[f];
655        }
656        // Cull voices whose envelope has fully released — or a percussive voice
657        // (sustain ≈ 0) that has decayed to silence but never got a note-off.
658        self.voices.retain(|v| v.env.active() && !v.env.faded());
659    }
660}
661
662impl AudioSource for Instrument {
663    fn fill(&mut self, out: &mut [f32]) -> usize {
664        let frames = out.len() / 2;
665        // No modulation ⇒ render the whole block directly (byte-identical to a
666        // pre-modulation instrument: trem stays 1.0).
667        if !self.design.modulation.is_active() {
668            self.render_block(out);
669            return frames;
670        }
671        // Modulated ⇒ step the LFOs at control rate (64-frame sub-blocks) so
672        // vibrato/wobble/tremolo move smoothly without per-sample coefficient cost.
673        const CTRL: usize = 64;
674        let mut done = 0;
675        while done < frames {
676            let n = CTRL.min(frames - done);
677            self.apply_modulation(n);
678            self.render_block(&mut out[done * 2..(done + n) * 2]);
679            done += n;
680        }
681        frames
682    }
683}