frontispiece / apps / frontispiece-editor / src / audio / audio-engine.ts
audio-engine.ts
Raw
/*
 * This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at https://mozilla.org/MPL/2.0/.
 */

import { Howl, Howler, HowlOptions } from 'howler'

export enum SOUND_TYPE {
	SFX,
	MUSIC,
	AMBIENT,
}

export type AudioEngineSound = {
	name: string
	sound: Howl
	type: SOUND_TYPE
}

export type PlaySoundOptions = {
	fadeIn?: boolean
	fadeTime?: number
}

export type StopSoundOptions = {
	fadeOut?: boolean
	fadeTime?: number
}

/**
 * Audio Engine. Note that this should only be called from within the AudioHookStateController,
 * so that hookstate and the audio engine remain in sync.
 */
export class AudioEngine {
	// used just for preloading; sounds here aren't defined as part of the map and shouldn't be played
	// the key is the _filename_
	protected preloadedSounds: Record<string, Howl>
	protected sounds: Record<string, AudioEngineSound>

	// volumes are a number from 0 to 1
	// TODO: make this synced up with the hookstate version. May require us to duplicate or move it entirely.
	protected volume: Record<SOUND_TYPE, number>

	// currently playing sounds
	protected currentSoundBuffer: AudioEngineSound[]
	protected listeners: AudioEngineListenerInterface[]

	constructor() {
		this.preloadedSounds = {}
		this.sounds = {}
		this.currentSoundBuffer = []
		this.volume = {
			[SOUND_TYPE.SFX]: 1,
			[SOUND_TYPE.MUSIC]: 1,
			[SOUND_TYPE.AMBIENT]: 1,
		}

		this.listeners = []
	}

	addListener(listener: AudioEngineListenerInterface) {
		this.listeners.push(listener)
	}

	removeListener(listener: AudioEngineListenerInterface) {
		const index = this.listeners.indexOf(listener)
		if (index > -1) {
			this.listeners.splice(index, 1)
		}
	}

	public exists(name: string) {
		return this.sounds[name] !== undefined;
	}

	public getType(name: string) {
		if (!this.sounds[name]) {
			console.warn(`Trying to get type of sound ${name} that doesn't exist`)
			return
		}
		return SOUND_TYPE.SFX
	}

	public getVolume(type: SOUND_TYPE) {
		return this.volume[type]
	}

	public setVolume(type: SOUND_TYPE, volume: number) {
		this.volume[type] = volume
		const sounds = this.getPlayingSoundsOfType(type)
		for (let sound of sounds) {
			sound.sound.volume(volume)
		}

		for (let listener of this.listeners) {
			if (listener.setVolume) {
				listener.setVolume(type, volume)
			}
		}
	}

	/**
	 * Helper function to stop the previous music and play a new song.
	 * @param name
	 * @param opts
	 */
	public changeMusic(
		name: string,
		playOpts: PlaySoundOptions = {},
		stopOpts: StopSoundOptions = {}
	) {
		if (!this.sounds[name]) {
			console.warn(`Trying to play music ${name} that doesn't exist`)
			return
		}
		const sound = this.sounds[name]
		if (sound.type !== SOUND_TYPE.MUSIC) {
			console.warn('Trying to change music into a sound clip that is not music')
		}
		this.stopAllSoundsOfType(SOUND_TYPE.MUSIC, stopOpts)
		this.playSound(name, playOpts)
	}

	public playSound(name: string, opts: PlaySoundOptions = {}) {
		if (!this.sounds[name]) {
			console.warn(`Trying to play sound ${name} that doesn't exist`)
			return
		}
		const sound = this.sounds[name].sound
		const type = this.sounds[name].type
		if (sound.loop() && sound.playing()) {
			sound.stop()
		}

		if (opts.fadeIn) {
			const fadeTime = opts.fadeTime ?? 1000
			sound.fade(0, this.volume[type], fadeTime)
		} else {
			sound.volume(this.volume[type])
		}

		sound.seek(0)
		sound.play()

		this.addSoundToBuffer(this.sounds[name])

		for (let listener of this.listeners) {
			if (listener.playSound) {
				listener.playSound(this.sounds[name])
			}
		}
	}

