Skip to main content

tono_core/runtime/
engine.rs

1//! The [`Engine`]: patch resources, live instances, polyphony/voice-stealing,
2//! and per-instance parameter re-renders.
3
4use std::collections::BTreeMap;
5
6use super::SCRATCH_FRAMES;
7use super::ring::{Controller, Renderer, spsc};
8use super::source::AudioSource;
9use crate::dsl::{Node, SoundDoc};
10use crate::edit::{EditOp, apply_ops};
11use crate::patch::Patch;
12use crate::player::Player;
13
14/// Fade applied wherever an abrupt gain change would click: voice steals and
15/// zero-length stops.
16const DECLICK_MS: f32 = 5.0;
17/// Floor for the re-render crossfade — a zero-length swap would click.
18const CROSSFADE_MIN_MS: f32 = 8.0;
19
20/// Handle to a loaded patch — an immutable, shareable resource. Cheap to copy;
21/// spawn as many instances of it as you like.
22#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
23pub struct PatchId(usize);
24
25/// Handle to one live instance of a patch. Stable for the instance's lifetime;
26/// setters against a finished/unknown handle are no-ops.
27#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
28pub struct InstanceHandle(pub(super) u64);
29
30/// A named patch parameter resolved to a typed handle at load — poke it by id on
31/// the hot path, never by string. Scoped to the [`PatchId`] it came from.
32#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
33pub struct ParamId {
34    patch: usize,
35    index: usize,
36}
37
38/// A named mixer layer (a `tracks` entry) resolved to a typed handle at load.
39#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
40pub struct LayerId {
41    patch: usize,
42    index: usize,
43}
44
45/// A voice's importance under a polyphony cap: when the [`Engine`] is at its
46/// [`max_voices`](Engine::set_max_voices) budget, a new voice steals the
47/// lowest-priority sounding one (oldest first on a tie), and is itself denied if
48/// every voice outranks it. Higher wins. Use the named tiers or any `u8`.
49#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug)]
50pub struct Priority(pub u8);
51
52impl Priority {
53    /// Ambient, interruptible one-shots (footsteps, UI ticks). `0`.
54    pub const LOW: Priority = Priority(0);
55    /// The default for [`Engine::play`]. `64`.
56    pub const NORMAL: Priority = Priority(64);
57    /// Salient events that should win a voice (impacts, pickups). `128`.
58    pub const HIGH: Priority = Priority(128);
59    /// Never stolen while anything lower is sounding (music, stingers). `255`.
60    pub const CRITICAL: Priority = Priority(255);
61}
62
63impl Default for Priority {
64    fn default() -> Self {
65        Priority::NORMAL
66    }
67}
68
69/// Control-rate linear smoothing over a fixed duration. `Tween::default()` /
70/// [`Tween::IMMEDIATE`] jumps instantly; [`Tween::ms`] ramps over a wall-clock time.
71#[derive(Clone, Copy, Debug)]
72pub struct Tween {
73    frames: u32,
74}
75
76impl Tween {
77    /// Jump to the target immediately (no ramp).
78    pub const IMMEDIATE: Tween = Tween { frames: 0 };
79
80    /// Ramp over an explicit number of frames.
81    pub const fn frames(n: u32) -> Self {
82        Tween { frames: n }
83    }
84
85    /// Ramp over `ms` milliseconds at `sample_rate`.
86    pub fn ms(ms: f32, sample_rate: u32) -> Self {
87        let f = (ms / 1000.0 * sample_rate as f32).round();
88        Tween {
89            frames: if f > 0.0 { f as u32 } else { 0 },
90        }
91    }
92}
93
94impl Default for Tween {
95    fn default() -> Self {
96        Tween::IMMEDIATE
97    }
98}
99
100/// A per-frame linear ramp toward a target value.
101#[derive(Clone, Copy)]
102struct Ramp {
103    value: f32,
104    target: f32,
105    step: f32,
106    remaining: u32,
107}
108
109impl Ramp {
110    fn new(v: f32) -> Self {
111        Ramp {
112            value: v,
113            target: v,
114            step: 0.0,
115            remaining: 0,
116        }
117    }
118
119    fn set(&mut self, target: f32, tw: Tween) {
120        self.target = target;
121        if tw.frames == 0 {
122            self.value = target;
123            self.step = 0.0;
124            self.remaining = 0;
125        } else {
126            self.step = (target - self.value) / tw.frames as f32;
127            self.remaining = tw.frames;
128        }
129    }
130
131    /// Advance one frame and return the current value.
132    fn tick(&mut self) -> f32 {
133        if self.remaining > 0 {
134            self.value += self.step;
135            self.remaining -= 1;
136            if self.remaining == 0 {
137                self.value = self.target;
138            }
139        }
140        self.value
141    }
142
143    fn at_target(&self) -> bool {
144        self.remaining == 0
145    }
146}
147
148/// Equal-level stereo balance (unity at centre): `pan` −1 = hard left, +1 = hard
149/// right. Returns per-channel gains `(left, right)`.
150pub(super) fn balance(pan: f32) -> (f32, f32) {
151    let l = if pan <= 0.0 { 1.0 } else { 1.0 - pan };
152    let r = if pan >= 0.0 { 1.0 } else { 1.0 + pan };
153    (l, r)
154}
155
156pub(super) struct Instance {
157    pub(super) id: u64,
158    patch: usize,
159    /// Current parameter values (name → value); seeds the next re-render.
160    values: BTreeMap<String, f32>,
161    /// Per-layer gain overrides (track index → gain).
162    layer_gains: BTreeMap<usize, f32>,
163    player: Player,
164    /// The outgoing player during a click-free re-render crossfade, with the
165    /// incoming mix ramp (0 → 1).
166    fading_in: Option<(Player, Ramp)>,
167    gain: Ramp,
168    pan: Ramp,
169    /// Fading out to be culled once silent.
170    pub(super) stopping: bool,
171    /// Importance under a polyphony cap (higher survives a steal).
172    priority: Priority,
173}
174
175/// The runtime mixer: owns patch resources and their live instances, and serves
176/// their mixed-down stereo through [`AudioSource::fill`].
177///
178/// ```
179/// use tono_core::prelude::*;
180/// use tono_core::dsl::Node;
181///
182/// let doc = SoundDoc::new("blip", Node::Sine { freq: 880.0.into() });
183/// let mut engine = Engine::new(48_000);
184/// let blip = engine.load(&doc);          // a reusable resource…
185/// let _voice = engine.play(blip);        // …spawning independent instances
186///
187/// let mut out = vec![0.0f32; 512];       // interleaved stereo L,R,L,R…
188/// engine.fill(&mut out);
189/// assert!(out.iter().any(|s| s.abs() > 0.0), "the blip is sounding");
190/// ```
191/// # Threading
192/// Mutating calls ([`play`](Self::play), [`set_param`](Self::set_param),
193/// [`stop`](Self::stop), …) render the whole document synchronously —
194/// O(duration), with allocations — so they must never run on an audio
195/// callback thread. Real-time hosts use [`split`](Self::split) to render on a
196/// pump thread joined to the callback by a wait-free ring. Note that
197/// `set_param` re-renders the whole doc per call with no coalescing —
198/// rate-limit parameter automation on the control side.
199pub struct Engine {
200    sample_rate: u32,
201    patches: Vec<Patch>,
202    pub(super) instances: Vec<Instance>,
203    next_id: u64,
204    /// Optional polyphony budget; `None` = unlimited (the default).
205    max_voices: Option<usize>,
206    buf_a: Vec<f32>,
207    buf_b: Vec<f32>,
208}
209
210impl Engine {
211    /// A fresh engine that renders at `sample_rate`.
212    pub fn new(sample_rate: u32) -> Self {
213        Engine {
214            sample_rate,
215            patches: Vec::new(),
216            instances: Vec::new(),
217            next_id: 1,
218            max_voices: None,
219            buf_a: vec![0.0; SCRATCH_FRAMES * 2],
220            buf_b: vec![0.0; SCRATCH_FRAMES * 2],
221        }
222    }
223
224    /// Cap the number of concurrently sounding instances. Once at the budget, a
225    /// new [`play`](Self::play) steals the lowest-[`Priority`] sounding instance
226    /// (declicked, not hard-cut) — or is denied if every instance outranks it.
227    /// Unset by default (unlimited). A `max` of 0 is treated as 1.
228    pub fn set_max_voices(&mut self, max: usize) {
229        self.max_voices = Some(max.max(1));
230    }
231
232    /// The current polyphony budget, or `None` if unlimited.
233    pub fn max_voices(&self) -> Option<usize> {
234        self.max_voices
235    }
236
237    /// The engine's sample rate.
238    pub fn sample_rate(&self) -> u32 {
239        self.sample_rate
240    }
241
242    /// Load a bare document as a patch resource (no named parameters).
243    pub fn load(&mut self, doc: &SoundDoc) -> PatchId {
244        self.load_patch(&Patch {
245            doc: doc.clone(),
246            params: Vec::new(),
247        })
248    }
249
250    /// Load a parametric [`Patch`] as a resource; its named params become
251    /// [`ParamId`]s via [`Engine::param`].
252    pub fn load_patch(&mut self, patch: &Patch) -> PatchId {
253        self.patches.push(patch.clone());
254        PatchId(self.patches.len() - 1)
255    }
256
257    /// Resolve a named parameter of a patch to a typed handle (once, off the hot
258    /// path). `None` if the patch has no such param.
259    pub fn param(&self, patch: PatchId, name: &str) -> Option<ParamId> {
260        // .get(): a PatchId is Copy and could come from another Engine — an
261        // unknown handle resolves to None, never a panic.
262        self.patches
263            .get(patch.0)?
264            .params
265            .iter()
266            .position(|p| p.name == name)
267            .map(|index| ParamId {
268                patch: patch.0,
269                index,
270            })
271    }
272
273    /// Resolve a named mixer layer (a `tracks` entry) to a typed handle. `None`
274    /// if the patch's graph has no track with that id.
275    pub fn layer(&self, patch: PatchId, name: &str) -> Option<LayerId> {
276        match &self.patches.get(patch.0)?.doc.root {
277            Node::Tracks { tracks, .. } => tracks
278                .iter()
279                .position(|t| t.id.as_deref() == Some(name))
280                .map(|index| LayerId {
281                    patch: patch.0,
282                    index,
283                }),
284            _ => None,
285        }
286    }
287
288    /// Spawn a one-shot instance of a patch (plays once, then culls itself) at
289    /// [`Priority::NORMAL`].
290    pub fn play(&mut self, patch: PatchId) -> InstanceHandle {
291        self.spawn(patch, false, Priority::NORMAL)
292    }
293
294    /// Spawn a looping instance of a patch (plays until [`stop`](Self::stop)ped)
295    /// at [`Priority::NORMAL`].
296    pub fn play_looping(&mut self, patch: PatchId) -> InstanceHandle {
297        self.spawn(patch, true, Priority::NORMAL)
298    }
299
300    /// Spawn a one-shot instance with an explicit [`Priority`]. Under a voice cap,
301    /// a higher priority survives a steal; a voice outranked by every sounding
302    /// instance is denied (returns an inert handle — [`is_active`](Self::is_active)
303    /// is `false`).
304    pub fn play_prioritized(&mut self, patch: PatchId, priority: Priority) -> InstanceHandle {
305        self.spawn(patch, false, priority)
306    }
307
308    /// Spawn a looping instance with an explicit [`Priority`] — e.g. music at
309    /// [`Priority::CRITICAL`] so it is never stolen by lower one-shots.
310    pub fn play_looping_prioritized(
311        &mut self,
312        patch: PatchId,
313        priority: Priority,
314    ) -> InstanceHandle {
315        self.spawn(patch, true, priority)
316    }
317
318    /// Change a live instance's [`Priority`] (no-op for an unknown handle).
319    pub fn set_priority(&mut self, h: InstanceHandle, priority: Priority) {
320        if let Some(i) = self.instance_mut(h) {
321            i.priority = priority;
322        }
323    }
324
325    fn spawn(&mut self, patch: PatchId, looping: bool, priority: Priority) -> InstanceHandle {
326        // Enforce the polyphony budget (if any) before adding a voice.
327        if let Some(max) = self.max_voices
328            && !self.make_room(max, priority)
329        {
330            // Outranked by every sounding voice — deny (virtualize to silence).
331            return InstanceHandle(0);
332        }
333        // .get(): a PatchId is Copy and could come from another Engine —
334        // return the inert handle rather than panic.
335        let Some(values) = self.patches.get(patch.0).map(Patch::defaults) else {
336            return InstanceHandle(0);
337        };
338        let doc = self.build_doc(patch.0, &values, &BTreeMap::new());
339        let player = self.new_player(doc, looping, 0);
340        let id = self.next_id;
341        self.next_id += 1;
342        self.instances.push(Instance {
343            id,
344            patch: patch.0,
345            values,
346            layer_gains: BTreeMap::new(),
347            player,
348            fading_in: None,
349            gain: Ramp::new(1.0),
350            pan: Ramp::new(0.0),
351            stopping: false,
352            priority,
353        });
354        InstanceHandle(id)
355    }
356
357    /// Make room for a new voice of `priority` under a `max` budget. Returns
358    /// `false` (deny the new voice) if every sounding instance outranks it.
359    /// Mirrors the instrument's two-stage bound: a soft steal declicks the victim
360    /// (so it briefly rides on top during the fade), and a hard bound at `2*max`
361    /// drops a voice outright so a flood faster than the declick can't grow.
362    fn make_room(&mut self, max: usize, priority: Priority) -> bool {
363        let sounding = self.instances.iter().filter(|i| !i.stopping).count();
364        if sounding >= max {
365            // Victim = lowest priority, oldest (lowest id) on a tie.
366            let victim = self
367                .instances
368                .iter()
369                .filter(|i| !i.stopping)
370                .min_by(|a, b| a.priority.cmp(&b.priority).then(a.id.cmp(&b.id)))
371                .map(|i| (i.id, i.priority));
372            match victim {
373                Some((id, vp)) if vp <= priority => {
374                    let fade = Tween::ms(DECLICK_MS, self.sample_rate);
375                    if let Some(v) = self.instances.iter_mut().find(|i| i.id == id) {
376                        v.gain.set(0.0, fade);
377                        v.stopping = true;
378                    }
379                }
380                _ => return false,
381            }
382        }
383        // Hard cap so declicking victims can't grow the Vec without bound.
384        if self.instances.len() >= max * 2
385            && let Some(pos) = self
386                .instances
387                .iter()
388                .enumerate()
389                .min_by(|(_, a), (_, b)| a.priority.cmp(&b.priority).then(a.id.cmp(&b.id)))
390                .map(|(pos, _)| pos)
391        {
392            self.instances.remove(pos);
393        }
394        true
395    }
396
397    /// Instantiate the patch with `values` and apply per-layer gain overrides;
398    /// falls back to the bare template if an edit fails (never a corrupt graph).
399    fn build_doc(
400        &self,
401        patch: usize,
402        values: &BTreeMap<String, f32>,
403        layer_gains: &BTreeMap<usize, f32>,
404    ) -> SoundDoc {
405        let p = &self.patches[patch];
406        let doc = p.instantiate(values).unwrap_or_else(|_| p.doc.clone());
407        if layer_gains.is_empty() {
408            return doc;
409        }
410        let ops: Vec<EditOp> = layer_gains
411            .iter()
412            .map(|(i, g)| EditOp::Set {
413                path: format!("root.tracks[{i}].gain"),
414                value: serde_json::json!(g),
415            })
416            .collect();
417        apply_ops(&doc, &ops).unwrap_or(doc)
418    }
419
420    fn new_player(&self, mut doc: SoundDoc, looping: bool, seek: usize) -> Player {
421        // Instances render at the engine's rate, not each doc's own — one internal
422        // rate for the whole mix (resampling to the device belongs at the adapter).
423        doc.sample_rate = self.sample_rate;
424        let mut player = Player::new(doc);
425        player.looping = looping;
426        player.seek(seek);
427        player.play();
428        player
429    }
430
431    fn instance_mut(&mut self, h: InstanceHandle) -> Option<&mut Instance> {
432        self.instances.iter_mut().find(|i| i.id == h.0)
433    }
434
435    fn find(&self, h: InstanceHandle) -> Option<usize> {
436        self.instances.iter().position(|i| i.id == h.0)
437    }
438
439    /// Set an instance's linear gain, smoothed over `tw` (no-op if finished).
440    pub fn set_gain(&mut self, h: InstanceHandle, gain: f32, tw: Tween) {
441        if let Some(i) = self.instance_mut(h) {
442            i.gain.set(gain.max(0.0), tw);
443        }
444    }
445
446    /// Set an instance's stereo balance (−1 left … +1 right), smoothed over `tw`.
447    pub fn set_pan(&mut self, h: InstanceHandle, pan: f32, tw: Tween) {
448        if let Some(i) = self.instance_mut(h) {
449            // clamp() passes NaN through, and a NaN pan would NaN the whole
450            // mix — fold it to centre.
451            let pan = if pan.is_nan() {
452                0.0
453            } else {
454                pan.clamp(-1.0, 1.0)
455            };
456            i.pan.set(pan, tw);
457        }
458    }
459
460    /// Set a named parameter on a live instance, crossfading over `tw` for a
461    /// click-free swap. `param` must come from the same patch the instance plays.
462    pub fn set_param(&mut self, h: InstanceHandle, param: ParamId, value: f32, tw: Tween) {
463        let Some(idx) = self.find(h) else { return };
464        if self.instances[idx].patch != param.patch {
465            return;
466        }
467        // .get(): a ParamId is Copy and could come from another Engine whose
468        // same-indexed patch has more params — never panic.
469        let Some(name) = self
470            .patches
471            .get(param.patch)
472            .and_then(|p| p.params.get(param.index))
473            .map(|p| p.name.clone())
474        else {
475            return;
476        };
477        self.instances[idx].values.insert(name, value);
478        self.rerender(idx, tw);
479    }
480
481    /// Set a named layer's gain on a live instance, crossfading over `tw`.
482    pub fn set_layer_gain(&mut self, h: InstanceHandle, layer: LayerId, gain: f32, tw: Tween) {
483        let Some(idx) = self.find(h) else { return };
484        if self.instances[idx].patch != layer.patch {
485            return;
486        }
487        self.instances[idx].layer_gains.insert(layer.index, gain);
488        self.rerender(idx, tw);
489    }
490
491    /// Re-render instance `idx` from its current values and crossfade the outgoing
492    /// audio into the new render over `tw` (a min fade avoids clicks).
493    fn rerender(&mut self, idx: usize, tw: Tween) {
494        let (patch, looping, pos) = {
495            let i = &self.instances[idx];
496            (i.patch, i.player.looping, i.player.position())
497        };
498        let values = self.instances[idx].values.clone();
499        let layer_gains = self.instances[idx].layer_gains.clone();
500        let doc = self.build_doc(patch, &values, &layer_gains);
501        let fresh = self.new_player(doc, looping, pos);
502
503        let fade = if tw.frames == 0 {
504            Tween::ms(CROSSFADE_MIN_MS, self.sample_rate)
505        } else {
506            tw
507        };
508        let inst = &mut self.instances[idx];
509        // The current player becomes the outgoing one; the fresh render fades in.
510        let outgoing = std::mem::replace(&mut inst.player, fresh);
511        // A stacked re-render (a param change landing mid-crossfade) drops the
512        // still-fading previous outgoing player. Starting the new mix at the
513        // old mix's current weight keeps the blend monotonic instead of
514        // re-fading from scratch, so the dropped tail carries only the
515        // remaining (1 − w) of its energy.
516        let w0 = inst.fading_in.as_ref().map_or(0.0, |(_, m)| m.value);
517        let mut mix = Ramp::new(w0);
518        mix.set(1.0, fade);
519        inst.fading_in = Some((outgoing, mix));
520    }
521
522    /// Stop an instance with a declick fade-out; it is culled once silent. A
523    /// zero-length `fade` is bumped to a short default so stops never click.
524    pub fn stop(&mut self, h: InstanceHandle, fade: Tween) {
525        let min_fade = Tween::ms(DECLICK_MS, self.sample_rate);
526        if let Some(i) = self.instance_mut(h) {
527            let fade = if fade.frames == 0 { min_fade } else { fade };
528            i.gain.set(0.0, fade);
529            i.stopping = true;
530        }
531    }
532
533    /// Number of live instances.
534    pub fn active(&self) -> usize {
535        self.instances.len()
536    }
537
538    /// Whether a handle still refers to a live instance.
539    pub fn is_active(&self, h: InstanceHandle) -> bool {
540        self.instances.iter().any(|i| i.id == h.0)
541    }
542
543    /// The current crossfade weight of an instance (test-only introspection).
544    #[cfg(test)]
545    pub(super) fn fade_weight(&self, idx: usize) -> Option<f32> {
546        self.instances
547            .get(idx)?
548            .fading_in
549            .as_ref()
550            .map(|(_, m)| m.value)
551    }
552}
553
554impl AudioSource for Engine {
555    fn fill(&mut self, out: &mut [f32]) -> usize {
556        let frames = out.len() / 2;
557        out.fill(0.0);
558        if frames == 0 {
559            return 0;
560        }
561        if self.buf_a.len() < out.len() {
562            self.buf_a.resize(out.len(), 0.0);
563            self.buf_b.resize(out.len(), 0.0);
564        }
565        let (a, b) = (&mut self.buf_a[..out.len()], &mut self.buf_b[..out.len()]);
566
567        for inst in self.instances.iter_mut() {
568            // Player::fill writes the whole block (silence past a one-shot's end)
569            // and flips `playing` off when a non-looping instance is exhausted.
570            inst.player.fill(a);
571            if let Some((out_player, _)) = inst.fading_in.as_mut() {
572                out_player.fill(b);
573            }
574            for f in 0..frames {
575                let g = inst.gain.tick();
576                let (lg, rg) = balance(inst.pan.tick());
577                let (mut l, mut r) = (a[f * 2], a[f * 2 + 1]);
578                if let Some((_, mix)) = inst.fading_in.as_mut() {
579                    let w = mix.tick();
580                    l = l * w + b[f * 2] * (1.0 - w);
581                    r = r * w + b[f * 2 + 1] * (1.0 - w);
582                }
583                out[f * 2] += l * g * lg;
584                out[f * 2 + 1] += r * g * rg;
585            }
586            if let Some((_, mix)) = &inst.fading_in
587                && mix.at_target()
588            {
589                inst.fading_in = None; // crossfade complete
590            }
591        }
592
593        // Cull finished one-shots and faded-out stops. An instance whose fresh
594        // one-shot ended mid-crossfade lives until the fade completes — its
595        // outgoing player is still sounding.
596        self.instances.retain(|i| {
597            let outgoing_sounding = i.fading_in.as_ref().is_some_and(|(p, _)| p.playing);
598            (i.player.playing || outgoing_sounding) && !(i.stopping && i.gain.at_target())
599        });
600        frames
601    }
602}
603
604impl Engine {
605    /// Split into a [`Controller`] (control thread) and a [`Renderer`] (audio
606    /// thread) joined by a wait-free ring `ring_frames` deep. Pump the controller
607    /// off the audio thread; the renderer drains it in the callback.
608    pub fn split(self, ring_frames: usize) -> (Controller, Renderer) {
609        spsc(self, ring_frames)
610    }
611}