src/audio/BinauralPanner.js
/**
* @fileOverview Multi-source binaural panner.
* @author Jean-Philippe.Lambert@ircam.fr
* @copyright 2016 IRCAM, Paris, France
* @license BSD-3-Clause
*/
import glMatrix from 'gl-matrix';
import { glToSystem, systemToGl } from '../geometry/coordinates';
import HrtfSet from '../sofa/HrtfSet';
import Source from './Source';
import Listener from '../geometry/Listener';
/**
* Binaural panner with multiple sources and a listener.
*/
export class BinauralPanner {
/**
* Constructs an HRTF set. Note that the filter positions are applied
* during the load of an HRTF URL.
*
* @see {@link HrtfSet}
* @see {@link BinauralPanner#loadHrtfSet}
*
* @param {Object} options
* @param {AudioContext} options.audioContext mandatory for the creation
* of FIR audio buffers
* @param {CoordinateSystem} [options.coordinateSystem='gl']
* {@link BinauralPanner#coordinateSystem}
* @param {Number} [options.sourceCount=1]
* @param {Array.<coordinates>} [options.sourcePositions=undefined] must
* be of length options.sourceCount {@link BinauralPanner#sourcePositions}
* @param {Number} [options.crossfadeDuration] in seconds.
* @param {HrtfSet} [options.hrtfSet] refer an external HRTF set.
* {@link BinauralPanner#hrtfSet}
* @param {CoordinateSystem} [options.filterCoordinateSystem=options.coordinateSystem]
* {@link BinauralPanner#filterCoordinateSystem}
* @param {Array.<coordinates>} [options.filterPositions=undefined]
* array of positions to filter. Use undefined to use all positions from the HRTF set.
* {@link BinauralPanner#filterPositions}
* @param {Boolean} [options.filterAfterLoad=false] true to filter after
* full load of SOFA file
* @param {Listener} [options.listener] refer an external listener.
* {@link BinauralPanner#listener}
* @param {CoordinateSystem} [options.listenerCoordinateSystem=options.coordinateSystem]
* {@link BinauralPanner#listenerCoordinateSystem}
* @param {Coordinates} [options.listenerPosition=[0,0,0]]
* {@link BinauralPanner#listenerPosition}
* @param {Coordinates} [options.listenerUp=[0,1,0]]
* {@link BinauralPanner#listenerUp}
* @param {Coordinates} [options.listenerView=[0,0,-1]]
* {@link BinauralPanner#listenerView}
* @param {Boolean} [options.listenerViewIsRelative=false]
* {@link Listener#viewIsRelative}
*/
constructor(options = {}) {
this._audioContext = options.audioContext;
this.coordinateSystem = options.coordinateSystem;
const sourceCount = (typeof options.sourceCount !== 'undefined'
? options.sourceCount
: 1);
// allocate first
this._listener = (typeof options.listener !== 'undefined'
? options.listener
: new Listener() );
// set coordinate system, that defaults to BinauralPanner's own system
this.listenerCoordinateSystem = options.listenerCoordinateSystem;
// use setters for internal or external listener
this.listenerPosition = (typeof options.listenerPosition !== 'undefined'
? options.listenerPosition
: glToSystem([], [0, 0, 0],
this._listener.coordinateSystem) );
this.listenerView = (typeof options.listenerView !== 'undefined'
? options.listenerView
: glToSystem([], [0, 0, -1],
this._listener.coordinateSystem) );
// undefined is fine
this.listenerViewIsRelative = options.listenerViewIsRelative;
this.listenerUp = (typeof options.listenerUp !== 'undefined'
? options.listenerUp
: glToSystem([], [0, 1, 0],
this._listener.coordinateSystem) );
this._sourcesOutdated = new Array(sourceCount).fill(true);
this._sources = this._sourcesOutdated.map( () => {
return new Source({
audioContext: this._audioContext,
crossfadeDuration: options.crossfadeDuration,
});
});
this._sourcePositionsAbsolute = this._sourcesOutdated.map( () => {
return [0, 0, 1]; // allocation and default value
});
this._sourcePositionsRelative = this._sourcesOutdated.map( () => {
return [0, 0, 1]; // allocation and default value
});
this.hrtfSet = (typeof options.hrtfSet !== 'undefined'
? options.hrtfSet
: new HrtfSet({
audioContext: this._audioContext,
coordinateSystem: 'gl',
}) );
this.filterCoordinateSystem = options.filterCoordinateSystem;
this.filterPositions = options.filterPositions;
this.filterAfterLoad = options.filterAfterLoad;
if (typeof options.sourcePositions !== 'undefined') {
this.sourcePositions = options.sourcePositions;
}
this.update();
}
// ----------- accessors
/**
* Set coordinate system.
*
* @param {CoordinateSystem} [system='gl']
*/
set coordinateSystem(system) {
this._coordinateSystem = (typeof system !== 'undefined'
? system
: 'gl');
}
/**
* Get coordinate system.
*
* @returns {CoordinateSystem}
*/
get coordinateSystem() {
return this._coordinateSystem;
}
/**
* Refer an external HRTF set, and update sources. Its positions
* coordinate system must be 'gl'.
*
* @see {@link HrtfSet}
* @see {@link BinauralPanner#update}
*
* @param {HrtfSet} hrtfSet
* @throws {Error} when hrtfSet in undefined or hrtfSet.coordinateSystem is
* not 'gl'.
*/
set hrtfSet(hrtfSet) {
if (typeof hrtfSet !== 'undefined') {
if (hrtfSet.coordinateSystem !== 'gl') {
throw new Error(`coordinate system of HRTF set must be 'gl' `
+ `(and not '${hrtfSet.coordinateSystem}') `
`for use with BinauralPannerNode`);
}
this._hrtfSet = hrtfSet;
} else {
throw new Error('Undefined HRTF set for BinauralPanner');
}
// update HRTF set references
this._sourcesOutdated.fill(true);
this._sources.forEach( (source) => {
source.hrtfSet = this._hrtfSet;
});
this.update();
}
/**
* Get the HrtfSet.
*
* @returns {HrtfSet}
*/
get hrtfSet() {
return this._hrtfSet;
}
// ------------- HRTF set proxies
/**
* Set the filter positions of the HRTF set.
*
* @see {@link HrtfSet#filterPositions}
*
* @param {Array.<Coordinates>} positions
*/
set filterPositions(positions) {
this._hrtfSet.filterPositions = positions;
}
/**
* Get the filter positions of the HRTF set.
*
* @see {@link HrtfSet#filterPositions}
*
* @return {Array.<Coordinates>} positions
*/
get filterPositions() {
return this._hrtfSet.filterPositions;
}
/**
* Set coordinate system for filter positions.
*
* @param {CoordinateSystem} [system='gl']
*/
set filterCoordinateSystem(system) {
this._hrtfSet.filterCoordinateSystem = (typeof system !== 'undefined'
? system
: this.coordinateSystem);
}
/**
* Get coordinate system for filter positions.
*
* @returns {CoordinateSystem}
*/
get filterCoordinateSystem() {
return this._hrtfSet.filterCoordinateSystem;
}
/**
* Set post-filtering flag. When false, try to load a partial set of
* HRTF.
*
* @param {Boolean} [post=false]
*/
set filterAfterLoad(post) {
this._hrtfSet.filterAfterLoad = post;
}
/**
* Get post-filtering flag. When false, try to load a partial set of
* HRTF.
*
* @returns {Boolean}
*/
get filterAfterLoad() {
return this._hrtfSet.filterAfterLoad;
}
/**
* Refer an external listener, and update sources.
*
* @see {@link Listener}
* @see {@link BinauralPanner#update}
*
* @param {Listener} listener
* @throws {Error} when listener in undefined.
*/
set listener(listener) {
if (typeof listener !== 'undefined') {
this._listener = listener;
} else {
throw new Error('Undefined listener for BinauralPanner');
}
this._sourcesOutdated.fill(true);
this.update();
}
// ---------- Listener proxies
/**
* Set coordinate system for listener.
*
* @see {@link Listener#coordinateSystem}
*
* @param {CoordinateSystem} [system='gl']
*/
set listenerCoordinateSystem(system) {
this._listener.coordinateSystem = (typeof system !== 'undefined'
? system
: this.coordinateSystem);
}
/**
* Get coordinate system for listener.
*
* @returns {CoordinateSystem}
*/
get listenerCoordinateSystem() {
return this._listener.coordinateSystem;
}
/**
* Set listener position. It will update the relative positions of the
* sources after a call to the update method.
*
* Default value is [0, 0, 0] in 'gl' coordinates.
*
* @see {@link Listener#position}
* @see {@link BinauralPanner#update}
*
* @param {Coordinates} positionRequest
*/
set listenerPosition(positionRequest) {
this._listener.position = positionRequest;
}
/**
* Get listener position.
*
* @returns {Coordinates}
*/
get listenerPosition() {
return this._listener.position;
}
/**
* Set listener up direction (not an absolute position). It will update
* the relative positions of the sources after a call to the update
* method.
*
* Default value is [0, 1, 0] in 'gl' coordinates.
*
* @see {@link Listener#up}
* @see {@link BinauralPanner#update}
*
* @param {Coordinates} upRequest
*/
set listenerUp(upRequest) {
this._listener.up = upRequest;
}
/**
* Get listener up direction.
*
* @returns {Coordinates}
*/
get listenerUp() {
return this._listener.up;
}
/**
* Set listener view, as an aiming position or a relative direction, if
* viewIsRelative is respectively false or true. It will update the
* relative positions of the sources after a call to the update method.
*
* Default value is [0, 0, -1] in 'gl' coordinates.
*
* @see {@link Listener#view}
* @see {@link Listener#viewIsRelative}
* @see {@link BinauralPanner#update}
*
* @param {Coordinates} viewRequest
*/
set listenerView(viewRequest) {
this._listener.view = viewRequest;
}
/**
* Get listener view.
*
* @returns {Coordinates}
*/
get listenerView() {
return this._listener.view;
}
/**
* Set the type of view: absolute to an aiming position (when false), or
* a relative direction (when true). It will update the relative
* positions after a call to the update method.
*
* @see {@link Listener#view}
*
* @param {Boolean} [relative=false] true when view is a direction, false
* when it is an absolute position.
*/
set listenerViewIsRelative(relative) {
this._listener.viewIsRelative = relative;
}
/**
* Get the type of view.
*
* @returns {Boolean}
*/
get listenerViewIsRelative() {
return this._listerner.viewIsRelative;
}
/**
* Set the sources positions. It will update the relative positions after
* a call to the update method.
*
* @see {@link BinauralPanner#update}
* @see {@link BinauralPanner#setSourcePositionByIndex}
*
* @param {Array.<Coordinates>} positionsRequest
* @throws {Error} if the length of positionsRequest is not the same as
* the number of sources
*/
set sourcePositions(positionsRequest) {
if (positionsRequest.length !== this._sources.length) {
throw new Error(`Bad number of source positions: `
+ `${positionsRequest.length} `
+ `instead of ${this._sources.length}`);
}
positionsRequest.forEach( (position, index) => {
this._sourcesOutdated[index] = true;
this.setSourcePositionByIndex(index, position);
});
}
/**
* Get the source positions.
*
* @returns {Array.<Coordinates>}
*/
get sourcePositions() {
return this._sourcePositionsAbsolute.map( (position) => {
return glToSystem([], position, this.coordinateSystem);
});
}
/**
* Set the position of one source. It will update the corresponding
* relative position after a call to the update method.
*
* @see {@link BinauralPanner#update}
*
* @param {Number} index
* @param {Coordinates} positionRequest
* @returns {this}
*/
setSourcePositionByIndex(index, positionRequest) {
this._sourcesOutdated[index] = true;
systemToGl(this._sourcePositionsAbsolute[index],
positionRequest,
this.coordinateSystem);
return this;
}
/**
* Get the position of one source.
*
* @param {Number} index
* @returns {Coordinates}
*/
getSourcePositionByIndex(index) {
return glToSystem([], this._sourcePositionsAbsolute[index],
this.coordinateSystem);
}
// ----------- public methods
/**
* Load an HRTF set form an URL, and update sources.
*
* @see {@link HrtfSet#load}
*
* @param {String} sourceUrl
* @returns {Promise.<this|Error>} resolve when URL successfully
* loaded.
*/
loadHrtfSet(sourceUrl) {
return this._hrtfSet.load(sourceUrl)
.then( () => {
this._sourcesOutdated.fill(true);
this.update();
return this;
});
}
/**
* Connect the input of a source.
*
* @param {Number} index
* @param {(AudioNode|Array.<AudioNode>)} nodesToConnect
* @param {Number} [output=0] output to connect from
* @param {Number} [input=0] input to connect to
* @returns {this}
*/
connectInputByIndex(index, nodesToConnect, output, input) {
this._sources[index].connectInput(nodesToConnect, output, input);
return this;
}
/**
* Disconnect the input of one source.
*
* @param {Number} index
* @param {(AudioNode|Array.<AudioNode>)} nodesToDisconnect disconnect
* all when undefined.
* @returns {this}
*/
disconnectInputByIndex(index, nodesToDisconnect) {
this._sources[index].disconnectInput(nodesToDisconnect);
return this;
}
/**
* Disconnect the input of each source.
*
* @param {(AudioNode|Array.<AudioNode>)} nodesToDisconnect disconnect
* all when undefined.
* @returns {this}
*/
disconnectInputs(nodesToDisconnect) {
const nodes = (Array.isArray(nodesToDisconnect)
? nodesToDisconnect
: [nodesToDisconnect] ); // make array
this._sources.forEach( (source, index) => {
source.disconnectInput(nodes[index]);
});
return this;
}
/**
* Connect the output of a source.
*
* @param {Number} index
* @param {(AudioNode|Array.<AudioNode>)} nodesToConnect
* @param {Number} [output=0] output to connect from
* @param {Number} [input=0] input to connect to
* @returns {this}
*/
connectOutputByIndex(index, nodesToConnect, output, input) {
this._sources[index].connectOutput(nodesToConnect, output, input);
return this;
}
/**
* Disconnect the output of a source.
*
* @param {Number} index
* @param {(AudioNode|Array.<AudioNode>)} nodesToDisconnect disconnect
* all when undefined.
* @returns {this}
*/
disconnectOutputByIndex(index, nodesToDisconnect) {
this._sources[index].disconnectOutput(nodesToDisconnect);
return this;
}
/**
* Connect the output of each source.
*
* @see {@link BinauralPanner#connectOutputByIndex}
*
* @param {(AudioNode|Array.<AudioNode>)} nodesToConnect
* @param {Number} [output=0] output to connect from
* @param {Number} [input=0] input to connect to
* @returns {this}
*/
connectOutputs(nodesToConnect, output, input) {
this._sources.forEach( (source) => {
source.connectOutput(nodesToConnect, output, input);
});
return this;
}
/**
* Disconnect the output of each source.
*
* @param {(AudioNode|Array.<AudioNode>)} nodesToDisconnect
* @returns {this}
*/
disconnectOutputs(nodesToDisconnect) {
this._sources.forEach( (source) => {
source.disconnectOutput(nodesToDisconnect);
});
return this;
}
/**
* Update the sources filters, according to pending changes in listener,
* and source positions.
*
* @returns {Boolean} true when at least a change occurred.
*/
update() {
let updated = false;
if (this._listener.update() ) {
this._sourcesOutdated.fill(true);
updated = true;
}
if (this._hrtfSet.isReady) {
this._sourcePositionsAbsolute.forEach( (positionAbsolute, index) => {
if (this._sourcesOutdated[index] ) {
glMatrix.vec3.transformMat4(this._sourcePositionsRelative[index],
positionAbsolute,
this._listener.lookAt);
this._sources[index].position = this._sourcePositionsRelative[index];
this._sourcesOutdated[index] = false;
updated = true;
}
});
}
return updated;
}
}
export default BinauralPanner;