Skip to main content

khora_lanes/audio_lane/mixing/
spatial_mixing_lane.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//! The core audio processing lane, responsible for mixing and
16//! spatializing sound sources.
17//!
18//! Per CLAD doctrine the lane consumes a typed
19//! [`AudioView`](khora_data::flow::AudioView) from the
20//! [`LaneBus`](khora_core::lane::LaneBus), runs the spatialised mix into
21//! a per-frame staging buffer, and pushes the result into a shared
22//! [`AudioMixBus`](khora_core::audio::AudioMixBus). The audio backend's
23//! callback drains the bus on its dedicated real-time thread. The lane
24//! never touches the hardware buffer directly and never queries
25//! `World`.
26//!
27//! Per-source playback updates land in an
28//! [`AudioPlaybackWriteback`](khora_data::flow::AudioPlaybackWriteback)
29//! slot of the per-frame `OutputDeck`; the
30//! `audio_playback_writeback` `DataSystem` drains that slot in
31//! `Maintenance` and patches `AudioSource` components.
32
33use std::sync::Arc;
34
35use khora_core::audio::{AudioMixBus, StreamInfo};
36use khora_core::lane::{LaneError, OutputDeck, Ref, Slot};
37use khora_data::ecs::PlaybackState;
38use khora_data::flow::{AudioPlaybackUpdate, AudioPlaybackWriteback, AudioView};
39
40/// Number of frames the lane mixes per `execute` call. Sized to comfortably
41/// cover one display frame at 60 Hz / 48 kHz (~800 frames) with headroom.
42const FRAMES_PER_TICK: usize = 1024;
43
44/// A lane that performs spatialized audio mixing.
45#[derive(Default)]
46pub struct SpatialMixingLane;
47
48impl SpatialMixingLane {
49    /// Creates a new `SpatialMixingLane`.
50    pub fn new() -> Self {
51        Self
52    }
53}
54
55impl khora_core::lane::Lane for SpatialMixingLane {
56    fn strategy_name(&self) -> &'static str {
57        "SpatialMixing"
58    }
59
60    fn lane_kind(&self) -> khora_core::lane::LaneKind {
61        khora_core::lane::LaneKind::Audio
62    }
63
64    fn execute(&self, ctx: &mut khora_core::lane::LaneContext) -> Result<(), LaneError> {
65        let mix_bus = ctx
66            .get::<Arc<dyn AudioMixBus>>()
67            .ok_or(LaneError::missing("Arc<dyn AudioMixBus>"))?
68            .clone();
69        let view = ctx
70            .get::<Ref<AudioView>>()
71            .ok_or(LaneError::missing("Ref<AudioView>"))?
72            .get();
73
74        let stream_info = mix_bus.stream_info();
75        let sample_count = FRAMES_PER_TICK * stream_info.channels as usize;
76        let mut staging = vec![0.0_f32; sample_count];
77
78        let writeback = self.mix(view, &mut staging, &stream_info);
79        mix_bus.write_block(&staging);
80
81        if let Some(deck_slot) = ctx.get::<Slot<OutputDeck>>() {
82            let deck = deck_slot.get();
83            let slot = deck.slot::<AudioPlaybackWriteback>();
84            slot.updates.extend(writeback.updates);
85        }
86
87        Ok(())
88    }
89
90    fn as_any(&self) -> &dyn std::any::Any {
91        self
92    }
93
94    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
95        self
96    }
97}
98
99impl SpatialMixingLane {
100    /// Mixes the snapshotted `AudioView` into `output_buffer` and
101    /// returns one [`AudioPlaybackUpdate`] per source whose state
102    /// changed. The updates are **not** applied to ECS components here
103    /// — that is the `audio_playback_writeback` DataSystem's job.
104    pub fn mix(
105        &self,
106        view: &AudioView,
107        output_buffer: &mut [f32],
108        stream_info: &StreamInfo,
109    ) -> AudioPlaybackWriteback {
110        output_buffer.fill(0.0);
111
112        let mut writeback = AudioPlaybackWriteback {
113            updates: Vec::with_capacity(view.sources.len()),
114        };
115
116        let listener_transform = view.listener_transform;
117        let samples_to_write = output_buffer.len() / stream_info.channels as usize;
118
119        for source in &view.sources {
120            // Local copy of the playback state — mutated below, then
121            // emitted in the writeback. The component itself is never
122            // touched here.
123            let mut state = source.state.clone();
124
125            if source.autoplay && state.is_none() {
126                state = Some(PlaybackState { cursor: 0.0 });
127            }
128
129            let sound_data = &source.handle;
130
131            // A freshly added `AudioSource` (or a placeholder asset) may
132            // have `channels == 0` and/or no samples. Treat both as
133            // "empty source — emit a stop and skip" so the lane never
134            // divides by zero.
135            let channels = sound_data.channels as usize;
136            let num_frames = sound_data.samples.len().checked_div(channels).unwrap_or(0);
137            if num_frames == 0 {
138                writeback.updates.push(AudioPlaybackUpdate {
139                    entity: source.entity,
140                    new_state: None,
141                });
142                continue;
143            }
144
145            let resample_ratio = sound_data.sample_rate as f32 / stream_info.sample_rate as f32;
146            let (mut volume, mut pan) = (source.volume, 0.5);
147
148            if let Some(listener_mat) = listener_transform {
149                let source_pos = source.position;
150                let listener_pos = listener_mat.translation();
151                let listener_right = listener_mat.right();
152                let to_source = source_pos - listener_pos;
153                let distance = to_source.length();
154
155                volume *= 1.0 / (1.0 + distance * distance);
156                if distance > 0.001 {
157                    pan = (to_source.normalize().dot(listener_right) + 1.0) * 0.5;
158                }
159            }
160
161            let vol_l = volume * (1.0 - pan).sqrt();
162            let vol_r = volume * pan.sqrt();
163
164            let mut stopped = false;
165            for i in 0..samples_to_write {
166                let cursor = if let Some(s) = state.as_mut() {
167                    &mut s.cursor
168                } else {
169                    stopped = true;
170                    break;
171                };
172
173                if *cursor >= num_frames as f32 {
174                    if source.looping {
175                        *cursor %= num_frames as f32;
176                    } else {
177                        stopped = true;
178                        break;
179                    }
180                }
181
182                let cursor_floor = cursor.floor() as usize;
183                let cursor_fract = cursor.fract();
184
185                let next_frame_idx = (cursor_floor + 1) % num_frames;
186
187                let s1_idx = cursor_floor * sound_data.channels as usize;
188                let s2_idx = next_frame_idx * sound_data.channels as usize;
189
190                if s1_idx >= sound_data.samples.len() || s2_idx >= sound_data.samples.len() {
191                    stopped = true;
192                    break;
193                }
194
195                let s1 = sound_data.samples[s1_idx];
196                let s2 = sound_data.samples[s2_idx];
197                let sample = s1 + (s2 - s1) * cursor_fract;
198
199                let out_idx = i * stream_info.channels as usize;
200                if stream_info.channels == 2 {
201                    output_buffer[out_idx] += sample * vol_l;
202                    output_buffer[out_idx + 1] += sample * vol_r;
203                } else {
204                    output_buffer[out_idx] += sample * volume;
205                }
206
207                *cursor += resample_ratio;
208            }
209
210            writeback.updates.push(AudioPlaybackUpdate {
211                entity: source.entity,
212                new_state: if stopped { None } else { state },
213            });
214        }
215
216        // Limiter.
217        for sample in output_buffer.iter_mut() {
218            *sample = sample.clamp(-1.0, 1.0);
219        }
220
221        writeback
222    }
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use khora_core::ecs::entity::EntityId;
229    use khora_core::{
230        asset::AssetHandle,
231        math::{affine_transform::AffineTransform, vector::Vec3},
232    };
233    use khora_data::assets::SoundData;
234    use khora_data::flow::AudioSourceSnapshot;
235
236    fn create_test_sound(len: usize, sample_rate: u32) -> AssetHandle<SoundData> {
237        let samples = (0..len).map(|i| (i as f32).sin()).collect();
238        AssetHandle::new(SoundData {
239            samples,
240            channels: 1,
241            sample_rate,
242        })
243    }
244
245    fn approx_eq(a: f32, b: f32) -> bool {
246        (a - b).abs() < 1e-5
247    }
248
249    fn make_view_panning_right() -> AudioView {
250        AudioView {
251            source_count: 1,
252            listener_position: Some(Vec3::ZERO),
253            listener_transform: Some(AffineTransform::IDENTITY),
254            sources: vec![AudioSourceSnapshot {
255                entity: EntityId {
256                    index: 0,
257                    generation: 0,
258                },
259                handle: create_test_sound(1024, 44100),
260                position: Vec3::new(10.0, 0.0, 0.0),
261                volume: 1.0,
262                looping: false,
263                autoplay: true,
264                state: None,
265            }],
266        }
267    }
268
269    #[test]
270    fn test_panning_right() {
271        let stream_info = StreamInfo {
272            channels: 2,
273            sample_rate: 44100,
274        };
275        let lane = SpatialMixingLane::new();
276        let mut buffer = vec![0.0; 128];
277        let view = make_view_panning_right();
278
279        let writeback = lane.mix(&view, &mut buffer, &stream_info);
280        assert_eq!(writeback.updates.len(), 1);
281
282        let energy_left = buffer.iter().step_by(2).map(|&s| s * s).sum::<f32>();
283        let energy_right = buffer
284            .iter()
285            .skip(1)
286            .step_by(2)
287            .map(|&s| s * s)
288            .sum::<f32>();
289
290        assert!(
291            energy_right > energy_left * 100.0,
292            "The energy should be much stronger in the right channel"
293        );
294        assert!(
295            approx_eq(energy_left, 0.0),
296            "The left channel should be silent for a sound perfectly to the right"
297        );
298    }
299
300    #[test]
301    fn test_no_listener_no_panning() {
302        let stream_info = StreamInfo {
303            channels: 2,
304            sample_rate: 44100,
305        };
306        let lane = SpatialMixingLane::new();
307        let mut buffer = vec![0.0; 128];
308        let view = AudioView {
309            source_count: 1,
310            listener_position: None,
311            listener_transform: None,
312            sources: vec![AudioSourceSnapshot {
313                entity: EntityId {
314                    index: 0,
315                    generation: 0,
316                },
317                handle: create_test_sound(1024, 44100),
318                position: Vec3::new(10.0, 0.0, 0.0),
319                volume: 1.0,
320                looping: false,
321                autoplay: true,
322                state: None,
323            }],
324        };
325
326        let _ = lane.mix(&view, &mut buffer, &stream_info);
327        let energy_left = buffer.iter().step_by(2).map(|&s| s * s).sum::<f32>();
328        let energy_right = buffer
329            .iter()
330            .skip(1)
331            .step_by(2)
332            .map(|&s| s * s)
333            .sum::<f32>();
334        assert!(
335            (energy_left - energy_right).abs() < energy_left.max(energy_right) * 0.5,
336            "Without a listener the channels should be roughly balanced"
337        );
338    }
339
340    #[test]
341    fn writeback_marks_finished_source_as_stopped() {
342        let stream_info = StreamInfo {
343            channels: 1,
344            sample_rate: 44100,
345        };
346        let lane = SpatialMixingLane::new();
347        // Buffer larger than the sound — ensures the cursor reaches end.
348        let mut buffer = vec![0.0; 4096];
349        let view = AudioView {
350            source_count: 1,
351            listener_position: None,
352            listener_transform: None,
353            sources: vec![AudioSourceSnapshot {
354                entity: EntityId {
355                    index: 0,
356                    generation: 0,
357                },
358                handle: create_test_sound(64, 44100),
359                position: Vec3::ZERO,
360                volume: 1.0,
361                looping: false,
362                autoplay: true,
363                state: None,
364            }],
365        };
366
367        let wb = lane.mix(&view, &mut buffer, &stream_info);
368        assert_eq!(wb.updates.len(), 1);
369        assert!(
370            wb.updates[0].new_state.is_none(),
371            "Non-looping source should report stopped state"
372        );
373    }
374}