Skip to main content

cranpose_audio/
mixer.rs

1//! The software mixer that runs on the platform's real-time audio thread.
2//!
3//! Everything the mixer needs is allocated before the stream starts: a fixed
4//! clip table, a fixed voice table, and the two rings it shares with the UI
5//! thread. [`Mixer::render`] therefore performs no allocation, takes no lock,
6//! logs nothing, and calls into no operating-system service. Its only inputs
7//! are commands popped from a wait-free queue.
8//!
9//! A clip whose slot is overwritten or released cannot be dropped here — the
10//! deallocation would run on the audio thread — so the mixer pushes it back to
11//! the UI thread through the `retired` ring instead.
12
13// The mixer is platform-independent and always compiled, so its tests run on
14// every host. A build with no output device compiled in has nothing that
15// constructs one, which is the single configuration where that is expected.
16#![cfg_attr(
17    not(any(
18        test,
19        all(feature = "aaudio", target_os = "android"),
20        all(
21            feature = "cpal-backend",
22            not(any(target_os = "android", target_arch = "wasm32"))
23        )
24    )),
25    allow(dead_code)
26)]
27
28use crate::ring::{Consumer, Producer};
29use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
30use std::sync::Arc;
31
32/// How many clips the engine holds at once. One byte of index, and far more
33/// than the couple of dozen cues a game keeps resident.
34pub const MAX_CLIPS: usize = 256;
35
36/// How many voices can sound simultaneously. Beyond this the oldest one-shot
37/// is stolen, which is what a listener expects when a cue storm arrives.
38pub const MAX_VOICES: usize = 32;
39
40/// How many mix buses exist. Mirrors `cranpose_services::AudioBus`.
41pub const BUS_COUNT: usize = 2;
42
43/// How long the output keeps running with nothing to play before the mixer
44/// stops it.
45///
46/// A running output stream is not free even when every sample it carries is
47/// zero: on Android it holds an MMAP route open and keeps the always-on audio
48/// DSP awake, which measures in tens of milliwatts on a phone and is a large
49/// share of a watch's budget. Stopping is therefore worth doing — but every
50/// restart is a device round trip (route setup, then the first callback), so
51/// stopping too eagerly turns a burst of UI cues into a burst of route changes
52/// and risks clipping the front of a sound.
53///
54/// Two seconds sits above both of the intervals that matter. A player working
55/// through a menu taps every few hundred milliseconds and a one-shot cue lasts
56/// well under a second, so an active screen never stops the stream; a screen
57/// the player has settled on goes quiet two seconds after its last sound and
58/// stays that way for as long as they look at it, which is where all of the
59/// battery is. Anything shorter buys nothing measurable and starts to thrash.
60pub const IDLE_GRACE_SECONDS: f32 = 2.0;
61
62/// What the output device should do once [`Mixer::render`] returns.
63#[derive(Clone, Copy, Debug, Eq, PartialEq)]
64pub enum RenderStatus {
65    /// Keep the stream running: something is sounding, or work is queued.
66    Continue,
67    /// Nothing has sounded for [`IDLE_GRACE_SECONDS`] and the command queue is
68    /// empty, so the stream should stop. The engine starts it again on the
69    /// next play.
70    Idle,
71}
72
73/// Decoded PCM as the audio thread sees it.
74#[derive(Clone)]
75pub struct ClipData {
76    /// Interleaved samples.
77    pub samples: Arc<[f32]>,
78    /// 1 or 2.
79    pub channels: u8,
80    /// The clip's recorded rate in Hz.
81    pub sample_rate: u32,
82}
83
84impl ClipData {
85    fn frames(&self) -> usize {
86        self.samples.len() / usize::from(self.channels)
87    }
88}
89
90impl std::fmt::Debug for ClipData {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("ClipData")
93            .field("frames", &self.frames())
94            .field("channels", &self.channels)
95            .field("sample_rate", &self.sample_rate)
96            .finish()
97    }
98}
99
100/// Work the UI thread hands to the mixer. Every variant is small and `Send`.
101#[derive(Debug)]
102pub enum Command {
103    /// Installs a clip in `slot`, replacing whatever was there.
104    LoadClip {
105        /// Index into the clip table.
106        slot: u32,
107        /// The clip to install.
108        clip: ClipData,
109    },
110    /// Releases a slot and silences its voices.
111    UnloadClip {
112        /// Index into the clip table.
113        slot: u32,
114    },
115    /// Starts a voice.
116    Play {
117        /// The voice handle the UI thread allocated.
118        voice: u64,
119        /// Index into the clip table.
120        slot: u32,
121        /// Left channel gain, volume and pan already folded in.
122        gain_left: f32,
123        /// Right channel gain, volume and pan already folded in.
124        gain_right: f32,
125        /// Playback rate relative to the clip's recorded pitch.
126        rate: f32,
127        /// Which bus the voice mixes into.
128        bus: u8,
129        /// Whether the voice restarts at the end.
130        looping: bool,
131    },
132    /// Changes a running voice's gain and rate.
133    RetuneVoice {
134        /// The voice to change.
135        voice: u64,
136        /// New left channel gain.
137        gain_left: f32,
138        /// New right channel gain.
139        gain_right: f32,
140        /// New playback rate.
141        rate: f32,
142    },
143    /// Silences one voice.
144    StopVoice {
145        /// The voice to silence.
146        voice: u64,
147    },
148    /// Silences every voice playing a clip.
149    StopClip {
150        /// Index into the clip table.
151        slot: u32,
152    },
153    /// Silences every voice.
154    StopAll,
155    /// Sets the gain applied to every bus.
156    SetMaster(f32),
157    /// Sets one bus's gain.
158    SetBusVolume {
159        /// Bus index.
160        bus: u8,
161        /// New gain.
162        volume: f32,
163    },
164    /// Mutes or unmutes one bus without stopping its voices.
165    SetBusEnabled {
166        /// Bus index.
167        bus: u8,
168        /// Whether the bus is audible.
169        enabled: bool,
170    },
171}
172
173/// The two ring ends and the shared counters a [`Mixer`] is built from.
174pub struct MixerSeed {
175    /// Commands arriving from the UI thread.
176    pub commands: Consumer<Command>,
177    /// Clips handed back for the UI thread to drop.
178    pub retired: Producer<ClipData>,
179    /// Incremented when a retired clip could not be handed back.
180    pub leaked_clips: Arc<AtomicU32>,
181    /// Incremented when the output ran with no room to grow, for diagnostics.
182    pub underruns: Arc<AtomicU32>,
183    /// Whether the output stream is producing audio.
184    ///
185    /// The mixer clears it when it gives the stream up for want of anything to
186    /// play; the engine sets it again when it starts the stream back up. It is
187    /// the only piece of mixer state the UI thread reads, which is why it is
188    /// an atomic rather than a call into [`Mixer`] — walking the voice table
189    /// from the UI thread would race the render that owns it.
190    pub streaming: Arc<AtomicBool>,
191}
192
193#[derive(Clone, Copy)]
194struct Voice {
195    /// 0 means the slot is free.
196    id: u64,
197    slot: usize,
198    /// Fractional read position in frames.
199    position: f64,
200    /// Frames advanced per output frame.
201    step: f64,
202    /// Requested rate, kept so the step can be recomputed if the device rate
203    /// turns out to differ from the one the mixer was built with.
204    rate: f32,
205    gain_left: f32,
206    gain_right: f32,
207    bus: usize,
208    looping: bool,
209}
210
211impl Voice {
212    const IDLE: Voice = Voice {
213        id: 0,
214        slot: 0,
215        position: 0.0,
216        step: 1.0,
217        rate: 1.0,
218        gain_left: 0.0,
219        gain_right: 0.0,
220        bus: 0,
221        looping: false,
222    };
223}
224
225/// The real-time mixer. Owned by the platform sink, driven from its callback.
226pub struct Mixer {
227    commands: Consumer<Command>,
228    retired: Producer<ClipData>,
229    leaked_clips: Arc<AtomicU32>,
230    underruns: Arc<AtomicU32>,
231    streaming: Arc<AtomicBool>,
232    clips: Vec<Option<ClipData>>,
233    voices: Vec<Voice>,
234    master: f32,
235    bus_volume: [f32; BUS_COUNT],
236    bus_enabled: [bool; BUS_COUNT],
237    device_sample_rate: f32,
238    device_channels: usize,
239    /// Output frames rendered since the last one that had a voice in it.
240    idle_frames: u64,
241    /// [`IDLE_GRACE_SECONDS`] at the device's rate, so the check below is an
242    /// integer compare rather than a multiply per callback.
243    idle_grace_frames: u64,
244}
245
246impl Mixer {
247    /// Builds a mixer for a device running at `sample_rate` with `channels`
248    /// output channels. Every buffer it will ever touch is allocated here.
249    pub fn new(seed: MixerSeed, sample_rate: f32, channels: usize) -> Mixer {
250        let mut clips = Vec::with_capacity(MAX_CLIPS);
251        clips.resize_with(MAX_CLIPS, || None);
252        Mixer {
253            commands: seed.commands,
254            retired: seed.retired,
255            leaked_clips: seed.leaked_clips,
256            underruns: seed.underruns,
257            streaming: seed.streaming,
258            clips,
259            voices: vec![Voice::IDLE; MAX_VOICES],
260            master: 1.0,
261            bus_volume: [1.0; BUS_COUNT],
262            bus_enabled: [true; BUS_COUNT],
263            device_sample_rate: sample_rate.max(1.0),
264            device_channels: channels.max(1),
265            idle_frames: 0,
266            idle_grace_frames: grace_frames(sample_rate),
267        }
268    }
269
270    /// Re-reads the device format. AAudio only reports the negotiated rate once
271    /// the stream is open, so the first callback corrects the assumption the
272    /// mixer was built with; running voices keep their pitch.
273    ///
274    /// Backends that know the format up front (cpal) never call this, which is
275    /// why the allow is needed on builds that compile only those.
276    #[allow(dead_code)]
277    pub fn set_device_format(&mut self, sample_rate: f32, channels: usize) {
278        let sample_rate = sample_rate.max(1.0);
279        let channels = channels.max(1);
280        if sample_rate == self.device_sample_rate && channels == self.device_channels {
281            return;
282        }
283        self.device_sample_rate = sample_rate;
284        self.device_channels = channels;
285        self.idle_grace_frames = grace_frames(sample_rate);
286        for index in 0..self.voices.len() {
287            if self.voices[index].id == 0 {
288                continue;
289            }
290            let slot = self.voices[index].slot;
291            let rate = self.voices[index].rate;
292            let clip_rate = self.clips[slot]
293                .as_ref()
294                .map(|clip| clip.sample_rate)
295                .unwrap_or(0);
296            self.voices[index].step = step_for(rate, clip_rate, sample_rate);
297        }
298    }
299
300    /// The device sample rate the mixer is currently resampling to.
301    #[allow(dead_code)]
302    pub fn device_sample_rate(&self) -> f32 {
303        self.device_sample_rate
304    }
305
306    /// How many voices are sounding. Diagnostics only; not called from the
307    /// audio callback.
308    #[allow(dead_code)]
309    pub fn active_voices(&self) -> usize {
310        self.voices.iter().filter(|voice| voice.id != 0).count()
311    }
312
313    /// Fills `out` with `out.len() / channels` interleaved output frames, and
314    /// reports whether the device still has a reason to run.
315    ///
316    /// This is the real-time entry point: it allocates nothing, locks nothing
317    /// and logs nothing.
318    pub fn render(&mut self, out: &mut [f32]) -> RenderStatus {
319        self.drain_commands();
320
321        for sample in out.iter_mut() {
322            *sample = 0.0;
323        }
324
325        let channels = self.device_channels;
326        if channels == 0 || out.is_empty() {
327            return RenderStatus::Continue;
328        }
329        let out_frames = out.len() / channels;
330        if out_frames == 0 {
331            self.underruns.fetch_add(1, Ordering::Relaxed);
332            return RenderStatus::Continue;
333        }
334
335        let master = self.master;
336        let bus_gain = [
337            if self.bus_enabled[0] {
338                self.bus_volume[0] * master
339            } else {
340                0.0
341            },
342            if self.bus_enabled[1] {
343                self.bus_volume[1] * master
344            } else {
345                0.0
346            },
347        ];
348
349        // How many voices put samples into this buffer. Counted here rather
350        // than by `active_voices`, which walks the whole voice table and is
351        // documented as diagnostics only: the loop below already visits every
352        // voice, so the idle test costs one increment.
353        let mut sounding = 0usize;
354        let clips = &self.clips;
355        for voice in self.voices.iter_mut() {
356            if voice.id == 0 {
357                continue;
358            }
359            let Some(clip) = clips[voice.slot].as_ref() else {
360                voice.id = 0;
361                continue;
362            };
363            let frames = clip.frames();
364            if frames == 0 {
365                voice.id = 0;
366                continue;
367            }
368            let stereo_clip = clip.channels == 2;
369            let gain = bus_gain[voice.bus];
370            let gain_left = voice.gain_left * gain;
371            let gain_right = voice.gain_right * gain;
372            let mut position = voice.position;
373            let step = voice.step;
374            let length = frames as f64;
375            // Whether this voice puts anything into this buffer. A one-shot
376            // that had already run out contributes nothing; every other voice
377            // renders at least the first frame. Judging by whether the voice
378            // is still alive afterwards would miss a cue that starts and ends
379            // inside one callback — a tap in a menu, exactly the thing the
380            // grace period exists to sit through.
381            let audible = voice.looping || position < length;
382
383            for frame in 0..out_frames {
384                if position >= length {
385                    if voice.looping {
386                        // A loop point is a subtraction, not a modulo: rates
387                        // are bounded so one wrap is always enough.
388                        position -= length;
389                        if position < 0.0 || position >= length {
390                            position = 0.0;
391                        }
392                    } else {
393                        voice.id = 0;
394                        break;
395                    }
396                }
397
398                let index = position as usize;
399                let index = if index < frames { index } else { frames - 1 };
400                let fraction = (position - index as f64) as f32;
401                let next = if index + 1 < frames {
402                    index + 1
403                } else if voice.looping {
404                    0
405                } else {
406                    index
407                };
408
409                let (left, right) = if stereo_clip {
410                    let a_left = clip.samples[index * 2];
411                    let a_right = clip.samples[index * 2 + 1];
412                    let b_left = clip.samples[next * 2];
413                    let b_right = clip.samples[next * 2 + 1];
414                    (
415                        a_left + (b_left - a_left) * fraction,
416                        a_right + (b_right - a_right) * fraction,
417                    )
418                } else {
419                    let a = clip.samples[index];
420                    let b = clip.samples[next];
421                    let sample = a + (b - a) * fraction;
422                    (sample, sample)
423                };
424
425                let base = frame * channels;
426                if channels == 1 {
427                    out[base] += (left * gain_left + right * gain_right) * 0.5;
428                } else {
429                    out[base] += left * gain_left;
430                    out[base + 1] += right * gain_right;
431                }
432
433                position += step;
434            }
435
436            voice.position = position;
437            if audible {
438                sounding += 1;
439            }
440        }
441
442        for sample in out.iter_mut() {
443            *sample = sample.clamp(-1.0, 1.0);
444        }
445
446        self.settle(sounding, out_frames)
447    }
448
449    /// Decides whether the device is still earning its keep.
450    ///
451    /// The handshake with the UI thread is the delicate part. A `play` there
452    /// pushes its command and *then* reads `streaming`; this publishes the
453    /// stop and *then* re-reads the queue. A fence on each side puts both
454    /// pairs into one order, so every interleaving leaves one side responsible:
455    /// either the push is visible below and the stream keeps running, or the
456    /// cleared flag is visible to the engine and it starts the stream again.
457    /// Neither side can decide the other will handle it, which is the failure
458    /// that would strand a queued sound behind a stopped stream.
459    fn settle(&mut self, sounding: usize, frames: usize) -> RenderStatus {
460        if sounding > 0 {
461            self.idle_frames = 0;
462            return RenderStatus::Continue;
463        }
464        self.idle_frames = self.idle_frames.saturating_add(frames as u64);
465        if self.idle_frames < self.idle_grace_frames {
466            return RenderStatus::Continue;
467        }
468
469        self.streaming.store(false, Ordering::SeqCst);
470        std::sync::atomic::fence(Ordering::SeqCst);
471        if !self.commands.is_empty() {
472            self.streaming.store(true, Ordering::SeqCst);
473            self.idle_frames = 0;
474            return RenderStatus::Continue;
475        }
476        RenderStatus::Idle
477    }
478
479    fn drain_commands(&mut self) {
480        while let Some(command) = self.commands.pop() {
481            self.apply(command);
482        }
483    }
484
485    fn apply(&mut self, command: Command) {
486        match command {
487            Command::LoadClip { slot, clip } => {
488                let slot = slot as usize;
489                if slot >= self.clips.len() {
490                    self.retire(clip);
491                    return;
492                }
493                self.silence_slot(slot);
494                if let Some(previous) = self.clips[slot].replace(clip) {
495                    self.retire(previous);
496                }
497            }
498            Command::UnloadClip { slot } => {
499                let slot = slot as usize;
500                if slot >= self.clips.len() {
501                    return;
502                }
503                self.silence_slot(slot);
504                if let Some(previous) = self.clips[slot].take() {
505                    self.retire(previous);
506                }
507            }
508            Command::Play {
509                voice,
510                slot,
511                gain_left,
512                gain_right,
513                rate,
514                bus,
515                looping,
516            } => {
517                let slot = slot as usize;
518                let bus = usize::from(bus).min(BUS_COUNT - 1);
519                let Some(clip) = self.clips.get(slot).and_then(|clip| clip.as_ref()) else {
520                    return;
521                };
522                let step = step_for(rate, clip.sample_rate, self.device_sample_rate);
523                let index = self.claim_voice();
524                self.voices[index] = Voice {
525                    id: voice,
526                    slot,
527                    position: 0.0,
528                    step,
529                    rate,
530                    gain_left,
531                    gain_right,
532                    bus,
533                    looping,
534                };
535            }
536            Command::RetuneVoice {
537                voice,
538                gain_left,
539                gain_right,
540                rate,
541            } => {
542                for index in 0..self.voices.len() {
543                    if self.voices[index].id != voice {
544                        continue;
545                    }
546                    let slot = self.voices[index].slot;
547                    let clip_rate = self.clips[slot]
548                        .as_ref()
549                        .map(|clip| clip.sample_rate)
550                        .unwrap_or(0);
551                    self.voices[index].gain_left = gain_left;
552                    self.voices[index].gain_right = gain_right;
553                    self.voices[index].rate = rate;
554                    self.voices[index].step = step_for(rate, clip_rate, self.device_sample_rate);
555                }
556            }
557            Command::StopVoice { voice } => {
558                for slot in self.voices.iter_mut() {
559                    if slot.id == voice {
560                        slot.id = 0;
561                    }
562                }
563            }
564            Command::StopClip { slot } => {
565                self.silence_slot(slot as usize);
566            }
567            Command::StopAll => {
568                for voice in self.voices.iter_mut() {
569                    voice.id = 0;
570                }
571            }
572            Command::SetMaster(volume) => self.master = sane_gain(volume),
573            Command::SetBusVolume { bus, volume } => {
574                if let Some(entry) = self.bus_volume.get_mut(usize::from(bus)) {
575                    *entry = sane_gain(volume);
576                }
577            }
578            Command::SetBusEnabled { bus, enabled } => {
579                if let Some(entry) = self.bus_enabled.get_mut(usize::from(bus)) {
580                    *entry = enabled;
581                }
582            }
583        }
584    }
585
586    fn silence_slot(&mut self, slot: usize) {
587        for voice in self.voices.iter_mut() {
588            if voice.id != 0 && voice.slot == slot {
589                voice.id = 0;
590            }
591        }
592    }
593
594    /// Picks the voice slot a new voice goes into: a free one if there is one,
595    /// otherwise the oldest one-shot, otherwise the oldest voice of any kind.
596    fn claim_voice(&mut self) -> usize {
597        let mut oldest_one_shot: Option<(usize, u64)> = None;
598        let mut oldest_any: Option<(usize, u64)> = None;
599        for (index, voice) in self.voices.iter().enumerate() {
600            if voice.id == 0 {
601                return index;
602            }
603            if oldest_any.is_none_or(|(_, id)| voice.id < id) {
604                oldest_any = Some((index, voice.id));
605            }
606            if !voice.looping && oldest_one_shot.is_none_or(|(_, id)| voice.id < id) {
607                oldest_one_shot = Some((index, voice.id));
608            }
609        }
610        oldest_one_shot
611            .or(oldest_any)
612            .map(|(index, _)| index)
613            .unwrap_or(0)
614    }
615
616    /// Hands a clip back to the UI thread to drop.
617    ///
618    /// Dropping it here would call the allocator on the real-time thread. When
619    /// the return ring is full the clip is deliberately leaked and counted
620    /// instead — the UI thread drains that ring on every audio call, so a full
621    /// ring means the app stopped talking to the engine entirely.
622    fn retire(&mut self, clip: ClipData) {
623        if let Err(clip) = self.retired.push(clip) {
624            std::mem::forget(clip);
625            self.leaked_clips.fetch_add(1, Ordering::Relaxed);
626        }
627    }
628}
629
630/// [`IDLE_GRACE_SECONDS`] measured in output frames at `sample_rate`.
631fn grace_frames(sample_rate: f32) -> u64 {
632    (f64::from(sample_rate.max(1.0)) * f64::from(IDLE_GRACE_SECONDS)) as u64
633}
634
635fn sane_gain(value: f32) -> f32 {
636    if value.is_finite() {
637        value.clamp(0.0, 4.0)
638    } else {
639        1.0
640    }
641}
642
643fn step_for(rate: f32, clip_sample_rate: u32, device_sample_rate: f32) -> f64 {
644    if clip_sample_rate == 0 || device_sample_rate <= 0.0 {
645        return 1.0;
646    }
647    let rate = if rate.is_finite() {
648        rate.clamp(0.05, 8.0)
649    } else {
650        1.0
651    };
652    f64::from(rate) * f64::from(clip_sample_rate) / f64::from(device_sample_rate)
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use crate::ring;
659
660    struct Harness {
661        commands: Producer<Command>,
662        retired: Consumer<ClipData>,
663        mixer: Mixer,
664        leaked: Arc<AtomicU32>,
665        streaming: Arc<AtomicBool>,
666    }
667
668    impl Harness {
669        /// Renders `frames` of output in device-sized bursts, the way a real
670        /// callback arrives, and reports the last burst's verdict.
671        fn run(&mut self, frames: usize) -> RenderStatus {
672            let burst = 128;
673            let channels = self.mixer.device_channels;
674            let mut out = vec![0.0f32; burst * channels];
675            let mut status = RenderStatus::Continue;
676            let mut remaining = frames;
677            while remaining > 0 {
678                let take = remaining.min(burst);
679                status = self.mixer.render(&mut out[..take * channels]);
680                remaining -= take;
681            }
682            status
683        }
684    }
685
686    fn harness(sample_rate: f32, channels: usize) -> Harness {
687        let (command_tx, command_rx) = ring::channel::<Command>(64);
688        let (retired_tx, retired_rx) = ring::channel::<ClipData>(64);
689        let leaked = Arc::new(AtomicU32::new(0));
690        let streaming = Arc::new(AtomicBool::new(true));
691        let seed = MixerSeed {
692            commands: command_rx,
693            retired: retired_tx,
694            leaked_clips: Arc::clone(&leaked),
695            underruns: Arc::new(AtomicU32::new(0)),
696            streaming: Arc::clone(&streaming),
697        };
698        Harness {
699            commands: command_tx,
700            retired: retired_rx,
701            mixer: Mixer::new(seed, sample_rate, channels),
702            leaked,
703            streaming,
704        }
705    }
706
707    fn clip(samples: Vec<f32>, channels: u8, sample_rate: u32) -> ClipData {
708        ClipData {
709            samples: samples.into(),
710            channels,
711            sample_rate,
712        }
713    }
714
715    fn play(voice: u64, slot: u32) -> Command {
716        Command::Play {
717            voice,
718            slot,
719            gain_left: 1.0,
720            gain_right: 1.0,
721            rate: 1.0,
722            bus: 0,
723            looping: false,
724        }
725    }
726
727    #[test]
728    fn renders_silence_without_voices() {
729        let mut h = harness(48_000.0, 2);
730        let mut out = vec![1.0f32; 8];
731        h.mixer.render(&mut out);
732        assert!(out.iter().all(|sample| *sample == 0.0));
733    }
734
735    #[test]
736    fn plays_a_one_shot_and_frees_the_voice() {
737        let mut h = harness(48_000.0, 2);
738        h.commands
739            .push(Command::LoadClip {
740                slot: 0,
741                clip: clip(vec![1.0, 1.0], 1, 48_000),
742            })
743            .expect("queued");
744        h.commands.push(play(1, 0)).expect("queued");
745
746        let mut out = vec![0.0f32; 8];
747        h.mixer.render(&mut out);
748        assert_eq!(h.mixer.active_voices(), 0, "a two-frame clip ends at once");
749        assert!(out[0] > 0.0 && out[1] > 0.0);
750        assert_eq!(out[6], 0.0, "past the end of the clip is silent");
751    }
752
753    #[test]
754    fn overlapping_voices_sum() {
755        let mut h = harness(48_000.0, 2);
756        h.commands
757            .push(Command::LoadClip {
758                slot: 0,
759                clip: clip(vec![0.25; 64], 1, 48_000),
760            })
761            .expect("queued");
762        h.commands.push(play(1, 0)).expect("queued");
763        h.commands.push(play(2, 0)).expect("queued");
764        h.commands.push(play(3, 0)).expect("queued");
765
766        let mut out = vec![0.0f32; 8];
767        h.mixer.render(&mut out);
768        assert_eq!(h.mixer.active_voices(), 3);
769        assert!(out[0] > 0.5, "three voices sum, got {}", out[0]);
770    }
771
772    #[test]
773    fn output_is_clamped_to_the_nominal_range() {
774        let mut h = harness(48_000.0, 2);
775        h.commands
776            .push(Command::LoadClip {
777                slot: 0,
778                clip: clip(vec![1.0; 64], 1, 48_000),
779            })
780            .expect("queued");
781        for voice in 1..=8 {
782            h.commands.push(play(voice, 0)).expect("queued");
783        }
784        let mut out = vec![0.0f32; 8];
785        h.mixer.render(&mut out);
786        assert!(out.iter().all(|s| (-1.0..=1.0).contains(s)));
787        assert!((out[0] - 1.0).abs() < 1e-6);
788    }
789
790    #[test]
791    fn rate_shifts_the_read_position() {
792        let mut h = harness(48_000.0, 1);
793        let ramp: Vec<f32> = (0..64).map(|i| i as f32 / 64.0).collect();
794        h.commands
795            .push(Command::LoadClip {
796                slot: 0,
797                clip: clip(ramp, 1, 48_000),
798            })
799            .expect("queued");
800        h.commands
801            .push(Command::Play {
802                voice: 1,
803                slot: 0,
804                gain_left: 1.0,
805                gain_right: 1.0,
806                rate: 2.0,
807                bus: 0,
808                looping: false,
809            })
810            .expect("queued");
811
812        let mut out = vec![0.0f32; 4];
813        h.mixer.render(&mut out);
814        // Reading twice as fast means output frame n is clip frame 2n, and the
815        // ramp makes that value exactly 2n/64.
816        for (frame, sample) in out.iter().enumerate() {
817            let expected = (2 * frame) as f32 / 64.0;
818            assert!(
819                (sample - expected).abs() < 1e-6,
820                "frame {frame}: expected {expected}, got {sample}"
821            );
822        }
823    }
824
825    #[test]
826    fn clip_sample_rate_is_resampled_to_the_device_rate() {
827        let mut h = harness(48_000.0, 1);
828        h.commands
829            .push(Command::LoadClip {
830                slot: 0,
831                clip: clip(vec![0.5; 1024], 1, 24_000),
832            })
833            .expect("queued");
834        h.commands.push(play(1, 0)).expect("queued");
835        let mut out = vec![0.0f32; 8];
836        h.mixer.render(&mut out);
837        assert_eq!(h.mixer.active_voices(), 1);
838        // A 24 kHz clip on a 48 kHz device advances half a frame per output
839        // frame, so 1024 frames last 2048 output frames.
840        for _ in 0..255 {
841            h.mixer.render(&mut out);
842        }
843        assert_eq!(
844            h.mixer.active_voices(),
845            1,
846            "still playing after 2048 frames"
847        );
848    }
849
850    #[test]
851    fn looping_voice_keeps_going_until_stopped() {
852        let mut h = harness(48_000.0, 2);
853        h.commands
854            .push(Command::LoadClip {
855                slot: 0,
856                clip: clip(vec![0.5, 0.5], 1, 48_000),
857            })
858            .expect("queued");
859        h.commands
860            .push(Command::Play {
861                voice: 9,
862                slot: 0,
863                gain_left: 1.0,
864                gain_right: 1.0,
865                rate: 1.0,
866                bus: 1,
867                looping: true,
868            })
869            .expect("queued");
870
871        let mut out = vec![0.0f32; 64];
872        h.mixer.render(&mut out);
873        assert_eq!(h.mixer.active_voices(), 1);
874        assert!(out[40] != 0.0, "the loop refills the whole buffer");
875
876        h.commands
877            .push(Command::StopVoice { voice: 9 })
878            .expect("queued");
879        h.mixer.render(&mut out);
880        assert_eq!(h.mixer.active_voices(), 0);
881    }
882
883    #[test]
884    fn muting_a_bus_silences_only_that_bus() {
885        let mut h = harness(48_000.0, 2);
886        h.commands
887            .push(Command::LoadClip {
888                slot: 0,
889                clip: clip(vec![1.0; 64], 1, 48_000),
890            })
891            .expect("queued");
892        h.commands
893            .push(Command::Play {
894                voice: 1,
895                slot: 0,
896                gain_left: 0.5,
897                gain_right: 0.5,
898                rate: 1.0,
899                bus: 1,
900                looping: true,
901            })
902            .expect("queued");
903        h.commands
904            .push(Command::SetBusEnabled {
905                bus: 1,
906                enabled: false,
907            })
908            .expect("queued");
909
910        let mut out = vec![0.0f32; 16];
911        h.mixer.render(&mut out);
912        assert!(out.iter().all(|sample| *sample == 0.0));
913        assert_eq!(h.mixer.active_voices(), 1, "muting does not stop the voice");
914
915        h.commands
916            .push(Command::SetBusEnabled {
917                bus: 1,
918                enabled: true,
919            })
920            .expect("queued");
921        h.mixer.render(&mut out);
922        assert!(out[0] > 0.0, "unmuting resumes mid-track");
923    }
924
925    #[test]
926    fn master_volume_scales_every_bus() {
927        let mut h = harness(48_000.0, 2);
928        h.commands
929            .push(Command::LoadClip {
930                slot: 0,
931                clip: clip(vec![1.0; 64], 1, 48_000),
932            })
933            .expect("queued");
934        h.commands
935            .push(Command::Play {
936                voice: 1,
937                slot: 0,
938                gain_left: 0.5,
939                gain_right: 0.5,
940                rate: 1.0,
941                bus: 0,
942                looping: true,
943            })
944            .expect("queued");
945        h.commands.push(Command::SetMaster(0.0)).expect("queued");
946        let mut out = vec![0.0f32; 16];
947        h.mixer.render(&mut out);
948        assert!(out.iter().all(|sample| *sample == 0.0));
949
950        h.commands.push(Command::SetMaster(1.0)).expect("queued");
951        h.mixer.render(&mut out);
952        assert!((out[0] - 0.5).abs() < 1e-6);
953    }
954
955    #[test]
956    fn stop_clip_silences_every_voice_of_that_clip() {
957        let mut h = harness(48_000.0, 2);
958        h.commands
959            .push(Command::LoadClip {
960                slot: 0,
961                clip: clip(vec![1.0; 64], 1, 48_000),
962            })
963            .expect("queued");
964        h.commands
965            .push(Command::LoadClip {
966                slot: 1,
967                clip: clip(vec![1.0; 64], 1, 48_000),
968            })
969            .expect("queued");
970        h.commands.push(play(1, 0)).expect("queued");
971        h.commands.push(play(2, 0)).expect("queued");
972        h.commands.push(play(3, 1)).expect("queued");
973        let mut out = vec![0.0f32; 8];
974        h.mixer.render(&mut out);
975        assert_eq!(h.mixer.active_voices(), 3);
976
977        h.commands
978            .push(Command::StopClip { slot: 0 })
979            .expect("queued");
980        h.mixer.render(&mut out);
981        assert_eq!(h.mixer.active_voices(), 1);
982
983        h.commands.push(Command::StopAll).expect("queued");
984        h.mixer.render(&mut out);
985        assert_eq!(h.mixer.active_voices(), 0);
986    }
987
988    #[test]
989    fn voice_stealing_prefers_one_shots_over_loops() {
990        let mut h = harness(48_000.0, 2);
991        h.commands
992            .push(Command::LoadClip {
993                slot: 0,
994                clip: clip(vec![1.0; 4096], 1, 48_000),
995            })
996            .expect("queued");
997        // One looping voice, then fill every remaining slot with one-shots.
998        h.commands
999            .push(Command::Play {
1000                voice: 1,
1001                slot: 0,
1002                gain_left: 1.0,
1003                gain_right: 1.0,
1004                rate: 1.0,
1005                bus: 0,
1006                looping: true,
1007            })
1008            .expect("queued");
1009        let mut out = vec![0.0f32; 8];
1010        for voice in 2..=(MAX_VOICES as u64) {
1011            h.commands.push(play(voice, 0)).expect("queued");
1012        }
1013        h.mixer.render(&mut out);
1014        assert_eq!(h.mixer.active_voices(), MAX_VOICES);
1015
1016        // The next play steals a one-shot; the loop survives.
1017        h.commands
1018            .push(play(MAX_VOICES as u64 + 1, 0))
1019            .expect("queued");
1020        h.mixer.render(&mut out);
1021        assert_eq!(h.mixer.active_voices(), MAX_VOICES);
1022        assert!(
1023            h.mixer.voices.iter().any(|voice| voice.id == 1),
1024            "the looping voice is not stolen while one-shots remain"
1025        );
1026    }
1027
1028    #[test]
1029    fn unloading_a_clip_returns_it_to_the_ui_thread() {
1030        let mut h = harness(48_000.0, 2);
1031        h.commands
1032            .push(Command::LoadClip {
1033                slot: 3,
1034                clip: clip(vec![1.0; 8], 1, 48_000),
1035            })
1036            .expect("queued");
1037        h.commands.push(play(5, 3)).expect("queued");
1038        let mut out = vec![0.0f32; 4];
1039        h.mixer.render(&mut out);
1040        assert_eq!(h.mixer.active_voices(), 1);
1041
1042        h.commands
1043            .push(Command::UnloadClip { slot: 3 })
1044            .expect("queued");
1045        h.mixer.render(&mut out);
1046        assert_eq!(h.mixer.active_voices(), 0);
1047        assert!(h.retired.pop().is_some(), "the clip came back for dropping");
1048        assert_eq!(h.leaked.load(Ordering::Relaxed), 0);
1049    }
1050
1051    #[test]
1052    fn replacing_a_slot_returns_the_previous_clip() {
1053        let mut h = harness(48_000.0, 2);
1054        for _ in 0..2 {
1055            h.commands
1056                .push(Command::LoadClip {
1057                    slot: 1,
1058                    clip: clip(vec![1.0; 8], 1, 48_000),
1059                })
1060                .expect("queued");
1061        }
1062        let mut out = vec![0.0f32; 4];
1063        h.mixer.render(&mut out);
1064        assert!(h.retired.pop().is_some());
1065        assert!(h.retired.pop().is_none());
1066    }
1067
1068    #[test]
1069    fn out_of_range_slots_are_ignored() {
1070        let mut h = harness(48_000.0, 2);
1071        h.commands
1072            .push(Command::LoadClip {
1073                slot: MAX_CLIPS as u32 + 5,
1074                clip: clip(vec![1.0; 8], 1, 48_000),
1075            })
1076            .expect("queued");
1077        h.commands
1078            .push(Command::UnloadClip {
1079                slot: MAX_CLIPS as u32 + 5,
1080            })
1081            .expect("queued");
1082        h.commands
1083            .push(play(1, MAX_CLIPS as u32 + 5))
1084            .expect("queued");
1085        let mut out = vec![0.0f32; 4];
1086        h.mixer.render(&mut out);
1087        assert_eq!(h.mixer.active_voices(), 0);
1088        assert!(
1089            h.retired.pop().is_some(),
1090            "the rejected clip is handed back"
1091        );
1092    }
1093
1094    #[test]
1095    fn retune_changes_gain_and_rate_of_a_running_voice() {
1096        let mut h = harness(48_000.0, 2);
1097        h.commands
1098            .push(Command::LoadClip {
1099                slot: 0,
1100                clip: clip(vec![1.0; 4096], 1, 48_000),
1101            })
1102            .expect("queued");
1103        h.commands
1104            .push(Command::Play {
1105                voice: 4,
1106                slot: 0,
1107                gain_left: 1.0,
1108                gain_right: 1.0,
1109                rate: 1.0,
1110                bus: 0,
1111                looping: true,
1112            })
1113            .expect("queued");
1114        let mut out = vec![0.0f32; 8];
1115        h.mixer.render(&mut out);
1116        assert!((out[0] - 1.0).abs() < 1e-6);
1117
1118        h.commands
1119            .push(Command::RetuneVoice {
1120                voice: 4,
1121                gain_left: 0.25,
1122                gain_right: 0.25,
1123                rate: 2.0,
1124            })
1125            .expect("queued");
1126        h.mixer.render(&mut out);
1127        assert!((out[0] - 0.25).abs() < 1e-6);
1128        let voice = h.mixer.voices.iter().find(|v| v.id == 4).expect("running");
1129        assert!((voice.step - 2.0).abs() < 1e-9);
1130    }
1131
1132    #[test]
1133    fn device_format_change_keeps_voice_pitch() {
1134        let mut h = harness(48_000.0, 2);
1135        h.commands
1136            .push(Command::LoadClip {
1137                slot: 0,
1138                clip: clip(vec![1.0; 4096], 1, 48_000),
1139            })
1140            .expect("queued");
1141        h.commands
1142            .push(Command::Play {
1143                voice: 1,
1144                slot: 0,
1145                gain_left: 1.0,
1146                gain_right: 1.0,
1147                rate: 1.0,
1148                bus: 0,
1149                looping: true,
1150            })
1151            .expect("queued");
1152        let mut out = vec![0.0f32; 8];
1153        h.mixer.render(&mut out);
1154        h.mixer.set_device_format(24_000.0, 2);
1155        assert_eq!(h.mixer.device_sample_rate(), 24_000.0);
1156        let voice = h.mixer.voices.iter().find(|v| v.id == 1).expect("running");
1157        assert!((voice.step - 2.0).abs() < 1e-9);
1158    }
1159
1160    #[test]
1161    fn nan_gains_and_rates_do_not_wedge_the_mixer() {
1162        let mut h = harness(48_000.0, 2);
1163        h.commands
1164            .push(Command::LoadClip {
1165                slot: 0,
1166                clip: clip(vec![1.0; 64], 1, 48_000),
1167            })
1168            .expect("queued");
1169        h.commands
1170            .push(Command::SetMaster(f32::NAN))
1171            .expect("queued");
1172        h.commands
1173            .push(Command::SetBusVolume {
1174                bus: 0,
1175                volume: f32::INFINITY,
1176            })
1177            .expect("queued");
1178        h.commands
1179            .push(Command::Play {
1180                voice: 1,
1181                slot: 0,
1182                gain_left: 0.5,
1183                gain_right: 0.5,
1184                rate: f32::NAN,
1185                bus: 0,
1186                looping: true,
1187            })
1188            .expect("queued");
1189        let mut out = vec![0.0f32; 16];
1190        h.mixer.render(&mut out);
1191        assert!(out.iter().all(|sample| sample.is_finite()));
1192    }
1193
1194    #[test]
1195    fn silence_gives_the_device_up_after_the_grace_period() {
1196        let mut h = harness(48_000.0, 2);
1197        let grace = grace_frames(48_000.0) as usize;
1198        assert_eq!(h.run(grace - 128), RenderStatus::Continue);
1199        assert!(h.streaming.load(Ordering::SeqCst), "still inside the grace");
1200        assert_eq!(h.run(128), RenderStatus::Idle);
1201        assert!(!h.streaming.load(Ordering::SeqCst));
1202    }
1203
1204    #[test]
1205    fn the_grace_period_starts_when_the_last_voice_ends() {
1206        let mut h = harness(48_000.0, 2);
1207        h.commands
1208            .push(Command::LoadClip {
1209                slot: 0,
1210                clip: clip(vec![0.5; 48_000], 1, 48_000),
1211            })
1212            .expect("queued");
1213        h.commands.push(play(1, 0)).expect("queued");
1214
1215        // One second of clip, then all but the last burst of the grace period.
1216        let grace = grace_frames(48_000.0) as usize;
1217        assert_eq!(h.run(48_000 + grace - 128), RenderStatus::Continue);
1218        assert_eq!(h.run(128), RenderStatus::Idle);
1219    }
1220
1221    #[test]
1222    fn a_looping_voice_holds_the_device_open_indefinitely() {
1223        let mut h = harness(48_000.0, 2);
1224        h.commands
1225            .push(Command::LoadClip {
1226                slot: 0,
1227                clip: clip(vec![0.5; 64], 1, 48_000),
1228            })
1229            .expect("queued");
1230        h.commands
1231            .push(Command::Play {
1232                voice: 1,
1233                slot: 0,
1234                gain_left: 1.0,
1235                gain_right: 1.0,
1236                rate: 1.0,
1237                bus: 0,
1238                looping: true,
1239            })
1240            .expect("queued");
1241
1242        let grace = grace_frames(48_000.0) as usize;
1243        assert_eq!(h.run(grace * 2), RenderStatus::Continue);
1244        assert!(h.streaming.load(Ordering::SeqCst));
1245    }
1246
1247    #[test]
1248    fn a_muted_voice_still_counts_as_a_reason_to_run() {
1249        let mut h = harness(48_000.0, 2);
1250        h.commands
1251            .push(Command::LoadClip {
1252                slot: 0,
1253                clip: clip(vec![0.5; 64], 1, 48_000),
1254            })
1255            .expect("queued");
1256        h.commands
1257            .push(Command::Play {
1258                voice: 1,
1259                slot: 0,
1260                gain_left: 1.0,
1261                gain_right: 1.0,
1262                rate: 1.0,
1263                bus: 1,
1264                looping: true,
1265            })
1266            .expect("queued");
1267        h.commands
1268            .push(Command::SetBusEnabled {
1269                bus: 1,
1270                enabled: false,
1271            })
1272            .expect("queued");
1273
1274        // Muting is the "music off" toggle, not a stop: the voice keeps its
1275        // position so unmuting resumes mid-track, which it could not do if the
1276        // device had been given up underneath it.
1277        let grace = grace_frames(48_000.0) as usize;
1278        assert_eq!(h.run(grace + 128), RenderStatus::Continue);
1279    }
1280
1281    #[test]
1282    fn a_command_landing_while_the_stream_stops_keeps_it_alive() {
1283        let mut h = harness(48_000.0, 2);
1284        assert_eq!(h.run(grace_frames(48_000.0) as usize), RenderStatus::Idle);
1285        assert!(!h.streaming.load(Ordering::SeqCst));
1286
1287        // The second half of the handover with the UI thread: a command pushed
1288        // after this callback drained the queue, in the window before the
1289        // engine reads the flag, has to be caught by the re-check rather than
1290        // stranded behind a stopped stream.
1291        h.commands.push(Command::StopAll).expect("queued");
1292        assert_eq!(h.mixer.settle(0, 128), RenderStatus::Continue);
1293        assert!(h.streaming.load(Ordering::SeqCst));
1294    }
1295
1296    #[test]
1297    fn the_grace_period_is_a_duration_not_a_callback_count() {
1298        let mut h = harness(24_000.0, 2);
1299        let grace = grace_frames(24_000.0) as usize;
1300        assert_eq!(grace * 2, grace_frames(48_000.0) as usize);
1301        assert_eq!(h.run(grace - 128), RenderStatus::Continue);
1302        assert_eq!(h.run(128), RenderStatus::Idle);
1303    }
1304
1305    #[test]
1306    fn a_device_rate_change_rescales_the_grace_period() {
1307        let mut h = harness(48_000.0, 2);
1308        h.mixer.set_device_format(24_000.0, 2);
1309        assert_eq!(h.run(grace_frames(24_000.0) as usize), RenderStatus::Idle);
1310    }
1311
1312    #[test]
1313    fn mono_device_downmixes_both_channels() {
1314        let mut h = harness(48_000.0, 1);
1315        h.commands
1316            .push(Command::LoadClip {
1317                slot: 0,
1318                clip: clip(vec![1.0, -1.0, 1.0, -1.0], 2, 48_000),
1319            })
1320            .expect("queued");
1321        h.commands.push(play(1, 0)).expect("queued");
1322        let mut out = vec![0.0f32; 2];
1323        h.mixer.render(&mut out);
1324        assert!(
1325            out[0].abs() < 1e-6,
1326            "opposite channels cancel in the downmix"
1327        );
1328    }
1329}