UNPKG

sound-manager

Version:
511 lines (421 loc) 13.5 kB
import { BaseNode, } from '../Node/BaseNode'; import { getFadeVolume, } from '../Fade/getFadeVolume'; import { getFrozenObject, } from '../functions/getFrozenObject'; import { IFade, } from '../Fade/IFade'; import { IPlaySoundOptions, } from './IPlaySoundOptions'; import { ISound, } from './ISound'; import { ISoundOptions, } from './ISoundOptions'; import { NodeTypes, } from '../enums/NodeTypes'; import { PanelRegisterableNodeMixin, } from '../Node/PanelRegisterableNodeMixin'; import { playAudioSource, } from './playAudioSource'; import { scheduleHtmlAudioFades, } from '../Fade/scheduleHtmlAudioFades'; import { strings, } from './Sound.strings'; import { TaggableNodeMixin, } from '../Node/TaggableNodeMixin'; import { assert, assertValid, } from 'ts-assertions'; export class Sound extends PanelRegisterableNodeMixin( TaggableNodeMixin( BaseNode )) implements ISound { get type(): NodeTypes.Sound { return NodeTypes.Sound; } private __playing = false; private __isStopping = false; private __startedTime = 0; private __pausedTime = 0; private __fadeStartTime = 0; private __fadeStopTime = 0; private __promise: Promise<void> | null = null; private __loop = false; // Tracks how many times the song has looped this play session. private __loopIterationCount = 0; // // Do not initialize. `false` is still a valid override value. private __loopOverride?: boolean; // private __fade: IFade | null = null; private __fadeOnLoops = true; private __fadeOverride?: IFade; private __audioElement: HTMLAudioElement | null = null; private __resolveOnEnd: ((value?: void | PromiseLike<void> | undefined) => void) | undefined = undefined; private __rejectOnError?: (err: string | Error) => Error; /* istanbul ignore next */ /* @ts-ignore */ public getManagerVolume: () => number = () => 1; /* istanbul ignore next */ public getGroupVolume: () => number = () => 1; constructor(options: ISoundOptions) { super({ ...options }); assert(options, strings.CTOR_OPTIONS_INVALID); const { audioElement, fade, fadeOnLoops, getManagerVolume, loop, trackPosition, } = options; this.__audioElement = assertValid<HTMLAudioElement>( audioElement, strings.CTOR_AUDIO_ELEMENT_INVALID, ); /* Needed to calculate volume for HTML5 audio. */ this.getManagerVolume = assertValid<() => number>( getManagerVolume, strings.CTOR_GET_MANAGER_VOLUME_INVALID, (aa) => typeof aa === 'function', ); this.__initializeArgumentProperties({ fade, fadeOnLoops, loop, trackPosition, }); }; private readonly __initializeArgumentProperties = ({ fade, fadeOnLoops, loop, trackPosition, }: { fade: IFade | undefined, fadeOnLoops: boolean | undefined, loop: boolean | undefined, trackPosition: number | undefined, }) => { if (fade) { this.setFade(fade); } if (typeof fadeOnLoops === 'boolean') { this.__fadeOnLoops = fadeOnLoops; } if (typeof loop === 'boolean') { this.setLoop(loop); } if (trackPosition && trackPosition > 0) { this.setTrackPosition(trackPosition); } } public readonly setVolume = (value: number) => { super.setVolume(value); this.updateAudioElementVolume(); return this; }; public readonly getTrackPosition = () => { if (this.isPlaying()) { return assertValid<HTMLAudioElement>( this.__audioElement, strings.GET_TRACK_POSITION_AUDIO_ELEMENT_INVALID, ).currentTime * 1000; } return this.__pausedTime; }; // Must be milliseconds. public readonly setTrackPosition = (milliseconds: number) => { this.__startedTime = milliseconds / 1000; assertValid<HTMLAudioElement>( this.__audioElement, strings.SET_TRACK_POSITION_AUDIO_ELEMENT_INVALID, ).currentTime = this.__startedTime; return this; }; // Returned in milliseconds. public readonly getDuration = () => { return assertValid<HTMLAudioElement>( this.__audioElement, strings.GET_DURATION_AUDIO_ELEMENT_INVALID, ).duration * 1000; }; public readonly isPlaying = () => Boolean(this.__playing); public readonly getLoop = () => typeof this.__loopOverride === 'boolean' ? this.__loopOverride : this.__loop; public readonly setLoop = (loop: boolean) => { this.__loop = Boolean(loop); return this; }; public readonly getFade = () => this.__fadeOverride || this.__fade; public readonly setFade = (fade: IFade | null) => { this.__fade = fade === null ? fade : getFrozenObject({ easingCurve: getFrozenObject({ ...fade.easingCurve, }), length: getFrozenObject({ ...fade.length, }), }); return this; }; public readonly play = ({ fadeOnLoops, fadeOverride, loopOverride, }: Partial<IPlaySoundOptions> = { fadeOnLoops: false, fadeOverride: null, loopOverride: undefined, }) => { try { if (this.isPlaying()) { return assertValid<Promise<void>>(this.__promise); } this.__fadeStartTime = this.getTrackPosition(); this.__updateSoundTimes(); this.__initializeForPlay({ fadeOnLoops, fadeOverride, loopOverride, }); playAudioSource(this, this.__audioElement); /* Reset the paused time. */ this.__pausedTime = 0; /* Ensure the sound knows it's playing. */ this.__playing = true; /* Emit the promise that was either just generated or emitted on previous * unfinished plays. */ return assertValid<Promise<void>>(this.__promise); } catch (err) { if (typeof this.__rejectOnError === 'function') { this.__rejectOnError(err); } } return this.__promise as any; }; /* Regenerates the source node, generates the promise if it does not already * exist, registers events, etc. */ private readonly __initializeForPlay = ({ fadeOnLoops, fadeOverride, loopOverride, }: Partial<IPlaySoundOptions> = {}) => { /* Sets the override properties e.g. if this sound is part of a * playlist, or the changes occurred through play options. */ if (typeof fadeOnLoops === 'boolean') { this.__fadeOnLoops = fadeOnLoops; } if (fadeOverride) { this.__fadeOverride = getFrozenObject({ easingCurve: getFrozenObject({ ...fadeOverride.easingCurve, }), length: getFrozenObject({ ...fadeOverride.length, }), }); } if (typeof loopOverride === 'boolean') { this.__loopOverride = loopOverride; } const fade = this.getFade(); const audioElement = this.__audioElement; if (fade) { /* Update the audio element volume on every tick, including fade * volume. */ /* istanbul ignore next */ this.__initializeFadeForPlay(this.__audioElement); } this.updateAudioElementVolume(); /* If a promise is already on the Sound, it must be respected, and * a new one should not be constructed. */ if (!this.__promise) { this.__initializePromiseForPlay(); } this.__initializeEventsForPlay(audioElement); }; private readonly __updateSoundTimes = () => { assertValid<HTMLAudioElement>( this.__audioElement, ).currentTime = this.getTrackPosition() / 1000; }; private readonly __initializeFadeForPlay = ( audioElement?: HTMLAudioElement | null, ) => { scheduleHtmlAudioFades( assertValid<HTMLAudioElement>(audioElement), this.updateAudioElementVolume, ); }; private readonly __initializeStartStopResolver = ( resolve: (value?: void | PromiseLike<void> | undefined) => void, ) => ( this.__resolveOnEnd = resolve ); private readonly __initializeRejector = (reject: Function) => ( this.__rejectOnError = (message?: string | Error) => reject( message || 'The sound was stopped, probably by a user-created script.' ) ); private readonly __initializePromiseForPlay = () => ( this.__promise = new Promise((resolve, reject) => { /* Allows the same promise to be used across pauses. */ this.__initializeStartStopResolver(resolve); /* Allow the promise to be rejected if the sound fails. */ this.__initializeRejector(reject); }) ); private readonly __initializeEventsForPlay = ( audioElement?: HTMLAudioElement | null, ) => { const source = assertValid<HTMLAudioElement>( audioElement, 'The audio element was not found in Sound.__initializeEventsForPlay.', ); /* Register the ended export function to fire when the audio source emits the * 'ended' event. */ const endHandler = this.__getEndEventHandler( source, ); source.addEventListener('ended', endHandler); // Pass the handler back up. return endHandler; }; private readonly __getEndEventHandler = ( source: AudioBufferSourceNode | HTMLAudioElement, ) => { const ended = (): Promise<void> => { this.__isStopping = true; // Remove the 'ended' event listener. source.removeEventListener('ended', ended); // If the track ended, and loop is true, manually reset it to the // beginning and keep playing. Increment the loop counter as well. this.pause(); this.__isStopping = false; if (this.getLoop()) { this.__loopIterationCount += 1; return this.play({ fadeOnLoops: this.__fadeOnLoops, fadeOverride: this.__fadeOverride, loopOverride: this.__loopOverride, }); } return this.stop(); }; return ended; }; public readonly pause = () => { /* Must be executed before __playing = false. */ this.__fadeStartTime = 0; this.__fadeStopTime = 0; this.__startedTime = 0; if (!this.__isStopping) { this.__pausedTime = this.getTrackPosition(); } try { if (this.isPlaying()) { /* Must be executed after __pausedTime = ... */ assertValid<HTMLAudioElement>(this.__audioElement).pause(); } /* Must be executed after __pausedTime = ... and this.getPlaying(). */ this.__playing = false; } catch (err) { if (typeof this.__rejectOnError === 'function') { this.__rejectOnError(err); } else { throw err; } } return this; }; public readonly stop = (hard = false) => { this.__isStopping = true; this.__loopIterationCount = 0; this.__fadeStopTime = this.getTrackPosition(); this.__initializeFadeForPlay(this.__audioElement); if (hard) { // Delete the rejector. delete this.__rejectOnError; // Delete the resolver. delete this.__resolveOnEnd; } else { const delay = this.getFade()?.length.out || 0; setTimeout(() => { this.pause(); this.setTrackPosition(0); if (typeof this.__resolveOnEnd === 'function') { this.__resolveOnEnd(); } else { Promise.resolve(this.__promise); } this.__isStopping = false; // Delete the rejector. delete this.__rejectOnError; // Delete the resolver. delete this.__resolveOnEnd; }, delay + 50); } return this.__promise!; }; public readonly rewind = (milliseconds: number) => { this.setTrackPosition(this.getTrackPosition() - milliseconds); return this; }; public readonly fastForward = (milliseconds: number) => { this.setTrackPosition(this.getTrackPosition() + milliseconds); return this; }; public readonly updateAudioElementVolume = () => { /* Set the audio element volume to the product of manager, group, and * fade, and sound volumes. */ const managerVolume = this.getManagerVolume(); const groupVolume = this.getGroupVolume(); const soundVolume = this.getVolume(); const fadeVolume = this.getFadeVolume(); const volProduct = managerVolume * groupVolume * soundVolume * fadeVolume; const boundedVol = Math.min(1, Math.max(0, volProduct)); assertValid<HTMLAudioElement>(this.__audioElement).volume = boundedVol; return this; }; // This is a ratio, so it doesn't need to know anything about the sound's // underlying volume(s). This ratio is computed solely from arguments. public readonly getFadeVolume = () => { const fade = this.getFade(); if (fade) { return getFadeVolume({ fade, duration: this.getDuration(), fadeOnLoops: this.__fadeOnLoops, loop: Boolean(this.__loopOverride), isStopping: this.__isStopping, loopIterationCount: this.__loopIterationCount, startingTime: this.__fadeStartTime, stoppingTime: this.__fadeStopTime, time: this.getTrackPosition(), }); } return 1; }; }