Skip to main content

mittens_engine/engine/ecs/system/
audio_system.rs

1use std::sync::Arc;
2use std::sync::atomic::{AtomicU64, Ordering};
3
4use slotmap::Key;
5
6use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
7
8use crate::engine::ecs::component::AudioBufferSizeComponent;
9use crate::engine::ecs::component::AudioOscillator;
10use crate::engine::ecs::component::AudioOscillatorComponent;
11use crate::engine::ecs::component::AudioOutputComponent;
12use crate::engine::ecs::system::System;
13use crate::engine::ecs::system::clock_system::ClockDriver;
14use crate::engine::ecs::{ComponentId, World};
15use crate::engine::graphics::VisualWorld;
16use crate::engine::user_input::InputState;
17
18use crate::engine::ecs::system::audio_system_fundsp::AudioClockState;
19use crate::engine::ecs::system::audio_system_fundsp::AudioQueueItem;
20use crate::engine::ecs::system::audio_system_fundsp::AudioRtLocalState;
21use crate::engine::ecs::system::audio_system_fundsp::MAX_AUDIO_GRAPH_CHILDREN_PER_NODE;
22use crate::engine::ecs::system::audio_system_fundsp::MAX_AUDIO_GRAPH_NODES;
23use crate::engine::ecs::system::audio_system_fundsp::MAX_OSCS_PER_COMPONENT;
24use crate::engine::ecs::system::audio_system_fundsp::RtAudioGraph;
25use crate::engine::ecs::system::audio_system_fundsp::RtAudioGraphChild;
26use crate::engine::ecs::system::audio_system_fundsp::RtAudioGraphNode;
27use crate::engine::ecs::system::audio_system_fundsp::ScheduledGraphOp;
28use crate::engine::ecs::system::audio_system_fundsp::SynthRtState;
29use crate::engine::ecs::system::audio_system_fundsp::{
30    RtAudioGraphNodeKind, RtAudioGraphNodeState,
31};
32
33use heapless::Vec as HVec;
34use rtrb::Producer;
35
36use crate::engine::ecs::system::audio_graph_compiler::{AudioGraphCompiler, CompiledAudioGraph};
37
38pub use crate::engine::ecs::system::audio_system_fundsp::ScheduledGraphOp as ScheduledGraphOperation;
39pub use crate::engine::ecs::system::audio_system_fundsp::{AudioOp, ScheduledAudioOp};
40
41// Keep a simple audio clock driven by the CPAL callback thread.
42
43#[derive(Debug)]
44pub struct AudioClockDriver {
45    state: Arc<AudioClockState>,
46}
47
48impl AudioClockDriver {
49    fn new(state: Arc<AudioClockState>) -> Self {
50        Self { state }
51    }
52}
53
54impl ClockDriver for AudioClockDriver {
55    fn name(&self) -> &'static str {
56        "audio"
57    }
58
59    fn time_now_sec(&self) -> f64 {
60        let frames = self.state.frames_played.load(Ordering::Relaxed) as f64;
61        frames / (self.state.sample_rate_hz as f64).max(1.0)
62    }
63}
64
65/// Audio system.
66///
67/// Minimal implementation today:
68/// - When an `AudioOutputComponent` is registered, start a CPAL output stream.
69/// - Maintain a monotonically increasing audio clock based on rendered frames.
70pub struct AudioSystem {
71    stream: Option<cpal::Stream>,
72    driver: Option<Arc<dyn ClockDriver>>,
73
74    clock_state: Option<Arc<AudioClockState>>,
75
76    output_component: Option<ComponentId>,
77    desired_buffer_size_frames: Option<u32>,
78
79    pending_buffer_sizes: Vec<(ComponentId, u32)>,
80
81    /// Registered oscillator components and their oscillator lists.
82    pub oscillators: std::collections::HashMap<ComponentId, Vec<AudioOscillator>>,
83
84    /// Audio outputs whose graphs need recompilation.
85    dirty_audio_outputs: std::collections::BTreeSet<ComponentId>,
86
87    /// Latest compiled graphs per output (debug/inspection for now).
88    compiled_graphs_by_output: std::collections::HashMap<ComponentId, Vec<CompiledAudioGraph>>,
89
90    /// Last transport snapshot received from the main clock (used for scheduling immediate RT swaps).
91    last_transport: Option<(f64, f64)>,
92
93    audio_tx: Option<Producer<AudioQueueItem>>,
94
95    /// Decode worker (phase 5 of docs/spec/audio-sources.md).
96    decode_thread: Option<super::audio_decode_thread::DecodeThreadHandle>,
97    /// Main-thread end of the decode→main completion channel.
98    decode_complete_rx:
99        Option<std::sync::mpsc::Receiver<super::audio_decode_thread::LoadedClipMessage>>,
100    /// Sender held alongside the receiver so the worker can post results.
101    decode_complete_tx:
102        Option<std::sync::mpsc::Sender<super::audio_decode_thread::LoadedClipMessage>>,
103    /// Engine playback format chosen at stream-init time. Cached so the
104    /// decode worker resamples / remixes to match the stream.
105    playback_format: Option<super::audio_sample_format_convert::PlaybackFormat>,
106    /// Registered clip URI per component, used to surface diagnostics
107    /// when the decode worker reports back. For cloned instances this
108    /// is the URI inherited from the source.
109    clip_uris: std::collections::HashMap<ComponentId, String>,
110    /// Per-asset decode state, keyed by `asset_key` (URI hash). Allows
111    /// multiple `AudioClipComponent`s sharing a URI (including clones
112    /// via `source_component`) to dedup a single decode pass.
113    /// See docs/draft/audio-clip-instance-cloning.md §2.
114    asset_states: std::collections::HashMap<u64, AssetDecodeState>,
115    /// Components waiting on a given asset to finish decoding. Drained
116    /// when the worker reports back (success or failure).
117    asset_subscribers: std::collections::HashMap<u64, Vec<ComponentId>>,
118    /// Decoded clip uploads buffered until the RT producer is ready.
119    /// Flushed on `register_audio_output`.
120    pending_clip_uploads: Vec<AudioQueueItem>,
121    /// Voice registrations buffered until the RT producer is ready.
122    /// Flushed on `register_audio_output` after asset uploads.
123    pending_clip_instance_registrations: Vec<AudioQueueItem>,
124}
125
126/// Decode-progress for a shared asset. URI is kept for diagnostics.
127#[derive(Debug, Clone)]
128enum AssetDecodeState {
129    Pending { uri: String },
130    Loaded { uri: String },
131    Failed { uri: String, reason: String },
132}
133
134impl Default for AudioSystem {
135    fn default() -> Self {
136        Self {
137            stream: None,
138            driver: None,
139            clock_state: None,
140            output_component: None,
141            desired_buffer_size_frames: None,
142            pending_buffer_sizes: Vec::new(),
143            oscillators: std::collections::HashMap::new(),
144            audio_tx: None,
145
146            dirty_audio_outputs: std::collections::BTreeSet::new(),
147            compiled_graphs_by_output: std::collections::HashMap::new(),
148
149            last_transport: None,
150
151            decode_thread: None,
152            decode_complete_rx: None,
153            decode_complete_tx: None,
154            playback_format: None,
155            clip_uris: std::collections::HashMap::new(),
156            asset_states: std::collections::HashMap::new(),
157            asset_subscribers: std::collections::HashMap::new(),
158            pending_clip_uploads: Vec::new(),
159            pending_clip_instance_registrations: Vec::new(),
160        }
161    }
162}
163
164impl std::fmt::Debug for AudioSystem {
165    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
166        let driver_name = self.driver.as_ref().map(|d| d.name()).unwrap_or("<none>");
167        f.debug_struct("AudioSystem")
168            .field("active", &self.is_active())
169            .field("driver", &driver_name)
170            .finish()
171    }
172}
173
174impl AudioSystem {
175    pub fn new() -> Self {
176        Self::default()
177    }
178
179    pub fn is_active(&self) -> bool {
180        self.stream.is_some() && self.driver.is_some()
181    }
182
183    pub fn driver(&self) -> Option<Arc<dyn ClockDriver>> {
184        self.driver.clone()
185    }
186
187    pub fn update_transport_from_clock(&mut self, beat_now: f64, bpm: f64) {
188        if !beat_now.is_finite() || !bpm.is_finite() || bpm <= 0.0 {
189            return;
190        }
191
192        self.last_transport = Some((beat_now, bpm));
193
194        // If audio isn't running yet, we still keep `last_transport` updated so that
195        // graph swaps can be scheduled as soon as the stream starts.
196        let Some(clock) = self.clock_state.as_ref() else {
197            return;
198        };
199
200        let frames = clock.frames_played.load(Ordering::Relaxed) as f64;
201        let sample_rate_hz = (clock.sample_rate_hz as f64).max(1.0);
202        let time_sec = frames / sample_rate_hz;
203        let beats_per_sec = bpm / 60.0;
204        let beat_offset = beat_now - time_sec * beats_per_sec;
205
206        let Some(tx) = self.audio_tx.as_mut() else {
207            return;
208        };
209        let _ = tx.push(AudioQueueItem::SetTransport { bpm, beat_offset });
210    }
211
212    pub fn schedule_graph_swap(&mut self, world: &World, source_root: ComponentId, beat: f64) {
213        if !beat.is_finite() {
214            return;
215        }
216
217        let Ok(compiled) = AudioGraphCompiler::compile(world, source_root) else {
218            return;
219        };
220
221        let Some(graph) = rt_graph_from_compiled(source_root, &compiled) else {
222            return;
223        };
224
225        let Some(tx) = self.audio_tx.as_mut() else {
226            return;
227        };
228
229        let target_component_ffi = source_root.data().as_ffi();
230        let msg = ScheduledGraphOp {
231            beat,
232            target_component_ffi,
233            graph,
234        };
235
236        let _ = tx.push(AudioQueueItem::GraphMessage(msg));
237    }
238
239    pub fn schedule_audio_op(&mut self, target_component: ComponentId, beat: f64, op: AudioOp) {
240        if !beat.is_finite() {
241            return;
242        }
243        let target_component_ffi = target_component.data().as_ffi();
244
245        let Some(tx) = self.audio_tx.as_mut() else {
246            return;
247        };
248
249        let event = ScheduledAudioOp {
250            beat,
251            target_component_ffi,
252            op,
253        };
254
255        // Drop if full.
256        let _ = tx.push(AudioQueueItem::Message(event));
257    }
258
259    pub fn register_audio_output(&mut self, world: &mut World, component: ComponentId) {
260        if world
261            .get_component_by_id_as::<AudioOutputComponent>(component)
262            .is_none()
263        {
264            return;
265        }
266
267        if self.stream.is_some() {
268            return;
269        }
270
271        self.output_component = Some(component);
272
273        let host = cpal::default_host();
274        let Some(device) = host.default_output_device() else {
275            return;
276        };
277
278        let Ok(supported_config) = device.default_output_config() else {
279            return;
280        };
281
282        // Resolve desired buffer size based on the most recently registered
283        // AudioBufferSizeComponent that is attached under this output component.
284        self.desired_buffer_size_frames =
285            self.pending_buffer_sizes
286                .iter()
287                .rev()
288                .find_map(|(cid, frames)| {
289                    if *frames == 0 {
290                        return None;
291                    }
292
293                    // Walk parent chain to check attachment.
294                    let mut cur = Some(*cid);
295                    while let Some(c) = cur {
296                        if c == component {
297                            return Some(*frames);
298                        }
299                        cur = world.parent_of(c);
300                    }
301                    None
302                });
303
304        let sample_rate_hz = supported_config.sample_rate().0;
305        let channels = supported_config.channels() as u64;
306        let sample_format = supported_config.sample_format();
307
308        let mut stream_config: cpal::StreamConfig = supported_config.into();
309        if let Some(frames) = self.desired_buffer_size_frames {
310            if frames > 0 {
311                stream_config.buffer_size = cpal::BufferSize::Fixed(frames);
312            }
313        }
314        let state = Arc::new(AudioClockState {
315            sample_rate_hz,
316            frames_played: AtomicU64::new(0),
317        });
318        let mut synth_state = SynthRtState::default();
319        let err_fn = |err| eprintln!("[AudioSystem] stream error: {err}");
320
321        let sample_rate_hz_f32 = (sample_rate_hz as f32).max(1.0);
322
323        fn f32_to_f32(s: f32) -> f32 {
324            s
325        }
326
327        fn f32_to_i16(s: f32) -> i16 {
328            (s * i16::MAX as f32)
329                .round()
330                .clamp(i16::MIN as f32, i16::MAX as f32) as i16
331        }
332
333        fn f32_to_u16(s: f32) -> u16 {
334            ((s * 0.5 + 0.5) * u16::MAX as f32)
335                .round()
336                .clamp(0.0, u16::MAX as f32) as u16
337        }
338
339        use crate::engine::ecs::system::audio_system_fundsp::AUDIO_QUEUE_CAP;
340        use crate::engine::ecs::system::audio_system_fundsp::render_buffer;
341
342        // Create a queue for GUI-thread -> audio-thread messages.
343        let (tx, rx) = rtrb::RingBuffer::<AudioQueueItem>::new(AUDIO_QUEUE_CAP);
344        self.audio_tx = Some(tx);
345
346        // Record the engine's chosen playback format so the decode worker
347        // resamples / remixes incoming PCM to match this stream.
348        self.playback_format = Some(super::audio_sample_format_convert::PlaybackFormat {
349            sample_rate: sample_rate_hz,
350            channels: channels as u16,
351        });
352
353        // Seed realtime thread with the most recent oscillator snapshots we know about.
354        if let Some(tx) = self.audio_tx.as_mut() {
355            for (cid, list) in self.oscillators.iter() {
356                let component_ffi = cid.data().as_ffi();
357                let mut hv = heapless::Vec::<AudioOscillator, MAX_OSCS_PER_COMPONENT>::new();
358                for osc in list.iter().take(MAX_OSCS_PER_COMPONENT) {
359                    let _ = hv.push(osc.clone());
360                }
361                let _ = tx.push(AudioQueueItem::ReplaceOscillators {
362                    target_component_ffi: component_ffi,
363                    oscillators: hv,
364                });
365            }
366            // Flush any clip uploads that decoded before the stream was up.
367            for item in self.pending_clip_uploads.drain(..) {
368                let _ = tx.push(item);
369            }
370            // Then flush voice registrations queued before the stream
371            // existed (asset must arrive first so render finds it).
372            for item in self.pending_clip_instance_registrations.drain(..) {
373                let _ = tx.push(item);
374            }
375        }
376
377        let stream = match sample_format {
378            cpal::SampleFormat::F32 => {
379                let state_for_cb = state.clone();
380                let mut rx = rx;
381                let mut rt = AudioRtLocalState::default();
382                device
383                    .build_output_stream(
384                        &stream_config,
385                        move |data: &mut [f32], _info| {
386                            render_buffer(
387                                data,
388                                channels,
389                                sample_rate_hz,
390                                sample_rate_hz_f32,
391                                &state_for_cb,
392                                &mut rx,
393                                &mut rt,
394                                &mut synth_state,
395                                f32_to_f32,
396                            );
397                        },
398                        err_fn,
399                        None,
400                    )
401                    .ok()
402            }
403            cpal::SampleFormat::I16 => {
404                let state_for_cb = state.clone();
405                let mut rx = rx;
406                let mut rt = AudioRtLocalState::default();
407                device
408                    .build_output_stream(
409                        &stream_config,
410                        move |data: &mut [i16], _info| {
411                            render_buffer(
412                                data,
413                                channels,
414                                sample_rate_hz,
415                                sample_rate_hz_f32,
416                                &state_for_cb,
417                                &mut rx,
418                                &mut rt,
419                                &mut synth_state,
420                                f32_to_i16,
421                            );
422                        },
423                        err_fn,
424                        None,
425                    )
426                    .ok()
427            }
428            cpal::SampleFormat::U16 => {
429                let state_for_cb = state.clone();
430                let mut rx = rx;
431                let mut rt = AudioRtLocalState::default();
432                device
433                    .build_output_stream(
434                        &stream_config,
435                        move |data: &mut [u16], _info| {
436                            render_buffer(
437                                data,
438                                channels,
439                                sample_rate_hz,
440                                sample_rate_hz_f32,
441                                &state_for_cb,
442                                &mut rx,
443                                &mut rt,
444                                &mut synth_state,
445                                f32_to_u16,
446                            );
447                        },
448                        err_fn,
449                        None,
450                    )
451                    .ok()
452            }
453            _ => None,
454        };
455
456        let Some(stream) = stream else {
457            return;
458        };
459
460        if let Err(e) = stream.play() {
461            eprintln!("[AudioSystem] failed to play stream: {e}");
462            return;
463        }
464
465        let state_for_driver = state.clone();
466        self.driver = Some(Arc::new(AudioClockDriver::new(state_for_driver)));
467        self.clock_state = Some(state);
468        self.stream = Some(stream);
469
470        // Mark graph dirty so we compile once the frame's mutations settle.
471        self.dirty_audio_outputs.insert(component);
472
473        // Also schedule an initial graph swap immediately so the RT thread has graphs
474        // before any keyframed parameter ops arrive.
475        let sources = collect_audio_oscillator_roots(world, component);
476        if !sources.is_empty() {
477            let (beat_now, bpm) = self.last_transport.unwrap_or((0.0, 120.0));
478            let beats_per_sec = bpm / 60.0;
479            let beat_epsilon = (beats_per_sec * 0.010).max(0.0); // ~10ms into the future.
480            let beat = beat_now + beat_epsilon;
481            for src in sources {
482                self.schedule_graph_swap(world, src, beat);
483            }
484        }
485    }
486
487    pub fn register_audio_oscillator(&mut self, world: &mut World, component: ComponentId) {
488        let Some(c) = world.get_component_by_id_as::<AudioOscillatorComponent>(component) else {
489            return;
490        };
491
492        let list = c.oscillators.clone();
493        self.oscillators.insert(component, list.clone());
494
495        let Some(tx) = self.audio_tx.as_mut() else {
496            return;
497        };
498        let component_ffi = component.data().as_ffi();
499
500        let mut hv = heapless::Vec::<AudioOscillator, MAX_OSCS_PER_COMPONENT>::new();
501        for osc in list.iter().take(MAX_OSCS_PER_COMPONENT) {
502            let _ = hv.push(osc.clone());
503        }
504
505        let _ = tx.push(AudioQueueItem::ReplaceOscillators {
506            target_component_ffi: component_ffi,
507            oscillators: hv,
508        });
509
510        // Any oscillator registration/update may affect compiled audio graphs.
511        self.mark_audio_graph_dirty(world, component);
512    }
513
514    pub fn register_audio_buffer_size(&mut self, world: &mut World, component: ComponentId) {
515        let Some(c) = world.get_component_by_id_as::<AudioBufferSizeComponent>(component) else {
516            return;
517        };
518        if c.frames == 0 {
519            return;
520        }
521
522        self.pending_buffer_sizes.push((component, c.frames));
523
524        let Some(output) = self.output_component else {
525            return;
526        };
527
528        // Only apply if attached under the audio output component.
529        let mut cur = Some(component);
530        let mut attached = false;
531        while let Some(cid) = cur {
532            if cid == output {
533                attached = true;
534                break;
535            }
536            cur = world.parent_of(cid);
537        }
538        if !attached {
539            return;
540        }
541
542        self.desired_buffer_size_frames = Some(c.frames);
543
544        // If audio is already active, restart the stream to apply the new size.
545        if self.stream.is_some() {
546            self.stream = None;
547            self.driver = None;
548            self.clock_state = None;
549            self.audio_tx = None;
550            self.register_audio_output(world, output);
551        }
552    }
553}
554
555impl AudioSystem {
556    /// Record that something in the audio graph changed.
557    ///
558    /// `component` can be any node in a subtree under an AudioOutputComponent.
559    pub fn mark_audio_graph_dirty(&mut self, world: &World, component: ComponentId) {
560        // Walk up parent chain until we find an AudioOutputComponent.
561        let mut cur = Some(component);
562        while let Some(cid) = cur {
563            if world
564                .get_component_by_id_as::<AudioOutputComponent>(cid)
565                .is_some()
566            {
567                self.dirty_audio_outputs.insert(cid);
568                return;
569            }
570            cur = world.parent_of(cid);
571        }
572    }
573
574    /// Recompile all dirty audio output graphs. Intended to be called once per frame
575    /// after CommandQueue mutations are applied.
576    pub fn rebuild_dirty_audio_graphs(&mut self, world: &World) {
577        if self.dirty_audio_outputs.is_empty() {
578            return;
579        }
580
581        let outputs: Vec<ComponentId> = self.dirty_audio_outputs.iter().copied().collect();
582        self.dirty_audio_outputs.clear();
583
584        for output in outputs {
585            let sources = collect_audio_oscillator_roots(world, output);
586
587            let mut compiled = Vec::new();
588            for &src in sources.iter() {
589                if let Ok(g) = AudioGraphCompiler::compile(world, src) {
590                    compiled.push(g);
591                }
592            }
593
594            // Deterministic order (ComponentId sort already used in collector, but keep stable).
595            self.compiled_graphs_by_output.insert(output, compiled);
596
597            // Schedule an RT graph swap for each source immediately (beat-timed). This is the
598            // "best effort" path for init/live edits; keyframe-driven topology should call
599            // `schedule_graph_swap` with the keyframe beat.
600            if self.audio_tx.is_some() {
601                let Some((beat_now, bpm)) = self.last_transport else {
602                    // We don't know current beat yet, so can't schedule.
603                    continue;
604                };
605                let beats_per_sec = bpm / 60.0;
606                let beat_epsilon = (beats_per_sec * 0.001).max(0.0); // 1ms into the future.
607                let beat = beat_now + beat_epsilon;
608
609                for &src in sources.iter() {
610                    self.schedule_graph_swap(world, src, beat);
611                }
612            }
613        }
614    }
615}
616
617fn rt_graph_from_compiled(
618    source_root: ComponentId,
619    compiled: &CompiledAudioGraph,
620) -> Option<RtAudioGraph> {
621    fn kind_and_state(
622        k: &crate::engine::ecs::system::audio_graph_compiler::AudioGraphNodeKind,
623    ) -> (RtAudioGraphNodeKind, RtAudioGraphNodeState) {
624        match *k {
625            crate::engine::ecs::system::audio_graph_compiler::AudioGraphNodeKind::OscillatorSource { .. } => {
626                (RtAudioGraphNodeKind::OscillatorSource, Default::default())
627            }
628            crate::engine::ecs::system::audio_graph_compiler::AudioGraphNodeKind::ClipSource => {
629                (RtAudioGraphNodeKind::ClipSource, Default::default())
630            }
631            crate::engine::ecs::system::audio_graph_compiler::AudioGraphNodeKind::Gain { gain } => {
632                (RtAudioGraphNodeKind::Gain { gain }, Default::default())
633            }
634            crate::engine::ecs::system::audio_graph_compiler::AudioGraphNodeKind::LowPass {
635                cutoff_hz,
636                resonance,
637            } => (
638                RtAudioGraphNodeKind::LowPass {
639                    cutoff_hz,
640                    resonance,
641                },
642                Default::default(),
643            ),
644            crate::engine::ecs::system::audio_graph_compiler::AudioGraphNodeKind::BandPass {
645                center_hz,
646                bandwidth_octaves,
647                resonance,
648            } => (
649                RtAudioGraphNodeKind::BandPass {
650                    center_hz,
651                    bandwidth_octaves,
652                    resonance,
653                },
654                Default::default(),
655            ),
656            crate::engine::ecs::system::audio_graph_compiler::AudioGraphNodeKind::HighPass {
657                cutoff_hz,
658                resonance,
659            } => (
660                RtAudioGraphNodeKind::HighPass {
661                    cutoff_hz,
662                    resonance,
663                },
664                Default::default(),
665            ),
666            crate::engine::ecs::system::audio_graph_compiler::AudioGraphNodeKind::Limiter {
667                attack_ms,
668                release_ms,
669                threshold,
670            } => {
671                let mut st: RtAudioGraphNodeState = Default::default();
672                st.limiter_attack_ms = attack_ms;
673                st.limiter_release_ms = release_ms;
674                st.limiter_threshold = threshold;
675                (RtAudioGraphNodeKind::Limiter, st)
676            }
677        }
678    }
679
680    fn build(
681        node: &crate::engine::ecs::system::audio_graph_compiler::AudioGraphNode,
682        nodes: &mut HVec<RtAudioGraphNode, MAX_AUDIO_GRAPH_NODES>,
683    ) -> Option<u8> {
684        let idx = nodes.len();
685        if idx >= MAX_AUDIO_GRAPH_NODES {
686            return None;
687        }
688
689        let (kind, state) = kind_and_state(&node.kind);
690
691        nodes
692            .push(RtAudioGraphNode {
693                component_ffi: node.component.data().as_ffi(),
694                kind,
695                state,
696                children: HVec::<RtAudioGraphChild, MAX_AUDIO_GRAPH_CHILDREN_PER_NODE>::new(),
697            })
698            .ok()?;
699
700        let idx_u8 = idx as u8;
701        for (i, ch) in node.children.iter().enumerate() {
702            let child_idx = build(ch, nodes)?;
703            let w = node
704                .mix
705                .as_ref()
706                .and_then(|m| m.weights.get(i))
707                .copied()
708                .unwrap_or(1.0);
709
710            // Best-effort: if a node has more children than the RT cap, ignore extras.
711            let parent = nodes.get_mut(idx).expect("just pushed");
712            if parent.children.len() >= MAX_AUDIO_GRAPH_CHILDREN_PER_NODE {
713                continue;
714            }
715            let _ = parent.children.push(RtAudioGraphChild {
716                idx: child_idx,
717                weight: w,
718            });
719        }
720
721        Some(idx_u8)
722    }
723
724    let mut nodes = HVec::<RtAudioGraphNode, MAX_AUDIO_GRAPH_NODES>::new();
725    let _root_idx = build(&compiled.root, &mut nodes)?;
726
727    Some(RtAudioGraph {
728        root_component_ffi: source_root.data().as_ffi(),
729        nodes,
730    })
731}
732
733fn collect_audio_oscillator_roots(world: &World, output: ComponentId) -> Vec<ComponentId> {
734    use crate::engine::ecs::component::AudioClipComponent;
735
736    // Collect oscillator AND clip components in the output subtree —
737    // both are `AudioSource` peers per docs/spec/audio-sources.md §5.
738    let mut all = Vec::new();
739    let mut stack = vec![output];
740    while let Some(node) = stack.pop() {
741        for &ch in world.children_of(node) {
742            stack.push(ch);
743        }
744
745        if node != output
746            && (world
747                .get_component_by_id_as::<AudioOscillatorComponent>(node)
748                .is_some()
749                || world
750                    .get_component_by_id_as::<AudioClipComponent>(node)
751                    .is_some())
752        {
753            all.push(node);
754        }
755    }
756
757    // Keep only roots (exclude sources that are under another source).
758    all.sort();
759    all.dedup();
760
761    all.into_iter()
762        .filter(|&cid| {
763            let mut cur = world.parent_of(cid);
764            while let Some(p) = cur {
765                if p == output {
766                    return true;
767                }
768                let is_source = world
769                    .get_component_by_id_as::<AudioOscillatorComponent>(p)
770                    .is_some()
771                    || world
772                        .get_component_by_id_as::<AudioClipComponent>(p)
773                        .is_some();
774                if is_source {
775                    return false;
776                }
777                cur = world.parent_of(p);
778            }
779            false
780        })
781        .collect()
782}
783
784impl System for AudioSystem {
785    fn tick(
786        &mut self,
787        world: &mut World,
788        _visuals: &mut VisualWorld,
789        _input: &InputState,
790        _dt_sec: f32,
791    ) {
792        self.drain_decode_completions(world);
793    }
794}
795
796impl AudioSystem {
797    /// Public wrapper for the internal drain — exposed so `SystemWorld`
798    /// can flush completions after command processing each frame.
799    pub fn drain_decode_completions_public(&mut self, world: &mut World) {
800        self.drain_decode_completions(world);
801    }
802
803    /// Forward decode-worker results to the audio RT thread and update
804    /// `AudioClipComponent.load_state` so the rest of the engine sees the
805    /// load outcome. Called from `System::tick`.
806    fn drain_decode_completions(&mut self, world: &mut World) {
807        use super::audio_decode_thread::LoadedClipMessage;
808        use crate::engine::ecs::component::{AudioClipComponent, AudioClipLoadState};
809
810        let Some(rx) = self.decode_complete_rx.as_ref() else {
811            return;
812        };
813
814        loop {
815            let msg = match rx.try_recv() {
816                Ok(m) => m,
817                Err(_) => break,
818            };
819            // `clip_id` end-to-end is the URI-derived asset key — we
820            // pass it into `LoadClipRequest` and the worker echoes it
821            // back. See `register_audio_clip`.
822            match msg {
823                LoadedClipMessage::Loaded {
824                    clip_id: asset_key,
825                    samples,
826                    channels,
827                    sample_rate,
828                } => {
829                    let upload = AudioQueueItem::UploadClip {
830                        asset_key,
831                        samples: samples.clone(),
832                        channels,
833                        sample_rate,
834                    };
835                    if let Some(tx) = self.audio_tx.as_mut() {
836                        let _ = tx.push(upload);
837                    } else {
838                        self.pending_clip_uploads.push(upload);
839                    }
840                    let uri = match self.asset_states.get(&asset_key) {
841                        Some(AssetDecodeState::Pending { uri })
842                        | Some(AssetDecodeState::Loaded { uri })
843                        | Some(AssetDecodeState::Failed { uri, .. }) => uri.clone(),
844                        None => String::new(),
845                    };
846                    self.asset_states
847                        .insert(asset_key, AssetDecodeState::Loaded { uri });
848                    // Mark every subscriber Loaded.
849                    if let Some(subs) = self.asset_subscribers.remove(&asset_key) {
850                        for cid in subs {
851                            if let Some(c) =
852                                world.get_component_by_id_as_mut::<AudioClipComponent>(cid)
853                            {
854                                c.load_state = AudioClipLoadState::Loaded;
855                            }
856                        }
857                    }
858                }
859                LoadedClipMessage::Failed {
860                    clip_id: asset_key,
861                    reason,
862                } => {
863                    let uri = match self.asset_states.get(&asset_key) {
864                        Some(AssetDecodeState::Pending { uri })
865                        | Some(AssetDecodeState::Loaded { uri })
866                        | Some(AssetDecodeState::Failed { uri, .. }) => uri.clone(),
867                        None => String::new(),
868                    };
869                    eprintln!("[AudioClip] {uri}: {reason}");
870                    self.asset_states.insert(
871                        asset_key,
872                        AssetDecodeState::Failed {
873                            uri,
874                            reason: reason.clone(),
875                        },
876                    );
877                    if let Some(subs) = self.asset_subscribers.remove(&asset_key) {
878                        for cid in subs {
879                            if let Some(c) =
880                                world.get_component_by_id_as_mut::<AudioClipComponent>(cid)
881                            {
882                                c.load_state = AudioClipLoadState::Failed(reason.clone());
883                            }
884                        }
885                    }
886                }
887            }
888        }
889    }
890
891    /// Register an `AudioClipComponent` for decode + RT playback. Spawns
892    /// the decode worker lazily on first call.
893    ///
894    /// Decode is deduplicated by URI hash (`asset_key`): two clips with
895    /// the same URI — or a clip and an `.instance_of()` clone — share
896    /// one decoded buffer on the RT side. Each component still gets its
897    /// own `RtClipInstance` voice via `RegisterClipInstance`.
898    pub fn register_audio_clip(&mut self, world: &mut World, component: ComponentId) {
899        use super::audio_decode_thread::{LoadClipRequest, spawn_decode_thread};
900        use super::audio_sample_format_convert::PlaybackFormat;
901        use crate::engine::ecs::component::{AudioClipComponent, AudioClipLoadState};
902        use slotmap::Key;
903        use std::collections::hash_map::DefaultHasher;
904        use std::hash::{Hash, Hasher};
905
906        // Clones copy the URI in at construction (see
907        // `AudioClipComponent::instance_of`), so this is just a
908        // straight read.
909        let (uri, start_beat, stop_beat) = {
910            let Some(clip) = world.get_component_by_id_as::<AudioClipComponent>(component) else {
911                return;
912            };
913            (clip.uri.clone(), clip.start_beat, clip.stop_beat)
914        };
915
916        if uri.is_empty() {
917            if let Some(c) = world.get_component_by_id_as_mut::<AudioClipComponent>(component) {
918                c.load_state = AudioClipLoadState::Failed("clip has no uri".into());
919            }
920            return;
921        }
922
923        self.clip_uris.insert(component, uri.clone());
924
925        // Compute the asset key (URI hash). DefaultHasher is per-process
926        // but the value never escapes this process — only the RT thread
927        // sees it via the audio queue.
928        let asset_key = {
929            let mut h = DefaultHasher::new();
930            uri.hash(&mut h);
931            h.finish()
932        };
933
934        // Ensure the decode thread + completion channel exist.
935        if self.decode_thread.is_none() {
936            let (tx, rx) = std::sync::mpsc::channel();
937            self.decode_complete_tx = Some(tx.clone());
938            self.decode_complete_rx = Some(rx);
939            self.decode_thread = Some(spawn_decode_thread(tx));
940        }
941
942        let target = self.playback_format.unwrap_or(PlaybackFormat {
943            sample_rate: 48_000,
944            channels: 1,
945        });
946
947        // Dedup decode: only one LoadClipRequest per asset_key.
948        let needs_decode = !self.asset_states.contains_key(&asset_key);
949        if needs_decode {
950            self.asset_states
951                .insert(asset_key, AssetDecodeState::Pending { uri: uri.clone() });
952            if let Some(handle) = self.decode_thread.as_ref() {
953                let _ = handle.tx.send(LoadClipRequest {
954                    clip_id: asset_key,
955                    uri: uri.clone(),
956                    target,
957                });
958            }
959        }
960
961        // Set this component's load_state immediately if the asset is
962        // already resolved; otherwise subscribe for the next completion.
963        match self.asset_states.get(&asset_key).cloned() {
964            Some(AssetDecodeState::Loaded { .. }) => {
965                if let Some(c) = world.get_component_by_id_as_mut::<AudioClipComponent>(component) {
966                    c.load_state = AudioClipLoadState::Loaded;
967                }
968            }
969            Some(AssetDecodeState::Failed { reason, .. }) => {
970                if let Some(c) = world.get_component_by_id_as_mut::<AudioClipComponent>(component) {
971                    c.load_state = AudioClipLoadState::Failed(reason);
972                }
973            }
974            _ => {
975                self.asset_subscribers
976                    .entry(asset_key)
977                    .or_default()
978                    .push(component);
979            }
980        }
981
982        // Register the voice on the RT thread (or buffer it until the
983        // stream is up).
984        let register = AudioQueueItem::RegisterClipInstance {
985            component_ffi: component.data().as_ffi(),
986            asset_key,
987            start_beat,
988            stop_beat,
989        };
990        if let Some(tx) = self.audio_tx.as_mut() {
991            let _ = tx.push(register);
992        } else {
993            self.pending_clip_instance_registrations.push(register);
994        }
995
996        // Touch graph dirtiness so the clip becomes a node in the
997        // compiled audio graph.
998        self.mark_audio_graph_dirty(world, component);
999    }
1000}