mittens-engine 0.7.0

A Vulkan and OpenXR scene engine with ECS, reactive signals, and Meow Meow scripting
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
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
# Audio Sources (。♥‿♥。)

Status: Spec draft; Action-based triggering sections are superseded by the 0.7 executable
keyframe model.
Date: 2026-05-16

Current timed authoring calls `MusicNote.*(..., source_handle)` or live source methods inside
`Keyframe.at(...) { ... }`. Any `ActionComponent`, `Action { ... }`, or `Action.*` examples below
are retained only as historical design context.

Unified vocabulary for sound-producing nodes in the audio graph. Covers
oscillators and PCM clips under one umbrella term so triggering, scheduling,
and graph wiring can be described once.

Supersedes the source-naming portion of:

- [docs/draft/audio_decoding_thread.md]../draft/audio_decoding_thread.md
- [docs/task/audio-clip-terminology-and-effect-consolidation.md]../task/audio-clip-terminology-and-effect-consolidation.md

---

## 1. Terminology

| Term | Meaning | Layer |
|---|---|---|
| `AudioSource` | Umbrella: any playable sound-producing node | architecture |
| `AudioOscillator` | Procedural synthesized source | ECS + MMS |
| `AudioClip` | PCM-backed playable source | ECS + MMS |
| `AudioOutput` | Sink / root output node | ECS + MMS |
| `AudioEffect` | Signal-processing node in the graph | ECS + MMS |
| `AudioClipAsset` | Decoded PCM data in `AudioAssets` registry | engine-internal |
| `MusicNote` | Trigger payload: pitch / velocity / duration | engine + animation |

Rule: avoid "sample" as a public term. "Sample" means a single PCM value or a
verb, never an authored node.

---

## 2. Source variants

| Variant | Component | Backing data | Notes |
|---|---|---|---|
| Oscillator | `AudioOscillatorComponent` | none (synthesized) | gate-driven |
| Clip | `AudioClipComponent` | `AudioClipAsset` in `AudioAssets` | cursor-driven |

Both connect to an `AudioOutputComponent` ancestor to be included in the
compiled DSP graph. Detached sources stay loaded but silent.

---

## 3. Unified trigger intent

One intent triggers any `AudioSource`:

```rust
IntentValue::AudioSchedulePlay {
    component_ids: Vec<ComponentId>,
    beat_offset: f64,
    beat_context: Option<f64>, // set by AnimationSystem during lookahead
    note: Option<MusicNote>,   // pitch/velocity/duration when meaningful
    gain: Option<f32>,         // generic playback gain
    rate: Option<f32>,         // generic playback rate (clips)
    duration: Option<f64>,     // generic stop-after; overrides note.duration
}
```

Naming rationale: "play" is source-agnostic. "note" is oscillator-flavored.
`MusicNote` remains the payload shape when pitch/velocity/duration apply.

Deprecated (oscillator-specific, to be folded in):

- `OscillatorScheduleSetNote`
- `OscillatorScheduleMusicNote`

---

## 4. Per-variant trigger semantics

| Field | Oscillator | Clip |
|---|---|---|
| `note.pitch` | sets oscillator frequency | maps to playback rate (resample) |
| `note.velocity` | sets gain | scales gain |
| `note.duration` | gate ON, then gate OFF after duration | stop playback after duration |
| `gain` (generic) | overrides velocity gain | overrides velocity gain |
| `rate` (generic) | ignored (use pitch) | playback rate; overrides note.pitch mapping |
| `duration` (generic) | overrides note.duration | overrides note.duration |
| no `note`, no fields | gate ON indefinitely | play from cursor=0 to end |

Trigger always resets clip cursor to 0 unless `AudioTriggerMode` says
otherwise (see clip component).

---

## 5. ECS / runtime boundary

Authored components and compiled runtime nodes are allowed to diverge.

| Authored (ECS / MMS)       | Compiled (RT / DSP) |
|---|---|
| `AudioOutputComponent`     | output sink node |
| `AudioOscillatorComponent` | oscillator DSP node |
| `AudioClipComponent`       | `AudioClipPlayer` (cursor + asset ref) |
| `AudioEffectComponent`     | per-kind internal effect node |