	public stopSound(name: string, opts: StopSoundOptions = {}) {
		if (!this.sounds[name]) {
			console.warn(`Trying to stop sound ${name} that doesn't exist`)
			return
		}
		const sound = this.sounds[name].sound

		if (opts.fadeOut) {
			const fadeTime = opts.fadeTime ?? 1000
			sound.fade(sound.volume(), 0, fadeTime)
		} else {
			sound.stop()
		}

		this.removeSoundFromBuffer(this.sounds[name])

		for (let listener of this.listeners) {
			if (listener.stopSound) {
				listener.stopSound(this.sounds[name])
			}
		}
	}

	public stopAllSounds(opts: StopSoundOptions = {}) {
		const sounds = this.getPlayingSounds()
		for (let sound of sounds) {
			this.stopSound(sound.name, opts)
		}
	}

	public stopAllSoundsOfType(type: SOUND_TYPE, opts: StopSoundOptions = {}) {
		const sounds = this.getPlayingSoundsOfType(type)
		for (let sound of sounds) {
			this.stopSound(sound.name, opts)
		}
	}

	public preloadSound(source: string[], options: HowlOptions = {}): Howl {
		const howlOptions: any = options

		const sound = new Howl({
			...howlOptions,
			src: source,
		})

		this.preloadedSounds[source[0]] = sound
		return sound
	}

	public addSound(source: string[], name: string, type: SOUND_TYPE) {
		const howlOptions: any = {}

		if (type === SOUND_TYPE.MUSIC || type === SOUND_TYPE.AMBIENT) {
			howlOptions.loop = true
		}

		let sound = this.preloadedSounds[source[0]]
		if (!sound) {
			sound = this.preloadSound(source)
		}

		if (type === SOUND_TYPE.MUSIC || type === SOUND_TYPE.AMBIENT) {
			sound.loop(true)
		} else {
			sound.loop(false)
		}

		const soundObj: AudioEngineSound = {
			name,
			sound,
			type,
		}

		this.sounds[name] = soundObj

		sound.on('fade', () => {
			if (sound.volume() === 0) {
				if (!this.isSoundPlayingInBuffer(this.sounds[name])) sound.stop()
			}
		})

		if (type === SOUND_TYPE.SFX) {
			sound.on('end', () => {
				this.removeSoundFromBuffer(this.sounds[name])
				for (let listener of this.listeners) {
					if (listener.stopSound) {
						listener.stopSound(this.sounds[name])
					}
				}
			})
		}

		return soundObj
	}

	public isPlaying(name: string): boolean {
		if (!this.sounds[name]) {
			return false
		}
		return this.sounds[name].sound.playing()
	}

	public getPlayingSounds(): AudioEngineSound[] {
		return this.currentSoundBuffer
	}

	public getPlayingSoundsOfType(type: SOUND_TYPE): AudioEngineSound[] {
		const sounds: AudioEngineSound[] = []
		for (let sound of this.currentSoundBuffer) {
			if (sound.type === type) {
				sounds.push(sound)
			}
		}
		return sounds
	}

	protected isSoundPlayingInBuffer(sound: AudioEngineSound) {
		const index = this.currentSoundBuffer.indexOf(sound)
		if (index > -1) {
			return true
		}
		return false
	}

	protected addSoundToBuffer(sound: AudioEngineSound) {
		const index = this.currentSoundBuffer.indexOf(sound)
		if (index > -1) {
			return
		}
		this.currentSoundBuffer.push(sound)
	}

	protected removeSoundFromBuffer(sound: AudioEngineSound) {
		const index = this.currentSoundBuffer.indexOf(sound)
		if (index > -1) {
			this.currentSoundBuffer.splice(index, 1)
		}
	}
}

export interface AudioEngineListenerInterface {
	setVolume?: (type: SOUND_TYPE, volume: number) => void
	playSound?: (sound: AudioEngineSound) => void
	stopSound?: (sound: AudioEngineSound) => void
}