castle-web-cli 0.4.128 → 0.4.129

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,8 @@
1
1
  // The pool of playing media elements: one `<audio>` / `<video>` per actor per
2
- // file. Behaviors (Sound, Video) ask for theirs each frame and set the state
3
- // they want on it; this module owns creating, reusing, and reaping them.
2
+ // file. Behaviors (SoundPlayer with `stream` on, Video) ask for theirs each frame
3
+ // and set the state they want on it; this module owns creating, reusing, and
4
+ // reaping them. Short sound effects don't come through here at all -- they are
5
+ // decoded buffers, in engine/sound.js.
4
6
  //
5
7
  // The pool is MODULE-level, not per-SceneRuntime, because the editor builds a
6
8
  // throwaway runtime every frame to draw the edit preview -- a per-runtime pool
@@ -15,7 +17,7 @@
15
17
  // editor's video preview draws a paused element's first frame.
16
18
 
17
19
  import { initialMedia } from './files';
18
- import { getAudioContext, resumeAudioContext } from './audioContext';
20
+ import { getAudioContext, resumeAudioContext, unlockTargets } from './audioContext';
19
21
 
20
22
  // Reaped after this long without being asked for. Long enough to survive a
21
23
  // scene's worth of frames where an actor is briefly not drawn (offscreen,
@@ -24,10 +26,6 @@ import { getAudioContext, resumeAudioContext } from './audioContext';
24
26
  const IDLE_REAP_MS = 1500;
25
27
 
26
28
  const entries = new Map();
27
- // One-shot plays: copies of an element that overlap the original instead of
28
- // cutting it off (a sound effect fired again before the last one finished).
29
- // They are not keyed by anything -- they exist until they end.
30
- const oneShots = new Set();
31
29
  // Elements routed through WebAudio for panning, and their panner nodes. An
32
30
  // element can only be connected to the graph ONCE, so this is also the record
33
31
  // of which ones already are.
@@ -63,8 +61,10 @@ function bindUnlock() {
63
61
  );
64
62
  }
65
63
  };
66
- window.addEventListener('pointerdown', retry, { passive: true });
67
- window.addEventListener('keydown', retry, { passive: true });
64
+ for (const target of unlockTargets()) {
65
+ target.addEventListener('pointerdown', retry, { passive: true });
66
+ target.addEventListener('keydown', retry, { passive: true });
67
+ }
68
68
  }
69
69
 
70
70
  // Play, tolerating the autoplay policy: a refused play is queued for the next
@@ -81,6 +81,26 @@ export function playMedia(element) {
81
81
  }
82
82
  }
83
83
 
84
+ // Retune a sound along with its speed, the way a tape does -- and the way the
85
+ // buffer path's playbackRate already does.
86
+ //
87
+ // An element does the OPPOSITE by default. `preservesPitch` starts TRUE, so it
88
+ // time-stretches to hold the pitch steady while the speed changes: right for a
89
+ // podcast at 1.5x, wrong for a game sound, and audibly a phase vocoder rather
90
+ // than a clean retune. Measured on a 110Hz tone at rate 2: 111Hz with the
91
+ // default, 220Hz with this off -- the latter matching an AudioBufferSourceNode
92
+ // exactly, which is what makes one `playbackRate` prop mean one thing across
93
+ // both of this kit's playback paths.
94
+ //
95
+ // The prefixed spellings are for browsers older than the standard property;
96
+ // assigning one that doesn't exist is harmless.
97
+ export function setMediaPlaybackRate(element, rate) {
98
+ element.preservesPitch = false;
99
+ element.webkitPreservesPitch = false;
100
+ element.mozPreservesPitch = false;
101
+ element.playbackRate = rate;
102
+ }
103
+
84
104
  // Pan a sound left/right (-1..1). The element has no panning of its own, so the
85
105
  // first non-zero pan routes it through a WebAudio graph -- permanently, since a
86
106
  // media element can only be connected once. Which is why pan 0 (the default)