`AudioGraphCompiler` owns the lowering. Authored components are scene
vocabulary; runtime structs are chosen for DSP efficiency.

---

## 6. Trigger payload vs config component (。•̀ᴗ-)✧

`MusicNote` (the struct) is doing two unrelated jobs today. The spec separates them:

| Role | Carrier | Lifetime | Read by |
|---|---|---|---|
| Trigger payload | `IntentValue::AudioSchedulePlay.note` | per-fire | source at trigger time |
| Default tuning | `AudioOscillatorComponent.frequency` (or similar field) | persistent | DSP at compile/init |

### 6.1 How animation triggers audio (today)

```text
KeyframeComponent
  └── ActionComponent { signal: IntentValue::OscillatorScheduleMusicNote { note, ... } }
                                              MusicNote lives inline here ┘
```

The action carries the note. The keyframe owns the action. `AnimationSystem`
emits the intent at the keyframe's beat (with lookahead for audio). Nothing
in this path reads `MusicNoteComponent`.

### 6.2 What `MusicNoteComponent` actually does

`MusicSystem::apply_music_note_to_oscillator` walks an oscillator's subtree,
finds the first `MusicNoteComponent`, converts the note to Hz, and writes it
into the oscillator's `frequency` field once (gated by `music_note_applied`).

That is a one-shot **initial-tuning setter**, not a trigger. It is also not
parallel to `ActionComponent` — actions trigger over time, this just sets a
default at init.

### 6.3 Recommendation: `MusicNoteComponent` stays, semantics change

`MusicNoteComponent` keeps its component status but stops being an
init-time tuning setter. New role: authored trigger that emits
`AudioSchedulePlay`, with its target resolved by the enclosing
`MusicContext` (or explicit constructor ref, or topology — see §6.6).

Conceptual mapping:

```text
old: MusicNoteComponent → MusicSystem::apply_music_note_to_oscillator
                          → mutates AudioOscillatorComponent.frequency at init

new: MusicNoteComponent → has .play() method + optional play_on_attach trigger
                          → emits IntentValue::AudioSchedulePlay { note, targets }
                          → targets resolved per §6.6 precedence
```

Why keep it as a component:

- the MMS surface is already `MusicNote.C(4, 1.0)` and authors expect a
  component-shaped thing they can attach, inspect, address, animate
- `.play()` / `play_on_attach()` / pre-authored `target(...)` config all
  need a stable place to live — a component is that place
- `MusicContext` resolution needs to find notes by walking the tree; that
  only works if notes are components
- editor/inspector already enumerates components

What changes:

- `MusicSystem::apply_music_note_to_oscillator` is **removed** — no more
  subtree walk, no more `music_note_applied` flag, no more silent
  retuning of `AudioOscillatorComponent.frequency`
- `MusicNoteComponent` gains a `target` field (resolved component ref),
  a `scheduled_beat` field (optional), and a `play_on_attach` flag
- `Component::init` for `MusicNoteComponent` checks `play_on_attach` and
  emits `AudioSchedulePlay` if set
- a new `.play(beat?, source?)` method on the component binding emits
  `AudioSchedulePlay` on demand (per §6.4)

`AudioSchedulePlay.note` is the only `MusicNote` value carrier at runtime.
The two-roles-same-name problem from §6.2 is resolved by removing the
init-time tuning role; the component's only job now is "trigger a note".

### 6.4 Firing model

Three ways an authored `MusicNote` can fire:

| Mode | MMS shape | Fires when |
|---|---|---|
| Play on attach | `MusicNote.C(4, 1.0) { play_on_attach() }` | source attaches to graph |
| Manual | `let note = MusicNote.C(4, 1.0); ...; note.play()` | `.play()` is called |
| Scheduled | `note.play(beat)` (optional `f64`) | transport reaches `beat` |

Default is **silent** — no `play_on_attach`, no `.play()` call → nothing
fires. This matches the "no parent keyframe, no implicit firing" rule and
keeps authored intent explicit.

