@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.
Files changed (29) hide show
  1. package/CHANGELOG.md +4 -0
  2. package/dist/{needle-engine.bundle-D_nuVSFb.umd.cjs → needle-engine.bundle-DOChN3At.umd.cjs} +103 -103
  3. package/dist/{needle-engine.bundle-a0Pw5htf.js → needle-engine.bundle-Du3vf5L1.js} +3638 -3551
  4. package/dist/{needle-engine.bundle-DlyYXu9v.min.js → needle-engine.bundle-fP5tOMI1.min.js} +104 -104
  5. package/dist/needle-engine.d.ts +49 -2
  6. package/dist/needle-engine.js +2 -2
  7. package/dist/needle-engine.min.js +1 -1
  8. package/dist/needle-engine.umd.cjs +1 -1
  9. package/lib/engine/debug/debug_spector.js +1 -1
  10. package/lib/engine/debug/debug_spector.js.map +1 -1
  11. package/lib/engine/engine_audio.d.ts +30 -1
  12. package/lib/engine/engine_audio.js +57 -13
  13. package/lib/engine/engine_audio.js.map +1 -1
  14. package/lib/engine/engine_context_registry.js +1 -1
  15. package/lib/engine/engine_context_registry.js.map +1 -1
  16. package/lib/engine/engine_shims.js +1 -1
  17. package/lib/engine/engine_shims.js.map +1 -1
  18. package/lib/engine-components/AudioSource.d.ts +49 -2
  19. package/lib/engine-components/AudioSource.js +261 -66
  20. package/lib/engine-components/AudioSource.js.map +1 -1
  21. package/lib/engine-components/CameraUtils.js +1 -1
  22. package/lib/engine-components/CameraUtils.js.map +1 -1
  23. package/package.json +1 -1
  24. package/src/engine/debug/debug_spector.ts +1 -1
  25. package/src/engine/engine_audio.ts +68 -15
  26. package/src/engine/engine_context_registry.ts +1 -1
  27. package/src/engine/engine_shims.ts +1 -1
  28. package/src/engine-components/AudioSource.ts +244 -63
  29. package/src/engine-components/CameraUtils.ts +1 -1
@@ -1,4 +1,4 @@
1
- import { AudioLoader, PositionalAudio } from "three";
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 { return this.sound?.isPlaying ?? false; }
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 && this.sound) {
158
- return this.sound?.context.currentTime / duration;
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 && this.sound) {
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 { return this.sound?.source ? (this.sound.source?.context.currentTime - this._lastContextTime + this.sound.offset) : 0; }
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.sound) this.sound.setLoop(val);
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
- if (this.sound) this.sound.setPlaybackRate(val);
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 _lastClipStartedLoading: string | MediaStream | null = null;
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
- this.audioLoader.load(this.clip, this.createAudio, () => { }, console.error);
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
- this.wasPlaying = this.isPlaying;
415
- if (this.isPlaying) {
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
- if (this.enabled && this.playOnAwake && !this.isPlaying && AudioSource.userInteractionRegistered && this.wasPlaying) {
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
- private createAudio = (buffer?: AudioBuffer) => {
437
- if(this.destroyed) {
438
- if(debug) console.warn("AudioSource destroyed, not creating audio", this.name);
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
- if (sound.isPlaying)
451
- sound.stop();
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
- if (sound.isPlaying)
461
- sound.stop();
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
- // const panner = sound.panner;
464
- // panner.coneOuterGain = 1;
465
- // sound.setDirectionalCone(360, 360, 1);
466
- // const src = sound.context.createBufferSource();
467
- // src.buffer = sound.buffer;
468
- // src.connect(sound.panner);
469
- // src.start(this.audioContext?.currentTime);
470
- // const gain = sound.context.createGain();
471
- // gain.gain.value = 1 - this.spatialBlend;
472
- // src.connect(gain);
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
- if (this._lastClipStartedLoading === clip) {
527
- if (debug) console.log("Is currently loading:", this._lastClipStartedLoading, this)
528
- return;
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
- // Check if we need to call load first
567
- let needsLoading = !this.sound || (clip && clip !== this.clip);
568
- if (typeof clip === "string" && !this.audioLoader) needsLoading = true;
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
- if (this.sound && !this.sound.isPlaying) {
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
- // We have to set the audio element source to the mediastream as well
589
- // otherwise it will not play for some reason...
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
- if (this._audioElement) this._audioElement.remove();
604
- this.sound.play(muted ? .1 : 0);
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("Pause", this);
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("Pause", this);
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
- if (this.sound && !this.sound.isPlaying && this.shouldPlay && !this._hasEnded) {
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