Home Reference Source Repository

src/sofa/HrtfSet.js

/**
 * @fileOverview Container for HRTF set: load a set from an URL and get
 * filters from corresponding positions.
 *
 * @author Jean-Philippe.Lambert@ircam.fr
 * @copyright 2015-2016 IRCAM, Paris, France
 * @license BSD-3-Clause
 */

import glMatrix from 'gl-matrix';

import info from '../info';
import { parseDataSet } from './parseDataSet';
import { parseSofa, stringifySofa } from './parseSofa';
import { conformSofaCoordinateSystem } from './parseSofa';
import coordinates from '../geometry/coordinates';
import kdTree from '../geometry/KdTree';
import { resampleFloat32Array } from '../audio/utilities';

/**
 * Container for HRTF set.
 */
export class HrtfSet {

  /**
   * Constructs an HRTF set. Note that the filter positions are applied
   * during the load of an URL.
   *
   * @see {@link HrtfSet#load}
   *
   * @param {Object} options
   * @param {AudioContext} options.audioContext mandatory for the creation
   * of FIR audio buffers
   * @param {CoordinateSystem} [options.coordinateSystem='gl']
   * {@link HrtfSet#coordinateSystem}
   * @param {CoordinateSystem} [options.filterCoordinateSystem=options.coordinateSystem]
   * {@link HrtfSet#filterCoordinateSystem}
   * @param {Array.<Coordinates>} [options.filterPositions=undefined]
   * {@link HrtfSet#filterPositions}
   * array of positions to filter. Use undefined to use all positions.
   * @param {Boolean} [options.filterAfterLoad=false] true to filter after
   * full load of SOFA file, instead of multiple partial loading.
   * {@link HrtfSet#filterAfterLoad}
   */
  constructor(options = {}) {
    this._audioContext = options.audioContext;

    this._ready = false;

    this.coordinateSystem = options.coordinateSystem;

    this.filterCoordinateSystem = options.filterCoordinateSystem;
    this.filterPositions = options.filterPositions;

    this.filterAfterLoad = options.filterAfterLoad;
  }

  // ------------ accessors

  /**
   * Set coordinate system for positions.
   * @param {CoordinateSystem} [system='gl']
   */
  set coordinateSystem(system) {
    this._coordinateSystem = (typeof system !== 'undefined'
                              ? system
                              : 'gl');
  }

  /**
   * Get coordinate system for positions.
   *
   * @returns {CoordinateSystem}
   */
  get coordinateSystem() {
    return this._coordinateSystem;
  }

  /**
   * Set coordinate system for filter positions.
   *
   * @param {CoordinateSystem} [system] undefined to use coordinateSystem
   */
  set filterCoordinateSystem(system) {
    this._filterCoordinateSystem = (typeof system !== 'undefined'
                                    ? system
                                    : this.coordinateSystem);
  }

  /**
   * Get coordinate system for filter positions.
   */
  get filterCoordinateSystem() {
    return this._filterCoordinateSystem;
  }

  /**
   * Set filter positions.
   *
   * @param {Array.<Coordinates>} [positions] undefined for no filtering.
   */
  set filterPositions(positions) {
    if (typeof positions === 'undefined') {
      this._filterPositions = undefined;
    } else {
      switch (this.filterCoordinateSystem) {
        case 'gl':
          this._filterPositions = positions.map( (current) => {
            return current.slice(0); // copy
          });
          break;

        case 'sofaCartesian':
          this._filterPositions = positions.map( (current) => {
            return coordinates.sofaCartesianToGl([], current);
          });
          break;

        case 'sofaSpherical':
          this._filterPositions = positions.map( (current) => {
            return coordinates.sofaSphericalToGl([], current);
          });
          break;

        default:
          throw new Error('Bad filter coordinate system');
      }
    }
  }

