@needle-tools/engine 4.16.9 → 4.16.11
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/CHANGELOG.md +7 -0
- package/SKILL.md +4 -1
- package/dist/{needle-engine.bundle-Cay176T9.umd.cjs → needle-engine.bundle-DOChN3At.umd.cjs} +103 -103
- package/dist/{needle-engine.bundle-KPYQq4Ry.js → needle-engine.bundle-Du3vf5L1.js} +3638 -3551
- package/dist/{needle-engine.bundle-D02SHqiW.min.js → needle-engine.bundle-fP5tOMI1.min.js} +104 -104
- package/dist/needle-engine.d.ts +49 -2
- package/dist/needle-engine.js +2 -2
- package/dist/needle-engine.min.js +1 -1
- package/dist/needle-engine.umd.cjs +1 -1
- package/lib/engine/debug/debug_spector.js +1 -1
- package/lib/engine/debug/debug_spector.js.map +1 -1
- package/lib/engine/engine_audio.d.ts +30 -1
- package/lib/engine/engine_audio.js +57 -13
- package/lib/engine/engine_audio.js.map +1 -1
- package/lib/engine/engine_context_registry.js +1 -1
- package/lib/engine/engine_context_registry.js.map +1 -1
- package/lib/engine/engine_shims.js +1 -1
- package/lib/engine/engine_shims.js.map +1 -1
- package/lib/engine-components/AudioSource.d.ts +49 -2
- package/lib/engine-components/AudioSource.js +261 -66
- package/lib/engine-components/AudioSource.js.map +1 -1
- package/lib/engine-components/CameraUtils.js +1 -1
- package/lib/engine-components/CameraUtils.js.map +1 -1
- package/package.json +1 -1
- package/src/engine/debug/debug_spector.ts +1 -1
- package/src/engine/engine_audio.ts +68 -15
- package/src/engine/engine_context_registry.ts +1 -1
- package/src/engine/engine_shims.ts +1 -1
- package/src/engine-components/AudioSource.ts +244 -63
- package/src/engine-components/CameraUtils.ts +1 -1
|
@@ -179,10 +179,19 @@ export declare class AudioSource extends Behaviour {
|
|
|
179
179
|
private sound;
|
|
180
180
|
private helper;
|
|
181
181
|
private wasPlaying;
|
|
182
|
-
private audioLoader;
|
|
183
182
|
private shouldPlay;
|
|
184
|
-
private
|
|
183
|
+
private _loadedClip;
|
|
185
184
|
private _audioElement;
|
|
185
|
+
/**
|
|
186
|
+
* True when {@link _audioElement} is a file-URL element routed through
|
|
187
|
+
* `sound.setMediaElementSource()` — the iOS-survivable string-clip path. For this path ALL
|
|
188
|
+
* playback control (play/pause/stop/loop/pitch/time/isPlaying) goes through the element,
|
|
189
|
+
* because three.js sets `hasPlaybackControl = false` once a media source is attached.
|
|
190
|
+
* It is `false` for the distinct MediaStream path.
|
|
191
|
+
*/
|
|
192
|
+
private _usesMediaElementSource;
|
|
193
|
+
/** Dedicated gain between the media source and the panner, used for click-free pause/resume fades. */
|
|
194
|
+
private _fadeNode;
|
|
186
195
|
/**
|
|
187
196
|
* Returns the underlying {@link PositionalAudio} object, creating it if necessary.
|
|
188
197
|
* The audio source needs a user interaction to be initialized due to browser autoplay policies.
|
|
@@ -203,6 +212,12 @@ export declare class AudioSource extends Behaviour {
|
|
|
203
212
|
* @returns The {@link AudioContext} or null if not available
|
|
204
213
|
*/
|
|
205
214
|
get audioContext(): AudioContext | undefined;
|
|
215
|
+
/**
|
|
216
|
+
* Resumes the shared AudioContext if it is currently suspended/interrupted and the page is
|
|
217
|
+
* visible. No-op when already running or when resuming is not allowed (resuming a hidden
|
|
218
|
+
* context throws iOS `InvalidStateError: Failed to start the audio device`).
|
|
219
|
+
*/
|
|
220
|
+
private ensureContextResumed;
|
|
206
221
|
/** @internal */
|
|
207
222
|
awake(): void;
|
|
208
223
|
/** @internal */
|
|
@@ -211,6 +226,38 @@ export declare class AudioSource extends Behaviour {
|
|
|
211
226
|
onDisable(): void;
|
|
212
227
|
private onVisibilityChanged;
|
|
213
228
|
private onApplicationMuteChanged;
|
|
229
|
+
/**
|
|
230
|
+
* Sets up playback of a file-URL string clip through an `HTMLAudioElement` routed into the Web
|
|
231
|
+
* Audio graph via `sound.setMediaElementSource()`.
|
|
232
|
+
*
|
|
233
|
+
* Why a media element instead of a decoded `AudioBuffer`: on iOS Safari an
|
|
234
|
+
* `AudioBufferSourceNode`'s output is torn down after a device lock / audio-session interruption
|
|
235
|
+
* and cannot be reliably revived. A `MediaElementAudioSourceNode` survives because the playing
|
|
236
|
+
* `<audio>` element keeps iOS's media session alive, so the shared AudioContext resumes cleanly
|
|
237
|
+
* on unlock. This mirrors three.js's `webaudio_orientation` example.
|
|
238
|
+
*
|
|
239
|
+
* Does NOT start playback — it only creates/wires the element so a later `.play()` is cheap.
|
|
240
|
+
*/
|
|
241
|
+
private createAudioFromElement;
|
|
242
|
+
/**
|
|
243
|
+
* Inserts a dedicated gain node between the media source and the panner so pause/resume can fade
|
|
244
|
+
* click-free without touching `sound.gain` (which carries volume/mute). three.js connects the
|
|
245
|
+
* source to `getOutput()` inside `setMediaElementSource()`, so we override it to feed our fade
|
|
246
|
+
* node, then route fade → panner (→ gain → listener, both already wired by PositionalAudio).
|
|
247
|
+
*/
|
|
248
|
+
private setupFadeNode;
|
|
249
|
+
/**
|
|
250
|
+
* Smoothly ramp the fade gain to `target` (1 = audible, 0 = silent) over a few ms. A short fade —
|
|
251
|
+
* not a hard 0/1 gain change and not a connect/disconnect — avoids the click on pause/resume. On
|
|
252
|
+
* iOS the MediaElementAudioSourceNode keeps feeding the graph after element.pause() following an
|
|
253
|
+
* interruption, so fading this gain to 0 is what actually silences a "paused" media clip.
|
|
254
|
+
*/
|
|
255
|
+
private fadeGain;
|
|
256
|
+
/**
|
|
257
|
+
* Sets up the {@link PositionalAudio} graph for a MediaStream clip (file-URL string clips go
|
|
258
|
+
* through {@link createAudioFromElement}). The stream node itself is attached in {@link play}
|
|
259
|
+
* via `setMediaStreamSource`.
|
|
260
|
+
*/
|
|
214
261
|
private createAudio;
|
|
215
262
|
private __onAllowAudioCallback;
|
|
216
263
|
private applySpatialDistanceSettings;
|
|
@@ -4,7 +4,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
|
|
|
4
4
|
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
5
5
|
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
6
6
|
};
|
|
7
|
-
import {
|
|
7
|
+
import { PositionalAudio } from "three";
|
|
8
8
|
import { PositionalAudioHelper } from 'three/examples/jsm/helpers/PositionalAudioHelper.js';
|
|
9
9
|
import { isDevEnvironment } from "../engine/debug/index.js";
|
|
10
10
|
import { Application, ApplicationEvents } from "../engine/engine_application.js";
|
|
@@ -125,13 +125,24 @@ export class AudioSource extends Behaviour {
|
|
|
125
125
|
*
|
|
126
126
|
* @returns True if the audio is playing, false otherwise
|
|
127
127
|
*/
|
|
128
|
-
get isPlaying() {
|
|
128
|
+
get isPlaying() {
|
|
129
|
+
// For the media-element path (file-URL string clips) three.js does NOT maintain
|
|
130
|
+
// sound.isPlaying (hasPlaybackControl is false once a media source is set), so the
|
|
131
|
+
// <audio> element is the source of truth.
|
|
132
|
+
if (this._usesMediaElementSource && this._audioElement)
|
|
133
|
+
return !this._audioElement.paused && !this._audioElement.ended;
|
|
134
|
+
return this.sound?.isPlaying ?? false;
|
|
135
|
+
}
|
|
129
136
|
/**
|
|
130
137
|
* The total duration of the current audio clip in seconds.
|
|
131
138
|
*
|
|
132
139
|
* @returns Duration in seconds or undefined if no clip is loaded
|
|
133
140
|
*/
|
|
134
141
|
get duration() {
|
|
142
|
+
// Media-element path is the primary source of truth for file-URL string clips.
|
|
143
|
+
const elDuration = this._usesMediaElementSource ? this._audioElement?.duration : undefined;
|
|
144
|
+
if (elDuration && isFinite(elDuration))
|
|
145
|
+
return elDuration;
|
|
135
146
|
return this.sound?.buffer?.duration;
|
|
136
147
|
}
|
|
137
148
|
/**
|
|
@@ -140,14 +151,14 @@ export class AudioSource extends Behaviour {
|
|
|
140
151
|
*/
|
|
141
152
|
get time01() {
|
|
142
153
|
const duration = this.duration;
|
|
143
|
-
if (duration
|
|
144
|
-
return this.
|
|
154
|
+
if (duration) {
|
|
155
|
+
return this.time / duration;
|
|
145
156
|
}
|
|
146
157
|
return 0;
|
|
147
158
|
}
|
|
148
159
|
set time01(val) {
|
|
149
160
|
const duration = this.duration;
|
|
150
|
-
if (duration
|
|
161
|
+
if (duration) {
|
|
151
162
|
this.time = val * duration;
|
|
152
163
|
}
|
|
153
164
|
}
|
|
@@ -155,8 +166,18 @@ export class AudioSource extends Behaviour {
|
|
|
155
166
|
* The current playback position in seconds.
|
|
156
167
|
* Can be set to seek to a specific time in the audio.
|
|
157
168
|
*/
|
|
158
|
-
get time() {
|
|
169
|
+
get time() {
|
|
170
|
+
// Media-element path: the element's currentTime is authoritative.
|
|
171
|
+
if (this._usesMediaElementSource && this._audioElement)
|
|
172
|
+
return this._audioElement.currentTime;
|
|
173
|
+
return this.sound?.source ? (this.sound.source?.context.currentTime - this._lastContextTime + this.sound.offset) : 0;
|
|
174
|
+
}
|
|
159
175
|
set time(val) {
|
|
176
|
+
// Media-element path: seek directly on the element (sound.offset is not used for media nodes).
|
|
177
|
+
if (this._usesMediaElementSource && this._audioElement) {
|
|
178
|
+
this._audioElement.currentTime = val;
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
160
181
|
if (this.sound) {
|
|
161
182
|
if (val === this.sound.offset)
|
|
162
183
|
return;
|
|
@@ -173,13 +194,20 @@ export class AudioSource extends Behaviour {
|
|
|
173
194
|
* @default false
|
|
174
195
|
*/
|
|
175
196
|
get loop() {
|
|
197
|
+
// Media-element path: the element holds the authoritative loop flag.
|
|
198
|
+
if (this._usesMediaElementSource && this._audioElement) {
|
|
199
|
+
this._loop = this._audioElement.loop;
|
|
200
|
+
return this._loop;
|
|
201
|
+
}
|
|
176
202
|
if (this.sound)
|
|
177
203
|
this._loop = this.sound.getLoop();
|
|
178
204
|
return this._loop;
|
|
179
205
|
}
|
|
180
206
|
set loop(val) {
|
|
181
207
|
this._loop = val;
|
|
182
|
-
if (this.
|
|
208
|
+
if (this._usesMediaElementSource && this._audioElement)
|
|
209
|
+
this._audioElement.loop = val;
|
|
210
|
+
else if (this.sound)
|
|
183
211
|
this.sound.setLoop(val);
|
|
184
212
|
}
|
|
185
213
|
/**
|
|
@@ -247,10 +275,16 @@ export class AudioSource extends Behaviour {
|
|
|
247
275
|
* @default 1
|
|
248
276
|
*/
|
|
249
277
|
set pitch(val) {
|
|
250
|
-
|
|
278
|
+
// Media-element path: playbackRate on the element (sound.setPlaybackRate is a no-op once
|
|
279
|
+
// hasPlaybackControl is false).
|
|
280
|
+
if (this._usesMediaElementSource && this._audioElement)
|
|
281
|
+
this._audioElement.playbackRate = val;
|
|
282
|
+
else if (this.sound)
|
|
251
283
|
this.sound.setPlaybackRate(val);
|
|
252
284
|
}
|
|
253
285
|
get pitch() {
|
|
286
|
+
if (this._usesMediaElementSource && this._audioElement)
|
|
287
|
+
return this._audioElement.playbackRate;
|
|
254
288
|
return this.sound ? this.sound.getPlaybackRate() : 1;
|
|
255
289
|
}
|
|
256
290
|
/**
|
|
@@ -263,12 +297,21 @@ export class AudioSource extends Behaviour {
|
|
|
263
297
|
sound = null;
|
|
264
298
|
helper = null;
|
|
265
299
|
wasPlaying = false;
|
|
266
|
-
audioLoader = null;
|
|
267
300
|
shouldPlay = false;
|
|
268
301
|
// set this from audio context time, used to set clip offset when setting "time" property
|
|
269
302
|
// there is maybe a better way to set a audio clip current time?!
|
|
270
|
-
|
|
303
|
+
_loadedClip = null;
|
|
271
304
|
_audioElement = null;
|
|
305
|
+
/**
|
|
306
|
+
* True when {@link _audioElement} is a file-URL element routed through
|
|
307
|
+
* `sound.setMediaElementSource()` — the iOS-survivable string-clip path. For this path ALL
|
|
308
|
+
* playback control (play/pause/stop/loop/pitch/time/isPlaying) goes through the element,
|
|
309
|
+
* because three.js sets `hasPlaybackControl = false` once a media source is attached.
|
|
310
|
+
* It is `false` for the distinct MediaStream path.
|
|
311
|
+
*/
|
|
312
|
+
_usesMediaElementSource = false;
|
|
313
|
+
/** Dedicated gain between the media source and the panner, used for click-free pause/resume fades. */
|
|
314
|
+
_fadeNode = null;
|
|
272
315
|
/**
|
|
273
316
|
* Returns the underlying {@link PositionalAudio} object, creating it if necessary.
|
|
274
317
|
* The audio source needs a user interaction to be initialized due to browser autoplay policies.
|
|
@@ -338,16 +381,46 @@ export class AudioSource extends Behaviour {
|
|
|
338
381
|
get audioContext() {
|
|
339
382
|
return this.sound?.context;
|
|
340
383
|
}
|
|
384
|
+
/**
|
|
385
|
+
* Resumes the shared AudioContext if it is currently suspended/interrupted and the page is
|
|
386
|
+
* visible. No-op when already running or when resuming is not allowed (resuming a hidden
|
|
387
|
+
* context throws iOS `InvalidStateError: Failed to start the audio device`).
|
|
388
|
+
*/
|
|
389
|
+
ensureContextResumed() {
|
|
390
|
+
const ctx = this.audioContext;
|
|
391
|
+
if (!ctx)
|
|
392
|
+
return;
|
|
393
|
+
const visibility = typeof document !== "undefined" ? document.visibilityState : "visible";
|
|
394
|
+
if (visibility !== "visible")
|
|
395
|
+
return;
|
|
396
|
+
// Resume even when ctx.state reports "running": on iOS an auto-resume that happened outside a
|
|
397
|
+
// user gesture can leave the context "running" while the audio hardware stays off. play() is
|
|
398
|
+
// user-initiated, so this resume() runs inside the gesture and re-enables real output.
|
|
399
|
+
ctx.resume().catch(e => { if (debug)
|
|
400
|
+
console.warn("[AudioSource] Failed to resume AudioContext:", e); });
|
|
401
|
+
}
|
|
341
402
|
/** @internal */
|
|
342
403
|
awake() {
|
|
343
404
|
if (debug)
|
|
344
405
|
console.log("[AudioSource]", this);
|
|
345
|
-
this.audioLoader = new AudioLoader();
|
|
346
406
|
if (this.playOnAwake)
|
|
347
407
|
this.shouldPlay = true;
|
|
348
408
|
if (this.preload) {
|
|
349
|
-
if (typeof this.clip === "string") {
|
|
350
|
-
|
|
409
|
+
if (typeof this.clip === "string" && this.clip.length > 0) {
|
|
410
|
+
// Preload = create the <audio> element and wire it into the graph, but do NOT play.
|
|
411
|
+
// Wiring needs the PositionalAudio (Sound), which needs an AudioListener that only
|
|
412
|
+
// exists after a user gesture — so defer when not ready.
|
|
413
|
+
if (this.Sound) {
|
|
414
|
+
this.createAudioFromElement();
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
AudioSource.registerWaitForAllowAudio(() => {
|
|
418
|
+
if (this.destroyed || this.enabled === false)
|
|
419
|
+
return;
|
|
420
|
+
if (typeof this.clip === "string" && this.clip.length > 0)
|
|
421
|
+
this.createAudioFromElement();
|
|
422
|
+
});
|
|
423
|
+
}
|
|
351
424
|
}
|
|
352
425
|
}
|
|
353
426
|
}
|
|
@@ -377,16 +450,23 @@ export class AudioSource extends Behaviour {
|
|
|
377
450
|
switch (document.visibilityState) {
|
|
378
451
|
case "hidden":
|
|
379
452
|
if (this.playInBackground === false || DeviceUtilities.isMobileDevice()) {
|
|
380
|
-
|
|
381
|
-
if (
|
|
453
|
+
const wasPlaying = this.isPlaying;
|
|
454
|
+
if (wasPlaying) {
|
|
382
455
|
this.pause();
|
|
383
456
|
}
|
|
457
|
+
// pause() clears wasPlaying so a *user-initiated* pause is not auto-resumed on
|
|
458
|
+
// the next foreground. This pause is automatic (page hidden / device locked), so
|
|
459
|
+
// re-assert the resume intent AFTER pause() — otherwise we never resume on unlock.
|
|
460
|
+
this.wasPlaying = wasPlaying;
|
|
384
461
|
}
|
|
385
462
|
break;
|
|
386
463
|
case "visible":
|
|
387
464
|
if (debug)
|
|
388
465
|
console.log("visible", this.enabled, this.playOnAwake, !this.isPlaying, AudioSource.userInteractionRegistered, this.wasPlaying);
|
|
389
|
-
|
|
466
|
+
// Resume anything that was actively playing when the page was hidden (not just
|
|
467
|
+
// playOnAwake sources). wasPlaying is only set when this source was playing at hide
|
|
468
|
+
// time, so script-started clips resume too.
|
|
469
|
+
if (this.enabled && !this.isPlaying && AudioSource.userInteractionRegistered && this.wasPlaying) {
|
|
390
470
|
this.play();
|
|
391
471
|
}
|
|
392
472
|
break;
|
|
@@ -398,43 +478,122 @@ export class AudioSource extends Behaviour {
|
|
|
398
478
|
else
|
|
399
479
|
this.sound?.setVolume(this.volume);
|
|
400
480
|
};
|
|
401
|
-
|
|
481
|
+
/**
|
|
482
|
+
* Sets up playback of a file-URL string clip through an `HTMLAudioElement` routed into the Web
|
|
483
|
+
* Audio graph via `sound.setMediaElementSource()`.
|
|
484
|
+
*
|
|
485
|
+
* Why a media element instead of a decoded `AudioBuffer`: on iOS Safari an
|
|
486
|
+
* `AudioBufferSourceNode`'s output is torn down after a device lock / audio-session interruption
|
|
487
|
+
* and cannot be reliably revived. A `MediaElementAudioSourceNode` survives because the playing
|
|
488
|
+
* `<audio>` element keeps iOS's media session alive, so the shared AudioContext resumes cleanly
|
|
489
|
+
* on unlock. This mirrors three.js's `webaudio_orientation` example.
|
|
490
|
+
*
|
|
491
|
+
* Does NOT start playback — it only creates/wires the element so a later `.play()` is cheap.
|
|
492
|
+
*/
|
|
493
|
+
createAudioFromElement() {
|
|
494
|
+
if (this.destroyed) {
|
|
495
|
+
if (debug)
|
|
496
|
+
console.warn("AudioSource destroyed, not creating audio", this.name);
|
|
497
|
+
return;
|
|
498
|
+
}
|
|
499
|
+
const sound = this.Sound;
|
|
500
|
+
if (!sound) {
|
|
501
|
+
if (debug)
|
|
502
|
+
console.warn("Failed getting sound?", this.name);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// Already wired for the current clip — re-running setMediaElementSource on the same element
|
|
506
|
+
// throws InvalidStateError, so just keep volume/mute in sync and bail.
|
|
507
|
+
if (this._audioElement && this._usesMediaElementSource && this._loadedClip === this.clip) {
|
|
508
|
+
if (this.context.application.muted)
|
|
509
|
+
sound.setVolume(0);
|
|
510
|
+
else
|
|
511
|
+
sound.setVolume(this.volume);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
// (re)create the element when the clip changed since the element was wired.
|
|
515
|
+
if (this._audioElement && this._loadedClip !== this.clip) {
|
|
516
|
+
this._audioElement.pause();
|
|
517
|
+
this._audioElement = null;
|
|
518
|
+
this._usesMediaElementSource = false;
|
|
519
|
+
}
|
|
520
|
+
if (!this._audioElement) {
|
|
521
|
+
const el = new Audio();
|
|
522
|
+
el.crossOrigin = "anonymous";
|
|
523
|
+
el.preload = "auto";
|
|
524
|
+
el.src = this.clip;
|
|
525
|
+
this._audioElement = el;
|
|
526
|
+
}
|
|
527
|
+
this._audioElement.loop = this._loop;
|
|
528
|
+
// IMPORTANT: build the fade node FIRST so getOutput() returns it, THEN route the media
|
|
529
|
+
// element source in. setMediaElementSource() immediately calls sound.connect()
|
|
530
|
+
// (source → getOutput()), so the fade node and the getOutput override must already exist.
|
|
531
|
+
this.setupFadeNode();
|
|
532
|
+
// Only connect once per element — createMediaElementSource() throws if the same element is
|
|
533
|
+
// passed twice.
|
|
534
|
+
if (!this._usesMediaElementSource) {
|
|
535
|
+
sound.setMediaElementSource(this._audioElement);
|
|
536
|
+
this._usesMediaElementSource = true;
|
|
537
|
+
}
|
|
538
|
+
this._loadedClip = this.clip;
|
|
539
|
+
if (this.context.application.muted)
|
|
540
|
+
sound.setVolume(0);
|
|
541
|
+
else
|
|
542
|
+
sound.setVolume(this.volume);
|
|
543
|
+
this.applySpatialDistanceSettings();
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Inserts a dedicated gain node between the media source and the panner so pause/resume can fade
|
|
547
|
+
* click-free without touching `sound.gain` (which carries volume/mute). three.js connects the
|
|
548
|
+
* source to `getOutput()` inside `setMediaElementSource()`, so we override it to feed our fade
|
|
549
|
+
* node, then route fade → panner (→ gain → listener, both already wired by PositionalAudio).
|
|
550
|
+
*/
|
|
551
|
+
setupFadeNode() {
|
|
552
|
+
const sound = this.sound;
|
|
553
|
+
if (!sound || this._fadeNode)
|
|
554
|
+
return;
|
|
555
|
+
this._fadeNode = sound.context.createGain();
|
|
556
|
+
this._fadeNode.connect(sound.panner);
|
|
557
|
+
sound.getOutput = () => this._fadeNode;
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* Smoothly ramp the fade gain to `target` (1 = audible, 0 = silent) over a few ms. A short fade —
|
|
561
|
+
* not a hard 0/1 gain change and not a connect/disconnect — avoids the click on pause/resume. On
|
|
562
|
+
* iOS the MediaElementAudioSourceNode keeps feeding the graph after element.pause() following an
|
|
563
|
+
* interruption, so fading this gain to 0 is what actually silences a "paused" media clip.
|
|
564
|
+
*/
|
|
565
|
+
fadeGain(target, seconds = 0.02) {
|
|
566
|
+
if (!this._fadeNode || !this.sound)
|
|
567
|
+
return;
|
|
568
|
+
const ctx = this.sound.context;
|
|
569
|
+
const g = this._fadeNode.gain;
|
|
570
|
+
g.cancelScheduledValues(ctx.currentTime);
|
|
571
|
+
g.setValueAtTime(g.value, ctx.currentTime);
|
|
572
|
+
g.linearRampToValueAtTime(target, ctx.currentTime + seconds);
|
|
573
|
+
}
|
|
574
|
+
/**
|
|
575
|
+
* Sets up the {@link PositionalAudio} graph for a MediaStream clip (file-URL string clips go
|
|
576
|
+
* through {@link createAudioFromElement}). The stream node itself is attached in {@link play}
|
|
577
|
+
* via `setMediaStreamSource`.
|
|
578
|
+
*/
|
|
579
|
+
createAudio = () => {
|
|
402
580
|
if (this.destroyed) {
|
|
403
581
|
if (debug)
|
|
404
582
|
console.warn("AudioSource destroyed, not creating audio", this.name);
|
|
405
583
|
return;
|
|
406
584
|
}
|
|
407
|
-
if (debug)
|
|
408
|
-
console.log("AudioBuffer finished loading", buffer);
|
|
409
585
|
const sound = this.Sound;
|
|
410
586
|
if (!sound) {
|
|
411
587
|
if (debug)
|
|
412
588
|
console.warn("Failed getting sound?", this.name);
|
|
413
589
|
return;
|
|
414
590
|
}
|
|
415
|
-
|
|
416
|
-
sound.stop();
|
|
417
|
-
if (buffer)
|
|
418
|
-
sound.setBuffer(buffer);
|
|
419
|
-
sound.loop = this._loop;
|
|
591
|
+
this._loadedClip = this.clip;
|
|
420
592
|
if (this.context.application.muted)
|
|
421
593
|
sound.setVolume(0);
|
|
422
594
|
else
|
|
423
595
|
sound.setVolume(this.volume);
|
|
424
|
-
sound.autoplay = this.shouldPlay && AudioSource.userInteractionRegistered;
|
|
425
596
|
this.applySpatialDistanceSettings();
|
|
426
|
-
if (sound.isPlaying)
|
|
427
|
-
sound.stop();
|
|
428
|
-
// const panner = sound.panner;
|
|
429
|
-
// panner.coneOuterGain = 1;
|
|
430
|
-
// sound.setDirectionalCone(360, 360, 1);
|
|
431
|
-
// const src = sound.context.createBufferSource();
|
|
432
|
-
// src.buffer = sound.buffer;
|
|
433
|
-
// src.connect(sound.panner);
|
|
434
|
-
// src.start(this.audioContext?.currentTime);
|
|
435
|
-
// const gain = sound.context.createGain();
|
|
436
|
-
// gain.gain.value = 1 - this.spatialBlend;
|
|
437
|
-
// src.connect(gain);
|
|
438
597
|
// make sure we only play the sound if the user has interacted with the page
|
|
439
598
|
AudioSource.registerWaitForAllowAudio(this.__onAllowAudioCallback);
|
|
440
599
|
};
|
|
@@ -483,24 +642,21 @@ export class AudioSource extends Behaviour {
|
|
|
483
642
|
if (debug)
|
|
484
643
|
console.log(clip);
|
|
485
644
|
if (clip.endsWith(".mp3") || clip.endsWith(".wav")) {
|
|
486
|
-
if (!this.audioLoader)
|
|
487
|
-
this.audioLoader = new AudioLoader();
|
|
488
645
|
this.shouldPlay = true;
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
646
|
+
// Route the file URL through an <audio> element (survives iOS lock — see
|
|
647
|
+
// createAudioFromElement). This creates/wires the element without playing.
|
|
648
|
+
this.createAudioFromElement();
|
|
649
|
+
// Start playback through the element (NOT this.sound — hasPlaybackControl is false
|
|
650
|
+
// for a media-element source). Covers the already-interacted case directly.
|
|
651
|
+
if (this.shouldPlay && AudioSource.userInteractionRegistered) {
|
|
652
|
+
this.ensureContextResumed();
|
|
653
|
+
this._hasEnded = false;
|
|
654
|
+
this._audioElement?.play().catch(e => {
|
|
655
|
+
if (debug)
|
|
656
|
+
console.warn(`[AudioSource] Failed to play audio clip "${clip}":`, e);
|
|
657
|
+
});
|
|
658
|
+
this.fadeGain(1);
|
|
493
659
|
}
|
|
494
|
-
this._lastClipStartedLoading = clip;
|
|
495
|
-
if (debug)
|
|
496
|
-
console.log("load audio", clip);
|
|
497
|
-
const buffer = await this.audioLoader.loadAsync(clip).catch(console.error);
|
|
498
|
-
if (this.destroyed)
|
|
499
|
-
return;
|
|
500
|
-
if (this._lastClipStartedLoading === clip)
|
|
501
|
-
this._lastClipStartedLoading = null;
|
|
502
|
-
if (buffer)
|
|
503
|
-
this.createAudio(buffer);
|
|
504
660
|
}
|
|
505
661
|
else
|
|
506
662
|
console.warn("Unsupported audio clip type", clip);
|
|
@@ -528,9 +684,11 @@ export class AudioSource extends Behaviour {
|
|
|
528
684
|
// e.g. when a AudioSource.Play is called from SpatialTrigger onEnter this event is called with the TriggerReceiver... to still make this work we *re-use* our already assigned clip. Because otherwise calling `play` would not play the clip...
|
|
529
685
|
clip = this.clip;
|
|
530
686
|
}
|
|
531
|
-
//
|
|
532
|
-
let needsLoading = !this.sound || (clip && clip !== this.
|
|
533
|
-
|
|
687
|
+
// Load if the sound hasn't been created yet or if the clip changed since last load.
|
|
688
|
+
let needsLoading = !this.sound || (clip && clip !== this._loadedClip);
|
|
689
|
+
// For string clips the <audio> element must additionally be wired (createAudioFromElement);
|
|
690
|
+
// if it isn't yet (e.g. preload was off, or the element was torn down), force a (re)load.
|
|
691
|
+
if (typeof clip === "string" && (!this._audioElement || !this._usesMediaElementSource))
|
|
534
692
|
needsLoading = true;
|
|
535
693
|
if (clip instanceof MediaStream || typeof clip === "string")
|
|
536
694
|
this.clip = clip;
|
|
@@ -543,14 +701,20 @@ export class AudioSource extends Behaviour {
|
|
|
543
701
|
this._hasEnded = false;
|
|
544
702
|
if (debug)
|
|
545
703
|
console.log("play", this.sound?.getVolume(), this.sound);
|
|
546
|
-
|
|
704
|
+
// Use the element-aware isPlaying getter so we don't re-trigger an already-playing media clip.
|
|
705
|
+
if (this.sound && !this.isPlaying) {
|
|
706
|
+
// On iOS the shared context can be suspended/interrupted (lock, backgrounding, a call) —
|
|
707
|
+
// resume it here (we are inside a user gesture) so playback doesn't silently depend on
|
|
708
|
+
// the global resume handler running first. See engine_audio.ts.
|
|
709
|
+
this.ensureContextResumed();
|
|
547
710
|
const muted = this.context.application.muted;
|
|
548
711
|
if (muted)
|
|
549
712
|
this.sound.setVolume(0);
|
|
550
713
|
this.gameObject?.add(this.sound);
|
|
551
714
|
if (this.clip instanceof MediaStream) {
|
|
552
|
-
//
|
|
553
|
-
//
|
|
715
|
+
// Distinct from the file-URL media-element path: a MediaStream uses
|
|
716
|
+
// setMediaStreamSource + an element holding srcObject.
|
|
717
|
+
this._usesMediaElementSource = false;
|
|
554
718
|
this.sound.setMediaStreamSource(this.clip);
|
|
555
719
|
if (!this._audioElement) {
|
|
556
720
|
this._audioElement = document.createElement('audio');
|
|
@@ -562,9 +726,14 @@ export class AudioSource extends Behaviour {
|
|
|
562
726
|
this._audioElement.autoplay = false;
|
|
563
727
|
}
|
|
564
728
|
else {
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
729
|
+
// File-URL media-element path: playback control is on the element, not this.sound.
|
|
730
|
+
// Resume from the (frozen) position and fade the gain back in so the resume is
|
|
731
|
+
// click-free.
|
|
732
|
+
this._audioElement?.play().catch(e => {
|
|
733
|
+
if (debug)
|
|
734
|
+
console.warn("[AudioSource] Failed to play audio element:", e);
|
|
735
|
+
});
|
|
736
|
+
this.fadeGain(1);
|
|
568
737
|
}
|
|
569
738
|
}
|
|
570
739
|
}
|
|
@@ -574,13 +743,26 @@ export class AudioSource extends Behaviour {
|
|
|
574
743
|
*/
|
|
575
744
|
pause() {
|
|
576
745
|
if (debug)
|
|
577
|
-
console.log("
|
|
746
|
+
console.log("[AudioSource] pause", this);
|
|
578
747
|
this._hasEnded = true;
|
|
579
748
|
this.shouldPlay = false;
|
|
749
|
+
// A user-initiated pause must not be revived by the visibility handler on the next
|
|
750
|
+
// foreground (it replays sources whose `wasPlaying` is set).
|
|
751
|
+
this.wasPlaying = false;
|
|
752
|
+
if (this._usesMediaElementSource) {
|
|
753
|
+
// File-URL media-element path. On iOS the MediaElementAudioSourceNode keeps emitting into
|
|
754
|
+
// the graph after element.pause() once the audio session has been interrupted — pausing
|
|
755
|
+
// the element does NOT silence it. So fade the gain to 0 (click-free silence), then
|
|
756
|
+
// freeze the element's position. play() fades it back in.
|
|
757
|
+
this.fadeGain(0);
|
|
758
|
+
this._audioElement?.pause();
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
580
761
|
if (this.sound && this.sound.isPlaying && this.sound.source) {
|
|
581
762
|
this._lastContextTime = this.sound?.context.currentTime;
|
|
582
763
|
this.sound.pause();
|
|
583
764
|
}
|
|
765
|
+
// MediaStream / legacy path: drop the stream element as before.
|
|
584
766
|
this._audioElement?.remove();
|
|
585
767
|
}
|
|
586
768
|
/**
|
|
@@ -589,15 +771,26 @@ export class AudioSource extends Behaviour {
|
|
|
589
771
|
*/
|
|
590
772
|
stop() {
|
|
591
773
|
if (debug)
|
|
592
|
-
console.log("
|
|
774
|
+
console.log("[AudioSource] stop", this);
|
|
593
775
|
this._hasEnded = true;
|
|
594
776
|
this.shouldPlay = false;
|
|
777
|
+
this.wasPlaying = false;
|
|
778
|
+
if (this._usesMediaElementSource) {
|
|
779
|
+
// True stop: fade to silence (element.pause() alone doesn't silence it on iOS after an
|
|
780
|
+
// interruption), pause, and rewind to the start.
|
|
781
|
+
this.fadeGain(0);
|
|
782
|
+
this._audioElement?.pause();
|
|
783
|
+
if (this._audioElement)
|
|
784
|
+
this._audioElement.currentTime = 0;
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
595
787
|
if (this.sound && this.sound.source) {
|
|
596
788
|
this._lastContextTime = this.sound?.context.currentTime;
|
|
597
789
|
if (debug)
|
|
598
|
-
console.log(this._lastContextTime);
|
|
790
|
+
console.log("[AudioSource] lastContextTime", this._lastContextTime);
|
|
599
791
|
this.sound.stop();
|
|
600
792
|
}
|
|
793
|
+
// MediaStream / legacy path: drop the stream element as before.
|
|
601
794
|
this._audioElement?.remove();
|
|
602
795
|
}
|
|
603
796
|
_lastContextTime = 0;
|
|
@@ -613,7 +806,9 @@ export class AudioSource extends Behaviour {
|
|
|
613
806
|
if (this._needUpdateSpatialDistanceSettings) {
|
|
614
807
|
this.applySpatialDistanceSettings();
|
|
615
808
|
}
|
|
616
|
-
|
|
809
|
+
// Use the element-aware isPlaying getter, NOT sound.isPlaying: for a media-element source
|
|
810
|
+
// three.js leaves sound.isPlaying permanently false, which would fire "ended" every frame.
|
|
811
|
+
if (this.sound && !this.isPlaying && this.shouldPlay && !this._hasEnded) {
|
|
617
812
|
this._hasEnded = true;
|
|
618
813
|
if (debug)
|
|
619
814
|
console.log("Audio clip ended", this.clip);
|