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