#### `.play()` signature

```text
note.play(scheduled_beat?: f64, audio_source?: SourceRef)
```

Both params are optional and may be supplied at the call site **or**
pre-authored on the component at registration / definition time (see
§6.4.1). Call-site values override pre-authored defaults.

Full resolution precedence (covers both params, plus context/topology
fallbacks for `audio_source`): see §6.6.

#### 6.4.1 Pre-authored defaults

A `MusicNote` can carry its target and scheduled beat as part of its
authored definition, not just at the `.play()` call:

```mms
let kick = MusicNote.C(2, 0.5) {
    target("bass")              // pre-bound voice (resolves via §6.6)
    at_beat(transport.next_bar()) // pre-scheduled default
    play_on_attach()             // optional firing trigger
}

kick.play();                    // uses target=bass, beat=next_bar
kick.play(transport.beat()+2);  // overrides beat only
kick.play(8.0, lead_osc);       // overrides both
```

This matches the broader MMS pattern: builder-body sets persistent
config, methods on the live binding override per-call.

Desugaring sketch:

```text
MusicNote.C(4, 1.0) { target("lead") play_on_attach() }
  ↓
Action {
    trigger = OnAttach
    signal  = AudioSchedulePlay { targets = [<lead from context>], note = C4 }
}

note.play()  // pre-authored target="lead", at_beat=t0
  ↓
emit IntentValue::AudioSchedulePlay {
    targets      = [<lead>],
    note,
    beat_context = Some(t0),
    beat_offset  = 0,
}

note.play(beat, source)  // both overridden
  ↓
emit IntentValue::AudioSchedulePlay {
    targets      = [source],
    note,
    beat_context = Some(beat),
    beat_offset  = 0,
}
```

`beat_context` is the absolute beat — same field `AnimationSystem`
already sets during keyframe lookahead.

### 6.5 Transport access from MMS

For `note.play(beat)` to be useful, MMS needs to read the transport's
current bar/beat so authors can write things like "play on the next
downbeat" or "play 2 beats from now".

Proposed surface (names provisional):

```mms
transport.beat()          // current global beat, f64
transport.bar()           // current bar index, integer
transport.beats_per_bar() // time signature numerator
transport.bpm()           // tempo
transport.next_bar()      // beat number of next bar start
transport.next_beat()     // beat number of next whole beat
```

Backed by the existing audio/clock transport (see `audio_transport` /
`Clock` systems). Read-only from MMS for now; transport mutation stays in
engine-driven systems.

Example:

```mms
let kick = MusicNote.C(2, 0.5);
kick.play(transport.next_bar());   // schedule on next downbeat
kick.play(transport.beat() + 4.0); // schedule 4 beats from now
```

Open sub-question: does MMS access transport as a global (`transport.x()`)
or via an injected handle bound at scene load? Global is simpler;
injected handle is friendlier to multi-transport setups if those ever
exist.

### 6.6 Associating a note with an `AudioSource``MusicContext` wrapper

Rule: **a `MusicNote` must always resolve to a source**. There is no such
thing as a free-floating note. The question is just where the binding
lives.

Primary mechanism is a **`MusicContext`** wrapper that declares the
available sources for any scope containing notes. Bare construction with
an explicit source ref is the escape hatch.

#### a) `MusicContext` — voices/tracks model

```mms
MusicContext {
    voices {
        bass  = bass_osc
        lead  = lead_osc
        kick  = kick_clip
    }

    Animation {
        Keyframe(0.0) { Action { signal = MusicNote.C(2, 0.5, "bass") } }
        Keyframe(0.5) { Action { signal = MusicNote.E(4, 0.25, "lead") } }
        Keyframe(1.0) { Action { signal = MusicNote.C(2, 0.5, "kick") } }
    }
}
```

Resolution rules inside a `MusicContext`:

| Note construction | Resolves to |
|---|---|
| `MusicNote.C(4, 1.0)` | voice `0` (first declared) |
| `MusicNote.C(4, 1.0, "lead")` | named voice from `voices {}` |
| `MusicNote.C(4, 1.0, 2)` | voice index `2` (shorthand) |
| `MusicNote.C(4, 1.0, some_ref)` | explicit component ref (overrides context) |

