/**
- * 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'),
+};
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.
-Maintainers
-Alain Pitiot - @apitiot
-Contributors
-The PsychoJS library was initially written by Ilixa with support from the Wellcome Trust. -It is now a collaborative effort, supported by the Chan Zuckerberg Initiative (2020-2021) and Open Science Tools (2020-):
--
-
- Alain Pitiot - @apitiot -
- Sotiri Bakagiannis - @thewhodidthis -
- Jonathan Peirce - @peircej -
- Thomas Pronk - @tpronk -
- Hiroyuki Sogo - @hsogo -
- Sijia Zhao - @sijiazhao -
The PsychoPy Builder's javascript code generator is built and maintained by the creators of PsychoPy at the University of Nottingham, with support from the Wellcome Trust (2017-2019), from the Chan Zuckerberg Initiative (2020-2021), and from Open Science Tools (2020-):
--
-
- Jonathan Peirce - @peircej -
- David Bridges - @dvbridges -
- Todd Parsons - @TEParsons -
License
-This project is licensed under the MIT License - see the LICENSE.md file for details.
Home
+ + + + + + + + ++ + + + + + + + + + + + + + @@ -88,16 +50,16 @@ It is now a collaborative effort, supported by the Home
Modules
Classes
- BuilderKeyResponse
- EventManager
- GUI
- Keyboard
- KeyPress
- Logger
- MinimalStim
- Mouse
- PsychoJS
- ServerManager
- Window
- ExperimentHandler
- TrialHandler
- Sound
- TonePlayer
- TrackPlayer
- Clock
- Color
- CountdownTimer
- EventEmitter
- MixinBuilder
- MonotonicClock
- PsychObject
- Scheduler
- Form
- ImageStim
- MovieStim
- Polygon
- Rect
- ShapeStim
- Slider
- TextBox
- TextStim
- VisualStim
- TextInput
Interfaces
Mixins
+Home
Modules
Classes
- BuilderKeyResponse
- EventManager
- GUI
- Keyboard
- KeyPress
- Logger
- MinimalStim
- Mouse
- PsychoJS
- ServerManager
- Window
- ExperimentHandler
- Shelf
- TrialHandler
- AudioClip
- Microphone
- Sound
- TonePlayer
- TrackPlayer
- Transcriber
- Transcript
- Clock
- Color
- CountdownTimer
- EventEmitter
- MixinBuilder
- MonotonicClock
- PsychObject
- Scheduler
- Form
- ImageStim
- MovieStim
- Polygon
- Rect
- ShapeStim
- Slider
- TextBox
- TextStim
- VisualStim
- TextInput