Skip to content

Commit

Permalink
audio-manager: Fix some bugs and add documentation
Browse files Browse the repository at this point in the history
  • Loading branch information
timothyhale committed Feb 23, 2024
1 parent 27b1701 commit b130226
Show file tree
Hide file tree
Showing 2 changed files with 115 additions and 112 deletions.
225 changes: 114 additions & 111 deletions src/audio-manager.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,27 @@
import {
_audioContext,
audioBuffers,
} from './audio-listener.js';

export enum SpatializationType {
HRFT,
EqualPower,
}
import {_audioContext} from './audio-listener.js';

class AudioManager {
private preloadedBuffers: {[key: string]: [Promise<AudioBuffer>, number]} = {};
const preloadedBuffers: {[key: string]: [Promise<AudioBuffer>, number]} = {};

/** AudioManager loads and manages audiofiles from which PlayableNodes are created */
export const AudioManager = {
/**
* Asynchronously loads an audio file and returns a Promise that resolves with a PlayableNode.
*
* @note Make sure to load files on `start()`, so that Nodes are ready when they are needed.
*
* @param file - The path or URL of the audio file to be loaded.
* @returns A Promise that resolves with a PlayableNode representing the loaded audio.
* @throws If there is an error during the loading process, a rejection with an error message is returned.
*/
async load(file: string): Promise<PlayableNode> {
try {
/* Return when a instance of this file already being loaded */
const [bufferPromise, referenceCount] = this.preloadedBuffers[file] || [
/* Return when a instance of this file already is being loaded */
const [bufferPromise, referenceCount] = preloadedBuffers[file] || [
undefined,
0,
];
if (bufferPromise !== undefined) {
this.preloadedBuffers[file][1] += 1;
preloadedBuffers[file][1] += 1;
return bufferPromise.then(() => new PlayableNode(file));
}
const response = await fetch(file);
Expand All @@ -37,128 +39,111 @@ class AudioManager {
.catch((error) => reject(error));
});

this.preloadedBuffers[file] = [decodingPromise, 1];
preloadedBuffers[file] = [decodingPromise, 1];
console.log(decodingPromise);

/* Return a promise that resolves with a PlayableNode when decoding is complete */
return decodingPromise.then(() => new PlayableNode(file));
} catch (error) {
return Promise.reject(`audio-manager: Error in load() for file ${file}`);
}
},
};

async function remove(file: string) {
const [bufferPromise, referenceCount] = preloadedBuffers[file] || [undefined, 0];
if (await bufferPromise) {
if (referenceCount <= 1) delete preloadedBuffers[file];
else preloadedBuffers[file][1] -= 1;
}
}

// @todo: This function should only be used internally by PlayableNode.
async remove(file: string) {
const [bufferPromise, referenceCount] = this.preloadedBuffers[file] || [
undefined,
0,
];
if (await bufferPromise) {
if (referenceCount <= 1) delete this.preloadedBuffers[file];
else this.preloadedBuffers[file][1] -= 1;
}
}
async function unlockAudioContext(): Promise<void> {
return new Promise<void>((resolve) => {
const unlockHandler = () => {
_audioContext.resume().then(() => {
window.removeEventListener('click', unlockHandler);
window.removeEventListener('touch', unlockHandler);
window.removeEventListener('keydown', unlockHandler);
window.removeEventListener('mousedown', unlockHandler);
resolve();
});
};

window.addEventListener('click', unlockHandler);
window.addEventListener('touch', unlockHandler);
window.addEventListener('keydown', unlockHandler);
window.addEventListener('mousedown', unlockHandler);
});
}

/**
* Represents a playable audio node that can be used to play audio panned or without panning.
*
* @note Use the `destroy()` method if audio is not going to be used anymore, to avoid unused audio files
* clogging up memory
*/
class PlayableNode {
private source: string;
private _isPlaying: boolean = false;
private gainNode: GainNode = new GainNode(_audioContext);
private pannerNode: PannerNode | undefined;
private audioNode: AudioBufferSourceNode = new AudioBufferSourceNode(_audioContext);
private _destroy: boolean = false;

/** Whether to loop the audio */
public loop: boolean = false;

/** Whether to enable HRTF over regular panning */
public HRTF: boolean = false;

constructor(src: string) {
this.source = src;
this.gainNode.connect(_audioContext.destination);
}

async play(): Promise<void> {
try {
if (this.isPlaying) {
this.stop();
}
this.audioNode = new AudioBufferSourceNode(_audioContext, {
buffer: await audioBuffers[this.source],
loop: this.loop,
});
this.audioNode.connect(this.gainNode);
if (_audioContext.state === 'suspended') {
await _audioContext.resume();
}
this.audioNode.addEventListener('ended', this.stop);
this.audioNode.start();
this._isPlaying = true;
} catch (e) {
console.warn(e);
}
}

async playSpatialHRTF(posVec: Float32Array): Promise<void> {
/**
* Asynchronously plays the loaded audio. If the audio is already playing, it stops the current playback and starts anew.
* If the audio context is in a suspended state, it attempts to unlock the audio context before playing and will
* continue after the user has interacted with the website.
*
* @param posVec - An optional parameter representing the 3D spatial position of the audio source.
* If provided, the audio will be spatialized using a PannerNode based on the given position vector.
* @returns A Promise that resolves once the audio playback starts.
* @throws - If there is an error during the playback process, a warning is logged to the console.
*/
async play(posVec?: Float32Array): Promise<void> {
try {
if (this.isPlaying) {
this.stop();
}
this.audioNode = new AudioBufferSourceNode(_audioContext, {
buffer: await audioBuffers[this.source],
loop: this.loop,
});
this.pannerNode = new PannerNode(_audioContext, {
coneInnerAngle: 360,
coneOuterAngle: 0,
coneOuterGain: 0,
distanceModel: 'exponential' as DistanceModelType,
maxDistance: 10000,
refDistance: 1.0,
rolloffFactor: 1.0,
panningModel: 'HRTF',
positionX: posVec[0],
positionY: posVec[2],
positionZ: -posVec[1],
orientationX: 0,
orientationY: 0,
orientationZ: 1,
});
this.audioNode.connect(this.pannerNode).connect(this.gainNode);
if (_audioContext.state === 'suspended') {
await _audioContext.resume();
}
this.audioNode.addEventListener('ended', this.stop);
this.audioNode.start();
this._isPlaying = true;
} catch (e) {
console.warn(e);
}
}

async playSpatialPanned(posVec: Float32Array): Promise<void> {
try {
if (this.isPlaying) {
this.stop();
await unlockAudioContext();
}
this.audioNode = new AudioBufferSourceNode(_audioContext, {
buffer: await audioBuffers[this.source],
buffer: await preloadedBuffers[this.source][0],
loop: this.loop,
});
this.pannerNode = new PannerNode(_audioContext, {
coneInnerAngle: 360,
coneOuterAngle: 0,
coneOuterGain: 0,
distanceModel: 'exponential' as DistanceModelType,
maxDistance: 10000,
refDistance: 1.0,
rolloffFactor: 1.0,
panningModel: 'equalpower',
positionX: posVec[0],
positionY: posVec[2],
positionZ: -posVec[1],
orientationX: 0,
orientationY: 0,
orientationZ: 1,
});
this.audioNode.connect(this.pannerNode).connect(this.gainNode);
if (_audioContext.state === 'suspended') {
await _audioContext.resume();
if (posVec !== undefined) {
this.pannerNode = new PannerNode(_audioContext, {
coneInnerAngle: 360,
coneOuterAngle: 0,
coneOuterGain: 0,
distanceModel: 'exponential' as DistanceModelType,
maxDistance: 10000,
refDistance: 1.0,
rolloffFactor: 1.0,
panningModel: this.HRTF ? 'HRTF' : 'equalpower',
positionX: posVec[0],
positionY: posVec[2],
positionZ: -posVec[1],
orientationX: 0,
orientationY: 0,
orientationZ: 1,
});
this.audioNode.connect(this.pannerNode).connect(this.gainNode);
} else {
this.audioNode.connect(this.gainNode);
}
this.audioNode.addEventListener('ended', this.stop);
this.audioNode.start();
Expand All @@ -169,7 +154,7 @@ class PlayableNode {
}

/**
* Stops the audio associated with this audio source.
* Stops the playback, and if set to destroy, removes associated audio file.
*/
stop() {
if (this.isPlaying) {
Expand All @@ -183,6 +168,7 @@ class PlayableNode {
this.pannerNode.disconnect();
}
this._isPlaying = false;
if (this._destroy) remove(this.source);
}

/**
Expand All @@ -192,17 +178,34 @@ class PlayableNode {
return this._isPlaying;
}

/**
* Sets the volume of this PlayableNode
*/
set volume(v: number) {
// @todo: Check value
this.gainNode.gain.value = v;
}

/**
* Free's up the audio resources after Node stopped playing.
*
* @example
* ```
* this.audio.play() // plays entire audio file
* this.destroy() // frees resources
* this.audio.play() // does nothing
* ```
*/
destroy() {
this.stop();
audioManager.remove(this.source);
}
}
if (this.isPlaying) {
this._destroy = true;
} else {
remove(this.source);
}

const audioManager: AudioManager = new AudioManager();
/* Remove ability to re-trigger the sound */
this.play = this.removePlay.bind(this);
this.destroy = () => {};
}

export {audioManager};
private async removePlay(): Promise<void> {}
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export {AudioSource} from './audio-source.js';
export {AudioListener} from './audio-listener.js';
export {audioManager} from './audio-manager.js';
export {AudioManager} from './audio-manager.js';

0 comments on commit b130226

Please sign in to comment.