@@ -107,31 +127,6 @@ export function setMediaPan(element, pan) {
107
127
  resumeAudioContext();
108
128
  }
109
129
 
110
- // Play a copy of this sound, overlapping whatever is already playing. Returns a
111
- // promise that resolves when the copy finishes, so a caller can wait for it.
112
- // Copies are never reused: a one-shot is done when it's done.
113
- export function playOneShot(path, { volume = 1, playbackRate = 1, pan = 0 } = {}) {
114
- const url = mediaUrl(path);
115
- if (!url) return Promise.resolve();
116
- const element = document.createElement('audio');
117
- element.src = url;
118
- element.volume = volume;
119
- element.playbackRate = playbackRate;
120
- oneShots.add(element);
121
- const finished = new Promise((resolve) => {
122
- const done = () => {
123
- oneShots.delete(element);
124
- element.removeAttribute('src');
125
- resolve();
126
- };
127
- element.addEventListener('ended', done, { once: true });
128
- element.addEventListener('error', done, { once: true });
129
- });
130
- if (pan !== 0) setMediaPan(element, pan);
131
- playMedia(element);
132
- return finished;
133
- }
134
-
135
130
  export function stopMedia(element) {
136
131
  blocked.delete(element);
137
132
  element.pause();
@@ -202,11 +197,6 @@ export function stopAllMedia() {
202
197
  stopMedia(entry.element);
203
198
  entry.element.removeAttribute('src');
204
199
  }
205
- for (const element of oneShots) {
206
- stopMedia(element);
207
- element.removeAttribute('src');
208
- }
209
200
  entries.clear();
210
- oneShots.clear();
211
201
  blocked.clear();
212
202
  }