  /**
   * Get filter positions.
   */
  get filterPositions() {
    let positions;
    if (typeof this._filterPositions !== 'undefined') {
      switch (this.filterCoordinateSystem) {
        case 'gl':
          positions = this._filterPositions.map( (current) => {
            return current.slice(0); // copy
          });
          break;

        case 'sofaCartesian':
          positions = this._filterPositions.map( (current) => {
            return coordinates.glToSofaCartesian([], current);
          });
          break;

        case 'sofaSpherical':
          positions = this._filterPositions.map( (current) => {
            return coordinates.glToSofaSpherical([], current);
          });
          break;

        default:
          throw new Error('Bad filter coordinate system');
      }
    }
    return positions;
  }

  /**
   * Set post-filtering flag. When false, try to load a partial set of
   * HRTF.
   *
   * @param {Boolean} [post=false]
   */
  set filterAfterLoad(post) {
    this._filterAfterLoad = (typeof post !== 'undefined'
                             ? post
                             : false);
  }

  /**
   * Get post-filtering flag. When false, try to load a partial set of
   * HRTF.
   *
   * @returns {Boolean}
   */
  get filterAfterLoad() {
    return this._filterAfterLoad;
  }

  /**
   * Test whether an HRTF set is actually loaded.
   *
   * @see {@link HrtfSet#load}
   *
   * @returns {Boolean} false before any successful load, true after.
   *
   */
  get isReady() {
    return this._ready;
  }

  /**
   * Get the original name of the HRTF set.
   *
   * @returns {String} that is undefined before a successfully load.
   */
  get sofaName() {
    return this._sofaName;
  }

  /**
   * Get the URL used to actually load the HRTF set.
   *
   * @returns {String} that is undefined before a successfully load.
   */
  get sofaUrl() {
    return this._sofaUrl;
  }

  /**
   * Get the original sample-rate from the SOFA URL already loaded.
   *
   * @returns {Number} that is undefined before a successfully load.
   */
  get sofaSampleRate() {
    return this._sofaSampleRate;
  }

  /**
   * Get the meta-data from the SOFA URL already loaded.
   *
   * @returns {Object} that is undefined before a successfully load.
   */
  get sofaMetaData() {
    return this._sofaMetaData;
  }

  // ------------- public methods

  /**
   * Apply filter positions to an existing set of HRTF. (After a successful
   * load.)
   *
   * This is destructive.
   *
   * @see {@link HrtfSet#load}
   */
  applyFilterPositions() {
    // do not use getter for gl positions
    let filteredPositions = this._filterPositions.map( (current) => {
      return this._kdt.nearest({ x: current[0], y: current[1], z: current[2] },
                               1)
        .pop()[0]; // nearest data
    });

    // filter out duplicates
    filteredPositions = [ ...new Set(filteredPositions) ];

    this._kdt = kdTree.tree.createKdTree(filteredPositions,
                                         kdTree.distanceSquared,
                                         ['x', 'y', 'z']);
  }

  /**
   * Load an URL and generate the corresponding set of IR buffers.
   *
   * @param {String} sourceUrl
   * @returns {Promise.<this|Error>} resolve when the URL sucessfully
   * loaded.
   */
  load(sourceUrl) {
    const extension = sourceUrl.split('.').pop();

    const url = (extension === 'sofa'
                 ? `${sourceUrl}.json`
                 : sourceUrl);

    let promise;

    // need a server for partial downloading ("sofa" extension may be naive)
    const preFilter = typeof this._filterPositions !== 'undefined'
            && !this.filterAfterLoad
            && extension === 'sofa';
    if (preFilter) {
      promise = Promise.all([
        this._loadMetaAndPositions(sourceUrl),
        this._loadDataSet(sourceUrl),
      ])
        .then( (indicesAndDataSet) => {
          const indices = indicesAndDataSet[0];
          const dataSet = indicesAndDataSet[1];
          return this._loadSofaPartial(sourceUrl, indices, dataSet)
            .then( () => {
              this._ready = true;
              return this; // final resolve
            });
        })
        .catch( () => {
          // when pre-fitering fails, for any reason, try to post-filter
          // console.log(`Error while partial loading of ${sourceUrl}. `
          //             + `${error.message}. `
          //             + `Load full and post-filtering, instead.`);
          return this._loadSofaFull(url)
            .then( () => {
              this.applyFilterPositions();
              this._ready = true;
              return this; // final resolve
            });
        });
    } else {
      promise = this._loadSofaFull(url)
        .then( () => {
          if (typeof this._filterPositions !== 'undefined'
              && this.filterAfterLoad) {
            this.applyFilterPositions();
          }
          this._ready = true;
          return this; // final resolve
        });
    }

    return promise;
  }

