From f155192fd5a9ddcff6dc3bcc835ea0ad89515b26 Mon Sep 17 00:00:00 2001
From: Alain Pitiot
Date: Mon, 31 May 2021 08:32:35 +0200
Subject: [PATCH] _
---
docs/TextInput.html | 4 +-
docs/core_EventManager.js.html | 4 +-
docs/core_GUI.js.html | 33 +-
docs/core_Keyboard.js.html | 6 +-
docs/core_Logger.js.html | 12 +-
docs/core_MinimalStim.js.html | 4 +-
docs/core_Mouse.js.html | 4 +-
docs/core_PsychoJS.js.html | 77 +-
docs/core_ServerManager.js.html | 584 +++++++++--
docs/core_Window.js.html | 10 +-
docs/core_WindowMixin.js.html | 4 +-
docs/data_ExperimentHandler.js.html | 4 +-
docs/data_TrialHandler.js.html | 7 +-
docs/index.html | 67 +-
docs/module-core.BuilderKeyResponse.html | 4 +-
docs/module-core.EventManager.html | 4 +-
docs/module-core.GUI.html | 8 +-
docs/module-core.KeyPress.html | 4 +-
docs/module-core.Keyboard.html | 4 +-
docs/module-core.Logger.html | 4 +-
docs/module-core.MinimalStim.html | 4 +-
docs/module-core.Mouse.html | 4 +-
docs/module-core.PsychoJS.html | 420 ++++----
docs/module-core.ServerManager.html | 1166 ++++++++++++++++++----
docs/module-core.Window.html | 4 +-
docs/module-core.WindowMixin.html | 4 +-
docs/module-core.html | 4 +-
docs/module-data.ExperimentHandler.html | 4 +-
docs/module-data.TrialHandler.html | 30 +-
docs/module-data.html | 7 +-
docs/module-sound.Sound.html | 4 +-
docs/module-sound.SoundPlayer.html | 4 +-
docs/module-sound.TonePlayer.html | 4 +-
docs/module-sound.TrackPlayer.html | 4 +-
docs/module-sound.html | 16 +-
docs/module-util.Clock.html | 4 +-
docs/module-util.Color.html | 4 +-
docs/module-util.ColorMixin.html | 4 +-
docs/module-util.CountdownTimer.html | 4 +-
docs/module-util.EventEmitter.html | 4 +-
docs/module-util.MixinBuilder.html | 4 +-
docs/module-util.MonotonicClock.html | 4 +-
docs/module-util.PsychObject.html | 44 +-
docs/module-util.Scheduler.html | 4 +-
docs/module-util.html | 165 ++-
docs/module-visual.Form.html | 4 +-
docs/module-visual.ImageStim.html | 4 +-
docs/module-visual.MovieStim.html | 4 +-
docs/module-visual.Polygon.html | 4 +-
docs/module-visual.Rect.html | 4 +-
docs/module-visual.ShapeStim.html | 4 +-
docs/module-visual.Slider.html | 4 +-
docs/module-visual.TextBox.html | 4 +-
docs/module-visual.TextStim.html | 4 +-
docs/module-visual.VisualStim.html | 4 +-
docs/module-visual.html | 4 +-
docs/sound_Sound.js.html | 4 +-
docs/sound_SoundPlayer.js.html | 4 +-
docs/sound_TonePlayer.js.html | 4 +-
docs/sound_TrackPlayer.js.html | 4 +-
docs/util_Clock.js.html | 4 +-
docs/util_Color.js.html | 4 +-
docs/util_ColorMixin.js.html | 4 +-
docs/util_EventEmitter.js.html | 4 +-
docs/util_PsychObject.js.html | 10 +-
docs/util_Scheduler.js.html | 16 +-
docs/util_Util.js.html | 53 +-
docs/visual_Form.js.html | 4 +-
docs/visual_ImageStim.js.html | 4 +-
docs/visual_MovieStim.js.html | 4 +-
docs/visual_Polygon.js.html | 4 +-
docs/visual_Rect.js.html | 4 +-
docs/visual_ShapeStim.js.html | 4 +-
docs/visual_Slider.js.html | 4 +-
docs/visual_TextBox.js.html | 4 +-
docs/visual_TextInput.js.html | 4 +-
docs/visual_TextStim.js.html | 4 +-
docs/visual_VisualStim.js.html | 4 +-
index.css | 149 +++
index.js | 5 +
package.json | 3 +-
src/core/GUI.js | 29 +-
src/core/Keyboard.js | 2 +-
src/core/Logger.js | 8 +-
src/core/PsychoJS.js | 73 +-
src/core/ServerManager.js | 580 +++++++++--
src/data/TrialHandler.js | 3 +-
src/data/index.js | 1 +
src/sound/AudioClip.js | 368 +++++++
src/sound/Microphone.js | 531 ++++++++++
src/sound/index.js | 4 +
src/util/PsychObject.js | 6 +-
src/util/Scheduler.js | 12 +-
src/util/Util.js | 392 +++++++-
94 files changed, 4192 insertions(+), 941 deletions(-)
create mode 100644 index.css
create mode 100644 index.js
create mode 100644 src/sound/AudioClip.js
create mode 100644 src/sound/Microphone.js
diff --git a/docs/TextInput.html b/docs/TextInput.html
index 2d6c4f2..37764dc 100644
--- a/docs/TextInput.html
+++ b/docs/TextInput.html
@@ -175,13 +175,13 @@
diff --git a/docs/core_EventManager.js.html b/docs/core_EventManager.js.html
index 971c491..9b5ee72 100644
--- a/docs/core_EventManager.js.html
+++ b/docs/core_EventManager.js.html
@@ -699,13 +699,13 @@ export class BuilderKeyResponse
diff --git a/docs/core_GUI.js.html b/docs/core_GUI.js.html
index 01f44d9..eef7a8a 100644
--- a/docs/core_GUI.js.html
+++ b/docs/core_GUI.js.html
@@ -341,7 +341,7 @@ export class GUI
//$.blockUI({ message: "", baseZ: 1});
// show dialog box:
- $("#progressbar").progressbar({value: self._progressBarCurrentIncrement});
+ $("#progressbar").progressbar({value: self._progressBarCurrentValue});
$("#progressbar").progressbar("option", "max", self._progressBarMax);
}
@@ -615,15 +615,14 @@ export class GUI
{
this._psychoJS.logger.debug('signal: ' + util.toString(signal));
- // all resources have been registered:
- if (signal.message === ServerManager.Event.RESOURCES_REGISTERED)
+ // the download of the specified resources has started:
+ if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCES)
{
// for each resource, we have a 'downloading resource' and a 'resource downloaded' message:
this._progressBarMax = signal.count * 2;
$("#progressbar").progressbar("option", "max", this._progressBarMax);
- this._progressBarCurrentIncrement = 0;
- $("#progressMsg").text('all resources registered.');
+ this._progressBarCurrentValue = 0;
}
// all the resources have been downloaded: show the ok button
@@ -635,23 +634,25 @@ export class GUI
}
// update progress bar:
- else if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCE || signal.message === ServerManager.Event.RESOURCE_DOWNLOADED)
+ else if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCE
+ || signal.message === ServerManager.Event.RESOURCE_DOWNLOADED)
{
- if (typeof this._progressBarCurrentIncrement === 'undefined')
+ if (typeof this._progressBarCurrentValue === 'undefined')
{
- this._progressBarCurrentIncrement = 0;
+ this._progressBarCurrentValue = 0;
}
- ++this._progressBarCurrentIncrement;
+ ++this._progressBarCurrentValue;
if (signal.message === ServerManager.Event.RESOURCE_DOWNLOADED)
{
- $("#progressMsg").text('downloaded ' + this._progressBarCurrentIncrement / 2 + ' / ' + this._progressBarMax / 2);
+ $("#progressMsg").text('downloaded ' + (this._progressBarCurrentValue / 2) + ' / ' + (this._progressBarMax / 2));
+ }
+ else
+ {
+ $("#progressMsg").text('downloading ' + (this._progressBarCurrentValue / 2) + ' / ' + (this._progressBarMax / 2));
}
// $("#progressMsg").text(signal.resource + ': downloaded.');
- // else
- // $("#progressMsg").text(signal.resource + ': downloading...');
-
- $("#progressbar").progressbar("option", "value", this._progressBarCurrentIncrement);
+ $("#progressbar").progressbar("option", "value", this._progressBarCurrentValue);
}
// unknown message: we just display it
@@ -908,13 +909,13 @@ GUI.dialogMargin = [50, 50];
diff --git a/docs/core_Keyboard.js.html b/docs/core_Keyboard.js.html
index b693e8a..ec87b44 100644
--- a/docs/core_Keyboard.js.html
+++ b/docs/core_Keyboard.js.html
@@ -226,7 +226,7 @@ export class Keyboard extends PsychObject
const keyEvent = this._circularBuffer[i];
if (keyEvent && keyEvent.status === Keyboard.KeyStatus.KEY_UP)
{
- // check that the key is in the keyList:
+ // if the keylist is empty of the key is in the keyList:
if (keyList.length === 0 || keyList.includes(keyEvent.pigletKey))
{
// look for a corresponding, preceding keydown event:
@@ -526,13 +526,13 @@ Keyboard.KeyStatus = {
diff --git a/docs/core_Logger.js.html b/docs/core_Logger.js.html
index a7808c5..36a525c 100644
--- a/docs/core_Logger.js.html
+++ b/docs/core_Logger.js.html
@@ -340,9 +340,9 @@ export class Logger
*/
_customConsoleLayout()
{
- const detectedBrowser = this._psychoJS.browser;
+ const detectedBrowser = util.detectBrowser();
- const customLayout = new log4javascript.PatternLayout("%p %f{1} | %m");
+ const customLayout = new log4javascript.PatternLayout("%p %d{HH:mm:ss.SSS} %f{1} | %m");
customLayout.setCustomField('location', function (layout, loggingReference)
{
// we throw a fake exception to retrieve the stack trace
@@ -373,7 +373,7 @@ export class Logger
const file = buf[buf.length - 3].split('/').pop();
const method = relevantEntry.split('@')[0];
- return method + ' ' + file + ' ' + line;
+ return method + ' ' + file + ':' + line;
}
else if (detectedBrowser === 'Safari')
{
@@ -391,7 +391,7 @@ export class Logger
const line = buf.pop();
const file = buf.pop().split('/').pop();
- return method + ' ' + file + ' ' + line;
+ return method + ' ' + file + ':' + line;
}
else
{
@@ -474,13 +474,13 @@ Logger._ServerLevelValue = {
diff --git a/docs/core_MinimalStim.js.html b/docs/core_MinimalStim.js.html
index 72d37c5..3ff925f 100644
--- a/docs/core_MinimalStim.js.html
+++ b/docs/core_MinimalStim.js.html
@@ -260,13 +260,13 @@ export class MinimalStim extends PsychObject
diff --git a/docs/core_Mouse.js.html b/docs/core_Mouse.js.html
index 1500d2e..0b7b6c7 100644
--- a/docs/core_Mouse.js.html
+++ b/docs/core_Mouse.js.html
@@ -397,13 +397,13 @@ export class Mouse extends PsychObject
diff --git a/docs/core_PsychoJS.js.html b/docs/core_PsychoJS.js.html
index 3604fd4..0f421dd 100644
--- a/docs/core_PsychoJS.js.html
+++ b/docs/core_PsychoJS.js.html
@@ -46,6 +46,7 @@ import {GUI} from './GUI';
import {MonotonicClock} from '../util/Clock';
import {Logger} from './Logger';
import * as util from '../util/Util';
+// import {Shelf} from "../data/Shelf";
/**
@@ -58,6 +59,7 @@ import * as util from '../util/Util';
*/
export class PsychoJS
{
+
/**
* Properties
*/
@@ -137,6 +139,11 @@ export class PsychoJS
return this._browser;
}
+ // get shelf()
+ // {
+ // return this._shelf;
+ // }
+
/**
* @constructor
@@ -182,6 +189,9 @@ export class PsychoJS
// Window:
this._window = undefined;
+ // // Shelf:
+ // this._shelf = new Shelf(this);
+
// redirection URLs:
this._cancellationUrl = undefined;
this._completionUrl = undefined;
@@ -320,6 +330,17 @@ export class PsychoJS
/**
* Start the experiment.
*
+ * <p>The resources are specified in the following fashion:
+ * <ul>
+ * <li>For an experiment running locally: the root directory for the specified resources is that of index.html
+ * unless they are prepended with a protocol, such as http:// or https://.</li>
+ * <li>For an experiment running on the server: if no resources are specified, all files in the resources directory
+ * of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
+ * local to index.html unless they are prepended with a protocol.</li>
+ * <li>If resources is null: we do not download any resources.</li>
+ * </ul>
+ * </p>
+ *
* @param {Object} options
* @param {string} [options.configURL=config.json] - the URL of the configuration file
* @param {string} [options.expName=UNKNOWN] - the name of the experiment
@@ -327,8 +348,6 @@ export class PsychoJS
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
* @async
* @public
- *
- * @todo: close session on window or tab close
*/
async start({configURL = 'config.json', expName = 'UNKNOWN', expInfo = {}, resources = []} = {})
{
@@ -414,11 +433,11 @@ export class PsychoJS
// start the asynchronous download of resources:
- this._serverManager.downloadResources(resources);
+ await this._serverManager.prepareResources(resources);
// start the experiment:
this.logger.info('[PsychoJS] Start Experiment.');
- this._scheduler.start();
+ await this._scheduler.start();
}
catch (error)
{
@@ -428,8 +447,12 @@ export class PsychoJS
}
+
/**
- * Synchronously download resources for the experiment.
+ * Block the experiment until the specified resources have been downloaded.
+ *
+ * <p>Note: only those resources that have not already been downloaded at that point are
+ * considered.</p>
*
* <ul>
* <li>For an experiment running locally: the root directory for the specified resources is that of index.html
@@ -439,14 +462,18 @@ export class PsychoJS
* local to index.html unless they are prepended with a protocol.</li>
*
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
- * @async
* @public
*/
- async downloadResources(resources = [])
+ waitForResources(resources = [])
{
+ const response = {
+ origin: 'PsychoJS.waitForResources',
+ context: 'while waiting for resources to be downloaded'
+ };
+
try
{
- await this.serverManager.downloadResources(resources);
+ return this.serverManager.waitForResources(resources);
}
catch (error)
{
@@ -456,6 +483,7 @@ export class PsychoJS
}
+
/**
* Make the attributes of the given object those of PsychoJS and those of
* the top level variable (e.g. window) as well.
@@ -631,6 +659,14 @@ export class PsychoJS
{
throw 'missing URL in pavlovia block in configuration';
}
+ if (!('gitlab' in this._config))
+ {
+ throw 'missing gitlab block in configuration';
+ }
+ if (!('projectId' in this._config.gitlab))
+ {
+ throw 'missing projectId in gitlab block in configuration';
+ }
this._config.environment = ExperimentHandler.Environment.SERVER;
@@ -713,23 +749,21 @@ export class PsychoJS
*/
_captureErrors()
{
- this.logger.debug('capturing all errors using window.onerror');
+ this.logger.debug('capturing all errors and showing them in a pop up window');
const self = this;
window.onerror = function (message, source, lineno, colno, error)
{
console.error(error);
- self._gui.dialog({"error": error});
+ self._gui.dialog({error});
return true;
};
-
- /* NOT UNIVERSALLY SUPPORTED YET
- window.addEventListener('unhandledrejection', event => {
- console.error(error);
- self._gui.dialog({"error" : error});
+ window.onunhandledrejection = function (error)
+ {
+ console.error(error.reason);
+ self._gui.dialog({error: error.reason});
return true;
- });*/
-
+ };
}
@@ -765,9 +799,10 @@ PsychoJS.Status = {
CONFIGURED: Symbol.for('CONFIGURED'),
NOT_STARTED: Symbol.for('NOT_STARTED'),
STARTED: Symbol.for('STARTED'),
+ PAUSED: Symbol.for('PAUSED'),
FINISHED: Symbol.for('FINISHED'),
-
- STOPPED: Symbol.for('FINISHED') //Symbol.for('STOPPED')
+ STOPPED: Symbol.for('FINISHED'), //Symbol.for('STOPPED')
+ ERROR: Symbol.for('ERROR')
};
@@ -780,13 +815,13 @@ PsychoJS.Status = {
diff --git a/docs/core_ServerManager.js.html b/docs/core_ServerManager.js.html
index 59ea9dc..94f3e94 100644
--- a/docs/core_ServerManager.js.html
+++ b/docs/core_ServerManager.js.html
@@ -27,7 +27,7 @@
/**
- * Manager responsible for the communication between the experiment running in the participant's browser and the remote PsychoJS manager running on the remote https://pavlovia.org server.
+ * Manager responsible for the communication between the experiment running in the participant's browser and the pavlovia.org server.
*
* @author Alain Pitiot
* @version 2021.1.4
@@ -46,9 +46,8 @@ import {MonotonicClock} from "../util/Clock";
/**
- * <p>This manager handles all communications between the experiment running in the participant's browser and the remote PsychoJS manager running on the [pavlovia.org]{@link http://pavlovia.org} server, <em>in an asynchronous manner</em>.</p>
- * <p>It is responsible for reading the configuration file of an experiment, for opening and closing a session, for listing and downloading resources, and for uploading results and log.</p>
- * <p>Note: The Server Manager uses [Promises]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise} to deal with asynchronicity, is mostly called by {@link PsychoJS}, and is not exposed to the experiment code.</p>
+ * <p>This manager handles all communications between the experiment running in the participant's browser and the [pavlovia.org]{@link http://pavlovia.org} server, <em>in an asynchronous manner</em>.</p>
+ * <p>It is responsible for reading the configuration file of an experiment, for opening and closing a session, for listing and downloading resources, and for uploading results, logs, and audio recordings.</p>
*
* @name module:core.ServerManager
* @class
@@ -59,6 +58,16 @@ import {MonotonicClock} from "../util/Clock";
*/
export class ServerManager extends PsychObject
{
+ /**
+ * Used to indicate to the ServerManager that all resources must be registered (and
+ * subsequently downloaded)
+ *
+ * @type {symbol}
+ * @readonly
+ * @public
+ */
+ static ALL_RESOURCES = Symbol.for('ALL_RESOURCES');
+
constructor({
psychoJS,
@@ -70,9 +79,8 @@ export class ServerManager extends PsychObject
// session:
this._session = {};
- // resources is a map of {name: string, path: string} -> data: any
+ // resources is a map of <name: string, { path: string, status: ResourceStatus, data: any }>
this._resources = new Map();
- this._nbResources = -1;
this._addAttribute('autoLog', autoLog);
this._addAttribute('status', ServerManager.Status.READY);
@@ -300,25 +308,65 @@ export class ServerManager extends PsychObject
* @name module:core.ServerManager#getResource
* @function
* @public
- * @param {string} name of the requested resource
- * @return {Object} value of the resource
+ * @param {string} name - name of the requested resource
+ * @param {boolean} [errorIfNotDownloaded = false] whether or not to throw an exception if the
+ * resource status is not DOWNLOADED
+ * @return {Object} value of the resource, or undefined if the resource has been registered
+ * but not downloaded yet.
* @throws {Object.<string, *>} exception if no resource with that name has previously been registered
*/
- getResource(name)
+ getResource(name, errorIfNotDownloaded = false)
{
const response = {
origin: 'ServerManager.getResource',
context: 'when getting the value of resource: ' + name
};
- const path_data = this._resources.get(name);
- if (typeof path_data === 'undefined')
- // throw { ...response, error: 'unknown resource' };
+ const pathStatusData = this._resources.get(name);
+
+ if (typeof pathStatusData === 'undefined')
{
+ // throw { ...response, error: 'unknown resource' };
throw Object.assign(response, {error: 'unknown resource'});
}
- return path_data.data;
+ if (errorIfNotDownloaded && pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
+ {
+ throw Object.assign(response, {
+ error: name + ' is not available for use (yet), its current status is: ' +
+ util.toString(pathStatusData.status)
+ });
+ }
+
+ return pathStatusData.data;
+ }
+
+
+ /**
+ * Get the status of a resource.
+ *
+ * @name module:core.ServerManager#getResourceStatus
+ * @function
+ * @public
+ * @param {string} name of the requested resource
+ * @return {core.ServerManager.ResourceStatus} status of the resource
+ * @throws {Object.<string, *>} exception if no resource with that name has previously been registered
+ */
+ getResourceStatus(name)
+ {
+ const response = {
+ origin: 'ServerManager.getResourceStatus',
+ context: 'when getting the status of resource: ' + name
+ };
+
+ const pathStatusData = this._resources.get(name);
+ if (typeof pathStatusData === 'undefined')
+ {
+ // throw { ...response, error: 'unknown resource' };
+ throw Object.assign(response, {error: 'unknown resource'});
+ }
+
+ return pathStatusData.status;
}
@@ -373,7 +421,8 @@ export class ServerManager extends PsychObject
/**
- * Asynchronously download resources for the experiment and register them with the server manager.
+ * Prepare resources for the experiment: register them with the server manager and possibly
+ * start downloading them right away.
*
* <ul>
* <li>For an experiment running locally: the root directory for the specified resources is that of index.html
@@ -381,85 +430,218 @@ export class ServerManager extends PsychObject
* <li>For an experiment running on the server: if no resources are specified, all files in the resources directory
* of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
* local to index.html unless they are prepended with a protocol.</li>
+ * <li>If resources is null, then we do not download any resources</li>
* </ul>
*
- * @name module:core.ServerManager#downloadResources
+ * @name module:core.ServerManager#prepareResources
+ * @param {Array.<{name: string, path: string, download: boolean} | Symbol>} [resources=[]] - the list of resources
+ * @function
+ * @public
+ */
+ async prepareResources(resources = [])
+ {
+ const response = {
+ origin: 'ServerManager.prepareResources',
+ context: 'when preparing resources for experiment: ' + this._psychoJS.config.experiment.name
+ };
+
+ this._psychoJS.logger.debug('preparing resources for experiment: ' + this._psychoJS.config.experiment.name);
+
+ try
+ {
+ const resourcesToDownload = new Set();
+
+ // register the resources:
+ if (resources !== null)
+ {
+ if (!Array.isArray(resources))
+ {
+ throw "resources should be an array of objects";
+ }
+
+ // whether all resources have been requested:
+
+ const allResources = (resources.length === 1 && resources[0] === ServerManager.ALL_RESOURCES);
+
+ // if the experiment is hosted on the pavlovia.org server and
+ // resources is [ServerManager.ALL_RESOURCES], then we register all the resources
+ // in the "resources" sub-directory
+ if (this._psychoJS.config.environment === ExperimentHandler.Environment.SERVER
+ && allResources)
+ {
+ // list the resources from the resources directory of the experiment on the server:
+ const serverResponse = await this._listResources();
+
+ // register and mark for download those resources that have not been
+ // registered already:
+ for (const name of serverResponse.resources)
+ {
+ if (!this._resources.has(name))
+ {
+ const path = serverResponse.resourceDirectory + '/' + name;
+ this._resources.set(name, {
+ status: ServerManager.ResourceStatus.REGISTERED,
+ path,
+ data: undefined
+ });
+ this._psychoJS.logger.debug('registered resource:', name, path);
+ resourcesToDownload.add(name);
+ }
+ }
+ }
+
+ // if the experiment is hosted locally (localhost) or if specific resources were given
+ // then we register those specific resources, if they have not been registered already
+ else
+ {
+ // we cannot ask for all resources to be registered locally, since we cannot list
+ // them:
+ if (this._psychoJS.config.environment === ExperimentHandler.Environment.LOCAL
+ && allResources)
+ {
+ throw "resources must be manually specified when the experiment is running locally: ALL_RESOURCES cannot be used";
+ }
+
+ for (let {name, path, download} of resources)
+ {
+ if (!this._resources.has(name))
+ {
+ // to deal with potential CORS issues, we use the pavlovia.org proxy for resources
+ // not hosted on pavlovia.org:
+ if ((path.toLowerCase().indexOf('www.') === 0 ||
+ path.toLowerCase().indexOf('http:') === 0 ||
+ path.toLowerCase().indexOf('https:') === 0) &&
+ (path.indexOf('pavlovia.org') === -1))
+ {
+ path = 'https://pavlovia.org/api/v2/proxy/' + path;
+ }
+
+ this._resources.set(name, {
+ status: ServerManager.ResourceStatus.REGISTERED,
+ path,
+ data: undefined
+ });
+ this._psychoJS.logger.debug('registered resource:', name, path);
+
+ // download resources by default:
+ if (typeof download === 'undefined' || download)
+ {
+ resourcesToDownload.add(name);
+ }
+ }
+ }
+ }
+ }
+
+ // download those registered resources for which download = true:
+ /*await*/ this._downloadResources(resourcesToDownload);
+ }
+ catch (error)
+ {
+ console.log('error', error);
+ throw Object.assign(response, {error});
+ // throw { ...response, error: error };
+ }
+ }
+
+
+ /**
+ * Block the experiment until the specified resources have been downloaded.
+ *
+ * @name module:core.ServerManager#waitForResources
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
* @function
* @public
*/
- downloadResources(resources = [])
+ waitForResources(resources = [])
{
- const response = {
- origin: 'ServerManager.downloadResources',
- context: 'when downloading the resources for experiment: ' + this._psychoJS.config.experiment.name
+ // prepare a PsychoJS component:
+ this._waitForDownloadComponent = {
+ status: PsychoJS.Status.NOT_STARTED,
+ clock: new Clock(),
+ resources: new Set()
};
- this._psychoJS.logger.debug('downloading resources for experiment: ' + this._psychoJS.config.experiment.name);
-
- // we use an anonymous async function here since downloadResources is non-blocking (asynchronous)
- // but we want to run the asynchronous _listResources and _downloadResources in sequence
const self = this;
- const newResources = new Map();
- let download = async () =>
+ return () =>
{
- try
+ const t = self._waitForDownloadComponent.clock.getTime();
+
+ // start the component:
+ if (t >= 0.0 && self._waitForDownloadComponent.status === PsychoJS.Status.NOT_STARTED)
{
- if (self._psychoJS.config.environment === ExperimentHandler.Environment.SERVER)
+ self._waitForDownloadComponent.tStart = t;
+ self._waitForDownloadComponent.status = PsychoJS.Status.STARTED;
+
+ // if resources is an empty array, we consider all registered resources:
+ if (resources.length === 0)
{
- // no resources specified, we register them all:
- if (resources.length === 0)
+ for (const [name, {status, path, data}] of this._resources)
{
- // list the resources from the resources directory of the experiment on the server:
- const serverResponse = await self._listResources();
- for (const name of serverResponse.resources)
- {
- self._resources.set(name, {path: serverResponse.resourceDirectory + '/' + name});
- }
- }
- else
- {
- // only registered the specified resources:
- for (const {name, path} of resources)
- {
- self._resources.set(name, {path});
- newResources.set(name, {path});
- }
- }
- }
- else
- {
- // register the specified resources:
- for (const {name, path} of resources)
- {
- self._resources.set(name, {path});
- newResources.set(name, {path});
+ resources.append({ name, path });
}
}
- self._nbResources = self._resources.size;
- for (const name of self._resources.keys())
+ // only download those resources not already downloaded or downloading:
+ const resourcesToDownload = new Set();
+ for (let {name, path} of resources)
{
- this._psychoJS.logger.debug('resource:', name, self._resources.get(name).path);
+ // to deal with potential CORS issues, we use the pavlovia.org proxy for resources
+ // not hosted on pavlovia.org:
+ if ( (path.toLowerCase().indexOf('www.') === 0 ||
+ path.toLowerCase().indexOf('http:') === 0 ||
+ path.toLowerCase().indexOf('https:') === 0) &&
+ (path.indexOf('pavlovia.org') === -1) )
+ {
+ path = 'https://devlovia.org/api/v2/proxy/' + path;
+ }
+
+ const pathStatusData = this._resources.get(name);
+
+ // the resource has not been registered yet:
+ if (typeof pathStatusData === 'undefined')
+ {
+ self._resources.set(name, {
+ status: ServerManager.ResourceStatus.REGISTERED,
+ path,
+ data: undefined
+ });
+ self._waitForDownloadComponent.resources.add(name);
+ resourcesToDownload.add(name);
+ self._psychoJS.logger.debug('registered resource:', name, path);
+ }
+
+ // the resource has been registered but is not downloaded yet:
+ else if (typeof pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
+ // else if (typeof pathStatusData.data === 'undefined')
+ {
+ self._waitForDownloadComponent.resources.add(name);
+ }
+
}
- self.emit(ServerManager.Event.RESOURCE, {
- message: ServerManager.Event.RESOURCES_REGISTERED,
- count: self._nbResources
- });
-
- // download the registered resources:
- await self._downloadRegisteredResources(newResources);
+ // start the download:
+ self._downloadResources(resourcesToDownload);
}
- catch (error)
+
+ // check whether all resources have been downloaded:
+ for (const name of self._waitForDownloadComponent.resources)
{
- console.log('error', error);
- // throw { ...response, error: error };
- throw Object.assign(response, {error});
+ const pathStatusData = this._resources.get(name);
+
+ // the resource has not been downloaded yet: loop this component
+ if (typeof pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
+ // if (typeof pathStatusData.data === 'undefined')
+ {
+ return Scheduler.Event.FLIP_REPEAT;
+ }
}
+
+ // all resources have been downloaded: move to the next component:
+ self._waitForDownloadComponent.status = PsychoJS.Status.FINISHED;
+ return Scheduler.Event.NEXT;
};
- download();
}
@@ -470,7 +652,7 @@ export class ServerManager extends PsychObject
* @property {Object.<string, *>} [error] an error message if we could not upload the data
*/
/**
- * Asynchronously upload experiment data to the remote PsychoJS manager.
+ * Asynchronously upload experiment data to the pavlovia server.
*
* @name module:core.ServerManager#uploadData
* @function
@@ -535,8 +717,9 @@ export class ServerManager extends PsychObject
}
+
/**
- * Asynchronously upload experiment logs to the remote PsychoJS manager.
+ * Asynchronously upload experiment logs to the pavlovia server.
*
* @name module:core.ServerManager#uploadLog
* @function
@@ -595,6 +778,84 @@ export class ServerManager extends PsychObject
}
+
+ /**
+ * Asynchronously upload audio data to the pavlovia server.
+ *
+ * @name module:core.ServerManager#uploadAudio
+ * @function
+ * @public
+ * @param {Blob} audioBlob - the audio blob to be uploaded
+ * @param {string} tag - additional tag
+ * @returns {Promise<ServerManager.UploadDataPromise>} the response
+ */
+ async uploadAudio(audioBlob, tag)
+ {
+ const response = {
+ origin: 'ServerManager.uploadAudio',
+ context: 'when uploading audio data for experiment: ' + this._psychoJS.config.experiment.fullpath
+ };
+
+ try
+ {
+ if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER ||
+ this._psychoJS.config.experiment.status !== 'RUNNING' ||
+ this._psychoJS._serverMsg.has('__pilotToken'))
+ {
+ throw 'audio recordings can only be uploaded to the server for experiments running on the server';
+ }
+
+ this._psychoJS.logger.debug('uploading audio data for experiment: ' + this._psychoJS.config.experiment.fullpath);
+ this.setStatus(ServerManager.Status.BUSY);
+
+ // prepare the request:
+ const info = this.psychoJS.experiment.extraInfo;
+ const participant = ((typeof info.participant === 'string' && info.participant.length > 0) ? info.participant : 'PARTICIPANT');
+ const experimentName = (typeof info.expName !== 'undefined') ? info.expName : this.psychoJS.config.experiment.name;
+ const datetime = ((typeof info.date !== 'undefined') ? info.date : MonotonicClock.getDateStr());
+ const filename = participant + '_' + experimentName + '_' + datetime + '_' + tag;
+
+ const formData = new FormData();
+ formData.append('audio', audioBlob, filename);
+
+ const url = this._psychoJS.config.pavlovia.URL +
+ '/api/v2/experiments/' + this._psychoJS.config.gitlab.projectId +
+ '/sessions/' + this._psychoJS.config.session.token +
+ '/audio';
+
+ // query the pavlovia server:
+ const response = await fetch(url, {
+ method: 'POST',
+ mode: 'cors', // no-cors, *cors, same-origin
+ cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
+ credentials: 'same-origin', // include, *same-origin, omit
+ redirect: 'follow', // manual, *follow, error
+ referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
+ body: formData
+ });
+ const jsonResponse = await response.json();
+
+ // deal with server errors:
+ if (!response.ok)
+ {
+ throw jsonResponse;
+ }
+
+ this.setStatus(ServerManager.Status.READY);
+ return jsonResponse;
+ }
+ catch (error)
+ {
+ this.setStatus(ServerManager.Status.ERROR);
+ console.error(error);
+
+ throw {...response, error};
+ }
+
+ }
+
+
+
/**
* List the resources available to the experiment.
@@ -664,62 +925,76 @@ export class ServerManager extends PsychObject
}
+
/**
- * Download the resources previously registered.
+ * Download the specified resources.
*
* <p>Note: we use the [preloadjs library]{@link https://www.createjs.com/preloadjs}.</p>
*
- * @name module:core.ServerManager#_downloadRegisteredResources
+ * @name module:core.ServerManager#_downloadResources
* @function
- * @private
+ * @protected
+ * @param {Set} resources - a set of names of previously registered resources
*/
- _downloadRegisteredResources(resources = new Map())
+ _downloadResources(resources)
{
const response = {
origin: 'ServerManager._downloadResources',
- context: 'when downloading the resources for experiment: ' + this._psychoJS.config.experiment.name
+ context: 'when downloading resources for experiment: ' + this._psychoJS.config.experiment.name
};
- this._psychoJS.logger.debug('downloading the registered resources for experiment: ' + this._psychoJS.config.experiment.name);
+ this._psychoJS.logger.debug('downloading resources for experiment: ' + this._psychoJS.config.experiment.name);
this.setStatus(ServerManager.Status.BUSY);
+ this.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOADING_RESOURCES,
+ count: resources.size
+ });
+
this._nbLoadedResources = 0;
// (*) set-up preload.js:
- this._resourceQueue = new createjs.LoadQueue(true); //, this._psychoJS.config.experiment.resourceDirectory);
+ this._resourceQueue = new createjs.LoadQueue(true, '', true);
const self = this;
- const filesToDownload = resources.size ? resources : this._resources;
-
+ // the loading of a specific resource has started:
this._resourceQueue.addEventListener("filestart", event =>
{
+ const pathStatusData = self._resources.get(event.item.id);
+ pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING;
+
self.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.DOWNLOADING_RESOURCE,
resource: event.item.id
});
});
+ // the loading of a specific resource has completed:
this._resourceQueue.addEventListener("fileload", event =>
{
- ++self._nbLoadedResources;
- let path_data = self._resources.get(event.item.id);
- path_data.data = event.result;
+ const pathStatusData = self._resources.get(event.item.id);
+ pathStatusData.data = event.result;
+ pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED;
+
+ ++ self._nbLoadedResources;
self.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.RESOURCE_DOWNLOADED,
resource: event.item.id
});
});
- // loading completed:
+ // the loading of all given resources completed:
this._resourceQueue.addEventListener("complete", event =>
{
self._resourceQueue.close();
- if (self._nbLoadedResources === filesToDownload.size)
+ if (self._nbLoadedResources === resources.size)
{
self.setStatus(ServerManager.Status.READY);
- self.emit(ServerManager.Event.RESOURCE, {message: ServerManager.Event.DOWNLOAD_COMPLETED});
+ self.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOAD_COMPLETED
+ });
}
});
@@ -727,16 +1002,43 @@ export class ServerManager extends PsychObject
this._resourceQueue.addEventListener("error", event =>
{
self.setStatus(ServerManager.Status.ERROR);
- const resourceId = (typeof event.data !== 'undefined') ? event.data.id : 'UNKNOWN RESOURCE';
- // throw { ...response, error: 'unable to download resource: ' + resourceId + ' (' + event.title + ')' };
- throw Object.assign(response, {error: 'unable to download resource: ' + resourceId + ' (' + event.title + ')'});
+ if (typeof event.item !== 'undefined')
+ {
+ const pathStatusData = self._resources.get(event.item.id);
+ pathStatusData.status = ServerManager.ResourceStatus.ERROR;
+ throw Object.assign(response, {
+ error: 'unable to download resource: ' + event.item.id + ' (' + event.title + ')'
+ });
+ }
+ else
+ {
+ console.error(event);
+
+ if (event.title === 'FILE_LOAD_ERROR' && typeof event.data !== 'undefined')
+ {
+ const id = event.data.id;
+ const title = event.data.src;
+
+ throw Object.assign(response, {
+ error: 'unable to download resource: ' + id + ' (' + title + ')'
+ });
+ }
+
+ else
+ {
+ throw Object.assign(response, {
+ error: 'unspecified download error'
+ });
+ }
+
+ }
});
// (*) dispatch resources to preload.js or howler.js based on extension:
let manifest = [];
- let soundResources = [];
- for (const [name, path_data] of filesToDownload)
+ const soundResources = new Set();
+ for (const name of resources)
{
const nameParts = name.toLowerCase().split('.');
const extension = (nameParts.length > 1) ? nameParts.pop() : undefined;
@@ -747,10 +1049,25 @@ export class ServerManager extends PsychObject
this.psychoJS.logger.warn(`"${name}" does not appear to have an extension, which may negatively impact its loading. We highly recommend you add an extension.`);
}
+ const pathStatusData = this._resources.get(name);
+ if (typeof pathStatusData === 'undefined')
+ {
+ throw Object.assign(response, {error: name + ' has not been previously registered'});
+ }
+ if (pathStatusData.status !== ServerManager.ResourceStatus.REGISTERED)
+ {
+ throw Object.assign(response, {error: name + ' is already downloaded or is currently already downloading'});
+ }
+
// preload.js with forced binary for xls and xlsx:
if (['csv', 'odp', 'xls', 'xlsx'].indexOf(extension) > -1)
{
- manifest.push({id: name, src: path_data.path, type: createjs.Types.BINARY});
+ manifest.push(new createjs.LoadItem().set({
+ id: name,
+ src: pathStatusData.path,
+ type: createjs.Types.BINARY,
+ crossOrigin: 'Anonymous'
+ }));
}/* ascii .csv are adequately handled in binary format
// forced text for .csv:
else if (['csv'].indexOf(resourceExtension) > -1)
@@ -760,7 +1077,7 @@ export class ServerManager extends PsychObject
// sound files are loaded through howler.js:
else if (['mp3', 'mpeg', 'opus', 'ogg', 'oga', 'wav', 'aac', 'caf', 'm4a', 'weba', 'dolby', 'flac'].indexOf(extension) > -1)
{
- soundResources.push(name);
+ soundResources.add(name);
if (extension === 'wav')
{
@@ -771,7 +1088,11 @@ export class ServerManager extends PsychObject
// preload.js for the other extensions (download type decided by preload.js):
else
{
- manifest.push({id: name, src: path_data.path});
+ manifest.push(new createjs.LoadItem().set({
+ id: name,
+ src: pathStatusData.path,
+ crossOrigin: 'Anonymous'
+ }));
}
}
@@ -783,10 +1104,11 @@ export class ServerManager extends PsychObject
}
else
{
- if (this._nbLoadedResources === filesToDownload.size)
+ if (this._nbLoadedResources === resources.size)
{
this.setStatus(ServerManager.Status.READY);
- this.emit(ServerManager.Event.RESOURCE, {message: ServerManager.Event.DOWNLOAD_COMPLETED});
+ this.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOAD_COMPLETED});
}
}
@@ -794,33 +1116,37 @@ export class ServerManager extends PsychObject
// (*) prepare and start loading sound resources:
for (const name of soundResources)
{
- self.emit(ServerManager.Event.RESOURCE, {
+ const pathStatusData = this._resources.get(name);
+ pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING;
+ this.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.DOWNLOADING_RESOURCE,
resource: name
});
- const path_data = self._resources.get(name);
const howl = new Howl({
- src: path_data.path,
+ src: pathStatusData.path,
preload: false,
autoplay: false
});
howl.on('load', (event) =>
{
- ++self._nbLoadedResources;
- path_data.data = howl;
- // self._resources.set(resource.name, howl);
+ ++ self._nbLoadedResources;
+ pathStatusData.data = howl;
+
+ pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED;
self.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.RESOURCE_DOWNLOADED,
resource: name
});
- if (self._nbLoadedResources === filesToDownload.size)
+ if (self._nbLoadedResources === resources.size)
{
self.setStatus(ServerManager.Status.READY);
- self.emit(ServerManager.Event.RESOURCE, {message: ServerManager.Event.DOWNLOAD_COMPLETED});
+ self.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOAD_COMPLETED});
}
});
+
howl.on('loaderror', (id, error) =>
{
// throw { ...response, error: 'unable to download resource: ' + name + ' (' + util.toString(error) + ')' };
@@ -829,6 +1155,7 @@ export class ServerManager extends PsychObject
howl.load();
}
+
}
}
@@ -849,22 +1176,26 @@ ServerManager.Event = {
* Event type: resource event
*/
RESOURCE: Symbol.for('RESOURCE'),
+
/**
- * Event: resources all registered
+ * Event: resources have started to download
*/
- RESOURCES_REGISTERED: Symbol.for('RESOURCES_REGISTERED'),
+ DOWNLOADING_RESOURCES: Symbol.for('DOWNLOADING_RESOURCES'),
+
/**
- * Event: resource download has started
+ * Event: a specific resource download has started
*/
DOWNLOADING_RESOURCE: Symbol.for('DOWNLOADING_RESOURCE'),
+
/**
- * Event: resource has been downloaded
+ * Event: a specific resource has been downloaded
*/
RESOURCE_DOWNLOADED: Symbol.for('RESOURCE_DOWNLOADED'),
+
/**
- * Event: resources all downloaded
+ * Event: resources have all downloaded
*/
- DOWNLOAD_COMPLETED: Symbol.for('DOWNLOAD_COMPLETED'),
+ DOWNLOADS_COMPLETED: Symbol.for('DOWNLOAD_COMPLETED'),
/**
* Event type: status event
@@ -897,6 +1228,37 @@ ServerManager.Status = {
*/
ERROR: Symbol.for('ERROR')
};
+
+
+/**
+ * Resource status
+ *
+ * @name module:core.ServerManager#ResourceStatus
+ * @enum {Symbol}
+ * @readonly
+ * @public
+ */
+ServerManager.ResourceStatus = {
+ /**
+ * The resource has been registered.
+ */
+ REGISTERED: Symbol.for('REGISTERED'),
+
+ /**
+ * The resource is currently downloading.
+ */
+ DOWNLOADING: Symbol.for('DOWNLOADING'),
+
+ /**
+ * The resource has been downloaded.
+ */
+ DOWNLOADED: Symbol.for('DOWNLOADED'),
+
+ /**
+ * There was an error during downloading, or the resource is in an unknown state.
+ */
+ ERROR: Symbol.for('ERROR'),
+};
@@ -907,13 +1269,13 @@ ServerManager.Status = {
diff --git a/docs/core_Window.js.html b/docs/core_Window.js.html
index 4a4553e..aeff689 100644
--- a/docs/core_Window.js.html
+++ b/docs/core_Window.js.html
@@ -35,9 +35,9 @@
* @license Distributed under the terms of the MIT License
*/
-import {Color} from '../util';
-import {PsychObject} from '../util';
-import {MonotonicClock} from '../util';
+import {Color} from '../util/Color';
+import {PsychObject} from '../util/PsychObject';
+import {MonotonicClock} from '../util/Clock';
import {Logger} from "./Logger";
/**
@@ -551,13 +551,13 @@ export class Window extends PsychObject
diff --git a/docs/core_WindowMixin.js.html b/docs/core_WindowMixin.js.html
index a77b2d9..a7935d5 100644
--- a/docs/core_WindowMixin.js.html
+++ b/docs/core_WindowMixin.js.html
@@ -229,13 +229,13 @@ export let WindowMixin = (superclass) => class extends superclass
diff --git a/docs/data_ExperimentHandler.js.html b/docs/data_ExperimentHandler.js.html
index 9b47e06..ff125da 100644
--- a/docs/data_ExperimentHandler.js.html
+++ b/docs/data_ExperimentHandler.js.html
@@ -487,13 +487,13 @@ ExperimentHandler.Environment = {
diff --git a/docs/data_TrialHandler.js.html b/docs/data_TrialHandler.js.html
index 3747849..00431b7 100644
--- a/docs/data_TrialHandler.js.html
+++ b/docs/data_TrialHandler.js.html
@@ -239,7 +239,6 @@ export class TrialHandler extends PsychObject
* @property {number} ran - whether or not the trial ran
* @property {number} finished - whether or not the trials finished
*/
-
/**
* Get a snapshot of the current internal state of the trial handler (e.g. current trial number,
* number of trial remaining).
@@ -461,7 +460,7 @@ export class TrialHandler extends PsychObject
if (['csv', 'odp', 'xls', 'xlsx'].indexOf(resourceExtension) > -1)
{
// (*) read conditions from resource:
- const resourceValue = serverManager.getResource(resourceName);
+ const resourceValue = serverManager.getResource(resourceName, true);
// Conditionally use a `TextDecoder` to reprocess .csv type input,
// which is then read in as a string
@@ -727,13 +726,13 @@ TrialHandler.Method = {
diff --git a/docs/index.html b/docs/index.html
index e1a94ed..815d98e 100644
--- a/docs/index.html
+++ b/docs/index.html
@@ -42,69 +42,6 @@
-
-
PsychoJS
-
PsychoJS is a JavaScript library that makes it possible to run neuroscience, psychology, and psychophysics experiments in a browser. It is the online counterpart of the PsychoPy Python library.
-It is also a git submodule: psychopy/psychojs
-
Motivation
-
Many studies in behavioural sciences (e.g. psychology, neuroscience, linguistics or mental health) use computers to present stimuli and record responses in a precise manner. These studies are still typically conducted on small numbers of people in laboratory environments equipped with dedicated hardware.
-
With high-speed broadband, improved web technologies and smart devices everywhere, studies can now go online without sacrificing too much temporal precision. This is a “game changer”. Data can be collected on larger, more varied, international populations. We can study people in environments they do not find intimidating. Experiments can be run multiple times per day, without data collection becoming impractical.
-
The idea behind PsychoJS is to make PsychoPy experiments available online, from a web page, so participants can run them on any device equipped with a web browser such as desktops, laptops, or tablets. In some circumstance, they can even use their phone!
-
Getting Started
-
Running PsychoPy experiments online requires the generation of an index.html file and of a javascript file that contains the code describing the experiment. Those files need to be hosted on a web server to which participants will point their browser in order to run the experiment. The server will also need to host the PsychoJS library.
-
PsychoPy Builder
-
Starting with PsychoPy version 3.0, PsychoPy Builder can automatically generate the javascript and html files. Many of the existing Builder experiments should "just work", subject to the Components being currently supported by PsychoJS (see below).
-
JavaScript Code
-
We built the PsychoJS library to make the JavaScript experiment files look and behave in very much the same way as to the Builder-generated Python files. PsychoJS offers classes such as Window and ImageStim, with very similar attributes to their Python equivalents. Experiment designers familiar with the PsychoPy library should feel at home with PsychoJS, and can expect the same level of control they have with PsychoPy, from the structure of the trials/loops all the way down to frame-by-frame updates.
-
There are however notable differences between the PsychoJS and PsychoPy libraries, most of which have to do with the way a web browser interprets and runs JavaScript, deals with resources (such as images, sound or videos), or render stimuli. To manage those web-specific aspect, PsychoJS introduces the concept of Scheduler. As the name indicate, Scheduler's offer a way to organise various PsychoJS along a timeline, such as downloading resources, running a loop, checking for keyboard input, saving experiment results, etc. As an illustration, a Flow in PsychoPy can be conceptualised as a Schedule, with various tasks on it. Some of those tasks, such as trial loops, can also schedule further events (i.e. the individual trials to be run).
-
Under the hood PsychoJS relies on PixiJs to present stimuli and collect responses. PixiJs is a multi-platform, accelerated, 2-D renderer, that runs in most modern browsers. It uses WebGL wherever possible and silently falls back to HTML5 canvas where not. WebGL directly addresses the graphic card, thereby considerably improving the rendering performance.
-
Hosting Experiments
-
A convenient way to make experiment available to participants is to host them on pavlovia.org, an open-science server under active development. PsychoPy Builder offers the possibility of uploading the experiment directly to pavlovia.org.
-
Which PsychoPy Components are supported by PsychoJS?
-
PsychoJS currently supports the following Components:
-
Stimuli:
-
-
Form
-
Image
-
Rect
-
Shape (Polygon)
-
Slider
-
Sound (tones and tracks)
-
Text
-
TextBox
-
Video
-
-
Events:
-
-
Keyboard
-
Mouse
-
-
We are constantly adding new Components and are regularly updating this list.
-
API
-
The full documentation of the PsychoJS API is here.
- Synchronously download resources for the experiment.
-
-
-
For an experiment running locally: the root directory for the specified resources is that of index.html
- unless they are prepended with a protocol, such as http:// or https://.
-
For an experiment running on the server: if no resources are specified, all files in the resources directory
- of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
- local to index.html unless they are prepended with a protocol.
@@ -2533,6 +2415,17 @@ that he or she needs to wait for a bit.
Start the experiment.
+
+
The resources are specified in the following fashion:
+
+
For an experiment running locally: the root directory for the specified resources is that of index.html
+ unless they are prepended with a protocol, such as http:// or https://.
+
For an experiment running on the server: if no resources are specified, all files in the resources directory
+ of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
+ local to index.html unless they are prepended with a protocol.
+
If resources is null: we do not download any resources.
+
+
@@ -2825,7 +2718,174 @@ that he or she needs to wait for a bit.
+ Block the experiment until the specified resources have been downloaded.
+
+
Note: only those resources that have not already been downloaded at that point are
+considered.
+
+
+
For an experiment running locally: the root directory for the specified resources is that of index.html
+ unless they are prepended with a protocol, such as http:// or https://.
+
For an experiment running on the server: if no resources are specified, all files in the resources directory
+ of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
+ local to index.html unless they are prepended with a protocol.
@@ -2871,13 +2931,13 @@ that he or she needs to wait for a bit.
diff --git a/docs/module-core.ServerManager.html b/docs/module-core.ServerManager.html
index 920c180..37f7bf5 100644
--- a/docs/module-core.ServerManager.html
+++ b/docs/module-core.ServerManager.html
@@ -50,9 +50,8 @@
-
This manager handles all communications between the experiment running in the participant's browser and the remote PsychoJS manager running on the pavlovia.org server, in an asynchronous manner.
-
It is responsible for reading the configuration file of an experiment, for opening and closing a session, for listing and downloading resources, and for uploading results and log.
-
Note: The Server Manager uses Promises to deal with asynchronicity, is mostly called by PsychoJS, and is not exposed to the experiment code.
+
This manager handles all communications between the experiment running in the participant's browser and the pavlovia.org server, in an asynchronous manner.
+
It is responsible for reading the configuration file of an experiment, for opening and closing a session, for listing and downloading resources, and for uploading results, logs, and audio recordings.
- Asynchronously download resources for the experiment and register them with the server manager.
-
-
-
For an experiment running locally: the root directory for the specified resources is that of index.html
- unless they are prepended with a protocol, such as http:// or https://.
-
For an experiment running on the server: if no resources are specified, all files in the resources directory
- of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
- local to index.html unless they are prepended with a protocol.
+ Prepare resources for the experiment: register them with the server manager and possibly
+start downloading them right away.
+
+
+
For an experiment running locally: the root directory for the specified resources is that of index.html
+ unless they are prepended with a protocol, such as http:// or https://.
+
For an experiment running on the server: if no resources are specified, all files in the resources directory
+ of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
+ local to index.html unless they are prepended with a protocol.
+
If resources is null, then we do not download any resources
+ the corresponding file extension, e.g. '.webm'
+
+
+
+
+
+
+ Type
+
+
+
+string
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
(static) flattenArray(array) → {Array.<Object>}
@@ -5197,13 +5358,13 @@ missing the naught prefix, and is able to process several arrays, e.g. "[1,2][3,
diff --git a/docs/module-visual.Form.html b/docs/module-visual.Form.html
index bccc6d5..d6000e4 100644
--- a/docs/module-visual.Form.html
+++ b/docs/module-visual.Form.html
@@ -2192,13 +2192,13 @@ This is typically called in the constructor of a stimulus, when attributes are a
diff --git a/docs/module-visual.ImageStim.html b/docs/module-visual.ImageStim.html
index 7bed7b5..14dd2c4 100644
--- a/docs/module-visual.ImageStim.html
+++ b/docs/module-visual.ImageStim.html
@@ -1291,13 +1291,13 @@
diff --git a/docs/module-visual.MovieStim.html b/docs/module-visual.MovieStim.html
index b79d365..f373872 100644
--- a/docs/module-visual.MovieStim.html
+++ b/docs/module-visual.MovieStim.html
@@ -1088,13 +1088,13 @@
diff --git a/docs/module-visual.Polygon.html b/docs/module-visual.Polygon.html
index c5af66e..5f0286f 100644
--- a/docs/module-visual.Polygon.html
+++ b/docs/module-visual.Polygon.html
@@ -1021,13 +1021,13 @@
diff --git a/docs/module-visual.Rect.html b/docs/module-visual.Rect.html
index 28d187e..e6e174a 100644
--- a/docs/module-visual.Rect.html
+++ b/docs/module-visual.Rect.html
@@ -1027,13 +1027,13 @@
diff --git a/docs/module-visual.ShapeStim.html b/docs/module-visual.ShapeStim.html
index 275a18e..479b243 100644
--- a/docs/module-visual.ShapeStim.html
+++ b/docs/module-visual.ShapeStim.html
@@ -1243,13 +1243,13 @@ This is overridden in order to provide a finer inclusion test.
diff --git a/docs/module-visual.Slider.html b/docs/module-visual.Slider.html
index 918ad7f..ade7872 100644
--- a/docs/module-visual.Slider.html
+++ b/docs/module-visual.Slider.html
@@ -2720,13 +2720,13 @@ This is typically called in the constructor of a stimulus, when attributes are a
diff --git a/docs/module-visual.TextBox.html b/docs/module-visual.TextBox.html
index 1d1d52e..1f3c67c 100644
--- a/docs/module-visual.TextBox.html
+++ b/docs/module-visual.TextBox.html
@@ -1616,13 +1616,13 @@
diff --git a/docs/module-visual.TextStim.html b/docs/module-visual.TextStim.html
index f2332e7..5c97d71 100644
--- a/docs/module-visual.TextStim.html
+++ b/docs/module-visual.TextStim.html
@@ -1384,13 +1384,13 @@ unlike getSize().
diff --git a/docs/module-visual.VisualStim.html b/docs/module-visual.VisualStim.html
index ad61c18..882eec4 100644
--- a/docs/module-visual.VisualStim.html
+++ b/docs/module-visual.VisualStim.html
@@ -1408,13 +1408,13 @@ This is typically called in the constructor of a stimulus, when attributes are a
diff --git a/docs/module-visual.html b/docs/module-visual.html
index ae95217..180dc6d 100644
--- a/docs/module-visual.html
+++ b/docs/module-visual.html
@@ -104,13 +104,13 @@
diff --git a/docs/sound_Sound.js.html b/docs/sound_Sound.js.html
index d312151..577f51c 100644
--- a/docs/sound_Sound.js.html
+++ b/docs/sound_Sound.js.html
@@ -290,13 +290,13 @@ export class Sound extends PsychObject
diff --git a/docs/sound_SoundPlayer.js.html b/docs/sound_SoundPlayer.js.html
index a54ebc0..e71a1e9 100644
--- a/docs/sound_SoundPlayer.js.html
+++ b/docs/sound_SoundPlayer.js.html
@@ -197,13 +197,13 @@ export class SoundPlayer extends PsychObject
diff --git a/docs/sound_TonePlayer.js.html b/docs/sound_TonePlayer.js.html
index ae36bd7..e8815c7 100644
--- a/docs/sound_TonePlayer.js.html
+++ b/docs/sound_TonePlayer.js.html
@@ -422,13 +422,13 @@ TonePlayer.SoundLibrary = {
diff --git a/docs/sound_TrackPlayer.js.html b/docs/sound_TrackPlayer.js.html
index 9516e69..27d3cab 100644
--- a/docs/sound_TrackPlayer.js.html
+++ b/docs/sound_TrackPlayer.js.html
@@ -264,13 +264,13 @@ export class TrackPlayer extends SoundPlayer
diff --git a/docs/util_Clock.js.html b/docs/util_Clock.js.html
index 7f772a0..10f0548 100644
--- a/docs/util_Clock.js.html
+++ b/docs/util_Clock.js.html
@@ -260,13 +260,13 @@ export class CountdownTimer extends Clock
diff --git a/docs/util_Color.js.html b/docs/util_Color.js.html
index 0ac1330..55a6bb2 100644
--- a/docs/util_Color.js.html
+++ b/docs/util_Color.js.html
@@ -753,13 +753,13 @@ Color.NAMED_COLORS = {
diff --git a/docs/util_ColorMixin.js.html b/docs/util_ColorMixin.js.html
index ca25efd..5e7ba73 100644
--- a/docs/util_ColorMixin.js.html
+++ b/docs/util_ColorMixin.js.html
@@ -115,13 +115,13 @@ export let ColorMixin = (superclass) => class extends superclass
diff --git a/docs/util_EventEmitter.js.html b/docs/util_EventEmitter.js.html
index e14ae5b..183d319 100644
--- a/docs/util_EventEmitter.js.html
+++ b/docs/util_EventEmitter.js.html
@@ -193,13 +193,13 @@ export class EventEmitter
diff --git a/docs/util_PsychObject.js.html b/docs/util_PsychObject.js.html
index 53fb58e..0dfe197 100644
--- a/docs/util_PsychObject.js.html
+++ b/docs/util_PsychObject.js.html
@@ -377,10 +377,10 @@ export class PsychObject extends EventEmitter
* @protected
* @param {string} name - the name of the attribute
* @param {object} value - the value of the attribute
- * @param {object} defaultValue - the default value for the attribute
- * @param {function} onChange - function called upon changes to the attribute value
+ * @param {object} [defaultValue] - the default value for the attribute
+ * @param {function} [onChange] - function called upon changes to the attribute value
*/
- _addAttribute(name, value, defaultValue, onChange = () => {})
+ _addAttribute(name, value, defaultValue = undefined, onChange = () => {})
{
const getPropertyName = 'get' + name[0].toUpperCase() + name.substr(1);
if (typeof this[getPropertyName] === 'undefined')
@@ -445,13 +445,13 @@ export class PsychObject extends EventEmitter
diff --git a/docs/util_Scheduler.js.html b/docs/util_Scheduler.js.html
index a353d18..17f7792 100644
--- a/docs/util_Scheduler.js.html
+++ b/docs/util_Scheduler.js.html
@@ -162,10 +162,10 @@ export class Scheduler
* @name module:util.Scheduler#start
* @public
*/
- start()
+ async start()
{
const self = this;
- let update = (timestamp) =>
+ const update = async (timestamp) =>
{
// stop the animation if need be:
if (self._stopAtNextUpdate)
@@ -177,7 +177,7 @@ export class Scheduler
// self._psychoJS.window._writeLogOnFlip();
// run the next scheduled tasks until a scene render is requested:
- const state = self._runNextTasks();
+ const state = await self._runNextTasks();
if (state === Scheduler.Event.QUIT)
{
self._status = Scheduler.Status.STOPPED;
@@ -223,7 +223,7 @@ export class Scheduler
* @private
* @return {module:util.Scheduler#Event} the state of the scheduler after the last task ran
*/
- _runNextTasks()
+ async _runNextTasks()
{
this._status = Scheduler.Status.RUNNING;
@@ -262,14 +262,14 @@ export class Scheduler
// if the current task is a function, we run it:
if (this._currentTask instanceof Function)
{
- state = this._currentTask(...this._currentArgs);
+ state = await this._currentTask(...this._currentArgs);
}
// otherwise, we assume that the current task is a scheduler and we run its tasks until a rendering
// of the scene is required.
// note: "if (this._currentTask instanceof Scheduler)" does not work because of CORS...
else
{
- state = this._currentTask._runNextTasks();
+ state = await this._currentTask._runNextTasks();
if (state === Scheduler.Event.QUIT)
{
// if the experiment has not ended, we move onto the next task:
@@ -356,13 +356,13 @@ Scheduler.Status = {
diff --git a/docs/util_Util.js.html b/docs/util_Util.js.html
index bbecf01..6aa6b39 100644
--- a/docs/util_Util.js.html
+++ b/docs/util_Util.js.html
@@ -986,12 +986,12 @@ export function offerDataForDownload(filename, data, type)
}
else
{
- let elem = window.document.createElement('a');
- elem.href = window.URL.createObjectURL(blob);
- elem.download = filename;
- document.body.appendChild(elem);
- elem.click();
- document.body.removeChild(elem);
+ const anchor = document.createElement('a');
+ anchor.href = window.URL.createObjectURL(blob);
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
}
}
@@ -1122,6 +1122,43 @@ export function sum(inputMaybe = [])
// Add up each successive entry starting from naught
.reduce(add, 0);
}
+
+
+/**
+ * Return the file extension corresponding to an audio mime type.
+ * If the provided mimeType is not a string (e.g. null, undefined, an array)
+ * or unknown, then '.dat' is returned, instead of throwing an exception.
+ *
+ * @name module:util.extensionFromMimeType
+ * @function
+ * @public
+ * @param {string} mimeType the MIME type, e.g. 'audio/webm;codecs=opus'
+ * @return {string} the corresponding file extension, e.g. '.webm'
+ */
+export function extensionFromMimeType(mimeType)
+{
+ if (typeof mimeType !== 'string')
+ {
+ return '.dat';
+ }
+
+ if (mimeType.indexOf('audio/webm') === 0)
+ {
+ return '.webm';
+ }
+
+ if (mimeType.indexOf('audio/ogg') === 0)
+ {
+ return '.ogg';
+ }
+
+ if (mimeType.indexOf('audio/wav') === 0)
+ {
+ return '.wav';
+ }
+
+ return '.dat';
+}
@@ -1132,13 +1169,13 @@ export function sum(inputMaybe = [])
diff --git a/docs/visual_Form.js.html b/docs/visual_Form.js.html
index 7521c8f..10cf284 100644
--- a/docs/visual_Form.js.html
+++ b/docs/visual_Form.js.html
@@ -1176,13 +1176,13 @@ Form._defaultItems = {
diff --git a/docs/visual_ImageStim.js.html b/docs/visual_ImageStim.js.html
index 2c75bc3..9dda506 100644
--- a/docs/visual_ImageStim.js.html
+++ b/docs/visual_ImageStim.js.html
@@ -395,13 +395,13 @@ export class ImageStim extends util.mix(VisualStim).with(ColorMixin)
diff --git a/docs/visual_MovieStim.js.html b/docs/visual_MovieStim.js.html
index 0983d3e..5f4473c 100644
--- a/docs/visual_MovieStim.js.html
+++ b/docs/visual_MovieStim.js.html
@@ -475,13 +475,13 @@ export class MovieStim extends VisualStim
diff --git a/docs/visual_Polygon.js.html b/docs/visual_Polygon.js.html
index 326c415..3a74b10 100644
--- a/docs/visual_Polygon.js.html
+++ b/docs/visual_Polygon.js.html
@@ -181,13 +181,13 @@ export class Polygon extends ShapeStim
diff --git a/docs/visual_Rect.js.html b/docs/visual_Rect.js.html
index 6feb07f..2b6c526 100644
--- a/docs/visual_Rect.js.html
+++ b/docs/visual_Rect.js.html
@@ -186,13 +186,13 @@ export class Rect extends ShapeStim
diff --git a/docs/visual_ShapeStim.js.html b/docs/visual_ShapeStim.js.html
index 27ee47a..2f5627b 100644
--- a/docs/visual_ShapeStim.js.html
+++ b/docs/visual_ShapeStim.js.html
@@ -423,13 +423,13 @@ ShapeStim.KnownShapes = {
diff --git a/docs/visual_Slider.js.html b/docs/visual_Slider.js.html
index 3bfe483..e047f73 100644
--- a/docs/visual_Slider.js.html
+++ b/docs/visual_Slider.js.html
@@ -1399,13 +1399,13 @@ Slider.Skin = {
diff --git a/docs/visual_TextBox.js.html b/docs/visual_TextBox.js.html
index 9c161d7..8eb3a56 100644
--- a/docs/visual_TextBox.js.html
+++ b/docs/visual_TextBox.js.html
@@ -557,13 +557,13 @@ TextBox._defaultSizeMap = new Map([
diff --git a/docs/visual_TextInput.js.html b/docs/visual_TextInput.js.html
index 01ae60b..f5c9373 100644
--- a/docs/visual_TextInput.js.html
+++ b/docs/visual_TextInput.js.html
@@ -889,13 +889,13 @@ function DefaultBoxGenerator(styles)
diff --git a/docs/visual_TextStim.js.html b/docs/visual_TextStim.js.html
index bd95a09..cb7a375 100644
--- a/docs/visual_TextStim.js.html
+++ b/docs/visual_TextStim.js.html
@@ -459,13 +459,13 @@ TextStim._defaultWrapWidthMap = new Map([
diff --git a/docs/visual_VisualStim.js.html b/docs/visual_VisualStim.js.html
index 077a327..31e7caf 100644
--- a/docs/visual_VisualStim.js.html
+++ b/docs/visual_VisualStim.js.html
@@ -341,13 +341,13 @@ export class VisualStim extends util.mix(MinimalStim).with(WindowMixin)
diff --git a/index.css b/index.css
new file mode 100644
index 0000000..2208889
--- /dev/null
+++ b/index.css
@@ -0,0 +1,149 @@
+
+/* project and resource dialogs */
+label, input, select {
+ display: block;
+ padding-bottom: .5em;
+}
+
+input.text, select.text {
+ margin-bottom: 1em;
+ width: 95%;
+ padding: .5em;
+}
+
+fieldset {
+ padding: 0;
+ border: 0;
+ margin-top: 1em;
+}
+
+a, a:active, a:focus, a:visited {
+ outline: 0;
+ color: #007EB7;
+}
+
+a:hover {
+ color: #000000;
+}
+
+.progress {
+ padding: .5em 0 .5em 0;
+}
+
+.logo {
+ display: block;
+ margin-left: auto;
+ margin-right: auto;
+ max-width: 100%;
+ margin-bottom: 1em;
+}
+
+
+/* don't display close button in the top right corner of the box */
+.no-close .ui-dialog-titlebar-close {
+ display: none;
+}
+
+.ui-dialog-content {
+ margin-top: 1em;
+}
+
+
+/* for mobile phones only */
+@media only screen and (max-width: 1080px) {
+
+ .ui-widget {
+ -ms-transform: scale(2);
+ -webkit-transform: scale(2);
+ transform: scale(2);
+ }
+
+ .ui-widget .ui-progressbar {
+ -ms-transform: scale(1);
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ }
+
+ .ui-dialog .ui-dialog-buttonpane {
+ padding-top: 1em;
+ }
+
+ .ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset .ui-button {
+ -ms-transform: scale(1);
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ }
+
+ .ui-dialog .ui-dialog-titlebar {
+ padding: 1em 2em;
+ }
+
+ .ui-dialog-titlebar .ui-button {
+ margin-right: 1em;
+ }
+
+ .ui-dialog-titlebar .ui-dialog-titlebar-close {
+ -ms-transform: scale(1);
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ }
+}
+
+@media only screen and (max-width: 1080px) and (orientation:landscape) {
+
+ .ui-widget {
+ -ms-transform: scale(1.5);
+ -webkit-transform: scale(1.5);
+ transform: scale(1.5);
+ }
+
+ .ui-widget .ui-progressbar {
+ -ms-transform: scale(1);
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ }
+
+ .ui-dialog .ui-dialog-buttonpane {
+ padding-top: 1em;
+ }
+
+ .ui-dialog .ui-dialog-buttonpane .ui-dialog-buttonset .ui-button {
+ -ms-transform: scale(1);
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ }
+
+ .ui-dialog .ui-dialog-titlebar {
+ padding: 1em 2em;
+ }
+
+ .ui-dialog-titlebar .ui-button {
+ margin-right: 1em;
+ }
+
+ .ui-dialog-titlebar .ui-dialog-titlebar-close {
+ -ms-transform: scale(1);
+ -webkit-transform: scale(1);
+ transform: scale(1);
+ }
+}
+
+
+/* Initialisation message (which will disappear behind the canvas) */
+#root:after {
+ content: "initialising the experiment...";
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+}
+
+/* Initialisation message for IE11 */
+@media screen and (-ms-high-contrast: active), (-ms-high-contrast: none) {
+ #root:after {
+ content: "initialising the experiment... | Internet Explorer / Edge [beta]";
+
+ color: #A05000;
+ font-weight: bold;
+ }
+}
diff --git a/index.js b/index.js
new file mode 100644
index 0000000..d484753
--- /dev/null
+++ b/index.js
@@ -0,0 +1,5 @@
+export * as util from './js/util/index.js';
+export * as core from './js/core/index.js';
+export * as data from './js/data/index.js';
+export * as visual from './js/visual/index.js';
+export * as sound from './js/sound/index.js';
diff --git a/package.json b/package.json
index 7a982a4..fc77762 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "psychojs",
- "version": "2021.1.4",
+ "version": "2021.x",
"private": true,
"description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments",
"license": "MIT",
@@ -49,7 +49,6 @@
]
}
},
- "dependencies": {},
"devDependencies": {
"@babel/core": "^7.12.3",
"@babel/preset-env": "^7.12.1",
diff --git a/src/core/GUI.js b/src/core/GUI.js
index 9964c1a..7bdb57b 100644
--- a/src/core/GUI.js
+++ b/src/core/GUI.js
@@ -313,7 +313,7 @@ export class GUI
//$.blockUI({ message: "", baseZ: 1});
// show dialog box:
- $("#progressbar").progressbar({value: self._progressBarCurrentIncrement});
+ $("#progressbar").progressbar({value: self._progressBarCurrentValue});
$("#progressbar").progressbar("option", "max", self._progressBarMax);
}
@@ -587,15 +587,14 @@ export class GUI
{
this._psychoJS.logger.debug('signal: ' + util.toString(signal));
- // all resources have been registered:
- if (signal.message === ServerManager.Event.RESOURCES_REGISTERED)
+ // the download of the specified resources has started:
+ if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCES)
{
// for each resource, we have a 'downloading resource' and a 'resource downloaded' message:
this._progressBarMax = signal.count * 2;
$("#progressbar").progressbar("option", "max", this._progressBarMax);
- this._progressBarCurrentIncrement = 0;
- $("#progressMsg").text('all resources registered.');
+ this._progressBarCurrentValue = 0;
}
// all the resources have been downloaded: show the ok button
@@ -607,23 +606,25 @@ export class GUI
}
// update progress bar:
- else if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCE || signal.message === ServerManager.Event.RESOURCE_DOWNLOADED)
+ else if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCE
+ || signal.message === ServerManager.Event.RESOURCE_DOWNLOADED)
{
- if (typeof this._progressBarCurrentIncrement === 'undefined')
+ if (typeof this._progressBarCurrentValue === 'undefined')
{
- this._progressBarCurrentIncrement = 0;
+ this._progressBarCurrentValue = 0;
}
- ++this._progressBarCurrentIncrement;
+ ++this._progressBarCurrentValue;
if (signal.message === ServerManager.Event.RESOURCE_DOWNLOADED)
{
- $("#progressMsg").text('downloaded ' + this._progressBarCurrentIncrement / 2 + ' / ' + this._progressBarMax / 2);
+ $("#progressMsg").text('downloaded ' + (this._progressBarCurrentValue / 2) + ' / ' + (this._progressBarMax / 2));
+ }
+ else
+ {
+ $("#progressMsg").text('downloading ' + (this._progressBarCurrentValue / 2) + ' / ' + (this._progressBarMax / 2));
}
// $("#progressMsg").text(signal.resource + ': downloaded.');
- // else
- // $("#progressMsg").text(signal.resource + ': downloading...');
-
- $("#progressbar").progressbar("option", "value", this._progressBarCurrentIncrement);
+ $("#progressbar").progressbar("option", "value", this._progressBarCurrentValue);
}
// unknown message: we just display it
diff --git a/src/core/Keyboard.js b/src/core/Keyboard.js
index 338fc8b..170854a 100644
--- a/src/core/Keyboard.js
+++ b/src/core/Keyboard.js
@@ -198,7 +198,7 @@ export class Keyboard extends PsychObject
const keyEvent = this._circularBuffer[i];
if (keyEvent && keyEvent.status === Keyboard.KeyStatus.KEY_UP)
{
- // check that the key is in the keyList:
+ // if the keylist is empty of the key is in the keyList:
if (keyList.length === 0 || keyList.includes(keyEvent.pigletKey))
{
// look for a corresponding, preceding keydown event:
diff --git a/src/core/Logger.js b/src/core/Logger.js
index 880a789..7de2bfb 100644
--- a/src/core/Logger.js
+++ b/src/core/Logger.js
@@ -312,9 +312,9 @@ export class Logger
*/
_customConsoleLayout()
{
- const detectedBrowser = this._psychoJS.browser;
+ const detectedBrowser = util.detectBrowser();
- const customLayout = new log4javascript.PatternLayout("%p %f{1} | %m");
+ const customLayout = new log4javascript.PatternLayout("%p %d{HH:mm:ss.SSS} %f{1} | %m");
customLayout.setCustomField('location', function (layout, loggingReference)
{
// we throw a fake exception to retrieve the stack trace
@@ -345,7 +345,7 @@ export class Logger
const file = buf[buf.length - 3].split('/').pop();
const method = relevantEntry.split('@')[0];
- return method + ' ' + file + ' ' + line;
+ return method + ' ' + file + ':' + line;
}
else if (detectedBrowser === 'Safari')
{
@@ -363,7 +363,7 @@ export class Logger
const line = buf.pop();
const file = buf.pop().split('/').pop();
- return method + ' ' + file + ' ' + line;
+ return method + ' ' + file + ':' + line;
}
else
{
diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js
index cb72574..96183f7 100644
--- a/src/core/PsychoJS.js
+++ b/src/core/PsychoJS.js
@@ -18,6 +18,7 @@ import {GUI} from './GUI';
import {MonotonicClock} from '../util/Clock';
import {Logger} from './Logger';
import * as util from '../util/Util';
+// import {Shelf} from "../data/Shelf";
/**
@@ -30,6 +31,7 @@ import * as util from '../util/Util';
*/
export class PsychoJS
{
+
/**
* Properties
*/
@@ -109,6 +111,11 @@ export class PsychoJS
return this._browser;
}
+ // get shelf()
+ // {
+ // return this._shelf;
+ // }
+
/**
* @constructor
@@ -154,6 +161,9 @@ export class PsychoJS
// Window:
this._window = undefined;
+ // // Shelf:
+ // this._shelf = new Shelf(this);
+
// redirection URLs:
this._cancellationUrl = undefined;
this._completionUrl = undefined;
@@ -292,6 +302,17 @@ export class PsychoJS
/**
* Start the experiment.
*
+ *
The resources are specified in the following fashion:
+ *
+ *
For an experiment running locally: the root directory for the specified resources is that of index.html
+ * unless they are prepended with a protocol, such as http:// or https://.
+ *
For an experiment running on the server: if no resources are specified, all files in the resources directory
+ * of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
+ * local to index.html unless they are prepended with a protocol.
+ *
If resources is null: we do not download any resources.
+ *
+ *
+ *
* @param {Object} options
* @param {string} [options.configURL=config.json] - the URL of the configuration file
* @param {string} [options.expName=UNKNOWN] - the name of the experiment
@@ -299,8 +320,6 @@ export class PsychoJS
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
* @async
* @public
- *
- * @todo: close session on window or tab close
*/
async start({configURL = 'config.json', expName = 'UNKNOWN', expInfo = {}, resources = []} = {})
{
@@ -386,11 +405,11 @@ export class PsychoJS
// start the asynchronous download of resources:
- this._serverManager.downloadResources(resources);
+ await this._serverManager.prepareResources(resources);
// start the experiment:
this.logger.info('[PsychoJS] Start Experiment.');
- this._scheduler.start();
+ await this._scheduler.start();
}
catch (error)
{
@@ -400,8 +419,12 @@ export class PsychoJS
}
+
/**
- * Synchronously download resources for the experiment.
+ * Block the experiment until the specified resources have been downloaded.
+ *
+ *
Note: only those resources that have not already been downloaded at that point are
+ * considered.
*
*
*
For an experiment running locally: the root directory for the specified resources is that of index.html
@@ -411,14 +434,18 @@ export class PsychoJS
* local to index.html unless they are prepended with a protocol.
*
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
- * @async
* @public
*/
- async downloadResources(resources = [])
+ waitForResources(resources = [])
{
+ const response = {
+ origin: 'PsychoJS.waitForResources',
+ context: 'while waiting for resources to be downloaded'
+ };
+
try
{
- await this.serverManager.downloadResources(resources);
+ return this.serverManager.waitForResources(resources);
}
catch (error)
{
@@ -428,6 +455,7 @@ export class PsychoJS
}
+
/**
* Make the attributes of the given object those of PsychoJS and those of
* the top level variable (e.g. window) as well.
@@ -603,6 +631,14 @@ export class PsychoJS
{
throw 'missing URL in pavlovia block in configuration';
}
+ if (!('gitlab' in this._config))
+ {
+ throw 'missing gitlab block in configuration';
+ }
+ if (!('projectId' in this._config.gitlab))
+ {
+ throw 'missing projectId in gitlab block in configuration';
+ }
this._config.environment = ExperimentHandler.Environment.SERVER;
@@ -685,23 +721,21 @@ export class PsychoJS
*/
_captureErrors()
{
- this.logger.debug('capturing all errors using window.onerror');
+ this.logger.debug('capturing all errors and showing them in a pop up window');
const self = this;
window.onerror = function (message, source, lineno, colno, error)
{
console.error(error);
- self._gui.dialog({"error": error});
+ self._gui.dialog({error});
return true;
};
-
- /* NOT UNIVERSALLY SUPPORTED YET
- window.addEventListener('unhandledrejection', event => {
- console.error(error);
- self._gui.dialog({"error" : error});
+ window.onunhandledrejection = function (error)
+ {
+ console.error(error.reason);
+ self._gui.dialog({error: error.reason});
return true;
- });*/
-
+ };
}
@@ -737,8 +771,9 @@ PsychoJS.Status = {
CONFIGURED: Symbol.for('CONFIGURED'),
NOT_STARTED: Symbol.for('NOT_STARTED'),
STARTED: Symbol.for('STARTED'),
+ PAUSED: Symbol.for('PAUSED'),
FINISHED: Symbol.for('FINISHED'),
-
- STOPPED: Symbol.for('FINISHED') //Symbol.for('STOPPED')
+ STOPPED: Symbol.for('FINISHED'), //Symbol.for('STOPPED')
+ ERROR: Symbol.for('ERROR')
};
diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js
index 2eb43fa..90fe169 100644
--- a/src/core/ServerManager.js
+++ b/src/core/ServerManager.js
@@ -1,5 +1,5 @@
/**
- * Manager responsible for the communication between the experiment running in the participant's browser and the remote PsychoJS manager running on the remote https://pavlovia.org server.
+ * Manager responsible for the communication between the experiment running in the participant's browser and the pavlovia.org server.
*
* @author Alain Pitiot
* @version 2021.1.4
@@ -18,9 +18,8 @@ import {MonotonicClock} from "../util/Clock";
/**
- *
This manager handles all communications between the experiment running in the participant's browser and the remote PsychoJS manager running on the [pavlovia.org]{@link http://pavlovia.org} server, in an asynchronous manner.
- *
It is responsible for reading the configuration file of an experiment, for opening and closing a session, for listing and downloading resources, and for uploading results and log.
- *
Note: The Server Manager uses [Promises]{@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise} to deal with asynchronicity, is mostly called by {@link PsychoJS}, and is not exposed to the experiment code.
+ *
This manager handles all communications between the experiment running in the participant's browser and the [pavlovia.org]{@link http://pavlovia.org} server, in an asynchronous manner.
+ *
It is responsible for reading the configuration file of an experiment, for opening and closing a session, for listing and downloading resources, and for uploading results, logs, and audio recordings.
*
* @name module:core.ServerManager
* @class
@@ -31,6 +30,16 @@ import {MonotonicClock} from "../util/Clock";
*/
export class ServerManager extends PsychObject
{
+ /**
+ * Used to indicate to the ServerManager that all resources must be registered (and
+ * subsequently downloaded)
+ *
+ * @type {symbol}
+ * @readonly
+ * @public
+ */
+ static ALL_RESOURCES = Symbol.for('ALL_RESOURCES');
+
constructor({
psychoJS,
@@ -42,9 +51,8 @@ export class ServerManager extends PsychObject
// session:
this._session = {};
- // resources is a map of {name: string, path: string} -> data: any
+ // resources is a map of
this._resources = new Map();
- this._nbResources = -1;
this._addAttribute('autoLog', autoLog);
this._addAttribute('status', ServerManager.Status.READY);
@@ -272,25 +280,65 @@ export class ServerManager extends PsychObject
* @name module:core.ServerManager#getResource
* @function
* @public
- * @param {string} name of the requested resource
- * @return {Object} value of the resource
+ * @param {string} name - name of the requested resource
+ * @param {boolean} [errorIfNotDownloaded = false] whether or not to throw an exception if the
+ * resource status is not DOWNLOADED
+ * @return {Object} value of the resource, or undefined if the resource has been registered
+ * but not downloaded yet.
* @throws {Object.} exception if no resource with that name has previously been registered
*/
- getResource(name)
+ getResource(name, errorIfNotDownloaded = false)
{
const response = {
origin: 'ServerManager.getResource',
context: 'when getting the value of resource: ' + name
};
- const path_data = this._resources.get(name);
- if (typeof path_data === 'undefined')
- // throw { ...response, error: 'unknown resource' };
+ const pathStatusData = this._resources.get(name);
+
+ if (typeof pathStatusData === 'undefined')
{
+ // throw { ...response, error: 'unknown resource' };
throw Object.assign(response, {error: 'unknown resource'});
}
- return path_data.data;
+ if (errorIfNotDownloaded && pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
+ {
+ throw Object.assign(response, {
+ error: name + ' is not available for use (yet), its current status is: ' +
+ util.toString(pathStatusData.status)
+ });
+ }
+
+ return pathStatusData.data;
+ }
+
+
+ /**
+ * Get the status of a resource.
+ *
+ * @name module:core.ServerManager#getResourceStatus
+ * @function
+ * @public
+ * @param {string} name of the requested resource
+ * @return {core.ServerManager.ResourceStatus} status of the resource
+ * @throws {Object.} exception if no resource with that name has previously been registered
+ */
+ getResourceStatus(name)
+ {
+ const response = {
+ origin: 'ServerManager.getResourceStatus',
+ context: 'when getting the status of resource: ' + name
+ };
+
+ const pathStatusData = this._resources.get(name);
+ if (typeof pathStatusData === 'undefined')
+ {
+ // throw { ...response, error: 'unknown resource' };
+ throw Object.assign(response, {error: 'unknown resource'});
+ }
+
+ return pathStatusData.status;
}
@@ -345,7 +393,8 @@ export class ServerManager extends PsychObject
/**
- * Asynchronously download resources for the experiment and register them with the server manager.
+ * Prepare resources for the experiment: register them with the server manager and possibly
+ * start downloading them right away.
*
*
*
For an experiment running locally: the root directory for the specified resources is that of index.html
@@ -353,85 +402,218 @@ export class ServerManager extends PsychObject
*
For an experiment running on the server: if no resources are specified, all files in the resources directory
* of the experiment are downloaded, otherwise we only download the specified resources. All resources are assumed
* local to index.html unless they are prepended with a protocol.
+ *
If resources is null, then we do not download any resources
*
*
- * @name module:core.ServerManager#downloadResources
+ * @name module:core.ServerManager#prepareResources
+ * @param {Array.<{name: string, path: string, download: boolean} | Symbol>} [resources=[]] - the list of resources
+ * @function
+ * @public
+ */
+ async prepareResources(resources = [])
+ {
+ const response = {
+ origin: 'ServerManager.prepareResources',
+ context: 'when preparing resources for experiment: ' + this._psychoJS.config.experiment.name
+ };
+
+ this._psychoJS.logger.debug('preparing resources for experiment: ' + this._psychoJS.config.experiment.name);
+
+ try
+ {
+ const resourcesToDownload = new Set();
+
+ // register the resources:
+ if (resources !== null)
+ {
+ if (!Array.isArray(resources))
+ {
+ throw "resources should be an array of objects";
+ }
+
+ // whether all resources have been requested:
+
+ const allResources = (resources.length === 1 && resources[0] === ServerManager.ALL_RESOURCES);
+
+ // if the experiment is hosted on the pavlovia.org server and
+ // resources is [ServerManager.ALL_RESOURCES], then we register all the resources
+ // in the "resources" sub-directory
+ if (this._psychoJS.config.environment === ExperimentHandler.Environment.SERVER
+ && allResources)
+ {
+ // list the resources from the resources directory of the experiment on the server:
+ const serverResponse = await this._listResources();
+
+ // register and mark for download those resources that have not been
+ // registered already:
+ for (const name of serverResponse.resources)
+ {
+ if (!this._resources.has(name))
+ {
+ const path = serverResponse.resourceDirectory + '/' + name;
+ this._resources.set(name, {
+ status: ServerManager.ResourceStatus.REGISTERED,
+ path,
+ data: undefined
+ });
+ this._psychoJS.logger.debug('registered resource:', name, path);
+ resourcesToDownload.add(name);
+ }
+ }
+ }
+
+ // if the experiment is hosted locally (localhost) or if specific resources were given
+ // then we register those specific resources, if they have not been registered already
+ else
+ {
+ // we cannot ask for all resources to be registered locally, since we cannot list
+ // them:
+ if (this._psychoJS.config.environment === ExperimentHandler.Environment.LOCAL
+ && allResources)
+ {
+ throw "resources must be manually specified when the experiment is running locally: ALL_RESOURCES cannot be used";
+ }
+
+ for (let {name, path, download} of resources)
+ {
+ if (!this._resources.has(name))
+ {
+ // to deal with potential CORS issues, we use the pavlovia.org proxy for resources
+ // not hosted on pavlovia.org:
+ if ((path.toLowerCase().indexOf('www.') === 0 ||
+ path.toLowerCase().indexOf('http:') === 0 ||
+ path.toLowerCase().indexOf('https:') === 0) &&
+ (path.indexOf('pavlovia.org') === -1))
+ {
+ path = 'https://pavlovia.org/api/v2/proxy/' + path;
+ }
+
+ this._resources.set(name, {
+ status: ServerManager.ResourceStatus.REGISTERED,
+ path,
+ data: undefined
+ });
+ this._psychoJS.logger.debug('registered resource:', name, path);
+
+ // download resources by default:
+ if (typeof download === 'undefined' || download)
+ {
+ resourcesToDownload.add(name);
+ }
+ }
+ }
+ }
+ }
+
+ // download those registered resources for which download = true:
+ /*await*/ this._downloadResources(resourcesToDownload);
+ }
+ catch (error)
+ {
+ console.log('error', error);
+ throw Object.assign(response, {error});
+ // throw { ...response, error: error };
+ }
+ }
+
+
+ /**
+ * Block the experiment until the specified resources have been downloaded.
+ *
+ * @name module:core.ServerManager#waitForResources
* @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources
* @function
* @public
*/
- downloadResources(resources = [])
+ waitForResources(resources = [])
{
- const response = {
- origin: 'ServerManager.downloadResources',
- context: 'when downloading the resources for experiment: ' + this._psychoJS.config.experiment.name
+ // prepare a PsychoJS component:
+ this._waitForDownloadComponent = {
+ status: PsychoJS.Status.NOT_STARTED,
+ clock: new Clock(),
+ resources: new Set()
};
- this._psychoJS.logger.debug('downloading resources for experiment: ' + this._psychoJS.config.experiment.name);
-
- // we use an anonymous async function here since downloadResources is non-blocking (asynchronous)
- // but we want to run the asynchronous _listResources and _downloadResources in sequence
const self = this;
- const newResources = new Map();
- let download = async () =>
+ return () =>
{
- try
+ const t = self._waitForDownloadComponent.clock.getTime();
+
+ // start the component:
+ if (t >= 0.0 && self._waitForDownloadComponent.status === PsychoJS.Status.NOT_STARTED)
{
- if (self._psychoJS.config.environment === ExperimentHandler.Environment.SERVER)
+ self._waitForDownloadComponent.tStart = t;
+ self._waitForDownloadComponent.status = PsychoJS.Status.STARTED;
+
+ // if resources is an empty array, we consider all registered resources:
+ if (resources.length === 0)
{
- // no resources specified, we register them all:
- if (resources.length === 0)
+ for (const [name, {status, path, data}] of this._resources)
{
- // list the resources from the resources directory of the experiment on the server:
- const serverResponse = await self._listResources();
- for (const name of serverResponse.resources)
- {
- self._resources.set(name, {path: serverResponse.resourceDirectory + '/' + name});
- }
- }
- else
- {
- // only registered the specified resources:
- for (const {name, path} of resources)
- {
- self._resources.set(name, {path});
- newResources.set(name, {path});
- }
- }
- }
- else
- {
- // register the specified resources:
- for (const {name, path} of resources)
- {
- self._resources.set(name, {path});
- newResources.set(name, {path});
+ resources.append({ name, path });
}
}
- self._nbResources = self._resources.size;
- for (const name of self._resources.keys())
+ // only download those resources not already downloaded or downloading:
+ const resourcesToDownload = new Set();
+ for (let {name, path} of resources)
{
- this._psychoJS.logger.debug('resource:', name, self._resources.get(name).path);
+ // to deal with potential CORS issues, we use the pavlovia.org proxy for resources
+ // not hosted on pavlovia.org:
+ if ( (path.toLowerCase().indexOf('www.') === 0 ||
+ path.toLowerCase().indexOf('http:') === 0 ||
+ path.toLowerCase().indexOf('https:') === 0) &&
+ (path.indexOf('pavlovia.org') === -1) )
+ {
+ path = 'https://devlovia.org/api/v2/proxy/' + path;
+ }
+
+ const pathStatusData = this._resources.get(name);
+
+ // the resource has not been registered yet:
+ if (typeof pathStatusData === 'undefined')
+ {
+ self._resources.set(name, {
+ status: ServerManager.ResourceStatus.REGISTERED,
+ path,
+ data: undefined
+ });
+ self._waitForDownloadComponent.resources.add(name);
+ resourcesToDownload.add(name);
+ self._psychoJS.logger.debug('registered resource:', name, path);
+ }
+
+ // the resource has been registered but is not downloaded yet:
+ else if (typeof pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
+ // else if (typeof pathStatusData.data === 'undefined')
+ {
+ self._waitForDownloadComponent.resources.add(name);
+ }
+
}
- self.emit(ServerManager.Event.RESOURCE, {
- message: ServerManager.Event.RESOURCES_REGISTERED,
- count: self._nbResources
- });
-
- // download the registered resources:
- await self._downloadRegisteredResources(newResources);
+ // start the download:
+ self._downloadResources(resourcesToDownload);
}
- catch (error)
+
+ // check whether all resources have been downloaded:
+ for (const name of self._waitForDownloadComponent.resources)
{
- console.log('error', error);
- // throw { ...response, error: error };
- throw Object.assign(response, {error});
+ const pathStatusData = this._resources.get(name);
+
+ // the resource has not been downloaded yet: loop this component
+ if (typeof pathStatusData.status !== ServerManager.ResourceStatus.DOWNLOADED)
+ // if (typeof pathStatusData.data === 'undefined')
+ {
+ return Scheduler.Event.FLIP_REPEAT;
+ }
}
+
+ // all resources have been downloaded: move to the next component:
+ self._waitForDownloadComponent.status = PsychoJS.Status.FINISHED;
+ return Scheduler.Event.NEXT;
};
- download();
}
@@ -442,7 +624,7 @@ export class ServerManager extends PsychObject
* @property {Object.} [error] an error message if we could not upload the data
*/
/**
- * Asynchronously upload experiment data to the remote PsychoJS manager.
+ * Asynchronously upload experiment data to the pavlovia server.
*
* @name module:core.ServerManager#uploadData
* @function
@@ -507,8 +689,9 @@ export class ServerManager extends PsychObject
}
+
/**
- * Asynchronously upload experiment logs to the remote PsychoJS manager.
+ * Asynchronously upload experiment logs to the pavlovia server.
*
* @name module:core.ServerManager#uploadLog
* @function
@@ -567,6 +750,84 @@ export class ServerManager extends PsychObject
}
+
+ /**
+ * Asynchronously upload audio data to the pavlovia server.
+ *
+ * @name module:core.ServerManager#uploadAudio
+ * @function
+ * @public
+ * @param {Blob} audioBlob - the audio blob to be uploaded
+ * @param {string} tag - additional tag
+ * @returns {Promise} the response
+ */
+ async uploadAudio(audioBlob, tag)
+ {
+ const response = {
+ origin: 'ServerManager.uploadAudio',
+ context: 'when uploading audio data for experiment: ' + this._psychoJS.config.experiment.fullpath
+ };
+
+ try
+ {
+ if (this._psychoJS.getEnvironment() !== ExperimentHandler.Environment.SERVER ||
+ this._psychoJS.config.experiment.status !== 'RUNNING' ||
+ this._psychoJS._serverMsg.has('__pilotToken'))
+ {
+ throw 'audio recordings can only be uploaded to the server for experiments running on the server';
+ }
+
+ this._psychoJS.logger.debug('uploading audio data for experiment: ' + this._psychoJS.config.experiment.fullpath);
+ this.setStatus(ServerManager.Status.BUSY);
+
+ // prepare the request:
+ const info = this.psychoJS.experiment.extraInfo;
+ const participant = ((typeof info.participant === 'string' && info.participant.length > 0) ? info.participant : 'PARTICIPANT');
+ const experimentName = (typeof info.expName !== 'undefined') ? info.expName : this.psychoJS.config.experiment.name;
+ const datetime = ((typeof info.date !== 'undefined') ? info.date : MonotonicClock.getDateStr());
+ const filename = participant + '_' + experimentName + '_' + datetime + '_' + tag;
+
+ const formData = new FormData();
+ formData.append('audio', audioBlob, filename);
+
+ const url = this._psychoJS.config.pavlovia.URL +
+ '/api/v2/experiments/' + this._psychoJS.config.gitlab.projectId +
+ '/sessions/' + this._psychoJS.config.session.token +
+ '/audio';
+
+ // query the pavlovia server:
+ const response = await fetch(url, {
+ method: 'POST',
+ mode: 'cors', // no-cors, *cors, same-origin
+ cache: 'no-cache', // *default, no-cache, reload, force-cache, only-if-cached
+ credentials: 'same-origin', // include, *same-origin, omit
+ redirect: 'follow', // manual, *follow, error
+ referrerPolicy: 'no-referrer', // no-referrer, *no-referrer-when-downgrade, origin, origin-when-cross-origin, same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url
+ body: formData
+ });
+ const jsonResponse = await response.json();
+
+ // deal with server errors:
+ if (!response.ok)
+ {
+ throw jsonResponse;
+ }
+
+ this.setStatus(ServerManager.Status.READY);
+ return jsonResponse;
+ }
+ catch (error)
+ {
+ this.setStatus(ServerManager.Status.ERROR);
+ console.error(error);
+
+ throw {...response, error};
+ }
+
+ }
+
+
+
/**
* List the resources available to the experiment.
@@ -636,62 +897,76 @@ export class ServerManager extends PsychObject
}
+
/**
- * Download the resources previously registered.
+ * Download the specified resources.
*
*
Note: we use the [preloadjs library]{@link https://www.createjs.com/preloadjs}.
*
- * @name module:core.ServerManager#_downloadRegisteredResources
+ * @name module:core.ServerManager#_downloadResources
* @function
- * @private
+ * @protected
+ * @param {Set} resources - a set of names of previously registered resources
*/
- _downloadRegisteredResources(resources = new Map())
+ _downloadResources(resources)
{
const response = {
origin: 'ServerManager._downloadResources',
- context: 'when downloading the resources for experiment: ' + this._psychoJS.config.experiment.name
+ context: 'when downloading resources for experiment: ' + this._psychoJS.config.experiment.name
};
- this._psychoJS.logger.debug('downloading the registered resources for experiment: ' + this._psychoJS.config.experiment.name);
+ this._psychoJS.logger.debug('downloading resources for experiment: ' + this._psychoJS.config.experiment.name);
this.setStatus(ServerManager.Status.BUSY);
+ this.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOADING_RESOURCES,
+ count: resources.size
+ });
+
this._nbLoadedResources = 0;
// (*) set-up preload.js:
- this._resourceQueue = new createjs.LoadQueue(true); //, this._psychoJS.config.experiment.resourceDirectory);
+ this._resourceQueue = new createjs.LoadQueue(true, '', true);
const self = this;
- const filesToDownload = resources.size ? resources : this._resources;
-
+ // the loading of a specific resource has started:
this._resourceQueue.addEventListener("filestart", event =>
{
+ const pathStatusData = self._resources.get(event.item.id);
+ pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING;
+
self.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.DOWNLOADING_RESOURCE,
resource: event.item.id
});
});
+ // the loading of a specific resource has completed:
this._resourceQueue.addEventListener("fileload", event =>
{
- ++self._nbLoadedResources;
- let path_data = self._resources.get(event.item.id);
- path_data.data = event.result;
+ const pathStatusData = self._resources.get(event.item.id);
+ pathStatusData.data = event.result;
+ pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED;
+
+ ++ self._nbLoadedResources;
self.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.RESOURCE_DOWNLOADED,
resource: event.item.id
});
});
- // loading completed:
+ // the loading of all given resources completed:
this._resourceQueue.addEventListener("complete", event =>
{
self._resourceQueue.close();
- if (self._nbLoadedResources === filesToDownload.size)
+ if (self._nbLoadedResources === resources.size)
{
self.setStatus(ServerManager.Status.READY);
- self.emit(ServerManager.Event.RESOURCE, {message: ServerManager.Event.DOWNLOAD_COMPLETED});
+ self.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOAD_COMPLETED
+ });
}
});
@@ -699,16 +974,43 @@ export class ServerManager extends PsychObject
this._resourceQueue.addEventListener("error", event =>
{
self.setStatus(ServerManager.Status.ERROR);
- const resourceId = (typeof event.data !== 'undefined') ? event.data.id : 'UNKNOWN RESOURCE';
- // throw { ...response, error: 'unable to download resource: ' + resourceId + ' (' + event.title + ')' };
- throw Object.assign(response, {error: 'unable to download resource: ' + resourceId + ' (' + event.title + ')'});
+ if (typeof event.item !== 'undefined')
+ {
+ const pathStatusData = self._resources.get(event.item.id);
+ pathStatusData.status = ServerManager.ResourceStatus.ERROR;
+ throw Object.assign(response, {
+ error: 'unable to download resource: ' + event.item.id + ' (' + event.title + ')'
+ });
+ }
+ else
+ {
+ console.error(event);
+
+ if (event.title === 'FILE_LOAD_ERROR' && typeof event.data !== 'undefined')
+ {
+ const id = event.data.id;
+ const title = event.data.src;
+
+ throw Object.assign(response, {
+ error: 'unable to download resource: ' + id + ' (' + title + ')'
+ });
+ }
+
+ else
+ {
+ throw Object.assign(response, {
+ error: 'unspecified download error'
+ });
+ }
+
+ }
});
// (*) dispatch resources to preload.js or howler.js based on extension:
let manifest = [];
- let soundResources = [];
- for (const [name, path_data] of filesToDownload)
+ const soundResources = new Set();
+ for (const name of resources)
{
const nameParts = name.toLowerCase().split('.');
const extension = (nameParts.length > 1) ? nameParts.pop() : undefined;
@@ -719,10 +1021,25 @@ export class ServerManager extends PsychObject
this.psychoJS.logger.warn(`"${name}" does not appear to have an extension, which may negatively impact its loading. We highly recommend you add an extension.`);
}
+ const pathStatusData = this._resources.get(name);
+ if (typeof pathStatusData === 'undefined')
+ {
+ throw Object.assign(response, {error: name + ' has not been previously registered'});
+ }
+ if (pathStatusData.status !== ServerManager.ResourceStatus.REGISTERED)
+ {
+ throw Object.assign(response, {error: name + ' is already downloaded or is currently already downloading'});
+ }
+
// preload.js with forced binary for xls and xlsx:
if (['csv', 'odp', 'xls', 'xlsx'].indexOf(extension) > -1)
{
- manifest.push({id: name, src: path_data.path, type: createjs.Types.BINARY});
+ manifest.push(new createjs.LoadItem().set({
+ id: name,
+ src: pathStatusData.path,
+ type: createjs.Types.BINARY,
+ crossOrigin: 'Anonymous'
+ }));
}/* ascii .csv are adequately handled in binary format
// forced text for .csv:
else if (['csv'].indexOf(resourceExtension) > -1)
@@ -732,7 +1049,7 @@ export class ServerManager extends PsychObject
// sound files are loaded through howler.js:
else if (['mp3', 'mpeg', 'opus', 'ogg', 'oga', 'wav', 'aac', 'caf', 'm4a', 'weba', 'dolby', 'flac'].indexOf(extension) > -1)
{
- soundResources.push(name);
+ soundResources.add(name);
if (extension === 'wav')
{
@@ -743,7 +1060,11 @@ export class ServerManager extends PsychObject
// preload.js for the other extensions (download type decided by preload.js):
else
{
- manifest.push({id: name, src: path_data.path});
+ manifest.push(new createjs.LoadItem().set({
+ id: name,
+ src: pathStatusData.path,
+ crossOrigin: 'Anonymous'
+ }));
}
}
@@ -755,10 +1076,11 @@ export class ServerManager extends PsychObject
}
else
{
- if (this._nbLoadedResources === filesToDownload.size)
+ if (this._nbLoadedResources === resources.size)
{
this.setStatus(ServerManager.Status.READY);
- this.emit(ServerManager.Event.RESOURCE, {message: ServerManager.Event.DOWNLOAD_COMPLETED});
+ this.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOAD_COMPLETED});
}
}
@@ -766,33 +1088,37 @@ export class ServerManager extends PsychObject
// (*) prepare and start loading sound resources:
for (const name of soundResources)
{
- self.emit(ServerManager.Event.RESOURCE, {
+ const pathStatusData = this._resources.get(name);
+ pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING;
+ this.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.DOWNLOADING_RESOURCE,
resource: name
});
- const path_data = self._resources.get(name);
const howl = new Howl({
- src: path_data.path,
+ src: pathStatusData.path,
preload: false,
autoplay: false
});
howl.on('load', (event) =>
{
- ++self._nbLoadedResources;
- path_data.data = howl;
- // self._resources.set(resource.name, howl);
+ ++ self._nbLoadedResources;
+ pathStatusData.data = howl;
+
+ pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED;
self.emit(ServerManager.Event.RESOURCE, {
message: ServerManager.Event.RESOURCE_DOWNLOADED,
resource: name
});
- if (self._nbLoadedResources === filesToDownload.size)
+ if (self._nbLoadedResources === resources.size)
{
self.setStatus(ServerManager.Status.READY);
- self.emit(ServerManager.Event.RESOURCE, {message: ServerManager.Event.DOWNLOAD_COMPLETED});
+ self.emit(ServerManager.Event.RESOURCE, {
+ message: ServerManager.Event.DOWNLOAD_COMPLETED});
}
});
+
howl.on('loaderror', (id, error) =>
{
// throw { ...response, error: 'unable to download resource: ' + name + ' (' + util.toString(error) + ')' };
@@ -801,6 +1127,7 @@ export class ServerManager extends PsychObject
howl.load();
}
+
}
}
@@ -821,22 +1148,26 @@ ServerManager.Event = {
* Event type: resource event
*/
RESOURCE: Symbol.for('RESOURCE'),
+
/**
- * Event: resources all registered
+ * Event: resources have started to download
*/
- RESOURCES_REGISTERED: Symbol.for('RESOURCES_REGISTERED'),
+ DOWNLOADING_RESOURCES: Symbol.for('DOWNLOADING_RESOURCES'),
+
/**
- * Event: resource download has started
+ * Event: a specific resource download has started
*/
DOWNLOADING_RESOURCE: Symbol.for('DOWNLOADING_RESOURCE'),
+
/**
- * Event: resource has been downloaded
+ * Event: a specific resource has been downloaded
*/
RESOURCE_DOWNLOADED: Symbol.for('RESOURCE_DOWNLOADED'),
+
/**
- * Event: resources all downloaded
+ * Event: resources have all downloaded
*/
- DOWNLOAD_COMPLETED: Symbol.for('DOWNLOAD_COMPLETED'),
+ DOWNLOADS_COMPLETED: Symbol.for('DOWNLOAD_COMPLETED'),
/**
* Event type: status event
@@ -869,3 +1200,34 @@ ServerManager.Status = {
*/
ERROR: Symbol.for('ERROR')
};
+
+
+/**
+ * Resource status
+ *
+ * @name module:core.ServerManager#ResourceStatus
+ * @enum {Symbol}
+ * @readonly
+ * @public
+ */
+ServerManager.ResourceStatus = {
+ /**
+ * The resource has been registered.
+ */
+ REGISTERED: Symbol.for('REGISTERED'),
+
+ /**
+ * The resource is currently downloading.
+ */
+ DOWNLOADING: Symbol.for('DOWNLOADING'),
+
+ /**
+ * The resource has been downloaded.
+ */
+ DOWNLOADED: Symbol.for('DOWNLOADED'),
+
+ /**
+ * There was an error during downloading, or the resource is in an unknown state.
+ */
+ ERROR: Symbol.for('ERROR'),
+};
diff --git a/src/data/TrialHandler.js b/src/data/TrialHandler.js
index 4cba062..279cbd3 100644
--- a/src/data/TrialHandler.js
+++ b/src/data/TrialHandler.js
@@ -211,7 +211,6 @@ export class TrialHandler extends PsychObject
* @property {number} ran - whether or not the trial ran
* @property {number} finished - whether or not the trials finished
*/
-
/**
* Get a snapshot of the current internal state of the trial handler (e.g. current trial number,
* number of trial remaining).
@@ -433,7 +432,7 @@ export class TrialHandler extends PsychObject
if (['csv', 'odp', 'xls', 'xlsx'].indexOf(resourceExtension) > -1)
{
// (*) read conditions from resource:
- const resourceValue = serverManager.getResource(resourceName);
+ const resourceValue = serverManager.getResource(resourceName, true);
// Conditionally use a `TextDecoder` to reprocess .csv type input,
// which is then read in as a string
diff --git a/src/data/index.js b/src/data/index.js
index 5c62135..a56e3f0 100644
--- a/src/data/index.js
+++ b/src/data/index.js
@@ -1,2 +1,3 @@
export * from './ExperimentHandler.js';
export * from './TrialHandler.js';
+export * from './Shelf.js';
diff --git a/src/sound/AudioClip.js b/src/sound/AudioClip.js
new file mode 100644
index 0000000..10ec223
--- /dev/null
+++ b/src/sound/AudioClip.js
@@ -0,0 +1,368 @@
+/**
+ * AudioClip encapsulate an audio recording.
+ *
+ * @author Alain Pitiot and Sotiri Bakagiannis
+ * @version 2021.x
+ * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @license Distributed under the terms of the MIT License
+ */
+
+import {PsychObject} from '../util/PsychObject';
+import {PsychoJS} from '../core/PsychoJS';
+import {ExperimentHandler} from '../data/ExperimentHandler';
+import * as util from '../util/Util';
+
+
+/**
+ *
AudioClip encapsulate an audio recording.
+ *
+ * @name module:sound.AudioClip
+ * @class
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {String} [options.name= 'audioclip'] - the name used when logging messages
+ * @param {string} options.format the format for the audio file
+ * @param {number} options.sampleRateHz - the sampling rate
+ * @param {Blob} options.data - the audio data, in the given format, at the given sampling rate
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ */
+export class AudioClip extends PsychObject
+{
+
+ constructor({psychoJS, name, sampleRateHz, format, data, autoLog} = {})
+ {
+ super(psychoJS);
+
+ this._addAttribute('name', name, 'audioclip');
+ this._addAttribute('format', format);
+ this._addAttribute('sampleRateHz', sampleRateHz);
+ this._addAttribute('data', data);
+ this._addAttribute('autoLog', false, autoLog);
+ this._addAttribute('status', AudioClip.Status.CREATED);
+
+ if (this._autoLog)
+ {
+ this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
+ }
+
+ // decode the blob into an audio buffer:
+ this._decodeAudio();
+ }
+
+
+ /**
+ * Play the audio clip.
+ *
+ * @name module:sound.AudioClip#upload
+ * @function
+ * @public
+ */
+ async play()
+ {
+ this._psychoJS.logger.debug('request to play the audio clip');
+
+ // wait for the decoding to complete:
+ await this._decodeAudio();
+
+ // play the audio buffer:
+ const source = this._audioContext.createBufferSource();
+ source.buffer = this._audioBuffer;
+ source.connect(this._audioContext.destination);
+ source.start();
+ }
+
+
+ /**
+ * Upload the audio clip to the pavlovia server.
+ *
+ * @name module:sound.AudioClip#upload
+ * @function
+ * @public
+ */
+ upload()
+ {
+ this._psychoJS.logger.debug('request to upload the audio clip to pavlovia.org');
+
+ // add a format-dependent audio extension to the name:
+ const filename = this._name + 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 clip 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(filename);
+ }
+
+ // upload the data:
+ return this._psychoJS.serverManager.uploadAudio(this._data, filename);
+ }
+
+
+
+ /**
+ * Offer the audio clip to the participant as a sound file to download.
+ *
+ * @name module:sound.AudioClip#download
+ * @function
+ * @public
+ */
+ download(filename = 'audio.webm')
+ {
+ const anchor = document.createElement('a');
+ anchor.href = window.URL.createObjectURL(this._data);
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
+ }
+
+
+ /**
+ * Transcribe the audio clip.
+ *
+ * ref: https://cloud.google.com/speech-to-text/docs/reference/rest/v1/speech/recognize
+ *
+ * @param {Object} options
+ * @param engine
+ * @param {String} options.languageCode - the BCP-47 language code for the recognition,
+ * e.g. 'en-gb'
+ * @return {Promise}
+ */
+ async transcribe({engine, languageCode} = {})
+ {
+ this._psychoJS.logger.debug('request to transcribe the audio clip');
+
+ // wait for the decoding to complete:
+ await this._decodeAudio();
+
+ return new Promise(async (resolve, reject) =>
+ {
+ // convert the Float32 PCM audio data to UInt16:
+ const buffer = new ArrayBuffer(this._audioData.length * 2);
+ const uint16View = new Uint16Array(buffer);
+ for (let t = 0; t < this._audioData.length; ++t)
+ {
+ uint16View[t] = (this._audioData[t] < 0)
+ ? this._audioData[t] * 0x8000
+ : this._audioData[t] * 0x7FFF;
+ }
+
+ // encode it to base64:
+ const base64Data = this._base64ArrayBuffer(new Uint8Array(buffer));
+
+ // query the Google speech-to-text service:
+ const body = {
+ config: {
+ encoding: 'LINEAR16',
+ sampleRateHertz: this._sampleRateHz,
+ languageCode
+ },
+ audio: {
+ content: base64Data
+ },
+ };
+
+ // TODO get the key from the designer's pavlovia account
+ const GOOGLE_API_KEY = 'AIzaSyDngTi-pJcVrm_Kr2yTKV8OYLtfRN180gY';
+ const url = `https://speech.googleapis.com/v1/speech:recognize?key=${GOOGLE_API_KEY}`;
+
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify(body)
+ });
+
+ // convert the response to json:
+ const decodedResponse = await response.json();
+ this._psychoJS.logger.debug('speech.googleapis.com response:', JSON.stringify(decodedResponse));
+
+ // TODO deal with more than one results and/or alternatives
+ resolve(decodedResponse.results[0].alternatives[0]);
+ });
+ }
+
+
+ /**
+ * Decode the formatted audio data (e.g. webm) into a 32bit float PCM audio buffer.
+ *
+ * @returns {Promise}
+ * @private
+ */
+ _decodeAudio()
+ {
+ this._psychoJS.logger.debug('request to decode the data of the audio clip');
+
+ // if the audio clip is ready, the PCM audio data is available in _audioData, a Float32Array:
+ if (this._status === AudioClip.Status.READY)
+ {
+ return;
+ }
+
+
+ // if we are already decoding, wait until the process completed:
+ if (this._status === AudioClip.Status.DECODING)
+ {
+ const self = this;
+ return new Promise(function (resolve, reject)
+ {
+ self._decodingCallbacks.push(resolve);
+
+ // self._errorCallback = reject; // TODO
+ }.bind(this));
+ }
+
+
+ // otherwise, start decoding the input formatted audio data:
+ this._status = AudioClip.Status.DECODING;
+ this._audioData = null;
+ this._decodingCallbacks = [];
+
+ this._audioContext = new (window.AudioContext || window.webkitAudioContext)({
+ sampleRate: this._sampleRateHz
+ });
+
+ const reader = new window.FileReader();
+ reader.onloadend = async () =>
+ {
+ try
+ {
+ // decode the ArrayBuffer containing the formatted audio data (e.g. webm)
+ // into an audio buffer:
+ this._audioBuffer = await this._audioContext.decodeAudioData(reader.result);
+
+ // get the Float32Array containing the PCM data:
+ this._audioData = this._audioBuffer.getChannelData(0);
+
+ // we are now ready to translate and play:
+ this._status = AudioClip.Status.READY;
+
+ // resolve all the promises waiting for the decoding to complete:
+ for (const callback of this._decodingCallbacks)
+ {
+ callback();
+ }
+ }
+ catch (error)
+ {
+ console.error(error);
+
+ // TODO
+ }
+ };
+
+ reader.onerror = (error) =>
+ {
+ // TODO
+ }
+
+ reader.readAsArrayBuffer(this._data);
+ }
+
+
+ /**
+ * Convert an array buffer to a base64 string.
+ *
+ * @note this is only very lightly adapted from the folowing post of @Grantlyk:
+ * https://gist.github.com/jonleighton/958841#gistcomment-1953137
+ *
+ * the following only works for small buffers:
+ * const dataAsString = String.fromCharCode.apply(null, new Uint8Array(buffer));
+ * base64Data = window.btoa(dataAsString);
+ *
+ * @param arrayBuffer
+ * @return {string} the base64 encoded input buffer
+ */
+ _base64ArrayBuffer(arrayBuffer)
+ {
+ let base64 = '';
+ const encodings = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
+
+ const bytes = new Uint8Array(arrayBuffer);
+ const byteLength = bytes.byteLength;
+ const byteRemainder = byteLength % 3;
+ const mainLength = byteLength - byteRemainder;
+
+ let a;
+ let b;
+ let c;
+ let d;
+ let chunk;
+
+ // Main loop deals with bytes in chunks of 3
+ for (let i = 0; i < mainLength; i += 3) {
+ // Combine the three bytes into a single integer
+ chunk = (bytes[i] << 16) | (bytes[i + 1] << 8) | bytes[i + 2];
+
+ // Use bitmasks to extract 6-bit segments from the triplet
+ a = (chunk & 16515072) >> 18; // 16515072 = (2^6 - 1) << 18
+ b = (chunk & 258048) >> 12; // 258048 = (2^6 - 1) << 12
+ c = (chunk & 4032) >> 6; // 4032 = (2^6 - 1) << 6
+ d = chunk & 63; // 63 = 2^6 - 1
+
+ // Convert the raw binary segments to the appropriate ASCII encoding
+ base64 += encodings[a] + encodings[b] + encodings[c] + encodings[d];
+ }
+
+ // Deal with the remaining bytes and padding
+ if (byteRemainder === 1) {
+ chunk = bytes[mainLength];
+
+ a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2
+
+ // Set the 4 least significant bits to zero
+ b = (chunk & 3) << 4; // 3 = 2^2 - 1
+
+ base64 += `${encodings[a]}${encodings[b]}==`;
+ } else if (byteRemainder === 2) {
+ chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1];
+
+ a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10
+ b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4
+
+ // Set the 2 least significant bits to zero
+ c = (chunk & 15) << 2; // 15 = 2^4 - 1
+
+ base64 += `${encodings[a]}${encodings[b]}${encodings[c]}=`;
+ }
+
+ return base64;
+}
+
+}
+
+
+/**
+ * Recognition engines.
+ *
+ * @name module:sound.AudioClip#Engine
+ * @enum {Symbol}
+ * @readonly
+ * @public
+ */
+AudioClip.Engine = {
+ /**
+ * Google Cloud Speech-to-Text.
+ */
+ GOOGLE: Symbol.for('GOOGLE')
+};
+
+
+/**
+ * AudioClip status.
+ *
+ * @enum {Symbol}
+ * @readonly
+ * @public
+ */
+AudioClip.Status = {
+ CREATED: Symbol.for('CREATED'),
+
+ DECODING: Symbol.for('DECODING'),
+
+ READY: Symbol.for('READY')
+};
diff --git a/src/sound/Microphone.js b/src/sound/Microphone.js
new file mode 100644
index 0000000..f7ec7c3
--- /dev/null
+++ b/src/sound/Microphone.js
@@ -0,0 +1,531 @@
+/**
+ * Manager handling the recording of audio signal.
+ *
+ * @author Alain Pitiot and Sotiri Bakagiannis
+ * @version 2021.x
+ * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @license Distributed under the terms of the MIT License
+ */
+
+import {Clock} from "../util/Clock";
+import {PsychObject} from "../util/PsychObject";
+import {PsychoJS} from "../core/PsychoJS";
+import * as util from '../util/Util';
+import {ExperimentHandler} from "../data/ExperimentHandler";
+import {AudioClip} from "./AudioClip";
+
+/**
+ *
This manager handles the recording of audio signal.
+ *
+ * @name module:sound.Microphone
+ * @class
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {String} options.name - the name used when logging messages
+ * @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 or not to log
+ */
+export class Microphone extends PsychObject
+{
+
+ constructor({psychoJS, name, format, sampleRateHz, clock, autoLog} = {})
+ {
+ super(psychoJS);
+
+ 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', false, 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.
+ *
+ *
Note that it typically takes 50ms-200ms for the recording to actually starts once
+ * a request to start has been submitted.
+ *
+ * @name module:sound.Microphone#start
+ * @public
+ * @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.
+ *
+ * @name module:sound.Microphone#stop
+ * @public
+ * @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.
+ *
+ * @name module:sound.Microphone#pause
+ * @public
+ * @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.
+ *
+ *
resume has no effect if the recording was not previously paused.
+ *
+ * @name module:sound.Microphone#resume
+ * @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.
+ *
+ * @name module:sound.Microphone#flush
+ * @public
+ * @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.
+ *
+ * @name module:sound.Microphone#download
+ * @function
+ * @public
+ * @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.
+ *
+ * @name module:sound.Microphone#upload
+ * @function
+ * @public
+ * @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.uploadAudio(audioBlob, tag);
+ }
+
+
+ /**
+ * Get the current audio recording as an AudioClip in the given format.
+ *
+ * @name module:sound.Microphone#getRecording
+ * @function
+ * @public
+ * @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.
+ *
+ *
Changes to the settings require the recording to stop and be re-started.
+ *
+ * @name module:sound.Microphone#_onChange
+ * @function
+ * @protected
+ */
+ _onChange()
+ {
+ if (this._status === PsychoJS.Status.STARTED)
+ {
+ this.stop();
+ }
+
+ this._prepareRecording();
+
+ this.start();
+ }
+
+
+ /**
+ * Prepare the recording.
+ *
+ * @name module:sound.Microphone#_prepareRecording
+ * @function
+ * @protected
+ */
+ async _prepareRecording()
+ {
+ // empty the audio buffer:
+ this._audioBuffer = [];
+ this._recorder = null;
+
+ // // create an audio context (mostly used for getRecording() ):
+ // this._audioContext = new (window.AudioContext || window.webkitAudioContext)({
+ // sampleRate: this._sampleRateHz
+ // });
+
+ // 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;
+ };
+
+ }
+
+}
+
+
diff --git a/src/sound/index.js b/src/sound/index.js
index b0b89ad..3b96d07 100644
--- a/src/sound/index.js
+++ b/src/sound/index.js
@@ -2,3 +2,7 @@ export * from './Sound.js';
export * from './SoundPlayer.js';
export * from './TonePlayer.js';
export * from './TrackPlayer.js';
+export * from './Microphone.js';
+export * from './AudioClip.js';
+export * from './Transcriber.js';
+
diff --git a/src/util/PsychObject.js b/src/util/PsychObject.js
index 9c31c55..18acaa1 100644
--- a/src/util/PsychObject.js
+++ b/src/util/PsychObject.js
@@ -349,10 +349,10 @@ export class PsychObject extends EventEmitter
* @protected
* @param {string} name - the name of the attribute
* @param {object} value - the value of the attribute
- * @param {object} defaultValue - the default value for the attribute
- * @param {function} onChange - function called upon changes to the attribute value
+ * @param {object} [defaultValue] - the default value for the attribute
+ * @param {function} [onChange] - function called upon changes to the attribute value
*/
- _addAttribute(name, value, defaultValue, onChange = () => {})
+ _addAttribute(name, value, defaultValue = undefined, onChange = () => {})
{
const getPropertyName = 'get' + name[0].toUpperCase() + name.substr(1);
if (typeof this[getPropertyName] === 'undefined')
diff --git a/src/util/Scheduler.js b/src/util/Scheduler.js
index 24965f6..dbf93e3 100644
--- a/src/util/Scheduler.js
+++ b/src/util/Scheduler.js
@@ -134,10 +134,10 @@ export class Scheduler
* @name module:util.Scheduler#start
* @public
*/
- start()
+ async start()
{
const self = this;
- let update = (timestamp) =>
+ const update = async (timestamp) =>
{
// stop the animation if need be:
if (self._stopAtNextUpdate)
@@ -149,7 +149,7 @@ export class Scheduler
// self._psychoJS.window._writeLogOnFlip();
// run the next scheduled tasks until a scene render is requested:
- const state = self._runNextTasks();
+ const state = await self._runNextTasks();
if (state === Scheduler.Event.QUIT)
{
self._status = Scheduler.Status.STOPPED;
@@ -195,7 +195,7 @@ export class Scheduler
* @private
* @return {module:util.Scheduler#Event} the state of the scheduler after the last task ran
*/
- _runNextTasks()
+ async _runNextTasks()
{
this._status = Scheduler.Status.RUNNING;
@@ -234,14 +234,14 @@ export class Scheduler
// if the current task is a function, we run it:
if (this._currentTask instanceof Function)
{
- state = this._currentTask(...this._currentArgs);
+ state = await this._currentTask(...this._currentArgs);
}
// otherwise, we assume that the current task is a scheduler and we run its tasks until a rendering
// of the scene is required.
// note: "if (this._currentTask instanceof Scheduler)" does not work because of CORS...
else
{
- state = this._currentTask._runNextTasks();
+ state = await this._currentTask._runNextTasks();
if (state === Scheduler.Event.QUIT)
{
// if the experiment has not ended, we move onto the next task:
diff --git a/src/util/Util.js b/src/util/Util.js
index 338333d..d571442 100644
--- a/src/util/Util.js
+++ b/src/util/Util.js
@@ -1,7 +1,7 @@
/**
* Various utilities.
*
- * @author Alain Pitiot
+ * @authors Alain Pitiot, Sotiri Bakagiannis, Thomas Pronk
* @version 2021.1.4
* @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
* @license Distributed under the terms of the MIT License
@@ -958,12 +958,12 @@ export function offerDataForDownload(filename, data, type)
}
else
{
- let elem = window.document.createElement('a');
- elem.href = window.URL.createObjectURL(blob);
- elem.download = filename;
- document.body.appendChild(elem);
- elem.click();
- document.body.removeChild(elem);
+ const anchor = document.createElement('a');
+ anchor.href = window.URL.createObjectURL(blob);
+ anchor.download = filename;
+ document.body.appendChild(anchor);
+ anchor.click();
+ document.body.removeChild(anchor);
}
}
@@ -1053,7 +1053,8 @@ export function randint(min = 0, max)
}
-/** Round to a certain number of decimal places.
+/**
+ * Round to a certain number of decimal places.
*
* This is the Crib Sheet provided solution, but please note that as of 2020 the most popular SO answer is different.
*
@@ -1072,25 +1073,380 @@ export function round(input, places = 0)
/**
- * Calculate a total for all numeric input array elements.
+ * Calculate the sum of the elements in the input array.
+ *
+ * If 'input' is not an array, then we return start.
*
* @name module:util.sum
* @function
* @public
- * @param {array} inputMaybe - a list of numbers to add up
- * @returns {number} numeric input entries added up
+ * @param {array} input - an array of numbers, or of objects that can be cast into a number, e.g. ['1', 2.5, 3e1]
+ * @param {number} start - value added to the sum of numbers (a la Python)
+ * @returns {number} the sum of the elements in the array + start
*/
-export function sum(inputMaybe = [])
+export function sum(input = [], start = 0)
{
- // Cover against null input
- const input = Array.isArray(inputMaybe) ? inputMaybe : [];
+ if (!Array.isArray(input))
+ {
+ return start;
+ }
+
const add = (a, b) => a + b;
return input
- // Type cast everything as a number
+ // type cast everything as a number
.map(value => Number(value))
- // Drop non numeric looking entries, needs transpiling for IE11
+ // drop non numeric looking entries (note: needs transpiling for IE11)
.filter(value => Number.isNaN(value) === false)
- // Add up each successive entry starting from naught
- .reduce(add, 0);
+ // add up each successive entry, starting with start
+ .reduce(add, start);
+}
+
+
+/**
+ * Calculate the average of the elements in the input array.
+ *
+ * If 'input' is not an array, or if it is an empty array, then we return 0.
+ *
+ * @name module:util.average
+ * @function
+ * @public
+ * @param {array} input - an array of numbers, or of objects that can be cast into a number, e.g. ['1', 2.5, 3e1]
+ * @returns {number} the average of the elements in the array
+ */
+export function average(input = [])
+{
+ if (!Array.isArray(input))
+ {
+ return 0;
+ }
+
+ if (input.length === 0)
+ {
+ return 0;
+ }
+
+ return sum(input, 0) / input.length;
+}
+
+
+/**
+ * Sort the elements of the input array, in increasing alphabetical or numerical order.
+ *
+ * @name module:util.sort
+ * @function
+ * @public
+ * @param {array} input - an array of numbers or of strings
+ * @return {array} the sorted array
+ * @throws if 'input' is not an array, or if its elements are not consistent in types, or if they are not all either numbers or
+ * strings
+ */
+export function sort(input)
+{
+ const response = {
+ origin: 'util.sort',
+ context: 'when sorting the elements of an array'
+ };
+
+ try
+ {
+ if (!Array.isArray(input))
+ {
+ throw 'the input argument should be an array';
+ }
+
+ // check the type and consistency of the array, and sort it accordingly:
+ const isNumberArray = input.every(element => typeof element === "number");
+ if (isNumberArray)
+ {
+ return input.sort((a, b) => (a - b));
+ }
+
+ const isStringArray = input.every(element => typeof element === "string");
+ if (isStringArray)
+ {
+ return input.sort();
+ }
+
+ throw 'the input array should either consist entirely of strings or of numbers';
+ }
+ catch (error)
+ {
+ throw {...response, error};
+ }
+ }
+
+
+/**
+ * Create a sequence of integers.
+ *
+ * The sequence is such that the integer at index i is: start + step * i, with i >= 0 and start + step * i < stop
+ *
+ *
Note: this is a JavaScript implement of the Python range function, which explains the unusual management of arguments.
+ *
+ * @name module:util.range
+ * @function
+ * @public
+ * @param {Number} [start=0] - the value of start
+ * @param {Number} stop - the value of stop
+ * @param {Number} [step=1] - the value of step
+ * @returns {Array.{Number}} the range as an array of numbers
+ */
+export function range(...args)
+{
+ const response = {
+ origin: 'util.range',
+ context: 'when building a range of numbers'
+ };
+
+ try
+ {
+ let start, stop, step;
+
+ switch (args.length)
+ {
+ case 0:
+ throw 'at least one argument is required';
+
+ // 1 arg: start = 0, stop = arg, step = 1
+ case 1:
+ start = 0;
+ stop = args[0];
+ step = 1;
+ break;
+
+ // 2 args: start = arg1, stop = arg2
+ case 2:
+ start = args[0];
+ stop = args[1];
+ step = 1;
+ break;
+
+ // 3 args:
+ case 3:
+ start = args[0];
+ stop = args[1];
+ step = args[2];
+ break;
+
+ default:
+ throw 'range requires at least one and at most 3 arguments'
+ }
+
+ if (!Number.isInteger(start)) {
+ throw 'start should be an integer';
+ }
+ if (!Number.isInteger(stop)) {
+ throw 'stop should be an integer';
+ }
+ if (!Number.isInteger(step)) {
+ throw 'step should be an integer';
+ }
+
+ // if start >= stop, the range is empty:
+ if (start >= stop)
+ {
+ return [];
+ }
+
+ let result = [];
+ for (let i = start; i < stop; i += step)
+ {
+ result.push(i);
+ }
+ return result;
+ }
+ catch (error)
+ {
+ throw {...response, error};
+ }
+}
+
+
+/**
+ * Create a boolean function that compares an input element to the given value.
+ *
+ * @name module:util._match
+ * @function
+ * @private
+ * @param {Number|string|object|null} value the matching value
+ * @return {} a function that compares an input element to the given value
+ */
+function _match(value)
+{
+ const response = {
+ origin: 'util._match',
+ context: 'when creating a function that compares an input element to the given value'
+ };
+
+ try
+ {
+ // function:
+ if (typeof value === 'function')
+ {
+ throw 'the value cannot be a function';
+ }
+
+ // NaN:
+ if (Number.isNaN(value))
+ {
+ return (element) => Number.isNaN(element);
+ }
+
+ // null:
+ if (value === null)
+ {
+ return (element) => element === null;
+ }
+
+ // object: we compare using JSON.stringify
+ if (typeof value === 'object')
+ {
+ const jsonValue = JSON.stringify(value);
+ if (typeof jsonValue === 'undefined')
+ {
+ throw 'value could not be converted to a JSON string';
+ }
+
+ return (element) =>
+ {
+ const jsonElement = JSON.stringify(element);
+ return (jsonElement === jsonValue);
+ }
+ }
+
+ // everything else:
+ return (element) => element === value;
+ }
+ catch (error)
+ {
+ throw {...response, error};
+ }
+ }
+
+
+ /**
+ * Count the number of elements in the input array that match the given value.
+ *
+ *
Note: count is able to handle NaN, null, as well as any value convertible to a JSON string.
+ *
+ * @name module:util.count
+ * @function
+ * @public
+ * @param {array} input the input array
+ * @param {Number|string|object|null} value the matching value
+ * @returns the number of matching elements
+ */
+ export function count(input, value)
+ {
+ const response = {
+ origin: 'util.count',
+ context: 'when counting how many elements in the input array match the given value'
+ };
+
+ try
+ {
+ if (!Array.isArray(input))
+ {
+ throw 'the input argument should be an array';
+ }
+
+ const match = _match(value);
+
+ let nbMatches = 0;
+ input.forEach(element =>
+ {
+ if (match(element))
+ {
+ ++ nbMatches;
+ }
+ });
+ return nbMatches;
+ }
+ catch (error)
+ {
+ throw {...response, error};
+ }
+ }
+
+
+ /**
+ * Get the index in the input array of the first element that matches the given value.
+ *
+ *
Note: index is able to handle NaN, null, as well as any value convertible to a JSON string.
+ *
+ * @name module:util.index
+ * @function
+ * @public
+ * @param {array} input the input array
+ * @param {Number|string|object|null} value the matching value
+ * @returns the index of the first element that matches the value
+ * @throws if the input array does not contain any matching element
+ */
+ export function index(input, value)
+ {
+ const response = {
+ origin: 'util.index',
+ context: 'when getting the index in the input array of the first element that matches the given value'
+ };
+
+ try
+ {
+ if (!Array.isArray(input))
+ {
+ throw 'the input argument should be an array';
+ }
+
+ const match = _match(value);
+ const index = input.findIndex(match);
+
+ if (index === -1)
+ {
+ throw 'no element in the input array matches the value';
+ }
+
+ return index;
+
+ }
+ catch (error)
+ {
+ throw {...response, error};
+ }
+ }
+
+
+/**
+ * Return the file extension corresponding to an audio mime type.
+ * If the provided mimeType is not a string (e.g. null, undefined, an array)
+ * or unknown, then '.dat' is returned, instead of throwing an exception.
+ *
+ * @name module:util.extensionFromMimeType
+ * @function
+ * @public
+ * @param {string} mimeType the MIME type, e.g. 'audio/webm;codecs=opus'
+ * @return {string} the corresponding file extension, e.g. '.webm'
+ */
+export function extensionFromMimeType(mimeType)
+{
+ if (typeof mimeType !== 'string')
+ {
+ return '.dat';
+ }
+
+ if (mimeType.indexOf('audio/webm') === 0)
+ {
+ return '.webm';
+ }
+
+ if (mimeType.indexOf('audio/ogg') === 0)
+ {
+ return '.ogg';
+ }
+
+ if (mimeType.indexOf('audio/wav') === 0)
+ {
+ return '.wav';
+ }
+
+ return '.dat';
}