@needle-tools/engine
Version:
Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.
195 lines • 7.45 kB
JavaScript
import { AudioContext } from "three";
import { Application } from "./engine_application.js";
/**
* @internal
* Ensure the audio context is resumed if it gets suspended or interrupted */
export function ensureAudioContextIsResumed() {
Application.registerWaitForInteraction(() => {
// this is a fix for https://github.com/mrdoob/three.js/issues/27779 & https://linear.app/needle/issue/NE-4257
const ctx = AudioContext.getContext();
ctx.addEventListener("statechange", () => {
setTimeout(() => {
// on iOS the audiocontext can be interrupted: https://developer.mozilla.org/en-US/docs/Web/API/BaseAudioContext/state#resuming_interrupted_play_states_in_ios_safari
const state = ctx.state;
if (state === "suspended" || state === "interrupted") {
ctx.resume()
.then(() => { console.log("AudioContext resumed successfully"); })
.catch((e) => { console.log("Failed to resume AudioContext: " + e); });
}
}, 500);
});
});
}
/**
* Represents an audio clip that can be loaded and played independently.
* The AudioClip class encapsulates the URL of the audio resource and provides
* methods for playback control (play, pause, stop) and querying duration.
*/
export class AudioClip {
url;
/**
* Creates a new AudioClip instance with the specified URL.
* @param url The URL of the audio resource to load. This can be a path to an audio file or a MediaStream URL.
*/
constructor(url) {
this.url = url;
}
/** Whether the clip is currently playing.
* @returns `true` if the clip is actively playing audio.
*/
get isPlaying() {
return this._audioElement !== undefined
&& !this._audioElement.paused
&& !this._audioElement.ended;
}
/**
* The total duration of the audio clip in seconds.
* Loads the audio metadata if not already available.
* @returns A promise that resolves with the duration in seconds.
*/
getDuration() {
if (this._duration !== undefined) {
return Promise.resolve(this._duration);
}
return this.ensureAudioElement().then(audio => {
this._duration = audio.duration;
return audio.duration;
});
}
/**
* Plays the audio clip from the current position.
* @returns A promise that resolves when playback finishes, or rejects on error.
* If the clip is looping, the promise will never resolve on its own – call {@link stop} or {@link pause} to end playback.
*/
// #region Play
play() {
return this.ensureAudioElement().then(audio => {
return new Promise((resolve, reject) => {
const onEnded = () => {
cleanup();
resolve();
};
const onError = () => {
cleanup();
reject(new Error(`Playback error for ${this.url}`));
};
const onPause = () => {
// pause/stop also resolve the promise
cleanup();
resolve();
};
const cleanup = () => {
audio.removeEventListener("ended", onEnded);
audio.removeEventListener("error", onError);
audio.removeEventListener("pause", onPause);
};
audio.addEventListener("ended", onEnded);
audio.addEventListener("error", onError);
audio.addEventListener("pause", onPause);
audio.play().catch(err => {
cleanup();
reject(err);
});
});
});
}
/**
* Pauses playback at the current position.
* Call {@link play} to resume.
*/
// #region Pause/Stop
pause() {
this._audioElement?.pause();
}
/**
* Stops playback and resets the position to the beginning.
*/
stop() {
if (this._audioElement) {
this._audioElement.pause();
this._audioElement.currentTime = 0;
}
}
/** Whether the clip should loop when reaching the end. */
get loop() { return this._loop; }
set loop(value) {
this._loop = value;
if (this._audioElement)
this._audioElement.loop = value;
}
/** Playback volume from 0 (silent) to 1 (full). */
get volume() { return this._volume; }
set volume(value) {
this._volume = value;
if (this._audioElement)
this._audioElement.volume = value;
}
/** Current playback position in seconds. */
get currentTime() { return this._audioElement?.currentTime ?? 0; }
set currentTime(value) {
if (this._audioElement)
this._audioElement.currentTime = value;
}
/** Normalized playback progress from 0 to 1.
* @returns The current playback position as a value between 0 and 1, or 0 if the duration is unknown.
*/
get progress() {
if (!this._audioElement || !this._duration)
return 0;
return this._audioElement.currentTime / this._duration;
}
/**
* Seeks to a normalized position (0–1) in the clip.
* @param position A value between 0 (start) and 1 (end).
*/
// #region Seek
seek(position) {
if (this._audioElement && this._duration) {
this._audioElement.currentTime = Math.max(0, Math.min(1, position)) * this._duration;
}
}
/** The underlying HTMLAudioElement, or `undefined` if not yet created.
* Use this to connect the element to the Web Audio API via `createMediaElementSource()`.
* @returns The HTMLAudioElement if the clip has been loaded or played, otherwise `undefined`.
*/
get audioElement() { return this._audioElement; }
_audioElement;
_duration;
_loadPromise;
_loop = false;
_volume = 1;
/** Lazily creates and loads the shared HTMLAudioElement. */
ensureAudioElement() {
if (this._audioElement && this._loadPromise) {
return this._loadPromise;
}
const audio = this._audioElement ?? new Audio(this.url);
this._audioElement = audio;
audio.loop = this._loop;
audio.volume = this._volume;
if (audio.readyState >= HTMLMediaElement.HAVE_METADATA) {
this._duration = audio.duration;
this._loadPromise = Promise.resolve(audio);
return this._loadPromise;
}
this._loadPromise = new Promise((resolve, reject) => {
const onLoaded = () => {
cleanup();
this._duration = audio.duration;
resolve(audio);
};
const onError = (e) => {
cleanup();
reject(new Error(`Failed to load audio clip from ${this.url}: ${e}`));
};
const cleanup = () => {
audio.removeEventListener("loadedmetadata", onLoaded);
audio.removeEventListener("error", onError);
};
audio.addEventListener("loadedmetadata", onLoaded);
audio.addEventListener("error", onError);
});
return this._loadPromise;
}
}
//# sourceMappingURL=engine_audio.js.map