  /**
   * Export the current HRTF set as a JSON string.
   *
   * When set, `this.filterPositions` reduce the actual number of filter, and
   * thus the exported set. The coordinate system of the export is
   * `this.filterCoordinateSystem`.
   *
   * @see {@link HrtfSet#filterCoordinateSystem}
   * @see {@link HrtfSet#filterPositions}
   *
   * @returns {String} as a SOFA JSON file.
   * @throws {Error} when this.filterCoordinateSystem is unknown.
   */
  export() {
    // in a SOFA file, the source positions are the HrtfSet filter positions.

    // SOFA listener is the reference for HrtfSet filter positions
    // which is normalised in HrtfSet

    let SourcePosition;
    const SourcePositionType = coordinates.systemType(
      this.filterCoordinateSystem);
    switch (SourcePositionType) {
      case 'cartesian':
        SourcePosition = this._sofaSourcePosition.map( (position) => {
          return coordinates.glToSofaCartesian([], position);
        });
        break;

      case 'spherical':
        SourcePosition = this._sofaSourcePosition.map( (position) => {
          return coordinates.glToSofaSpherical([], position);
        });
        break;

      default:
        throw new Error(`Bad source position type ${SourcePositionType} ` +
                        `for export.`);
    }

    const DataIR = this._sofaSourcePosition.map( (position) => {
      // retrieve fir for each position, without conversion
      const fir = this._kdt.nearest(
        { x: position[0], y: position[1], z: position[2] }, 1)
              .pop()[0].fir; // nearest data
      const ir = [];
      for (let channel = 0; channel < fir.numberOfChannels; ++channel) {
        // Float32Array to array for stringify
        ir.push([... fir.getChannelData(channel) ] );
      }
      return ir;
    });

    return stringifySofa({
      name: this._sofaName,
      metaData: this._sofaMetaData,
      ListenerPosition: [0, 0, 0],
      ListenerPositionType: 'cartesian',
      ListenerUp: [0, 0, 1],
      ListenerUpType: 'cartesian',
      ListenerView: [1, 0, 0],
      ListenerViewType: 'cartesian',
      SourcePositionType,
      SourcePosition,
      DataSamplingRate: this._audioContext.sampleRate,
      DataDelay: this._sofaDelay,
      DataIR,
      RoomVolume: this._sofaRoomVolume,
    });
  }

  /**
   * @typedef {Object} HrtfSet.nearestType
   * @property {Number} distance from the request
   * @property {AudioBuffer} fir 2-channels impulse response
   * @property {Number} index original index in the SOFA set
   * @property {Coordinates} position using coordinateSystem coordinates
   * system.
   */

  /**
   * Get the nearest point in the HRTF set, after a successful load.
   *
   * @see {@link HrtfSet#load}
   *
   * @param {Coordinates} positionRequest
   * @returns {HrtfSet.nearestType}
   */
  nearest(positionRequest) {
    const position = coordinates.systemToGl([], positionRequest, this.coordinateSystem);
    const nearest = this._kdt.nearest({
      x: position[0],
      y: position[1],
      z: position[2],
    }, 1).pop(); // nearest only
    const data = nearest[0];
    coordinates.glToSystem(position, [data.x, data.y, data.z], this.coordinateSystem);
    return {
      distance: nearest[1],
      fir: data.fir,
      index: data.index,
      position,
    };
  }

  /**
   * Get the FIR AudioBuffer that corresponds to the closest position in
   * the set.
   * @param {Coordinates} positionRequest
   * @returns {AudioBuffer}
   */
  nearestFir(positionRequest) {
    return this.nearest(positionRequest).fir;
  }

  // ----------- private methods

