furkleindustries/sound-manager

View on GitHub
src/Sound/Sound.ts

Summary

Maintainability
C
1 day
Test Coverage
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;
  };
}