Skip to main content

arcane_engine/audio/
mod.rs

1use std::collections::HashMap;
2use std::io::Cursor;
3use std::sync::{mpsc, Arc};
4
5use rodio::Source;
6
7/// Audio bus for grouping sounds. Each bus has independent volume control.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum AudioBus {
10    Sfx = 0,
11    Music = 1,
12    Ambient = 2,
13    Voice = 3,
14}
15
16impl AudioBus {
17    pub fn from_u32(value: u32) -> Option<Self> {
18        match value {
19            0 => Some(Self::Sfx),
20            1 => Some(Self::Music),
21            2 => Some(Self::Ambient),
22            3 => Some(Self::Voice),
23            _ => None,
24        }
25    }
26}
27
28/// Commands sent from the main thread to the audio thread.
29pub enum AudioCommand {
30    LoadSound { id: u32, data: Vec<u8> },
31    PlaySound { id: u32, volume: f32, looping: bool },
32    StopSound { id: u32 },
33    StopAll,
34    SetMasterVolume { volume: f32 },
35
36    // Phase 20: New instance-based commands
37    PlaySoundEx {
38        sound_id: u32,
39        instance_id: u64,
40        volume: f32,
41        looping: bool,
42        bus: AudioBus,
43        pan: f32,
44        pitch: f32,
45        low_pass_freq: u32,
46        reverb_mix: f32,
47        reverb_delay_ms: u32,
48    },
49    PlaySoundSpatial {
50        sound_id: u32,
51        instance_id: u64,
52        volume: f32,
53        looping: bool,
54        bus: AudioBus,
55        pitch: f32,
56        source_x: f32,
57        source_y: f32,
58        listener_x: f32,
59        listener_y: f32,
60    },
61    StopInstance { instance_id: u64 },
62    SetInstanceVolume { instance_id: u64, volume: f32 },
63    SetInstancePitch { instance_id: u64, pitch: f32 },
64    UpdateSpatialPositions {
65        updates: Vec<(u64, f32, f32)>, // (instance_id, source_x, source_y)
66        listener_x: f32,
67        listener_y: f32,
68    },
69    SetBusVolume { bus: AudioBus, volume: f32 },
70
71    Shutdown,
72}
73
74pub type AudioSender = mpsc::Sender<AudioCommand>;
75pub type AudioReceiver = mpsc::Receiver<AudioCommand>;
76
77/// Create a channel for sending audio commands to the audio thread.
78pub fn audio_channel() -> (AudioSender, AudioReceiver) {
79    mpsc::channel()
80}
81
82/// Instance metadata for tracking per-instance state.
83struct InstanceMetadata {
84    bus: AudioBus,
85    base_volume: f32,
86    is_spatial: bool,
87}
88
89/// Scale factor to convert game pixel coordinates to audio-space coordinates.
90/// rodio's SpatialSink uses inverse-distance attenuation, so game distances
91/// of 100s of pixels would produce near-zero volume without scaling.
92/// With SPATIAL_SCALE = 0.01, 100 game pixels = 1.0 audio unit.
93const SPATIAL_SCALE: f32 = 0.01;
94
95/// Spawn the audio thread. It owns the rodio OutputStream and processes commands.
96pub fn start_audio_thread(rx: AudioReceiver) -> std::thread::JoinHandle<()> {
97    std::thread::spawn(move || {
98        // Initialize rodio output stream
99        let stream_handle = match rodio::OutputStream::try_default() {
100            Ok((stream, handle)) => {
101                // Leak the stream so it lives as long as the thread
102                std::mem::forget(stream);
103                handle
104            }
105            Err(e) => {
106                eprintln!("[audio] Failed to initialize audio output: {e}");
107                // Drain commands without playing
108                while let Ok(cmd) = rx.recv() {
109                    if matches!(cmd, AudioCommand::Shutdown) {
110                        break;
111                    }
112                }
113                return;
114            }
115        };
116
117        // Sound data storage (Arc for sharing across concurrent plays)
118        let mut sounds: HashMap<u32, Arc<Vec<u8>>> = HashMap::new();
119
120        // Instance-based sinks (new Phase 20 architecture)
121        let mut sinks: HashMap<u64, rodio::Sink> = HashMap::new();
122        let mut spatial_sinks: HashMap<u64, rodio::SpatialSink> = HashMap::new();
123        let mut instance_metadata: HashMap<u64, InstanceMetadata> = HashMap::new();
124
125        // Volume state
126        let mut master_volume: f32 = 1.0;
127        let mut bus_volumes: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; // Sfx, Music, Ambient, Voice
128
129        // Legacy sink tracking (old sound_id-keyed sinks for backward compat)
130        let mut legacy_sinks: HashMap<u32, rodio::Sink> = HashMap::new();
131
132        // Cleanup counter for periodic sink cleanup
133        let mut cleanup_counter = 0;
134
135        loop {
136            let cmd = match rx.recv() {
137                Ok(cmd) => cmd,
138                Err(_) => break, // Channel closed
139            };
140
141            match cmd {
142                AudioCommand::LoadSound { id, data } => {
143                    sounds.insert(id, Arc::new(data));
144                }
145
146                AudioCommand::PlaySound { id, volume, looping } => {
147                    // Legacy command: route through old sink system for backward compat
148                    if let Some(data) = sounds.get(&id) {
149                        match rodio::Sink::try_new(&stream_handle) {
150                            Ok(sink) => {
151                                sink.set_volume(volume * master_volume);
152                                let cursor = Cursor::new((**data).clone());
153                                match rodio::Decoder::new(cursor) {
154                                    Ok(source) => {
155                                        if looping {
156                                            sink.append(rodio::source::Source::repeat_infinite(source));
157                                        } else {
158                                            sink.append(source);
159                                        }
160                                        sink.play();
161                                        legacy_sinks.insert(id, sink);
162                                    }
163                                    Err(e) => {
164                                        eprintln!("[audio] Failed to decode sound {id}: {e}");
165                                    }
166                                }
167                            }
168                            Err(e) => {
169                                eprintln!("[audio] Failed to create sink for sound {id}: {e}");
170                            }
171                        }
172                    }
173                }
174
175                AudioCommand::StopSound { id } => {
176                    if let Some(sink) = legacy_sinks.remove(&id) {
177                        sink.stop();
178                    }
179                }
180
181                AudioCommand::StopAll => {
182                    for (_, sink) in legacy_sinks.drain() {
183                        sink.stop();
184                    }
185                    for (_, sink) in sinks.drain() {
186                        sink.stop();
187                    }
188                    for (_, sink) in spatial_sinks.drain() {
189                        sink.stop();
190                    }
191                    instance_metadata.clear();
192                }
193
194                AudioCommand::SetMasterVolume { volume } => {
195                    master_volume = volume;
196                    // Update all legacy sinks
197                    for sink in legacy_sinks.values() {
198                        sink.set_volume(volume);
199                    }
200                    // Update all instance sinks
201                    update_all_volumes(&sinks, &spatial_sinks, &instance_metadata, &bus_volumes, master_volume);
202                }
203
204                // Phase 20: New instance-based commands
205                AudioCommand::PlaySoundEx {
206                    sound_id,
207                    instance_id,
208                    volume,
209                    looping,
210                    bus,
211                    pan,
212                    pitch,
213                    low_pass_freq,
214                    reverb_mix: _,
215                    reverb_delay_ms: _,
216                } => {
217                    if let Some(data) = sounds.get(&sound_id) {
218                        match rodio::Sink::try_new(&stream_handle) {
219                            Ok(sink) => {
220                                let cursor = Cursor::new((**data).clone());
221                                match rodio::Decoder::new(cursor) {
222                                    Ok(source) => {
223                                        // Convert to f32 samples for effects
224                                        let source = source.convert_samples::<f32>();
225
226                                        // Apply low-pass filter if requested
227                                        let source = if low_pass_freq > 0 {
228                                            rodio::source::Source::low_pass(source, low_pass_freq)
229                                        } else {
230                                            rodio::source::Source::low_pass(source, 20000) // No filtering
231                                        };
232
233                                        // Note: rodio's reverb requires Clone, which BltFilter doesn't implement.
234                                        // For simplicity, skip reverb implementation for now (or use buffered source).
235                                        // In production, we'd buffer the source first.
236
237                                        // Apply looping
238                                        if looping {
239                                            sink.append(rodio::source::Source::repeat_infinite(source));
240                                        } else {
241                                            sink.append(source);
242                                        }
243
244                                        // Apply pan by adjusting left/right channel volumes
245                                        // Pan range: -1.0 (left) to +1.0 (right)
246                                        // Note: rodio doesn't expose direct channel volume control,
247                                        // so pan is computed but not applied. Store for future reference.
248                                        let (_left, _right) = pan_to_volumes(pan);
249
250                                        sink.set_volume(volume * bus_volumes[bus as usize] * master_volume);
251
252                                        // Apply pitch
253                                        sink.set_speed(pitch);
254
255                                        sink.play();
256
257                                        // Store metadata
258                                        instance_metadata.insert(instance_id, InstanceMetadata {
259                                            bus,
260                                            base_volume: volume,
261                                            is_spatial: false,
262                                        });
263
264                                        sinks.insert(instance_id, sink);
265                                    }
266                                    Err(e) => {
267                                        eprintln!("[audio] Failed to decode sound {sound_id} for instance {instance_id}: {e}");
268                                    }
269                                }
270                            }
271                            Err(e) => {
272                                eprintln!("[audio] Failed to create sink for sound {sound_id}: {e}");
273                            }
274                        }
275                    }
276                }
277
278                AudioCommand::PlaySoundSpatial {
279                    sound_id,
280                    instance_id,
281                    volume,
282                    looping,
283                    bus,
284                    pitch,
285                    source_x,
286                    source_y,
287                    listener_x,
288                    listener_y,
289                } => {
290                    if let Some(data) = sounds.get(&sound_id) {
291                        // Scale game pixel coords to audio-space coords
292                        let sx = source_x * SPATIAL_SCALE;
293                        let sy = source_y * SPATIAL_SCALE;
294                        let lx = listener_x * SPATIAL_SCALE;
295                        let ly = listener_y * SPATIAL_SCALE;
296
297                        // SpatialSink constructor: try_new(handle, emitter_pos, left_ear, right_ear)
298                        match rodio::SpatialSink::try_new(
299                            &stream_handle,
300                            [sx, sy, 0.0],
301                            [lx - 0.1, ly, 0.0], // Left ear
302                            [lx + 0.1, ly, 0.0], // Right ear
303                        ) {
304                            Ok(sink) => {
305                                let cursor = Cursor::new((**data).clone());
306                                match rodio::Decoder::new(cursor) {
307                                    Ok(source) => {
308                                        if looping {
309                                            sink.append(rodio::source::Source::repeat_infinite(source));
310                                        } else {
311                                            sink.append(source);
312                                        }
313
314                                        sink.set_volume(volume * bus_volumes[bus as usize] * master_volume);
315                                        sink.set_speed(pitch);
316                                        sink.play();
317
318                                        instance_metadata.insert(instance_id, InstanceMetadata {
319                                            bus,
320                                            base_volume: volume,
321                                            is_spatial: true,
322                                        });
323
324                                        spatial_sinks.insert(instance_id, sink);
325                                    }
326                                    Err(e) => {
327                                        eprintln!("[audio] Failed to decode sound {sound_id} for spatial instance {instance_id}: {e}");
328                                    }
329                                }
330                            }
331                            Err(e) => {
332                                eprintln!("[audio] Failed to create spatial sink for sound {sound_id}: {e}");
333                            }
334                        }
335                    }
336                }
337
338                AudioCommand::StopInstance { instance_id } => {
339                    if let Some(sink) = sinks.remove(&instance_id) {
340                        sink.stop();
341                        instance_metadata.remove(&instance_id);
342                    } else if let Some(sink) = spatial_sinks.remove(&instance_id) {
343                        sink.stop();
344                        instance_metadata.remove(&instance_id);
345                    }
346                }
347
348                AudioCommand::SetInstanceVolume { instance_id, volume } => {
349                    if let Some(metadata) = instance_metadata.get_mut(&instance_id) {
350                        metadata.base_volume = volume;
351                        let final_volume = volume * bus_volumes[metadata.bus as usize] * master_volume;
352
353                        if metadata.is_spatial {
354                            if let Some(sink) = spatial_sinks.get(&instance_id) {
355                                sink.set_volume(final_volume);
356                            }
357                        } else {
358                            if let Some(sink) = sinks.get(&instance_id) {
359                                sink.set_volume(final_volume);
360                            }
361                        }
362                    }
363                }
364
365                AudioCommand::SetInstancePitch { instance_id, pitch } => {
366                    if let Some(metadata) = instance_metadata.get(&instance_id) {
367                        if metadata.is_spatial {
368                            if let Some(sink) = spatial_sinks.get(&instance_id) {
369                                sink.set_speed(pitch);
370                            }
371                        } else {
372                            if let Some(sink) = sinks.get(&instance_id) {
373                                sink.set_speed(pitch);
374                            }
375                        }
376                    }
377                }
378
379                AudioCommand::UpdateSpatialPositions { updates, listener_x, listener_y } => {
380                    let lx = listener_x * SPATIAL_SCALE;
381                    let ly = listener_y * SPATIAL_SCALE;
382                    for (instance_id, source_x, source_y) in updates {
383                        if let Some(sink) = spatial_sinks.get(&instance_id) {
384                            sink.set_emitter_position([source_x * SPATIAL_SCALE, source_y * SPATIAL_SCALE, 0.0]);
385                            sink.set_left_ear_position([lx - 0.1, ly, 0.0]);
386                            sink.set_right_ear_position([lx + 0.1, ly, 0.0]);
387                        }
388                    }
389                }
390
391                AudioCommand::SetBusVolume { bus, volume } => {
392                    bus_volumes[bus as usize] = volume;
393                    update_all_volumes(&sinks, &spatial_sinks, &instance_metadata, &bus_volumes, master_volume);
394                }
395
396                AudioCommand::Shutdown => break,
397            }
398
399            // Periodic cleanup of finished sinks (every 100 commands)
400            cleanup_counter += 1;
401            if cleanup_counter >= 100 {
402                cleanup_counter = 0;
403                sinks.retain(|id, sink| {
404                    let keep = !sink.empty();
405                    if !keep {
406                        instance_metadata.remove(id);
407                    }
408                    keep
409                });
410                spatial_sinks.retain(|id, sink| {
411                    let keep = !sink.empty();
412                    if !keep {
413                        instance_metadata.remove(id);
414                    }
415                    keep
416                });
417                legacy_sinks.retain(|_, sink| !sink.empty());
418            }
419        }
420    })
421}
422
423/// Convert pan value (-1.0 to +1.0) to left/right channel volumes.
424/// Pan -1.0 = full left (1.0, 0.0), 0.0 = center (0.707, 0.707), +1.0 = full right (0.0, 1.0)
425fn pan_to_volumes(pan: f32) -> (f32, f32) {
426    let pan_clamped = pan.clamp(-1.0, 1.0);
427    // Equal power panning: use sqrt for smooth volume curve
428    let left = ((1.0 - pan_clamped) / 2.0).sqrt();
429    let right = ((1.0 + pan_clamped) / 2.0).sqrt();
430    (left, right)
431}
432
433/// Update volumes for all active instances based on bus volumes and master volume.
434fn update_all_volumes(
435    sinks: &HashMap<u64, rodio::Sink>,
436    spatial_sinks: &HashMap<u64, rodio::SpatialSink>,
437    metadata: &HashMap<u64, InstanceMetadata>,
438    bus_volumes: &[f32; 4],
439    master_volume: f32,
440) {
441    for (id, sink) in sinks {
442        if let Some(meta) = metadata.get(id) {
443            let final_volume = meta.base_volume * bus_volumes[meta.bus as usize] * master_volume;
444            sink.set_volume(final_volume);
445        }
446    }
447
448    for (id, sink) in spatial_sinks {
449        if let Some(meta) = metadata.get(id) {
450            let final_volume = meta.base_volume * bus_volumes[meta.bus as usize] * master_volume;
451            sink.set_volume(final_volume);
452        }
453    }
454}