@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.
Files changed (30) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/SKILL.md +4 -1
  3. package/dist/{needle-engine.bundle-Cay176T9.umd.cjs → needle-engine.bundle-DOChN3At.umd.cjs} +103 -103
  4. package/dist/{needle-engine.bundle-KPYQq4Ry.js → needle-engine.bundle-Du3vf5L1.js} +3638 -3551
  5. package/dist/{needle-engine.bundle-D02SHqiW.min.js → needle-engine.bundle-fP5tOMI1.min.js} +104 -104
  6. package/dist/needle-engine.d.ts +49 -2
  7. package/dist/needle-engine.js +2 -2
  8. package/dist/needle-engine.min.js +1 -1
  9. package/dist/needle-engine.umd.cjs +1 -1
  10. package/lib/engine/debug/debug_spector.js +1 -1
  11. package/lib/engine/debug/debug_spector.js.map +1 -1
  12. package/lib/engine/engine_audio.d.ts +30 -1
  13. package/lib/engine/engine_audio.js +57 -13
  14. package/lib/engine/engine_audio.js.map +1 -1
  15. package/lib/engine/engine_context_registry.js +1 -1
  16. package/lib/engine/engine_context_registry.js.map +1 -1
  17. package/lib/engine/engine_shims.js +1 -1
  18. package/lib/engine/engine_shims.js.map +1 -1
  19. package/lib/engine-components/AudioSource.d.ts +49 -2
  20. package/lib/engine-components/AudioSource.js +261 -66
  21. package/lib/engine-components/AudioSource.js.map +1 -1
  22. package/lib/engine-components/CameraUtils.js +1 -1
  23. package/lib/engine-components/CameraUtils.js.map +1 -1
  24. package/package.json +1 -1
  25. package/src/engine/debug/debug_spector.ts +1 -1
  26. package/src/engine/engine_audio.ts +68 -15
  27. package/src/engine/engine_context_registry.ts +1 -1
  28. package/src/engine/engine_shims.ts +1 -1
  29. package/src/engine-components/AudioSource.ts +244 -63
  30. 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 _lastClipStartedLoading;
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 { AudioLoader, PositionalAudio } from "three";
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() { return this.sound?.isPlaying ?? false; }
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 && this.sound) {
144
- return this.sound?.context.currentTime / duration;
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 && this.sound) {
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() { return this.sound?.source ? (this.sound.source?.context.currentTime - this._lastContextTime + this.sound.offset) : 0; }
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.sound)
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
- if (this.sound)
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
- _lastClipStartedLoading = null;
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
- this.audioLoader.load(this.clip, this.createAudio, () => { }, console.error);
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
- this.wasPlaying = this.isPlaying;
381
- if (this.isPlaying) {
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
- if (this.enabled && this.playOnAwake && !this.isPlaying && AudioSource.userInteractionRegistered && this.wasPlaying) {
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
- createAudio = (buffer) => {
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
- if (sound.isPlaying)
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
- if (this._lastClipStartedLoading === clip) {
490
- if (debug)
491
- console.log("Is currently loading:", this._lastClipStartedLoading, this);
492
- return;
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
- // Check if we need to call load first
532
- let needsLoading = !this.sound || (clip && clip !== this.clip);
533
- if (typeof clip === "string" && !this.audioLoader)
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
- if (this.sound && !this.sound.isPlaying) {
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
- // We have to set the audio element source to the mediastream as well
553
- // otherwise it will not play for some reason...
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
- if (this._audioElement)
566
- this._audioElement.remove();
567
- this.sound.play(muted ? .1 : 0);
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("Pause", this);
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("Pause", this);
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
- if (this.sound && !this.sound.isPlaying && this.shouldPlay && !this._hasEnded) {
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);