sound/Microphone.js

/**
 * Manager handling the recording of audio signal.
 *
 * @author Alain Pitiot and Sotiri Bakagiannis
 * @version 2022.2.3
 * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
 * @license Distributed under the terms of the MIT License
 */

import { PsychoJS } from "../core/PsychoJS.js";
import { ExperimentHandler } from "../data/ExperimentHandler.js";
import { Clock } from "../util/Clock.js";
import { PsychObject } from "../util/PsychObject.js";
import * as util from "../util/Util.js";
import { AudioClip } from "./AudioClip.js";

/**
 * <p>This manager handles the recording of audio signal.</p>
 *
 * @extends PsychObject
 */
export class Microphone extends PsychObject
{
	/**
	 * @memberOf module:sound
	 * @param {Object} options
	 * @param {module:core.Window} options.win - the associated Window
	 * @param {String} options.name - the name used when logging messages from this stimulus
	 * @param {string} [options.format='audio/webm;codecs=opus'] the format for the audio file
	 * @param {number} [options.sampleRateHz= 48000] - the audio sampling rate, in Hz
	 * @param {Clock} [options.clock= undefined] - an optional clock
	 * @param {boolean} [options.autoLog= false] - whether to log
	 */
	constructor({ win, name, format, sampleRateHz, clock, autoLog } = {})
	{
		super(win._psychoJS);

		this._addAttribute("win", win, undefined);
		this._addAttribute("name", name, "microphone");
		this._addAttribute("format", format, "audio/webm;codecs=opus", this._onChange);
		this._addAttribute("sampleRateHz", sampleRateHz, 48000, this._onChange);
		this._addAttribute("clock", clock, new Clock());
		this._addAttribute("autoLog", autoLog, autoLog);
		this._addAttribute("status", PsychoJS.Status.NOT_STARTED);

		// prepare the recording:
		this._prepareRecording();

		if (this._autoLog)
		{
			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
		}
	}

	/**
	 * Submit a request to start the recording.
	 *
	 * <p>Note that it typically takes 50ms-200ms for the recording to actually starts once
	 * a request to start has been submitted.</p>
	 *
	 * @return {Promise} promise fulfilled when the recording actually started
	 */
	start()
	{
		// if the microphone is currently paused, a call to start resumes it
		// with a new recording:
		if (this._status === PsychoJS.Status.PAUSED)
		{
			return this.resume({ clear: true });
		}

		if (this._status !== PsychoJS.Status.STARTED)
		{
			this._psychoJS.logger.debug("request to start audio recording");

			try
			{
				if (!this._recorder)
				{
					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record audio";
				}

				this._recorder.start();

				// return a promise, which will be satisfied when the recording actually starts, which
				// is also when the reset of the clock and the change of status takes place
				const self = this;
				return new Promise((resolve, reject) =>
				{
					self._startCallback = resolve;
					self._errorCallback = reject;
				});
			}
			catch (error)
			{
				this._psychoJS.logger.error("unable to start the audio recording: " + JSON.stringify(error));
				this._status = PsychoJS.Status.ERROR;

				throw {
					origin: "Microphone.start",
					context: "when starting the audio recording for microphone: " + this._name,
					error,
				};
			}
		}
	}