Named refs are preferred over integer indices — reordering `voices {}`
won't silently reassign every note. Integer is shorthand for quick
prototyping.

The wrapper isn't `Animation`-specific. It wraps **any keyframe- or
note-bearing scope**, including manual `note.play()` chains. A
`MusicContext` may contain animations, raw actions, or `let`-bound notes
played later by other code.

#### b) Inline child of source (no context)

```mms
AudioOscillator {
    MusicNote.C(4, 1.0) { play_on_attach() }
}
```

Still works. Desugar walks up: nearest `AudioSource` ancestor wins, no
context needed. This is the "one sound, one source" shortcut.

#### c) Bare construction with explicit ref (no context)

```mms
let note = MusicNote.C(4, 1.0, my_osc);
note.play();
```

Third argument is required when there's no enclosing `MusicContext` and
no source ancestor. Compile error otherwise.

#### Resolution precedence

Unified for both `audio_source` and `scheduled_beat`. Call-site always
wins; whatever shows up in `.play(...)` overrides construction.

| Rank | Source of value | Example |
|---|---|---|
| 1 | `.play(...)` call-site arg | `note.play(8.0, lead_osc)` |
| 2 | Pre-authored default in note body | `MusicNote.C(...) { target("bass") at_beat(4.0) }` |
| 3 | Constructor positional arg | `MusicNote.C(4, 1.0, "bass")` |
| 4 | Enclosing `MusicContext` voice 0 (source only) | first entry in `voices {}` |
| 5 | Nearest `AudioSource` ancestor (source only) | parent topology |
| 6 | For `audio_source`: compile error<br>For `scheduled_beat`: fire immediately (`beat_offset = 0`) ||

Notes:

- ranks 2 and 3 are both construction-time. Using both for the same
  field is a conflict — compile error. Pick one style per note.
- ranks 4 and 5 apply only to `audio_source`. `scheduled_beat` has no
  topological default; absence just means "now".
- ranks 1 and 2 always override 3, 4, 5. There is no "merge" — higher
  rank fully replaces lower.

#### Error case

```mms
let bare = MusicNote.C(4, 1.0);  // no context, no ancestor, no ref
bare.play();                     // compile error: unresolved voice
```

#### Fan-out

`AudioSchedulePlay.targets` stays `Vec<ComponentId>`. Authoring sugar for
fan-out can come later (`MusicNote.C(4, 1.0, ["bass", "lead"])` or a
voice group declared in the context).

#### Why a context wrapper

- matches how musicians/composers already think (tracks/voices)
- removes per-note source plumbing in the common case
- one place to swap instruments (change the voice binding, every note
  follows)
- inspector/editor has a natural unit to show: "this context's voices"
- `let`-bound notes work without `.on()` ceremony

### 6.4 Payload shape lock for `AudioSchedulePlay`

Given §6.3, the intent payload stays hybrid as drafted in §3:

- `note: Option<MusicNote>` when pitch/velocity/duration semantics apply
- generic `gain` / `rate` / `duration` for source-agnostic playback overrides

No `MusicNoteComponent` lookup is involved in trigger dispatch.

---

## 7. Remaining open items (deferred)

- `AudioEffectComponent` consolidation — see terminology task doc §6
- loudness normalization policy — see decode/convert/normalize split task doc
- MMS sugar shape for oscillator default tuning (post §6.3)

---

## 8. Related docs

- [docs/draft/audio_decoding_thread.md]../draft/audio_decoding_thread.md — decode thread + AudioAssets
- [docs/task/audio-clip-terminology-and-effect-consolidation.md]../task/audio-clip-terminology-and-effect-consolidation.md — terminology + effect consolidation
- [docs/task/audio-decode-convert-normalize-split.md]../task/audio-decode-convert-normalize-split.md — decode/convert/normalize layering
- [MMS signal guide]../how_to/guide/signals.md — intent/event model and current MMS exposure