Skip to main content

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