khora_lanes/audio_lane/mixing/
spatial_mixing_lane.rs1use 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
40const FRAMES_PER_TICK: usize = 1024;
43
44#[derive(Default)]
46pub struct SpatialMixingLane;
47
48impl SpatialMixingLane {
49 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 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 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 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 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 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}