  /**
   * Creates a kd-tree out of the specified indices, positions, and FIR.
   *
   * @private
   *
   * @param {Array} indicesPositionsFirs
   * @returns {this}
   */
  _createKdTree(indicesPositionsFirs) {
    const positions = indicesPositionsFirs.map( (value) => {
      const impulseResponses = value[2];
      const fir = this._audioContext.createBuffer(
        impulseResponses.length,
        impulseResponses[0].length,
        this._audioContext.sampleRate);
      impulseResponses.forEach( (samples, channel) => {
        // do not use copyToChannel because of Safari <= 9
        fir.getChannelData(channel).set(samples);
      });

      return {
        index: value[0],
        x: value[1][0],
        y: value[1][1],
        z: value[1][2],
        fir,
      };
    });

    this._sofaSourcePosition = positions.map( (position) => {
      return [position.x, position.y, position.z];
    });

    this._kdt = kdTree.tree.createKdTree(positions,
                                         kdTree.distanceSquared,
                                         ['x', 'y', 'z']);
    return this;
  }

  /**
   * Asynchronously create Float32Arrays, with possible re-sampling.
   *
   * @private
   *
   * @param {Array.<Number>} indices
   * @param {Array.<Coordinates>} positions
   * @param {Array.<Float32Array>} firs
   * @returns {Promise.<Array|Error>}
   * @throws {Error} assertion that the channel count is 2
   */
  _generateIndicesPositionsFirs(indices, positions, firs) {
    const sofaFirsPromises = firs.map( (sofaFirChannels, index) => {
      const channelCount = sofaFirChannels.length;
      if (channelCount !== 2) {
        throw new Error(`Bad number of channels`
                        + ` for IR index ${indices[index]}`
                        + ` (${channelCount} instead of 2)`);
      }

      const sofaFirsChannelsPromises = sofaFirChannels.map( (fir) => {
        return resampleFloat32Array({
          inputSamples: fir,
          inputSampleRate: this._sofaSampleRate,
          outputSampleRate: this._audioContext.sampleRate,
        });
      });
      return Promise.all(sofaFirsChannelsPromises)
        .then( (firChannels) => {
          return [
            indices[index],
            positions[index],
            firChannels,
          ];
        })
        .catch( (error) => {
          // re-throw
          throw new Error(
            `Unable to re-sample impulse response ${index}. ${error.message}`);
        });
    });
    return Promise.all(sofaFirsPromises);
  }

  /**
   * Try to load a data set from a SOFA URL.
   *
   * @private
   *
   * @param {String} sourceUrl
   * @returns {Promise.<Object|Error>}
   */
  _loadDataSet(sourceUrl) {
    const promise = new Promise( (resolve, reject) => {
      const ddsUrl = `${sourceUrl}.dds`;
      const request = new window.XMLHttpRequest();
      request.open('GET', ddsUrl);
      request.onerror = () => {
        reject(new Error(`Unable to GET ${ddsUrl}, status ${request.status} `
                         + `${request.responseText}`) );
      };

      request.onload = () => {
        if (request.status < 200 || request.status >= 300) {
          request.onerror();
          return;
        }

        try {
          const dds = parseDataSet(request.response);
          resolve(dds);
        } catch (error) {
          // re-throw
          reject(new Error(`Unable to parse ${ddsUrl}. ${error.message}`) );
        }
      }; // request.onload

      request.send();
    });

    return promise;
  }

