@needle-tools/engine 4.16.10 → 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 +4 -0
- package/dist/{needle-engine.bundle-D_nuVSFb.umd.cjs → needle-engine.bundle-DOChN3At.umd.cjs} +103 -103
- package/dist/{needle-engine.bundle-a0Pw5htf.js → needle-engine.bundle-Du3vf5L1.js} +3638 -3551
- package/dist/{needle-engine.bundle-DlyYXu9v.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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PositionalAudio } from "three";
|
|
2
2
|
import { PositionalAudioHelper } from 'three/examples/jsm/helpers/PositionalAudioHelper.js';
|
|
3
3
|
|
|
4
4
|
import { isDevEnvironment } from "../engine/debug/index.js";
|
|
@@ -137,7 +137,13 @@ export class AudioSource extends Behaviour {
|
|
|
137
137
|
*
|
|
138
138
|
* @returns True if the audio is playing, false otherwise
|
|
139
139
|
*/
|
|
140
|
-
get isPlaying(): boolean {
|
|
140
|
+
get isPlaying(): boolean {
|
|
141
|
+
// For the media-element path (file-URL string clips) three.js does NOT maintain
|
|
142
|
+
// sound.isPlaying (hasPlaybackControl is false once a media source is set), so the
|
|
143
|
+
// <audio> element is the source of truth.
|
|
144
|
+
if (this._usesMediaElementSource && this._audioElement) return !this._audioElement.paused && !this._audioElement.ended;
|
|
145
|
+
return this.sound?.isPlaying ?? false;
|
|
146
|
+
}
|
|
141
147
|
|
|
142
148
|
/**
|
|
143
149
|
* The total duration of the current audio clip in seconds.
|
|
@@ -145,6 +151,9 @@ export class AudioSource extends Behaviour {
|
|
|
145
151
|
* @returns Duration in seconds or undefined if no clip is loaded
|
|
146
152
|
*/
|
|
147
153
|
get duration() {
|
|
154
|
+
// Media-element path is the primary source of truth for file-URL string clips.
|
|
155
|
+
const elDuration = this._usesMediaElementSource ? this._audioElement?.duration : undefined;
|
|
156
|
+
if (elDuration && isFinite(elDuration)) return elDuration;
|
|
148
157
|
return this.sound?.buffer?.duration;
|
|
149
158
|
}
|
|
150
159
|
|
|
@@ -154,14 +163,14 @@ export class AudioSource extends Behaviour {
|
|
|
154
163
|
*/
|
|
155
164
|
get time01() {
|
|
156
165
|
const duration = this.duration;
|
|
157
|
-
if (duration
|
|
158
|
-
return this.
|
|
166
|
+
if (duration) {
|
|
167
|
+
return this.time / duration;
|
|
159
168
|
}
|
|
160
169
|
return 0;
|
|
161
170
|
}
|
|
162
171
|
set time01(val: number) {
|
|
163
172
|
const duration = this.duration;
|
|
164
|
-
if (duration
|
|
173
|
+
if (duration) {
|
|
165
174
|
this.time = val * duration;
|
|
166
175
|
}
|
|
167
176
|
}
|
|
@@ -170,8 +179,17 @@ export class AudioSource extends Behaviour {
|
|
|
170
179
|
* The current playback position in seconds.
|
|
171
180
|
* Can be set to seek to a specific time in the audio.
|
|
172
181
|
*/
|
|
173
|
-
get time(): number {
|
|
182
|
+
get time(): number {
|
|
183
|
+
// Media-element path: the element's currentTime is authoritative.
|
|
184
|
+
if (this._usesMediaElementSource && this._audioElement) return this._audioElement.currentTime;
|
|
185
|
+
return this.sound?.source ? (this.sound.source?.context.currentTime - this._lastContextTime + this.sound.offset) : 0;
|
|
186
|
+
}
|
|
174
187
|
set time(val: number) {
|
|
188
|
+
// Media-element path: seek directly on the element (sound.offset is not used for media nodes).
|
|
189
|
+
if (this._usesMediaElementSource && this._audioElement) {
|
|
190
|
+
this._audioElement.currentTime = val;
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
175
193
|
if (this.sound) {
|
|
176
194
|
if (val === this.sound.offset) return;
|
|
177
195
|
const wasPlaying = this.isPlaying;
|
|
@@ -189,12 +207,18 @@ export class AudioSource extends Behaviour {
|
|
|
189
207
|
*/
|
|
190
208
|
@serializable()
|
|
191
209
|
get loop(): boolean {
|
|
210
|
+
// Media-element path: the element holds the authoritative loop flag.
|
|
211
|
+
if (this._usesMediaElementSource && this._audioElement) {
|
|
212
|
+
this._loop = this._audioElement.loop;
|
|
213
|
+
return this._loop;
|
|
214
|
+
}
|
|
192
215
|
if (this.sound) this._loop = this.sound.getLoop();
|
|
193
216
|
return this._loop;
|
|
194
217
|
}
|
|
195
218
|
set loop(val: boolean) {
|
|
196
219
|
this._loop = val;
|
|
197
|
-
if (this.
|
|
220
|
+
if (this._usesMediaElementSource && this._audioElement) this._audioElement.loop = val;
|
|
221
|
+
else if (this.sound) this.sound.setLoop(val);
|
|
198
222
|
}
|
|
199
223
|
|
|
200
224
|
/**
|
|
@@ -268,9 +292,13 @@ export class AudioSource extends Behaviour {
|
|
|
268
292
|
*/
|
|
269
293
|
@serializable()
|
|
270
294
|
set pitch(val: number) {
|
|
271
|
-
|
|
295
|
+
// Media-element path: playbackRate on the element (sound.setPlaybackRate is a no-op once
|
|
296
|
+
// hasPlaybackControl is false).
|
|
297
|
+
if (this._usesMediaElementSource && this._audioElement) this._audioElement.playbackRate = val;
|
|
298
|
+
else if (this.sound) this.sound.setPlaybackRate(val);
|
|
272
299
|
}
|
|
273
300
|
get pitch(): number {
|
|
301
|
+
if (this._usesMediaElementSource && this._audioElement) return this._audioElement.playbackRate;
|
|
274
302
|
return this.sound ? this.sound.getPlaybackRate() : 1;
|
|
275
303
|
}
|
|
276
304
|
|
|
@@ -287,12 +315,21 @@ export class AudioSource extends Behaviour {
|
|
|
287
315
|
private sound: PositionalAudio | null = null;
|
|
288
316
|
private helper: PositionalAudioHelper | null = null;
|
|
289
317
|
private wasPlaying = false;
|
|
290
|
-
private audioLoader: AudioLoader | null = null;
|
|
291
318
|
private shouldPlay: boolean = false;
|
|
292
319
|
// set this from audio context time, used to set clip offset when setting "time" property
|
|
293
320
|
// there is maybe a better way to set a audio clip current time?!
|
|
294
|
-
private
|
|
321
|
+
private _loadedClip: string | MediaStream | null = null;
|
|
295
322
|
private _audioElement: HTMLAudioElement | null = null;
|
|
323
|
+
/**
|
|
324
|
+
* True when {@link _audioElement} is a file-URL element routed through
|
|
325
|
+
* `sound.setMediaElementSource()` — the iOS-survivable string-clip path. For this path ALL
|
|
326
|
+
* playback control (play/pause/stop/loop/pitch/time/isPlaying) goes through the element,
|
|
327
|
+
* because three.js sets `hasPlaybackControl = false` once a media source is attached.
|
|
328
|
+
* It is `false` for the distinct MediaStream path.
|
|
329
|
+
*/
|
|
330
|
+
private _usesMediaElementSource: boolean = false;
|
|
331
|
+
/** Dedicated gain between the media source and the panner, used for click-free pause/resume fades. */
|
|
332
|
+
private _fadeNode: GainNode | null = null;
|
|
296
333
|
|
|
297
334
|
/**
|
|
298
335
|
* Returns the underlying {@link PositionalAudio} object, creating it if necessary.
|
|
@@ -368,16 +405,42 @@ export class AudioSource extends Behaviour {
|
|
|
368
405
|
return this.sound?.context;
|
|
369
406
|
}
|
|
370
407
|
|
|
408
|
+
/**
|
|
409
|
+
* Resumes the shared AudioContext if it is currently suspended/interrupted and the page is
|
|
410
|
+
* visible. No-op when already running or when resuming is not allowed (resuming a hidden
|
|
411
|
+
* context throws iOS `InvalidStateError: Failed to start the audio device`).
|
|
412
|
+
*/
|
|
413
|
+
private ensureContextResumed() {
|
|
414
|
+
const ctx = this.audioContext;
|
|
415
|
+
if (!ctx) return;
|
|
416
|
+
const visibility = typeof document !== "undefined" ? document.visibilityState : "visible";
|
|
417
|
+
if (visibility !== "visible") return;
|
|
418
|
+
// Resume even when ctx.state reports "running": on iOS an auto-resume that happened outside a
|
|
419
|
+
// user gesture can leave the context "running" while the audio hardware stays off. play() is
|
|
420
|
+
// user-initiated, so this resume() runs inside the gesture and re-enables real output.
|
|
421
|
+
ctx.resume().catch(e => { if (debug) console.warn("[AudioSource] Failed to resume AudioContext:", e); });
|
|
422
|
+
}
|
|
423
|
+
|
|
371
424
|
/** @internal */
|
|
372
425
|
awake() {
|
|
373
426
|
if (debug) console.log("[AudioSource]", this);
|
|
374
427
|
|
|
375
|
-
this.audioLoader = new AudioLoader();
|
|
376
428
|
if (this.playOnAwake) this.shouldPlay = true;
|
|
377
429
|
|
|
378
430
|
if (this.preload) {
|
|
379
|
-
if (typeof this.clip === "string") {
|
|
380
|
-
|
|
431
|
+
if (typeof this.clip === "string" && this.clip.length > 0) {
|
|
432
|
+
// Preload = create the <audio> element and wire it into the graph, but do NOT play.
|
|
433
|
+
// Wiring needs the PositionalAudio (Sound), which needs an AudioListener that only
|
|
434
|
+
// exists after a user gesture — so defer when not ready.
|
|
435
|
+
if (this.Sound) {
|
|
436
|
+
this.createAudioFromElement();
|
|
437
|
+
}
|
|
438
|
+
else {
|
|
439
|
+
AudioSource.registerWaitForAllowAudio(() => {
|
|
440
|
+
if (this.destroyed || this.enabled === false) return;
|
|
441
|
+
if (typeof this.clip === "string" && this.clip.length > 0) this.createAudioFromElement();
|
|
442
|
+
});
|
|
443
|
+
}
|
|
381
444
|
}
|
|
382
445
|
}
|
|
383
446
|
}
|
|
@@ -411,15 +474,22 @@ export class AudioSource extends Behaviour {
|
|
|
411
474
|
switch (document.visibilityState) {
|
|
412
475
|
case "hidden":
|
|
413
476
|
if (this.playInBackground === false || DeviceUtilities.isMobileDevice()) {
|
|
414
|
-
|
|
415
|
-
if (
|
|
477
|
+
const wasPlaying = this.isPlaying;
|
|
478
|
+
if (wasPlaying) {
|
|
416
479
|
this.pause();
|
|
417
480
|
}
|
|
481
|
+
// pause() clears wasPlaying so a *user-initiated* pause is not auto-resumed on
|
|
482
|
+
// the next foreground. This pause is automatic (page hidden / device locked), so
|
|
483
|
+
// re-assert the resume intent AFTER pause() — otherwise we never resume on unlock.
|
|
484
|
+
this.wasPlaying = wasPlaying;
|
|
418
485
|
}
|
|
419
486
|
break;
|
|
420
487
|
case "visible":
|
|
421
488
|
if (debug) console.log("visible", this.enabled, this.playOnAwake, !this.isPlaying, AudioSource.userInteractionRegistered, this.wasPlaying);
|
|
422
|
-
|
|
489
|
+
// Resume anything that was actively playing when the page was hidden (not just
|
|
490
|
+
// playOnAwake sources). wasPlaying is only set when this source was playing at hide
|
|
491
|
+
// time, so script-started clips resume too.
|
|
492
|
+
if (this.enabled && !this.isPlaying && AudioSource.userInteractionRegistered && this.wasPlaying) {
|
|
423
493
|
this.play();
|
|
424
494
|
}
|
|
425
495
|
break;
|
|
@@ -433,44 +503,117 @@ export class AudioSource extends Behaviour {
|
|
|
433
503
|
this.sound?.setVolume(this.volume);
|
|
434
504
|
}
|
|
435
505
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
506
|
+
/**
|
|
507
|
+
* Sets up playback of a file-URL string clip through an `HTMLAudioElement` routed into the Web
|
|
508
|
+
* Audio graph via `sound.setMediaElementSource()`.
|
|
509
|
+
*
|
|
510
|
+
* Why a media element instead of a decoded `AudioBuffer`: on iOS Safari an
|
|
511
|
+
* `AudioBufferSourceNode`'s output is torn down after a device lock / audio-session interruption
|
|
512
|
+
* and cannot be reliably revived. A `MediaElementAudioSourceNode` survives because the playing
|
|
513
|
+
* `<audio>` element keeps iOS's media session alive, so the shared AudioContext resumes cleanly
|
|
514
|
+
* on unlock. This mirrors three.js's `webaudio_orientation` example.
|
|
515
|
+
*
|
|
516
|
+
* Does NOT start playback — it only creates/wires the element so a later `.play()` is cheap.
|
|
517
|
+
*/
|
|
518
|
+
private createAudioFromElement() {
|
|
519
|
+
if (this.destroyed) {
|
|
520
|
+
if (debug) console.warn("AudioSource destroyed, not creating audio", this.name);
|
|
439
521
|
return;
|
|
440
522
|
}
|
|
441
|
-
|
|
442
|
-
if (debug) console.log("AudioBuffer finished loading", buffer);
|
|
443
|
-
|
|
444
523
|
const sound = this.Sound;
|
|
445
524
|
if (!sound) {
|
|
446
525
|
if (debug) console.warn("Failed getting sound?", this.name);
|
|
447
526
|
return;
|
|
448
527
|
}
|
|
449
528
|
|
|
450
|
-
|
|
451
|
-
|
|
529
|
+
// Already wired for the current clip — re-running setMediaElementSource on the same element
|
|
530
|
+
// throws InvalidStateError, so just keep volume/mute in sync and bail.
|
|
531
|
+
if (this._audioElement && this._usesMediaElementSource && this._loadedClip === this.clip) {
|
|
532
|
+
if (this.context.application.muted) sound.setVolume(0);
|
|
533
|
+
else sound.setVolume(this.volume);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// (re)create the element when the clip changed since the element was wired.
|
|
538
|
+
if (this._audioElement && this._loadedClip !== this.clip) {
|
|
539
|
+
this._audioElement.pause();
|
|
540
|
+
this._audioElement = null;
|
|
541
|
+
this._usesMediaElementSource = false;
|
|
542
|
+
}
|
|
543
|
+
if (!this._audioElement) {
|
|
544
|
+
const el = new Audio();
|
|
545
|
+
el.crossOrigin = "anonymous";
|
|
546
|
+
el.preload = "auto";
|
|
547
|
+
el.src = this.clip as string;
|
|
548
|
+
this._audioElement = el;
|
|
549
|
+
}
|
|
550
|
+
this._audioElement!.loop = this._loop;
|
|
551
|
+
|
|
552
|
+
// IMPORTANT: build the fade node FIRST so getOutput() returns it, THEN route the media
|
|
553
|
+
// element source in. setMediaElementSource() immediately calls sound.connect()
|
|
554
|
+
// (source → getOutput()), so the fade node and the getOutput override must already exist.
|
|
555
|
+
this.setupFadeNode();
|
|
556
|
+
// Only connect once per element — createMediaElementSource() throws if the same element is
|
|
557
|
+
// passed twice.
|
|
558
|
+
if (!this._usesMediaElementSource) {
|
|
559
|
+
sound.setMediaElementSource(this._audioElement!);
|
|
560
|
+
this._usesMediaElementSource = true;
|
|
561
|
+
}
|
|
562
|
+
this._loadedClip = this.clip;
|
|
452
563
|
|
|
453
|
-
if (buffer) sound.setBuffer(buffer);
|
|
454
|
-
sound.loop = this._loop;
|
|
455
564
|
if (this.context.application.muted) sound.setVolume(0);
|
|
456
565
|
else sound.setVolume(this.volume);
|
|
457
|
-
sound.autoplay = this.shouldPlay && AudioSource.userInteractionRegistered;
|
|
458
566
|
this.applySpatialDistanceSettings();
|
|
567
|
+
}
|
|
459
568
|
|
|
460
|
-
|
|
461
|
-
|
|
569
|
+
/**
|
|
570
|
+
* Inserts a dedicated gain node between the media source and the panner so pause/resume can fade
|
|
571
|
+
* click-free without touching `sound.gain` (which carries volume/mute). three.js connects the
|
|
572
|
+
* source to `getOutput()` inside `setMediaElementSource()`, so we override it to feed our fade
|
|
573
|
+
* node, then route fade → panner (→ gain → listener, both already wired by PositionalAudio).
|
|
574
|
+
*/
|
|
575
|
+
private setupFadeNode() {
|
|
576
|
+
const sound = this.sound;
|
|
577
|
+
if (!sound || this._fadeNode) return;
|
|
578
|
+
this._fadeNode = sound.context.createGain();
|
|
579
|
+
this._fadeNode.connect(sound.panner);
|
|
580
|
+
sound.getOutput = () => this._fadeNode! as unknown as PannerNode;
|
|
581
|
+
}
|
|
462
582
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
583
|
+
/**
|
|
584
|
+
* Smoothly ramp the fade gain to `target` (1 = audible, 0 = silent) over a few ms. A short fade —
|
|
585
|
+
* not a hard 0/1 gain change and not a connect/disconnect — avoids the click on pause/resume. On
|
|
586
|
+
* iOS the MediaElementAudioSourceNode keeps feeding the graph after element.pause() following an
|
|
587
|
+
* interruption, so fading this gain to 0 is what actually silences a "paused" media clip.
|
|
588
|
+
*/
|
|
589
|
+
private fadeGain(target: number, seconds = 0.02) {
|
|
590
|
+
if (!this._fadeNode || !this.sound) return;
|
|
591
|
+
const ctx = this.sound.context;
|
|
592
|
+
const g = this._fadeNode.gain;
|
|
593
|
+
g.cancelScheduledValues(ctx.currentTime);
|
|
594
|
+
g.setValueAtTime(g.value, ctx.currentTime);
|
|
595
|
+
g.linearRampToValueAtTime(target, ctx.currentTime + seconds);
|
|
596
|
+
}
|
|
473
597
|
|
|
598
|
+
/**
|
|
599
|
+
* Sets up the {@link PositionalAudio} graph for a MediaStream clip (file-URL string clips go
|
|
600
|
+
* through {@link createAudioFromElement}). The stream node itself is attached in {@link play}
|
|
601
|
+
* via `setMediaStreamSource`.
|
|
602
|
+
*/
|
|
603
|
+
private createAudio = () => {
|
|
604
|
+
if (this.destroyed) {
|
|
605
|
+
if (debug) console.warn("AudioSource destroyed, not creating audio", this.name);
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
const sound = this.Sound;
|
|
609
|
+
if (!sound) {
|
|
610
|
+
if (debug) console.warn("Failed getting sound?", this.name);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
this._loadedClip = this.clip;
|
|
614
|
+
if (this.context.application.muted) sound.setVolume(0);
|
|
615
|
+
else sound.setVolume(this.volume);
|
|
616
|
+
this.applySpatialDistanceSettings();
|
|
474
617
|
// make sure we only play the sound if the user has interacted with the page
|
|
475
618
|
AudioSource.registerWaitForAllowAudio(this.__onAllowAudioCallback);
|
|
476
619
|
}
|
|
@@ -520,20 +663,20 @@ export class AudioSource extends Behaviour {
|
|
|
520
663
|
if (debug)
|
|
521
664
|
console.log(clip);
|
|
522
665
|
if (clip.endsWith(".mp3") || clip.endsWith(".wav")) {
|
|
523
|
-
if (!this.audioLoader)
|
|
524
|
-
this.audioLoader = new AudioLoader();
|
|
525
666
|
this.shouldPlay = true;
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
667
|
+
// Route the file URL through an <audio> element (survives iOS lock — see
|
|
668
|
+
// createAudioFromElement). This creates/wires the element without playing.
|
|
669
|
+
this.createAudioFromElement();
|
|
670
|
+
// Start playback through the element (NOT this.sound — hasPlaybackControl is false
|
|
671
|
+
// for a media-element source). Covers the already-interacted case directly.
|
|
672
|
+
if (this.shouldPlay && AudioSource.userInteractionRegistered) {
|
|
673
|
+
this.ensureContextResumed();
|
|
674
|
+
this._hasEnded = false;
|
|
675
|
+
this._audioElement?.play().catch(e => {
|
|
676
|
+
if (debug) console.warn(`[AudioSource] Failed to play audio clip "${clip}":`, e);
|
|
677
|
+
});
|
|
678
|
+
this.fadeGain(1);
|
|
529
679
|
}
|
|
530
|
-
this._lastClipStartedLoading = clip;
|
|
531
|
-
if (debug)
|
|
532
|
-
console.log("load audio", clip);
|
|
533
|
-
const buffer = await this.audioLoader.loadAsync(clip).catch(console.error);
|
|
534
|
-
if(this.destroyed) return;
|
|
535
|
-
if(this._lastClipStartedLoading === clip) this._lastClipStartedLoading = null;
|
|
536
|
-
if (buffer) this.createAudio(buffer);
|
|
537
680
|
}
|
|
538
681
|
else console.warn("Unsupported audio clip type", clip)
|
|
539
682
|
}
|
|
@@ -563,9 +706,11 @@ export class AudioSource extends Behaviour {
|
|
|
563
706
|
clip = this.clip;
|
|
564
707
|
}
|
|
565
708
|
|
|
566
|
-
//
|
|
567
|
-
let needsLoading = !this.sound || (clip && clip !== this.
|
|
568
|
-
|
|
709
|
+
// Load if the sound hasn't been created yet or if the clip changed since last load.
|
|
710
|
+
let needsLoading = !this.sound || (clip && clip !== this._loadedClip);
|
|
711
|
+
// For string clips the <audio> element must additionally be wired (createAudioFromElement);
|
|
712
|
+
// if it isn't yet (e.g. preload was off, or the element was torn down), force a (re)load.
|
|
713
|
+
if (typeof clip === "string" && (!this._audioElement || !this._usesMediaElementSource)) needsLoading = true;
|
|
569
714
|
if (clip instanceof MediaStream || typeof clip === "string")
|
|
570
715
|
this.clip = clip;
|
|
571
716
|
if (needsLoading) {
|
|
@@ -578,15 +723,21 @@ export class AudioSource extends Behaviour {
|
|
|
578
723
|
this._hasEnded = false;
|
|
579
724
|
if (debug)
|
|
580
725
|
console.log("play", this.sound?.getVolume(), this.sound);
|
|
581
|
-
|
|
726
|
+
// Use the element-aware isPlaying getter so we don't re-trigger an already-playing media clip.
|
|
727
|
+
if (this.sound && !this.isPlaying) {
|
|
728
|
+
// On iOS the shared context can be suspended/interrupted (lock, backgrounding, a call) —
|
|
729
|
+
// resume it here (we are inside a user gesture) so playback doesn't silently depend on
|
|
730
|
+
// the global resume handler running first. See engine_audio.ts.
|
|
731
|
+
this.ensureContextResumed();
|
|
582
732
|
const muted = this.context.application.muted;
|
|
583
733
|
if (muted) this.sound.setVolume(0);
|
|
584
734
|
this.gameObject?.add(this.sound);
|
|
585
735
|
|
|
586
736
|
if (this.clip instanceof MediaStream) {
|
|
587
737
|
|
|
588
|
-
//
|
|
589
|
-
//
|
|
738
|
+
// Distinct from the file-URL media-element path: a MediaStream uses
|
|
739
|
+
// setMediaStreamSource + an element holding srcObject.
|
|
740
|
+
this._usesMediaElementSource = false;
|
|
590
741
|
this.sound.setMediaStreamSource(this.clip);
|
|
591
742
|
|
|
592
743
|
if (!this._audioElement) {
|
|
@@ -600,8 +751,13 @@ export class AudioSource extends Behaviour {
|
|
|
600
751
|
|
|
601
752
|
}
|
|
602
753
|
else {
|
|
603
|
-
|
|
604
|
-
|
|
754
|
+
// File-URL media-element path: playback control is on the element, not this.sound.
|
|
755
|
+
// Resume from the (frozen) position and fade the gain back in so the resume is
|
|
756
|
+
// click-free.
|
|
757
|
+
this._audioElement?.play().catch(e => {
|
|
758
|
+
if (debug) console.warn("[AudioSource] Failed to play audio element:", e);
|
|
759
|
+
});
|
|
760
|
+
this.fadeGain(1);
|
|
605
761
|
}
|
|
606
762
|
}
|
|
607
763
|
}
|
|
@@ -611,13 +767,26 @@ export class AudioSource extends Behaviour {
|
|
|
611
767
|
* Use play() to resume from the paused position.
|
|
612
768
|
*/
|
|
613
769
|
pause() {
|
|
614
|
-
if (debug) console.log("
|
|
770
|
+
if (debug) console.log("[AudioSource] pause", this);
|
|
615
771
|
this._hasEnded = true;
|
|
616
772
|
this.shouldPlay = false;
|
|
773
|
+
// A user-initiated pause must not be revived by the visibility handler on the next
|
|
774
|
+
// foreground (it replays sources whose `wasPlaying` is set).
|
|
775
|
+
this.wasPlaying = false;
|
|
776
|
+
if (this._usesMediaElementSource) {
|
|
777
|
+
// File-URL media-element path. On iOS the MediaElementAudioSourceNode keeps emitting into
|
|
778
|
+
// the graph after element.pause() once the audio session has been interrupted — pausing
|
|
779
|
+
// the element does NOT silence it. So fade the gain to 0 (click-free silence), then
|
|
780
|
+
// freeze the element's position. play() fades it back in.
|
|
781
|
+
this.fadeGain(0);
|
|
782
|
+
this._audioElement?.pause();
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
617
785
|
if (this.sound && this.sound.isPlaying && this.sound.source) {
|
|
618
786
|
this._lastContextTime = this.sound?.context.currentTime;
|
|
619
787
|
this.sound.pause();
|
|
620
788
|
}
|
|
789
|
+
// MediaStream / legacy path: drop the stream element as before.
|
|
621
790
|
this._audioElement?.remove();
|
|
622
791
|
}
|
|
623
792
|
|
|
@@ -626,15 +795,25 @@ export class AudioSource extends Behaviour {
|
|
|
626
795
|
* Unlike pause(), calling play() after stop() will start from the beginning.
|
|
627
796
|
*/
|
|
628
797
|
stop() {
|
|
629
|
-
if (debug) console.log("
|
|
798
|
+
if (debug) console.log("[AudioSource] stop", this);
|
|
630
799
|
this._hasEnded = true;
|
|
631
800
|
this.shouldPlay = false;
|
|
801
|
+
this.wasPlaying = false;
|
|
802
|
+
if (this._usesMediaElementSource) {
|
|
803
|
+
// True stop: fade to silence (element.pause() alone doesn't silence it on iOS after an
|
|
804
|
+
// interruption), pause, and rewind to the start.
|
|
805
|
+
this.fadeGain(0);
|
|
806
|
+
this._audioElement?.pause();
|
|
807
|
+
if (this._audioElement) this._audioElement.currentTime = 0;
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
632
810
|
if (this.sound && this.sound.source) {
|
|
633
811
|
this._lastContextTime = this.sound?.context.currentTime;
|
|
634
812
|
if (debug)
|
|
635
|
-
console.log(this._lastContextTime)
|
|
813
|
+
console.log("[AudioSource] lastContextTime", this._lastContextTime);
|
|
636
814
|
this.sound.stop();
|
|
637
815
|
}
|
|
816
|
+
// MediaStream / legacy path: drop the stream element as before.
|
|
638
817
|
this._audioElement?.remove();
|
|
639
818
|
}
|
|
640
819
|
|
|
@@ -655,7 +834,9 @@ export class AudioSource extends Behaviour {
|
|
|
655
834
|
this.applySpatialDistanceSettings();
|
|
656
835
|
}
|
|
657
836
|
|
|
658
|
-
|
|
837
|
+
// Use the element-aware isPlaying getter, NOT sound.isPlaying: for a media-element source
|
|
838
|
+
// three.js leaves sound.isPlaying permanently false, which would fire "ended" every frame.
|
|
839
|
+
if (this.sound && !this.isPlaying && this.shouldPlay && !this._hasEnded) {
|
|
659
840
|
this._hasEnded = true;
|
|
660
841
|
if (debug)
|
|
661
842
|
console.log("Audio clip ended", this.clip);
|
|
@@ -46,7 +46,7 @@ ContextRegistry.registerCallback(ContextEvent.MissingCamera, (evt) => {
|
|
|
46
46
|
// Don't set the background color if the user set a background color in the <needle-engine> element
|
|
47
47
|
if (!evt.context.domElement.getAttribute("background-color")) {
|
|
48
48
|
let backgroundColor = "#efefef";
|
|
49
|
-
if (typeof window !== undefined && (window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
49
|
+
if (typeof window !== "undefined" && (window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
|
50
50
|
backgroundColor = "#1f1f1f";
|
|
51
51
|
}
|
|
52
52
|
scene.background = new Color(backgroundColor); // dont set it on the camera because this might be controlled from "background-color" attribute which is set on the scene directly. If the camera has a background color, it will override the scene's background color
|