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.
- package/kits/physics-2d/CLAUDE.md +31 -13
- package/kits/physics-2d/behaviors/Joints.jsx +4 -3
- package/kits/physics-2d/behaviors/SoundPlayer.jsx +266 -0
- package/kits/physics-2d/behaviors/Sprite.jsx +3 -3
- package/kits/physics-2d/behaviors/Video.jsx +4 -3
- package/kits/physics-2d/castle.json +1 -1
- package/kits/physics-2d/engine/audioContext.js +41 -0
- package/kits/physics-2d/engine/autoInspector.jsx +50 -33
- package/kits/physics-2d/engine/media.js +29 -39
- package/kits/physics-2d/engine/sound.js +316 -0
- package/kits/physics-2d/engine/ui.jsx +272 -3
- package/kits/physics-2d/engine/ui.module.css +95 -16
- package/kits/physics-2d/scripts/testsounds.mjs +84 -0
- package/kits/physics-2d/systems/media.js +31 -15
- package/package.json +1 -1
- package/kits/physics-2d/behaviors/Sound.jsx +0 -163
- package/kits/physics-2d/behaviors/Tone.jsx +0 -95
- package/kits/physics-2d/engine/tone.js +0 -112
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// The pool of playing media elements: one `<audio>` / `<video>` per actor per
|
|
2
|
-
// file. Behaviors (
|
|
3
|
-
// they want on it; this module owns creating, reusing, and
|
|
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
|
-
|
|
67
|
-
|
|
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
|
+
};
|