UNPKG

@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.

208 lines (188 loc) 7.75 kB
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 as AudioContextState | "interrupted"; 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 { /** * 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(public readonly url: string) { } /** Whether the clip is currently playing. * @returns `true` if the clip is actively playing audio. */ get isPlaying(): boolean { 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(): Promise<number> { 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(): Promise<void> { return this.ensureAudioElement().then(audio => { return new Promise<void>((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(): void { this._audioElement?.pause(); } /** * Stops playback and resets the position to the beginning. */ stop(): void { if (this._audioElement) { this._audioElement.pause(); this._audioElement.currentTime = 0; } } /** Whether the clip should loop when reaching the end. */ get loop(): boolean { return this._loop; } set loop(value: boolean) { this._loop = value; if (this._audioElement) this._audioElement.loop = value; } /** Playback volume from 0 (silent) to 1 (full). */ get volume(): number { return this._volume; } set volume(value: number) { this._volume = value; if (this._audioElement) this._audioElement.volume = value; } /** Current playback position in seconds. */ get currentTime(): number { return this._audioElement?.currentTime ?? 0; } set currentTime(value: number) { 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(): number { 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: number): void { 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(): HTMLAudioElement | undefined { return this._audioElement; } private _audioElement?: HTMLAudioElement; private _duration?: number; private _loadPromise?: Promise<HTMLAudioElement>; private _loop: boolean = false; private _volume: number = 1; /** Lazily creates and loads the shared HTMLAudioElement. */ private ensureAudioElement(): Promise<HTMLAudioElement> { 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<HTMLAudioElement>((resolve, reject) => { const onLoaded = () => { cleanup(); this._duration = audio.duration; resolve(audio); }; const onError = (e: Event) => { 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; } }