Files
GDevelop/GDJS/Runtime/howler-sound-manager/howler-sound-manager.ts

960 lines
28 KiB
TypeScript

///<reference path='../types/howler'>
/*
* GDevelop JS Platform
* Copyright 2013-present Florian Rival (Florian.Rival@gmail.com). All rights reserved.
* This project is released under the MIT License.
*/
namespace gdjs {
const logger = new gdjs.Logger('Audio manager');
const resourceKinds: Array<ResourceKind> = ['audio'];
const HowlParameters: HowlOptions = {
preload: true,
onplayerror: (_, error) =>
logger.error("Can't play an audio file: " + error),
onloaderror: (_, error) =>
logger.error('Error while loading an audio file: ' + error),
};
/**
* Ensure the volume is between 0 and 1.
*/
const clampVolume = (volume: float): float => {
if (volume > 1.0) {
return 1.0;
}
if (volume < 0) {
return 0;
}
return volume;
};
/**
* A thin wrapper around a Howl object with:
* * Handling of callbacks when the sound is not yet loaded.
* * Automatic clamping when calling `setRate` to ensure a valid value is passed to Howler.js.
* * Automatic clamping when calling `setVolume` so that the volume is always between 0 and 1.
*
* @memberof gdjs
* @class HowlerSound
*/
export class HowlerSound {
/**
* The ID of the played sound.
*/
private _id: integer | null = null;
/**
* The Howl passed to the constructor.
* It defines the sound file that is being played.
*/
private _howl: Howl;
/**
* The **initial** volume at which the sound is being played.
* Once the sound is started, this volume can be not in sync
* (in the case the sound is faded by Howler), so volume must
* be gotten from `this._howl` directly.
*
* This value is clamped between 0 and 1.
*/
private _initialVolume: float;
/**
* Whether the sound is being played in a loop or not.
*/
private _loop: boolean;
/**
* The rate (speed) the sound is being played at.
* This value is not clamped, though technically Howler.js will only
* accepts values between a specific range (so we clamp this when
* passing it to Howler.js, but keep the original value here).
*/
private _rate: float;
/**
* An array of callbacks to call once the sound starts to play.
*/
private _oncePlay: Array<HowlCallback> = [];
/**
* An array of callbacks to call everytime the sound starts to play.
*/
private _onPlay: Array<HowlCallback> = [];
constructor(howl: Howl, volume: float, loop: boolean, rate: float) {
this._howl = howl;
this._initialVolume = clampVolume(volume);
this._loop = loop;
this._rate = rate;
}
/**
* Returns true if the associated howl is fully loaded.
*/
isLoaded(): boolean {
return this._howl.state() === 'loaded';
}
/**
* Begins playback of the sound, or if the Howl is still loading, schedule playing for once it loads.
* @returns The current instance for chaining.
*/
play(): this {
if (this.isLoaded()) {
const newID = this._howl.play(
this._id === null ? '__default' : this._id
);
this._id = newID;
// Set the howl properties as soon as the sound is played and we have its ID.
this._howl.volume(this._initialVolume, newID); // this._initialVolume is already clamped between 0 and 1.
this._howl.loop(this._loop, newID);
// this._rate is not clamped, but we need to clamp it when passing it to Howler.js as it
// only supports a specific range.
this._howl.rate(gdjs.HowlerSoundManager.clampRate(this._rate), newID);
// Manually handle the play event before we have an ID.
// Before loading, howler won't register events as without an ID we cannot set a listener.
// Once we have an ID, we can transfer control of the events to howler.
// We also need to call them once as Howler doesn't for the first play event.
this._onPlay.forEach((func) => {
// Transfer the event to howler now that we have an ID
this.on('play', func);
func(newID);
});
this._oncePlay.forEach((func) => func(newID));
this._onPlay = [];
this._oncePlay = [];
} else this._howl.once('load', () => this.play()); // Play only once the howl is fully loaded
return this;
}
/**
* Pauses playback of the sound, saving the seek of playback.
* @returns The current instance for chaining.
*/
pause(): this {
if (this._id !== null) this._howl.pause(this._id);
return this;
}
/**
* Stops playback of the sound, resetting seek to 0.
* @returns The current instance for chaining.
*/
stop(): this {
if (this._id !== null) this._howl.stop(this._id);
return this;
}
/**
* Check if the sound is currently playing.
* Note that a loading sound is considered as playing (as it will be
* played as soon as it's loaded). To avoid loading at runtime, prefer
* to preload the sounds.
*/
playing(): boolean {
return (
(this._id !== null ? this._howl.playing(this._id) : true) ||
!this.isLoaded() // Loading is considered playing
);
}
/**
* Check if the sound is currently paused.
*/
paused(): boolean {
return !this.playing();
}
/**
* Check if the sound is currently stopped.
*/
stopped(): boolean {
return this.paused() && this.getSeek() === 0;
}
/**
* Get the sound playback rate. This 1 for the default speed.
* This value is not clamped (any value greater than 0 is valid),
* but the underlying audio system might not play the sound at the required
* rate if it's very low or very high.
*/
getRate(): float {
return this._rate;
}
/**
* Set the playback rate.
* This value is not clamped (any value greater than 0 is valid),
* but the underlying audio system might not play the sound at the required
* rate if it's very low or very high.
* @returns The current instance for chaining.
*/
setRate(rate: float): this {
this._rate = rate;
// If the sound has already started playing, then change the value directly.
if (this._id !== null) {
rate = gdjs.HowlerSoundManager.clampRate(rate);
this._howl.rate(rate, this._id);
}
return this;
}
/**
* Get if the sound is looping.
*/
getLoop(): boolean {
return this._loop;
}
/**
* Set if the sound is looping.
* @returns The current instance for chaining.
*/
setLoop(loop: boolean): this {
this._loop = loop;
// If the sound has already started playing, then change the value directly.
if (this._id !== null) this._howl.loop(loop, this._id);
return this;
}
//TODO: Replace float type in those 2 methods with RangeOf<0..1> once it is standardized (https://github.com/Microsoft/TypeScript/issues/15480)
/**
* Get the sound volume.
* @returns A float from 0 to 1.
*/
getVolume(): float {
if (this._id === null) return this._initialVolume;
return this._howl.volume(this._id);
}
/**
* Set the sound volume.
* @param volume A float from 0 to 1. The value is clamped if too high or too low.
* @returns The current instance for chaining.
*/
setVolume(volume: float): this {
this._initialVolume = clampVolume(volume);
// If the sound has already started playing, then change the value directly.
if (this._id !== null) this._howl.volume(this._initialVolume, this._id);
return this;
}
/**
* Get if the sound is muted.
*/
getMute(): boolean {
if (this._id === null) return false;
return this._howl.mute(this._id);
}
/**
* Set if the sound is muted.
* @returns The current instance for chaining.
*/
setMute(mute: boolean): this {
if (this._id !== null) this._howl.mute(mute, this._id);
return this;
}
/**
* Get the sound seek.
*/
getSeek(): float {
if (this._id === null) return 0;
return this._howl.seek(this._id);
}
/**
* Set the sound seek.
* @returns The current instance for chaining.
*/
setSeek(seek: float): this {
if (this._id !== null) this._howl.seek(seek, this._id);
return this;
}
/**
* Get the sound spatial position.
*/
getSpatialPosition(axis: 'x' | 'y' | 'z'): float {
if (this._id === null) return 0;
return this._howl.pos(this._id)[axis === 'x' ? 0 : axis === 'y' ? 1 : 2];
}
/**
* Set the sound spatial position.
* @returns The current instance for chaining.
*/
setSpatialPosition(x: float, y: float, z: float): this {
if (this._id !== null) this._howl.pos(x, y, z, this._id);
return this;
}
/**
* Fade the volume sound.
* @returns The current instance for chaining.
*/
fade(from: float, to: float, duration: float): this {
if (this._id !== null)
this._howl.fade(clampVolume(from), clampVolume(to), duration, this._id);
return this;
}
/**
* Adds an event listener to the howl.
*/
on(event: HowlEvent, handler: HowlCallback): this {
if (event === 'play') {
if (this._id === null) {
this._onPlay.push(handler);
} else {
this._howl.on(event, handler, this._id);
}
} else if (this._id === null)
this.once('play', () => this.on(event, handler));
else this._howl.on(event, handler, this._id);
return this;
}
/**
* Adds an event listener to the howl that removes itself after being called.
* If the event is `play` and the sound is being played, the handler is
* called synchronously.
*/
once(event: HowlEvent, handler: HowlCallback): this {
if (event === 'play') {
if (this._id === null) {
this._oncePlay.push(handler);
} else if (this.playing()) {
// Immediately call the handler if the sound is already playing.
// This is useful for sounds that were just started and have a `.once('play', ...)`
// handler added on them to set up the volume/rate/loop. If we don't do it
// synchronously, the sound can play for a tiny bit at the default volume and rate.
// See https://github.com/4ian/GDevelop/issues/2490.
handler(this._id);
} else {
this._howl.once(event, handler, this._id);
}
} else if (this._id === null)
this.once('play', () => this.once(event, handler));
else this._howl.once(event, handler, this._id);
return this;
}
/**
* Removes an event listener to the howl.
*/
off(event: HowlEvent, handler: HowlCallback): this {
if (this._id !== null) this._howl.off(event, handler, this._id);
return this;
}
}
/**
* HowlerSoundManager is used to manage the sounds and musics of a RuntimeScene.
*
* It is basically a container to associate channels to sounds and keep a list
* of all sounds being played.
*/
export class HowlerSoundManager implements gdjs.ResourceManager {
_loadedMusics = new gdjs.ResourceCache<Howl>();
_loadedSounds = new gdjs.ResourceCache<Howl>();
_availableResources: Record<string, ResourceData> = {};
_globalVolume: float = 100;
_sounds: Record<integer, HowlerSound> = {};
_cachedSpatialPosition: Record<integer, [number, number, number]> = {};
_musics: Record<integer, HowlerSound> = {};
_freeSounds: HowlerSound[] = []; // Sounds without an assigned channel.
_freeMusics: HowlerSound[] = []; // Musics without an assigned channel.
/** Paused sounds or musics that should be played once the game is resumed. */
_pausedSounds: HowlerSound[] = [];
_paused: boolean = false;
_resourceLoader: gdjs.ResourceLoader;
/**
* @param resourceLoader The resources loader of the game.
*/
constructor(resourceLoader: gdjs.ResourceLoader) {
this._resourceLoader = resourceLoader;
gdjs.registerRuntimeScenePostEventsCallback(
this._clearCachedSpatialPosition.bind(this)
);
const that = this;
document.addEventListener('deviceready', function () {
// pause/resume sounds in Cordova when the app is being paused/resumed
document.addEventListener(
'pause',
function () {
that.pauseAllActiveSounds();
},
false
);
document.addEventListener(
'resume',
function () {
that.resumeAllActiveSounds();
},
false
);
});
}
pauseAllActiveSounds(): void {
const soundList = this._freeSounds.concat(this._freeMusics);
for (let key in this._sounds) {
if (this._sounds.hasOwnProperty(key)) {
soundList.push(this._sounds[key]);
}
}
for (let key in this._musics) {
if (this._musics.hasOwnProperty(key)) {
soundList.push(this._musics[key]);
}
}
for (let i = 0; i < soundList.length; i++) {
const sound = soundList[i];
if (!sound.paused() && !sound.stopped()) {
sound.pause();
this._pausedSounds.push(sound);
}
}
this._paused = true;
}
resumeAllActiveSounds(): void {
try {
for (let i = 0; i < this._pausedSounds.length; i++) {
const sound = this._pausedSounds[i];
if (!sound.stopped()) {
sound.play();
}
}
} catch (error) {
if (
error.message &&
typeof error.message === 'string' &&
error.message.startsWith('Maximum call stack size exceeded')
) {
console.warn(
'An error occurred when resuming paused sounds while the game was in background:',
error
);
} else {
throw error;
}
}
this._pausedSounds.length = 0;
this._paused = false;
}
getResourceKinds(): ResourceKind[] {
return resourceKinds;
}
/**
* Ensure rate is in a range valid for Howler.js
* @return The clamped rate
*/
static clampRate(rate: float): float {
if (rate > 4.0) {
return 4.0;
}
if (rate < 0.5) {
return 0.5;
}
return rate;
}
/**
* Return the file associated to the given sound name.
*
* Names and files are loaded from resources when preloadAudio is called. If no
* file is associated to the given name, then the name will be considered as a
* filename and will be returned.
*
* @return The associated resource
*/
private _getAudioResource = (resourceName: string): ResourceData => {
const resource = this._resourceLoader.getResource(resourceName);
return resource && this.getResourceKinds().includes(resource.kind)
? resource
: ({
file: resourceName,
kind: 'audio',
metadata: '',
name: resourceName,
} as ResourceData);
};
/**
* @param resource
* @returns Resource files
*/
private _getSoundUrlsFromResource(resource: ResourceData): string[] {
return [this._resourceLoader.getFullUrl(resource.file)];
}
/**
* @param resource
* @returns Resource file
*/
private _getDefaultSoundUrl(resource: ResourceData): string {
return this._resourceLoader.getFullUrl(resource.file);
}
/**
* Preload audio file
* @param resource
* @param isMusic
*/
private _preloadAudioFile(
resource: ResourceData,
isMusic: boolean
): Promise<number> {
const file = resource.file;
return new Promise((resolve, reject) => {
const container = isMusic ? this._loadedMusics : this._loadedSounds;
container[file] = new Howl(
Object.assign({}, HowlParameters, {
src: this._getSoundUrlsFromResource(resource),
onload: resolve,
onloaderror: (soundId: number, error?: string) => reject(error),
html5: isMusic,
xhr: {
withCredentials:
this._resourceLoader.checkIfCredentialsRequired(file),
},
// Cache the sound with no volume. This avoids a bug where it plays at full volume
// for a split second before setting its correct volume.
volume: 0,
})
);
});
}
/**
* Store the sound in the specified array, put it at the first index that
* is free, or add it at the end if no element is free
* ("free" means that the gdjs.HowlerSound can be destroyed).
*
* @param arr The array containing the sounds.
* @param arr The gdjs.HowlerSound to add.
* @return The gdjs.HowlerSound that have been added (i.e: the second parameter).
*/
private _storeSoundInArray(
arr: Array<HowlerSound>,
sound: HowlerSound
): HowlerSound {
// Try to recycle an old sound.
for (let i = 0, len = arr.length; i < len; ++i) {
if (!arr[i] || arr[i].stopped()) {
arr[i] = sound;
return sound;
}
}
arr.push(sound);
return sound;
}
/**
* Creates a new gdjs.HowlerSound using preloaded/cached Howl instances.
* @param soundName The name of the file or resource to play.
* @param isMusic True if a music, false if a sound.
* @param volume Between 0 and 1.
* @param loop True if it should be played looping.
* @param rate speed at which it is played.
*/
createHowlerSound(
soundName: string,
isMusic: boolean,
volume: float,
loop: boolean,
rate: float
): HowlerSound {
const cacheContainer = isMusic ? this._loadedMusics : this._loadedSounds;
const resource = this._getAudioResource(soundName);
let howl = cacheContainer.get(resource);
if (!howl) {
howl = new Howl(
Object.assign(
{
src: this._getSoundUrlsFromResource(resource),
html5: isMusic,
xhr: {
withCredentials:
this._resourceLoader.checkIfCredentialsRequired(
resource.file
),
},
// Cache the sound with no volume. This avoids a bug where it plays at full volume
// for a split second before setting its correct volume.
volume: 0,
},
HowlParameters
)
);
cacheContainer.set(resource, howl);
}
return new gdjs.HowlerSound(howl, volume, loop, rate);
}
/**
* Preloads a sound or a music in memory.
* @param soundName The name of the file or resource to preload.
* @param isMusic True if a music, false if a sound.
*/
loadAudio(soundName: string, isMusic: boolean) {
const cacheContainer = isMusic ? this._loadedMusics : this._loadedSounds;
const resource = this._getAudioResource(soundName);
// Do not reload if it is already loaded.
if (cacheContainer.get(resource)) {
return;
}
cacheContainer.set(
resource,
new Howl(
Object.assign(
{
src: this._getSoundUrlsFromResource(resource),
html5: isMusic,
xhr: {
withCredentials:
this._resourceLoader.checkIfCredentialsRequired(
resource.file
),
},
// Cache the sound with no volume. This avoids a bug where it plays at full volume
// for a split second before setting its correct volume.
volume: 0,
},
HowlParameters
)
)
);
}
/**
* Unloads a sound or a music from memory. This will stop any sound/music using it.
* @param soundName The name of the file or resource to unload.
* @param isMusic True if a music, false if a sound.
*/
unloadAudio(soundName: string, isMusic: boolean) {
const cacheContainer = isMusic ? this._loadedMusics : this._loadedSounds;
const resource = this._getAudioResource(soundName);
const howl = cacheContainer.get(resource);
if (!howl) {
return;
}
// Make sure any sound using the howl is deleted so
// that the howl can be garbage collected
// and no weird "zombies" using the unloaded howl can exist.
function clearContainer(howlerSoundContainer: HowlerSound[]) {
for (let i in howlerSoundContainer) {
if (
howlerSoundContainer[i] &&
//@ts-ignore We really need to access the raw howl here.
howlerSoundContainer[i]._howl === howl
) {
howlerSoundContainer[i].stop();
delete howlerSoundContainer[i];
}
}
}
clearContainer(this._freeMusics);
clearContainer(this._freeSounds);
clearContainer(Object.values(this._musics));
clearContainer(Object.values(this._sounds));
clearContainer(this._pausedSounds);
howl.unload();
cacheContainer.delete(resource);
}
/**
* Unloads all audio from memory.
* This will clear the Howl cache.
* This will also stop any running music or sounds.
*/
unloadAll() {
Howler.unload();
// Clean up old sounds that still have the dead Howl instances.
this._freeSounds.length = 0;
this._freeMusics.length = 0;
this._sounds = {};
this._musics = {};
this._pausedSounds.length = 0;
this._loadedMusics.clear();
this._loadedSounds.clear();
}
playSound(soundName: string, loop: boolean, volume: float, pitch: float) {
const sound = this.createHowlerSound(
soundName,
/* isMusic= */ false,
volume / 100,
loop,
pitch
);
this._storeSoundInArray(this._freeSounds, sound);
sound.once('play', () => {
if (this._paused) {
sound.pause();
this._pausedSounds.push(sound);
}
});
sound.play();
}
playSoundOnChannel(
soundName: string,
channel: integer,
loop: boolean,
volume: float,
pitch: float
) {
if (this._sounds[channel]) this._sounds[channel].stop();
const sound = this.createHowlerSound(
soundName,
/* isMusic= */ false,
volume / 100,
loop,
pitch
);
const spatialPosition = this._cachedSpatialPosition[channel];
if (spatialPosition) {
sound.once('play', () => {
sound.setSpatialPosition(...spatialPosition);
});
}
this._sounds[channel] = sound;
sound.once('play', () => {
if (this._paused) {
sound.pause();
this._pausedSounds.push(sound);
}
});
sound.play();
}
getSoundOnChannel(channel: integer): HowlerSound | null {
return this._sounds[channel] || null;
}
playMusic(soundName: string, loop: boolean, volume: float, pitch: float) {
const music = this.createHowlerSound(
soundName,
/* isMusic= */ true,
volume / 100,
loop,
pitch
);
this._storeSoundInArray(this._freeMusics, music);
music.once('play', () => {
if (this._paused) {
music.pause();
this._pausedSounds.push(music);
}
});
music.play();
}
playMusicOnChannel(
soundName: string,
channel: integer,
loop: boolean,
volume: float,
pitch: float
) {
if (this._musics[channel]) this._musics[channel].stop();
const music = this.createHowlerSound(
soundName,
/* isMusic= */ true,
volume / 100,
loop,
pitch
);
// Musics are played with the html5 backend, that is not compatible with spatialization.
this._musics[channel] = music;
music.once('play', () => {
if (this._paused) {
music.pause();
this._pausedSounds.push(music);
}
});
music.play();
}
getMusicOnChannel(channel: integer): HowlerSound | null {
return this._musics[channel] || null;
}
setSoundSpatialPositionOnChannel(
channel: number,
x: number,
y: number,
z: number
) {
const sound = this.getSoundOnChannel(channel);
if (sound && !sound.paused()) sound.setSpatialPosition(x, y, z);
else {
// If no sound is playing at the time the method is called, the
// position is cached and will be used by the `playSoundOnChannel` method
// to set the spatial position right after the sound starts playing.
// This cached value is then cleared at the end of the frame.
// Without this caching strategy, if actions are in the wrong order,
// the spatial position will not apply to the sound because
// it is not playing yet.
this._cachedSpatialPosition[channel] = [x, y, z];
}
}
_clearCachedSpatialPosition() {
this._cachedSpatialPosition = {};
}
setGlobalVolume(volume: float): void {
this._globalVolume = volume;
if (this._globalVolume > 100) {
this._globalVolume = 100;
}
if (this._globalVolume < 0) {
this._globalVolume = 0;
}
Howler.volume(this._globalVolume / 100);
}
getGlobalVolume(): float {
return this._globalVolume;
}
clearAll() {
Howler.stop();
this._freeSounds.length = 0;
this._freeMusics.length = 0;
this._sounds = {};
this._musics = {};
this._pausedSounds.length = 0;
}
async processResource(resourceName: string): Promise<void> {
// Do nothing because sounds are light enough to be parsed in background.
}
async loadResource(resourceName: string): Promise<void> {
const resource = this._resourceLoader.getResource(resourceName);
if (!resource) {
logger.warn(
'Unable to find audio for resource "' + resourceName + '".'
);
return;
}
if (resource.file) {
if (this._availableResources[resource.name]) {
return;
}
this._availableResources[resource.name] = resource;
}
if (resource.preloadAsMusic) {
try {
await this._preloadAudioFile(resource, /* isMusic= */ true);
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
}
if (resource.preloadAsSound) {
try {
await this._preloadAudioFile(resource, /* isMusic= */ false);
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
} else if (
resource.preloadInCache ||
// Force downloading of sounds.
// TODO Decide if sounds should be allowed to be downloaded after the scene starts.
// - they should be requested automatically at the end of the scene loading
// - they will be downloaded while the scene is playing
// - other scenes will be pre-loaded only when all the sounds for the current scene are in cache
!resource.preloadAsMusic
) {
// preloading as sound already does a XHR request, hence "else if"
try {
const file = resource.file;
await new Promise((resolve, reject) => {
const sound = new XMLHttpRequest();
sound.withCredentials =
this._resourceLoader.checkIfCredentialsRequired(file);
sound.addEventListener('load', resolve);
sound.addEventListener('error', (_) =>
reject('XHR error: ' + file)
);
sound.addEventListener('abort', (_) =>
reject('XHR abort: ' + file)
);
sound.open('GET', this._getDefaultSoundUrl(resource));
sound.send();
});
} catch (error) {
logger.warn(
'There was an error while preloading an audio file: ' + error
);
}
}
}
/**
* To be called when the game is disposed.
* Unloads all audio from memory, clear Howl cache and stop all audio.
*/
dispose(): void {
this.unloadAll();
}
unloadResource(resourceData: ResourceData): void {
const musicRes = this._loadedMusics.get(resourceData);
if (musicRes) {
this.unloadAudio(resourceData.name, true);
}
const soundRes = this._loadedSounds.get(resourceData);
if (soundRes) {
this.unloadAudio(resourceData.name, false);
}
}
}
// Register the class to let the engine use it.
export const SoundManager = HowlerSoundManager;
export type SoundManager = HowlerSoundManager;
}