	/**
	 * Submit a request to stop the recording.
	 *
	 * @param {Object} options
	 * @param {string} [options.filename] the name of the file to which the audio recording will be
	 * 	saved
	 * @return {Promise} promise fulfilled when the recording actually stopped, and the recorded
	 * 	data was made available
	 */
	stop({ filename } = {})
	{
		if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED)
		{
			this._psychoJS.logger.debug("request to stop audio recording");

			this._stopOptions = {
				filename,
			};

			// note: calling the stop method of the MediaRecorder will first raise a dataavailable event,
			// and then a stop event
			// ref: https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/stop
			this._recorder.stop();

			// return a promise, which will be satisfied when the recording actually stops and the data
			// has been made available:
			const self = this;
			return new Promise((resolve, reject) =>
			{
				self._stopCallback = resolve;
				self._errorCallback = reject;
			});
		}
	}

	/**
	 * Submit a request to pause the recording.
	 *
	 * @return {Promise} promise fulfilled when the recording actually paused
	 */
	pause()
	{
		if (this._status === PsychoJS.Status.STARTED)
		{
			this._psychoJS.logger.debug("request to pause audio recording");

			try
			{
				if (!this._recorder)
				{
					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record audio";
				}

				// note: calling the pause method of the MediaRecorder raises a pause event
				this._recorder.pause();

				// return a promise, which will be satisfied when the recording actually pauses:
				const self = this;
				return new Promise((resolve, reject) =>
				{
					self._pauseCallback = resolve;
					self._errorCallback = reject;
				});
			}
			catch (error)
			{
				self._psychoJS.logger.error("unable to pause the audio recording: " + JSON.stringify(error));
				this._status = PsychoJS.Status.ERROR;

				throw {
					origin: "Microphone.pause",
					context: "when pausing the audio recording for microphone: " + this._name,
					error,
				};
			}
		}
	}

	/**
	 * Submit a request to resume the recording.
	 *
	 * <p>resume has no effect if the recording was not previously paused.</p>
	 *
	 * @param {Object} options
	 * @param {boolean} [options.clear= false] whether or not to empty the audio buffer before
	 * 	resuming the recording
	 * @return {Promise} promise fulfilled when the recording actually resumed
	 */
	resume({ clear = false } = {})
	{
		if (this._status === PsychoJS.Status.PAUSED)
		{
			this._psychoJS.logger.debug("request to resume audio recording");

			try
			{
				if (!this._recorder)
				{
					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record audio";
				}

				// empty the audio buffer is needed:
				if (clear)
				{
					this._audioBuffer = [];
					this._audioBuffer.length = 0;
				}

				this._recorder.resume();

				// return a promise, which will be satisfied when the recording actually resumes:
				const self = this;
				return new Promise((resolve, reject) =>
				{
					self._resumeCallback = resolve;
					self._errorCallback = reject;
				});
			}
			catch (error)
			{
				self._psychoJS.logger.error("unable to resume the audio recording: " + JSON.stringify(error));
				this._status = PsychoJS.Status.ERROR;

				throw {
					origin: "Microphone.resume",
					context: "when resuming the audio recording for microphone: " + this._name,
					error,
				};
			}
		}
	}

	/**
	 * Submit a request to flush the recording.
	 *
	 * @return {Promise} promise fulfilled when the data has actually been made available
	 */
	flush()
	{
		if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED)
		{
			this._psychoJS.logger.debug("request to flush audio recording");

			// note: calling the requestData method of the MediaRecorder will raise a
			// dataavailable event
			// ref: https://developer.mozilla.org/en-US/docs/Web/API/MediaRecorder/requestData
			this._recorder.requestData();

			// return a promise, which will be satisfied when the data has been made available:
			const self = this;
			return new Promise((resolve, reject) =>
			{
				self._dataAvailableCallback = resolve;
				self._errorCallback = reject;
			});
		}
	}

	/**
	 * Offer the audio recording to the participant as a sound file to download.
	 *
	 * @param {string} filename the filename
	 */
	download(filename = "audio.webm")
	{
		const audioBlob = new Blob(this._audioBuffer);

		const anchor = document.createElement("a");
		anchor.href = window.URL.createObjectURL(audioBlob);
		anchor.download = filename;
		document.body.appendChild(anchor);
		anchor.click();
		document.body.removeChild(anchor);
	}

	/**
	 * Upload the audio recording to the pavlovia server.
	 *
	 * @param {string} tag an optional tag for the audio file
	 */
	async upload({ tag } = {})
	{
		// default tag: the name of this Microphone object
		if (typeof tag === "undefined")
		{
			tag = this._name;
		}

		// add a format-dependent audio extension to the tag:
		tag += util.extensionFromMimeType(this._format);

		// if the audio recording cannot be uploaded, e.g. the experiment is running locally, or
		// if it is piloting mode, then we offer the audio recording as a file for download:
		if (
			this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER
			|| this._psychoJS.config.experiment.status !== "RUNNING"
			|| this._psychoJS._serverMsg.has("__pilotToken")
		)
		{
			return this.download(tag);
		}

		// upload the blob:
		const audioBlob = new Blob(this._audioBuffer);
		return this._psychoJS.serverManager.uploadAudioVideo({
			mediaBlob: audioBlob,
			tag
		});
	}

	/**
	 * Get the current audio recording as an AudioClip in the given format.
	 *
 	 * @param {string} tag an optional tag for the audio clip
	 * @param {boolean} [flush=false] whether or not to first flush the recording
	 */
	async getRecording({ tag, flush = false } = {})
	{
		// default tag: the name of this Microphone object
		if (typeof tag === "undefined")
		{
			tag = this._name;
		}

		const audioClip = new AudioClip({
			psychoJS: this._psychoJS,
			name: tag,
			format: this._format,
			sampleRateHz: this._sampleRateHz,
			data: new Blob(this._audioBuffer),
		});

		return audioClip;
	}

	/**
	 * Callback for changes to the recording settings.
	 *
	 * <p>Changes to the settings require the recording to stop and be re-started.</p>
	 *
	 * @protected
	 */
	_onChange()
	{
		if (this._status === PsychoJS.Status.STARTED)
		{
			this.stop();
		}

		this._prepareRecording();

		this.start();
	}

	/**
	 * Prepare the recording.
	 *
	 * @protected
	 */
	async _prepareRecording()
	{
		// empty the audio buffer:
		this._audioBuffer = [];
		this._recorder = null;

		// create a new audio recorder:
		const stream = await navigator.mediaDevices.getUserMedia({
			audio: {
				advanced: [
					{
						channelCount: 1,
						sampleRate: this._sampleRateHz,
					},
				],
			},
		});

		// check that the specified format is supported, use default if it is not:
		let options;
		if (typeof this._format === "string" && MediaRecorder.isTypeSupported(this._format))
		{
			options = { type: this._format };
		}
		else
		{
			this._psychoJS.logger.warn(`The specified audio format, ${this._format}, is not supported by this browser, using the default format instead`);
		}

		this._recorder = new MediaRecorder(stream, options);

		// setup the callbacks:
		const self = this;

		// called upon Microphone.start(), at which point the audio data starts being gathered
		// into a blob:
		this._recorder.onstart = () =>
		{
			self._audioBuffer = [];
			self._audioBuffer.length = 0;
			self._clock.reset();
			self._status = PsychoJS.Status.STARTED;
			self._psychoJS.logger.debug("audio recording started");

			// resolve the Microphone.start promise:
			if (self._startCallback)
			{
				self._startCallback(self._psychoJS.monotonicClock.getTime());
			}
		};

		// called upon Microphone.pause():
		this._recorder.onpause = () =>
		{
			self._status = PsychoJS.Status.PAUSED;
			self._psychoJS.logger.debug("audio recording paused");

			// resolve the Microphone.pause promise:
			if (self._pauseCallback)
			{
				self._pauseCallback(self._psychoJS.monotonicClock.getTime());
			}
		};

		// called upon Microphone.resume():
		this._recorder.onresume = () =>
		{
			self._status = PsychoJS.Status.STARTED;
			self._psychoJS.logger.debug("audio recording resumed");

			// resolve the Microphone.resume promise:
			if (self._resumeCallback)
			{
				self._resumeCallback(self._psychoJS.monotonicClock.getTime());
			}
		};

		// called when audio data is available, typically upon Microphone.stop() or Microphone.flush():
		this._recorder.ondataavailable = (event) =>
		{
			const data = event.data;

			// add data to the buffer:
			self._audioBuffer.push(data);
			self._psychoJS.logger.debug("audio data added to the buffer");

			// resolve the data available promise, if needed:
			if (self._dataAvailableCallback)
			{
				self._dataAvailableCallback(self._psychoJS.monotonicClock.getTime());
			}
		};

		// called upon Microphone.stop(), after data has been made available:
		this._recorder.onstop = () =>
		{
			self._psychoJS.logger.debug("audio recording stopped");
			self._status = PsychoJS.Status.NOT_STARTED;

			// resolve the Microphone.stop promise:
			if (self._stopCallback)
			{
				self._stopCallback(self._psychoJS.monotonicClock.getTime());
			}

			// treat stop options if there are any:

			// download to a file, immediately offered to the participant:
			if (typeof self._stopOptions.filename === "string")
			{
				self.download(self._stopOptions.filename);
			}
		};

		// called upon recording errors:
		this._recorder.onerror = (event) =>
		{
			// TODO
			self._psychoJS.logger.error("audio recording error: " + JSON.stringify(event));
			self._status = PsychoJS.Status.ERROR;
		};
	}
}