  /**
   * Try to load meta-data and positions from a SOFA URL, to get the
   * indices closest to the filter positions.
   *
   * @private
   *
   * @param {String} sourceUrl
   * @returns {Promise.<Array.<Number>|Error>}
   */
  _loadMetaAndPositions(sourceUrl) {
    const promise = new Promise( (resolve, reject) => {
      const positionsUrl = `${sourceUrl}.json?`
              + `ListenerPosition,ListenerUp,ListenerView,SourcePosition,`
              + `Data.Delay,Data.SamplingRate,`
              + `EmitterPosition,ReceiverPosition,RoomVolume`; // meta

      const request = new window.XMLHttpRequest();
      request.open('GET', positionsUrl);
      request.onerror = () => {
        reject(new Error(`Unable to GET ${positionsUrl}, status ${request.status} `
                         + `${request.responseText}`) );
      };

      request.onload = () => {
        if (request.status < 200 || request.status >= 300) {
          request.onerror();
          return;
        }

        try {
          const data = parseSofa(request.response);
          this._setMetaData(data, sourceUrl);

          const sourcePositions = this._sourcePositionsToGl(data);
          const hrtfPositions = sourcePositions.map( (position, index) => {
            return {
              x: position[0],
              y: position[1],
              z: position[2],
              index,
            };
          });

          const kdt = kdTree.tree.createKdTree(
            hrtfPositions,
            kdTree.distanceSquared,
            ['x', 'y', 'z']);

          let nearestIndices = this._filterPositions.map( (current) => {
            return kdt.nearest({ x: current[0], y: current[1], z: current[2] },
                               1)
              .pop()[0] // nearest data
              .index;
          });

          // filter out duplicates
          nearestIndices = [ ...new Set(nearestIndices) ];

          this._sofaUrl = sourceUrl;
          resolve(nearestIndices);
        } catch (error) {
          // re-throw
          reject(new Error(`Unable to parse ${positionsUrl}. ${error.message}`) );
        }
      }; // request.onload

      request.send();
    });

    return promise;
  }

  /**
   * Try to load full SOFA URL.
   *
   * @private
   *
   * @param {String} url
   * @returns {Promise.<this|Error>}
   */
  _loadSofaFull(url) {
    const promise = new Promise( (resolve, reject) => {
      const request = new window.XMLHttpRequest();
      request.open('GET', url);
      request.onerror = () => {
        reject(new Error(`Unable to GET ${url}, status ${request.status} `
                         + `${request.responseText}`) );
      };

      request.onload = () => {
        if (request.status < 200 || request.status >= 300) {
          request.onerror();
          return;
        }

        try {
          const data = parseSofa(request.response);
          this._setMetaData(data, url);
          const sourcePositions = this._sourcePositionsToGl(data);
          this._generateIndicesPositionsFirs(
            sourcePositions.map( (position, index) => index), // full
            sourcePositions,
            data['Data.IR'].data
          )
            .then( (indicesPositionsFirs) => {
              this._createKdTree(indicesPositionsFirs);
              this._sofaUrl = url;
              resolve(this);
            });

        } catch (error) {
          // re-throw
          reject(new Error(`Unable to parse ${url}. ${error.message}`) );
        }
      }; // request.onload

      request.send();
    });

    return promise;
  }

  /**
   * Try to load partial data from a SOFA URL.
   *
   * @private
   *
   * @param {Array.<String>} sourceUrl
   * @param {Array.<Number>} indices
   * @param {Object} dataSet
   * @returns {Promise.<this|Error>}
   */
  _loadSofaPartial(sourceUrl, indices, dataSet) {
    const urlPromises = indices.map( (index) => {
      const urlPromise = new Promise( (resolve, reject) => {
        const positionUrl = `${sourceUrl}.json?`
                + `SourcePosition[${index}][0:1:${dataSet.SourcePosition.C - 1}],`
                + `Data.IR[${index}][0:1:${dataSet['Data.IR'].R - 1}]`
                + `[0:1:${dataSet['Data.IR'].N - 1}]`;

        const request = new window.XMLHttpRequest();
        request.open('GET', positionUrl);
        request.onerror = () => {
          reject(new Error(`Unable to GET ${positionUrl}, status ${request.status} `
                           + `${request.responseText}`) );
        };

        request.onload = () => {
          if (request.status < 200 || request.status >= 300) {
            request.onerror();
          }

          try {
            const data = parseSofa(request.response);
            // (meta-data is already loaded)

            const sourcePositions = this._sourcePositionsToGl(data);
            this._generateIndicesPositionsFirs([index],
                                               sourcePositions,
                                               data['Data.IR'].data)
              .then( (indicesPositionsFirs) => {
                // One position per URL here
                // Array made of multiple promises, later
                resolve(indicesPositionsFirs[0]);
              });
          } catch (error) {
            // re-throw
            reject(new Error(
              `Unable to parse ${positionUrl}. ${error.message}`) );
          }
        }; // request.onload

        request.send();
      });

      return urlPromise;
    });

    return Promise.all(urlPromises)
      .then( (indicesPositionsFirs) => {
        this._createKdTree(indicesPositionsFirs);
        return this; // final resolve
      });
  }

