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}