Source: tasks/impulse-response/impulseResponse.js

import AudioCalibrator from '../audioCalibrator';
import MlsGenInterface from './mlsGen/mlsGenInterface';

import {sleep, csvToArray} from '../../utils';

/**
 *
 */
class ImpulseResponse extends AudioCalibrator {
  /**
   * Default constructor. Creates an instance with any number of paramters passed or the default parameters defined here.
   * @param {*} param0
   */
  constructor({download = false, mlsOrder = 18, numCaptures = 5, numMLSPerCapture = 4}) {
    super(numCaptures, numMLSPerCapture);
    this.#mlsOrder = parseInt(mlsOrder, 10);
    this.#P = 2 ** mlsOrder - 1;
    this.#download = download;
  }

  /** @private */
  #download;

  /** @private */
  #mlsGenInterface;

  /** @private */
  #mlsBufferView;

  /** @private */
  invertedImpulseResponse = null;

  /** @private */
  impulseResponses = [];

  /** @private */
  #mlsOrder;

  #P;

  /** @private */
  TAPER_SECS = 5;

  /** @private */
  offsetGainNode;

  /**
   * Sends all the computed impulse responses to the backend server for processing
   * @returns sets the resulting inverted impulse response to the class property
   */
  sendImpulseResponsesToServerForProcessing = async () => {
    const computedIRs = await Promise.all(this.impulseResponses);
    this.emit('update', {message: `computing the IIR...`});
    return this.pyServerAPI
      .getInverseImpulseResponse({
        payload: computedIRs,
      })
      .then(res => {
        this.emit('update', {message: `done computing the IIR...`});
        this.invertedImpulseResponse = res;
      })
      .catch(err => {
        // this.emit('InvertedImpulseResponse', {res: false});
        console.error(err);
      });
  };

  /**
   * Sends the recorded signal, or a given csv string of a signal, to the back end server for processing
   * @param {<array>String} signalCsv - Optional csv string of a previously recorded signal, if given, this signal will be processed
   */
  sendRecordingToServerForProcessing = signalCsv => {
    const allSignals = this.getAllRecordedSignals();
    const numSignals = allSignals.length;
    const payload =
      signalCsv && signalCsv.length > 0 ? csvToArray(signalCsv) : allSignals[numSignals - 1];

    this.emit('update', {message: `computing the IR of the last recording...`});
    this.impulseResponses.push(
      this.pyServerAPI
        .getImpulseResponse({
          sampleRate: this.sourceSamplingRate || 96000,
          payload,
          P: this.#P,
        })
        .then(res => {
          if (this.numSuccessfulCaptured < this.numCaptures) {
            this.numSuccessfulCaptured += 1;
            this.emit('update', {
              message: `${this.numSuccessfulCaptured}/${this.numCaptures} IRs computed...`,
            });
          }
          return res;
        })
        .catch(err => {
          console.error(err);
        })
    );
  };

  /**
   * Passed to the calibration steps function, awaits the desired amount of seconds to capture the desired number
   * of MLS periods defined in the constructor
   */
  #awaitDesiredMLSLength = async () => {
    // seconds per MLS = P / SR
    // await N * P / SR
    this.emit('update', {
      message: `sampling the calibration signal...`,
    });
    await sleep((this.#P / this.sourceSamplingRate) * this.numMLSPerCapture);
  };

  /**
   * Passed to the calibration steps function, awaits the onset of the signal to ensure a steady state
   */
  #awaitSignalOnset = async () => {
    this.emit('update', {
      message: `waiting for the signal to stabalize...`,
    });
    await sleep(this.TAPER_SECS);
  };