  /**
   * Set meta-data, and assert for supported HRTF type.
   *
   * @private
   *
   * @param {Object} data
   * @param {String} sourceUrl
   * @throws {Error} assertion for FIR data.
   */
  _setMetaData(data, sourceUrl) {
    if (typeof data.metaData.DataType !== 'undefined'
        && data.metaData.DataType !== 'FIR') {
      throw new Error('According to meta-data, SOFA data type is not FIR');
    }

    const dateString = new Date().toISOString();

    this._sofaName = (typeof data.name !== 'undefined'
                      ? `${data.name}`
                      : `HRTF.sofa`);

    this._sofaMetaData = (typeof data.metaData !== 'undefined'
                          ? data.metaData
                          : {} );

    // append conversion information
    if (typeof sourceUrl !== 'undefined') {
      this._sofaMetaData.OriginalUrl = sourceUrl;
    }

    this._sofaMetaData.Converter = `Ircam ${info.name} ${info.version} `
      + `javascript API `;
    this._sofaMetaData.DateConverted = dateString;

    this._sofaSampleRate = (typeof data['Data.SamplingRate'] !== 'undefined'
                            ? data['Data.SamplingRate'].data[0]
                            : 48000); // Table C.1
    if (this._sofaSampleRate !== this._audioContext.sampleRate) {
      this._sofaMetaData.OriginalSampleRate = this._sofaSampleRate;
    }

    this._sofaDelay = (typeof data['Data.Delay'] !== 'undefined'
                         ? data['Data.Delay'].data[0]
                         : 0);

    this._sofaRoomVolume = (typeof data.RoomVolume !== 'undefined'
                            ? data.RoomVolume.data[0]
                            : undefined);

    // Convert listener position, up, and view to SOFA cartesian,
    // to generate a SOFA-to-GL look-at mat4.
    // Default SOFA type is 'cartesian' (see table D.4A).

    const listenerPosition = coordinates.sofaToSofaCartesian(
      [], data.ListenerPosition.data[0],
      conformSofaCoordinateSystem(data.ListenerPosition.Type || 'cartesian') );

    const listenerView = coordinates.sofaToSofaCartesian(
      [], data.ListenerView.data[0],
      conformSofaCoordinateSystem(data.ListenerView.Type || 'cartesian') );

    const listenerUp = coordinates.sofaToSofaCartesian(
      [], data.ListenerUp.data[0],
      conformSofaCoordinateSystem(data.ListenerUp.Type || 'cartesian') );

    this._sofaToGl = glMatrix.mat4.lookAt(
      [], listenerPosition, listenerView, listenerUp);
  }

  /**
   * Convert to GL coordinates, in-place.
   *
   * @private
   *
   * @param {Object} data
   * @returns {Array.<Coordinates>}
   * @throws {Error}
   */
  _sourcePositionsToGl(data) {
    const sourcePositions = data.SourcePosition.data; // reference
    const sourceCoordinateSystem = (typeof data.SourcePosition.Type !== 'undefined'
                                    ? data.SourcePosition.Type
                                    : 'spherical'); // default (SOFA Table D.4C)
    switch (sourceCoordinateSystem) {
      case 'cartesian':
        sourcePositions.forEach( (position) => {
          glMatrix.vec3.transformMat4(position, position,
                                      this._sofaToGl);
        });
        break;

      case 'spherical':
        sourcePositions.forEach( (position) => {
          coordinates.sofaSphericalToSofaCartesian(position, position); // in-place
          glMatrix.vec3.transformMat4(position, position,
                                      this._sofaToGl);
        });
        break;

      default:
        throw new Error('Bad source position type');
    }

    return sourcePositions;
  }

}

export default HrtfSet;