Skip to main content

khora_core/audio/
mix_bus.rs

1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Cross-thread audio mix bus.
16//!
17//! Bridge between audio-rendering lanes (main thread, ~60 Hz) and the
18//! audio backend's hardware callback (RT thread, ~kHz). Contributors call
19//! [`AudioMixBus::write_block`] to push pre-mixed PCM frames; the backend
20//! callback calls [`AudioMixBus::pull`] to drain the next N samples into
21//! the hardware buffer.
22//!
23//! The trait abstracts the queue implementation so the v1 ships a simple
24//! mutex-backed ringbuffer ([`DefaultMixBus`]) and a future PR can swap in
25//! a lock-free SPSC/SPMC queue without touching consumers.
26
27use std::collections::VecDeque;
28use std::sync::Mutex;
29
30use super::device::StreamInfo;
31
32/// The contract between audio-producing lanes and the audio backend.
33///
34/// All methods are `&self` so the bus can sit inside an `Arc` shared by
35/// multiple lanes (writers) and the backend's callback (single reader).
36pub trait AudioMixBus: Send + Sync {
37    /// Channel count and sample rate negotiated with the device.
38    ///
39    /// Lanes use this to size their writes (frames × channels) and to
40    /// avoid sample-rate conversion mismatches.
41    fn stream_info(&self) -> StreamInfo;
42
43    /// Push interleaved PCM samples produced by an audio lane.
44    ///
45    /// `samples.len()` must be a multiple of `stream_info().channels`.
46    /// Implementations may drop the oldest data on overflow — audio is
47    /// real-time and stale samples are worse than silence.
48    fn write_block(&self, samples: &[f32]);
49
50    /// Drain the next `out.len()` samples into the hardware output
51    /// buffer. Called from the audio backend's callback thread.
52    ///
53    /// Underrun (queue shorter than `out.len()`) is filled with silence
54    /// (`0.0`) — never blocks, never allocates.
55    fn pull(&self, out: &mut [f32]);
56}
57
58/// Mutex-backed ringbuffer impl of [`AudioMixBus`].
59///
60/// Lives in `khora-core` so apps and tests can construct one without
61/// pulling `khora-infra`. Performance is adequate for early development;
62/// migration to a lock-free queue is a follow-up PR.
63pub struct DefaultMixBus {
64    info: StreamInfo,
65    capacity_samples: usize,
66    queue: Mutex<VecDeque<f32>>,
67}
68
69impl DefaultMixBus {
70    /// `capacity_frames` is the high-water mark in *frames* (not samples).
71    /// Samples beyond that are dropped from the head — newest wins.
72    #[must_use]
73    pub fn new(info: StreamInfo, capacity_frames: usize) -> Self {
74        let capacity_samples = capacity_frames * info.channels as usize;
75        Self {
76            info,
77            capacity_samples,
78            queue: Mutex::new(VecDeque::with_capacity(capacity_samples)),
79        }
80    }
81}
82
83impl AudioMixBus for DefaultMixBus {
84    fn stream_info(&self) -> StreamInfo {
85        self.info
86    }
87
88    fn write_block(&self, samples: &[f32]) {
89        let Ok(mut q) = self.queue.lock() else {
90            return;
91        };
92        q.extend(samples.iter().copied());
93        // Trim from the head once we exceed capacity — keep the newest
94        // data; old samples are stale and worse than silence in real time.
95        while q.len() > self.capacity_samples {
96            q.pop_front();
97        }
98    }
99
100    fn pull(&self, out: &mut [f32]) {
101        let Ok(mut q) = self.queue.lock() else {
102            out.fill(0.0);
103            return;
104        };
105        let take = out.len().min(q.len());
106        for slot in out.iter_mut().take(take) {
107            // SAFETY (logical): `take <= q.len()` so pop_front is non-None.
108            *slot = q.pop_front().unwrap_or(0.0);
109        }
110        if take < out.len() {
111            for slot in out.iter_mut().skip(take) {
112                *slot = 0.0;
113            }
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    fn info() -> StreamInfo {
123        StreamInfo {
124            channels: 2,
125            sample_rate: 48_000,
126        }
127    }
128
129    #[test]
130    fn write_then_pull_roundtrips_same_sequence() {
131        let bus = DefaultMixBus::new(info(), 1024);
132        let written: Vec<f32> = (0..16).map(|i| i as f32 * 0.1).collect();
133        bus.write_block(&written);
134
135        let mut out = vec![0.0_f32; 16];
136        bus.pull(&mut out);
137        assert_eq!(out, written);
138    }
139
140    #[test]
141    fn pull_under_run_fills_silence() {
142        let bus = DefaultMixBus::new(info(), 1024);
143        bus.write_block(&[0.5, 0.5]);
144        let mut out = vec![1.0_f32; 8];
145        bus.pull(&mut out);
146        assert_eq!(out[0..2], [0.5, 0.5]);
147        assert!(out[2..].iter().all(|&s| s == 0.0));
148    }
149
150    #[test]
151    fn write_overflow_drops_oldest_samples() {
152        let bus = DefaultMixBus::new(info(), 4); // 4 frames * 2 ch = 8 samples
153        let burst: Vec<f32> = (0..12).map(|i| i as f32).collect();
154        bus.write_block(&burst);
155
156        let mut out = vec![0.0_f32; 8];
157        bus.pull(&mut out);
158        // Newest 8 samples kept (4..=11), oldest 4 dropped.
159        assert_eq!(out, vec![4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 11.0]);
160    }
161
162    #[test]
163    fn stream_info_is_what_was_passed_in() {
164        let bus = DefaultMixBus::new(info(), 1024);
165        let si = bus.stream_info();
166        assert_eq!(si.channels, 2);
167        assert_eq!(si.sample_rate, 48_000);
168    }
169}