  /**
   * Called immediately after a recording is captured. Used to process the resulting signal
   * whether by sending the result to a server or by computing a result locally
   */
  #afterMLSRecord = () => {
    if (this.#download) {
      this.downloadData();
    }
    this.#stopCalibrationAudio();
    this.sendRecordingToServerForProcessing();
  };

  #afterMLSwIIRRecord = () => {
    if (this.#download) {
      this.downloadData();
    }
    this.#stopCalibrationAudio();
  };

  /**
   * Created an S Curver Buffer to taper the signal onset
   * @param {*} length
   * @param {*} phase
   * @returns
   */
  static createSCurveBuffer = (length, phase) => {
    const curve = new Float32Array(length);
    let i;
    for (i = 0; i < length; i += 1) {
      // scale the curve to be between 0-1
      curve[i] = Math.sin((Math.PI * i) / length - phase) / 2 + 0.5;
    }
    return curve;
  };

  static createInverseSCurveBuffer = (length, phase) => {
    const curve = new Float32Array(length);
    let i;
    let j = length - 1;
    for (i = 0; i < length; i += 1) {
      // scale the curve to be between 0-1
      curve[i] = Math.sin((Math.PI * j) / length - phase) / 2 + 0.5;
      j -= 1;
    }
    return curve;
  };

  /**
   * Construct a Calibration Node with the calibration parameters.
   * @private
   */
  #createPureTonenNode = CALIBRATION_TONE_FREQUENCY => {
    const audioContext = this.makeNewSourceAudioContext();
    const oscilator = audioContext.createOscillator();
    const gainNode = audioContext.createGain();

    oscilator.frequency.value = CALIBRATION_TONE_FREQUENCY;
    oscilator.type = 'sine';
    gainNode.gain.value = 0.04;

    oscilator.connect(gainNode);
    gainNode.connect(audioContext.destination);

    this.addCalibrationNode(oscilator);
  };

  /**
   * Construct a Calibration Node with the calibration parameters.
   * @private
   */
  #createCalibrationNodeFromBuffer = dataBuffer => {
    const audioContext = this.makeNewSourceAudioContext();
    const buffer = audioContext.createBuffer(
      1, // number of channels
      dataBuffer.length,
      audioContext.sampleRate // sample rate
    );

    const data = buffer.getChannelData(0); // get data
    // fill the buffer with our data
    try {
      for (let i = 0; i < dataBuffer.length; i += 1) {
        data[i] = dataBuffer[i];
      }
    } catch (error) {
      console.error(error);
    }

    const source = audioContext.createBufferSource();

    source.buffer = buffer;
    source.loop = true;
    source.connect(audioContext.destination);

    this.addCalibrationNode(source);
  };

  /**
   * Given a data buffer, creates the required calibration node
   * @param {*} dataBufferArray
   */
  #setCalibrationNodesFromBuffer = (dataBufferArray = [this.#mlsBufferView]) => {
    if (dataBufferArray.length === 1) {
      this.#createCalibrationNodeFromBuffer(dataBufferArray[0]);
    } else {
      throw new Error('The length of the data buffer array must be 1');
    }
  };

  #createImpulseResponseFilterGraph = (calibrationSignal, iir) => {
    const audioCtx = this.makeNewSourceAudioContext();

    // -------------------------------------------------------- IIR
    const iirBuffer = audioCtx.createBuffer(
      1,
      // TODO: quality check this
      iir.length - 1,
      audioCtx.sampleRate
    );

    // Fill the buffer with the inverted impulse response
    const iirChannelZeroBuffer = iirBuffer.getChannelData(0);
    for (let i = 0; i < iirBuffer.length; i++) {
      // audio needs to be in [-1.0; 1.0]
      iirChannelZeroBuffer[i] = iir[i];
    }

    const convolverNode = audioCtx.createConvolver();

    convolverNode.normalize = false;
    convolverNode.channelCount = 1;
    convolverNode.buffer = iirBuffer;

    // ------------------------------------------------------ MLS
    const calibrationSignalBuffer = audioCtx.createBuffer(
      1, // number of channels
      calibrationSignal.length,
      audioCtx.sampleRate // sample rate
    );

    const mlsChannelZeroBuffer = calibrationSignalBuffer.getChannelData(0); // get data
    // fill the buffer with our data
    try {
      for (let i = 0; i < calibrationSignal.length; i += 1) {
        mlsChannelZeroBuffer[i] = calibrationSignal[i];
      }
    } catch (error) {
      console.error(error);
    }

    const sourceNode = audioCtx.createBufferSource();

    sourceNode.buffer = calibrationSignalBuffer;
    sourceNode.loop = true;
    sourceNode.connect(convolverNode);

    convolverNode.connect(audioCtx.destination);

    console.log({convolverNode, sourceNode});

    this.addCalibrationNode(sourceNode);
  };

  #createIIRwMLSGraph = () => {
    this.#createImpulseResponseFilterGraph(this.impulseResponses, [this.#mlsBufferView][0]);
  };

  /**
   * Creates an audio context and plays it for a few seconds.
   * @private
   * @returns {Promise} - Resolves when the audio is done playing.
   */
  #playCalibrationAudio = () => {
    this.calibrationNodes[0].start(0);
    this.emit('update', {message: 'playing the calibration tone...'});
  };

  /**
   * Stops the audio with tapered offset
   */
  #stopCalibrationAudio = () => {
    this.calibrationNodes[0].stop();
    this.emit('update', {message: 'stopping the calibration tone...'});
  };

  playMLSwithIIR = async (stream, iir) => {
    this.invertedImpulseResponse = iir;
    // initialize the MLSGenInterface object with it's factory method
    await MlsGenInterface.factory(
      this.#mlsOrder,
      this.sinkSamplingRate,
      this.sourceSamplingRate
    ).then(mlsGenInterface => {
      this.#mlsGenInterface = mlsGenInterface;
      this.#mlsBufferView = this.#mlsGenInterface.getMLS();
    });

    // after intializating, start the calibration steps with garbage collection
    await this.#mlsGenInterface.withGarbageCollection([
      [
        this.calibrationSteps,
        [
          stream,
          this.#playCalibrationAudio, // play audio func (required)
          this.#createImpulseResponseFilterGraph, // before play func
          null, // before record
          this.#awaitDesiredMLSLength, // during record
          this.#afterMLSwIIRRecord, // after record
        ],
      ],
    ]);
  };

  /**
   * Public method to start the calibration process. Objects intialized from webassembly allocate new memory
   * and must be manually freed. This function is responsible for intializing the MlsGenInterface,
   * and wrapping the calibration steps with a garbage collection safe gaurd.
   * @public
   * @param {MediaStream} stream - The stream of audio from the Listener.
   */
  startCalibration = async stream => {
    // initialize the MLSGenInterface object with it's factory method
    await MlsGenInterface.factory(
      this.#mlsOrder,
      this.sinkSamplingRate,
      this.sourceSamplingRate
    ).then(mlsGenInterface => {
      this.#mlsGenInterface = mlsGenInterface;
      this.#mlsBufferView = this.#mlsGenInterface.getMLS();
    });

    // after intializating, start the calibration steps with garbage collection
    await this.#mlsGenInterface.withGarbageCollection([
      [
        this.calibrationSteps,
        [
          stream,
          this.#playCalibrationAudio, // play audio func (required)
          this.#setCalibrationNodesFromBuffer, // before play func
          null, // before record
          this.#awaitDesiredMLSLength, // during record
          this.#afterMLSRecord, // after record
        ],
      ],
    ]);

    // await the server response
    await this.sendImpulseResponsesToServerForProcessing();
    await this.playMLSwithIIR(stream, this.invertedImpulseResponse);

    return this.invertedImpulseResponse;
  };
}

export default ImpulseResponse;