Skip to main content

mittens_engine/engine/ecs/component/
audio_clip.rs

1use super::Component;
2use crate::engine::ecs::ComponentId;
3
4/// How an `AudioClipComponent` reacts to repeated `AudioSchedulePlay`
5/// intents. See docs/spec/audio-sources.md.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
7#[serde(rename_all = "snake_case")]
8pub enum AudioTriggerMode {
9    /// Every trigger resets the cursor and replays from the start.
10    Retrigger,
11    /// Triggers are ignored once a clip has started playing (one-shot).
12    OneShot,
13    /// Triggers are ignored once the clip is playing; suited for ambient
14    /// loops set to `playing: true` at scene start.
15    Latched,
16}
17
18impl Default for AudioTriggerMode {
19    fn default() -> Self {
20        AudioTriggerMode::Retrigger
21    }
22}
23
24/// Result of attempting to acquire decoded PCM data for this clip's URI.
25///
26/// Phase 4 stores only the URI shape and a load result. Phase 5 wires
27/// `Pending` to the decode thread; for now the runtime synchronously
28/// checks file existence so missing assets surface immediately and the
29/// scene keeps going.
30#[derive(Debug, Clone, PartialEq)]
31pub enum AudioClipLoadState {
32    /// Not yet inspected.
33    Pending,
34    /// Asset is on disk and has been claimed by the decode pipeline.
35    /// Phase 4 collapses this into "URI is non-empty + file exists".
36    Loaded,
37    /// Load failed — either file missing, unsupported codec, or decode
38    /// error. The `String` carries a one-line reason for diagnostics.
39    /// Dispatch from `AudioSchedulePlay` against a Failed clip is silent;
40    /// nothing crashes.
41    Failed(String),
42}
43
44impl Default for AudioClipLoadState {
45    fn default() -> Self {
46        AudioClipLoadState::Pending
47    }
48}
49
50/// PCM-backed audio source. Peer to `AudioOscillatorComponent` in the
51/// unified `AudioSource` model.
52///
53/// Cloning model (docs/draft/audio-clip-instance-cloning.md):
54/// `source_component = Some(other)` marks this clip as an instance
55/// sharing `other`'s decoded buffer. Each instance still has its own
56/// playhead on the RT side; only the asset is shared.
57#[derive(Debug, Clone)]
58pub struct AudioClipComponent {
59    pub uri: String,
60    pub trigger_mode: AudioTriggerMode,
61    pub load_state: AudioClipLoadState,
62    /// When `Some`, this clip is an instance of another `AudioClipComponent`
63    /// and shares its decoded asset (no re-decode). The URI is inherited
64    /// from the source at registration time.
65    pub source_component: Option<ComponentId>,
66    /// Initial cursor offset, in transport beats, applied each time the
67    /// clip is triggered (`SetEnabled(true)`).
68    pub start_beat: f64,
69    /// Optional hard stop in beats relative to the trigger fire beat.
70    /// Combined with the trigger's `duration` as the minimum of the two.
71    pub stop_beat: Option<f64>,
72    component: Option<ComponentId>,
73}
74
75impl AudioClipComponent {
76    pub fn new(uri: impl Into<String>) -> Self {
77        Self {
78            uri: uri.into(),
79            trigger_mode: AudioTriggerMode::Retrigger,
80            load_state: AudioClipLoadState::Pending,
81            source_component: None,
82            start_beat: 0.0,
83            stop_beat: None,
84            component: None,
85        }
86    }
87
88    /// Build a clip that shares another clip's decoded buffer. The URI
89    /// is copied from `source` directly — the caller already has the
90    /// authoritative value, so registration doesn't need to chase the
91    /// component graph. `source_component` is recorded as metadata
92    /// (useful for inspector / future "follow source" features).
93    pub fn instance_of(source: &AudioClipComponent) -> Self {
94        Self {
95            uri: source.uri.clone(),
96            trigger_mode: AudioTriggerMode::Retrigger,
97            load_state: AudioClipLoadState::Pending,
98            source_component: source.component,
99            start_beat: 0.0,
100            stop_beat: None,
101            component: None,
102        }
103    }
104
105    pub fn with_trigger_mode(mut self, mode: AudioTriggerMode) -> Self {
106        self.trigger_mode = mode;
107        self
108    }
109
110    pub fn with_start_beat(mut self, beat: f64) -> Self {
111        self.start_beat = beat;
112        self
113    }
114
115    pub fn with_stop_beat(mut self, beat: f64) -> Self {
116        self.stop_beat = Some(beat);
117        self
118    }
119
120    pub fn id(&self) -> Option<ComponentId> {
121        self.component
122    }
123
124    /// Best-effort existence check for the URI. Phase 5 will replace this
125    /// with a real decode request to `AudioAssets`.
126    pub fn check_uri_exists(uri: &str) -> AudioClipLoadState {
127        if uri.is_empty() {
128            return AudioClipLoadState::Failed("empty uri".into());
129        }
130        match std::fs::metadata(uri) {
131            Ok(meta) if meta.is_file() => AudioClipLoadState::Loaded,
132            Ok(_) => AudioClipLoadState::Failed(format!("not a file: {uri}")),
133            Err(e) => AudioClipLoadState::Failed(format!("{uri}: {e}")),
134        }
135    }
136}
137
138impl Component for AudioClipComponent {
139    fn set_id(&mut self, component: ComponentId) {
140        self.component = Some(component);
141    }
142
143    fn name(&self) -> &'static str {
144        "audio_clip"
145    }
146
147    fn as_any(&self) -> &dyn std::any::Any {
148        self
149    }
150
151    fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
152        self
153    }
154
155    fn init(&mut self, emit: &mut dyn crate::engine::ecs::SignalEmitter, component: ComponentId) {
156        // Phase 5: request a decode via the AudioSystem. The decode
157        // worker resolves missing files / unsupported codecs and reports
158        // back through the engine's completion channel, which updates
159        // `load_state` to `Loaded` / `Failed`.
160        emit.push_intent_now(
161            component,
162            crate::engine::ecs::IntentValue::RegisterAudioClip {
163                component_ids: vec![component],
164            },
165        );
166    }
167
168    fn to_mms_ast(
169        &self,
170        _world: &crate::engine::ecs::World,
171    ) -> crate::scripting::ast::ComponentExpression {
172        use crate::engine::ecs::component::ce_helpers::*;
173        let ctor = match self
174            .uri
175            .rsplit('.')
176            .next()
177            .map(str::to_ascii_lowercase)
178            .as_deref()
179        {
180            Some("wav") => "wav",
181            Some("opus") => "opus",
182            Some("ogg") => "ogg",
183            Some("mp3") => "mp3",
184            Some("flac") => "flac",
185            _ => "new",
186        };
187        let mut c = ce_call("AudioClip", ctor, vec![s(&self.uri)]);
188        match self.trigger_mode {
189            AudioTriggerMode::Retrigger => {}
190            AudioTriggerMode::OneShot => c = c.with_call("one_shot", vec![]),
191            AudioTriggerMode::Latched => c = c.with_call("latched", vec![]),
192        }
193        c
194    }
195}