@@ -0,0 +1,316 @@
1
+ // The deck's sound effects: audio files decoded into WebAudio buffers ahead of
2
+ // time, then fired as overlapping one-shots from anywhere.
3
+ //
4
+ // This is the SFX half of the kit's audio. The other half is `engine/media.js`,
5
+ // which plays an `<audio>` element -- the right thing for a long track, whose
6
+ // decoded PCM would be tens of megabytes held in memory for as long as the deck
7
+ // runs (a minute of 44.1kHz stereo is ~21MB). Short sounds go here, long ones
8
+ // stream there, and `behaviors/SoundPlayer.jsx` is where a deck chooses.
9
+ //
10
+ // Deliberately decoupled from the rest of the kit: the file map arrives through
11
+ // `initSounds` rather than being imported, and nothing here knows about actors,
12
+ // scenes or blueprints. The one kit import is the shared AudioContext, which has
13
+ // to be shared -- a second context could not mix with the element path. So
14
+ // moving this into the SDK or a package other kits can use is a file move, not a
15
+ // rewrite.
16
+ //
17
+ // A context starts SUSPENDED until the page has been interacted with, but
18
+ // decoding does not need a running one, so preloading begins at load and only
19
+ // playback waits for the unlock (see engine/audioContext.js).
20
+
21
+ import { bindAudioResume, getAudioContext, resumeAudioContext } from './audioContext';
22
+
23
+ // Generous on purpose: a voice is ~3 nodes, so this is a backstop against a
24
+ // runaway loop, not a mixing decision. Sounds stack and clip if a deck asks them
25
+ // to -- that is the deck's business.
26
+ const MAX_VOICES = 128;
27
+
28
+ // Preloaded sounds live here, in the deck's own tree or an import's (so a kit
29
+ // can ship its own effects). Everything else in the deck is still playable and
30
+ // still listed in the inspector -- this decides what is held in memory, not what
31
+ // exists. Keeping it a DIRECTORY rather than a size cutoff means the answer is
32
+ // visible in the file tree instead of buried in a constant here.
33
+ const PRELOAD_DIR = /^(?:imports\/[^/]+\/)?assets\/sounds\//;
34
+
35
+ // Where an import's files live at the deck root, mirroring castle-web-sdk's
36
+ // IMPORTS_DIR. Spelled out rather than imported so this module keeps depending on
37
+ // nothing but the shared AudioContext.
38
+ const IMPORTS_PREFIX = 'imports/';
39
+ const isImported = (path) => path.startsWith(IMPORTS_PREFIX);
40
+
41
+ // A preloaded sound longer than this is probably meant to stream. Warned about,
42
+ // never excluded: what preloads is the file tree's business, and a rule that
43
+ // silently dropped a file would be exactly the hidden internal the directory
44
+ // convention exists to avoid.
45
+ const LONG_SOUND_SECONDS = 15;
46
+
47
+ const DEV = Boolean(import.meta.env?.DEV);
48
+
49
+ let media = {};
50
+ let stems = new Map();
51
+ const buffers = new Map();
52
+ const loading = new Map();
53
+ // Insertion-ordered, which is what makes the cap's "steal the oldest" a lookup
54
+ // rather than a search.
55
+ const voices = new Set();
56
+ const preloadSet = new Set();
57
+ const warned = new Set();
58
+ let master = null;
59
+ let ready = null;
60
+ let preloaded = false;
61
+
62
+ function warnOnce(key, message) {
63
+ if (!DEV || warned.has(key)) return;
64
+ warned.add(key);
65
+ console.warn(`[sound] ${message}`);
66
+ }
67
+
68
+ function clamp(value, min, max, fallback) {
69
+ return Number.isFinite(value) ? Math.min(max, Math.max(min, value)) : fallback;
70
+ }
71
+
72
+ // Stems within one deck: `hit` for `assets/sounds/hit.wav`. A stem shared by two
73
+ // of that deck's files resolves to NEITHER -- picking one arbitrarily would make
74
+ // the winner a function of sort order, which the deck author cannot see. The full
75
+ // path always works and is how such a pair is told apart.
76
+ function uniqueStems(paths, ambiguous) {
77
+ const found = new Map();
78
+ for (const path of paths) {
79
+ const stem = path.slice(path.lastIndexOf('/') + 1).replace(/\.[^.]+$/, '');
80
+ if (found.has(stem)) ambiguous.add(stem);
81
+ else found.set(stem, path);
82
+ }
83
+ for (const stem of ambiguous) found.delete(stem);
84
+ return found;
85
+ }
86
+
87
+ // Short names for sounds, resolved deck-first: a sound the deck owns beats one of
88
+ // the same name from an import, the same precedence editors/behaviorRegistry.js
89
+ // gives a deck's behaviors over a dependency's. Without that, a kit that shipped
90
+ // its own `click.wav` would make every importing deck's `play('click')` ambiguous
91
+ // -- a kit could break decks by adding a file.
92
+ function buildStems(paths) {
93
+ const ownAmbiguous = new Set();
94
+ const importAmbiguous = new Set();
95
+ const imported = uniqueStems(paths.filter(isImported), importAmbiguous);
96
+ const own = uniqueStems(paths.filter((path) => !isImported(path)), ownAmbiguous);
97
+ const merged = new Map([...imported, ...own]);
98
+ for (const stem of ownAmbiguous) {
99
+ merged.delete(stem);
100
+ warnOnce(`stem:${stem}`, `This deck has more than one sound named "${stem}" -- play it by full path instead.`);
101
+ }
102
+ for (const stem of importAmbiguous) {
103
+ if (own.has(stem)) continue;
104
+ merged.delete(stem);
105
+ warnOnce(`stem:${stem}`, `An import has more than one sound named "${stem}" -- play it by full path instead.`);
106
+ }
107
+ return merged;
108
+ }
109
+
110
+ function resolvePath(name) {
111
+ const key = typeof name === 'string' ? name.replace(/^\.\//, '') : '';
112
+ if (media[key]) return key;
113
+ return stems.get(key) ?? null;
114
+ }
115
+
116
+ // Everything is mixed through one gain node, so a master volume and a fade on
117
+ // stop have somewhere to live later. Unity, and no compressor: a deck that
118
+ // stacks forty copies of one sample is meant to clip.
119
+ function masterGain() {
120
+ const ctx = getAudioContext();
121
+ if (!ctx) return null;
122
+ if (!master || master.context !== ctx) {
123
+ master = ctx.createGain();
124
+ master.gain.value = 1;
125
+ master.connect(ctx.destination);
126
+ }
127
+ return master;
128
+ }
129
+
130
+ async function decode(path) {
131
+ const url = media[path];
132
+ const ctx = getAudioContext();
133
+ if (!url || !ctx) return null;
134
+ try {
135
+ const response = await fetch(url);
136
+ const buffer = await ctx.decodeAudioData(await response.arrayBuffer());
137
+ buffers.set(path, buffer);
138
+ return buffer;
139
+ } catch (error) {
140
+ warnOnce(`decode:${path}`, `Could not decode ${path}: ${error?.message ?? error}`);
141
+ return null;
142
+ }
143
+ }
144
+
145
+ // One decode per file, however many callers ask at once -- two `play`s of an
146
+ // unloaded sound in the same frame must not decode it twice.
147
+ function load(path) {
148
+ if (buffers.has(path)) return Promise.resolve(buffers.get(path));
149
+ const inFlight = loading.get(path);
150
+ if (inFlight) return inFlight;
151
+ const started = decode(path).finally(() => loading.delete(path));
152
+ loading.set(path, started);
153
+ return started;
154
+ }
155
+
156
+ async function preload(path) {
157
+ const buffer = await load(path);
158
+ if (buffer && buffer.duration > LONG_SOUND_SECONDS) {
159
+ warnOnce(
160
+ `long:${path}`,
161
+ `${path} is ${Math.round(buffer.duration)}s and is held decoded in memory. ` +
162
+ `Long tracks belong outside assets/sounds/, played by a SoundPlayer with stream on.`
163
+ );
164
+ }
165
+ }
166
+
167
+ /** Whether a deck path is one this library preloads. */
168
+ export function isPreloadedPath(path) {
169
+ return PRELOAD_DIR.test(String(path ?? ''));
170
+ }
171
+
172
+ /**
173
+ * Point the library at the deck's audio files and start preloading.
174
+ *
175
+ * @param audioFiles path -> URL, every audio file in the deck (see
176
+ * engine/files.js -- URLs, not paths, so this keeps working once the deck is
177
+ * bundled into a single file and the assets are `data:` URIs).
178
+ */
179
+ export function initSounds(audioFiles) {
180
+ media = { ...audioFiles };
181
+ stems = buildStems(Object.keys(media));
182
+ preloadSet.clear();
183
+ for (const path of Object.keys(media)) {
184
+ if (PRELOAD_DIR.test(path)) preloadSet.add(path);
185
+ }
186
+ // No sounds to preload means no AudioContext either: a deck that makes no
187
+ // sound should not build an audio graph just by loading the kit.
188
+ if (preloadSet.size === 0) {
189
+ preloaded = true;
190
+ ready = Promise.resolve();
191
+ return ready;
192
+ }
193
+ ready = Promise.all([...preloadSet].map(preload)).then(() => {
194
+ preloaded = true;
195
+ });
196
+ return ready;
197
+ }
198
+
199
+ function stopVoice(voice) {
200
+ voices.delete(voice);
201
+ try {
202
+ voice.source.stop();
203
+ } catch {
204
+ // Already ended -- `ended` has fired or the source never started.
205
+ }
206
+ }
207
+
208
+ function startVoice(buffer, { volume, pan, playbackRate }) {
209
+ const ctx = getAudioContext();
210
+ const out = masterGain();
211
+ if (!ctx || !out) return null;
212
+ // The context starts suspended until the page has been interacted with. This
213
+ // asks now, and binds a listener so the next gesture asks again -- a deck with
214
+ // no streaming sound has nothing else that would.
215
+ bindAudioResume();
216
+ resumeAudioContext();
217
+ if (voices.size >= MAX_VOICES) stopVoice(voices.values().next().value);
218
+
219
+ const source = ctx.createBufferSource();
220
+ source.buffer = buffer;
221
+ source.playbackRate.value = clamp(playbackRate, 0.01, 10, 1);
222
+ const gain = ctx.createGain();
223
+ gain.gain.value = clamp(volume, 0, 1, 1);
224
+ source.connect(gain);
225
+ let tail = gain;
226
+ const wanted = clamp(pan, -1, 1, 0);
227
+ if (wanted !== 0 && ctx.createStereoPanner) {
228
+ const panner = ctx.createStereoPanner();
229
+ panner.pan.value = wanted;
230
+ gain.connect(panner);
231
+ tail = panner;
232
+ }
233
+ tail.connect(out);
234
+
235
+ const voice = { source };
236
+ voices.add(voice);
237
+ const finished = new Promise((resolve) => {
238
+ source.addEventListener('ended', () => {
239
+ voices.delete(voice);
240
+ resolve();
241
+ });
242
+ });
243
+ source.start();
244
+ return {
245
+ stop() {
246
+ stopVoice(voice);
247
+ },
248
+ finished,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Play a sound, overlapping whatever else is playing.
254
+ *
255
+ * `name` is a deck-relative path (`assets/sounds/hit.wav`) or the bare filename
256
+ * without its extension (`hit`).
257
+ *
258
+ * Returns `{ stop, finished }`, or null when nothing played -- an unknown name,
259
+ * or a sound whose buffer is not decoded yet. A play that lands before its sound
260
+ * is ready is DROPPED rather than deferred: a sound effect that arrives late is
261
+ * worse than one that never arrives, and deferring it would fire it at a moment
262
+ * that has already passed.
263
+ */
264
+ export function playSound(name, { volume = 1, pan = 0, playbackRate = 1 } = {}) {
265
+ const path = resolvePath(name);
266
+ if (!path) {
267
+ warnOnce(`unknown:${name}`, `No sound named "${name}".`);
268
+ return null;
269
+ }
270
+ const buffer = buffers.get(path);
271
+ if (buffer) return startVoice(buffer, { volume, pan, playbackRate });
272
+ // Not decoded. Start it now so the NEXT play works -- which for a sound
273
+ // outside assets/sounds/ is what makes the first play the only silent one.
274
+ void load(path);
275
+ if (!preloadSet.has(path)) {
276
+ warnOnce(
277
+ `cold:${path}`,
278
+ `${path} is not preloaded, so this first play is silent. ` +
279
+ `Move it under assets/sounds/ to have it ready before the deck starts.`
280
+ );
281
+ }
282
+ return null;
283
+ }
284
+
285
+ /** Every sound effect, off. Long tracks are the element path's to stop. */
286
+ export function stopAllSounds() {
287
+ for (const voice of [...voices]) stopVoice(voice);
288
+ voices.clear();
289
+ }
290
+
291
+ // The deck-facing library, also reachable as `scene.sound` (see systems/media.js)
292
+ // so a behavior needs no import to make a noise.
293
+ export const Sound = {
294
+ /** True once every preloaded sound has been decoded (or failed to). */
295
+ get isLoaded() {
296
+ return preloaded;
297
+ },
298
+ /** Resolves when preloading settles, so a caller need not poll `isLoaded`. */
299
+ get whenLoaded() {
300
+ return ready ?? Promise.resolve();
301
+ },
302
+ /** Whether a name resolves to a sound this deck has. */
303
+ has(name) {
304
+ return resolvePath(name) !== null;
305
+ },
306
+ /**
307
+ * How many voices are sounding right now. Worth watching if effects seem to
308
+ * cut each other off: at MAX_VOICES the oldest is stolen to make room, and
309
+ * nothing else says so.
310
+ */
311
+ get voiceCount() {
312
+ return voices.size;
313
+ },
314
+ play: playSound,
315
+ stopAll: stopAllSounds,
316
+ };