From a0eb577d1964f91c60ae8354bb4a9030dea798d2 Mon Sep 17 00:00:00 2001 From: lgtst Date: Tue, 18 Jan 2022 00:57:25 +0300 Subject: [PATCH 01/92] Started Implementing GratingStim; --- src/visual/GratingStim.js | 258 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 src/visual/GratingStim.js diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js new file mode 100644 index 00000000..3c3e310b --- /dev/null +++ b/src/visual/GratingStim.js @@ -0,0 +1,258 @@ +/** + * Grating Stimulus. + * + * @author Alain Pitiot + * @version 2021.2.0 + * @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 + */ + + + +/** + * Grating Stimulus. + * + * @name module:visual.GratingStim + * @class + * @extends VisualStim + * @mixes ColorMixin + * @param {Object} options + * @param {String} options.name - the name used when logging messages from this stimulus + * @param {Window} options.win - the associated Window + * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image + * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus + * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position + * @param {number} [options.ori= 0.0] - the orientation (in degrees) + * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) + * @param {Color} [options.color= 'white'] the background color + * @param {number} [options.opacity= 1.0] - the opacity + * @param {number} [options.contrast= 1.0] - the contrast + * @param {number} [options.depth= 0] - the depth (i.e. the z order) + * @param {number} [options.texRes= 128] - the resolution of the text + * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated + * @param {boolean} [options.flipHoriz= false] - whether or not to flip horizontally + * @param {boolean} [options.flipVert= false] - whether or not to flip vertically + * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip + * @param {boolean} [options.autoLog= false] - whether or not to log + */ + + // win, + // tex="sin", + // mask="none", + // units="", + // pos=(0.0, 0.0), + // size=None, + // sf=None, + // ori=0.0, + // phase=(0.0, 0.0), + // texRes=128, + // rgb=None, + // dkl=None, + // lms=None, + // color=(1.0, 1.0, 1.0), + // colorSpace='rgb', + // contrast=1.0, + // opacity=None, + // depth=0, + // rgbPedestal=(0.0, 0.0, 0.0), + // interpolate=False, + // blendmode='avg', + // name=None, + // autoLog=None, + // autoDraw=False, + // maskParams=None) +export class GratingStim extends util.mix(VisualStim).with(ColorMixin) +{ + constructor({ + name, + tex, + win, + mask, + pos, + units, + sf, + ori, + phase, + size, + rgb, + dkl, + lms, + color, + colorSpace, + opacity, + contrast, + texRes, + depth, + rgbPedestal, + interpolate, + blendmode, + autoDraw, + autoLog, + maskParams + } = {}) + { + super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); + + this._addAttribute( + "tex", + tex, + ); + + this._addAttribute( + "mask", + mask, + ); + this._addAttribute( + "color", + color, + "white", + this._onChange(true, false), + ); + this._addAttribute( + "contrast", + contrast, + 1.0, + this._onChange(true, false), + ); + this._addAttribute( + "texRes", + texRes, + 128, + this._onChange(true, false), + ); + this._addAttribute( + "interpolate", + interpolate, + false, + this._onChange(true, false), + ); + this._addAttribute( + "flipHoriz", + flipHoriz, + false, + this._onChange(false, false), + ); + this._addAttribute( + "flipVert", + flipVert, + false, + this._onChange(false, false), + ); + + // estimate the bounding box: + this._estimateBoundingBox(); + + if (this._autoLog) + { + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + } + } + + /** + * Setter for the image attribute. + * + * @name module:visual.GratingStim#setImage + * @public + * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image + * @param {boolean} [log= false] - whether of not to log + */ + setTex(tex, log = false) + { + const response = { + origin: "GratingStim.setTex", + context: "when setting the texture of GratingStim: " + this._name, + }; + + try + { + // tex is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem + if (typeof tex === "undefined") + { + this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); + } + else + { + // tex is a string: it should be the name of a resource, which we load + if (typeof tex === "string") + { + tex = this.psychoJS.serverManager.getResource(tex); + } + + // tex should now be an actual HTMLImageElement: we raise an error if it is not + if (!(tex instanceof HTMLImageElement)) + { + throw "the argument: " + tex.toString() + ' is not an image" }'; + } + + this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: src= " + tex.src + ", size= " + tex.width + "x" + tex.height); + } + + const existingImage = this.getImage(); + const hasChanged = existingImage ? existingImage.src !== tex.src : true; + + this._setAttribute("tex", tex, log); + + if (hasChanged) + { + this._onChange(true, true)(); + } + } + catch (error) + { + throw Object.assign(response, { error }); + } + } + + /** + * Setter for the mask attribute. + * + * @name module:visual.GratingStim#setMask + * @public + * @param {HTMLImageElement | string} mask - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {boolean} [log= false] - whether of not to log + */ + setMask(mask, log = false) + { + const response = { + origin: "GratingStim.setMask", + context: "when setting the mask of GratingStim: " + this._name, + }; + + try + { + // mask is undefined: that's fine but we raise a warning in case this is a sympton of an actual problem + if (typeof mask === "undefined") + { + this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); + } + else + { + // mask is a string: it should be the name of a resource, which we load + if (typeof mask === "string") + { + mask = this.psychoJS.serverManager.getResource(mask); + } + + // mask should now be an actual HTMLImageElement: we raise an error if it is not + if (!(mask instanceof HTMLImageElement)) + { + throw "the argument: " + mask.toString() + ' is not an image" }'; + } + + this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height); + } + + this._setAttribute("mask", mask, log); + + this._onChange(true, false)(); + } + catch (error) + { + throw Object.assign(response, { error }); + } + } +} From e050eaf906349e12cf5dfeadc2295e54630458af Mon Sep 17 00:00:00 2001 From: lgtst Date: Wed, 26 Jan 2022 00:31:03 +0300 Subject: [PATCH 02/92] GratingStim implementation first take; --- src/visual/GratingStim.js | 297 +++++++++++++++++++++++----- src/visual/index.js | 1 + src/visual/shaders/defaultQuad.vert | 15 ++ src/visual/shaders/gaussShader.frag | 17 ++ src/visual/shaders/sinShader.frag | 15 ++ 5 files changed, 300 insertions(+), 45 deletions(-) create mode 100644 src/visual/shaders/defaultQuad.vert create mode 100644 src/visual/shaders/gaussShader.frag create mode 100644 src/visual/shaders/sinShader.frag diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index 3c3e310b..f5f67181 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -7,7 +7,29 @@ * @license Distributed under the terms of the MIT License */ +import * as PIXI from "pixi.js-legacy"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; +import defaultQuadVert from './shaders/defaultQuad.vert'; +import sinShader from './shaders/sinShader.frag'; +import gaussShader from './shaders/gaussShader.frag'; +const DEFINED_FUNCTIONS = { + sin: sinShader, + sqr: undefined, + saw: undefined, + tri: undefined, + sinXsin: undefined, + sqrXsqr: undefined, + circle: undefined, + gauss: gaussShader, + cross: undefined, + radRamp: undefined, + raisedCos: undefined +}; /** * Grating Stimulus. @@ -32,39 +54,37 @@ * @param {number} [options.depth= 0] - the depth (i.e. the z order) * @param {number} [options.texRes= 128] - the resolution of the text * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated - * @param {boolean} [options.flipHoriz= false] - whether or not to flip horizontally - * @param {boolean} [options.flipVert= false] - whether or not to flip vertically * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ - // win, - // tex="sin", - // mask="none", - // units="", - // pos=(0.0, 0.0), - // size=None, - // sf=None, - // ori=0.0, - // phase=(0.0, 0.0), - // texRes=128, - // rgb=None, - // dkl=None, - // lms=None, - // color=(1.0, 1.0, 1.0), - // colorSpace='rgb', - // contrast=1.0, - // opacity=None, - // depth=0, - // rgbPedestal=(0.0, 0.0, 0.0), - // interpolate=False, - // blendmode='avg', - // name=None, - // autoLog=None, - // autoDraw=False, - // maskParams=None) export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { + // win, + // tex="sin", + // mask="none", + // units="", + // pos=(0.0, 0.0), + // size=None, + // sf=None, + // ori=0.0, + // phase=(0.0, 0.0), + // texRes=128, + // rgb=None, + // dkl=None, + // lms=None, + // color=(1.0, 1.0, 1.0), + // colorSpace='rgb', + // contrast=1.0, + // opacity=None, + // depth=0, + // rgbPedestal=(0.0, 0.0, 0.0), + // interpolate=False, + // blendmode='avg', + // name=None, + // autoLog=None, + // autoDraw=False, + // maskParams=None) constructor({ name, tex, @@ -72,7 +92,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) mask, pos, units, - sf, + spatialFrequency = 10., ori, phase, size, @@ -99,11 +119,22 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) "tex", tex, ); - this._addAttribute( "mask", mask, ); + this._addAttribute( + "spatialFrequency", + spatialFrequency, + 10., + this._onChange(true, false) + ); + this._addAttribute( + "phase", + phase, + 0., + this._onChange(true, false) + ); this._addAttribute( "color", color, @@ -128,18 +159,6 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) false, this._onChange(true, false), ); - this._addAttribute( - "flipHoriz", - flipHoriz, - false, - this._onChange(false, false), - ); - this._addAttribute( - "flipVert", - flipVert, - false, - this._onChange(false, false), - ); // estimate the bounding box: this._estimateBoundingBox(); @@ -162,17 +181,26 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { const response = { origin: "GratingStim.setTex", - context: "when setting the texture of GratingStim: " + this._name, + context: "when setting the tex of GratingStim: " + this._name, }; try { + let hasChanged = false; + // tex is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem if (typeof tex === "undefined") { this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); } + else if (DEFINED_FUNCTIONS[tex] !== undefined) + { + // tex is a string and it is one of predefined functions available in shaders + this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); + const existingImage = this.getTex(); + hasChanged = existingImage ? existingImage !== tex : true; + } else { // tex is a string: it should be the name of a resource, which we load @@ -188,11 +216,10 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: src= " + tex.src + ", size= " + tex.width + "x" + tex.height); + const existingImage = this.getTex(); + hasChanged = existingImage ? existingImage.src !== tex.src : true; } - const existingImage = this.getImage(); - const hasChanged = existingImage ? existingImage.src !== tex.src : true; - this._setAttribute("tex", tex, log); if (hasChanged) @@ -229,6 +256,11 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); } + else if (DEFINED_FUNCTIONS[mask] !== undefined) + { + // mask is a string and it is one of predefined functions available in shaders + this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask); + } else { // mask is a string: it should be the name of a resource, which we load @@ -255,4 +287,179 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) throw Object.assign(response, { error }); } } + + /** + * Get the size of the display image, which is either that of the ImageStim or that of the image + * it contains. + * + * @name module:visual.ImageStim#_getDisplaySize + * @private + * @return {number[]} the size of the displayed image + */ + _getDisplaySize() + { + let displaySize = this.size; + + if (typeof displaySize === "undefined") + { + // use the size of the pixi element, if we have access to it: + if (typeof this._pixi !== "undefined" && this._pixi.width > 0) + { + const pixiContainerSize = [this._pixi.width, this._pixi.height]; + displaySize = util.to_unit(pixiContainerSize, "pix", this.win, this.units); + } + } + + return displaySize; + } + + /** + * Estimate the bounding box. + * + * @name module:visual.ImageStim#_estimateBoundingBox + * @function + * @override + * @protected + */ + _estimateBoundingBox() + { + const size = this._getDisplaySize(); + if (typeof size !== "undefined") + { + this._boundingBox = new PIXI.Rectangle( + this._pos[0] - size[0] / 2, + this._pos[1] - size[1] / 2, + size[0], + size[1], + ); + } + + // TODO take the orientation into account + } + + _getPixiMeshFromPredefinedShaders (funcName = '', uniforms = {}) { + const geometry = new PIXI.Geometry(); + geometry.addAttribute( + 'aVertexPosition', + [ + 0, 0, + 256, 0, + 256, 256, + 0, 256 + ], + 2 + ); + geometry.addAttribute( + 'aUvs', + [0, 0, 1, 0, 1, 1, 0, 1], + 2 + ); + geometry.addIndex([0, 1, 2, 0, 2, 3]); + const vertexSrc = defaultQuadVert; + const fragmentSrc = DEFINED_FUNCTIONS[funcName]; + const uniformsFinal = Object.assign(uniforms, { + // for future default uniforms + }); + const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); + return new PIXI.Mesh(geometry, shader); + } + + /** + * Update the stimulus, if necessary. + * + * @name module:visual.ImageStim#_updateIfNeeded + * @private + */ + _updateIfNeeded() + { + if (!this._needUpdate) + { + return; + } + this._needUpdate = false; + + // update the PIXI representation, if need be: + if (this._needPixiUpdate) + { + this._needPixiUpdate = false; + if (typeof this._pixi !== "undefined") + { + this._pixi.destroy(true); + } + this._pixi = undefined; + + // no image to draw: return immediately + if (typeof this._tex === "undefined") + { + return; + } + + if (this._tex instanceof HTMLImageElement) + { + this._pixi = PIXI.Sprite.from(this._tex); + } + else + { + this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { + uFreq: this.spatialFrequency, + uPhase: this.phase + }); + } + this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5); + + // add a mask if need be: + if (typeof this._mask !== "undefined") + { + if (this._mask instanceof HTMLImageElement) + { + this._pixi.mask = PIXI.Sprite.from(this._mask); + this._pixi.addChild(this._pixi.mask); + } + else + { + // for some reason setting PIXI.Mesh as .mask doesn't do anything, + // rendering mask to texture for further use. + const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask); + const rt = PIXI.RenderTexture.create({ + width: 256, + height: 256 + }); + this.win._renderer.render(maskMesh, { + renderTexture: rt + }); + const maskSprite = new PIXI.Sprite.from(rt); + this._pixi.mask = maskSprite; + this._pixi.addChild(maskSprite); + } + } + + // since _pixi.width may not be immediately available but the rest of the code needs its value + // we arrange for repeated calls to _updateIfNeeded until we have a width: + if (this._pixi.width === 0) + { + this._needUpdate = true; + this._needPixiUpdate = true; + return; + } + } + + this._pixi.zIndex = this._depth; + this._pixi.alpha = this.opacity; + + // set the scale: + const displaySize = this._getDisplaySize(); + const size_px = util.to_px(displaySize, this.units, this.win); + const scaleX = size_px[0] / this._pixi.width; + const scaleY = size_px[1] / this._pixi.height; + this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; + this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; + + // set the position, rotation, and anchor (image centered on pos): + let pos = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.position.set(pos.x, pos.y); + this._pixi.rotation = this.ori * Math.PI / 180; + + // re-estimate the bounding box, as the texture's width may now be available: + this._estimateBoundingBox(); + } } diff --git a/src/visual/index.js b/src/visual/index.js index 2794a47f..445e7548 100644 --- a/src/visual/index.js +++ b/src/visual/index.js @@ -1,6 +1,7 @@ export * from "./ButtonStim.js"; export * from "./Form.js"; export * from "./ImageStim.js"; +export * from "./GratingStim.js"; export * from "./MovieStim.js"; export * from "./Polygon.js"; export * from "./Rect.js"; diff --git a/src/visual/shaders/defaultQuad.vert b/src/visual/shaders/defaultQuad.vert new file mode 100644 index 00000000..1f2ea67d --- /dev/null +++ b/src/visual/shaders/defaultQuad.vert @@ -0,0 +1,15 @@ +#version 300 es + +precision mediump float; + +in vec2 aVertexPosition; +in vec2 aUvs; +out vec2 vUvs; + +uniform mat3 translationMatrix; +uniform mat3 projectionMatrix; + +void main() { + vUvs = aUvs; + gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0); +} diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag new file mode 100644 index 00000000..17543f62 --- /dev/null +++ b/src/visual/shaders/gaussShader.frag @@ -0,0 +1,17 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 + +float gauss(float x) { + return exp(-(x * x) * 20.); +} + +void main() { + vec2 uv = vUvs; + float g = gauss(uv.x - .5) * gauss(uv.y - .5); + shaderOut = vec4(vec3(g), 1.); +} diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag new file mode 100644 index 00000000..9775d64f --- /dev/null +++ b/src/visual/shaders/sinShader.frag @@ -0,0 +1,15 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float s = sin(uFreq * uv.x * 2. * M_PI + uPhase); + shaderOut = vec4(vec3(s), 1.0); +} From e4eb6f04c897dbb067846890c7bd0f696ab0c2ad Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 28 Jan 2022 16:13:25 +0300 Subject: [PATCH 03/92] sin range chnged to [0, 1] for proper gabor patch; --- src/visual/shaders/sinShader.frag | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index 9775d64f..e4a78139 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -7,9 +7,10 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uFreq; uniform float uPhase; +uniform sampler2D uMaskTex; void main() { vec2 uv = vUvs; float s = sin(uFreq * uv.x * 2. * M_PI + uPhase); - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(.5 + .5 * vec3(s), 1.0); } From 120060cbab1d30b4197678790715fd5d31252fec Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 28 Jan 2022 18:06:28 +0300 Subject: [PATCH 04/92] removed unused uniform uMaskTex; --- src/visual/shaders/sinShader.frag | 1 - 1 file changed, 1 deletion(-) diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index e4a78139..abdd5299 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -7,7 +7,6 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uFreq; uniform float uPhase; -uniform sampler2D uMaskTex; void main() { vec2 uv = vUvs; From f7e4b2ec53df3bb94d125e785973bf1bb9209df5 Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 28 Jan 2022 19:33:05 +0300 Subject: [PATCH 05/92] gamma correction introduced; --- package.json | 1 + src/core/PsychoJS.js | 2 ++ src/core/Window.js | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/package.json b/package.json index 8f9f2d82..6006e5ae 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "start": "npm run build" }, "dependencies": { + "@pixi/filter-adjustment": "^4.1.3", "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index 922ff0f8..4aaa176b 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -218,6 +218,7 @@ export class PsychoJS name, fullscr, color, + gamma, units, waitBlanking, autoLog, @@ -239,6 +240,7 @@ export class PsychoJS name, fullscr, color, + gamma, units, waitBlanking, autoLog, diff --git a/src/core/Window.js b/src/core/Window.js index 7b11cc6d..a0f0c9d3 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -8,6 +8,7 @@ */ import * as PIXI from "pixi.js-legacy"; +import {AdjustmentFilter} from "@pixi/filter-adjustment"; import { MonotonicClock } from "../util/Clock.js"; import { Color } from "../util/Color.js"; import { PsychObject } from "../util/PsychObject.js"; @@ -25,6 +26,7 @@ import { Logger } from "./Logger.js"; * @param {string} [options.name] the name of the window * @param {boolean} [options.fullscr= false] whether or not to go fullscreen * @param {Color} [options.color= Color('black')] the background color of the window + * @param {number} [options.gamma= 1] sets the delimiter for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) * @param {string} [options.units= 'pix'] the units of the window * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done * before flipping @@ -49,6 +51,7 @@ export class Window extends PsychObject name, fullscr = false, color = new Color("black"), + gamma = 1, units = "pix", waitBlanking = false, autoLog = true, @@ -64,6 +67,7 @@ export class Window extends PsychObject this._addAttribute("fullscr", fullscr); this._addAttribute("color", color); + this._addAttribute("gamma", gamma); this._addAttribute("units", units); this._addAttribute("waitBlanking", waitBlanking); this._addAttribute("autoLog", autoLog); @@ -428,6 +432,9 @@ export class Window extends PsychObject // create a top-level PIXI container: this._rootContainer = new PIXI.Container(); this._rootContainer.interactive = true; + this._rootContainer.filters = [new AdjustmentFilter({ + gamma: this.gamma + })]; // set the initial size of the PIXI renderer and the position of the root container: Window._resizePixiRenderer(this); From 3594405efe5c133807fb983599bbc0f4c93b1cea Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 28 Jan 2022 23:51:47 +0300 Subject: [PATCH 06/92] added onChange() callback to properly reflect changes of the _gamma value in adjustmentFilter; --- src/core/Window.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/core/Window.js b/src/core/Window.js index a0f0c9d3..cf27c937 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -62,12 +62,19 @@ export class Window extends PsychObject // messages to be logged at the next "flip": this._msgToBeLogged = []; + // storing AdjustmentFilter instance to access later; + this._adjustmentFilter = new AdjustmentFilter({ + gamma + }); + // list of all elements, in the order they are currently drawn: this._drawList = []; this._addAttribute("fullscr", fullscr); this._addAttribute("color", color); - this._addAttribute("gamma", gamma); + this._addAttribute("gamma", gamma, 1, () => { + this._adjustmentFilter.gamma = this._gamma; + }); this._addAttribute("units", units); this._addAttribute("waitBlanking", waitBlanking); this._addAttribute("autoLog", autoLog); @@ -432,9 +439,7 @@ export class Window extends PsychObject // create a top-level PIXI container: this._rootContainer = new PIXI.Container(); this._rootContainer.interactive = true; - this._rootContainer.filters = [new AdjustmentFilter({ - gamma: this.gamma - })]; + this._rootContainer.filters = [this._adjustmentFilter]; // set the initial size of the PIXI renderer and the position of the root container: Window._resizePixiRenderer(this); From 88b7345535c66e89c35be2dffac39035fc47a23d Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 10 Feb 2022 19:54:52 +0300 Subject: [PATCH 07/92] added spatial frequency and phase support for image based grating stims; --- src/visual/GratingStim.js | 73 +++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index f5f67181..b1f93ff9 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -31,6 +31,8 @@ const DEFINED_FUNCTIONS = { raisedCos: undefined }; +const DEFAULT_STIM_SIZE = [256, 256]; // in pixels + /** * Grating Stimulus. * @@ -45,7 +47,6 @@ const DEFINED_FUNCTIONS = { * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus - * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position * @param {number} [options.ori= 0.0] - the orientation (in degrees) * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) * @param {Color} [options.color= 'white'] the background color @@ -126,14 +127,12 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "spatialFrequency", spatialFrequency, - 10., - this._onChange(true, false) + 10. ); this._addAttribute( "phase", phase, - 0., - this._onChange(true, false) + 0. ); this._addAttribute( "color", @@ -167,6 +166,11 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); } + + if (!Array.isArray(this.size) || this.size.length === 0) { + this.size = util.to_unit(DEFAULT_STIM_SIZE, "pix", this.win, this.units); + } + this._sizeInPixels = util.to_px(this.size, this.units, this.win); } /** @@ -198,8 +202,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { // tex is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); - const existingImage = this.getTex(); - hasChanged = existingImage ? existingImage !== tex : true; + const curFuncName = this.getTex(); + hasChanged = curFuncName ? curFuncName !== tex : true; } else { @@ -289,10 +293,10 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } /** - * Get the size of the display image, which is either that of the ImageStim or that of the image + * Get the size of the display image, which is either that of the GratingStim or that of the image * it contains. * - * @name module:visual.ImageStim#_getDisplaySize + * @name module:visual.GratingStim#_getDisplaySize * @private * @return {number[]} the size of the displayed image */ @@ -316,7 +320,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) /** * Estimate the bounding box. * - * @name module:visual.ImageStim#_estimateBoundingBox + * @name module:visual.GratingStim#_estimateBoundingBox * @function * @override * @protected @@ -343,9 +347,9 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) 'aVertexPosition', [ 0, 0, - 256, 0, - 256, 256, - 0, 256 + this._sizeInPixels[0], 0, + this._sizeInPixels[0], this._sizeInPixels[1], + 0, this._sizeInPixels[1] ], 2 ); @@ -364,10 +368,30 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) return new PIXI.Mesh(geometry, shader); } + setPhase (phase, log = false) { + this._setAttribute("phase", phase, log); + if (this._pixi instanceof PIXI.Mesh) { + this._pixi.shader.uniforms.uPhase = phase; + } else if (this._pixi instanceof PIXI.TilingSprite) { + this._pixi.tilePosition.x = -phase * (this._sizeInPixels[0] * this._pixi.tileScale.x) / (2 * Math.PI) + } + } + + setSpatialFrequency (sf, log = false) { + this._setAttribute("spatialFrequency", sf, log); + if (this._pixi instanceof PIXI.Mesh) { + this._pixi.shader.uniforms.uFreq = sf; + } else if (this._pixi instanceof PIXI.TilingSprite) { + // tileScale units are pixels, so converting function frequency to pixels + // and also taking into account possible size difference between used texture and requested stim size + this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width); + } + } + /** * Update the stimulus, if necessary. * - * @name module:visual.ImageStim#_updateIfNeeded + * @name module:visual.GratingStim#_updateIfNeeded * @private */ _updateIfNeeded() @@ -396,13 +420,18 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) if (this._tex instanceof HTMLImageElement) { - this._pixi = PIXI.Sprite.from(this._tex); + this._pixi = PIXI.TilingSprite.from(this._tex, { + width: this._sizeInPixels[0], + height: this._sizeInPixels[1] + }); + this.setPhase(this._phase); + this.setSpatialFrequency(this._spatialFrequency); } else { this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { - uFreq: this.spatialFrequency, - uPhase: this.phase + uFreq: this._spatialFrequency, + uPhase: this._phase }); } this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5); @@ -421,8 +450,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // rendering mask to texture for further use. const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask); const rt = PIXI.RenderTexture.create({ - width: 256, - height: 256 + width: this._sizeInPixels[0], + height: this._sizeInPixels[1] }); this.win._renderer.render(maskMesh, { renderTexture: rt @@ -448,9 +477,9 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // set the scale: const displaySize = this._getDisplaySize(); - const size_px = util.to_px(displaySize, this.units, this.win); - const scaleX = size_px[0] / this._pixi.width; - const scaleY = size_px[1] / this._pixi.height; + this._sizeInPixels = util.to_px(displaySize, this.units, this.win); + const scaleX = this._sizeInPixels[0] / this._pixi.width; + const scaleY = this._sizeInPixels[1] / this._pixi.height; this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; From ff13338563c1e82f6b7c81bc5dfecec86fd8f758 Mon Sep 17 00:00:00 2001 From: lgtst Date: Wed, 2 Mar 2022 20:45:12 +0300 Subject: [PATCH 08/92] Full set of signals for grating stim; Documentation test; --- docs/visual_GratingStim.js.html | 644 ++++++++++++++++++++++++ src/visual/GratingStim.js | 195 +++++-- src/visual/shaders/circleShader.frag | 13 + src/visual/shaders/crossShader.frag | 16 + src/visual/shaders/gaussShader.frag | 17 +- src/visual/shaders/radRampShader.frag | 17 + src/visual/shaders/raisedCosShader.frag | 29 ++ src/visual/shaders/sawShader.frag | 21 + src/visual/shaders/sinXsinShader.frag | 17 + src/visual/shaders/sqrShader.frag | 20 + src/visual/shaders/sqrXsqrShader.frag | 17 + src/visual/shaders/triShader.frag | 22 + 12 files changed, 975 insertions(+), 53 deletions(-) create mode 100644 docs/visual_GratingStim.js.html create mode 100644 src/visual/shaders/circleShader.frag create mode 100644 src/visual/shaders/crossShader.frag create mode 100644 src/visual/shaders/radRampShader.frag create mode 100644 src/visual/shaders/raisedCosShader.frag create mode 100644 src/visual/shaders/sawShader.frag create mode 100644 src/visual/shaders/sinXsinShader.frag create mode 100644 src/visual/shaders/sqrShader.frag create mode 100644 src/visual/shaders/sqrXsqrShader.frag create mode 100644 src/visual/shaders/triShader.frag diff --git a/docs/visual_GratingStim.js.html b/docs/visual_GratingStim.js.html new file mode 100644 index 00000000..e6804ffb --- /dev/null +++ b/docs/visual_GratingStim.js.html @@ -0,0 +1,644 @@ + + + + + JSDoc: Source: visual/GratingStim.js + + + + + + + + + + +
+ +

Source: visual/GratingStim.js

+ + + + + + +
+
+
/**
+ * Grating Stimulus.
+ *
+ * @author Alain Pitiot
+ * @version 2021.2.0
+ * @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
+ */
+
+import * as PIXI from "pixi.js-legacy";
+import { Color } from "../util/Color.js";
+import { ColorMixin } from "../util/ColorMixin.js";
+import { to_pixiPoint } from "../util/Pixi.js";
+import * as util from "../util/Util.js";
+import { VisualStim } from "./VisualStim.js";
+import defaultQuadVert from "./shaders/defaultQuad.vert";
+import sinShader from "./shaders/sinShader.frag";
+import sqrShader from "./shaders/sqrShader.frag";
+import sawShader from "./shaders/sawShader.frag";
+import triShader from "./shaders/triShader.frag";
+import sinXsinShader from "./shaders/sinXsinShader.frag";
+import sqrXsqrShader from "./shaders/sqrXsqrShader.frag";
+import circleShader from "./shaders/circleShader.frag";
+import gaussShader from "./shaders/gaussShader.frag";
+import crossShader from "./shaders/crossShader.frag";
+import radRampShader from "./shaders/radRampShader.frag";
+import raisedCosShader from "./shaders/raisedCosShader.frag";
+
+
+/**
+ * Grating Stimulus.
+ *
+ * @name module:visual.GratingStim
+ * @class
+ * @extends VisualStim
+ * @mixes ColorMixin
+ * @param {Object} options
+ * @param {String} options.name - the name used when logging messages from this stimulus
+ * @param {Window} options.win - the associated Window
+ * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image
+ * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask
+ * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices)
+ * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus
+ * @param {number} [options.ori= 0.0] - the orientation (in degrees)
+ * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified)
+ * @param {Color} [options.color= "white"] the background color
+ * @param {number} [options.opacity= 1.0] - the opacity
+ * @param {number} [options.contrast= 1.0] - the contrast
+ * @param {number} [options.depth= 0] - the depth (i.e. the z order)
+ * @param {number} [options.texRes= 128] - the resolution of the text
+ * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated
+ * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ */
+
+export class GratingStim extends util.mix(VisualStim).with(ColorMixin)
+{
+	// win,
+	// tex="sin",
+	// mask="none",
+	// units="",
+	// pos=(0.0, 0.0),
+	// size=None,
+	// sf=None,
+	// ori=0.0,
+	// phase=(0.0, 0.0),
+	// texRes=128,
+	// rgb=None,
+	// dkl=None,
+	// lms=None,
+	// color=(1.0, 1.0, 1.0),
+	// colorSpace='rgb',
+	// contrast=1.0,
+	// opacity=None,
+	// depth=0,
+	// rgbPedestal=(0.0, 0.0, 0.0),
+	// interpolate=False,
+	// blendmode='avg',
+	// name=None,
+	// autoLog=None,
+	// autoDraw=False,
+	// maskParams=None)
+
+	static #DEFINED_FUNCTIONS = {
+		sin: {
+			shader: sinShader,
+			uniforms: {
+				uFreq: 1.0,
+				uPhase: 0.0
+			}
+		},
+		sqr: {
+			shader: sqrShader,
+			uniforms: {
+				uFreq: 1.0,
+				uPhase: 0.0
+			}
+		},
+		saw: {
+			shader: sawShader,
+			uniforms: {
+				uFreq: 1.0,
+				uPhase: 0.0
+			}
+		},
+		tri: {
+			shader: triShader,
+			uniforms: {
+				uFreq: 1.0,
+				uPhase: 0.0,
+				uPeriod: 1.0
+			}
+		},
+		sinXsin: {
+			shader: sinXsinShader,
+			uniforms: {
+
+			}
+		},
+		sqrXsqr: {
+			shader: sqrXsqrShader,
+			uniforms: {
+				uFreq: 1.0,
+				uPhase: 0.0
+			}
+		},
+		circle: {
+			shader: circleShader,
+			uniforms: {
+
+			}
+		},
+		gauss: {
+			shader: gaussShader,
+			uniforms: {
+				uA: 1.0,
+				uB: 0.0,
+				uC: 0.16
+			}
+		},
+		cross: {
+			shader: crossShader,
+			uniforms: {
+				uThickness: 0.1
+			}
+		},
+		radRamp: {
+			shader: radRampShader,
+			uniforms: {
+
+			}
+		},
+		raisedCos: {
+			shader: raisedCosShader,
+			uniforms: {
+				uBeta: 0.25,
+				uPeriod: 1.0
+			}
+		}
+	};
+
+	static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels
+
+	constructor({
+		name,
+		tex = "sin",
+		win,
+		mask,
+		pos,
+		units,
+		spatialFrequency = 1.,
+		ori,
+		phase,
+		size,
+		rgb,
+	    dkl,
+	    lms,
+		color,
+		colorSpace,
+		opacity,
+		contrast,
+		texRes,
+		depth,
+		rgbPedestal,
+		interpolate,
+		blendmode,
+		autoDraw,
+		autoLog,
+		maskParams
+	} = {})
+	{
+		super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog });
+
+		this._addAttribute(
+			"tex",
+			tex,
+		);
+		this._addAttribute(
+			"mask",
+			mask,
+		);
+		this._addAttribute(
+			"spatialFrequency",
+			spatialFrequency,
+			GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uFreq || 1.0
+		);
+		this._addAttribute(
+			"phase",
+			phase,
+			GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uPhase || 0.0
+		);
+		this._addAttribute(
+			"color",
+			color,
+			"white",
+			this._onChange(true, false),
+		);
+		this._addAttribute(
+			"contrast",
+			contrast,
+			1.0,
+			this._onChange(true, false),
+		);
+		this._addAttribute(
+			"texRes",
+			texRes,
+			128,
+			this._onChange(true, false),
+		);
+		this._addAttribute(
+			"interpolate",
+			interpolate,
+			false,
+			this._onChange(true, false),
+		);
+
+		// estimate the bounding box:
+		this._estimateBoundingBox();
+
+		if (this._autoLog)
+		{
+			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
+		}
+
+		if (!Array.isArray(this.size) || this.size.length === 0) {
+			this.size = util.to_unit(GratingStim.#DEFAULT_STIM_SIZE_PX, "pix", this.win, this.units);
+		}
+		this._size_px = util.to_px(this.size, this.units, this.win);
+	}
+
+	/**
+	 * Setter for the image attribute.
+	 *
+	 * @name module:visual.GratingStim#setImage
+	 * @public
+	 * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image
+	 * @param {boolean} [log= false] - whether of not to log
+	 */
+	setTex(tex, log = false)
+	{
+		const response = {
+			origin: "GratingStim.setTex",
+			context: "when setting the tex of GratingStim: " + this._name,
+		};
+
+		try
+		{
+			let hasChanged = false;
+
+			// tex is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem
+			if (typeof tex === "undefined")
+			{
+				this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined.");
+				this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined");
+			}
+			else if (GratingStim.#DEFINED_FUNCTIONS[tex] !== undefined)
+			{
+				// tex is a string and it is one of predefined functions available in shaders
+				this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex);
+				const curFuncName = this.getTex();
+				hasChanged = curFuncName ? curFuncName !== tex : true;
+			}
+			else
+			{
+				// tex is a string: it should be the name of a resource, which we load
+				if (typeof tex === "string")
+				{
+					tex = this.psychoJS.serverManager.getResource(tex);
+				}
+
+				// tex should now be an actual HTMLImageElement: we raise an error if it is not
+				if (!(tex instanceof HTMLImageElement))
+				{
+					throw "the argument: " + tex.toString() + " is not an image\" }";
+				}
+
+				this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: src= " + tex.src + ", size= " + tex.width + "x" + tex.height);
+				const existingImage = this.getTex();
+				hasChanged = existingImage ? existingImage.src !== tex.src : true;
+			}
+
+			this._setAttribute("tex", tex, log);
+
+			if (hasChanged)
+			{
+				this._onChange(true, true)();
+			}
+		}
+		catch (error)
+		{
+			throw Object.assign(response, { error });
+		}
+	}
+
+	/**
+	 * Setter for the mask attribute.
+	 *
+	 * @name module:visual.GratingStim#setMask
+	 * @public
+	 * @param {HTMLImageElement | string} mask - the name of the mask resource or HTMLImageElement corresponding to the mask
+	 * @param {boolean} [log= false] - whether of not to log
+	 */
+	setMask(mask, log = false)
+	{
+		const response = {
+			origin: "GratingStim.setMask",
+			context: "when setting the mask of GratingStim: " + this._name,
+		};
+
+		try
+		{
+			// mask is undefined: that's fine but we raise a warning in case this is a sympton of an actual problem
+			if (typeof mask === "undefined")
+			{
+				this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined.");
+				this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined");
+			}
+			else if (GratingStim.#DEFINED_FUNCTIONS[mask] !== undefined)
+			{
+				// mask is a string and it is one of predefined functions available in shaders
+				this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask);
+			}
+			else
+			{
+				// mask is a string: it should be the name of a resource, which we load
+				if (typeof mask === "string")
+				{
+					mask = this.psychoJS.serverManager.getResource(mask);
+				}
+
+				// mask should now be an actual HTMLImageElement: we raise an error if it is not
+				if (!(mask instanceof HTMLImageElement))
+				{
+					throw "the argument: " + mask.toString() + " is not an image\" }";
+				}
+
+				this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height);
+			}
+
+			this._setAttribute("mask", mask, log);
+
+			this._onChange(true, false)();
+		}
+		catch (error)
+		{
+			throw Object.assign(response, { error });
+		}
+	}
+
+	/**
+	 * Get the size of the display image, which is either that of the GratingStim or that of the image
+	 * it contains.
+	 *
+	 * @name module:visual.GratingStim#_getDisplaySize
+	 * @private
+	 * @return {number[]} the size of the displayed image
+	 */
+	_getDisplaySize()
+	{
+		let displaySize = this.size;
+
+		if (typeof displaySize === "undefined")
+		{
+			// use the size of the pixi element, if we have access to it:
+			if (typeof this._pixi !== "undefined" && this._pixi.width > 0)
+			{
+				const pixiContainerSize = [this._pixi.width, this._pixi.height];
+				displaySize = util.to_unit(pixiContainerSize, "pix", this.win, this.units);
+			}
+		}
+
+		return displaySize;
+	}
+
+	/**
+	 * Estimate the bounding box.
+	 *
+	 * @name module:visual.GratingStim#_estimateBoundingBox
+	 * @function
+	 * @override
+	 * @protected
+	 */
+	_estimateBoundingBox()
+	{
+		const size = this._getDisplaySize();
+		if (typeof size !== "undefined")
+		{
+			this._boundingBox = new PIXI.Rectangle(
+				this._pos[0] - size[0] / 2,
+				this._pos[1] - size[1] / 2,
+				size[0],
+				size[1],
+			);
+		}
+
+		// TODO take the orientation into account
+	}
+
+	/**
+	 * Generate PIXI.Mesh object based on provided shader function name and uniforms.
+	 * 
+	 * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders
+	 * @function
+	 * @private
+	 * @param {String} funcName - name of the shader function. Must be one of the DEFINED_FUNCTIONS
+	 * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values.
+	 * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene.
+	 */
+	_getPixiMeshFromPredefinedShaders (funcName = "", uniforms = {}) {
+		const geometry = new PIXI.Geometry();
+		geometry.addAttribute(
+			"aVertexPosition",
+			[
+				0, 0,
+				this._size_px[0], 0,
+				this._size_px[0], this._size_px[1],
+				0, this._size_px[1]
+			],
+			2
+		);
+		geometry.addAttribute(
+			"aUvs",
+			[0, 0, 1, 0, 1, 1, 0, 1],
+			2
+		);
+		geometry.addIndex([0, 1, 2, 0, 2, 3]);
+		const vertexSrc = defaultQuadVert;
+	    const fragmentSrc = GratingStim.#DEFINED_FUNCTIONS[funcName].shader;
+	    const uniformsFinal = Object.assign({}, GratingStim.#DEFINED_FUNCTIONS[funcName].uniforms, uniforms);
+		const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal);
+		return new PIXI.Mesh(geometry, shader);
+	}
+
+	/**
+	 * Set phase value for the function.
+	 * 
+	 * @name module:visual.GratingStim#setPhase
+	 * @public
+	 * @param {number} phase - phase value
+	 * @param {boolean} [log= false] - whether of not to log
+	 */ 
+	setPhase (phase, log = false) {
+		this._setAttribute("phase", phase, log);
+		if (this._pixi instanceof PIXI.Mesh) {
+			this._pixi.shader.uniforms.uPhase = phase;
+		} else if (this._pixi instanceof PIXI.TilingSprite) {
+			this._pixi.tilePosition.x = -phase * (this._size_px[0] * this._pixi.tileScale.x) / (2 * Math.PI)
+		}
+	}
+
+	/**
+	 * Set spatial frequency value for the function.
+	 * 
+	 * @name module:visual.GratingStim#setPhase
+	 * @public
+	 * @param {number} sf - spatial frequency value
+	 * @param {boolean} [log= false] - whether of not to log
+	 */ 
+	setSpatialFrequency (sf, log = false) {
+		this._setAttribute("spatialFrequency", sf, log);
+		if (this._pixi instanceof PIXI.Mesh) {
+			this._pixi.shader.uniforms.uFreq = sf;
+		} else if (this._pixi instanceof PIXI.TilingSprite) {
+			// tileScale units are pixels, so converting function frequency to pixels
+			// and also taking into account possible size difference between used texture and requested stim size
+			this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width);
+		}
+	}
+
+	/**
+	 * Update the stimulus, if necessary.
+	 *
+	 * @name module:visual.GratingStim#_updateIfNeeded
+	 * @private
+	 */
+	_updateIfNeeded()
+	{
+		if (!this._needUpdate)
+		{
+			return;
+		}
+		this._needUpdate = false;
+
+		// update the PIXI representation, if need be:
+		if (this._needPixiUpdate)
+		{
+			this._needPixiUpdate = false;
+			if (typeof this._pixi !== "undefined")
+			{
+				this._pixi.destroy(true);
+			}
+			this._pixi = undefined;
+
+			// no image to draw: return immediately
+			if (typeof this._tex === "undefined")
+			{
+				return;
+			}
+
+			if (this._tex instanceof HTMLImageElement)
+			{
+				this._pixi = PIXI.TilingSprite.from(this._tex, {
+					width: this._size_px[0],
+					height: this._size_px[1]
+				});
+				this.setPhase(this._phase);
+				this.setSpatialFrequency(this._spatialFrequency);
+			}
+			else
+			{
+				this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, {
+					uFreq: this._spatialFrequency,
+					uPhase: this._phase
+				});
+			}
+			this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5);
+
+			// add a mask if need be:
+			if (typeof this._mask !== "undefined")
+			{
+				if (this._mask instanceof HTMLImageElement)
+				{
+					this._pixi.mask = PIXI.Sprite.from(this._mask);
+					this._pixi.addChild(this._pixi.mask);
+				}
+				else
+				{
+					// for some reason setting PIXI.Mesh as .mask doesn't do anything,
+					// rendering mask to texture for further use.
+					const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask);
+					const rt = PIXI.RenderTexture.create({
+						width: this._size_px[0],
+						height: this._size_px[1]
+					});
+					this.win._renderer.render(maskMesh, {
+						renderTexture: rt
+					});
+					const maskSprite = new PIXI.Sprite.from(rt);
+					this._pixi.mask = maskSprite;
+					this._pixi.addChild(maskSprite);
+				}
+			}
+
+			// since _pixi.width may not be immediately available but the rest of the code needs its value
+			// we arrange for repeated calls to _updateIfNeeded until we have a width:
+			if (this._pixi.width === 0)
+			{
+				this._needUpdate = true;
+				this._needPixiUpdate = true;
+				return;
+			}
+		}
+
+		this._pixi.zIndex = this._depth;
+		this._pixi.alpha = this.opacity;
+
+		// set the scale:
+		const displaySize = this._getDisplaySize();
+		this._size_px = util.to_px(displaySize, this.units, this.win);
+		const scaleX = this._size_px[0] / this._pixi.width;
+		const scaleY = this._size_px[1] / this._pixi.height;
+		this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX;
+		this._pixi.scale.y = this.flipVert ? scaleY : -scaleY;
+
+		// set the position, rotation, and anchor (image centered on pos):
+		let pos = to_pixiPoint(this.pos, this.units, this.win);
+		this._pixi.position.set(pos.x, pos.y);
+		this._pixi.rotation = this.ori * Math.PI / 180;
+
+		// re-estimate the bounding box, as the texture's width may now be available:
+		this._estimateBoundingBox();
+	}
+}
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Wed Mar 02 2022 20:43:58 GMT+0300 (Moscow Standard Time) +
+ + + + + diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index b1f93ff9..c06df13c 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -13,25 +13,19 @@ import { ColorMixin } from "../util/ColorMixin.js"; import { to_pixiPoint } from "../util/Pixi.js"; import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; -import defaultQuadVert from './shaders/defaultQuad.vert'; -import sinShader from './shaders/sinShader.frag'; -import gaussShader from './shaders/gaussShader.frag'; - -const DEFINED_FUNCTIONS = { - sin: sinShader, - sqr: undefined, - saw: undefined, - tri: undefined, - sinXsin: undefined, - sqrXsqr: undefined, - circle: undefined, - gauss: gaussShader, - cross: undefined, - radRamp: undefined, - raisedCos: undefined -}; - -const DEFAULT_STIM_SIZE = [256, 256]; // in pixels +import defaultQuadVert from "./shaders/defaultQuad.vert"; +import sinShader from "./shaders/sinShader.frag"; +import sqrShader from "./shaders/sqrShader.frag"; +import sawShader from "./shaders/sawShader.frag"; +import triShader from "./shaders/triShader.frag"; +import sinXsinShader from "./shaders/sinXsinShader.frag"; +import sqrXsqrShader from "./shaders/sqrXsqrShader.frag"; +import circleShader from "./shaders/circleShader.frag"; +import gaussShader from "./shaders/gaussShader.frag"; +import crossShader from "./shaders/crossShader.frag"; +import radRampShader from "./shaders/radRampShader.frag"; +import raisedCosShader from "./shaders/raisedCosShader.frag"; + /** * Grating Stimulus. @@ -49,7 +43,7 @@ const DEFAULT_STIM_SIZE = [256, 256]; // in pixels * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) - * @param {Color} [options.color= 'white'] the background color + * @param {Color} [options.color= "white"] the background color * @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.contrast= 1.0] - the contrast * @param {number} [options.depth= 0] - the depth (i.e. the z order) @@ -86,14 +80,95 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // autoLog=None, // autoDraw=False, // maskParams=None) + + static #DEFINED_FUNCTIONS = { + sin: { + shader: sinShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0 + } + }, + sqr: { + shader: sqrShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0 + } + }, + saw: { + shader: sawShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0 + } + }, + tri: { + shader: triShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uPeriod: 1.0 + } + }, + sinXsin: { + shader: sinXsinShader, + uniforms: { + + } + }, + sqrXsqr: { + shader: sqrXsqrShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0 + } + }, + circle: { + shader: circleShader, + uniforms: { + + } + }, + gauss: { + shader: gaussShader, + uniforms: { + uA: 1.0, + uB: 0.0, + uC: 0.16 + } + }, + cross: { + shader: crossShader, + uniforms: { + uThickness: 0.1 + } + }, + radRamp: { + shader: radRampShader, + uniforms: { + + } + }, + raisedCos: { + shader: raisedCosShader, + uniforms: { + uBeta: 0.25, + uPeriod: 1.0 + } + } + }; + + static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels + constructor({ name, - tex, + tex = "sin", win, mask, pos, units, - spatialFrequency = 10., + spatialFrequency = 1., ori, phase, size, @@ -127,12 +202,12 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "spatialFrequency", spatialFrequency, - 10. + GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uFreq || 1.0 ); this._addAttribute( "phase", phase, - 0. + GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uPhase || 0.0 ); this._addAttribute( "color", @@ -168,9 +243,9 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } if (!Array.isArray(this.size) || this.size.length === 0) { - this.size = util.to_unit(DEFAULT_STIM_SIZE, "pix", this.win, this.units); + this.size = util.to_unit(GratingStim.#DEFAULT_STIM_SIZE_PX, "pix", this.win, this.units); } - this._sizeInPixels = util.to_px(this.size, this.units, this.win); + this._size_px = util.to_px(this.size, this.units, this.win); } /** @@ -198,7 +273,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); } - else if (DEFINED_FUNCTIONS[tex] !== undefined) + else if (GratingStim.#DEFINED_FUNCTIONS[tex] !== undefined) { // tex is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); @@ -216,7 +291,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // tex should now be an actual HTMLImageElement: we raise an error if it is not if (!(tex instanceof HTMLImageElement)) { - throw "the argument: " + tex.toString() + ' is not an image" }'; + throw "the argument: " + tex.toString() + " is not an image\" }"; } this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: src= " + tex.src + ", size= " + tex.width + "x" + tex.height); @@ -260,7 +335,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); } - else if (DEFINED_FUNCTIONS[mask] !== undefined) + else if (GratingStim.#DEFINED_FUNCTIONS[mask] !== undefined) { // mask is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask); @@ -276,7 +351,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // mask should now be an actual HTMLImageElement: we raise an error if it is not if (!(mask instanceof HTMLImageElement)) { - throw "the argument: " + mask.toString() + ' is not an image" }'; + throw "the argument: " + mask.toString() + " is not an image\" }"; } this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height); @@ -341,42 +416,66 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // TODO take the orientation into account } - _getPixiMeshFromPredefinedShaders (funcName = '', uniforms = {}) { + /** + * Generate PIXI.Mesh object based on provided shader function name and uniforms. + * + * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders + * @function + * @private + * @param {String} funcName - name of the shader function. Must be one of the DEFINED_FUNCTIONS + * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values. + * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene. + */ + _getPixiMeshFromPredefinedShaders (funcName = "", uniforms = {}) { const geometry = new PIXI.Geometry(); geometry.addAttribute( - 'aVertexPosition', + "aVertexPosition", [ 0, 0, - this._sizeInPixels[0], 0, - this._sizeInPixels[0], this._sizeInPixels[1], - 0, this._sizeInPixels[1] + this._size_px[0], 0, + this._size_px[0], this._size_px[1], + 0, this._size_px[1] ], 2 ); geometry.addAttribute( - 'aUvs', + "aUvs", [0, 0, 1, 0, 1, 1, 0, 1], 2 ); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = DEFINED_FUNCTIONS[funcName]; - const uniformsFinal = Object.assign(uniforms, { - // for future default uniforms - }); + const fragmentSrc = GratingStim.#DEFINED_FUNCTIONS[funcName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#DEFINED_FUNCTIONS[funcName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } + /** + * Set phase value for the function. + * + * @name module:visual.GratingStim#setPhase + * @public + * @param {number} phase - phase value + * @param {boolean} [log= false] - whether of not to log + */ setPhase (phase, log = false) { this._setAttribute("phase", phase, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uPhase = phase; } else if (this._pixi instanceof PIXI.TilingSprite) { - this._pixi.tilePosition.x = -phase * (this._sizeInPixels[0] * this._pixi.tileScale.x) / (2 * Math.PI) + this._pixi.tilePosition.x = -phase * (this._size_px[0] * this._pixi.tileScale.x) / (2 * Math.PI) } } + /** + * Set spatial frequency value for the function. + * + * @name module:visual.GratingStim#setPhase + * @public + * @param {number} sf - spatial frequency value + * @param {boolean} [log= false] - whether of not to log + */ setSpatialFrequency (sf, log = false) { this._setAttribute("spatialFrequency", sf, log); if (this._pixi instanceof PIXI.Mesh) { @@ -421,8 +520,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) if (this._tex instanceof HTMLImageElement) { this._pixi = PIXI.TilingSprite.from(this._tex, { - width: this._sizeInPixels[0], - height: this._sizeInPixels[1] + width: this._size_px[0], + height: this._size_px[1] }); this.setPhase(this._phase); this.setSpatialFrequency(this._spatialFrequency); @@ -450,8 +549,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // rendering mask to texture for further use. const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask); const rt = PIXI.RenderTexture.create({ - width: this._sizeInPixels[0], - height: this._sizeInPixels[1] + width: this._size_px[0], + height: this._size_px[1] }); this.win._renderer.render(maskMesh, { renderTexture: rt @@ -477,9 +576,9 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // set the scale: const displaySize = this._getDisplaySize(); - this._sizeInPixels = util.to_px(displaySize, this.units, this.win); - const scaleX = this._sizeInPixels[0] / this._pixi.width; - const scaleY = this._sizeInPixels[1] / this._pixi.height; + this._size_px = util.to_px(displaySize, this.units, this.win); + const scaleX = this._size_px[0] / this._pixi.width; + const scaleY = this._size_px[1] / this._pixi.height; this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; diff --git a/src/visual/shaders/circleShader.frag b/src/visual/shaders/circleShader.frag new file mode 100644 index 00000000..3211825a --- /dev/null +++ b/src/visual/shaders/circleShader.frag @@ -0,0 +1,13 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 + +void main() { + vec2 uv = vUvs; + float s = 1. - step(.5, length(uv - .5)); + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/crossShader.frag b/src/visual/shaders/crossShader.frag new file mode 100644 index 00000000..dbff4c10 --- /dev/null +++ b/src/visual/shaders/crossShader.frag @@ -0,0 +1,16 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uThickness; + +void main() { + vec2 uv = vUvs; + float sx = step(uThickness, length(uv.x - .5)); + float sy = step(uThickness, length(uv.y - .5)); + float s = 1. - sx * sy; + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag index 17543f62..ed7fbecd 100644 --- a/src/visual/shaders/gaussShader.frag +++ b/src/visual/shaders/gaussShader.frag @@ -1,17 +1,24 @@ +// +// Gaussian Function: +// https://en.wikipedia.org/wiki/Gaussian_function +// + #version 300 es precision mediump float; in vec2 vUvs; out vec4 shaderOut; -#define M_PI 3.14159265358979 +uniform float uA; +uniform float uB; +uniform float uC; -float gauss(float x) { - return exp(-(x * x) * 20.); -} +#define M_PI 3.14159265358979 void main() { vec2 uv = vUvs; - float g = gauss(uv.x - .5) * gauss(uv.y - .5); + float c2 = uC * uC; + float x = length(uv - .5); + float g = uA * exp(-pow(x - uB, 2.) / c2 * .5); shaderOut = vec4(vec3(g), 1.); } diff --git a/src/visual/shaders/radRampShader.frag b/src/visual/shaders/radRampShader.frag new file mode 100644 index 00000000..bbbc5867 --- /dev/null +++ b/src/visual/shaders/radRampShader.frag @@ -0,0 +1,17 @@ +// +// Radial ramp function +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 + +void main() { + vec2 uv = vUvs; + float s = 1. - length(uv * 2. - 1.); + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/raisedCosShader.frag b/src/visual/shaders/raisedCosShader.frag new file mode 100644 index 00000000..605fbfed --- /dev/null +++ b/src/visual/shaders/raisedCosShader.frag @@ -0,0 +1,29 @@ +// +// Raised-cosine function: +// https://en.wikipedia.org/wiki/Raised-cosine_filter +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uBeta; +uniform float uPeriod; + +void main() { + vec2 uv = vUvs; + float absX = length(uv * 2. - 1.); + float edgeArgument1 = (1. - uBeta) / (2. * uPeriod); + float edgeArgument2 = (1. + uBeta) / (2. * uPeriod); + float frequencyFactor = (M_PI * uPeriod) / uBeta; + float s = .5 * (1. + cos(frequencyFactor * (absX - edgeArgument1))); + if (absX <= edgeArgument1) { + s = 1.; + } else if (absX > edgeArgument2) { + s = 0.; + } + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/sawShader.frag b/src/visual/shaders/sawShader.frag new file mode 100644 index 00000000..ed55ceb8 --- /dev/null +++ b/src/visual/shaders/sawShader.frag @@ -0,0 +1,21 @@ +// +// Sawtooth wave: +// https://en.wikipedia.org/wiki/Sawtooth_wave +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float s = uFreq * uv.x + uPhase; + s = mod(s, 1.); + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/sinXsinShader.frag b/src/visual/shaders/sinXsinShader.frag new file mode 100644 index 00000000..15435ac3 --- /dev/null +++ b/src/visual/shaders/sinXsinShader.frag @@ -0,0 +1,17 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float sx = sin(uFreq * uv.x * 2. * M_PI + uPhase); + float sy = sin(uFreq * uv.y * 2. * M_PI + uPhase); + float s = sx * sy * .5 + .5; + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/sqrShader.frag b/src/visual/shaders/sqrShader.frag new file mode 100644 index 00000000..dc9bd747 --- /dev/null +++ b/src/visual/shaders/sqrShader.frag @@ -0,0 +1,20 @@ +// +// Square wave: +// https://en.wikipedia.org/wiki/Square_wave +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float s = sign(sin(uFreq * uv.x * 2. * M_PI + uPhase)); + shaderOut = vec4(.5 + .5 * vec3(s), 1.0); +} diff --git a/src/visual/shaders/sqrXsqrShader.frag b/src/visual/shaders/sqrXsqrShader.frag new file mode 100644 index 00000000..4eb6cfa2 --- /dev/null +++ b/src/visual/shaders/sqrXsqrShader.frag @@ -0,0 +1,17 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float sx = sign(sin(uFreq * uv.x * 2. * M_PI + uPhase)); + float sy = sign(sin(uFreq * uv.y * 2. * M_PI + uPhase)); + float s = sx * sy * .5 + .5; + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/triShader.frag b/src/visual/shaders/triShader.frag new file mode 100644 index 00000000..5b45ce08 --- /dev/null +++ b/src/visual/shaders/triShader.frag @@ -0,0 +1,22 @@ +// +// Triangle wave: +// https://en.wikipedia.org/wiki/Triangle_wave +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; +uniform float uPeriod; + +void main() { + vec2 uv = vUvs; + float s = uFreq * uv.x + uPhase; + s = 2. * abs(s / uPeriod - floor(s / uPeriod + .5)); + shaderOut = vec4(vec3(s), 1.0); +} From 3ebd9c4943403f330b994013925ba637e9483475 Mon Sep 17 00:00:00 2001 From: lgtst Date: Mon, 21 Mar 2022 23:28:54 +0300 Subject: [PATCH 09/92] new documentation for GratingStim, variables and methods renamed to match psychoPy --- docs/visual_GratingStim.js.html | 205 +++++++++++++++--------- package.json | 1 + scripts/build.js.cjs | 26 ++- src/visual/GratingStim.js | 203 ++++++++++++++--------- src/visual/shaders/circleShader.frag | 13 +- src/visual/shaders/crossShader.frag | 14 +- src/visual/shaders/gaussShader.frag | 14 +- src/visual/shaders/radRampShader.frag | 15 +- src/visual/shaders/raisedCosShader.frag | 14 +- src/visual/shaders/sawShader.frag | 14 +- src/visual/shaders/sinShader.frag | 11 ++ src/visual/shaders/sinXsinShader.frag | 11 ++ src/visual/shaders/sqrShader.frag | 14 +- src/visual/shaders/sqrXsqrShader.frag | 11 ++ src/visual/shaders/triShader.frag | 14 +- 15 files changed, 403 insertions(+), 177 deletions(-) diff --git a/docs/visual_GratingStim.js.html b/docs/visual_GratingStim.js.html index e6804ffb..80cfac39 100644 --- a/docs/visual_GratingStim.js.html +++ b/docs/visual_GratingStim.js.html @@ -29,9 +29,9 @@

Source: visual/GratingStim.js

/**
  * Grating Stimulus.
  *
- * @author Alain Pitiot
+ * @author Alain Pitiot, Nikita Agafonov
  * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
  * @license Distributed under the terms of the MIT License
  */
 
@@ -54,7 +54,6 @@ 

Source: visual/GratingStim.js

import radRampShader from "./shaders/radRampShader.frag"; import raisedCosShader from "./shaders/raisedCosShader.frag"; - /** * Grating Stimulus. * @@ -65,51 +64,108 @@

Source: visual/GratingStim.js

* @param {Object} options * @param {String} options.name - the name used when logging messages from this stimulus * @param {Window} options.win - the associated Window - * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image - * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask - * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {String | HTMLImageElement} [options.tex="sin"] - the name of the predefined grating texture or image resource or the HTMLImageElement corresponding to the texture + * @param {String | HTMLImageElement} [options.mask] - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {String} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {number} [options.sf=1.0] - spatial frequency of the function used in grating stimulus + * @param {number} [options.phase=1.0] - phase of the function used in grating stimulus * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) - * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) + * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) * @param {Color} [options.color= "white"] the background color * @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.contrast= 1.0] - the contrast * @param {number} [options.depth= 0] - the depth (i.e. the z order) - * @param {number} [options.texRes= 128] - the resolution of the text - * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated + * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated. NOT IMPLEMENTED YET. + * @param {String} [options.blendmode= 'avg'] - blend mode of the stimulus, determines how the stimulus is blended with the background. NOT IMPLEMENTED YET. * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { - // win, - // tex="sin", - // mask="none", - // units="", - // pos=(0.0, 0.0), - // size=None, - // sf=None, - // ori=0.0, - // phase=(0.0, 0.0), - // texRes=128, - // rgb=None, - // dkl=None, - // lms=None, - // color=(1.0, 1.0, 1.0), - // colorSpace='rgb', - // contrast=1.0, - // opacity=None, - // depth=0, - // rgbPedestal=(0.0, 0.0, 0.0), - // interpolate=False, - // blendmode='avg', - // name=None, - // autoLog=None, - // autoDraw=False, - // maskParams=None) - - static #DEFINED_FUNCTIONS = { + /** + * An object that keeps shaders source code and default uniform values for them. + * Shader source code is later used for construction of shader programs to create respective visual stimuli. + * @name module:visual.GratingStim.#SHADERS + * @type {Object} + * @property {Object} sin - Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Sine_wave} + * @property {String} sin.shader - shader source code for the sine wave stimuli + * @property {Object} sin.uniforms - default uniforms for sine wave shader + * @property {float} sin.uniforms.uFreq=1.0 - frequency of sine wave. + * @property {float} sin.uniforms.uPhase=0.0 - phase of sine wave. + * + * @property {Object} sqr - Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Square_wave} + * @property {String} sqr.shader - shader source code for the square wave stimuli + * @property {Object} sqr.uniforms - default uniforms for square wave shader + * @property {float} sqr.uniforms.uFreq=1.0 - frequency of square wave. + * @property {float} sqr.uniforms.uPhase=0.0 - phase of square wave. + * + * @property {Object} saw - Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Sawtooth_wave} + * @property {String} saw.shader - shader source code for the sawtooth wave stimuli + * @property {Object} saw.uniforms - default uniforms for sawtooth wave shader + * @property {float} saw.uniforms.uFreq=1.0 - frequency of sawtooth wave. + * @property {float} saw.uniforms.uPhase=0.0 - phase of sawtooth wave. + * + * @property {Object} tri - Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Triangle_wave} + * @property {String} tri.shader - shader source code for the triangle wave stimuli + * @property {Object} tri.uniforms - default uniforms for triangle wave shader + * @property {float} tri.uniforms.uFreq=1.0 - frequency of triangle wave. + * @property {float} tri.uniforms.uPhase=0.0 - phase of triangle wave. + * @property {float} tri.uniforms.uPeriod=1.0 - period of triangle wave. + * + * @property {Object} sinXsin - Creates an image of two 2d sine waves multiplied with each other. + * {@link https://en.wikipedia.org/wiki/Sine_wave} + * @property {String} sinXsin.shader - shader source code for the two multiplied sine waves stimuli + * @property {Object} sinXsin.uniforms - default uniforms for shader + * @property {float} sinXsin.uniforms.uFreq=1.0 - frequency of sine wave (both of them). + * @property {float} sinXsin.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * + * @property {Object} sqrXsqr - Creates an image of two 2d square waves multiplied with each other. + * {@link https://en.wikipedia.org/wiki/Square_wave} + * @property {String} sqrXsqr.shader - shader source code for the two multiplied sine waves stimuli + * @property {Object} sqrXsqr.uniforms - default uniforms for shader + * @property {float} sqrXsqr.uniforms.uFreq=1.0 - frequency of sine wave (both of them). + * @property {float} sqrXsqr.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * + * @property {Object} circle - Creates a filled circle shape with sharp edges. + * @property {String} circle.shader - shader source code for filled circle. + * @property {Object} circle.uniforms - default uniforms for shader. + * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim + * and 1.0 is circle that spans from edge to edge of the stim. + * + * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Gaussian_function} + * @property {String} gauss.shader - shader source code for Gaussian shader + * @property {Object} gauss.uniforms - default uniforms for shader + * @property {float} gauss.uniforms.uA=1.0 - A constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uB=0.0 - B constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uC=0.16 - C constant for gaussian formula (see link). + * + * @property {Object} cross - Creates a filled cross shape with sharp edges. + * @property {String} cross.shader - shader source code for cross shader + * @property {Object} cross.uniforms - default uniforms for shader + * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes + * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. + * + * @property {Object} radRamp - Creates 2d radial ramp image. + * @property {String} radRamp.shader - shader source code for radial ramp shader + * @property {Object} radRamp.uniforms - default uniforms for shader + * @property {float} radRamp.uniforms.uSqueeze=1.0 - coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large + * it fills the entire stim and Infinity makes it so tiny it's invisible. + * + * @property {Object} raisedCos - Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Raised-cosine_filter} + * @property {String} raisedCos.shader - shader source code for raised-cosine shader + * @property {Object} raisedCos.uniforms - default uniforms for shader + * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). + * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + */ + static #SHADERS = { sin: { shader: sinShader, uniforms: { @@ -142,7 +198,8 @@

Source: visual/GratingStim.js

sinXsin: { shader: sinXsinShader, uniforms: { - + uFreq: 1.0, + uPhase: 0.0 } }, sqrXsqr: { @@ -155,7 +212,7 @@

Source: visual/GratingStim.js

circle: { shader: circleShader, uniforms: { - + uRadius: 1.0 } }, gauss: { @@ -169,24 +226,30 @@

Source: visual/GratingStim.js

cross: { shader: crossShader, uniforms: { - uThickness: 0.1 + uThickness: 0.2 } }, radRamp: { shader: radRampShader, uniforms: { - + uSqueeze: 1.0 } }, raisedCos: { shader: raisedCosShader, uniforms: { uBeta: 0.25, - uPeriod: 1.0 + uPeriod: 0.625 } } }; + /** + * Default size of the Grating Stimuli in pixels. + * @name module:visual.GratingStim.#DEFAULT_STIM_SIZE_PX + * @type {Array} + * @default [256, 256] + */ static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels constructor({ @@ -196,20 +259,15 @@

Source: visual/GratingStim.js

mask, pos, units, - spatialFrequency = 1., + sf = 1.0, ori, phase, size, - rgb, - dkl, - lms, color, colorSpace, opacity, contrast, - texRes, depth, - rgbPedestal, interpolate, blendmode, autoDraw, @@ -228,14 +286,14 @@

Source: visual/GratingStim.js

mask, ); this._addAttribute( - "spatialFrequency", - spatialFrequency, - GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uFreq || 1.0 + "SF", + sf, + GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uFreq || 1.0 : 1.0 ); this._addAttribute( "phase", phase, - GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uPhase || 0.0 + GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uPhase || 0.0 : 0.0 ); this._addAttribute( "color", @@ -249,12 +307,6 @@

Source: visual/GratingStim.js

1.0, this._onChange(true, false), ); - this._addAttribute( - "texRes", - texRes, - 128, - this._onChange(true, false), - ); this._addAttribute( "interpolate", interpolate, @@ -277,11 +329,11 @@

Source: visual/GratingStim.js

} /** - * Setter for the image attribute. + * Setter for the tex attribute. * - * @name module:visual.GratingStim#setImage + * @name module:visual.GratingStim#setTex * @public - * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image + * @param {HTMLImageElement | string} tex - the name of built in shader function or name of the image resource or HTMLImageElement corresponding to the image * @param {boolean} [log= false] - whether of not to log */ setTex(tex, log = false) @@ -301,7 +353,7 @@

Source: visual/GratingStim.js

this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); } - else if (GratingStim.#DEFINED_FUNCTIONS[tex] !== undefined) + else if (GratingStim.#SHADERS[tex] !== undefined) { // tex is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); @@ -363,7 +415,7 @@

Source: visual/GratingStim.js

this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); } - else if (GratingStim.#DEFINED_FUNCTIONS[mask] !== undefined) + else if (GratingStim.#SHADERS[mask] !== undefined) { // mask is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask); @@ -449,8 +501,8 @@

Source: visual/GratingStim.js

* * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders * @function - * @private - * @param {String} funcName - name of the shader function. Must be one of the DEFINED_FUNCTIONS + * @protected + * @param {String} funcName - name of the shader function. Must be one of the SHADERS * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values. * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene. */ @@ -473,8 +525,8 @@

Source: visual/GratingStim.js

); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#DEFINED_FUNCTIONS[funcName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#DEFINED_FUNCTIONS[funcName].uniforms, uniforms); + const fragmentSrc = GratingStim.#SHADERS[funcName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } @@ -499,19 +551,22 @@

Source: visual/GratingStim.js

/** * Set spatial frequency value for the function. * - * @name module:visual.GratingStim#setPhase + * @name module:visual.GratingStim#setSF * @public * @param {number} sf - spatial frequency value - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log=false] - whether or not to log */ - setSpatialFrequency (sf, log = false) { - this._setAttribute("spatialFrequency", sf, log); + setSF (sf, log = false) { + this._setAttribute("SF", sf, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uFreq = sf; } else if (this._pixi instanceof PIXI.TilingSprite) { // tileScale units are pixels, so converting function frequency to pixels // and also taking into account possible size difference between used texture and requested stim size this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width); + // since most functions defined in SHADERS assume spatial frequency change along X axis + // we assume desired effect for image based stims to be the same so tileScale.y is not affected by spatialFrequency + this._pixi.tileScale.y = this._pixi.height / this._pixi.texture.height; } } @@ -552,16 +607,16 @@

Source: visual/GratingStim.js

height: this._size_px[1] }); this.setPhase(this._phase); - this.setSpatialFrequency(this._spatialFrequency); + this.setSF(this._SF); } else { this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { - uFreq: this._spatialFrequency, + uFreq: this._SF, uPhase: this._phase }); } - this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5); + this._pixi.pivot.set(this._pixi.width * 0.5, this._pixi.width * 0.5); // add a mask if need be: if (typeof this._mask !== "undefined") @@ -569,6 +624,8 @@

Source: visual/GratingStim.js

if (this._mask instanceof HTMLImageElement) { this._pixi.mask = PIXI.Sprite.from(this._mask); + this._pixi.mask.width = this._size_px[0]; + this._pixi.mask.height = this._size_px[1]; this._pixi.addChild(this._pixi.mask); } else @@ -635,7 +692,7 @@

Home

Modules

  • diff --git a/package.json b/package.json index 8f9f2d82..f2233a6f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "start": "npm run build" }, "dependencies": { + "esbuild-plugin-glsl": "^1.0.5", "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", diff --git a/scripts/build.js.cjs b/scripts/build.js.cjs index 825e0cd0..d7d720be 100644 --- a/scripts/build.js.cjs +++ b/scripts/build.js.cjs @@ -1,9 +1,18 @@ -const { buildSync } = require("esbuild"); -const pkg = require("psychojs/package.json"); +const { buildSync, build } = require("esbuild"); +const { glsl } = require("esbuild-plugin-glsl"); +const pkg = require("../package.json"); const versionMaybe = process.env.npm_config_outver; const dirMaybe = process.env.npm_config_outdir; const [, , , dir = dirMaybe || "out", version = versionMaybe || pkg.version] = process.argv; +let shouldWatchDir = false; + +for (var i = 0; i < process.argv.length; i++) { + if (process.argv[i] === '-w') { + shouldWatchDir = true; + break; + } +} [ // The ESM bundle @@ -20,13 +29,19 @@ const [, , , dir = dirMaybe || "out", version = versionMaybe || pkg.version] = p }, ].forEach(function(options) { - buildSync({ ...this, ...options }); + build({ ...this, ...options }) + .then(()=> { + if (shouldWatchDir) { + console.log('watching...') + } + }); }, { // Shared options banner: { js: `/*! For license information please see psychojs-${version}.js.LEGAL.txt */`, }, bundle: true, + watch: shouldWatchDir, sourcemap: true, entryPoints: ["src/index.js"], minifySyntax: true, @@ -36,4 +51,9 @@ const [, , , dir = dirMaybe || "out", version = versionMaybe || pkg.version] = p "es2017", "node14", ], + plugins: [ + glsl({ + minify: true + }) + ] }); diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index c06df13c..a226c100 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -1,9 +1,9 @@ /** * Grating Stimulus. * - * @author Alain Pitiot + * @author Alain Pitiot, Nikita Agafonov * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -26,7 +26,6 @@ import crossShader from "./shaders/crossShader.frag"; import radRampShader from "./shaders/radRampShader.frag"; import raisedCosShader from "./shaders/raisedCosShader.frag"; - /** * Grating Stimulus. * @@ -37,51 +36,108 @@ import raisedCosShader from "./shaders/raisedCosShader.frag"; * @param {Object} options * @param {String} options.name - the name used when logging messages from this stimulus * @param {Window} options.win - the associated Window - * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image - * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask - * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {String | HTMLImageElement} [options.tex="sin"] - the name of the predefined grating texture or image resource or the HTMLImageElement corresponding to the texture + * @param {String | HTMLImageElement} [options.mask] - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {String} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {number} [options.sf=1.0] - spatial frequency of the function used in grating stimulus + * @param {number} [options.phase=1.0] - phase of the function used in grating stimulus * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) - * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) + * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) * @param {Color} [options.color= "white"] the background color * @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.contrast= 1.0] - the contrast * @param {number} [options.depth= 0] - the depth (i.e. the z order) - * @param {number} [options.texRes= 128] - the resolution of the text - * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated + * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated. NOT IMPLEMENTED YET. + * @param {String} [options.blendmode= 'avg'] - blend mode of the stimulus, determines how the stimulus is blended with the background. NOT IMPLEMENTED YET. * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { - // win, - // tex="sin", - // mask="none", - // units="", - // pos=(0.0, 0.0), - // size=None, - // sf=None, - // ori=0.0, - // phase=(0.0, 0.0), - // texRes=128, - // rgb=None, - // dkl=None, - // lms=None, - // color=(1.0, 1.0, 1.0), - // colorSpace='rgb', - // contrast=1.0, - // opacity=None, - // depth=0, - // rgbPedestal=(0.0, 0.0, 0.0), - // interpolate=False, - // blendmode='avg', - // name=None, - // autoLog=None, - // autoDraw=False, - // maskParams=None) - - static #DEFINED_FUNCTIONS = { + /** + * An object that keeps shaders source code and default uniform values for them. + * Shader source code is later used for construction of shader programs to create respective visual stimuli. + * @name module:visual.GratingStim.#SHADERS + * @type {Object} + * @property {Object} sin - Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Sine_wave} + * @property {String} sin.shader - shader source code for the sine wave stimuli + * @property {Object} sin.uniforms - default uniforms for sine wave shader + * @property {float} sin.uniforms.uFreq=1.0 - frequency of sine wave. + * @property {float} sin.uniforms.uPhase=0.0 - phase of sine wave. + * + * @property {Object} sqr - Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Square_wave} + * @property {String} sqr.shader - shader source code for the square wave stimuli + * @property {Object} sqr.uniforms - default uniforms for square wave shader + * @property {float} sqr.uniforms.uFreq=1.0 - frequency of square wave. + * @property {float} sqr.uniforms.uPhase=0.0 - phase of square wave. + * + * @property {Object} saw - Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Sawtooth_wave} + * @property {String} saw.shader - shader source code for the sawtooth wave stimuli + * @property {Object} saw.uniforms - default uniforms for sawtooth wave shader + * @property {float} saw.uniforms.uFreq=1.0 - frequency of sawtooth wave. + * @property {float} saw.uniforms.uPhase=0.0 - phase of sawtooth wave. + * + * @property {Object} tri - Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Triangle_wave} + * @property {String} tri.shader - shader source code for the triangle wave stimuli + * @property {Object} tri.uniforms - default uniforms for triangle wave shader + * @property {float} tri.uniforms.uFreq=1.0 - frequency of triangle wave. + * @property {float} tri.uniforms.uPhase=0.0 - phase of triangle wave. + * @property {float} tri.uniforms.uPeriod=1.0 - period of triangle wave. + * + * @property {Object} sinXsin - Creates an image of two 2d sine waves multiplied with each other. + * {@link https://en.wikipedia.org/wiki/Sine_wave} + * @property {String} sinXsin.shader - shader source code for the two multiplied sine waves stimuli + * @property {Object} sinXsin.uniforms - default uniforms for shader + * @property {float} sinXsin.uniforms.uFreq=1.0 - frequency of sine wave (both of them). + * @property {float} sinXsin.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * + * @property {Object} sqrXsqr - Creates an image of two 2d square waves multiplied with each other. + * {@link https://en.wikipedia.org/wiki/Square_wave} + * @property {String} sqrXsqr.shader - shader source code for the two multiplied sine waves stimuli + * @property {Object} sqrXsqr.uniforms - default uniforms for shader + * @property {float} sqrXsqr.uniforms.uFreq=1.0 - frequency of sine wave (both of them). + * @property {float} sqrXsqr.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * + * @property {Object} circle - Creates a filled circle shape with sharp edges. + * @property {String} circle.shader - shader source code for filled circle. + * @property {Object} circle.uniforms - default uniforms for shader. + * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim + * and 1.0 is circle that spans from edge to edge of the stim. + * + * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Gaussian_function} + * @property {String} gauss.shader - shader source code for Gaussian shader + * @property {Object} gauss.uniforms - default uniforms for shader + * @property {float} gauss.uniforms.uA=1.0 - A constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uB=0.0 - B constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uC=0.16 - C constant for gaussian formula (see link). + * + * @property {Object} cross - Creates a filled cross shape with sharp edges. + * @property {String} cross.shader - shader source code for cross shader + * @property {Object} cross.uniforms - default uniforms for shader + * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes + * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. + * + * @property {Object} radRamp - Creates 2d radial ramp image. + * @property {String} radRamp.shader - shader source code for radial ramp shader + * @property {Object} radRamp.uniforms - default uniforms for shader + * @property {float} radRamp.uniforms.uSqueeze=1.0 - coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large + * it fills the entire stim and Infinity makes it so tiny it's invisible. + * + * @property {Object} raisedCos - Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Raised-cosine_filter} + * @property {String} raisedCos.shader - shader source code for raised-cosine shader + * @property {Object} raisedCos.uniforms - default uniforms for shader + * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). + * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + */ + static #SHADERS = { sin: { shader: sinShader, uniforms: { @@ -114,7 +170,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) sinXsin: { shader: sinXsinShader, uniforms: { - + uFreq: 1.0, + uPhase: 0.0 } }, sqrXsqr: { @@ -127,7 +184,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) circle: { shader: circleShader, uniforms: { - + uRadius: 1.0 } }, gauss: { @@ -141,24 +198,30 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) cross: { shader: crossShader, uniforms: { - uThickness: 0.1 + uThickness: 0.2 } }, radRamp: { shader: radRampShader, uniforms: { - + uSqueeze: 1.0 } }, raisedCos: { shader: raisedCosShader, uniforms: { uBeta: 0.25, - uPeriod: 1.0 + uPeriod: 0.625 } } }; + /** + * Default size of the Grating Stimuli in pixels. + * @name module:visual.GratingStim.#DEFAULT_STIM_SIZE_PX + * @type {Array} + * @default [256, 256] + */ static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels constructor({ @@ -168,20 +231,15 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) mask, pos, units, - spatialFrequency = 1., + sf = 1.0, ori, phase, size, - rgb, - dkl, - lms, color, colorSpace, opacity, contrast, - texRes, depth, - rgbPedestal, interpolate, blendmode, autoDraw, @@ -200,14 +258,14 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) mask, ); this._addAttribute( - "spatialFrequency", - spatialFrequency, - GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uFreq || 1.0 + "SF", + sf, + GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uFreq || 1.0 : 1.0 ); this._addAttribute( "phase", phase, - GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uPhase || 0.0 + GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uPhase || 0.0 : 0.0 ); this._addAttribute( "color", @@ -221,12 +279,6 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) 1.0, this._onChange(true, false), ); - this._addAttribute( - "texRes", - texRes, - 128, - this._onChange(true, false), - ); this._addAttribute( "interpolate", interpolate, @@ -249,11 +301,11 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } /** - * Setter for the image attribute. + * Setter for the tex attribute. * - * @name module:visual.GratingStim#setImage + * @name module:visual.GratingStim#setTex * @public - * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image + * @param {HTMLImageElement | string} tex - the name of built in shader function or name of the image resource or HTMLImageElement corresponding to the image * @param {boolean} [log= false] - whether of not to log */ setTex(tex, log = false) @@ -273,7 +325,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); } - else if (GratingStim.#DEFINED_FUNCTIONS[tex] !== undefined) + else if (GratingStim.#SHADERS[tex] !== undefined) { // tex is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); @@ -335,7 +387,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); } - else if (GratingStim.#DEFINED_FUNCTIONS[mask] !== undefined) + else if (GratingStim.#SHADERS[mask] !== undefined) { // mask is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask); @@ -421,8 +473,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) * * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders * @function - * @private - * @param {String} funcName - name of the shader function. Must be one of the DEFINED_FUNCTIONS + * @protected + * @param {String} funcName - name of the shader function. Must be one of the SHADERS * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values. * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene. */ @@ -445,8 +497,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) ); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#DEFINED_FUNCTIONS[funcName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#DEFINED_FUNCTIONS[funcName].uniforms, uniforms); + const fragmentSrc = GratingStim.#SHADERS[funcName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } @@ -471,19 +523,22 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) /** * Set spatial frequency value for the function. * - * @name module:visual.GratingStim#setPhase + * @name module:visual.GratingStim#setSF * @public * @param {number} sf - spatial frequency value - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log=false] - whether or not to log */ - setSpatialFrequency (sf, log = false) { - this._setAttribute("spatialFrequency", sf, log); + setSF (sf, log = false) { + this._setAttribute("SF", sf, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uFreq = sf; } else if (this._pixi instanceof PIXI.TilingSprite) { // tileScale units are pixels, so converting function frequency to pixels // and also taking into account possible size difference between used texture and requested stim size this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width); + // since most functions defined in SHADERS assume spatial frequency change along X axis + // we assume desired effect for image based stims to be the same so tileScale.y is not affected by spatialFrequency + this._pixi.tileScale.y = this._pixi.height / this._pixi.texture.height; } } @@ -524,16 +579,16 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) height: this._size_px[1] }); this.setPhase(this._phase); - this.setSpatialFrequency(this._spatialFrequency); + this.setSF(this._SF); } else { this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { - uFreq: this._spatialFrequency, + uFreq: this._SF, uPhase: this._phase }); } - this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5); + this._pixi.pivot.set(this._pixi.width * 0.5, this._pixi.width * 0.5); // add a mask if need be: if (typeof this._mask !== "undefined") @@ -541,6 +596,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) if (this._mask instanceof HTMLImageElement) { this._pixi.mask = PIXI.Sprite.from(this._mask); + this._pixi.mask.width = this._size_px[0]; + this._pixi.mask.height = this._size_px[1]; this._pixi.addChild(this._pixi.mask); } else diff --git a/src/visual/shaders/circleShader.frag b/src/visual/shaders/circleShader.frag index 3211825a..51e9eccf 100644 --- a/src/visual/shaders/circleShader.frag +++ b/src/visual/shaders/circleShader.frag @@ -1,3 +1,13 @@ +/** + * Circle Shape. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a filled circle shape with sharp edges. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; @@ -5,9 +15,10 @@ in vec2 vUvs; out vec4 shaderOut; #define M_PI 3.14159265358979 +uniform float uRadius; void main() { vec2 uv = vUvs; - float s = 1. - step(.5, length(uv - .5)); + float s = 1. - step(uRadius, length(uv * 2. - 1.)); shaderOut = vec4(vec3(s), 1.0); } diff --git a/src/visual/shaders/crossShader.frag b/src/visual/shaders/crossShader.frag index dbff4c10..b487b9eb 100644 --- a/src/visual/shaders/crossShader.frag +++ b/src/visual/shaders/crossShader.frag @@ -1,3 +1,13 @@ +/** + * Cross Shape. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a filled cross shape with sharp edges. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; @@ -9,8 +19,8 @@ uniform float uThickness; void main() { vec2 uv = vUvs; - float sx = step(uThickness, length(uv.x - .5)); - float sy = step(uThickness, length(uv.y - .5)); + float sx = step(uThickness, length(uv.x * 2. - 1.)); + float sy = step(uThickness, length(uv.y * 2. - 1.)); float s = 1. - sx * sy; shaderOut = vec4(vec3(s), 1.0); } diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag index ed7fbecd..3ba302ca 100644 --- a/src/visual/shaders/gaussShader.frag +++ b/src/visual/shaders/gaussShader.frag @@ -1,7 +1,13 @@ -// -// Gaussian Function: -// https://en.wikipedia.org/wiki/Gaussian_function -// +/** + * Gaussian Function. + * https://en.wikipedia.org/wiki/Gaussian_function + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; diff --git a/src/visual/shaders/radRampShader.frag b/src/visual/shaders/radRampShader.frag index bbbc5867..192acd49 100644 --- a/src/visual/shaders/radRampShader.frag +++ b/src/visual/shaders/radRampShader.frag @@ -1,17 +1,24 @@ -// -// Radial ramp function -// +/** + * Radial Ramp. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d radial ramp image. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; in vec2 vUvs; out vec4 shaderOut; +uniform float uSqueeze; #define M_PI 3.14159265358979 void main() { vec2 uv = vUvs; - float s = 1. - length(uv * 2. - 1.); + float s = 1. - length(uv * 2. - 1.) * uSqueeze; shaderOut = vec4(vec3(s), 1.0); } diff --git a/src/visual/shaders/raisedCosShader.frag b/src/visual/shaders/raisedCosShader.frag index 605fbfed..05e75cde 100644 --- a/src/visual/shaders/raisedCosShader.frag +++ b/src/visual/shaders/raisedCosShader.frag @@ -1,7 +1,13 @@ -// -// Raised-cosine function: -// https://en.wikipedia.org/wiki/Raised-cosine_filter -// +/** + * Raised-cosine. + * https://en.wikipedia.org/wiki/Raised-cosine_filter + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; diff --git a/src/visual/shaders/sawShader.frag b/src/visual/shaders/sawShader.frag index ed55ceb8..0948bf73 100644 --- a/src/visual/shaders/sawShader.frag +++ b/src/visual/shaders/sawShader.frag @@ -1,7 +1,13 @@ -// -// Sawtooth wave: -// https://en.wikipedia.org/wiki/Sawtooth_wave -// +/** + * Sawtooth wave. + * https://en.wikipedia.org/wiki/Sawtooth_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index abdd5299..5e53d87b 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -1,3 +1,14 @@ +/** + * Sine wave. + * https://en.wikipedia.org/wiki/Sine_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; diff --git a/src/visual/shaders/sinXsinShader.frag b/src/visual/shaders/sinXsinShader.frag index 15435ac3..9b8ffda3 100644 --- a/src/visual/shaders/sinXsinShader.frag +++ b/src/visual/shaders/sinXsinShader.frag @@ -1,3 +1,14 @@ +/** + * Sine wave multiplied by another sine wave. + * https://en.wikipedia.org/wiki/Sine_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates an image of two 2d sine waves multiplied with each other. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; diff --git a/src/visual/shaders/sqrShader.frag b/src/visual/shaders/sqrShader.frag index dc9bd747..1d696020 100644 --- a/src/visual/shaders/sqrShader.frag +++ b/src/visual/shaders/sqrShader.frag @@ -1,7 +1,13 @@ -// -// Square wave: -// https://en.wikipedia.org/wiki/Square_wave -// +/** + * Square wave. + * https://en.wikipedia.org/wiki/Square_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; diff --git a/src/visual/shaders/sqrXsqrShader.frag b/src/visual/shaders/sqrXsqrShader.frag index 4eb6cfa2..4f320ab6 100644 --- a/src/visual/shaders/sqrXsqrShader.frag +++ b/src/visual/shaders/sqrXsqrShader.frag @@ -1,3 +1,14 @@ +/** + * Square wave multiplied by another square wave. + * https://en.wikipedia.org/wiki/Square_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates an image of two 2d square waves multiplied with each other. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; diff --git a/src/visual/shaders/triShader.frag b/src/visual/shaders/triShader.frag index 5b45ce08..445a0c4d 100644 --- a/src/visual/shaders/triShader.frag +++ b/src/visual/shaders/triShader.frag @@ -1,7 +1,13 @@ -// -// Triangle wave: -// https://en.wikipedia.org/wiki/Triangle_wave -// +/** + * Triangle wave. + * https://en.wikipedia.org/wiki/Triangle_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; From 2ce87e2224d04b8e72bdc8c73ea122fa694699b5 Mon Sep 17 00:00:00 2001 From: lgtst Date: Tue, 22 Mar 2022 12:48:38 +0300 Subject: [PATCH 10/92] indentation change - spaces to tabs --- src/visual/GratingStim.js | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index a226c100..dcf2c21b 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -105,12 +105,12 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) * @property {float} sqrXsqr.uniforms.uPhase=0.0 - phase of sine wave (both of them). * * @property {Object} circle - Creates a filled circle shape with sharp edges. - * @property {String} circle.shader - shader source code for filled circle. - * @property {Object} circle.uniforms - default uniforms for shader. - * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim - * and 1.0 is circle that spans from edge to edge of the stim. - * - * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * @property {String} circle.shader - shader source code for filled circle. + * @property {Object} circle.uniforms - default uniforms for shader. + * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim + * and 1.0 is circle that spans from edge to edge of the stim. + * + * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. * {@link https://en.wikipedia.org/wiki/Gaussian_function} * @property {String} gauss.shader - shader source code for Gaussian shader * @property {Object} gauss.uniforms - default uniforms for shader @@ -119,12 +119,12 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) * @property {float} gauss.uniforms.uC=0.16 - C constant for gaussian formula (see link). * * @property {Object} cross - Creates a filled cross shape with sharp edges. - * @property {String} cross.shader - shader source code for cross shader - * @property {Object} cross.uniforms - default uniforms for shader - * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes - * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. - * - * @property {Object} radRamp - Creates 2d radial ramp image. + * @property {String} cross.shader - shader source code for cross shader + * @property {Object} cross.uniforms - default uniforms for shader + * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes + * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. + * + * @property {Object} radRamp - Creates 2d radial ramp image. * @property {String} radRamp.shader - shader source code for radial ramp shader * @property {Object} radRamp.uniforms - default uniforms for shader * @property {float} radRamp.uniforms.uSqueeze=1.0 - coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large @@ -132,10 +132,10 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) * * @property {Object} raisedCos - Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. * {@link https://en.wikipedia.org/wiki/Raised-cosine_filter} - * @property {String} raisedCos.shader - shader source code for raised-cosine shader - * @property {Object} raisedCos.uniforms - default uniforms for shader - * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). - * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + * @property {String} raisedCos.shader - shader source code for raised-cosine shader + * @property {Object} raisedCos.uniforms - default uniforms for shader + * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). + * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). */ static #SHADERS = { sin: { @@ -497,8 +497,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) ); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#SHADERS[funcName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); + const fragmentSrc = GratingStim.#SHADERS[funcName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } From 14dcfbca7afb1505d9a93dee1c1aa99d237eae22 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Wed, 23 Mar 2022 13:42:38 +0100 Subject: [PATCH 11/92] Update README.md added Nikita Agafonov as contributor --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 5911875e..858fd9be 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,7 @@ The PsychoJS library was initially written by [Ilixa](http://www.ilixa.com) with It is now a collaborative effort, supported by the [Chan Zuckerberg Initiative](https://chanzuckerberg.com/) (2020-2021) and [Open Science Tools](https://opensciencetools.org/) (2020-): - Alain Pitiot - [@apitiot](https://github.com/apitiot) - Sotiri Bakagiannis - [@thewhodidthis](https://github.com/thewhodidthis) +- Nikita Agafonov - [@lightest](https://github.com/lightest) - Jonathan Peirce - [@peircej](https://github.com/peircej) - Thomas Pronk - [@tpronk](https://github.com/tpronk) - Hiroyuki Sogo - [@hsogo](https://github.com/hsogo) From b7c7ae7381f50153410e50884c09e26df07f1a70 Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 25 Mar 2022 15:56:52 +0300 Subject: [PATCH 12/92] TextBox fillColor, borderColor and color are now becoming transparent if no color specified, also undefined is the default color value; --- src/visual/TextBox.js | 61 +++++++++++++++++++++++++++++++++++------ src/visual/TextInput.js | 9 ++---- 2 files changed, 55 insertions(+), 15 deletions(-) diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 67a989f1..203fe535 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -1,9 +1,9 @@ /** * Editable TextBox Stimulus. * - * @author Alain Pitiot + * @author Alain Pitiot, Nikita Agafonov * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -152,20 +152,17 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "color", color, - "white", - this._onChange(true, false), + undefined ); this._addAttribute( "fillColor", fillColor, - "lightgrey", - this._onChange(true, false), + undefined ); this._addAttribute( "borderColor", borderColor, - this.fillColor, - this._onChange(true, false), + undefined ); this._addAttribute( "contrast", @@ -265,6 +262,48 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) return this._text; } + /** + * Setter for the color attribute. + * + * @name module:visual.TextBox#setColor + * @public + * @param {boolean} color - color of the text + * @param {boolean} [log= false] - whether of not to log + */ + setColor (color, log = false) { + this._setAttribute('color', color, log); + this._needUpdate = true; + this._needPixiUpdate = true; + } + + /** + * Setter for the fillColor attribute. + * + * @name module:visual.TextBox#setFillColor + * @public + * @param {boolean} fillColor - fill color of the text box + * @param {boolean} [log= false] - whether of not to log + */ + setFillColor (fillColor, log = false) { + this._setAttribute('fillColor', fillColor, log); + this._needUpdate = true; + this._needPixiUpdate = true; + } + + /** + * Setter for the borderColor attribute. + * + * @name module:visual.TextBox#setBorderColor + * @public + * @param {boolean} borderColor - border color of the text box + * @param {boolean} [log= false] - whether of not to log + */ + setBorderColor (borderColor, log = false) { + this._setAttribute('borderColor', borderColor, log); + this._needUpdate = true; + this._needPixiUpdate = true; + } + /** * Setter for the size attribute. * @@ -346,10 +385,11 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) const multiline = this._multiline; return { + // input style properties eventually become CSS, so same syntax applies input: { fontFamily: this._font, fontSize: letterHeight_px + "px", - color: new Color(this._color).hex, + color: this._color === undefined || this._color === null ? 'transparent' : new Color(this._color).hex, fontWeight: (this._bold) ? "bold" : "normal", fontStyle: (this._italic) ? "italic" : "normal", @@ -359,12 +399,15 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) height: multiline ? (height_px - 2 * padding_px) + "px" : undefined, width: (width_px - 2 * padding_px) + "px", }, + // box style properties eventually become PIXI.Graphics settings, so same syntax applies box: { fill: new Color(this._fillColor).int, + alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1, rounded: 5, stroke: { color: new Color(this._borderColor).int, width: borderWidth_px, + alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1 }, /*default: { fill: new Color(this._fillColor).int, diff --git a/src/visual/TextInput.js b/src/visual/TextInput.js index b3435078..fc116faa 100644 --- a/src/visual/TextInput.js +++ b/src/visual/TextInput.js @@ -824,17 +824,14 @@ function DefaultBoxGenerator(styles) let style = styles[state.toLowerCase()]; let box = new PIXI.Graphics(); - if (style.fill) - { - box.beginFill(style.fill); - } + box.beginFill(style.fill, style.alpha); if (style.stroke) { box.lineStyle( style.stroke.width ?? 1, - style.stroke.color ?? 0, - style.stroke.alpha ?? 1, + style.stroke.color, + style.stroke.alpha, ); } From 9c332e8dde1c8c9a3b75fd80f6cbd5f5d184ffd8 Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 28 Jan 2022 19:33:05 +0300 Subject: [PATCH 13/92] gamma correction introduced; --- package.json | 1 + src/core/PsychoJS.js | 2 ++ src/core/Window.js | 7 +++++++ 3 files changed, 10 insertions(+) diff --git a/package.json b/package.json index a4a95b9b..76ad3d0f 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "start": "npm run build" }, "dependencies": { + "@pixi/filter-adjustment": "^4.1.3", "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index 0acd9626..954c82b0 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -218,6 +218,7 @@ export class PsychoJS name, fullscr, color, + gamma, units, waitBlanking, autoLog, @@ -239,6 +240,7 @@ export class PsychoJS name, fullscr, color, + gamma, units, waitBlanking, autoLog, diff --git a/src/core/Window.js b/src/core/Window.js index 7b11cc6d..a0f0c9d3 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -8,6 +8,7 @@ */ import * as PIXI from "pixi.js-legacy"; +import {AdjustmentFilter} from "@pixi/filter-adjustment"; import { MonotonicClock } from "../util/Clock.js"; import { Color } from "../util/Color.js"; import { PsychObject } from "../util/PsychObject.js"; @@ -25,6 +26,7 @@ import { Logger } from "./Logger.js"; * @param {string} [options.name] the name of the window * @param {boolean} [options.fullscr= false] whether or not to go fullscreen * @param {Color} [options.color= Color('black')] the background color of the window + * @param {number} [options.gamma= 1] sets the delimiter for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) * @param {string} [options.units= 'pix'] the units of the window * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done * before flipping @@ -49,6 +51,7 @@ export class Window extends PsychObject name, fullscr = false, color = new Color("black"), + gamma = 1, units = "pix", waitBlanking = false, autoLog = true, @@ -64,6 +67,7 @@ export class Window extends PsychObject this._addAttribute("fullscr", fullscr); this._addAttribute("color", color); + this._addAttribute("gamma", gamma); this._addAttribute("units", units); this._addAttribute("waitBlanking", waitBlanking); this._addAttribute("autoLog", autoLog); @@ -428,6 +432,9 @@ export class Window extends PsychObject // create a top-level PIXI container: this._rootContainer = new PIXI.Container(); this._rootContainer.interactive = true; + this._rootContainer.filters = [new AdjustmentFilter({ + gamma: this.gamma + })]; // set the initial size of the PIXI renderer and the position of the root container: Window._resizePixiRenderer(this); From 3b57bd76b4be502cdeda78b668e347f8aa234366 Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 28 Jan 2022 23:51:47 +0300 Subject: [PATCH 14/92] added onChange() callback to properly reflect changes of the _gamma value in adjustmentFilter; --- src/core/Window.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/src/core/Window.js b/src/core/Window.js index a0f0c9d3..cf27c937 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -62,12 +62,19 @@ export class Window extends PsychObject // messages to be logged at the next "flip": this._msgToBeLogged = []; + // storing AdjustmentFilter instance to access later; + this._adjustmentFilter = new AdjustmentFilter({ + gamma + }); + // list of all elements, in the order they are currently drawn: this._drawList = []; this._addAttribute("fullscr", fullscr); this._addAttribute("color", color); - this._addAttribute("gamma", gamma); + this._addAttribute("gamma", gamma, 1, () => { + this._adjustmentFilter.gamma = this._gamma; + }); this._addAttribute("units", units); this._addAttribute("waitBlanking", waitBlanking); this._addAttribute("autoLog", autoLog); @@ -432,9 +439,7 @@ export class Window extends PsychObject // create a top-level PIXI container: this._rootContainer = new PIXI.Container(); this._rootContainer.interactive = true; - this._rootContainer.filters = [new AdjustmentFilter({ - gamma: this.gamma - })]; + this._rootContainer.filters = [this._adjustmentFilter]; // set the initial size of the PIXI renderer and the position of the root container: Window._resizePixiRenderer(this); From ea80090e6fcb9311fcfba49875d5f02057cb3b67 Mon Sep 17 00:00:00 2001 From: lgtst Date: Tue, 18 Jan 2022 00:57:25 +0300 Subject: [PATCH 15/92] Started Implementing GratingStim; --- src/visual/GratingStim.js | 258 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 src/visual/GratingStim.js diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js new file mode 100644 index 00000000..3c3e310b --- /dev/null +++ b/src/visual/GratingStim.js @@ -0,0 +1,258 @@ +/** + * Grating Stimulus. + * + * @author Alain Pitiot + * @version 2021.2.0 + * @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 + */ + + + +/** + * Grating Stimulus. + * + * @name module:visual.GratingStim + * @class + * @extends VisualStim + * @mixes ColorMixin + * @param {Object} options + * @param {String} options.name - the name used when logging messages from this stimulus + * @param {Window} options.win - the associated Window + * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image + * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus + * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position + * @param {number} [options.ori= 0.0] - the orientation (in degrees) + * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) + * @param {Color} [options.color= 'white'] the background color + * @param {number} [options.opacity= 1.0] - the opacity + * @param {number} [options.contrast= 1.0] - the contrast + * @param {number} [options.depth= 0] - the depth (i.e. the z order) + * @param {number} [options.texRes= 128] - the resolution of the text + * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated + * @param {boolean} [options.flipHoriz= false] - whether or not to flip horizontally + * @param {boolean} [options.flipVert= false] - whether or not to flip vertically + * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip + * @param {boolean} [options.autoLog= false] - whether or not to log + */ + + // win, + // tex="sin", + // mask="none", + // units="", + // pos=(0.0, 0.0), + // size=None, + // sf=None, + // ori=0.0, + // phase=(0.0, 0.0), + // texRes=128, + // rgb=None, + // dkl=None, + // lms=None, + // color=(1.0, 1.0, 1.0), + // colorSpace='rgb', + // contrast=1.0, + // opacity=None, + // depth=0, + // rgbPedestal=(0.0, 0.0, 0.0), + // interpolate=False, + // blendmode='avg', + // name=None, + // autoLog=None, + // autoDraw=False, + // maskParams=None) +export class GratingStim extends util.mix(VisualStim).with(ColorMixin) +{ + constructor({ + name, + tex, + win, + mask, + pos, + units, + sf, + ori, + phase, + size, + rgb, + dkl, + lms, + color, + colorSpace, + opacity, + contrast, + texRes, + depth, + rgbPedestal, + interpolate, + blendmode, + autoDraw, + autoLog, + maskParams + } = {}) + { + super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); + + this._addAttribute( + "tex", + tex, + ); + + this._addAttribute( + "mask", + mask, + ); + this._addAttribute( + "color", + color, + "white", + this._onChange(true, false), + ); + this._addAttribute( + "contrast", + contrast, + 1.0, + this._onChange(true, false), + ); + this._addAttribute( + "texRes", + texRes, + 128, + this._onChange(true, false), + ); + this._addAttribute( + "interpolate", + interpolate, + false, + this._onChange(true, false), + ); + this._addAttribute( + "flipHoriz", + flipHoriz, + false, + this._onChange(false, false), + ); + this._addAttribute( + "flipVert", + flipVert, + false, + this._onChange(false, false), + ); + + // estimate the bounding box: + this._estimateBoundingBox(); + + if (this._autoLog) + { + this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); + } + } + + /** + * Setter for the image attribute. + * + * @name module:visual.GratingStim#setImage + * @public + * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image + * @param {boolean} [log= false] - whether of not to log + */ + setTex(tex, log = false) + { + const response = { + origin: "GratingStim.setTex", + context: "when setting the texture of GratingStim: " + this._name, + }; + + try + { + // tex is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem + if (typeof tex === "undefined") + { + this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); + } + else + { + // tex is a string: it should be the name of a resource, which we load + if (typeof tex === "string") + { + tex = this.psychoJS.serverManager.getResource(tex); + } + + // tex should now be an actual HTMLImageElement: we raise an error if it is not + if (!(tex instanceof HTMLImageElement)) + { + throw "the argument: " + tex.toString() + ' is not an image" }'; + } + + this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: src= " + tex.src + ", size= " + tex.width + "x" + tex.height); + } + + const existingImage = this.getImage(); + const hasChanged = existingImage ? existingImage.src !== tex.src : true; + + this._setAttribute("tex", tex, log); + + if (hasChanged) + { + this._onChange(true, true)(); + } + } + catch (error) + { + throw Object.assign(response, { error }); + } + } + + /** + * Setter for the mask attribute. + * + * @name module:visual.GratingStim#setMask + * @public + * @param {HTMLImageElement | string} mask - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {boolean} [log= false] - whether of not to log + */ + setMask(mask, log = false) + { + const response = { + origin: "GratingStim.setMask", + context: "when setting the mask of GratingStim: " + this._name, + }; + + try + { + // mask is undefined: that's fine but we raise a warning in case this is a sympton of an actual problem + if (typeof mask === "undefined") + { + this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); + } + else + { + // mask is a string: it should be the name of a resource, which we load + if (typeof mask === "string") + { + mask = this.psychoJS.serverManager.getResource(mask); + } + + // mask should now be an actual HTMLImageElement: we raise an error if it is not + if (!(mask instanceof HTMLImageElement)) + { + throw "the argument: " + mask.toString() + ' is not an image" }'; + } + + this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height); + } + + this._setAttribute("mask", mask, log); + + this._onChange(true, false)(); + } + catch (error) + { + throw Object.assign(response, { error }); + } + } +} From f032d4c34afceb1dab5d4437ec34a25ee9034c8e Mon Sep 17 00:00:00 2001 From: lgtst Date: Wed, 26 Jan 2022 00:31:03 +0300 Subject: [PATCH 16/92] GratingStim implementation first take; --- src/visual/GratingStim.js | 297 +++++++++++++++++++++++----- src/visual/index.js | 1 + src/visual/shaders/defaultQuad.vert | 15 ++ src/visual/shaders/gaussShader.frag | 17 ++ src/visual/shaders/sinShader.frag | 15 ++ 5 files changed, 300 insertions(+), 45 deletions(-) create mode 100644 src/visual/shaders/defaultQuad.vert create mode 100644 src/visual/shaders/gaussShader.frag create mode 100644 src/visual/shaders/sinShader.frag diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index 3c3e310b..f5f67181 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -7,7 +7,29 @@ * @license Distributed under the terms of the MIT License */ +import * as PIXI from "pixi.js-legacy"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; +import defaultQuadVert from './shaders/defaultQuad.vert'; +import sinShader from './shaders/sinShader.frag'; +import gaussShader from './shaders/gaussShader.frag'; +const DEFINED_FUNCTIONS = { + sin: sinShader, + sqr: undefined, + saw: undefined, + tri: undefined, + sinXsin: undefined, + sqrXsqr: undefined, + circle: undefined, + gauss: gaussShader, + cross: undefined, + radRamp: undefined, + raisedCos: undefined +}; /** * Grating Stimulus. @@ -32,39 +54,37 @@ * @param {number} [options.depth= 0] - the depth (i.e. the z order) * @param {number} [options.texRes= 128] - the resolution of the text * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated - * @param {boolean} [options.flipHoriz= false] - whether or not to flip horizontally - * @param {boolean} [options.flipVert= false] - whether or not to flip vertically * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ - // win, - // tex="sin", - // mask="none", - // units="", - // pos=(0.0, 0.0), - // size=None, - // sf=None, - // ori=0.0, - // phase=(0.0, 0.0), - // texRes=128, - // rgb=None, - // dkl=None, - // lms=None, - // color=(1.0, 1.0, 1.0), - // colorSpace='rgb', - // contrast=1.0, - // opacity=None, - // depth=0, - // rgbPedestal=(0.0, 0.0, 0.0), - // interpolate=False, - // blendmode='avg', - // name=None, - // autoLog=None, - // autoDraw=False, - // maskParams=None) export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { + // win, + // tex="sin", + // mask="none", + // units="", + // pos=(0.0, 0.0), + // size=None, + // sf=None, + // ori=0.0, + // phase=(0.0, 0.0), + // texRes=128, + // rgb=None, + // dkl=None, + // lms=None, + // color=(1.0, 1.0, 1.0), + // colorSpace='rgb', + // contrast=1.0, + // opacity=None, + // depth=0, + // rgbPedestal=(0.0, 0.0, 0.0), + // interpolate=False, + // blendmode='avg', + // name=None, + // autoLog=None, + // autoDraw=False, + // maskParams=None) constructor({ name, tex, @@ -72,7 +92,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) mask, pos, units, - sf, + spatialFrequency = 10., ori, phase, size, @@ -99,11 +119,22 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) "tex", tex, ); - this._addAttribute( "mask", mask, ); + this._addAttribute( + "spatialFrequency", + spatialFrequency, + 10., + this._onChange(true, false) + ); + this._addAttribute( + "phase", + phase, + 0., + this._onChange(true, false) + ); this._addAttribute( "color", color, @@ -128,18 +159,6 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) false, this._onChange(true, false), ); - this._addAttribute( - "flipHoriz", - flipHoriz, - false, - this._onChange(false, false), - ); - this._addAttribute( - "flipVert", - flipVert, - false, - this._onChange(false, false), - ); // estimate the bounding box: this._estimateBoundingBox(); @@ -162,17 +181,26 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { const response = { origin: "GratingStim.setTex", - context: "when setting the texture of GratingStim: " + this._name, + context: "when setting the tex of GratingStim: " + this._name, }; try { + let hasChanged = false; + // tex is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem if (typeof tex === "undefined") { this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); } + else if (DEFINED_FUNCTIONS[tex] !== undefined) + { + // tex is a string and it is one of predefined functions available in shaders + this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); + const existingImage = this.getTex(); + hasChanged = existingImage ? existingImage !== tex : true; + } else { // tex is a string: it should be the name of a resource, which we load @@ -188,11 +216,10 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: src= " + tex.src + ", size= " + tex.width + "x" + tex.height); + const existingImage = this.getTex(); + hasChanged = existingImage ? existingImage.src !== tex.src : true; } - const existingImage = this.getImage(); - const hasChanged = existingImage ? existingImage.src !== tex.src : true; - this._setAttribute("tex", tex, log); if (hasChanged) @@ -229,6 +256,11 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); } + else if (DEFINED_FUNCTIONS[mask] !== undefined) + { + // mask is a string and it is one of predefined functions available in shaders + this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask); + } else { // mask is a string: it should be the name of a resource, which we load @@ -255,4 +287,179 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) throw Object.assign(response, { error }); } } + + /** + * Get the size of the display image, which is either that of the ImageStim or that of the image + * it contains. + * + * @name module:visual.ImageStim#_getDisplaySize + * @private + * @return {number[]} the size of the displayed image + */ + _getDisplaySize() + { + let displaySize = this.size; + + if (typeof displaySize === "undefined") + { + // use the size of the pixi element, if we have access to it: + if (typeof this._pixi !== "undefined" && this._pixi.width > 0) + { + const pixiContainerSize = [this._pixi.width, this._pixi.height]; + displaySize = util.to_unit(pixiContainerSize, "pix", this.win, this.units); + } + } + + return displaySize; + } + + /** + * Estimate the bounding box. + * + * @name module:visual.ImageStim#_estimateBoundingBox + * @function + * @override + * @protected + */ + _estimateBoundingBox() + { + const size = this._getDisplaySize(); + if (typeof size !== "undefined") + { + this._boundingBox = new PIXI.Rectangle( + this._pos[0] - size[0] / 2, + this._pos[1] - size[1] / 2, + size[0], + size[1], + ); + } + + // TODO take the orientation into account + } + + _getPixiMeshFromPredefinedShaders (funcName = '', uniforms = {}) { + const geometry = new PIXI.Geometry(); + geometry.addAttribute( + 'aVertexPosition', + [ + 0, 0, + 256, 0, + 256, 256, + 0, 256 + ], + 2 + ); + geometry.addAttribute( + 'aUvs', + [0, 0, 1, 0, 1, 1, 0, 1], + 2 + ); + geometry.addIndex([0, 1, 2, 0, 2, 3]); + const vertexSrc = defaultQuadVert; + const fragmentSrc = DEFINED_FUNCTIONS[funcName]; + const uniformsFinal = Object.assign(uniforms, { + // for future default uniforms + }); + const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); + return new PIXI.Mesh(geometry, shader); + } + + /** + * Update the stimulus, if necessary. + * + * @name module:visual.ImageStim#_updateIfNeeded + * @private + */ + _updateIfNeeded() + { + if (!this._needUpdate) + { + return; + } + this._needUpdate = false; + + // update the PIXI representation, if need be: + if (this._needPixiUpdate) + { + this._needPixiUpdate = false; + if (typeof this._pixi !== "undefined") + { + this._pixi.destroy(true); + } + this._pixi = undefined; + + // no image to draw: return immediately + if (typeof this._tex === "undefined") + { + return; + } + + if (this._tex instanceof HTMLImageElement) + { + this._pixi = PIXI.Sprite.from(this._tex); + } + else + { + this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { + uFreq: this.spatialFrequency, + uPhase: this.phase + }); + } + this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5); + + // add a mask if need be: + if (typeof this._mask !== "undefined") + { + if (this._mask instanceof HTMLImageElement) + { + this._pixi.mask = PIXI.Sprite.from(this._mask); + this._pixi.addChild(this._pixi.mask); + } + else + { + // for some reason setting PIXI.Mesh as .mask doesn't do anything, + // rendering mask to texture for further use. + const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask); + const rt = PIXI.RenderTexture.create({ + width: 256, + height: 256 + }); + this.win._renderer.render(maskMesh, { + renderTexture: rt + }); + const maskSprite = new PIXI.Sprite.from(rt); + this._pixi.mask = maskSprite; + this._pixi.addChild(maskSprite); + } + } + + // since _pixi.width may not be immediately available but the rest of the code needs its value + // we arrange for repeated calls to _updateIfNeeded until we have a width: + if (this._pixi.width === 0) + { + this._needUpdate = true; + this._needPixiUpdate = true; + return; + } + } + + this._pixi.zIndex = this._depth; + this._pixi.alpha = this.opacity; + + // set the scale: + const displaySize = this._getDisplaySize(); + const size_px = util.to_px(displaySize, this.units, this.win); + const scaleX = size_px[0] / this._pixi.width; + const scaleY = size_px[1] / this._pixi.height; + this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; + this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; + + // set the position, rotation, and anchor (image centered on pos): + let pos = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.position.set(pos.x, pos.y); + this._pixi.rotation = this.ori * Math.PI / 180; + + // re-estimate the bounding box, as the texture's width may now be available: + this._estimateBoundingBox(); + } } diff --git a/src/visual/index.js b/src/visual/index.js index 2794a47f..445e7548 100644 --- a/src/visual/index.js +++ b/src/visual/index.js @@ -1,6 +1,7 @@ export * from "./ButtonStim.js"; export * from "./Form.js"; export * from "./ImageStim.js"; +export * from "./GratingStim.js"; export * from "./MovieStim.js"; export * from "./Polygon.js"; export * from "./Rect.js"; diff --git a/src/visual/shaders/defaultQuad.vert b/src/visual/shaders/defaultQuad.vert new file mode 100644 index 00000000..1f2ea67d --- /dev/null +++ b/src/visual/shaders/defaultQuad.vert @@ -0,0 +1,15 @@ +#version 300 es + +precision mediump float; + +in vec2 aVertexPosition; +in vec2 aUvs; +out vec2 vUvs; + +uniform mat3 translationMatrix; +uniform mat3 projectionMatrix; + +void main() { + vUvs = aUvs; + gl_Position = vec4((projectionMatrix * translationMatrix * vec3(aVertexPosition, 1.0)).xy, 0.0, 1.0); +} diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag new file mode 100644 index 00000000..17543f62 --- /dev/null +++ b/src/visual/shaders/gaussShader.frag @@ -0,0 +1,17 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 + +float gauss(float x) { + return exp(-(x * x) * 20.); +} + +void main() { + vec2 uv = vUvs; + float g = gauss(uv.x - .5) * gauss(uv.y - .5); + shaderOut = vec4(vec3(g), 1.); +} diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag new file mode 100644 index 00000000..9775d64f --- /dev/null +++ b/src/visual/shaders/sinShader.frag @@ -0,0 +1,15 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float s = sin(uFreq * uv.x * 2. * M_PI + uPhase); + shaderOut = vec4(vec3(s), 1.0); +} From e822675cde8f2e6a0bea084cce4cc5fddc0c6c22 Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 28 Jan 2022 16:13:25 +0300 Subject: [PATCH 17/92] sin range chnged to [0, 1] for proper gabor patch; --- src/visual/shaders/sinShader.frag | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index 9775d64f..e4a78139 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -7,9 +7,10 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uFreq; uniform float uPhase; +uniform sampler2D uMaskTex; void main() { vec2 uv = vUvs; float s = sin(uFreq * uv.x * 2. * M_PI + uPhase); - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(.5 + .5 * vec3(s), 1.0); } From 948b14bb41a6275108e3ca71141f762626755920 Mon Sep 17 00:00:00 2001 From: lgtst Date: Fri, 28 Jan 2022 18:06:28 +0300 Subject: [PATCH 18/92] removed unused uniform uMaskTex; --- src/visual/shaders/sinShader.frag | 1 - 1 file changed, 1 deletion(-) diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index e4a78139..abdd5299 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -7,7 +7,6 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uFreq; uniform float uPhase; -uniform sampler2D uMaskTex; void main() { vec2 uv = vUvs; From ac13de28c358c37b4072d6d547be9d2df2391d30 Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 10 Feb 2022 19:54:52 +0300 Subject: [PATCH 19/92] added spatial frequency and phase support for image based grating stims; --- src/visual/GratingStim.js | 73 +++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 22 deletions(-) diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index f5f67181..b1f93ff9 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -31,6 +31,8 @@ const DEFINED_FUNCTIONS = { raisedCos: undefined }; +const DEFAULT_STIM_SIZE = [256, 256]; // in pixels + /** * Grating Stimulus. * @@ -45,7 +47,6 @@ const DEFINED_FUNCTIONS = { * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus - * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position * @param {number} [options.ori= 0.0] - the orientation (in degrees) * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) * @param {Color} [options.color= 'white'] the background color @@ -126,14 +127,12 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "spatialFrequency", spatialFrequency, - 10., - this._onChange(true, false) + 10. ); this._addAttribute( "phase", phase, - 0., - this._onChange(true, false) + 0. ); this._addAttribute( "color", @@ -167,6 +166,11 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); } + + if (!Array.isArray(this.size) || this.size.length === 0) { + this.size = util.to_unit(DEFAULT_STIM_SIZE, "pix", this.win, this.units); + } + this._sizeInPixels = util.to_px(this.size, this.units, this.win); } /** @@ -198,8 +202,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { // tex is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); - const existingImage = this.getTex(); - hasChanged = existingImage ? existingImage !== tex : true; + const curFuncName = this.getTex(); + hasChanged = curFuncName ? curFuncName !== tex : true; } else { @@ -289,10 +293,10 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } /** - * Get the size of the display image, which is either that of the ImageStim or that of the image + * Get the size of the display image, which is either that of the GratingStim or that of the image * it contains. * - * @name module:visual.ImageStim#_getDisplaySize + * @name module:visual.GratingStim#_getDisplaySize * @private * @return {number[]} the size of the displayed image */ @@ -316,7 +320,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) /** * Estimate the bounding box. * - * @name module:visual.ImageStim#_estimateBoundingBox + * @name module:visual.GratingStim#_estimateBoundingBox * @function * @override * @protected @@ -343,9 +347,9 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) 'aVertexPosition', [ 0, 0, - 256, 0, - 256, 256, - 0, 256 + this._sizeInPixels[0], 0, + this._sizeInPixels[0], this._sizeInPixels[1], + 0, this._sizeInPixels[1] ], 2 ); @@ -364,10 +368,30 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) return new PIXI.Mesh(geometry, shader); } + setPhase (phase, log = false) { + this._setAttribute("phase", phase, log); + if (this._pixi instanceof PIXI.Mesh) { + this._pixi.shader.uniforms.uPhase = phase; + } else if (this._pixi instanceof PIXI.TilingSprite) { + this._pixi.tilePosition.x = -phase * (this._sizeInPixels[0] * this._pixi.tileScale.x) / (2 * Math.PI) + } + } + + setSpatialFrequency (sf, log = false) { + this._setAttribute("spatialFrequency", sf, log); + if (this._pixi instanceof PIXI.Mesh) { + this._pixi.shader.uniforms.uFreq = sf; + } else if (this._pixi instanceof PIXI.TilingSprite) { + // tileScale units are pixels, so converting function frequency to pixels + // and also taking into account possible size difference between used texture and requested stim size + this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width); + } + } + /** * Update the stimulus, if necessary. * - * @name module:visual.ImageStim#_updateIfNeeded + * @name module:visual.GratingStim#_updateIfNeeded * @private */ _updateIfNeeded() @@ -396,13 +420,18 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) if (this._tex instanceof HTMLImageElement) { - this._pixi = PIXI.Sprite.from(this._tex); + this._pixi = PIXI.TilingSprite.from(this._tex, { + width: this._sizeInPixels[0], + height: this._sizeInPixels[1] + }); + this.setPhase(this._phase); + this.setSpatialFrequency(this._spatialFrequency); } else { this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { - uFreq: this.spatialFrequency, - uPhase: this.phase + uFreq: this._spatialFrequency, + uPhase: this._phase }); } this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5); @@ -421,8 +450,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // rendering mask to texture for further use. const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask); const rt = PIXI.RenderTexture.create({ - width: 256, - height: 256 + width: this._sizeInPixels[0], + height: this._sizeInPixels[1] }); this.win._renderer.render(maskMesh, { renderTexture: rt @@ -448,9 +477,9 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // set the scale: const displaySize = this._getDisplaySize(); - const size_px = util.to_px(displaySize, this.units, this.win); - const scaleX = size_px[0] / this._pixi.width; - const scaleY = size_px[1] / this._pixi.height; + this._sizeInPixels = util.to_px(displaySize, this.units, this.win); + const scaleX = this._sizeInPixels[0] / this._pixi.width; + const scaleY = this._sizeInPixels[1] / this._pixi.height; this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; From cb7b0617b60d3516a3eb5f142c760f4a0f9c397a Mon Sep 17 00:00:00 2001 From: lgtst Date: Wed, 2 Mar 2022 20:45:12 +0300 Subject: [PATCH 20/92] Full set of signals for grating stim; Documentation test; --- docs/visual_GratingStim.js.html | 644 ++++++++++++++++++++++++ src/visual/GratingStim.js | 195 +++++-- src/visual/shaders/circleShader.frag | 13 + src/visual/shaders/crossShader.frag | 16 + src/visual/shaders/gaussShader.frag | 17 +- src/visual/shaders/radRampShader.frag | 17 + src/visual/shaders/raisedCosShader.frag | 29 ++ src/visual/shaders/sawShader.frag | 21 + src/visual/shaders/sinXsinShader.frag | 17 + src/visual/shaders/sqrShader.frag | 20 + src/visual/shaders/sqrXsqrShader.frag | 17 + src/visual/shaders/triShader.frag | 22 + 12 files changed, 975 insertions(+), 53 deletions(-) create mode 100644 docs/visual_GratingStim.js.html create mode 100644 src/visual/shaders/circleShader.frag create mode 100644 src/visual/shaders/crossShader.frag create mode 100644 src/visual/shaders/radRampShader.frag create mode 100644 src/visual/shaders/raisedCosShader.frag create mode 100644 src/visual/shaders/sawShader.frag create mode 100644 src/visual/shaders/sinXsinShader.frag create mode 100644 src/visual/shaders/sqrShader.frag create mode 100644 src/visual/shaders/sqrXsqrShader.frag create mode 100644 src/visual/shaders/triShader.frag diff --git a/docs/visual_GratingStim.js.html b/docs/visual_GratingStim.js.html new file mode 100644 index 00000000..e6804ffb --- /dev/null +++ b/docs/visual_GratingStim.js.html @@ -0,0 +1,644 @@ + + + + + JSDoc: Source: visual/GratingStim.js + + + + + + + + + + +
    + +

    Source: visual/GratingStim.js

    + + + + + + +
    +
    +
    /**
    + * Grating Stimulus.
    + *
    + * @author Alain Pitiot
    + * @version 2021.2.0
    + * @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
    + */
    +
    +import * as PIXI from "pixi.js-legacy";
    +import { Color } from "../util/Color.js";
    +import { ColorMixin } from "../util/ColorMixin.js";
    +import { to_pixiPoint } from "../util/Pixi.js";
    +import * as util from "../util/Util.js";
    +import { VisualStim } from "./VisualStim.js";
    +import defaultQuadVert from "./shaders/defaultQuad.vert";
    +import sinShader from "./shaders/sinShader.frag";
    +import sqrShader from "./shaders/sqrShader.frag";
    +import sawShader from "./shaders/sawShader.frag";
    +import triShader from "./shaders/triShader.frag";
    +import sinXsinShader from "./shaders/sinXsinShader.frag";
    +import sqrXsqrShader from "./shaders/sqrXsqrShader.frag";
    +import circleShader from "./shaders/circleShader.frag";
    +import gaussShader from "./shaders/gaussShader.frag";
    +import crossShader from "./shaders/crossShader.frag";
    +import radRampShader from "./shaders/radRampShader.frag";
    +import raisedCosShader from "./shaders/raisedCosShader.frag";
    +
    +
    +/**
    + * Grating Stimulus.
    + *
    + * @name module:visual.GratingStim
    + * @class
    + * @extends VisualStim
    + * @mixes ColorMixin
    + * @param {Object} options
    + * @param {String} options.name - the name used when logging messages from this stimulus
    + * @param {Window} options.win - the associated Window
    + * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image
    + * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask
    + * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices)
    + * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus
    + * @param {number} [options.ori= 0.0] - the orientation (in degrees)
    + * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified)
    + * @param {Color} [options.color= "white"] the background color
    + * @param {number} [options.opacity= 1.0] - the opacity
    + * @param {number} [options.contrast= 1.0] - the contrast
    + * @param {number} [options.depth= 0] - the depth (i.e. the z order)
    + * @param {number} [options.texRes= 128] - the resolution of the text
    + * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated
    + * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
    + * @param {boolean} [options.autoLog= false] - whether or not to log
    + */
    +
    +export class GratingStim extends util.mix(VisualStim).with(ColorMixin)
    +{
    +	// win,
    +	// tex="sin",
    +	// mask="none",
    +	// units="",
    +	// pos=(0.0, 0.0),
    +	// size=None,
    +	// sf=None,
    +	// ori=0.0,
    +	// phase=(0.0, 0.0),
    +	// texRes=128,
    +	// rgb=None,
    +	// dkl=None,
    +	// lms=None,
    +	// color=(1.0, 1.0, 1.0),
    +	// colorSpace='rgb',
    +	// contrast=1.0,
    +	// opacity=None,
    +	// depth=0,
    +	// rgbPedestal=(0.0, 0.0, 0.0),
    +	// interpolate=False,
    +	// blendmode='avg',
    +	// name=None,
    +	// autoLog=None,
    +	// autoDraw=False,
    +	// maskParams=None)
    +
    +	static #DEFINED_FUNCTIONS = {
    +		sin: {
    +			shader: sinShader,
    +			uniforms: {
    +				uFreq: 1.0,
    +				uPhase: 0.0
    +			}
    +		},
    +		sqr: {
    +			shader: sqrShader,
    +			uniforms: {
    +				uFreq: 1.0,
    +				uPhase: 0.0
    +			}
    +		},
    +		saw: {
    +			shader: sawShader,
    +			uniforms: {
    +				uFreq: 1.0,
    +				uPhase: 0.0
    +			}
    +		},
    +		tri: {
    +			shader: triShader,
    +			uniforms: {
    +				uFreq: 1.0,
    +				uPhase: 0.0,
    +				uPeriod: 1.0
    +			}
    +		},
    +		sinXsin: {
    +			shader: sinXsinShader,
    +			uniforms: {
    +
    +			}
    +		},
    +		sqrXsqr: {
    +			shader: sqrXsqrShader,
    +			uniforms: {
    +				uFreq: 1.0,
    +				uPhase: 0.0
    +			}
    +		},
    +		circle: {
    +			shader: circleShader,
    +			uniforms: {
    +
    +			}
    +		},
    +		gauss: {
    +			shader: gaussShader,
    +			uniforms: {
    +				uA: 1.0,
    +				uB: 0.0,
    +				uC: 0.16
    +			}
    +		},
    +		cross: {
    +			shader: crossShader,
    +			uniforms: {
    +				uThickness: 0.1
    +			}
    +		},
    +		radRamp: {
    +			shader: radRampShader,
    +			uniforms: {
    +
    +			}
    +		},
    +		raisedCos: {
    +			shader: raisedCosShader,
    +			uniforms: {
    +				uBeta: 0.25,
    +				uPeriod: 1.0
    +			}
    +		}
    +	};
    +
    +	static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels
    +
    +	constructor({
    +		name,
    +		tex = "sin",
    +		win,
    +		mask,
    +		pos,
    +		units,
    +		spatialFrequency = 1.,
    +		ori,
    +		phase,
    +		size,
    +		rgb,
    +	    dkl,
    +	    lms,
    +		color,
    +		colorSpace,
    +		opacity,
    +		contrast,
    +		texRes,
    +		depth,
    +		rgbPedestal,
    +		interpolate,
    +		blendmode,
    +		autoDraw,
    +		autoLog,
    +		maskParams
    +	} = {})
    +	{
    +		super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog });
    +
    +		this._addAttribute(
    +			"tex",
    +			tex,
    +		);
    +		this._addAttribute(
    +			"mask",
    +			mask,
    +		);
    +		this._addAttribute(
    +			"spatialFrequency",
    +			spatialFrequency,
    +			GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uFreq || 1.0
    +		);
    +		this._addAttribute(
    +			"phase",
    +			phase,
    +			GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uPhase || 0.0
    +		);
    +		this._addAttribute(
    +			"color",
    +			color,
    +			"white",
    +			this._onChange(true, false),
    +		);
    +		this._addAttribute(
    +			"contrast",
    +			contrast,
    +			1.0,
    +			this._onChange(true, false),
    +		);
    +		this._addAttribute(
    +			"texRes",
    +			texRes,
    +			128,
    +			this._onChange(true, false),
    +		);
    +		this._addAttribute(
    +			"interpolate",
    +			interpolate,
    +			false,
    +			this._onChange(true, false),
    +		);
    +
    +		// estimate the bounding box:
    +		this._estimateBoundingBox();
    +
    +		if (this._autoLog)
    +		{
    +			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
    +		}
    +
    +		if (!Array.isArray(this.size) || this.size.length === 0) {
    +			this.size = util.to_unit(GratingStim.#DEFAULT_STIM_SIZE_PX, "pix", this.win, this.units);
    +		}
    +		this._size_px = util.to_px(this.size, this.units, this.win);
    +	}
    +
    +	/**
    +	 * Setter for the image attribute.
    +	 *
    +	 * @name module:visual.GratingStim#setImage
    +	 * @public
    +	 * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image
    +	 * @param {boolean} [log= false] - whether of not to log
    +	 */
    +	setTex(tex, log = false)
    +	{
    +		const response = {
    +			origin: "GratingStim.setTex",
    +			context: "when setting the tex of GratingStim: " + this._name,
    +		};
    +
    +		try
    +		{
    +			let hasChanged = false;
    +
    +			// tex is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem
    +			if (typeof tex === "undefined")
    +			{
    +				this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined.");
    +				this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined");
    +			}
    +			else if (GratingStim.#DEFINED_FUNCTIONS[tex] !== undefined)
    +			{
    +				// tex is a string and it is one of predefined functions available in shaders
    +				this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex);
    +				const curFuncName = this.getTex();
    +				hasChanged = curFuncName ? curFuncName !== tex : true;
    +			}
    +			else
    +			{
    +				// tex is a string: it should be the name of a resource, which we load
    +				if (typeof tex === "string")
    +				{
    +					tex = this.psychoJS.serverManager.getResource(tex);
    +				}
    +
    +				// tex should now be an actual HTMLImageElement: we raise an error if it is not
    +				if (!(tex instanceof HTMLImageElement))
    +				{
    +					throw "the argument: " + tex.toString() + " is not an image\" }";
    +				}
    +
    +				this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: src= " + tex.src + ", size= " + tex.width + "x" + tex.height);
    +				const existingImage = this.getTex();
    +				hasChanged = existingImage ? existingImage.src !== tex.src : true;
    +			}
    +
    +			this._setAttribute("tex", tex, log);
    +
    +			if (hasChanged)
    +			{
    +				this._onChange(true, true)();
    +			}
    +		}
    +		catch (error)
    +		{
    +			throw Object.assign(response, { error });
    +		}
    +	}
    +
    +	/**
    +	 * Setter for the mask attribute.
    +	 *
    +	 * @name module:visual.GratingStim#setMask
    +	 * @public
    +	 * @param {HTMLImageElement | string} mask - the name of the mask resource or HTMLImageElement corresponding to the mask
    +	 * @param {boolean} [log= false] - whether of not to log
    +	 */
    +	setMask(mask, log = false)
    +	{
    +		const response = {
    +			origin: "GratingStim.setMask",
    +			context: "when setting the mask of GratingStim: " + this._name,
    +		};
    +
    +		try
    +		{
    +			// mask is undefined: that's fine but we raise a warning in case this is a sympton of an actual problem
    +			if (typeof mask === "undefined")
    +			{
    +				this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined.");
    +				this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined");
    +			}
    +			else if (GratingStim.#DEFINED_FUNCTIONS[mask] !== undefined)
    +			{
    +				// mask is a string and it is one of predefined functions available in shaders
    +				this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask);
    +			}
    +			else
    +			{
    +				// mask is a string: it should be the name of a resource, which we load
    +				if (typeof mask === "string")
    +				{
    +					mask = this.psychoJS.serverManager.getResource(mask);
    +				}
    +
    +				// mask should now be an actual HTMLImageElement: we raise an error if it is not
    +				if (!(mask instanceof HTMLImageElement))
    +				{
    +					throw "the argument: " + mask.toString() + " is not an image\" }";
    +				}
    +
    +				this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height);
    +			}
    +
    +			this._setAttribute("mask", mask, log);
    +
    +			this._onChange(true, false)();
    +		}
    +		catch (error)
    +		{
    +			throw Object.assign(response, { error });
    +		}
    +	}
    +
    +	/**
    +	 * Get the size of the display image, which is either that of the GratingStim or that of the image
    +	 * it contains.
    +	 *
    +	 * @name module:visual.GratingStim#_getDisplaySize
    +	 * @private
    +	 * @return {number[]} the size of the displayed image
    +	 */
    +	_getDisplaySize()
    +	{
    +		let displaySize = this.size;
    +
    +		if (typeof displaySize === "undefined")
    +		{
    +			// use the size of the pixi element, if we have access to it:
    +			if (typeof this._pixi !== "undefined" && this._pixi.width > 0)
    +			{
    +				const pixiContainerSize = [this._pixi.width, this._pixi.height];
    +				displaySize = util.to_unit(pixiContainerSize, "pix", this.win, this.units);
    +			}
    +		}
    +
    +		return displaySize;
    +	}
    +
    +	/**
    +	 * Estimate the bounding box.
    +	 *
    +	 * @name module:visual.GratingStim#_estimateBoundingBox
    +	 * @function
    +	 * @override
    +	 * @protected
    +	 */
    +	_estimateBoundingBox()
    +	{
    +		const size = this._getDisplaySize();
    +		if (typeof size !== "undefined")
    +		{
    +			this._boundingBox = new PIXI.Rectangle(
    +				this._pos[0] - size[0] / 2,
    +				this._pos[1] - size[1] / 2,
    +				size[0],
    +				size[1],
    +			);
    +		}
    +
    +		// TODO take the orientation into account
    +	}
    +
    +	/**
    +	 * Generate PIXI.Mesh object based on provided shader function name and uniforms.
    +	 * 
    +	 * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders
    +	 * @function
    +	 * @private
    +	 * @param {String} funcName - name of the shader function. Must be one of the DEFINED_FUNCTIONS
    +	 * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values.
    +	 * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene.
    +	 */
    +	_getPixiMeshFromPredefinedShaders (funcName = "", uniforms = {}) {
    +		const geometry = new PIXI.Geometry();
    +		geometry.addAttribute(
    +			"aVertexPosition",
    +			[
    +				0, 0,
    +				this._size_px[0], 0,
    +				this._size_px[0], this._size_px[1],
    +				0, this._size_px[1]
    +			],
    +			2
    +		);
    +		geometry.addAttribute(
    +			"aUvs",
    +			[0, 0, 1, 0, 1, 1, 0, 1],
    +			2
    +		);
    +		geometry.addIndex([0, 1, 2, 0, 2, 3]);
    +		const vertexSrc = defaultQuadVert;
    +	    const fragmentSrc = GratingStim.#DEFINED_FUNCTIONS[funcName].shader;
    +	    const uniformsFinal = Object.assign({}, GratingStim.#DEFINED_FUNCTIONS[funcName].uniforms, uniforms);
    +		const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal);
    +		return new PIXI.Mesh(geometry, shader);
    +	}
    +
    +	/**
    +	 * Set phase value for the function.
    +	 * 
    +	 * @name module:visual.GratingStim#setPhase
    +	 * @public
    +	 * @param {number} phase - phase value
    +	 * @param {boolean} [log= false] - whether of not to log
    +	 */ 
    +	setPhase (phase, log = false) {
    +		this._setAttribute("phase", phase, log);
    +		if (this._pixi instanceof PIXI.Mesh) {
    +			this._pixi.shader.uniforms.uPhase = phase;
    +		} else if (this._pixi instanceof PIXI.TilingSprite) {
    +			this._pixi.tilePosition.x = -phase * (this._size_px[0] * this._pixi.tileScale.x) / (2 * Math.PI)
    +		}
    +	}
    +
    +	/**
    +	 * Set spatial frequency value for the function.
    +	 * 
    +	 * @name module:visual.GratingStim#setPhase
    +	 * @public
    +	 * @param {number} sf - spatial frequency value
    +	 * @param {boolean} [log= false] - whether of not to log
    +	 */ 
    +	setSpatialFrequency (sf, log = false) {
    +		this._setAttribute("spatialFrequency", sf, log);
    +		if (this._pixi instanceof PIXI.Mesh) {
    +			this._pixi.shader.uniforms.uFreq = sf;
    +		} else if (this._pixi instanceof PIXI.TilingSprite) {
    +			// tileScale units are pixels, so converting function frequency to pixels
    +			// and also taking into account possible size difference between used texture and requested stim size
    +			this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width);
    +		}
    +	}
    +
    +	/**
    +	 * Update the stimulus, if necessary.
    +	 *
    +	 * @name module:visual.GratingStim#_updateIfNeeded
    +	 * @private
    +	 */
    +	_updateIfNeeded()
    +	{
    +		if (!this._needUpdate)
    +		{
    +			return;
    +		}
    +		this._needUpdate = false;
    +
    +		// update the PIXI representation, if need be:
    +		if (this._needPixiUpdate)
    +		{
    +			this._needPixiUpdate = false;
    +			if (typeof this._pixi !== "undefined")
    +			{
    +				this._pixi.destroy(true);
    +			}
    +			this._pixi = undefined;
    +
    +			// no image to draw: return immediately
    +			if (typeof this._tex === "undefined")
    +			{
    +				return;
    +			}
    +
    +			if (this._tex instanceof HTMLImageElement)
    +			{
    +				this._pixi = PIXI.TilingSprite.from(this._tex, {
    +					width: this._size_px[0],
    +					height: this._size_px[1]
    +				});
    +				this.setPhase(this._phase);
    +				this.setSpatialFrequency(this._spatialFrequency);
    +			}
    +			else
    +			{
    +				this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, {
    +					uFreq: this._spatialFrequency,
    +					uPhase: this._phase
    +				});
    +			}
    +			this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5);
    +
    +			// add a mask if need be:
    +			if (typeof this._mask !== "undefined")
    +			{
    +				if (this._mask instanceof HTMLImageElement)
    +				{
    +					this._pixi.mask = PIXI.Sprite.from(this._mask);
    +					this._pixi.addChild(this._pixi.mask);
    +				}
    +				else
    +				{
    +					// for some reason setting PIXI.Mesh as .mask doesn't do anything,
    +					// rendering mask to texture for further use.
    +					const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask);
    +					const rt = PIXI.RenderTexture.create({
    +						width: this._size_px[0],
    +						height: this._size_px[1]
    +					});
    +					this.win._renderer.render(maskMesh, {
    +						renderTexture: rt
    +					});
    +					const maskSprite = new PIXI.Sprite.from(rt);
    +					this._pixi.mask = maskSprite;
    +					this._pixi.addChild(maskSprite);
    +				}
    +			}
    +
    +			// since _pixi.width may not be immediately available but the rest of the code needs its value
    +			// we arrange for repeated calls to _updateIfNeeded until we have a width:
    +			if (this._pixi.width === 0)
    +			{
    +				this._needUpdate = true;
    +				this._needPixiUpdate = true;
    +				return;
    +			}
    +		}
    +
    +		this._pixi.zIndex = this._depth;
    +		this._pixi.alpha = this.opacity;
    +
    +		// set the scale:
    +		const displaySize = this._getDisplaySize();
    +		this._size_px = util.to_px(displaySize, this.units, this.win);
    +		const scaleX = this._size_px[0] / this._pixi.width;
    +		const scaleY = this._size_px[1] / this._pixi.height;
    +		this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX;
    +		this._pixi.scale.y = this.flipVert ? scaleY : -scaleY;
    +
    +		// set the position, rotation, and anchor (image centered on pos):
    +		let pos = to_pixiPoint(this.pos, this.units, this.win);
    +		this._pixi.position.set(pos.x, pos.y);
    +		this._pixi.rotation = this.ori * Math.PI / 180;
    +
    +		// re-estimate the bounding box, as the texture's width may now be available:
    +		this._estimateBoundingBox();
    +	}
    +}
    +
    +
    +
    + + + + +
    + + + +
    + +
    + Documentation generated by JSDoc 3.6.7 on Wed Mar 02 2022 20:43:58 GMT+0300 (Moscow Standard Time) +
    + + + + + diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index b1f93ff9..c06df13c 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -13,25 +13,19 @@ import { ColorMixin } from "../util/ColorMixin.js"; import { to_pixiPoint } from "../util/Pixi.js"; import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; -import defaultQuadVert from './shaders/defaultQuad.vert'; -import sinShader from './shaders/sinShader.frag'; -import gaussShader from './shaders/gaussShader.frag'; - -const DEFINED_FUNCTIONS = { - sin: sinShader, - sqr: undefined, - saw: undefined, - tri: undefined, - sinXsin: undefined, - sqrXsqr: undefined, - circle: undefined, - gauss: gaussShader, - cross: undefined, - radRamp: undefined, - raisedCos: undefined -}; - -const DEFAULT_STIM_SIZE = [256, 256]; // in pixels +import defaultQuadVert from "./shaders/defaultQuad.vert"; +import sinShader from "./shaders/sinShader.frag"; +import sqrShader from "./shaders/sqrShader.frag"; +import sawShader from "./shaders/sawShader.frag"; +import triShader from "./shaders/triShader.frag"; +import sinXsinShader from "./shaders/sinXsinShader.frag"; +import sqrXsqrShader from "./shaders/sqrXsqrShader.frag"; +import circleShader from "./shaders/circleShader.frag"; +import gaussShader from "./shaders/gaussShader.frag"; +import crossShader from "./shaders/crossShader.frag"; +import radRampShader from "./shaders/radRampShader.frag"; +import raisedCosShader from "./shaders/raisedCosShader.frag"; + /** * Grating Stimulus. @@ -49,7 +43,7 @@ const DEFAULT_STIM_SIZE = [256, 256]; // in pixels * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) - * @param {Color} [options.color= 'white'] the background color + * @param {Color} [options.color= "white"] the background color * @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.contrast= 1.0] - the contrast * @param {number} [options.depth= 0] - the depth (i.e. the z order) @@ -86,14 +80,95 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // autoLog=None, // autoDraw=False, // maskParams=None) + + static #DEFINED_FUNCTIONS = { + sin: { + shader: sinShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0 + } + }, + sqr: { + shader: sqrShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0 + } + }, + saw: { + shader: sawShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0 + } + }, + tri: { + shader: triShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uPeriod: 1.0 + } + }, + sinXsin: { + shader: sinXsinShader, + uniforms: { + + } + }, + sqrXsqr: { + shader: sqrXsqrShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0 + } + }, + circle: { + shader: circleShader, + uniforms: { + + } + }, + gauss: { + shader: gaussShader, + uniforms: { + uA: 1.0, + uB: 0.0, + uC: 0.16 + } + }, + cross: { + shader: crossShader, + uniforms: { + uThickness: 0.1 + } + }, + radRamp: { + shader: radRampShader, + uniforms: { + + } + }, + raisedCos: { + shader: raisedCosShader, + uniforms: { + uBeta: 0.25, + uPeriod: 1.0 + } + } + }; + + static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels + constructor({ name, - tex, + tex = "sin", win, mask, pos, units, - spatialFrequency = 10., + spatialFrequency = 1., ori, phase, size, @@ -127,12 +202,12 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "spatialFrequency", spatialFrequency, - 10. + GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uFreq || 1.0 ); this._addAttribute( "phase", phase, - 0. + GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uPhase || 0.0 ); this._addAttribute( "color", @@ -168,9 +243,9 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } if (!Array.isArray(this.size) || this.size.length === 0) { - this.size = util.to_unit(DEFAULT_STIM_SIZE, "pix", this.win, this.units); + this.size = util.to_unit(GratingStim.#DEFAULT_STIM_SIZE_PX, "pix", this.win, this.units); } - this._sizeInPixels = util.to_px(this.size, this.units, this.win); + this._size_px = util.to_px(this.size, this.units, this.win); } /** @@ -198,7 +273,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); } - else if (DEFINED_FUNCTIONS[tex] !== undefined) + else if (GratingStim.#DEFINED_FUNCTIONS[tex] !== undefined) { // tex is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); @@ -216,7 +291,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // tex should now be an actual HTMLImageElement: we raise an error if it is not if (!(tex instanceof HTMLImageElement)) { - throw "the argument: " + tex.toString() + ' is not an image" }'; + throw "the argument: " + tex.toString() + " is not an image\" }"; } this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: src= " + tex.src + ", size= " + tex.width + "x" + tex.height); @@ -260,7 +335,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); } - else if (DEFINED_FUNCTIONS[mask] !== undefined) + else if (GratingStim.#DEFINED_FUNCTIONS[mask] !== undefined) { // mask is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask); @@ -276,7 +351,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // mask should now be an actual HTMLImageElement: we raise an error if it is not if (!(mask instanceof HTMLImageElement)) { - throw "the argument: " + mask.toString() + ' is not an image" }'; + throw "the argument: " + mask.toString() + " is not an image\" }"; } this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height); @@ -341,42 +416,66 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // TODO take the orientation into account } - _getPixiMeshFromPredefinedShaders (funcName = '', uniforms = {}) { + /** + * Generate PIXI.Mesh object based on provided shader function name and uniforms. + * + * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders + * @function + * @private + * @param {String} funcName - name of the shader function. Must be one of the DEFINED_FUNCTIONS + * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values. + * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene. + */ + _getPixiMeshFromPredefinedShaders (funcName = "", uniforms = {}) { const geometry = new PIXI.Geometry(); geometry.addAttribute( - 'aVertexPosition', + "aVertexPosition", [ 0, 0, - this._sizeInPixels[0], 0, - this._sizeInPixels[0], this._sizeInPixels[1], - 0, this._sizeInPixels[1] + this._size_px[0], 0, + this._size_px[0], this._size_px[1], + 0, this._size_px[1] ], 2 ); geometry.addAttribute( - 'aUvs', + "aUvs", [0, 0, 1, 0, 1, 1, 0, 1], 2 ); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = DEFINED_FUNCTIONS[funcName]; - const uniformsFinal = Object.assign(uniforms, { - // for future default uniforms - }); + const fragmentSrc = GratingStim.#DEFINED_FUNCTIONS[funcName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#DEFINED_FUNCTIONS[funcName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } + /** + * Set phase value for the function. + * + * @name module:visual.GratingStim#setPhase + * @public + * @param {number} phase - phase value + * @param {boolean} [log= false] - whether of not to log + */ setPhase (phase, log = false) { this._setAttribute("phase", phase, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uPhase = phase; } else if (this._pixi instanceof PIXI.TilingSprite) { - this._pixi.tilePosition.x = -phase * (this._sizeInPixels[0] * this._pixi.tileScale.x) / (2 * Math.PI) + this._pixi.tilePosition.x = -phase * (this._size_px[0] * this._pixi.tileScale.x) / (2 * Math.PI) } } + /** + * Set spatial frequency value for the function. + * + * @name module:visual.GratingStim#setPhase + * @public + * @param {number} sf - spatial frequency value + * @param {boolean} [log= false] - whether of not to log + */ setSpatialFrequency (sf, log = false) { this._setAttribute("spatialFrequency", sf, log); if (this._pixi instanceof PIXI.Mesh) { @@ -421,8 +520,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) if (this._tex instanceof HTMLImageElement) { this._pixi = PIXI.TilingSprite.from(this._tex, { - width: this._sizeInPixels[0], - height: this._sizeInPixels[1] + width: this._size_px[0], + height: this._size_px[1] }); this.setPhase(this._phase); this.setSpatialFrequency(this._spatialFrequency); @@ -450,8 +549,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // rendering mask to texture for further use. const maskMesh = this._getPixiMeshFromPredefinedShaders(this._mask); const rt = PIXI.RenderTexture.create({ - width: this._sizeInPixels[0], - height: this._sizeInPixels[1] + width: this._size_px[0], + height: this._size_px[1] }); this.win._renderer.render(maskMesh, { renderTexture: rt @@ -477,9 +576,9 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // set the scale: const displaySize = this._getDisplaySize(); - this._sizeInPixels = util.to_px(displaySize, this.units, this.win); - const scaleX = this._sizeInPixels[0] / this._pixi.width; - const scaleY = this._sizeInPixels[1] / this._pixi.height; + this._size_px = util.to_px(displaySize, this.units, this.win); + const scaleX = this._size_px[0] / this._pixi.width; + const scaleY = this._size_px[1] / this._pixi.height; this._pixi.scale.x = this.flipHoriz ? -scaleX : scaleX; this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; diff --git a/src/visual/shaders/circleShader.frag b/src/visual/shaders/circleShader.frag new file mode 100644 index 00000000..3211825a --- /dev/null +++ b/src/visual/shaders/circleShader.frag @@ -0,0 +1,13 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 + +void main() { + vec2 uv = vUvs; + float s = 1. - step(.5, length(uv - .5)); + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/crossShader.frag b/src/visual/shaders/crossShader.frag new file mode 100644 index 00000000..dbff4c10 --- /dev/null +++ b/src/visual/shaders/crossShader.frag @@ -0,0 +1,16 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uThickness; + +void main() { + vec2 uv = vUvs; + float sx = step(uThickness, length(uv.x - .5)); + float sy = step(uThickness, length(uv.y - .5)); + float s = 1. - sx * sy; + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag index 17543f62..ed7fbecd 100644 --- a/src/visual/shaders/gaussShader.frag +++ b/src/visual/shaders/gaussShader.frag @@ -1,17 +1,24 @@ +// +// Gaussian Function: +// https://en.wikipedia.org/wiki/Gaussian_function +// + #version 300 es precision mediump float; in vec2 vUvs; out vec4 shaderOut; -#define M_PI 3.14159265358979 +uniform float uA; +uniform float uB; +uniform float uC; -float gauss(float x) { - return exp(-(x * x) * 20.); -} +#define M_PI 3.14159265358979 void main() { vec2 uv = vUvs; - float g = gauss(uv.x - .5) * gauss(uv.y - .5); + float c2 = uC * uC; + float x = length(uv - .5); + float g = uA * exp(-pow(x - uB, 2.) / c2 * .5); shaderOut = vec4(vec3(g), 1.); } diff --git a/src/visual/shaders/radRampShader.frag b/src/visual/shaders/radRampShader.frag new file mode 100644 index 00000000..bbbc5867 --- /dev/null +++ b/src/visual/shaders/radRampShader.frag @@ -0,0 +1,17 @@ +// +// Radial ramp function +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 + +void main() { + vec2 uv = vUvs; + float s = 1. - length(uv * 2. - 1.); + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/raisedCosShader.frag b/src/visual/shaders/raisedCosShader.frag new file mode 100644 index 00000000..605fbfed --- /dev/null +++ b/src/visual/shaders/raisedCosShader.frag @@ -0,0 +1,29 @@ +// +// Raised-cosine function: +// https://en.wikipedia.org/wiki/Raised-cosine_filter +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uBeta; +uniform float uPeriod; + +void main() { + vec2 uv = vUvs; + float absX = length(uv * 2. - 1.); + float edgeArgument1 = (1. - uBeta) / (2. * uPeriod); + float edgeArgument2 = (1. + uBeta) / (2. * uPeriod); + float frequencyFactor = (M_PI * uPeriod) / uBeta; + float s = .5 * (1. + cos(frequencyFactor * (absX - edgeArgument1))); + if (absX <= edgeArgument1) { + s = 1.; + } else if (absX > edgeArgument2) { + s = 0.; + } + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/sawShader.frag b/src/visual/shaders/sawShader.frag new file mode 100644 index 00000000..ed55ceb8 --- /dev/null +++ b/src/visual/shaders/sawShader.frag @@ -0,0 +1,21 @@ +// +// Sawtooth wave: +// https://en.wikipedia.org/wiki/Sawtooth_wave +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float s = uFreq * uv.x + uPhase; + s = mod(s, 1.); + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/sinXsinShader.frag b/src/visual/shaders/sinXsinShader.frag new file mode 100644 index 00000000..15435ac3 --- /dev/null +++ b/src/visual/shaders/sinXsinShader.frag @@ -0,0 +1,17 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float sx = sin(uFreq * uv.x * 2. * M_PI + uPhase); + float sy = sin(uFreq * uv.y * 2. * M_PI + uPhase); + float s = sx * sy * .5 + .5; + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/sqrShader.frag b/src/visual/shaders/sqrShader.frag new file mode 100644 index 00000000..dc9bd747 --- /dev/null +++ b/src/visual/shaders/sqrShader.frag @@ -0,0 +1,20 @@ +// +// Square wave: +// https://en.wikipedia.org/wiki/Square_wave +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float s = sign(sin(uFreq * uv.x * 2. * M_PI + uPhase)); + shaderOut = vec4(.5 + .5 * vec3(s), 1.0); +} diff --git a/src/visual/shaders/sqrXsqrShader.frag b/src/visual/shaders/sqrXsqrShader.frag new file mode 100644 index 00000000..4eb6cfa2 --- /dev/null +++ b/src/visual/shaders/sqrXsqrShader.frag @@ -0,0 +1,17 @@ +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; + +void main() { + vec2 uv = vUvs; + float sx = sign(sin(uFreq * uv.x * 2. * M_PI + uPhase)); + float sy = sign(sin(uFreq * uv.y * 2. * M_PI + uPhase)); + float s = sx * sy * .5 + .5; + shaderOut = vec4(vec3(s), 1.0); +} diff --git a/src/visual/shaders/triShader.frag b/src/visual/shaders/triShader.frag new file mode 100644 index 00000000..5b45ce08 --- /dev/null +++ b/src/visual/shaders/triShader.frag @@ -0,0 +1,22 @@ +// +// Triangle wave: +// https://en.wikipedia.org/wiki/Triangle_wave +// + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform float uFreq; +uniform float uPhase; +uniform float uPeriod; + +void main() { + vec2 uv = vUvs; + float s = uFreq * uv.x + uPhase; + s = 2. * abs(s / uPeriod - floor(s / uPeriod + .5)); + shaderOut = vec4(vec3(s), 1.0); +} From 5baa60dbeeb995dbf26a6d9c79025e1177e78ef0 Mon Sep 17 00:00:00 2001 From: lgtst Date: Mon, 21 Mar 2022 23:28:54 +0300 Subject: [PATCH 21/92] rebasing branches --- docs/visual_GratingStim.js.html | 205 +++++++++++++++--------- package.json | 1 + scripts/build.js.cjs | 26 ++- src/visual/GratingStim.js | 203 ++++++++++++++--------- src/visual/shaders/circleShader.frag | 13 +- src/visual/shaders/crossShader.frag | 14 +- src/visual/shaders/gaussShader.frag | 14 +- src/visual/shaders/radRampShader.frag | 15 +- src/visual/shaders/raisedCosShader.frag | 14 +- src/visual/shaders/sawShader.frag | 14 +- src/visual/shaders/sinShader.frag | 11 ++ src/visual/shaders/sinXsinShader.frag | 11 ++ src/visual/shaders/sqrShader.frag | 14 +- src/visual/shaders/sqrXsqrShader.frag | 11 ++ src/visual/shaders/triShader.frag | 14 +- 15 files changed, 403 insertions(+), 177 deletions(-) diff --git a/docs/visual_GratingStim.js.html b/docs/visual_GratingStim.js.html index e6804ffb..80cfac39 100644 --- a/docs/visual_GratingStim.js.html +++ b/docs/visual_GratingStim.js.html @@ -29,9 +29,9 @@

    Source: visual/GratingStim.js

    /**
      * Grating Stimulus.
      *
    - * @author Alain Pitiot
    + * @author Alain Pitiot, Nikita Agafonov
      * @version 2021.2.0
    - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
    + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
      * @license Distributed under the terms of the MIT License
      */
     
    @@ -54,7 +54,6 @@ 

    Source: visual/GratingStim.js

    import radRampShader from "./shaders/radRampShader.frag"; import raisedCosShader from "./shaders/raisedCosShader.frag"; - /** * Grating Stimulus. * @@ -65,51 +64,108 @@

    Source: visual/GratingStim.js

    * @param {Object} options * @param {String} options.name - the name used when logging messages from this stimulus * @param {Window} options.win - the associated Window - * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image - * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask - * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {String | HTMLImageElement} [options.tex="sin"] - the name of the predefined grating texture or image resource or the HTMLImageElement corresponding to the texture + * @param {String | HTMLImageElement} [options.mask] - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {String} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {number} [options.sf=1.0] - spatial frequency of the function used in grating stimulus + * @param {number} [options.phase=1.0] - phase of the function used in grating stimulus * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) - * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) + * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) * @param {Color} [options.color= "white"] the background color * @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.contrast= 1.0] - the contrast * @param {number} [options.depth= 0] - the depth (i.e. the z order) - * @param {number} [options.texRes= 128] - the resolution of the text - * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated + * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated. NOT IMPLEMENTED YET. + * @param {String} [options.blendmode= 'avg'] - blend mode of the stimulus, determines how the stimulus is blended with the background. NOT IMPLEMENTED YET. * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { - // win, - // tex="sin", - // mask="none", - // units="", - // pos=(0.0, 0.0), - // size=None, - // sf=None, - // ori=0.0, - // phase=(0.0, 0.0), - // texRes=128, - // rgb=None, - // dkl=None, - // lms=None, - // color=(1.0, 1.0, 1.0), - // colorSpace='rgb', - // contrast=1.0, - // opacity=None, - // depth=0, - // rgbPedestal=(0.0, 0.0, 0.0), - // interpolate=False, - // blendmode='avg', - // name=None, - // autoLog=None, - // autoDraw=False, - // maskParams=None) - - static #DEFINED_FUNCTIONS = { + /** + * An object that keeps shaders source code and default uniform values for them. + * Shader source code is later used for construction of shader programs to create respective visual stimuli. + * @name module:visual.GratingStim.#SHADERS + * @type {Object} + * @property {Object} sin - Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Sine_wave} + * @property {String} sin.shader - shader source code for the sine wave stimuli + * @property {Object} sin.uniforms - default uniforms for sine wave shader + * @property {float} sin.uniforms.uFreq=1.0 - frequency of sine wave. + * @property {float} sin.uniforms.uPhase=0.0 - phase of sine wave. + * + * @property {Object} sqr - Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Square_wave} + * @property {String} sqr.shader - shader source code for the square wave stimuli + * @property {Object} sqr.uniforms - default uniforms for square wave shader + * @property {float} sqr.uniforms.uFreq=1.0 - frequency of square wave. + * @property {float} sqr.uniforms.uPhase=0.0 - phase of square wave. + * + * @property {Object} saw - Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Sawtooth_wave} + * @property {String} saw.shader - shader source code for the sawtooth wave stimuli + * @property {Object} saw.uniforms - default uniforms for sawtooth wave shader + * @property {float} saw.uniforms.uFreq=1.0 - frequency of sawtooth wave. + * @property {float} saw.uniforms.uPhase=0.0 - phase of sawtooth wave. + * + * @property {Object} tri - Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Triangle_wave} + * @property {String} tri.shader - shader source code for the triangle wave stimuli + * @property {Object} tri.uniforms - default uniforms for triangle wave shader + * @property {float} tri.uniforms.uFreq=1.0 - frequency of triangle wave. + * @property {float} tri.uniforms.uPhase=0.0 - phase of triangle wave. + * @property {float} tri.uniforms.uPeriod=1.0 - period of triangle wave. + * + * @property {Object} sinXsin - Creates an image of two 2d sine waves multiplied with each other. + * {@link https://en.wikipedia.org/wiki/Sine_wave} + * @property {String} sinXsin.shader - shader source code for the two multiplied sine waves stimuli + * @property {Object} sinXsin.uniforms - default uniforms for shader + * @property {float} sinXsin.uniforms.uFreq=1.0 - frequency of sine wave (both of them). + * @property {float} sinXsin.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * + * @property {Object} sqrXsqr - Creates an image of two 2d square waves multiplied with each other. + * {@link https://en.wikipedia.org/wiki/Square_wave} + * @property {String} sqrXsqr.shader - shader source code for the two multiplied sine waves stimuli + * @property {Object} sqrXsqr.uniforms - default uniforms for shader + * @property {float} sqrXsqr.uniforms.uFreq=1.0 - frequency of sine wave (both of them). + * @property {float} sqrXsqr.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * + * @property {Object} circle - Creates a filled circle shape with sharp edges. + * @property {String} circle.shader - shader source code for filled circle. + * @property {Object} circle.uniforms - default uniforms for shader. + * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim + * and 1.0 is circle that spans from edge to edge of the stim. + * + * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Gaussian_function} + * @property {String} gauss.shader - shader source code for Gaussian shader + * @property {Object} gauss.uniforms - default uniforms for shader + * @property {float} gauss.uniforms.uA=1.0 - A constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uB=0.0 - B constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uC=0.16 - C constant for gaussian formula (see link). + * + * @property {Object} cross - Creates a filled cross shape with sharp edges. + * @property {String} cross.shader - shader source code for cross shader + * @property {Object} cross.uniforms - default uniforms for shader + * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes + * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. + * + * @property {Object} radRamp - Creates 2d radial ramp image. + * @property {String} radRamp.shader - shader source code for radial ramp shader + * @property {Object} radRamp.uniforms - default uniforms for shader + * @property {float} radRamp.uniforms.uSqueeze=1.0 - coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large + * it fills the entire stim and Infinity makes it so tiny it's invisible. + * + * @property {Object} raisedCos - Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Raised-cosine_filter} + * @property {String} raisedCos.shader - shader source code for raised-cosine shader + * @property {Object} raisedCos.uniforms - default uniforms for shader + * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). + * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + */ + static #SHADERS = { sin: { shader: sinShader, uniforms: { @@ -142,7 +198,8 @@

    Source: visual/GratingStim.js

    sinXsin: { shader: sinXsinShader, uniforms: { - + uFreq: 1.0, + uPhase: 0.0 } }, sqrXsqr: { @@ -155,7 +212,7 @@

    Source: visual/GratingStim.js

    circle: { shader: circleShader, uniforms: { - + uRadius: 1.0 } }, gauss: { @@ -169,24 +226,30 @@

    Source: visual/GratingStim.js

    cross: { shader: crossShader, uniforms: { - uThickness: 0.1 + uThickness: 0.2 } }, radRamp: { shader: radRampShader, uniforms: { - + uSqueeze: 1.0 } }, raisedCos: { shader: raisedCosShader, uniforms: { uBeta: 0.25, - uPeriod: 1.0 + uPeriod: 0.625 } } }; + /** + * Default size of the Grating Stimuli in pixels. + * @name module:visual.GratingStim.#DEFAULT_STIM_SIZE_PX + * @type {Array} + * @default [256, 256] + */ static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels constructor({ @@ -196,20 +259,15 @@

    Source: visual/GratingStim.js

    mask, pos, units, - spatialFrequency = 1., + sf = 1.0, ori, phase, size, - rgb, - dkl, - lms, color, colorSpace, opacity, contrast, - texRes, depth, - rgbPedestal, interpolate, blendmode, autoDraw, @@ -228,14 +286,14 @@

    Source: visual/GratingStim.js

    mask, ); this._addAttribute( - "spatialFrequency", - spatialFrequency, - GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uFreq || 1.0 + "SF", + sf, + GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uFreq || 1.0 : 1.0 ); this._addAttribute( "phase", phase, - GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uPhase || 0.0 + GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uPhase || 0.0 : 0.0 ); this._addAttribute( "color", @@ -249,12 +307,6 @@

    Source: visual/GratingStim.js

    1.0, this._onChange(true, false), ); - this._addAttribute( - "texRes", - texRes, - 128, - this._onChange(true, false), - ); this._addAttribute( "interpolate", interpolate, @@ -277,11 +329,11 @@

    Source: visual/GratingStim.js

    } /** - * Setter for the image attribute. + * Setter for the tex attribute. * - * @name module:visual.GratingStim#setImage + * @name module:visual.GratingStim#setTex * @public - * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image + * @param {HTMLImageElement | string} tex - the name of built in shader function or name of the image resource or HTMLImageElement corresponding to the image * @param {boolean} [log= false] - whether of not to log */ setTex(tex, log = false) @@ -301,7 +353,7 @@

    Source: visual/GratingStim.js

    this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); } - else if (GratingStim.#DEFINED_FUNCTIONS[tex] !== undefined) + else if (GratingStim.#SHADERS[tex] !== undefined) { // tex is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); @@ -363,7 +415,7 @@

    Source: visual/GratingStim.js

    this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); } - else if (GratingStim.#DEFINED_FUNCTIONS[mask] !== undefined) + else if (GratingStim.#SHADERS[mask] !== undefined) { // mask is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask); @@ -449,8 +501,8 @@

    Source: visual/GratingStim.js

    * * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders * @function - * @private - * @param {String} funcName - name of the shader function. Must be one of the DEFINED_FUNCTIONS + * @protected + * @param {String} funcName - name of the shader function. Must be one of the SHADERS * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values. * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene. */ @@ -473,8 +525,8 @@

    Source: visual/GratingStim.js

    ); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#DEFINED_FUNCTIONS[funcName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#DEFINED_FUNCTIONS[funcName].uniforms, uniforms); + const fragmentSrc = GratingStim.#SHADERS[funcName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } @@ -499,19 +551,22 @@

    Source: visual/GratingStim.js

    /** * Set spatial frequency value for the function. * - * @name module:visual.GratingStim#setPhase + * @name module:visual.GratingStim#setSF * @public * @param {number} sf - spatial frequency value - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log=false] - whether or not to log */ - setSpatialFrequency (sf, log = false) { - this._setAttribute("spatialFrequency", sf, log); + setSF (sf, log = false) { + this._setAttribute("SF", sf, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uFreq = sf; } else if (this._pixi instanceof PIXI.TilingSprite) { // tileScale units are pixels, so converting function frequency to pixels // and also taking into account possible size difference between used texture and requested stim size this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width); + // since most functions defined in SHADERS assume spatial frequency change along X axis + // we assume desired effect for image based stims to be the same so tileScale.y is not affected by spatialFrequency + this._pixi.tileScale.y = this._pixi.height / this._pixi.texture.height; } } @@ -552,16 +607,16 @@

    Source: visual/GratingStim.js

    height: this._size_px[1] }); this.setPhase(this._phase); - this.setSpatialFrequency(this._spatialFrequency); + this.setSF(this._SF); } else { this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { - uFreq: this._spatialFrequency, + uFreq: this._SF, uPhase: this._phase }); } - this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5); + this._pixi.pivot.set(this._pixi.width * 0.5, this._pixi.width * 0.5); // add a mask if need be: if (typeof this._mask !== "undefined") @@ -569,6 +624,8 @@

    Source: visual/GratingStim.js

    if (this._mask instanceof HTMLImageElement) { this._pixi.mask = PIXI.Sprite.from(this._mask); + this._pixi.mask.width = this._size_px[0]; + this._pixi.mask.height = this._size_px[1]; this._pixi.addChild(this._pixi.mask); } else @@ -635,7 +692,7 @@

    Home

    Modules

    • diff --git a/package.json b/package.json index 76ad3d0f..516d2c93 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ }, "dependencies": { "@pixi/filter-adjustment": "^4.1.3", + "esbuild-plugin-glsl": "^1.0.5", "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", diff --git a/scripts/build.js.cjs b/scripts/build.js.cjs index 825e0cd0..d7d720be 100644 --- a/scripts/build.js.cjs +++ b/scripts/build.js.cjs @@ -1,9 +1,18 @@ -const { buildSync } = require("esbuild"); -const pkg = require("psychojs/package.json"); +const { buildSync, build } = require("esbuild"); +const { glsl } = require("esbuild-plugin-glsl"); +const pkg = require("../package.json"); const versionMaybe = process.env.npm_config_outver; const dirMaybe = process.env.npm_config_outdir; const [, , , dir = dirMaybe || "out", version = versionMaybe || pkg.version] = process.argv; +let shouldWatchDir = false; + +for (var i = 0; i < process.argv.length; i++) { + if (process.argv[i] === '-w') { + shouldWatchDir = true; + break; + } +} [ // The ESM bundle @@ -20,13 +29,19 @@ const [, , , dir = dirMaybe || "out", version = versionMaybe || pkg.version] = p }, ].forEach(function(options) { - buildSync({ ...this, ...options }); + build({ ...this, ...options }) + .then(()=> { + if (shouldWatchDir) { + console.log('watching...') + } + }); }, { // Shared options banner: { js: `/*! For license information please see psychojs-${version}.js.LEGAL.txt */`, }, bundle: true, + watch: shouldWatchDir, sourcemap: true, entryPoints: ["src/index.js"], minifySyntax: true, @@ -36,4 +51,9 @@ const [, , , dir = dirMaybe || "out", version = versionMaybe || pkg.version] = p "es2017", "node14", ], + plugins: [ + glsl({ + minify: true + }) + ] }); diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index c06df13c..a226c100 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -1,9 +1,9 @@ /** * Grating Stimulus. * - * @author Alain Pitiot + * @author Alain Pitiot, Nikita Agafonov * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -26,7 +26,6 @@ import crossShader from "./shaders/crossShader.frag"; import radRampShader from "./shaders/radRampShader.frag"; import raisedCosShader from "./shaders/raisedCosShader.frag"; - /** * Grating Stimulus. * @@ -37,51 +36,108 @@ import raisedCosShader from "./shaders/raisedCosShader.frag"; * @param {Object} options * @param {String} options.name - the name used when logging messages from this stimulus * @param {Window} options.win - the associated Window - * @param {string | HTMLImageElement} options.image - the name of the image resource or the HTMLImageElement corresponding to the image - * @param {string | HTMLImageElement} options.mask - the name of the mask resource or HTMLImageElement corresponding to the mask - * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {String | HTMLImageElement} [options.tex="sin"] - the name of the predefined grating texture or image resource or the HTMLImageElement corresponding to the texture + * @param {String | HTMLImageElement} [options.mask] - the name of the mask resource or HTMLImageElement corresponding to the mask + * @param {String} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) + * @param {number} [options.sf=1.0] - spatial frequency of the function used in grating stimulus + * @param {number} [options.phase=1.0] - phase of the function used in grating stimulus * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) - * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified) + * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) * @param {Color} [options.color= "white"] the background color * @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.contrast= 1.0] - the contrast * @param {number} [options.depth= 0] - the depth (i.e. the z order) - * @param {number} [options.texRes= 128] - the resolution of the text - * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated + * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated. NOT IMPLEMENTED YET. + * @param {String} [options.blendmode= 'avg'] - blend mode of the stimulus, determines how the stimulus is blended with the background. NOT IMPLEMENTED YET. * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { - // win, - // tex="sin", - // mask="none", - // units="", - // pos=(0.0, 0.0), - // size=None, - // sf=None, - // ori=0.0, - // phase=(0.0, 0.0), - // texRes=128, - // rgb=None, - // dkl=None, - // lms=None, - // color=(1.0, 1.0, 1.0), - // colorSpace='rgb', - // contrast=1.0, - // opacity=None, - // depth=0, - // rgbPedestal=(0.0, 0.0, 0.0), - // interpolate=False, - // blendmode='avg', - // name=None, - // autoLog=None, - // autoDraw=False, - // maskParams=None) - - static #DEFINED_FUNCTIONS = { + /** + * An object that keeps shaders source code and default uniform values for them. + * Shader source code is later used for construction of shader programs to create respective visual stimuli. + * @name module:visual.GratingStim.#SHADERS + * @type {Object} + * @property {Object} sin - Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Sine_wave} + * @property {String} sin.shader - shader source code for the sine wave stimuli + * @property {Object} sin.uniforms - default uniforms for sine wave shader + * @property {float} sin.uniforms.uFreq=1.0 - frequency of sine wave. + * @property {float} sin.uniforms.uPhase=0.0 - phase of sine wave. + * + * @property {Object} sqr - Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Square_wave} + * @property {String} sqr.shader - shader source code for the square wave stimuli + * @property {Object} sqr.uniforms - default uniforms for square wave shader + * @property {float} sqr.uniforms.uFreq=1.0 - frequency of square wave. + * @property {float} sqr.uniforms.uPhase=0.0 - phase of square wave. + * + * @property {Object} saw - Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Sawtooth_wave} + * @property {String} saw.shader - shader source code for the sawtooth wave stimuli + * @property {Object} saw.uniforms - default uniforms for sawtooth wave shader + * @property {float} saw.uniforms.uFreq=1.0 - frequency of sawtooth wave. + * @property {float} saw.uniforms.uPhase=0.0 - phase of sawtooth wave. + * + * @property {Object} tri - Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Triangle_wave} + * @property {String} tri.shader - shader source code for the triangle wave stimuli + * @property {Object} tri.uniforms - default uniforms for triangle wave shader + * @property {float} tri.uniforms.uFreq=1.0 - frequency of triangle wave. + * @property {float} tri.uniforms.uPhase=0.0 - phase of triangle wave. + * @property {float} tri.uniforms.uPeriod=1.0 - period of triangle wave. + * + * @property {Object} sinXsin - Creates an image of two 2d sine waves multiplied with each other. + * {@link https://en.wikipedia.org/wiki/Sine_wave} + * @property {String} sinXsin.shader - shader source code for the two multiplied sine waves stimuli + * @property {Object} sinXsin.uniforms - default uniforms for shader + * @property {float} sinXsin.uniforms.uFreq=1.0 - frequency of sine wave (both of them). + * @property {float} sinXsin.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * + * @property {Object} sqrXsqr - Creates an image of two 2d square waves multiplied with each other. + * {@link https://en.wikipedia.org/wiki/Square_wave} + * @property {String} sqrXsqr.shader - shader source code for the two multiplied sine waves stimuli + * @property {Object} sqrXsqr.uniforms - default uniforms for shader + * @property {float} sqrXsqr.uniforms.uFreq=1.0 - frequency of sine wave (both of them). + * @property {float} sqrXsqr.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * + * @property {Object} circle - Creates a filled circle shape with sharp edges. + * @property {String} circle.shader - shader source code for filled circle. + * @property {Object} circle.uniforms - default uniforms for shader. + * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim + * and 1.0 is circle that spans from edge to edge of the stim. + * + * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Gaussian_function} + * @property {String} gauss.shader - shader source code for Gaussian shader + * @property {Object} gauss.uniforms - default uniforms for shader + * @property {float} gauss.uniforms.uA=1.0 - A constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uB=0.0 - B constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uC=0.16 - C constant for gaussian formula (see link). + * + * @property {Object} cross - Creates a filled cross shape with sharp edges. + * @property {String} cross.shader - shader source code for cross shader + * @property {Object} cross.uniforms - default uniforms for shader + * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes + * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. + * + * @property {Object} radRamp - Creates 2d radial ramp image. + * @property {String} radRamp.shader - shader source code for radial ramp shader + * @property {Object} radRamp.uniforms - default uniforms for shader + * @property {float} radRamp.uniforms.uSqueeze=1.0 - coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large + * it fills the entire stim and Infinity makes it so tiny it's invisible. + * + * @property {Object} raisedCos - Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. + * {@link https://en.wikipedia.org/wiki/Raised-cosine_filter} + * @property {String} raisedCos.shader - shader source code for raised-cosine shader + * @property {Object} raisedCos.uniforms - default uniforms for shader + * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). + * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + */ + static #SHADERS = { sin: { shader: sinShader, uniforms: { @@ -114,7 +170,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) sinXsin: { shader: sinXsinShader, uniforms: { - + uFreq: 1.0, + uPhase: 0.0 } }, sqrXsqr: { @@ -127,7 +184,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) circle: { shader: circleShader, uniforms: { - + uRadius: 1.0 } }, gauss: { @@ -141,24 +198,30 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) cross: { shader: crossShader, uniforms: { - uThickness: 0.1 + uThickness: 0.2 } }, radRamp: { shader: radRampShader, uniforms: { - + uSqueeze: 1.0 } }, raisedCos: { shader: raisedCosShader, uniforms: { uBeta: 0.25, - uPeriod: 1.0 + uPeriod: 0.625 } } }; + /** + * Default size of the Grating Stimuli in pixels. + * @name module:visual.GratingStim.#DEFAULT_STIM_SIZE_PX + * @type {Array} + * @default [256, 256] + */ static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels constructor({ @@ -168,20 +231,15 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) mask, pos, units, - spatialFrequency = 1., + sf = 1.0, ori, phase, size, - rgb, - dkl, - lms, color, colorSpace, opacity, contrast, - texRes, depth, - rgbPedestal, interpolate, blendmode, autoDraw, @@ -200,14 +258,14 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) mask, ); this._addAttribute( - "spatialFrequency", - spatialFrequency, - GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uFreq || 1.0 + "SF", + sf, + GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uFreq || 1.0 : 1.0 ); this._addAttribute( "phase", phase, - GratingStim.#DEFINED_FUNCTIONS[tex].uniforms.uPhase || 0.0 + GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uPhase || 0.0 : 0.0 ); this._addAttribute( "color", @@ -221,12 +279,6 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) 1.0, this._onChange(true, false), ); - this._addAttribute( - "texRes", - texRes, - 128, - this._onChange(true, false), - ); this._addAttribute( "interpolate", interpolate, @@ -249,11 +301,11 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } /** - * Setter for the image attribute. + * Setter for the tex attribute. * - * @name module:visual.GratingStim#setImage + * @name module:visual.GratingStim#setTex * @public - * @param {HTMLImageElement | string} image - the name of the image resource or HTMLImageElement corresponding to the image + * @param {HTMLImageElement | string} tex - the name of built in shader function or name of the image resource or HTMLImageElement corresponding to the image * @param {boolean} [log= false] - whether of not to log */ setTex(tex, log = false) @@ -273,7 +325,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the tex of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the tex of GratingStim: " + this._name + " as: undefined"); } - else if (GratingStim.#DEFINED_FUNCTIONS[tex] !== undefined) + else if (GratingStim.#SHADERS[tex] !== undefined) { // tex is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the tex is one of predefined functions. Set the tex of GratingStim: " + this._name + " as: " + tex); @@ -335,7 +387,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) this.psychoJS.logger.warn("setting the mask of GratingStim: " + this._name + " with argument: undefined."); this.psychoJS.logger.debug("set the mask of GratingStim: " + this._name + " as: undefined"); } - else if (GratingStim.#DEFINED_FUNCTIONS[mask] !== undefined) + else if (GratingStim.#SHADERS[mask] !== undefined) { // mask is a string and it is one of predefined functions available in shaders this.psychoJS.logger.debug("the mask is one of predefined functions. Set the mask of GratingStim: " + this._name + " as: " + mask); @@ -421,8 +473,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) * * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders * @function - * @private - * @param {String} funcName - name of the shader function. Must be one of the DEFINED_FUNCTIONS + * @protected + * @param {String} funcName - name of the shader function. Must be one of the SHADERS * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values. * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene. */ @@ -445,8 +497,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) ); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#DEFINED_FUNCTIONS[funcName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#DEFINED_FUNCTIONS[funcName].uniforms, uniforms); + const fragmentSrc = GratingStim.#SHADERS[funcName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } @@ -471,19 +523,22 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) /** * Set spatial frequency value for the function. * - * @name module:visual.GratingStim#setPhase + * @name module:visual.GratingStim#setSF * @public * @param {number} sf - spatial frequency value - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log=false] - whether or not to log */ - setSpatialFrequency (sf, log = false) { - this._setAttribute("spatialFrequency", sf, log); + setSF (sf, log = false) { + this._setAttribute("SF", sf, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uFreq = sf; } else if (this._pixi instanceof PIXI.TilingSprite) { // tileScale units are pixels, so converting function frequency to pixels // and also taking into account possible size difference between used texture and requested stim size this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width); + // since most functions defined in SHADERS assume spatial frequency change along X axis + // we assume desired effect for image based stims to be the same so tileScale.y is not affected by spatialFrequency + this._pixi.tileScale.y = this._pixi.height / this._pixi.texture.height; } } @@ -524,16 +579,16 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) height: this._size_px[1] }); this.setPhase(this._phase); - this.setSpatialFrequency(this._spatialFrequency); + this.setSF(this._SF); } else { this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { - uFreq: this._spatialFrequency, + uFreq: this._SF, uPhase: this._phase }); } - this._pixi.pivot.set(this._pixi.width * .5, this._pixi.width * .5); + this._pixi.pivot.set(this._pixi.width * 0.5, this._pixi.width * 0.5); // add a mask if need be: if (typeof this._mask !== "undefined") @@ -541,6 +596,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) if (this._mask instanceof HTMLImageElement) { this._pixi.mask = PIXI.Sprite.from(this._mask); + this._pixi.mask.width = this._size_px[0]; + this._pixi.mask.height = this._size_px[1]; this._pixi.addChild(this._pixi.mask); } else diff --git a/src/visual/shaders/circleShader.frag b/src/visual/shaders/circleShader.frag index 3211825a..51e9eccf 100644 --- a/src/visual/shaders/circleShader.frag +++ b/src/visual/shaders/circleShader.frag @@ -1,3 +1,13 @@ +/** + * Circle Shape. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a filled circle shape with sharp edges. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; @@ -5,9 +15,10 @@ in vec2 vUvs; out vec4 shaderOut; #define M_PI 3.14159265358979 +uniform float uRadius; void main() { vec2 uv = vUvs; - float s = 1. - step(.5, length(uv - .5)); + float s = 1. - step(uRadius, length(uv * 2. - 1.)); shaderOut = vec4(vec3(s), 1.0); } diff --git a/src/visual/shaders/crossShader.frag b/src/visual/shaders/crossShader.frag index dbff4c10..b487b9eb 100644 --- a/src/visual/shaders/crossShader.frag +++ b/src/visual/shaders/crossShader.frag @@ -1,3 +1,13 @@ +/** + * Cross Shape. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a filled cross shape with sharp edges. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; @@ -9,8 +19,8 @@ uniform float uThickness; void main() { vec2 uv = vUvs; - float sx = step(uThickness, length(uv.x - .5)); - float sy = step(uThickness, length(uv.y - .5)); + float sx = step(uThickness, length(uv.x * 2. - 1.)); + float sy = step(uThickness, length(uv.y * 2. - 1.)); float s = 1. - sx * sy; shaderOut = vec4(vec3(s), 1.0); } diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag index ed7fbecd..3ba302ca 100644 --- a/src/visual/shaders/gaussShader.frag +++ b/src/visual/shaders/gaussShader.frag @@ -1,7 +1,13 @@ -// -// Gaussian Function: -// https://en.wikipedia.org/wiki/Gaussian_function -// +/** + * Gaussian Function. + * https://en.wikipedia.org/wiki/Gaussian_function + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; diff --git a/src/visual/shaders/radRampShader.frag b/src/visual/shaders/radRampShader.frag index bbbc5867..192acd49 100644 --- a/src/visual/shaders/radRampShader.frag +++ b/src/visual/shaders/radRampShader.frag @@ -1,17 +1,24 @@ -// -// Radial ramp function -// +/** + * Radial Ramp. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d radial ramp image. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; in vec2 vUvs; out vec4 shaderOut; +uniform float uSqueeze; #define M_PI 3.14159265358979 void main() { vec2 uv = vUvs; - float s = 1. - length(uv * 2. - 1.); + float s = 1. - length(uv * 2. - 1.) * uSqueeze; shaderOut = vec4(vec3(s), 1.0); } diff --git a/src/visual/shaders/raisedCosShader.frag b/src/visual/shaders/raisedCosShader.frag index 605fbfed..05e75cde 100644 --- a/src/visual/shaders/raisedCosShader.frag +++ b/src/visual/shaders/raisedCosShader.frag @@ -1,7 +1,13 @@ -// -// Raised-cosine function: -// https://en.wikipedia.org/wiki/Raised-cosine_filter -// +/** + * Raised-cosine. + * https://en.wikipedia.org/wiki/Raised-cosine_filter + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; diff --git a/src/visual/shaders/sawShader.frag b/src/visual/shaders/sawShader.frag index ed55ceb8..0948bf73 100644 --- a/src/visual/shaders/sawShader.frag +++ b/src/visual/shaders/sawShader.frag @@ -1,7 +1,13 @@ -// -// Sawtooth wave: -// https://en.wikipedia.org/wiki/Sawtooth_wave -// +/** + * Sawtooth wave. + * https://en.wikipedia.org/wiki/Sawtooth_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index abdd5299..5e53d87b 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -1,3 +1,14 @@ +/** + * Sine wave. + * https://en.wikipedia.org/wiki/Sine_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; diff --git a/src/visual/shaders/sinXsinShader.frag b/src/visual/shaders/sinXsinShader.frag index 15435ac3..9b8ffda3 100644 --- a/src/visual/shaders/sinXsinShader.frag +++ b/src/visual/shaders/sinXsinShader.frag @@ -1,3 +1,14 @@ +/** + * Sine wave multiplied by another sine wave. + * https://en.wikipedia.org/wiki/Sine_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates an image of two 2d sine waves multiplied with each other. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; diff --git a/src/visual/shaders/sqrShader.frag b/src/visual/shaders/sqrShader.frag index dc9bd747..1d696020 100644 --- a/src/visual/shaders/sqrShader.frag +++ b/src/visual/shaders/sqrShader.frag @@ -1,7 +1,13 @@ -// -// Square wave: -// https://en.wikipedia.org/wiki/Square_wave -// +/** + * Square wave. + * https://en.wikipedia.org/wiki/Square_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; diff --git a/src/visual/shaders/sqrXsqrShader.frag b/src/visual/shaders/sqrXsqrShader.frag index 4eb6cfa2..4f320ab6 100644 --- a/src/visual/shaders/sqrXsqrShader.frag +++ b/src/visual/shaders/sqrXsqrShader.frag @@ -1,3 +1,14 @@ +/** + * Square wave multiplied by another square wave. + * https://en.wikipedia.org/wiki/Square_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates an image of two 2d square waves multiplied with each other. + * @usedby GratingStim.js + */ + #version 300 es precision mediump float; diff --git a/src/visual/shaders/triShader.frag b/src/visual/shaders/triShader.frag index 5b45ce08..445a0c4d 100644 --- a/src/visual/shaders/triShader.frag +++ b/src/visual/shaders/triShader.frag @@ -1,7 +1,13 @@ -// -// Triangle wave: -// https://en.wikipedia.org/wiki/Triangle_wave -// +/** + * Triangle wave. + * https://en.wikipedia.org/wiki/Triangle_wave + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. + * @usedby GratingStim.js + */ #version 300 es precision mediump float; From 328601b0d21a498459c6825cf88257097ee5618c Mon Sep 17 00:00:00 2001 From: lgtst Date: Tue, 22 Mar 2022 12:48:38 +0300 Subject: [PATCH 22/92] indentation change - spaces to tabs --- src/visual/GratingStim.js | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index a226c100..dcf2c21b 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -105,12 +105,12 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) * @property {float} sqrXsqr.uniforms.uPhase=0.0 - phase of sine wave (both of them). * * @property {Object} circle - Creates a filled circle shape with sharp edges. - * @property {String} circle.shader - shader source code for filled circle. - * @property {Object} circle.uniforms - default uniforms for shader. - * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim - * and 1.0 is circle that spans from edge to edge of the stim. - * - * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * @property {String} circle.shader - shader source code for filled circle. + * @property {Object} circle.uniforms - default uniforms for shader. + * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim + * and 1.0 is circle that spans from edge to edge of the stim. + * + * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. * {@link https://en.wikipedia.org/wiki/Gaussian_function} * @property {String} gauss.shader - shader source code for Gaussian shader * @property {Object} gauss.uniforms - default uniforms for shader @@ -119,12 +119,12 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) * @property {float} gauss.uniforms.uC=0.16 - C constant for gaussian formula (see link). * * @property {Object} cross - Creates a filled cross shape with sharp edges. - * @property {String} cross.shader - shader source code for cross shader - * @property {Object} cross.uniforms - default uniforms for shader - * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes - * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. - * - * @property {Object} radRamp - Creates 2d radial ramp image. + * @property {String} cross.shader - shader source code for cross shader + * @property {Object} cross.uniforms - default uniforms for shader + * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes + * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. + * + * @property {Object} radRamp - Creates 2d radial ramp image. * @property {String} radRamp.shader - shader source code for radial ramp shader * @property {Object} radRamp.uniforms - default uniforms for shader * @property {float} radRamp.uniforms.uSqueeze=1.0 - coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large @@ -132,10 +132,10 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) * * @property {Object} raisedCos - Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. * {@link https://en.wikipedia.org/wiki/Raised-cosine_filter} - * @property {String} raisedCos.shader - shader source code for raised-cosine shader - * @property {Object} raisedCos.uniforms - default uniforms for shader - * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). - * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + * @property {String} raisedCos.shader - shader source code for raised-cosine shader + * @property {Object} raisedCos.uniforms - default uniforms for shader + * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). + * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). */ static #SHADERS = { sin: { @@ -497,8 +497,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) ); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#SHADERS[funcName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); + const fragmentSrc = GratingStim.#SHADERS[funcName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } From 6761e7c0bd309aae7d1e38c935719f3be661cd48 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Wed, 23 Mar 2022 13:42:38 +0100 Subject: [PATCH 23/92] Update README.md added Nikita Agafonov as contributor --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 697de497..43333eb5 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,7 @@ The PsychoJS library was initially written by [Ilixa](http://www.ilixa.com) with It is now a collaborative effort, supported by the [Chan Zuckerberg Initiative](https://chanzuckerberg.com/) (2020-2021) and [Open Science Tools](https://opensciencetools.org/) (2020-): - Alain Pitiot - [@apitiot](https://github.com/apitiot) - Sotiri Bakagiannis - [@thewhodidthis](https://github.com/thewhodidthis) +- Nikita Agafonov - [@lightest](https://github.com/lightest) - Jonathan Peirce - [@peircej](https://github.com/peircej) - Thomas Pronk - [@tpronk](https://github.com/tpronk) - Hiroyuki Sogo - [@hsogo](https://github.com/hsogo) From 08b71731d12696993d899ba913a482735440ccdf Mon Sep 17 00:00:00 2001 From: lgtst Date: Wed, 30 Mar 2022 22:39:47 +0300 Subject: [PATCH 24/92] anchoring and alignment fixed; --- src/visual/TextBox.js | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 203fe535..63f26190 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -392,7 +392,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) color: this._color === undefined || this._color === null ? 'transparent' : new Color(this._color).hex, fontWeight: (this._bold) ? "bold" : "normal", fontStyle: (this._italic) ? "italic" : "normal", - + textAlign: this._alignment, padding: padding_px + "px", multiline, text: this._text, @@ -523,11 +523,13 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._pixi.disabled = !this._editable; const anchor = this._getAnchor(); - this._pixi.pivot.x = anchor[0] * this._pixi.width; - this._pixi.pivot.y = anchor[1] * this._pixi.height; this._pixi.scale.x = this._flipHoriz ? -1 : 1; this._pixi.scale.y = this._flipVert ? 1 : -1; + this._pixi.pivot.x = anchor[0] * this._pixi.width; + // when setting PIXI.Container.scale.y = -1 + // PIXI.Container.height becomes negative + this._pixi.pivot.y = anchor[1] * -this._pixi.height; this._pixi.rotation = -this._ori * Math.PI / 180; [this._pixi.x, this._pixi.y] = util.to_px(this._pos, this._units, this._win); From d0ed55758bd988665684d4a040f51a4162afadd4 Mon Sep 17 00:00:00 2001 From: lgtst Date: Wed, 30 Mar 2022 23:56:54 +0300 Subject: [PATCH 25/92] textBox#setAlignment method added to avoid complete reconstruction when changing text alignment --- src/visual/TextBox.js | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 63f26190..5eee052b 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -144,8 +144,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "alignment", alignment, - "left", - this._onChange(true, true), + "left" ); // colors: @@ -228,6 +227,21 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this.setText(); } + /** + * Setter for the alignment attribute. + * + * @name module:visual.TextBox#setAlignment + * @public + * @param {boolean} alignment - alignment of the text + * @param {boolean} [log= false] - whether of not to log + */ + setAlignment(alignment = "left", log = false) { + this._setAttribute("alignment", alignment, log); + if (this._pixi !== undefined) { + this._pixi.setInputStyle("text-align", alignment); + } + } + /** * For tweaking the underlying input value. * From 837c6775e67e5fae5cc74881587d53bb4126f8cc Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Thu, 31 Mar 2022 13:29:54 +0200 Subject: [PATCH 26/92] [BF] fix issue with saving to database --- package-lock.json | 42 ++++++++++++++++++++++++++++++----- src/data/ExperimentHandler.js | 4 ++-- 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 23eaa798..60b32084 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,14 +1,16 @@ { "name": "psychojs", - "version": "2021.2.x", + "version": "2022.1.1", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "psychojs", - "version": "2021.2.x", + "version": "2022.1.1", "license": "MIT", "dependencies": { + "@pixi/filter-adjustment": "^4.1.3", + "esbuild-plugin-glsl": "^1.0.5", "howler": "^2.2.1", "log4javascript": "github:Ritzlgrmft/log4javascript", "pako": "^1.0.10", @@ -337,6 +339,15 @@ "@pixi/utils": "6.0.4" } }, + "node_modules/@pixi/filter-adjustment": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-4.1.3.tgz", + "integrity": "sha512-W+NhPiZRYKoRToa5+tkU95eOw8gnS5dfIp3ZP+pLv2mdER9RI+4xHxp1uLHMqUYZViTaMdZIIoVOuCgHFPYCbQ==", + "peerDependencies": { + "@pixi/constants": "^6.0.0", + "@pixi/core": "^6.0.0" + } + }, "node_modules/@pixi/filter-alpha": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-6.0.4.tgz", @@ -957,12 +968,22 @@ "version": "0.12.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.5.tgz", "integrity": "sha512-vcuP53pA5XiwUU4FnlXM+2PnVjTfHGthM7uP1gtp+9yfheGvFFbq/KyuESThmtoHPUrfZH5JpxGVJIFDVD1Egw==", - "dev": true, "hasInstallScript": true, "bin": { "esbuild": "bin/esbuild" } }, + "node_modules/esbuild-plugin-glsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/esbuild-plugin-glsl/-/esbuild-plugin-glsl-1.1.0.tgz", + "integrity": "sha512-OBzCa/nRy/Vbm62DBzBnV25p1BfTpvFf2SP2Vv9Ls38sdEEuHzhYT5xTOh3Ghu+77VI4iZsOam19cmjwq5RcJQ==", + "engines": { + "node": ">= 0.10.18" + }, + "peerDependencies": { + "esbuild": "0.x.x" + } + }, "node_modules/escape-string-regexp": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", @@ -2690,6 +2711,12 @@ "@pixi/utils": "6.0.4" } }, + "@pixi/filter-adjustment": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/@pixi/filter-adjustment/-/filter-adjustment-4.1.3.tgz", + "integrity": "sha512-W+NhPiZRYKoRToa5+tkU95eOw8gnS5dfIp3ZP+pLv2mdER9RI+4xHxp1uLHMqUYZViTaMdZIIoVOuCgHFPYCbQ==", + "requires": {} + }, "@pixi/filter-alpha": { "version": "6.0.4", "resolved": "https://registry.npmjs.org/@pixi/filter-alpha/-/filter-alpha-6.0.4.tgz", @@ -3230,8 +3257,13 @@ "esbuild": { "version": "0.12.5", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.12.5.tgz", - "integrity": "sha512-vcuP53pA5XiwUU4FnlXM+2PnVjTfHGthM7uP1gtp+9yfheGvFFbq/KyuESThmtoHPUrfZH5JpxGVJIFDVD1Egw==", - "dev": true + "integrity": "sha512-vcuP53pA5XiwUU4FnlXM+2PnVjTfHGthM7uP1gtp+9yfheGvFFbq/KyuESThmtoHPUrfZH5JpxGVJIFDVD1Egw==" + }, + "esbuild-plugin-glsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/esbuild-plugin-glsl/-/esbuild-plugin-glsl-1.1.0.tgz", + "integrity": "sha512-OBzCa/nRy/Vbm62DBzBnV25p1BfTpvFf2SP2Vv9Ls38sdEEuHzhYT5xTOh3Ghu+77VI4iZsOam19cmjwq5RcJQ==", + "requires": {} }, "escape-string-regexp": { "version": "1.0.5", diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index 10ecf91b..70ce3ea6 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -338,14 +338,14 @@ export class ExperimentHandler extends PsychObject else if (this._psychoJS.config.experiment.saveFormat === ExperimentHandler.SaveFormat.DATABASE) { const gitlabConfig = this._psychoJS.config.gitlab; - const projectId = (typeof gitlabConfig !== "undefined" && typeof gitlabConfig.projectId !== "undefined") ? gitlabConfig.projectId : undefined; + const __projectId = (typeof gitlabConfig !== "undefined" && typeof gitlabConfig.projectId !== "undefined") ? gitlabConfig.projectId : undefined; let documents = []; for (let r = 0; r < data.length; r++) { let doc = { - projectId, + __projectId, __experimentName: this._experimentName, __participant: this._participant, __session: this._session, From e8d50104c45658769c4a6a236266db6b56f7e214 Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 7 Apr 2022 00:00:17 +0300 Subject: [PATCH 27/92] added depth parameter and pointer-events:"none" for TextBox; --- src/visual/Slider.js | 3 ++- src/visual/TextBox.js | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/visual/Slider.js b/src/visual/Slider.js index 1dbb5c0d..567ceb28 100644 --- a/src/visual/Slider.js +++ b/src/visual/Slider.js @@ -81,6 +81,7 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin) lineColor, contrast, opacity, + depth, style, ticks, labels, @@ -99,7 +100,7 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin) } = {}, ) { - super({ name, win, units, ori, opacity, pos, size, clipMask, autoDraw, autoLog }); + super({ name, win, units, ori, opacity, depth, pos, size, clipMask, autoDraw, autoLog }); this._needMarkerUpdate = false; diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 5eee052b..1bd404ec 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -412,6 +412,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) text: this._text, height: multiline ? (height_px - 2 * padding_px) + "px" : undefined, width: (width_px - 2 * padding_px) + "px", + pointerEvents: "none" }, // box style properties eventually become PIXI.Graphics settings, so same syntax applies box: { From c6aacad99c1a3ea16fd1cc52c1a9f7f69d55d41f Mon Sep 17 00:00:00 2001 From: lgtst Date: Tue, 12 Apr 2022 14:29:55 +0300 Subject: [PATCH 28/92] phase parameter for grating functions is now multiples of those function's periods --- src/visual/GratingStim.js | 5 +++-- src/visual/shaders/sinShader.frag | 5 +++-- src/visual/shaders/sinXsinShader.frag | 5 +++-- src/visual/shaders/sqrShader.frag | 2 +- src/visual/shaders/sqrXsqrShader.frag | 5 +++-- 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index dcf2c21b..88015a06 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -40,7 +40,7 @@ import raisedCosShader from "./shaders/raisedCosShader.frag"; * @param {String | HTMLImageElement} [options.mask] - the name of the mask resource or HTMLImageElement corresponding to the mask * @param {String} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) * @param {number} [options.sf=1.0] - spatial frequency of the function used in grating stimulus - * @param {number} [options.phase=1.0] - phase of the function used in grating stimulus + * @param {number} [options.phase=0.0] - phase of the function used in grating stimulus, multiples of period of that function * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) @@ -142,7 +142,8 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) shader: sinShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [.5, 0, .5] } }, sqr: { diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index 5e53d87b..441d2fda 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -18,9 +18,10 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uFreq; uniform float uPhase; +uniform vec3 uColor; void main() { vec2 uv = vUvs; - float s = sin(uFreq * uv.x * 2. * M_PI + uPhase); - shaderOut = vec4(.5 + .5 * vec3(s), 1.0); + float s = sin((uFreq * uv.x + uPhase) * 2. * M_PI); + shaderOut = vec4((.5 + .5 * vec3(s)) * uColor, 1.0); } diff --git a/src/visual/shaders/sinXsinShader.frag b/src/visual/shaders/sinXsinShader.frag index 9b8ffda3..21d39754 100644 --- a/src/visual/shaders/sinXsinShader.frag +++ b/src/visual/shaders/sinXsinShader.frag @@ -16,13 +16,14 @@ in vec2 vUvs; out vec4 shaderOut; #define M_PI 3.14159265358979 +#define PI2 2.* M_PI uniform float uFreq; uniform float uPhase; void main() { vec2 uv = vUvs; - float sx = sin(uFreq * uv.x * 2. * M_PI + uPhase); - float sy = sin(uFreq * uv.y * 2. * M_PI + uPhase); + float sx = sin((uFreq * uv.x + uPhase) * PI2); + float sy = sin((uFreq * uv.y + uPhase) * PI2); float s = sx * sy * .5 + .5; shaderOut = vec4(vec3(s), 1.0); } diff --git a/src/visual/shaders/sqrShader.frag b/src/visual/shaders/sqrShader.frag index 1d696020..f44ffca6 100644 --- a/src/visual/shaders/sqrShader.frag +++ b/src/visual/shaders/sqrShader.frag @@ -21,6 +21,6 @@ uniform float uPhase; void main() { vec2 uv = vUvs; - float s = sign(sin(uFreq * uv.x * 2. * M_PI + uPhase)); + float s = sign(sin((uFreq * uv.x + uPhase) * 2. * M_PI)); shaderOut = vec4(.5 + .5 * vec3(s), 1.0); } diff --git a/src/visual/shaders/sqrXsqrShader.frag b/src/visual/shaders/sqrXsqrShader.frag index 4f320ab6..6f140b41 100644 --- a/src/visual/shaders/sqrXsqrShader.frag +++ b/src/visual/shaders/sqrXsqrShader.frag @@ -16,13 +16,14 @@ in vec2 vUvs; out vec4 shaderOut; #define M_PI 3.14159265358979 +#define PI2 2.* M_PI uniform float uFreq; uniform float uPhase; void main() { vec2 uv = vUvs; - float sx = sign(sin(uFreq * uv.x * 2. * M_PI + uPhase)); - float sy = sign(sin(uFreq * uv.y * 2. * M_PI + uPhase)); + float sx = sign(sin((uFreq * uv.x + uPhase) * PI2)); + float sy = sign(sin((uFreq * uv.y + uPhase) * PI2)); float s = sx * sy * .5 + .5; shaderOut = vec4(vec3(s), 1.0); } From f7171287fcd0048b8f02271b8b7d2810561639d8 Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 14 Apr 2022 19:53:03 +0300 Subject: [PATCH 29/92] 22ybz82 "greedy" focus fix, also window#rootContainer now has sortableChildren=true; --- src/core/Window.js | 3 +++ src/visual/Slider.js | 1 + src/visual/TextInput.js | 11 ++++++++--- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/core/Window.js b/src/core/Window.js index cf27c937..7df70104 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -438,6 +438,9 @@ export class Window extends PsychObject // create a top-level PIXI container: this._rootContainer = new PIXI.Container(); + // sorts children according to their zIndex value. Higher zIndex means it will be moved towards the end of the array, + // and thus rendered on top of previous one. + this._rootContainer.sortableChildren = true; this._rootContainer.interactive = true; this._rootContainer.filters = [this._adjustmentFilter]; diff --git a/src/visual/Slider.js b/src/visual/Slider.js index 567ceb28..175bbb7b 100644 --- a/src/visual/Slider.js +++ b/src/visual/Slider.js @@ -733,6 +733,7 @@ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin) this._pixi.position = this._getPosition_px(); this._pixi.alpha = this._opacity; + this._pixi.zIndex = this._depth; // make sure that the dependent Stimuli are also updated: for (const dependentStim of this._dependentStims) diff --git a/src/visual/TextInput.js b/src/visual/TextInput.js index fc116faa..14477bf7 100644 --- a/src/visual/TextInput.js +++ b/src/visual/TextInput.js @@ -1,12 +1,12 @@ /** * TextInput encapsulates an html element into a PIXI Container. * - * @author 'Mwni' (https://github.com/Mwni) + * @author 'Mwni' (https://github.com/Mwni), Nikita Agafonov * @copyright (c) 2018 Mwni * @license Distributed under the terms of the MIT License * * @note TextInput was initially developed by 'Mwni' and is available under the MIT License. - * We are currently using it almost as is but will be making modification in the near future. + * We are currently modifying it to our needs. */ import * as PIXI from "pixi.js-legacy"; @@ -558,7 +558,7 @@ export class TextInput extends PIXI.Container } _onSurrogateFocus() - { + { this._setDOMInputVisible(true); // sometimes the input is not being focused by the mouseclick setTimeout(this._ensureFocus.bind(this), 10); @@ -824,6 +824,11 @@ function DefaultBoxGenerator(styles) let style = styles[state.toLowerCase()]; let box = new PIXI.Graphics(); + box.interactive = true; + box.on('pointerdown', () => { + this._dom_input.focus(); + }); + box.beginFill(style.fill, style.alpha); if (style.stroke) From 783047de124c21b8ac55ae126015b98556dd012d Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 14 Apr 2022 19:55:11 +0300 Subject: [PATCH 30/92] removed redundant tab symbol; --- src/visual/TextInput.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/visual/TextInput.js b/src/visual/TextInput.js index 14477bf7..61d392b3 100644 --- a/src/visual/TextInput.js +++ b/src/visual/TextInput.js @@ -558,7 +558,7 @@ export class TextInput extends PIXI.Container } _onSurrogateFocus() - { + { this._setDOMInputVisible(true); // sometimes the input is not being focused by the mouseclick setTimeout(this._ensureFocus.bind(this), 10); From 45bcb3633ce310f38da7459c232318cb51ea12de Mon Sep 17 00:00:00 2001 From: lgtst Date: Sun, 24 Apr 2022 04:23:24 +0300 Subject: [PATCH 31/92] [WIP] fitToContent prototype; --- src/visual/GratingStim.js | 2 +- src/visual/TextBox.js | 70 +++++++++++++++++++++++++++++++++------ src/visual/TextInput.js | 44 +++++++++++++++++++----- src/visual/VisualStim.js | 6 +++- 4 files changed, 100 insertions(+), 22 deletions(-) diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index dcf2c21b..942630ae 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -642,7 +642,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) // set the position, rotation, and anchor (image centered on pos): let pos = to_pixiPoint(this.pos, this.units, this.win); this._pixi.position.set(pos.x, pos.y); - this._pixi.rotation = this.ori * Math.PI / 180; + this._pixi.rotation = -this.ori * Math.PI / 180; // re-estimate the bounding box, as the texture's width may now be available: this._estimateBoundingBox(); diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 1bd404ec..97eb7c72 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -28,7 +28,7 @@ import { VisualStim } from "./VisualStim.js"; * @param {string} [options.font= "Arial"] - the font family * @param {Array.} [options.pos= [0, 0]] - the position of the center of the text * - * @param {Color} [options.color= Color('white')] the background color + * @param {Color} [options.color= Color('white')] color of the text * @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.depth= 0] - the depth (i.e. the z order) * @param {number} [options.contrast= 1.0] - the contrast @@ -39,13 +39,16 @@ import { VisualStim } from "./VisualStim.js"; * @param {boolean} [options.italic= false] - whether or not the text is italic * @param {string} [options.anchor = 'left'] - horizontal alignment * - * @param {boolean} [options.multiline= false] - whether or not a textarea is used + * @param {boolean} [options.multiline= false] - whether or not a multiline element is used * @param {boolean} [options.autofocus= true] - whether or not the first input should receive focus by default * @param {boolean} [options.flipHoriz= false] - whether or not to flip the text horizontally * @param {boolean} [options.flipVert= false] - whether or not to flip the text vertically + * @param {Color} [options.fillColor= undefined] - fill color of the text-box + * @param {Color} [options.borderColor= undefined] - border color of the text-box * @param {PIXI.Graphics} [options.clipMask= null] - the clip mask * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log + * @param {boolean} [options.fitToContent = false] - whether or not to resize itself automaitcally to fit to the text content */ export class TextBox extends util.mix(VisualStim).with(ColorMixin) { @@ -80,6 +83,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) clipMask, autoDraw, autoLog, + fitToContent } = {}, ) { @@ -194,6 +198,11 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) // assert: v => (v != null) && (typeof v !== 'undefined') && Array.isArray(v) ) // log); + this._addAttribute("fitToContent", fitToContent, false); + // setting size again since fitToContent field becomes available only at this point + // and setSize called from super class would not have a proper effect + this.setSize(size); + // estimate the bounding box: this._estimateBoundingBox(); @@ -212,7 +221,6 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) reset() { const text = this.editable ? "" : this.placeholder; - this.setText(this.placeholder); } @@ -238,7 +246,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) setAlignment(alignment = "left", log = false) { this._setAttribute("alignment", alignment, log); if (this._pixi !== undefined) { - this._pixi.setInputStyle("text-align", alignment); + this._pixi.setInputStyle("textAlign", alignment); } } @@ -309,7 +317,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * * @name module:visual.TextBox#setBorderColor * @public - * @param {boolean} borderColor - border color of the text box + * @param {Color} borderColor - border color of the text box * @param {boolean} [log= false] - whether of not to log */ setBorderColor (borderColor, log = false) { @@ -318,6 +326,18 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._needPixiUpdate = true; } + /** + * Setter for the fitToContent attribute. + * + * @name module:visual.TextBox#setFitToContent + * @public + * @param {boolean} fitToContent - whether or not to autoresize textbox to fit to text content + * @param {boolean} [log= false] - whether of not to log + */ + setFitToContent (fitToContent, log = false) { + this._setAttribute("fitToContent", fitToContent, log); + } + /** * Setter for the size attribute. * @@ -337,6 +357,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) if (isSizeUndefined) { size = TextBox._defaultSizeMap.get(this._units); + this.fitToContent = true; if (typeof size === "undefined") { @@ -360,6 +381,21 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) } } + /** + * Add event listeners to text-box object. Method is called internally upon object construction. + * + * @name module:visual.TextBox#_addEventListeners + * @protected + */ + _addEventListeners () { + this._pixi.on("input", (textContent) => { + const anchor = this._getAnchor(); + this._pixi.pivot.x = anchor[0] * this._pixi.width; + this._pixi.pivot.y = anchor[1] * -this._pixi.height; + this._text = textContent; + }); + } + /** * Get the default letter height given the stimulus' units. * @@ -396,11 +432,11 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) const width_px = Math.round(this._getLengthPix(this._size[0])); const borderWidth_px = Math.round(this._getLengthPix(this._borderWidth)); const height_px = Math.round(this._getLengthPix(this._size[1])); - const multiline = this._multiline; return { // input style properties eventually become CSS, so same syntax applies input: { + display: "inline-block", fontFamily: this._font, fontSize: letterHeight_px + "px", color: this._color === undefined || this._color === null ? 'transparent' : new Color(this._color).hex, @@ -408,10 +444,13 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) fontStyle: (this._italic) ? "italic" : "normal", textAlign: this._alignment, padding: padding_px + "px", - multiline, + multiline: this._multiline, text: this._text, - height: multiline ? (height_px - 2 * padding_px) + "px" : undefined, - width: (width_px - 2 * padding_px) + "px", + height: this._fitToContent ? "auto" : (this._multiline ? (height_px - 2 * padding_px) + "px" : undefined), + width: this._fitToContent ? "auto" : (width_px - 2 * padding_px) + "px", + maxWidth: `${Math.round(this._getLengthPix(1))}px`, + maxHeight: `${Math.round(this._getLengthPix(1))}px`, + overflow: this._fitToContent ? "hidden" : "auto", pointerEvents: "none" }, // box style properties eventually become PIXI.Graphics settings, so same syntax applies @@ -498,14 +537,23 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) { this._needPixiUpdate = false; + let enteredText = ""; + // at this point this._pixi might exist but is removed from the scene, in such cases this._pixi.text + // does not retain the information about new lines etc. so we go with a local copy of entered text + if (this._pixi !== undefined && this._pixi.parent !== null) { + enteredText = this._pixi.text; + } else { + enteredText = this._text; + } + if (typeof this._pixi !== "undefined") { this._pixi.destroy(true); } - // Get the currently entered text - let enteredText = this._pixi !== undefined ? this._pixi.text : ""; + // Create new TextInput this._pixi = new TextInput(this._getTextInputOptions()); + this._addEventListeners(); // listeners required for regular textboxes, but may cause problems with button stimuli if (!(this instanceof ButtonStim)) diff --git a/src/visual/TextInput.js b/src/visual/TextInput.js index 61d392b3..f92d48bd 100644 --- a/src/visual/TextInput.js +++ b/src/visual/TextInput.js @@ -25,6 +25,7 @@ export class TextInput extends PIXI.Container text: "", transformOrigin: "0 0", lineHeight: "1", + boxSizing: "border-box" }, styles.input, ); @@ -119,6 +120,7 @@ export class TextInput extends PIXI.Container { this._disabled = disabled; this._dom_input.disabled = disabled; + this._dom_input.contentEditable = !disabled; this._setState(disabled ? "DISABLED" : "DEFAULT"); } @@ -166,12 +168,21 @@ export class TextInput extends PIXI.Container get text() { - return this._dom_input.value; + if (this._dom_input.tagName === "INPUT" || this._dom_input.tagName === "TEXTAREA") { + return this._dom_input.value; + } + + // innerText, not textContent properly preserves new lines + return this._dom_input.innerText; } set text(text) { - this._dom_input.value = text; + if (this._dom_input.tagName === "INPUT" || this._dom_input.tagName === "TEXTAREA") { + this._dom_input.value = text; + } else { + this._dom_input.textContent = text; + } if (this._substituted) { this._updateSurrogate(); @@ -220,6 +231,10 @@ export class TextInput extends PIXI.Container } } + getInputStyle (key) { + return this._dom_input.style[key]; + } + destroy(options) { this._destroyBoxCache(); @@ -232,8 +247,10 @@ export class TextInput extends PIXI.Container { if (this._multiline) { - this._dom_input = document.createElement("textarea"); - this._dom_input.style.resize = "none"; + // this._dom_input = document.createElement("textarea"); + // this._dom_input.style.resize = "none"; + this._dom_input = document.createElement("div"); + this._dom_input.contentEditable = "true"; } else { @@ -270,6 +287,10 @@ export class TextInput extends PIXI.Container _onInputInput(e) { + if (this._disabled) + { + return; + } if (this._restrict_regex) { this._applyRestriction(); @@ -280,6 +301,7 @@ export class TextInput extends PIXI.Container this._updateSubstitution(); } + this._updateBox(); this.emit("input", this.text); } @@ -393,6 +415,7 @@ export class TextInput extends PIXI.Container } this._box = this._box_cache[this.state]; + this._box.interactive = !this._disabled; this.addChildAt(this._box, 0); this._previous.state = this.state; } @@ -719,7 +742,8 @@ export class TextInput extends PIXI.Container _setDOMInputVisible(visible) { - this._dom_input.style.display = visible ? "block" : "none"; + const definedStyle = this._input_style.display; + this._dom_input.style.display = visible ? (definedStyle ? definedStyle : "display") : "none"; } _getCanvasBounds() @@ -824,10 +848,12 @@ function DefaultBoxGenerator(styles) let style = styles[state.toLowerCase()]; let box = new PIXI.Graphics(); - box.interactive = true; - box.on('pointerdown', () => { - this._dom_input.focus(); - }); + if (this._multiline) { + box.interactive = !this._disabled; + box.on("pointerdown", () => { + this._dom_input.focus(); + }); + } box.beginFill(style.fill, style.alpha); diff --git a/src/visual/VisualStim.js b/src/visual/VisualStim.js index 00ab1e42..c30c2b95 100644 --- a/src/visual/VisualStim.js +++ b/src/visual/VisualStim.js @@ -150,7 +150,11 @@ export class VisualStim extends util.mix(MinimalStim).with(WindowMixin) [Math.sin(radians), Math.cos(radians)] ]; - this._onChange(true, true)(); + if (this._pixi instanceof PIXI.DisplayObject) { + this._pixi.rotation = -ori * Math.PI / 180; + } else { + this._onChange(true, true)(); + } } } From 112234c68ad567647c0cf30f9e707357843f4ebd Mon Sep 17 00:00:00 2001 From: lgtst Date: Sun, 1 May 2022 00:25:41 +0300 Subject: [PATCH 32/92] fixed text reset on add/remove from scene; added fitToContent functionality; --- src/core/Window.js | 2 + src/visual/TextBox.js | 96 +++++++++++++++++++++++++---------------- src/visual/TextInput.js | 22 +++++++++- 3 files changed, 82 insertions(+), 38 deletions(-) diff --git a/src/core/Window.js b/src/core/Window.js index 7df70104..2a5d3e26 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -126,6 +126,8 @@ export class Window extends PsychObject return; } + this._rootContainer.destroy(); + if (document.body.contains(this._renderer.view)) { document.body.removeChild(this._renderer.view); diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 97eb7c72..23c213d2 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -92,8 +92,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "text", text, - "", - this._onChange(true, true), + "" ); this._addAttribute( "placeholder", @@ -104,8 +103,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "anchor", anchor, - "center", - this._onChange(false, true), + "center" ); this._addAttribute( "flipHoriz", @@ -220,7 +218,6 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) */ reset() { - const text = this.editable ? "" : this.placeholder; this.setText(this.placeholder); } @@ -241,15 +238,34 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * @name module:visual.TextBox#setAlignment * @public * @param {boolean} alignment - alignment of the text - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ - setAlignment(alignment = "left", log = false) { + setAlignment(alignment = "left", log = false) + { this._setAttribute("alignment", alignment, log); if (this._pixi !== undefined) { this._pixi.setInputStyle("textAlign", alignment); } } + /** + * Setter for the anchor attribute. + * + * @name module:visual.TextBox#setAnchor + * @public + * @param {boolean} anchor - anchor of the textbox + * @param {boolean} [log= false] - whether or not to log + */ + setAnchor (anchor = "center", log = false) + { + this._setAttribute("anchor", anchor, log); + if (this._pixi !== undefined) { + const anchorUnits = this._getAnchor(); + this._pixi.anchor.x = anchorUnits[0]; + this._pixi.anchor.y = anchorUnits[1]; + } + } + /** * For tweaking the underlying input value. * @@ -290,9 +306,10 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * @name module:visual.TextBox#setColor * @public * @param {boolean} color - color of the text - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ - setColor (color, log = false) { + setColor (color, log = false) + { this._setAttribute('color', color, log); this._needUpdate = true; this._needPixiUpdate = true; @@ -304,9 +321,10 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * @name module:visual.TextBox#setFillColor * @public * @param {boolean} fillColor - fill color of the text box - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ - setFillColor (fillColor, log = false) { + setFillColor (fillColor, log = false) + { this._setAttribute('fillColor', fillColor, log); this._needUpdate = true; this._needPixiUpdate = true; @@ -318,9 +336,10 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * @name module:visual.TextBox#setBorderColor * @public * @param {Color} borderColor - border color of the text box - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ - setBorderColor (borderColor, log = false) { + setBorderColor (borderColor, log = false) + { this._setAttribute('borderColor', borderColor, log); this._needUpdate = true; this._needPixiUpdate = true; @@ -332,10 +351,17 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * @name module:visual.TextBox#setFitToContent * @public * @param {boolean} fitToContent - whether or not to autoresize textbox to fit to text content - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ - setFitToContent (fitToContent, log = false) { + setFitToContent (fitToContent, log = false) + { this._setAttribute("fitToContent", fitToContent, log); + const width_px = Math.abs(Math.round(this._getLengthPix(this._size[0]))); + const height_px = Math.abs(Math.round(this._getLengthPix(this._size[1]))); + if (this._pixi !== undefined) { + this._pixi.setInputStyle("width", fitToContent ? "auto" : `${width_px}px`); + this._pixi.setInputStyle("height", fitToContent ? "auto" : `${height_px}px`); + } } /** @@ -344,7 +370,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * @name module:visual.TextBox#setSize * @public * @param {boolean} size - whether or not to wrap the text at the given width - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ setSize(size, log) { @@ -354,10 +380,11 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) || (Array.isArray(size) && size.every((v) => typeof v === "undefined" || v === null)) ); + this.fitToContent = isSizeUndefined; + if (isSizeUndefined) { size = TextBox._defaultSizeMap.get(this._units); - this.fitToContent = true; if (typeof size === "undefined") { @@ -387,12 +414,13 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) * @name module:visual.TextBox#_addEventListeners * @protected */ - _addEventListeners () { + _addEventListeners () + { this._pixi.on("input", (textContent) => { - const anchor = this._getAnchor(); - this._pixi.pivot.x = anchor[0] * this._pixi.width; - this._pixi.pivot.y = anchor[1] * -this._pixi.height; this._text = textContent; + let size = [this._pixi.width, this._pixi.height]; + size = util.to_unit(size, "pix", this._win, this._units); + this._setAttribute("size", size, false); }); } @@ -429,8 +457,8 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) { const letterHeight_px = Math.round(this._getLengthPix(this._letterHeight)); const padding_px = Math.round(this._getLengthPix(this._padding)); - const width_px = Math.round(this._getLengthPix(this._size[0])); const borderWidth_px = Math.round(this._getLengthPix(this._borderWidth)); + const width_px = Math.round(this._getLengthPix(this._size[0])); const height_px = Math.round(this._getLengthPix(this._size[1])); return { @@ -438,19 +466,19 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) input: { display: "inline-block", fontFamily: this._font, - fontSize: letterHeight_px + "px", + fontSize: `${letterHeight_px}px`, color: this._color === undefined || this._color === null ? 'transparent' : new Color(this._color).hex, fontWeight: (this._bold) ? "bold" : "normal", fontStyle: (this._italic) ? "italic" : "normal", textAlign: this._alignment, - padding: padding_px + "px", + padding: `${padding_px}px`, multiline: this._multiline, text: this._text, - height: this._fitToContent ? "auto" : (this._multiline ? (height_px - 2 * padding_px) + "px" : undefined), - width: this._fitToContent ? "auto" : (width_px - 2 * padding_px) + "px", - maxWidth: `${Math.round(this._getLengthPix(1))}px`, - maxHeight: `${Math.round(this._getLengthPix(1))}px`, - overflow: this._fitToContent ? "hidden" : "auto", + height: this._fitToContent ? "auto" : (this._multiline ? `${height_px}px` : undefined), + width: this._fitToContent ? "auto" : `${width_px}px`, + maxWidth: `${this.win.size[0]}px`, + maxHeight: `${this.win.size[1]}px`, + overflow: "hidden", pointerEvents: "none" }, // box style properties eventually become PIXI.Graphics settings, so same syntax applies @@ -553,12 +581,12 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) // Create new TextInput this._pixi = new TextInput(this._getTextInputOptions()); - this._addEventListeners(); // listeners required for regular textboxes, but may cause problems with button stimuli if (!(this instanceof ButtonStim)) { this._pixi._addListeners(); + this._addEventListeners(); } // check if other TextBox instances are already in focus @@ -585,14 +613,10 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._pixi.disabled = !this._editable; - const anchor = this._getAnchor(); - + // now when this._pixi is available, setting anchor again to trigger internal to this._pixi mechanisms + this.anchor = this._anchor; this._pixi.scale.x = this._flipHoriz ? -1 : 1; this._pixi.scale.y = this._flipVert ? 1 : -1; - this._pixi.pivot.x = anchor[0] * this._pixi.width; - // when setting PIXI.Container.scale.y = -1 - // PIXI.Container.height becomes negative - this._pixi.pivot.y = anchor[1] * -this._pixi.height; this._pixi.rotation = -this._ori * Math.PI / 180; [this._pixi.x, this._pixi.y] = util.to_px(this._pos, this._units, this._win); diff --git a/src/visual/TextInput.js b/src/visual/TextInput.js index f92d48bd..5b4d8b8a 100644 --- a/src/visual/TextInput.js +++ b/src/visual/TextInput.js @@ -49,6 +49,7 @@ export class TextInput extends PIXI.Container this._multiline = false; } + this._anchor = new PIXI.ObservablePoint(this._onAnchorUpdate, this, 0, 0); this._box_cache = {}; this._previous = {}; this._dom_added = false; @@ -181,7 +182,7 @@ export class TextInput extends PIXI.Container if (this._dom_input.tagName === "INPUT" || this._dom_input.tagName === "TEXTAREA") { this._dom_input.value = text; } else { - this._dom_input.textContent = text; + this._dom_input.innerText = text; } if (this._substituted) { @@ -194,6 +195,16 @@ export class TextInput extends PIXI.Container return this._dom_input; } + get anchor () + { + return this._anchor; + } + + set anchor (v) + { + this._anchor.copyFrom(v); + } + focus(options = { preventScroll: true }) { if (this._substituted && !this.dom_visible) @@ -275,6 +286,11 @@ export class TextInput extends PIXI.Container this._dom_input.addEventListener("blur", this._onBlurred.bind(this)); } + _onAnchorUpdate () { + this.pivot.x = this._anchor.x * this.scale.x * this.width; + this.pivot.y = this._anchor.y * this.scale.y * this.height; + } + _onInputKeyDown(e) { this._selection = [ @@ -325,7 +341,7 @@ export class TextInput extends PIXI.Container _onAdded() { document.body.appendChild(this._dom_input); - this._dom_input.style.display = "none"; + this._updateDOMInput(); this._dom_added = true; } @@ -418,6 +434,8 @@ export class TextInput extends PIXI.Container this._box.interactive = !this._disabled; this.addChildAt(this._box, 0); this._previous.state = this.state; + this.pivot.x = this._anchor.x * this.scale.x * this.width; + this.pivot.y = this._anchor.y * this.scale.y * this.height; } _updateSubstitution() From e6a018d619401ab8581e867e6f8a4fd39da4bc64 Mon Sep 17 00:00:00 2001 From: lgtst Date: Sun, 1 May 2022 01:26:32 +0300 Subject: [PATCH 33/92] added font and letterHeight setters; --- src/visual/TextBox.js | 41 ++++++++++++++++++++++++++++++++++++----- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/src/visual/TextBox.js b/src/visual/TextBox.js index 23c213d2..0f9d999e 100644 --- a/src/visual/TextBox.js +++ b/src/visual/TextBox.js @@ -122,14 +122,12 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._addAttribute( "font", font, - "Arial", - this._onChange(true, true), + "Arial" ); this._addAttribute( "letterHeight", letterHeight, - this._getDefaultLetterHeight(), - this._onChange(true, true), + this._getDefaultLetterHeight() ); this._addAttribute( "bold", @@ -283,6 +281,39 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) this._text = text; } + /** + * Set the font for textbox. + * + * @name module:visual.TextBox#setFont + * @public + * @param {string} text + */ + setFont(font = "Arial", log = false) + { + this._setAttribute("font", font, log); + if (this._pixi !== undefined) + { + this._pixi.setInputStyle("fontFamily", font); + } + } + + /** + * Set letterHeight (font size) for textbox. + * + * @name module:visual.TextBox#setLetterHeight + * @public + * @param {string} text + */ + setLetterHeight(fontSize = this._getDefaultLetterHeight(), log = false) + { + this._setAttribute("letterHeight", fontSize, log); + const fontSize_px = this._getLengthPix(fontSize); + if (this._pixi !== undefined) + { + this._pixi.setInputStyle("fontSize", `${fontSize_px}px`); + } + } + /** * For accessing the underlying input value. * @@ -381,7 +412,7 @@ export class TextBox extends util.mix(VisualStim).with(ColorMixin) ); this.fitToContent = isSizeUndefined; - + if (isSizeUndefined) { size = TextBox._defaultSizeMap.get(this._units); From 496154442f7b14e718b45678939746d11e87d7f8 Mon Sep 17 00:00:00 2001 From: lgtst Date: Sun, 1 May 2022 22:26:37 +0300 Subject: [PATCH 34/92] CU-1yn08hm - added contrast support; --- src/core/Window.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/core/Window.js b/src/core/Window.js index cf27c937..74553953 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -52,6 +52,7 @@ export class Window extends PsychObject fullscr = false, color = new Color("black"), gamma = 1, + contrast = 1, units = "pix", waitBlanking = false, autoLog = true, @@ -64,7 +65,8 @@ export class Window extends PsychObject // storing AdjustmentFilter instance to access later; this._adjustmentFilter = new AdjustmentFilter({ - gamma + gamma, + contrast }); // list of all elements, in the order they are currently drawn: @@ -75,6 +77,9 @@ export class Window extends PsychObject this._addAttribute("gamma", gamma, 1, () => { this._adjustmentFilter.gamma = this._gamma; }); + this._addAttribute("contrast", contrast, 1, () => { + this._adjustmentFilter.contrast = this._contrast; + }); this._addAttribute("units", units); this._addAttribute("waitBlanking", waitBlanking); this._addAttribute("autoLog", autoLog); From a1effe6969f8fa2608f4bfec208085e25969f410 Mon Sep 17 00:00:00 2001 From: lgtst Date: Thu, 5 May 2022 19:37:30 +0300 Subject: [PATCH 35/92] added per grating and per window contrast filter; added separate stimuli PIXI container as a result of workaround with background; added grating blend mode support; started work on grating colors; --- src/core/MinimalStim.js | 8 +- src/core/Window.js | 53 +++++++- src/visual/Form.js | 4 +- src/visual/GratingStim.js | 158 ++++++++++++++++-------- src/visual/shaders/circleShader.frag | 3 +- src/visual/shaders/crossShader.frag | 3 +- src/visual/shaders/gaussShader.frag | 3 +- src/visual/shaders/radRampShader.frag | 3 +- src/visual/shaders/raisedCosShader.frag | 3 +- src/visual/shaders/sawShader.frag | 3 +- src/visual/shaders/sinShader.frag | 4 +- src/visual/shaders/sinXsinShader.frag | 3 +- src/visual/shaders/sqrShader.frag | 5 +- src/visual/shaders/sqrXsqrShader.frag | 3 +- src/visual/shaders/triShader.frag | 3 +- 15 files changed, 187 insertions(+), 72 deletions(-) diff --git a/src/core/MinimalStim.js b/src/core/MinimalStim.js index 215ed447..c20d5752 100644 --- a/src/core/MinimalStim.js +++ b/src/core/MinimalStim.js @@ -101,7 +101,7 @@ export class MinimalStim extends PsychObject } else { - this.win._rootContainer.addChild(this._pixi); + this._win.addPixiObject(this._pixi); this.win._drawList.push(this); } } @@ -111,9 +111,9 @@ export class MinimalStim extends PsychObject // from the window container, update it, then put it back: if (this._needUpdate && typeof this._pixi !== "undefined") { - this.win._rootContainer.removeChild(this._pixi); + this._win.removePixiObject(this._pixi); this._updateIfNeeded(); - this.win._rootContainer.addChild(this._pixi); + this._win.addPixiObject(this._pixi); } } } @@ -140,7 +140,7 @@ export class MinimalStim extends PsychObject // if the stimulus has a pixi representation, remove it from the root container: if (typeof this._pixi !== "undefined") { - this._win._rootContainer.removeChild(this._pixi); + this._win.removePixiObject(this._pixi); } } this.status = PsychoJS.Status.STOPPED; diff --git a/src/core/Window.js b/src/core/Window.js index 74553953..b0eeb50b 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -26,7 +26,8 @@ import { Logger } from "./Logger.js"; * @param {string} [options.name] the name of the window * @param {boolean} [options.fullscr= false] whether or not to go fullscreen * @param {Color} [options.color= Color('black')] the background color of the window - * @param {number} [options.gamma= 1] sets the delimiter for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) + * @param {number} [options.gamma= 1] sets the divisor for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) + * @param {number} [options.contrast= 1] sets the contrast value * @param {string} [options.units= 'pix'] the units of the window * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done * before flipping @@ -73,7 +74,11 @@ export class Window extends PsychObject this._drawList = []; this._addAttribute("fullscr", fullscr); - this._addAttribute("color", color); + this._addAttribute("color", color, new Color("black"), () => { + if (this._backgroundSprite) { + this._backgroundSprite.tint = color.int; + } + }); this._addAttribute("gamma", gamma, 1, () => { this._adjustmentFilter.gamma = this._gamma; }); @@ -298,6 +303,28 @@ export class Window extends PsychObject this._flipCallbacks.push({ function: flipCallback, arguments: flipCallbackArgs }); } + /** + * Add PIXI.DisplayObject to the container displayed on the scene (window) + * + * @name module:core.Window#addPixiObject + * @function + * @public + */ + addPixiObject (pixiObject) { + this._stimsContainer.addChild(pixiObject); + } + + /** + * Remove PIXI.DisplayObject from the container displayed on the scene (window) + * + * @name module:core.Window#removePixiObject + * @function + * @public + */ + removePixiObject (pixiObject) { + this._stimsContainer.removeChild(pixiObject); + } + /** * Render the stimuli onto the canvas. * @@ -385,9 +412,9 @@ export class Window extends PsychObject { if (stimulus._needUpdate && typeof stimulus._pixi !== "undefined") { - this._rootContainer.removeChild(stimulus._pixi); + this._stimsContainer.removeChild(stimulus._pixi); stimulus._updateIfNeeded(); - this._rootContainer.addChild(stimulus._pixi); + this._stimsContainer.addChild(stimulus._pixi); } } } @@ -432,6 +459,7 @@ export class Window extends PsychObject width: this._size[0], height: this._size[1], backgroundColor: this.color.int, + powerPreference: "high-performance", resolution: window.devicePixelRatio, }); this._renderer.view.style.transform = "translatez(0)"; @@ -441,8 +469,25 @@ export class Window extends PsychObject // we also change the background color of the body since the dialog popup may be longer than the window's height: document.body.style.backgroundColor = this._color.hex; + // filters in PIXI work in a slightly unexpected fashion: + // when setting this._rootContainer.filters, filtering itself + // ignores backgroundColor of this._renderer and in addition to that + // all child elements of this._rootContainer ignore backgroundColor when blending. + // To circumvent that creating a separate PIXI.Sprite that serves as background color. + // Then placing all Stims to a separate this._stimsContainer which hovers on top of + // background sprite so that if we need to move all stims at once, the background sprite + // won't get affected. + this._backgroundSprite = new PIXI.Sprite(PIXI.Texture.WHITE); + this._backgroundSprite.tint = this.color.int; + this._backgroundSprite.width = this._size[0]; + this._backgroundSprite.height = this._size[1]; + this._backgroundSprite.anchor.set(.5); + this._stimsContainer = new PIXI.Container(); + this._stimsContainer.sortableChildren = true; + // create a top-level PIXI container: this._rootContainer = new PIXI.Container(); + this._rootContainer.addChild(this._backgroundSprite, this._stimsContainer); this._rootContainer.interactive = true; this._rootContainer.filters = [this._adjustmentFilter]; diff --git a/src/visual/Form.js b/src/visual/Form.js index 5d7001f4..91cb6452 100644 --- a/src/visual/Form.js +++ b/src/visual/Form.js @@ -1009,8 +1009,8 @@ export class Form extends util.mix(VisualStim).with(ColorMixin) this._stimuliClipMask.clear(); this._stimuliClipMask.beginFill(0xFFFFFF); this._stimuliClipMask.drawRect( - this._win._rootContainer.position.x + this._leftEdge_px + 2, - this._win._rootContainer.position.y + this._bottomEdge_px + 2, + this._win._stimsContainer.position.x + this._leftEdge_px + 2, + this._win._stimsContainer.position.y + this._bottomEdge_px + 2, this._size_px[0] - 4, this._size_px[1] - 6, ); diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index 88015a06..154523e1 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -8,8 +8,8 @@ */ import * as PIXI from "pixi.js-legacy"; +import {AdjustmentFilter} from "@pixi/filter-adjustment"; import { Color } from "../util/Color.js"; -import { ColorMixin } from "../util/ColorMixin.js"; import { to_pixiPoint } from "../util/Pixi.js"; import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; @@ -32,7 +32,6 @@ import raisedCosShader from "./shaders/raisedCosShader.frag"; * @name module:visual.GratingStim * @class * @extends VisualStim - * @mixes ColorMixin * @param {Object} options * @param {String} options.name - the name used when logging messages from this stimulus * @param {Window} options.win - the associated Window @@ -44,17 +43,17 @@ import raisedCosShader from "./shaders/raisedCosShader.frag"; * @param {Array.} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) - * @param {Color} [options.color= "white"] the background color - * @param {number} [options.opacity= 1.0] - the opacity - * @param {number} [options.contrast= 1.0] - the contrast + * @param {Color} [options.color= "white"] - Foreground color of the stimulus. Can be String like "red" or "#ff0000" or Number like 0xff0000. + * @param {number} [options.opacity= 1.0] - Set the opacity of the stimulus. Determines how visible the stimulus is relative to background. + * @param {number} [options.contrast= 1.0] - Set the contrast of the stimulus, i.e. scales how far the stimulus deviates from the middle grey. Ranges [-1, 1]. * @param {number} [options.depth= 0] - the depth (i.e. the z order) * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated. NOT IMPLEMENTED YET. - * @param {String} [options.blendmode= 'avg'] - blend mode of the stimulus, determines how the stimulus is blended with the background. NOT IMPLEMENTED YET. + * @param {String} [options.blendmode= "avg"] - blend mode of the stimulus, determines how the stimulus is blended with the background. Supported values: "avg", "add", "mul", "screen". * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ -export class GratingStim extends util.mix(VisualStim).with(ColorMixin) +export class GratingStim extends VisualStim { /** * An object that keeps shaders source code and default uniform values for them. @@ -143,21 +142,23 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) uniforms: { uFreq: 1.0, uPhase: 0.0, - uColor: [.5, 0, .5] + uColor: [1., 1., 1.] } }, sqr: { shader: sqrShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.] } }, saw: { shader: sawShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.] } }, tri: { @@ -165,27 +166,31 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) uniforms: { uFreq: 1.0, uPhase: 0.0, - uPeriod: 1.0 + uPeriod: 1.0, + uColor: [1., 1., 1.] } }, sinXsin: { shader: sinXsinShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.] } }, sqrXsqr: { shader: sqrXsqrShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.] } }, circle: { shader: circleShader, uniforms: { - uRadius: 1.0 + uRadius: 1.0, + uColor: [1., 1., 1.] } }, gauss: { @@ -193,26 +198,30 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) uniforms: { uA: 1.0, uB: 0.0, - uC: 0.16 + uC: 0.16, + uColor: [1., 1., 1.] } }, cross: { shader: crossShader, uniforms: { - uThickness: 0.2 + uThickness: 0.2, + uColor: [1., 1., 1.] } }, radRamp: { shader: radRampShader, uniforms: { - uSqueeze: 1.0 + uSqueeze: 1.0, + uColor: [1., 1., 1.] } }, raisedCos: { shader: raisedCosShader, uniforms: { uBeta: 0.25, - uPeriod: 0.625 + uPeriod: 0.625, + uColor: [1., 1., 1.] } } }; @@ -225,6 +234,13 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) */ static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels + static #BLEND_MODES_MAP = { + avg: PIXI.BLEND_MODES.NORMAL, + add: PIXI.BLEND_MODES.ADD, + mul: PIXI.BLEND_MODES.MULTIPLY, + screen: PIXI.BLEND_MODES.SCREEN + }; + constructor({ name, tex = "sin", @@ -239,7 +255,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) color, colorSpace, opacity, - contrast, + contrast = 1, depth, interpolate, blendmode, @@ -250,36 +266,19 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) { super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); - this._addAttribute( - "tex", - tex, - ); - this._addAttribute( - "mask", - mask, - ); - this._addAttribute( - "SF", - sf, - GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uFreq || 1.0 : 1.0 - ); - this._addAttribute( - "phase", - phase, - GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uPhase || 0.0 : 0.0 - ); - this._addAttribute( - "color", - color, - "white", - this._onChange(true, false), - ); - this._addAttribute( - "contrast", - contrast, - 1.0, - this._onChange(true, false), - ); + this._adjustmentFilter = new AdjustmentFilter({ + contrast + }); + this._addAttribute("tex", tex); + this._addAttribute("mask", mask); + this._addAttribute("SF", sf, GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uFreq || 1.0 : 1.0); + this._addAttribute("phase", phase, GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uPhase || 0.0 : 0.0); + this._addAttribute("color", color, "white"); + this._addAttribute("colorSpace", colorSpace, "RGB"); + this._addAttribute("contrast", contrast, 1.0, () => { + this._adjustmentFilter.contrast = this._contrast; + }); + this._addAttribute("blendmode", blendmode, "avg"); this._addAttribute( "interpolate", interpolate, @@ -521,6 +520,43 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } } + /** + * Set color space value for the grating stimulus. + * + * @name module:visual.GratingStim#setColorSpace + * @public + * @param {String} colorSpaceVal - color space value + * @param {boolean} [log= false] - whether of not to log + */ + setColorSpace (colorSpaceVal = "RGB", log = false) { + let colorSpaceValU = colorSpaceVal.toUpperCase(); + if (Color.COLOR_SPACE[colorSpaceValU] === undefined) { + colorSpaceValU = "RGB"; + } + const hasChanged = this._setAttribute("colorSpace", colorSpaceValU, log); + if (hasChanged) { + this.setColor(this._color); + } + } + + /** + * Set foreground color value for the grating stimulus. + * + * @name module:visual.GratingStim#setColor + * @public + * @param {Color} colorVal - color value, can be String like "red" or "#ff0000" or Number like 0xff0000. + * @param {boolean} [log= false] - whether of not to log + */ + setColor (colorVal = "white", log = false) { + const colorObj = (colorVal instanceof Color) ? colorVal : new Color(colorVal, Color.COLOR_SPACE[this._colorSpace]) + this._setAttribute("color", colorObj, log); + if (this._pixi instanceof PIXI.Mesh) { + this._pixi.shader.uniforms.uColor = colorObj.rgb; + } else if (this._pixi instanceof PIXI.TilingSprite) { + + } + } + /** * Set spatial frequency value for the function. * @@ -543,6 +579,29 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) } } + /** + * Set blend mode of the grating stimulus. + * + * @name module:visual.GratingStim#setBlendmode + * @public + * @param {String} blendMode - blend mode, can be one of the following: ["avg", "add", "mul", "screen"]. + * @param {boolean} [log=false] - whether or not to log + */ + setBlendmode (blendMode = "avg", log = false) { + this._setAttribute("blendmode", blendMode, log); + if (this._pixi !== undefined) { + let pixiBlendMode = GratingStim.#BLEND_MODES_MAP[blendMode]; + if (pixiBlendMode === undefined) { + pixiBlendMode = PIXI.BLEND_MODES.NORMAL; + } + if (this._pixi.filters) { + this._pixi.filters[this._pixi.filters.length - 1].blendMode = pixiBlendMode; + } else { + this._pixi.blendMode = pixiBlendMode; + } + } + } + /** * Update the stimulus, if necessary. * @@ -590,6 +649,7 @@ export class GratingStim extends util.mix(VisualStim).with(ColorMixin) }); } this._pixi.pivot.set(this._pixi.width * 0.5, this._pixi.width * 0.5); + this._pixi.filters = [this._adjustmentFilter]; // add a mask if need be: if (typeof this._mask !== "undefined") diff --git a/src/visual/shaders/circleShader.frag b/src/visual/shaders/circleShader.frag index 51e9eccf..61801769 100644 --- a/src/visual/shaders/circleShader.frag +++ b/src/visual/shaders/circleShader.frag @@ -16,9 +16,10 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uRadius; +uniform vec3 uColor; void main() { vec2 uv = vUvs; float s = 1. - step(uRadius, length(uv * 2. - 1.)); - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/crossShader.frag b/src/visual/shaders/crossShader.frag index b487b9eb..d2f3e911 100644 --- a/src/visual/shaders/crossShader.frag +++ b/src/visual/shaders/crossShader.frag @@ -16,11 +16,12 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uThickness; +uniform vec3 uColor; void main() { vec2 uv = vUvs; float sx = step(uThickness, length(uv.x * 2. - 1.)); float sy = step(uThickness, length(uv.y * 2. - 1.)); float s = 1. - sx * sy; - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag index 3ba302ca..efa69f16 100644 --- a/src/visual/shaders/gaussShader.frag +++ b/src/visual/shaders/gaussShader.frag @@ -18,6 +18,7 @@ out vec4 shaderOut; uniform float uA; uniform float uB; uniform float uC; +uniform vec3 uColor; #define M_PI 3.14159265358979 @@ -26,5 +27,5 @@ void main() { float c2 = uC * uC; float x = length(uv - .5); float g = uA * exp(-pow(x - uB, 2.) / c2 * .5); - shaderOut = vec4(vec3(g), 1.); + shaderOut = vec4(vec3(g) * uColor, 1.); } diff --git a/src/visual/shaders/radRampShader.frag b/src/visual/shaders/radRampShader.frag index 192acd49..af47d933 100644 --- a/src/visual/shaders/radRampShader.frag +++ b/src/visual/shaders/radRampShader.frag @@ -14,11 +14,12 @@ precision mediump float; in vec2 vUvs; out vec4 shaderOut; uniform float uSqueeze; +uniform vec3 uColor; #define M_PI 3.14159265358979 void main() { vec2 uv = vUvs; float s = 1. - length(uv * 2. - 1.) * uSqueeze; - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/raisedCosShader.frag b/src/visual/shaders/raisedCosShader.frag index 05e75cde..6d1eec21 100644 --- a/src/visual/shaders/raisedCosShader.frag +++ b/src/visual/shaders/raisedCosShader.frag @@ -18,6 +18,7 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uBeta; uniform float uPeriod; +uniform vec3 uColor; void main() { vec2 uv = vUvs; @@ -31,5 +32,5 @@ void main() { } else if (absX > edgeArgument2) { s = 0.; } - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/sawShader.frag b/src/visual/shaders/sawShader.frag index 0948bf73..829cbcc9 100644 --- a/src/visual/shaders/sawShader.frag +++ b/src/visual/shaders/sawShader.frag @@ -18,10 +18,11 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uFreq; uniform float uPhase; +uniform vec3 uColor; void main() { vec2 uv = vUvs; float s = uFreq * uv.x + uPhase; s = mod(s, 1.); - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index 441d2fda..b55a402a 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -22,6 +22,6 @@ uniform vec3 uColor; void main() { vec2 uv = vUvs; - float s = sin((uFreq * uv.x + uPhase) * 2. * M_PI); - shaderOut = vec4((.5 + .5 * vec3(s)) * uColor, 1.0); + float s = sin((uFreq * uv.x + uPhase) * 2. * M_PI) * .5 + .5; + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/sinXsinShader.frag b/src/visual/shaders/sinXsinShader.frag index 21d39754..88b4e0d9 100644 --- a/src/visual/shaders/sinXsinShader.frag +++ b/src/visual/shaders/sinXsinShader.frag @@ -19,11 +19,12 @@ out vec4 shaderOut; #define PI2 2.* M_PI uniform float uFreq; uniform float uPhase; +uniform vec3 uColor; void main() { vec2 uv = vUvs; float sx = sin((uFreq * uv.x + uPhase) * PI2); float sy = sin((uFreq * uv.y + uPhase) * PI2); float s = sx * sy * .5 + .5; - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/sqrShader.frag b/src/visual/shaders/sqrShader.frag index f44ffca6..2669c9ba 100644 --- a/src/visual/shaders/sqrShader.frag +++ b/src/visual/shaders/sqrShader.frag @@ -18,9 +18,10 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uFreq; uniform float uPhase; +uniform vec3 uColor; void main() { vec2 uv = vUvs; - float s = sign(sin((uFreq * uv.x + uPhase) * 2. * M_PI)); - shaderOut = vec4(.5 + .5 * vec3(s), 1.0); + float s = sign(sin((uFreq * uv.x + uPhase) * 2. * M_PI)) * .5 + .5; + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/sqrXsqrShader.frag b/src/visual/shaders/sqrXsqrShader.frag index 6f140b41..f953f9c0 100644 --- a/src/visual/shaders/sqrXsqrShader.frag +++ b/src/visual/shaders/sqrXsqrShader.frag @@ -19,11 +19,12 @@ out vec4 shaderOut; #define PI2 2.* M_PI uniform float uFreq; uniform float uPhase; +uniform vec3 uColor; void main() { vec2 uv = vUvs; float sx = sign(sin((uFreq * uv.x + uPhase) * PI2)); float sy = sign(sin((uFreq * uv.y + uPhase) * PI2)); float s = sx * sy * .5 + .5; - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(vec3(s) * uColor, 1.0); } diff --git a/src/visual/shaders/triShader.frag b/src/visual/shaders/triShader.frag index 445a0c4d..0189a6c7 100644 --- a/src/visual/shaders/triShader.frag +++ b/src/visual/shaders/triShader.frag @@ -19,10 +19,11 @@ out vec4 shaderOut; uniform float uFreq; uniform float uPhase; uniform float uPeriod; +uniform vec3 uColor; void main() { vec2 uv = vUvs; float s = uFreq * uv.x + uPhase; s = 2. * abs(s / uPeriod - floor(s / uPeriod + .5)); - shaderOut = vec4(vec3(s), 1.0); + shaderOut = vec4(vec3(s) * uColor, 1.0); } From 908f52e9410fee15ead3ea8dc232298745dc07a9 Mon Sep 17 00:00:00 2001 From: lgtst Date: Sat, 7 May 2022 22:16:56 +0300 Subject: [PATCH 36/92] added proper support for grating stim coloring; utils/Color extended with rgbFull(), which provides [-1, 1] rgb color space; --- src/util/Color.js | 15 ++++++++ src/visual/GratingStim.js | 46 ++++++++++++++++++------- src/visual/shaders/circleShader.frag | 6 ++-- src/visual/shaders/crossShader.frag | 6 ++-- src/visual/shaders/gaussShader.frag | 6 ++-- src/visual/shaders/radRampShader.frag | 6 ++-- src/visual/shaders/raisedCosShader.frag | 5 ++- src/visual/shaders/sawShader.frag | 6 ++-- src/visual/shaders/sinShader.frag | 5 +-- src/visual/shaders/sinXsinShader.frag | 5 +-- src/visual/shaders/sqrShader.frag | 5 +-- src/visual/shaders/sqrXsqrShader.frag | 5 +-- src/visual/shaders/triShader.frag | 6 ++-- 13 files changed, 89 insertions(+), 33 deletions(-) diff --git a/src/util/Color.js b/src/util/Color.js index 666f7882..b677ea94 100644 --- a/src/util/Color.js +++ b/src/util/Color.js @@ -127,6 +127,8 @@ export class Color { this._rgb = obj._rgb.slice(); } + + this._rgbFull = this._rgb.map(c => c * 2 - 1); } /** @@ -142,6 +144,19 @@ export class Color return this._rgb; } + /** + * Get the [-1,1] RGB triplet equivalent of this Color. + * + * @name module:util.Color.rgbFull + * @function + * @public + * @return {Array.} the [-1,1] RGB triplet equivalent + */ + get rgbFull() + { + return this._rgbFull; + } + /** * Get the [0,255] RGB triplet equivalent of this Color. * diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index 154523e1..218576c0 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -47,7 +47,7 @@ import raisedCosShader from "./shaders/raisedCosShader.frag"; * @param {number} [options.opacity= 1.0] - Set the opacity of the stimulus. Determines how visible the stimulus is relative to background. * @param {number} [options.contrast= 1.0] - Set the contrast of the stimulus, i.e. scales how far the stimulus deviates from the middle grey. Ranges [-1, 1]. * @param {number} [options.depth= 0] - the depth (i.e. the z order) - * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated. NOT IMPLEMENTED YET. + * @param {boolean} [options.interpolate= false] - Whether to interpolate (linearly) the texture in the stimulus. Currently supports only image based gratings. * @param {String} [options.blendmode= "avg"] - blend mode of the stimulus, determines how the stimulus is blended with the background. Supported values: "avg", "add", "mul", "screen". * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log @@ -279,12 +279,7 @@ export class GratingStim extends VisualStim this._adjustmentFilter.contrast = this._contrast; }); this._addAttribute("blendmode", blendmode, "avg"); - this._addAttribute( - "interpolate", - interpolate, - false, - this._onChange(true, false), - ); + this._addAttribute("interpolate", interpolate, false); // estimate the bounding box: this._estimateBoundingBox(); @@ -551,7 +546,7 @@ export class GratingStim extends VisualStim const colorObj = (colorVal instanceof Color) ? colorVal : new Color(colorVal, Color.COLOR_SPACE[this._colorSpace]) this._setAttribute("color", colorObj, log); if (this._pixi instanceof PIXI.Mesh) { - this._pixi.shader.uniforms.uColor = colorObj.rgb; + this._pixi.shader.uniforms.uColor = colorObj.rgbFull; } else if (this._pixi instanceof PIXI.TilingSprite) { } @@ -602,6 +597,24 @@ export class GratingStim extends VisualStim } } + /** + * Whether to interpolate (linearly) the texture in the stimulus. + * + * @name module:visual.GratingStim#setInterpolate + * @public + * @param {boolean} interpolate - interpolate or not. + * @param {boolean} [log=false] - whether or not to log + */ + setInterpolate (interpolate = false, log = false) { + this._setAttribute("interpolate", interpolate, log); + if (this._pixi instanceof PIXI.Mesh) { + + } else if (this._pixi instanceof PIXI.TilingSprite) { + this._pixi.texture.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; + this._pixi.texture.baseTexture.update(); + } + } + /** * Update the stimulus, if necessary. * @@ -620,8 +633,12 @@ export class GratingStim extends VisualStim if (this._needPixiUpdate) { this._needPixiUpdate = false; + let currentUniforms = {}; if (typeof this._pixi !== "undefined") { + if (this._pixi instanceof PIXI.Mesh) { + Object.assign(currentUniforms, this._pixi.shader.uniforms); + } this._pixi.destroy(true); } this._pixi = undefined; @@ -635,6 +652,7 @@ export class GratingStim extends VisualStim if (this._tex instanceof HTMLImageElement) { this._pixi = PIXI.TilingSprite.from(this._tex, { + scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST, width: this._size_px[0], height: this._size_px[1] }); @@ -643,10 +661,14 @@ export class GratingStim extends VisualStim } else { - this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { - uFreq: this._SF, - uPhase: this._phase - }); + this._pixi = this._getPixiMeshFromPredefinedShaders( + this._tex, + Object.assign({ + uFreq: this._SF, + uPhase: this._phase, + uColor: this._color.rgbFull + }, currentUniforms) + ); } this._pixi.pivot.set(this._pixi.width * 0.5, this._pixi.width * 0.5); this._pixi.filters = [this._adjustmentFilter]; diff --git a/src/visual/shaders/circleShader.frag b/src/visual/shaders/circleShader.frag index 61801769..87addc65 100644 --- a/src/visual/shaders/circleShader.frag +++ b/src/visual/shaders/circleShader.frag @@ -20,6 +20,8 @@ uniform vec3 uColor; void main() { vec2 uv = vUvs; - float s = 1. - step(uRadius, length(uv * 2. - 1.)); - shaderOut = vec4(vec3(s) * uColor, 1.0); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float s = (1. - step(uRadius, length(uv * 2. - 1.))) * 2. - 1.; + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/crossShader.frag b/src/visual/shaders/crossShader.frag index d2f3e911..8518f910 100644 --- a/src/visual/shaders/crossShader.frag +++ b/src/visual/shaders/crossShader.frag @@ -22,6 +22,8 @@ void main() { vec2 uv = vUvs; float sx = step(uThickness, length(uv.x * 2. - 1.)); float sy = step(uThickness, length(uv.y * 2. - 1.)); - float s = 1. - sx * sy; - shaderOut = vec4(vec3(s) * uColor, 1.0); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float s = (1. - sx * sy) * 2. - 1.; + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag index efa69f16..deb1de3e 100644 --- a/src/visual/shaders/gaussShader.frag +++ b/src/visual/shaders/gaussShader.frag @@ -26,6 +26,8 @@ void main() { vec2 uv = vUvs; float c2 = uC * uC; float x = length(uv - .5); - float g = uA * exp(-pow(x - uB, 2.) / c2 * .5); - shaderOut = vec4(vec3(g) * uColor, 1.); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float g = uA * exp(-pow(x - uB, 2.) / c2 * .5) * 2. - 1.; + shaderOut = vec4(vec3(g) * uColor * .5 + .5, 1.); } diff --git a/src/visual/shaders/radRampShader.frag b/src/visual/shaders/radRampShader.frag index af47d933..0ea424d8 100644 --- a/src/visual/shaders/radRampShader.frag +++ b/src/visual/shaders/radRampShader.frag @@ -20,6 +20,8 @@ uniform vec3 uColor; void main() { vec2 uv = vUvs; - float s = 1. - length(uv * 2. - 1.) * uSqueeze; - shaderOut = vec4(vec3(s) * uColor, 1.0); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + float s = (1. - length(uv * 2. - 1.) * uSqueeze) * 2. - 1.; + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/raisedCosShader.frag b/src/visual/shaders/raisedCosShader.frag index 6d1eec21..955d9f8e 100644 --- a/src/visual/shaders/raisedCosShader.frag +++ b/src/visual/shaders/raisedCosShader.frag @@ -32,5 +32,8 @@ void main() { } else if (absX > edgeArgument2) { s = 0.; } - shaderOut = vec4(vec3(s) * uColor, 1.0); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + s = s * 2. - 1.; + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/sawShader.frag b/src/visual/shaders/sawShader.frag index 829cbcc9..0802154b 100644 --- a/src/visual/shaders/sawShader.frag +++ b/src/visual/shaders/sawShader.frag @@ -23,6 +23,8 @@ uniform vec3 uColor; void main() { vec2 uv = vUvs; float s = uFreq * uv.x + uPhase; - s = mod(s, 1.); - shaderOut = vec4(vec3(s) * uColor, 1.0); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + s = mod(s, 1.) * 2. - 1.; + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index b55a402a..37b31a02 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -22,6 +22,7 @@ uniform vec3 uColor; void main() { vec2 uv = vUvs; - float s = sin((uFreq * uv.x + uPhase) * 2. * M_PI) * .5 + .5; - shaderOut = vec4(vec3(s) * uColor, 1.0); + float s = sin((uFreq * uv.x + uPhase) * 2. * M_PI); + // it's important to convert to [0, 1] while multiplication to uColor, not before, to preserve desired coloring functionality + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/sinXsinShader.frag b/src/visual/shaders/sinXsinShader.frag index 88b4e0d9..58cf0b9d 100644 --- a/src/visual/shaders/sinXsinShader.frag +++ b/src/visual/shaders/sinXsinShader.frag @@ -25,6 +25,7 @@ void main() { vec2 uv = vUvs; float sx = sin((uFreq * uv.x + uPhase) * PI2); float sy = sin((uFreq * uv.y + uPhase) * PI2); - float s = sx * sy * .5 + .5; - shaderOut = vec4(vec3(s) * uColor, 1.0); + float s = sx * sy; + // it's important to convert to [0, 1] while multiplication to uColor, not before, to preserve desired coloring functionality + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/sqrShader.frag b/src/visual/shaders/sqrShader.frag index 2669c9ba..dc7e34bb 100644 --- a/src/visual/shaders/sqrShader.frag +++ b/src/visual/shaders/sqrShader.frag @@ -22,6 +22,7 @@ uniform vec3 uColor; void main() { vec2 uv = vUvs; - float s = sign(sin((uFreq * uv.x + uPhase) * 2. * M_PI)) * .5 + .5; - shaderOut = vec4(vec3(s) * uColor, 1.0); + float s = sign(sin((uFreq * uv.x + uPhase) * 2. * M_PI)); + // it's important to convert to [0, 1] while multiplication to uColor, not before, to preserve desired coloring functionality + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/sqrXsqrShader.frag b/src/visual/shaders/sqrXsqrShader.frag index f953f9c0..7542208f 100644 --- a/src/visual/shaders/sqrXsqrShader.frag +++ b/src/visual/shaders/sqrXsqrShader.frag @@ -25,6 +25,7 @@ void main() { vec2 uv = vUvs; float sx = sign(sin((uFreq * uv.x + uPhase) * PI2)); float sy = sign(sin((uFreq * uv.y + uPhase) * PI2)); - float s = sx * sy * .5 + .5; - shaderOut = vec4(vec3(s) * uColor, 1.0); + float s = sx * sy; + // it's important to convert to [0, 1] while multiplication to uColor, not before, to preserve desired coloring functionality + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } diff --git a/src/visual/shaders/triShader.frag b/src/visual/shaders/triShader.frag index 0189a6c7..c74260c7 100644 --- a/src/visual/shaders/triShader.frag +++ b/src/visual/shaders/triShader.frag @@ -24,6 +24,8 @@ uniform vec3 uColor; void main() { vec2 uv = vUvs; float s = uFreq * uv.x + uPhase; - s = 2. * abs(s / uPeriod - floor(s / uPeriod + .5)); - shaderOut = vec4(vec3(s) * uColor, 1.0); + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + s = (2. * abs(s / uPeriod - floor(s / uPeriod + .5))) * 2. - 1.; + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); } From 51d57083a3358f51155a72201be90067f175aa4b Mon Sep 17 00:00:00 2001 From: lgtst Date: Wed, 11 May 2022 21:52:40 +0300 Subject: [PATCH 37/92] added coloring support for image based Gratings; added proper opacity support; --- src/visual/GratingStim.js | 130 +++++++++++++++--------- src/visual/shaders/circleShader.frag | 3 +- src/visual/shaders/crossShader.frag | 3 +- src/visual/shaders/gaussShader.frag | 3 +- src/visual/shaders/imageShader.frag | 31 ++++++ src/visual/shaders/radRampShader.frag | 3 +- src/visual/shaders/raisedCosShader.frag | 3 +- src/visual/shaders/sawShader.frag | 3 +- src/visual/shaders/sinShader.frag | 7 +- src/visual/shaders/sinXsinShader.frag | 7 +- src/visual/shaders/sqrShader.frag | 7 +- src/visual/shaders/sqrXsqrShader.frag | 7 +- src/visual/shaders/triShader.frag | 3 +- 13 files changed, 144 insertions(+), 66 deletions(-) create mode 100644 src/visual/shaders/imageShader.frag diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index 88ad1aec..b5a222b4 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -14,6 +14,7 @@ import { to_pixiPoint } from "../util/Pixi.js"; import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; import defaultQuadVert from "./shaders/defaultQuad.vert"; +import imageShader from "./shaders/imageShader.frag"; import sinShader from "./shaders/sinShader.frag"; import sqrShader from "./shaders/sqrShader.frag"; import sawShader from "./shaders/sawShader.frag"; @@ -60,6 +61,13 @@ export class GratingStim extends VisualStim * Shader source code is later used for construction of shader programs to create respective visual stimuli. * @name module:visual.GratingStim.#SHADERS * @type {Object} + * + * @property {Object} imageShader - Renders provided image with applied effects (coloring, phase, frequency). + * @property {String} imageShader.shader - shader source code for the image based grating stimuli. + * @property {Object} imageShader.uniforms - default uniforms for the image based shader. + * @property {float} imageShader.uniforms.uFreq=1.0 - how much times image repeated within grating stimuli. + * @property {float} imageShader.uniforms.uPhase=0.0 - offset of the image along X axis. + * * @property {Object} sin - Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Sine_wave} * @property {String} sin.shader - shader source code for the sine wave stimuli @@ -137,12 +145,22 @@ export class GratingStim extends VisualStim * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). */ static #SHADERS = { + imageShader: { + shader: imageShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, sin: { shader: sinShader, uniforms: { uFreq: 1.0, uPhase: 0.0, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, sqr: { @@ -150,7 +168,8 @@ export class GratingStim extends VisualStim uniforms: { uFreq: 1.0, uPhase: 0.0, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, saw: { @@ -158,7 +177,8 @@ export class GratingStim extends VisualStim uniforms: { uFreq: 1.0, uPhase: 0.0, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, tri: { @@ -167,7 +187,8 @@ export class GratingStim extends VisualStim uFreq: 1.0, uPhase: 0.0, uPeriod: 1.0, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, sinXsin: { @@ -175,7 +196,8 @@ export class GratingStim extends VisualStim uniforms: { uFreq: 1.0, uPhase: 0.0, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, sqrXsqr: { @@ -183,14 +205,16 @@ export class GratingStim extends VisualStim uniforms: { uFreq: 1.0, uPhase: 0.0, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, circle: { shader: circleShader, uniforms: { uRadius: 1.0, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, gauss: { @@ -199,21 +223,24 @@ export class GratingStim extends VisualStim uA: 1.0, uB: 0.0, uC: 0.16, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, cross: { shader: crossShader, uniforms: { uThickness: 0.2, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, radRamp: { shader: radRampShader, uniforms: { uSqueeze: 1.0, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, raisedCos: { @@ -221,7 +248,8 @@ export class GratingStim extends VisualStim uniforms: { uBeta: 0.25, uPeriod: 0.625, - uColor: [1., 1., 1.] + uColor: [1., 1., 1.], + uAlpha: 1.0 } } }; @@ -469,11 +497,11 @@ export class GratingStim extends VisualStim * @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders * @function * @protected - * @param {String} funcName - name of the shader function. Must be one of the SHADERS + * @param {String} shaderName - name of the shader. Must be one of the SHADERS * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values. * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene. */ - _getPixiMeshFromPredefinedShaders (funcName = "", uniforms = {}) { + _getPixiMeshFromPredefinedShaders (shaderName = "", uniforms = {}) { const geometry = new PIXI.Geometry(); geometry.addAttribute( "aVertexPosition", @@ -492,8 +520,8 @@ export class GratingStim extends VisualStim ); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#SHADERS[funcName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); + const fragmentSrc = GratingStim.#SHADERS[shaderName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[shaderName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } @@ -509,9 +537,7 @@ export class GratingStim extends VisualStim setPhase (phase, log = false) { this._setAttribute("phase", phase, log); if (this._pixi instanceof PIXI.Mesh) { - this._pixi.shader.uniforms.uPhase = phase; - } else if (this._pixi instanceof PIXI.TilingSprite) { - this._pixi.tilePosition.x = -phase * (this._size_px[0] * this._pixi.tileScale.x) / (2 * Math.PI) + this._pixi.shader.uniforms.uPhase = -phase; } } @@ -547,8 +573,21 @@ export class GratingStim extends VisualStim this._setAttribute("color", colorObj, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uColor = colorObj.rgbFull; - } else if (this._pixi instanceof PIXI.TilingSprite) { - + } + } + + /** + * Determines how visible the stimulus is relative to background. + * + * @name module:visual.GratingStim#setOpacity + * @public + * @param {number} [opacity=1] opacity - The value should be a single float ranging 1.0 (opaque) to 0.0 (transparent). + * @param {boolean} [log= false] - whether of not to log + */ + setOpacity (opacity = 1, log = false) { + this._setAttribute("opacity", opacity, log); + if (this._pixi instanceof PIXI.Mesh) { + this._pixi.shader.uniforms.uAlpha = opacity; } } @@ -564,13 +603,6 @@ export class GratingStim extends VisualStim this._setAttribute("SF", sf, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uFreq = sf; - } else if (this._pixi instanceof PIXI.TilingSprite) { - // tileScale units are pixels, so converting function frequency to pixels - // and also taking into account possible size difference between used texture and requested stim size - this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width); - // since most functions defined in SHADERS assume spatial frequency change along X axis - // we assume desired effect for image based stims to be the same so tileScale.y is not affected by spatialFrequency - this._pixi.tileScale.y = this._pixi.height / this._pixi.texture.height; } } @@ -607,12 +639,11 @@ export class GratingStim extends VisualStim */ setInterpolate (interpolate = false, log = false) { this._setAttribute("interpolate", interpolate, log); - if (this._pixi instanceof PIXI.Mesh) { - - } else if (this._pixi instanceof PIXI.TilingSprite) { - this._pixi.texture.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; - this._pixi.texture.baseTexture.update(); + if (this._pixi === undefined || !this._pixi.shader.uniforms.uTex) { + return; } + this._pixi.shader.uniforms.uTex.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; + this._pixi.shader.uniforms.uTex.baseTexture.update(); } /** @@ -633,6 +664,8 @@ export class GratingStim extends VisualStim if (this._needPixiUpdate) { this._needPixiUpdate = false; + let shaderName; + let shaderUniforms; let currentUniforms = {}; if (typeof this._pixi !== "undefined") { @@ -651,25 +684,28 @@ export class GratingStim extends VisualStim if (this._tex instanceof HTMLImageElement) { - this._pixi = PIXI.TilingSprite.from(this._tex, { - scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST, - width: this._size_px[0], - height: this._size_px[1] + shaderName = "imageShader"; + let shaderTex = PIXI.Texture.from(this._tex, { + wrapMode: PIXI.WRAP_MODES.REPEAT, + scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST }); - this.setPhase(this._phase); - this.setSF(this._SF); + shaderUniforms = { + uTex: shaderTex, + uFreq: this._SF, + uPhase: this._phase, + uColor: this._color.rgbFull + }; } else { - this._pixi = this._getPixiMeshFromPredefinedShaders( - this._tex, - Object.assign({ - uFreq: this._SF, - uPhase: this._phase, - uColor: this._color.rgbFull - }, currentUniforms) - ); + shaderName = this._tex; + shaderUniforms = { + uFreq: this._SF, + uPhase: this._phase, + uColor: this._color.rgbFull + }; } + this._pixi = this._getPixiMeshFromPredefinedShaders(shaderName, Object.assign(shaderUniforms, currentUniforms)); this._pixi.pivot.set(this._pixi.width * 0.5, this._pixi.width * 0.5); this._pixi.filters = [this._adjustmentFilter]; @@ -712,7 +748,7 @@ export class GratingStim extends VisualStim } this._pixi.zIndex = this._depth; - this._pixi.alpha = this.opacity; + this.opacity = this._opacity; // set the scale: const displaySize = this._getDisplaySize(); diff --git a/src/visual/shaders/circleShader.frag b/src/visual/shaders/circleShader.frag index 87addc65..4a2d7416 100644 --- a/src/visual/shaders/circleShader.frag +++ b/src/visual/shaders/circleShader.frag @@ -17,11 +17,12 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uRadius; uniform vec3 uColor; +uniform float uAlpha; void main() { vec2 uv = vUvs; // converting first to [-1, 1] space to get the proper color functionality // then back to [0, 1] float s = (1. - step(uRadius, length(uv * 2. - 1.))) * 2. - 1.; - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/crossShader.frag b/src/visual/shaders/crossShader.frag index 8518f910..36c71f4d 100644 --- a/src/visual/shaders/crossShader.frag +++ b/src/visual/shaders/crossShader.frag @@ -17,6 +17,7 @@ out vec4 shaderOut; #define M_PI 3.14159265358979 uniform float uThickness; uniform vec3 uColor; +uniform float uAlpha; void main() { vec2 uv = vUvs; @@ -25,5 +26,5 @@ void main() { // converting first to [-1, 1] space to get the proper color functionality // then back to [0, 1] float s = (1. - sx * sy) * 2. - 1.; - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/gaussShader.frag b/src/visual/shaders/gaussShader.frag index deb1de3e..3600fe88 100644 --- a/src/visual/shaders/gaussShader.frag +++ b/src/visual/shaders/gaussShader.frag @@ -19,6 +19,7 @@ uniform float uA; uniform float uB; uniform float uC; uniform vec3 uColor; +uniform float uAlpha; #define M_PI 3.14159265358979 @@ -29,5 +30,5 @@ void main() { // converting first to [-1, 1] space to get the proper color functionality // then back to [0, 1] float g = uA * exp(-pow(x - uB, 2.) / c2 * .5) * 2. - 1.; - shaderOut = vec4(vec3(g) * uColor * .5 + .5, 1.); + shaderOut = vec4(vec3(g) * uColor * .5 + .5, 1.) * uAlpha; } diff --git a/src/visual/shaders/imageShader.frag b/src/visual/shaders/imageShader.frag new file mode 100644 index 00000000..2871eebb --- /dev/null +++ b/src/visual/shaders/imageShader.frag @@ -0,0 +1,31 @@ +/** + * Image shader. + * + * @author Nikita Agafonov + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + * @description Renders passed in image with applied effects. + * @usedby GratingStim.js + */ + +#version 300 es +precision mediump float; + +in vec2 vUvs; +out vec4 shaderOut; + +#define M_PI 3.14159265358979 +uniform sampler2D uTex; +uniform float uFreq; +uniform float uPhase; +uniform vec3 uColor; +uniform float uAlpha; + +void main() { + vec2 uv = vUvs; + // converting first to [-1, 1] space to get the proper color functionality + // then back to [0, 1] + vec4 s = texture(uTex, vec2(uv.x * uFreq + uPhase, uv.y)); + s.xyz = s.xyz * 2. - 1.; + shaderOut = vec4(s.xyz * uColor * .5 + .5, s.a) * uAlpha; +} diff --git a/src/visual/shaders/radRampShader.frag b/src/visual/shaders/radRampShader.frag index 0ea424d8..5aed76c7 100644 --- a/src/visual/shaders/radRampShader.frag +++ b/src/visual/shaders/radRampShader.frag @@ -15,6 +15,7 @@ in vec2 vUvs; out vec4 shaderOut; uniform float uSqueeze; uniform vec3 uColor; +uniform float uAlpha; #define M_PI 3.14159265358979 @@ -23,5 +24,5 @@ void main() { // converting first to [-1, 1] space to get the proper color functionality // then back to [0, 1] float s = (1. - length(uv * 2. - 1.) * uSqueeze) * 2. - 1.; - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/raisedCosShader.frag b/src/visual/shaders/raisedCosShader.frag index 955d9f8e..9ec01292 100644 --- a/src/visual/shaders/raisedCosShader.frag +++ b/src/visual/shaders/raisedCosShader.frag @@ -19,6 +19,7 @@ out vec4 shaderOut; uniform float uBeta; uniform float uPeriod; uniform vec3 uColor; +uniform float uAlpha; void main() { vec2 uv = vUvs; @@ -35,5 +36,5 @@ void main() { // converting first to [-1, 1] space to get the proper color functionality // then back to [0, 1] s = s * 2. - 1.; - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/sawShader.frag b/src/visual/shaders/sawShader.frag index 0802154b..510a4919 100644 --- a/src/visual/shaders/sawShader.frag +++ b/src/visual/shaders/sawShader.frag @@ -19,6 +19,7 @@ out vec4 shaderOut; uniform float uFreq; uniform float uPhase; uniform vec3 uColor; +uniform float uAlpha; void main() { vec2 uv = vUvs; @@ -26,5 +27,5 @@ void main() { // converting first to [-1, 1] space to get the proper color functionality // then back to [0, 1] s = mod(s, 1.) * 2. - 1.; - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/sinShader.frag b/src/visual/shaders/sinShader.frag index 37b31a02..a95267db 100644 --- a/src/visual/shaders/sinShader.frag +++ b/src/visual/shaders/sinShader.frag @@ -19,10 +19,11 @@ out vec4 shaderOut; uniform float uFreq; uniform float uPhase; uniform vec3 uColor; +uniform float uAlpha; void main() { - vec2 uv = vUvs; + vec2 uv = vUvs - .25; float s = sin((uFreq * uv.x + uPhase) * 2. * M_PI); - // it's important to convert to [0, 1] while multiplication to uColor, not before, to preserve desired coloring functionality - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + // it's important to convert to [0, 1] while multiplying to uColor, not before, to preserve desired coloring functionality + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/sinXsinShader.frag b/src/visual/shaders/sinXsinShader.frag index 58cf0b9d..45b7353f 100644 --- a/src/visual/shaders/sinXsinShader.frag +++ b/src/visual/shaders/sinXsinShader.frag @@ -20,12 +20,13 @@ out vec4 shaderOut; uniform float uFreq; uniform float uPhase; uniform vec3 uColor; +uniform float uAlpha; void main() { - vec2 uv = vUvs; + vec2 uv = vec2(vUvs.x - .25, vUvs.y * -1. - .25); float sx = sin((uFreq * uv.x + uPhase) * PI2); float sy = sin((uFreq * uv.y + uPhase) * PI2); float s = sx * sy; - // it's important to convert to [0, 1] while multiplication to uColor, not before, to preserve desired coloring functionality - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + // it's important to convert to [0, 1] while multiplying to uColor, not before, to preserve desired coloring functionality + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/sqrShader.frag b/src/visual/shaders/sqrShader.frag index dc7e34bb..6198c5a0 100644 --- a/src/visual/shaders/sqrShader.frag +++ b/src/visual/shaders/sqrShader.frag @@ -19,10 +19,11 @@ out vec4 shaderOut; uniform float uFreq; uniform float uPhase; uniform vec3 uColor; +uniform float uAlpha; void main() { - vec2 uv = vUvs; + vec2 uv = vUvs - .25; float s = sign(sin((uFreq * uv.x + uPhase) * 2. * M_PI)); - // it's important to convert to [0, 1] while multiplication to uColor, not before, to preserve desired coloring functionality - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + // it's important to convert to [0, 1] while multiplying to uColor, not before, to preserve desired coloring functionality + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/sqrXsqrShader.frag b/src/visual/shaders/sqrXsqrShader.frag index 7542208f..2b3af202 100644 --- a/src/visual/shaders/sqrXsqrShader.frag +++ b/src/visual/shaders/sqrXsqrShader.frag @@ -20,12 +20,13 @@ out vec4 shaderOut; uniform float uFreq; uniform float uPhase; uniform vec3 uColor; +uniform float uAlpha; void main() { - vec2 uv = vUvs; + vec2 uv = vec2(vUvs.x - .25, vUvs.y * -1. - .25); float sx = sign(sin((uFreq * uv.x + uPhase) * PI2)); float sy = sign(sin((uFreq * uv.y + uPhase) * PI2)); float s = sx * sy; - // it's important to convert to [0, 1] while multiplication to uColor, not before, to preserve desired coloring functionality - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + // it's important to convert to [0, 1] while multiplying to uColor, not before, to preserve desired coloring functionality + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } diff --git a/src/visual/shaders/triShader.frag b/src/visual/shaders/triShader.frag index c74260c7..adf38f12 100644 --- a/src/visual/shaders/triShader.frag +++ b/src/visual/shaders/triShader.frag @@ -20,6 +20,7 @@ uniform float uFreq; uniform float uPhase; uniform float uPeriod; uniform vec3 uColor; +uniform float uAlpha; void main() { vec2 uv = vUvs; @@ -27,5 +28,5 @@ void main() { // converting first to [-1, 1] space to get the proper color functionality // then back to [0, 1] s = (2. * abs(s / uPeriod - floor(s / uPeriod + .5))) * 2. - 1.; - shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0); + shaderOut = vec4(vec3(s) * uColor * .5 + .5, 1.0) * uAlpha; } From 268dddf7790955a04e6e493b3f71428550dbcbb5 Mon Sep 17 00:00:00 2001 From: lgtst Date: Wed, 11 May 2022 22:20:56 +0300 Subject: [PATCH 38/92] added docs for uAlpha uniform; better pixi check for setInterpolate method; --- src/visual/GratingStim.js | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/visual/GratingStim.js b/src/visual/GratingStim.js index b5a222b4..176c7c00 100644 --- a/src/visual/GratingStim.js +++ b/src/visual/GratingStim.js @@ -67,6 +67,7 @@ export class GratingStim extends VisualStim * @property {Object} imageShader.uniforms - default uniforms for the image based shader. * @property {float} imageShader.uniforms.uFreq=1.0 - how much times image repeated within grating stimuli. * @property {float} imageShader.uniforms.uPhase=0.0 - offset of the image along X axis. + * @property {float} imageShader.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} sin - Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Sine_wave} @@ -74,6 +75,7 @@ export class GratingStim extends VisualStim * @property {Object} sin.uniforms - default uniforms for sine wave shader * @property {float} sin.uniforms.uFreq=1.0 - frequency of sine wave. * @property {float} sin.uniforms.uPhase=0.0 - phase of sine wave. + * @property {float} sin.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} sqr - Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Square_wave} @@ -81,6 +83,7 @@ export class GratingStim extends VisualStim * @property {Object} sqr.uniforms - default uniforms for square wave shader * @property {float} sqr.uniforms.uFreq=1.0 - frequency of square wave. * @property {float} sqr.uniforms.uPhase=0.0 - phase of square wave. + * @property {float} sqr.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} saw - Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Sawtooth_wave} @@ -88,6 +91,7 @@ export class GratingStim extends VisualStim * @property {Object} saw.uniforms - default uniforms for sawtooth wave shader * @property {float} saw.uniforms.uFreq=1.0 - frequency of sawtooth wave. * @property {float} saw.uniforms.uPhase=0.0 - phase of sawtooth wave. + * @property {float} saw.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} tri - Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Triangle_wave} @@ -96,6 +100,7 @@ export class GratingStim extends VisualStim * @property {float} tri.uniforms.uFreq=1.0 - frequency of triangle wave. * @property {float} tri.uniforms.uPhase=0.0 - phase of triangle wave. * @property {float} tri.uniforms.uPeriod=1.0 - period of triangle wave. + * @property {float} tri.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} sinXsin - Creates an image of two 2d sine waves multiplied with each other. * {@link https://en.wikipedia.org/wiki/Sine_wave} @@ -103,6 +108,7 @@ export class GratingStim extends VisualStim * @property {Object} sinXsin.uniforms - default uniforms for shader * @property {float} sinXsin.uniforms.uFreq=1.0 - frequency of sine wave (both of them). * @property {float} sinXsin.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * @property {float} sinXsin.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} sqrXsqr - Creates an image of two 2d square waves multiplied with each other. * {@link https://en.wikipedia.org/wiki/Square_wave} @@ -110,12 +116,14 @@ export class GratingStim extends VisualStim * @property {Object} sqrXsqr.uniforms - default uniforms for shader * @property {float} sqrXsqr.uniforms.uFreq=1.0 - frequency of sine wave (both of them). * @property {float} sqrXsqr.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * @property {float} sqrXsqr.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} circle - Creates a filled circle shape with sharp edges. * @property {String} circle.shader - shader source code for filled circle. * @property {Object} circle.uniforms - default uniforms for shader. * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim * and 1.0 is circle that spans from edge to edge of the stim. + * @property {float} circle.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. * {@link https://en.wikipedia.org/wiki/Gaussian_function} @@ -124,18 +132,21 @@ export class GratingStim extends VisualStim * @property {float} gauss.uniforms.uA=1.0 - A constant for gaussian formula (see link). * @property {float} gauss.uniforms.uB=0.0 - B constant for gaussian formula (see link). * @property {float} gauss.uniforms.uC=0.16 - C constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} cross - Creates a filled cross shape with sharp edges. * @property {String} cross.shader - shader source code for cross shader * @property {Object} cross.uniforms - default uniforms for shader * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. + * @property {float} cross.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} radRamp - Creates 2d radial ramp image. * @property {String} radRamp.shader - shader source code for radial ramp shader * @property {Object} radRamp.uniforms - default uniforms for shader * @property {float} radRamp.uniforms.uSqueeze=1.0 - coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large * it fills the entire stim and Infinity makes it so tiny it's invisible. + * @property {float} radRamp.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} raisedCos - Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. * {@link https://en.wikipedia.org/wiki/Raised-cosine_filter} @@ -143,6 +154,7 @@ export class GratingStim extends VisualStim * @property {Object} raisedCos.uniforms - default uniforms for shader * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + * @property {float} raisedCos.uniforms.uAlpha=1.0 - value of the alpha channel. */ static #SHADERS = { imageShader: { @@ -639,11 +651,10 @@ export class GratingStim extends VisualStim */ setInterpolate (interpolate = false, log = false) { this._setAttribute("interpolate", interpolate, log); - if (this._pixi === undefined || !this._pixi.shader.uniforms.uTex) { - return; + if (this._pixi instanceof PIXI.Mesh && this._pixi.shader.uniforms.uTex instanceof PIXI.Texture) { + this._pixi.shader.uniforms.uTex.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; + this._pixi.shader.uniforms.uTex.baseTexture.update(); } - this._pixi.shader.uniforms.uTex.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; - this._pixi.shader.uniforms.uTex.baseTexture.update(); } /** From cafc34610d965b48ca332aab6a6db9ed32bcd903 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Mon, 23 May 2022 12:18:47 +0200 Subject: [PATCH 39/92] NF first incarnation of Shelf --- package.json | 2 +- src/core/PsychoJS.js | 16 +- src/data/Shelf.js | 644 +++++++++++++++++++++++++++++++++++++++++++ src/data/index.js | 1 + 4 files changed, 654 insertions(+), 9 deletions(-) create mode 100644 src/data/Shelf.js diff --git a/package.json b/package.json index 516d2c93..7156e57a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "psychojs", - "version": "2022.1.1", + "version": "2022.2.0", "private": true, "description": "Helps run in-browser neuroscience, psychology, and psychophysics experiments", "license": "MIT", diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index eb11dd02..5f50ac61 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -18,7 +18,7 @@ import { GUI } from "./GUI.js"; import { Logger } from "./Logger.js"; import { ServerManager } from "./ServerManager.js"; import { Window } from "./Window.js"; -// import {Shelf} from "../data/Shelf"; +import {Shelf} from "../data/Shelf"; /** *

      PsychoJS manages the lifecycle of an experiment. It initialises the PsychoJS library and its various components (e.g. the {@link ServerManager}, the {@link EventManager}), and is used by the experiment to schedule the various tasks.

      @@ -109,10 +109,10 @@ export class PsychoJS return this._browser; } - // get shelf() - // { - // return this._shelf; - // } + get shelf() + { + return this._shelf; + } /** * @constructor @@ -158,8 +158,8 @@ export class PsychoJS // Window: this._window = undefined; - // // Shelf: - // this._shelf = new Shelf(this); + // Shelf: + this._shelf = new Shelf({psychoJS: this}); // redirection URLs: this._cancellationUrl = undefined; @@ -176,7 +176,7 @@ export class PsychoJS } this.logger.info("[PsychoJS] Initialised."); - this.logger.info("[PsychoJS] @version 2022.1.2"); + this.logger.info("[PsychoJS] @version 2022.2.0"); // hide the initialisation message: jQuery("#root").addClass("is-ready"); diff --git a/src/data/Shelf.js b/src/data/Shelf.js new file mode 100644 index 00000000..82dbd506 --- /dev/null +++ b/src/data/Shelf.js @@ -0,0 +1,644 @@ +/** @module data */ +/** + * Shelf handles persistent key/value pairs, which are stored in the shelf collection on the + * server, and accessed in a safe, concurrent fashion. + * + * @author Alain Pitiot + * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org) + * @license Distributed under the terms of the MIT License + */ + +import {PsychObject} from "../util/PsychObject.js"; +import { PsychoJS } from "../core/PsychoJS.js"; +import {ExperimentHandler} from "./ExperimentHandler"; +import { Scheduler } from "../util/Scheduler.js"; + + +/** + *

      Shelf handles persistent key/value pairs, which are stored in the shelf collection on the + * server, and accesses in a safe, concurrent fashion.

      + * + * @name module:data.Shelf + * @class + * @extends PsychObject + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {boolean} [options.autoLog= false] - whether or not to log + */ +export class Shelf extends PsychObject +{ + /** + * Maximum number of components in a key + * @name module:data.Shelf.#MAX_KEY_LENGTH + * @type {number} + * @note this value should mirror that on the server, i.e. the server also checks that the key is valid + */ + static #MAX_KEY_LENGTH = 10; + + constructor({psychoJS, autoLog = false } = {}) + { + super(psychoJS); + + this._addAttribute('autoLog', autoLog); + this._addAttribute('status', Shelf.Status.READY); + } + + /** + * Get the value associated with the given key. + * + * @name module:data.Shelf#getValue + * @function + * @public + * @param {string[]} [key = [] ] key as an array of key components + * @param [defaultValue] default value + * @return {Promise} + */ + async getValue(key = [], defaultValue) + { + const response = { + origin: 'Shelf.getValue', + context: `when getting the value associated with key: ${JSON.stringify(key)}` + }; + + // TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again? + + try + { + this._checkAvailability("getValue"); + this._status = Shelf.Status.BUSY; + this._checkKey(key); + + // prepare the request: + const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/value`; + const data = { + key + }; + if (typeof defaultValue !== 'undefined') + { + data['defaultValue'] = defaultValue; + } + + // query the server: + const response = await fetch(url, { + method: 'PUT', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + // convert the response to json: + const document = await response.json(); + + if (response.status !== 200) + { + throw ('error' in document) ? document['error'] : document; + } + + // return the updated value: + this._status = Shelf.Status.READY; + return document['value']; + } + catch (error) + { + this._status = Shelf.Status.ERROR; + throw {...response, error}; + } + } + + /** + * Set the value associated with the given key. + * + *

      This creates a new key/value pair if the key was previously unknown.

      + * + * @name module:data.Shelf#setValue + * @function + * @public + * @param {string[]} [key = [] ] key as an array of key components + * @param value + * @return {Promise} + */ + async setValue(key = [], value) + { + const response = { + origin: 'Shelf.setValue', + context: `when setting the value associated with key: ${JSON.stringify(key)}` + }; + + // TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again? + + try + { + this._checkAvailability("setValue"); + this._status = Shelf.Status.BUSY; + this._checkKey(key); + + // prepare the request: + // const componentList = key.reduce((list, component) => list + '+' + component, ''); + const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/value`; + const data = { + key, + value + }; + + // query the server: + const response = await fetch(url, { + method: 'POST', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + // convert the response to json: + const document = await response.json(); + + if (response.status !== 200) + { + throw ('error' in document) ? document['error'] : document; + } + + // return the updated value: + this._status = Shelf.Status.READY; + return document['record']['value']; + } + catch (error) + { + this._status = Shelf.Status.ERROR; + throw {...response, error}; + } + } + + /** + * Get the names of the fields in the dictionary record associated with the given key. + * + * @name module:data.Shelf#getDictionaryFieldNames + * @function + * @public + * @param {string[]} [key = [] ] key as an array of key components + * @return {Promise} + */ + async getDictionaryFieldNames(key = []) + { + const response = { + origin: 'Shelf.getDictionaryFieldNames', + context: `when getting the names of the fields in the dictionary record associated with key: ${JSON.stringify(key)}` + }; + + // TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again? + + try + { + this._checkAvailability("getDictionaryFieldNames"); + this._status = Shelf.Status.BUSY; + this._checkKey(key); + + // prepare the request: + const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/dictionary/fields`; + const data = { + key + }; + + // query the server: + const response = await fetch(url, { + method: 'PUT', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + // convert the response to json: + const document = await response.json(); + + if (response.status !== 200) + { + throw ('error' in document) ? document['error'] : document; + } + + // return the field names: + this._status = Shelf.Status.READY; + return document['fieldNames']; + } + catch (error) + { + this._status = Shelf.Status.ERROR; + throw {...response, error}; + } + } + + /** + * Get the value of a given field in the dictionary record associated with the given key. + * + * @name module:data.Shelf#getDictionaryValue + * @function + * @public + * @param {string[]} [key = [] ] key as an array of key components + * @param {string} fieldName the name of the field + * @param [defaultValue] default value + * @return {Promise} + */ + async getDictionaryValue(key = [], fieldName, defaultValue) + { + const response = { + origin: 'Shelf.getDictionaryFieldNames', + context: `when getting value of field: ${fieldName} in the dictionary record associated with key: ${JSON.stringify(key)}` + }; + + // TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again? + + try + { + this._checkAvailability("getDictionaryValue"); + this._status = Shelf.Status.BUSY; + this._checkKey(key); + + // prepare the request: + const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/dictionary/values`; + const data = { + key, + fieldName + }; + if (typeof defaultValue !== 'undefined') + { + data['defaultValue'] = defaultValue; + } + + // query the server: + const response = await fetch(url, { + method: 'PUT', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + // convert the response to json: + const document = await response.json(); + + if (response.status !== 200) + { + throw ('error' in document) ? document['error'] : document; + } + + // return the value: + this._status = Shelf.Status.READY; + return document['value']; + } + catch (error) + { + this._status = Shelf.Status.ERROR; + throw {...response, error}; + } + } + + /** + * Set a field in the dictionary record associated to the given key. + * + * @name module:data.Shelf#setDictionaryField + * @function + * @public + * @param {string[]} [key = [] ] key as an array of key components + * @param fieldName + * @param fieldValue + * @return {Promise} + */ + async setDictionaryField(key = [], fieldName, fieldValue) + { + const response = { + origin: 'Shelf.setDictionaryField', + context: `when setting a field with name: ${fieldName} in the dictionary record associated with key: ${JSON.stringify(key)}` + }; + + // TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again? + + try + { + this._checkAvailability("setDictionaryField"); + this._status = Shelf.Status.BUSY; + this._checkKey(key); + + // prepare the request: + // const componentList = key.reduce((list, component) => list + '+' + component, ''); + const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/dictionary/fields`; + const data = { + key, + fieldName, + fieldValue + }; + + // query the server: + const response = await fetch(url, { + method: 'POST', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + // convert the response to json: + const document = await response.json(); + + if (response.status !== 200) + { + throw ('error' in document) ? document['error'] : document; + } + + // return the updated value: + this._status = Shelf.Status.READY; + return document['record']['value']; + } + catch (error) + { + this._status = Shelf.Status.ERROR; + throw {...response, error}; + } + } + + /** + * Schedulable component that will block the experiment until the counter associated with the given key + * has been incremented by the given amount. + * + * @name module:data.Shelf#incrementComponent + * @function + * @public + * @param key + * @param increment + * @param callback + * @returns {function(): module:util.Scheduler.Event|Symbol|*} a component that can be scheduled + * + * @example + * const flowScheduler = new Scheduler(psychoJS); + * var experimentCounter = '<>'; + * flowScheduler.add(psychoJS.shelf.incrementComponent(['counter'], 1, (value) => experimentCounter = value)); + */ + incrementComponent(key = [], increment = 1, callback) + { + const response = { + origin: 'Shelf.incrementComponent', + context: 'when making a component to increment a shelf counter' + }; + + try + { + // TODO replace this._incrementComponent by a component with a unique name + let incrementComponent = {}; + incrementComponent.status = PsychoJS.Status.NOT_STARTED; + return () => + { + if (incrementComponent.status === PsychoJS.Status.NOT_STARTED) + { + incrementComponent.status = PsychoJS.Status.STARTED; + this.increment(key, increment) + .then( (newValue) => + { + callback(newValue); + incrementComponent.status = PsychoJS.Status.FINISHED; + }); + } + + return (incrementComponent.status === PsychoJS.Status.FINISHED) ? + Scheduler.Event.NEXT : + Scheduler.Event.FLIP_REPEAT; + }; + } + catch (error) + { + this._status = Shelf.Status.ERROR; + throw {...response, error}; + } + } + + /** + * Increment the integer counter associated with the given key by the given amount. + * + * @name module:data.Shelf#increment + * @function + * @public + * @param {string[]} [key = [] ] key as an array of key components + * @param {number} [increment = 1] increment + * @return {Promise} + */ + async increment(key = [], increment = 1) + { + const response = { + origin: 'Shelf.increment', + context: `when incrementing the integer counter with key: ${JSON.stringify(key)}` + }; + + // TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again? + + try + { + this._checkAvailability("increment"); + this._status = Shelf.Status.BUSY; + this._checkKey(key); + + // prepare the request: + // const componentList = key.reduce((list, component) => list + '+' + component, ''); + const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counter`; + const data = { + key, + increment + }; + + // query the server: + const response = await fetch(url, { + method: 'POST', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + // convert the response to json: + const document = await response.json(); + + if (response.status !== 200) + { + throw ('error' in document) ? document['error'] : document; + } + + // return the updated value: + this._status = Shelf.Status.READY; + return document['value']; + } + catch (error) + { + this._status = Shelf.Status.ERROR; + throw {...response, error}; + } + } + + /** + * Get the name of a group, using a counterbalanced design. + * + * @name module:data.Shelf#counterBalanceSelect + * @function + * @public + * @param {string[]} [key = [] ] key as an array of key components + * @param {string[]} groups the names of the groups + * @param {number[]} groupSizes the size of the groups + * @return {Promise} + */ + async counterBalanceSelect(key = [], groups, groupSizes) + { + const response = { + origin: 'Shelf.counterBalanceSelect', + context: `when getting the name of a group, using a counterbalanced design, with key: ${JSON.stringify(key)}` + }; + + // TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again? + + try + { + this._checkAvailability("counterBalanceSelect"); + this._status = Shelf.Status.BUSY; + this._checkKey(key); + + // prepare the request: + // const componentList = key.reduce((list, component) => list + '+' + component, ''); + const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance`; + const data = { + key, + groups, + groupSizes + }; + + // query the server: + const response = await fetch(url, { + method: 'PUT', + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + + // convert the response to json: + const document = await response.json(); + + if (response.status !== 200) + { + throw ('error' in document) ? document['error'] : document; + } + + // return the updated value: + this._status = Shelf.Status.READY; + return [ document['group'], document['finished'] ]; + } + catch (error) + { + this._status = Shelf.Status.ERROR; + throw {...response, error}; + } + } + + /** + * Check whether it is possible to run shelf commands. + * + * @name module:data.Shelf#_checkAvailability + * @function + * @public + * @param {string} [methodName=""] name of the method requiring a check + * @throw {Object.} exception when it is not possible to run shelf commands + */ + _checkAvailability(methodName = "") + { + // Shelf requires access to the server, where the key/value pairs are stored: + if (this._psychoJS.config.environment !== ExperimentHandler.Environment.SERVER) + { + throw { + origin: 'Shelf._checkAvailability', + context: 'when checking whether Shelf is available', + error: 'the experiment has to be run on the server: shelf commands are not available locally' + } + } + } + + /** + * Check the validity of the key. + * + * @name module:data.Shelf#_checkKey + * @function + * @public + * @param {object} key key whose validity is to be checked + * @throw {Object.} exception when the key is invalid + */ + _checkKey(key) + { + // the key must be a non empty array: + if (!Array.isArray(key) || key.length === 0) + { + throw 'the key must be a non empty array'; + } + + if (key.length > Shelf.#MAX_KEY_LENGTH) + { + throw 'the key consists of too many components'; + } + + // the only @ in the key should be @designer and @experiment + // TODO + } +} + + +/** + * Shelf status + * + * @name module:data.Shelf#Status + * @enum {Symbol} + * @readonly + * @public + */ +Shelf.Status = { + /** + * The shelf is ready. + */ + READY: Symbol.for('READY'), + + /** + * The shelf is busy, e.g. storing or retrieving values. + */ + BUSY: Symbol.for('BUSY'), + + /** + * The shelf has encountered an error. + */ + ERROR: Symbol.for('ERROR') +}; diff --git a/src/data/index.js b/src/data/index.js index 5598fa32..bd13927b 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -3,3 +3,4 @@ export * from "./TrialHandler.js"; export * from "./QuestHandler.js"; export * from "./MultiStairHandler.js"; //export * from "./Shelf.js"; +export * from "./Shelf.js"; From b07d8f5b0ef42269bb55fcba99b5edcf503f9cb7 Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Mon, 23 May 2022 12:31:01 +0200 Subject: [PATCH 40/92] DOC updated the documentation --- docs/FaceDetector_FaceDetector.html | 166 + docs/core_EventManager.js.html | 82 +- docs/core_GUI.js.html | 588 ++- docs/core_Keyboard.js.html | 98 +- docs/core_Logger.js.html | 174 +- docs/core_MinimalStim.js.html | 72 +- docs/core_Mouse.js.html | 92 +- docs/core_PsychoJS.js.html | 332 +- docs/core_ServerManager.js.html | 1011 +++-- docs/core_Window.js.html | 214 +- docs/core_WindowMixin.js.html | 291 +- docs/data_ExperimentHandler.js.html | 169 +- docs/data_MultiStairHandler.js.html | 473 +++ docs/data_QuestHandler.js.html | 443 +++ docs/data_Shelf.js.html | 695 ++++ docs/data_TrialHandler.js.html | 197 +- docs/index.html | 48 +- docs/module-core.BuilderKeyResponse.html | 6 +- docs/module-core.EventManager.html | 28 +- docs/module-core.GUI.html | 102 +- docs/module-core.KeyPress.html | 6 +- docs/module-core.Keyboard.html | 20 +- docs/module-core.Logger.html | 24 +- docs/module-core.MinimalStim.html | 16 +- docs/module-core.Mouse.html | 22 +- docs/module-core.PsychoJS.html | 36 +- docs/module-core.ServerManager.html | 2457 +----------- docs/module-core.Window.html | 432 ++- docs/module-core.WindowMixin.html | 14 +- docs/module-core.html | 4 +- docs/module-data.ExperimentHandler.html | 301 +- docs/module-data.MultiStairHandler.html | 893 +++++ docs/module-data.QuestHandler.html | 1551 ++++++++ docs/module-data.Shelf.html | 2505 ++++++++++++ docs/module-data.TrialHandler.html | 116 +- docs/module-data.html | 1341 ++++++- docs/module-sound.AudioClip.html | 336 +- docs/module-sound.AudioClipPlayer.html | 1617 ++++++++ docs/module-sound.Microphone.html | 24 +- docs/module-sound.Sound.html | 22 +- docs/module-sound.SoundPlayer.html | 20 +- docs/module-sound.TonePlayer.html | 20 +- docs/module-sound.TrackPlayer.html | 22 +- docs/module-sound.Transcriber.html | 1395 +++++++ docs/module-sound.Transcript.html | 171 + docs/module-sound.html | 10 +- docs/module-util.Clock.html | 10 +- docs/module-util.Color.html | 140 +- docs/module-util.ColorMixin.html | 12 +- docs/module-util.CountdownTimer.html | 12 +- docs/module-util.EventEmitter.html | 16 +- docs/module-util.MixinBuilder.html | 6 +- docs/module-util.MonotonicClock.html | 16 +- docs/module-util.PsychObject.html | 16 +- docs/module-util.Scheduler.html | 24 +- docs/module-util.html | 717 +++- docs/module-visual.ButtonStim.html | 10 +- docs/module-visual.Camera.html | 2416 ++++++++++++ docs/module-visual.FaceDetector.html | 1708 +++++++++ docs/module-visual.Form.html | 41 +- docs/module-visual.GratingStim.html | 4450 ++++++++++++++++++++++ docs/module-visual.ImageStim.html | 14 +- docs/module-visual.MovieStim.html | 110 +- docs/module-visual.Polygon.html | 10 +- docs/module-visual.Rect.html | 10 +- docs/module-visual.ShapeStim.html | 16 +- docs/module-visual.Slider.html | 161 +- docs/module-visual.TextBox.html | 719 +++- docs/module-visual.TextStim.html | 259 +- docs/module-visual.VisualStim.html | 31 +- docs/module-visual.html | 13 +- docs/module.data.MultiStairHandler.html | 600 +++ docs/module.data.QuestHandler.html | 845 ++++ docs/sound_AudioClip.js.html | 318 +- docs/sound_AudioClipPlayer.js.html | 235 ++ docs/sound_Microphone.js.html | 159 +- docs/sound_Sound.js.html | 111 +- docs/sound_SoundPlayer.js.html | 57 +- docs/sound_TonePlayer.js.html | 109 +- docs/sound_TrackPlayer.js.html | 62 +- docs/sound_Transcriber.js.html | 444 +++ docs/util_Clock.js.html | 41 +- docs/util_Color.js.html | 410 +- docs/util_ColorMixin.js.html | 65 +- docs/util_EventEmitter.js.html | 27 +- docs/util_Pixi.js.html | 87 + docs/util_PsychObject.js.html | 210 +- docs/util_Scheduler.js.html | 32 +- docs/util_Util.js.html | 548 +-- docs/visual_ButtonStim.js.html | 87 +- docs/visual_Camera.js.html | 657 ++++ docs/visual_FaceDetector.js.html | 375 ++ docs/visual_Form.js.html | 402 +- docs/visual_GratingStim.js.html | 328 +- docs/visual_ImageStim.js.html | 127 +- docs/visual_MovieStim.js.html | 184 +- docs/visual_Polygon.js.html | 37 +- docs/visual_Rect.js.html | 43 +- docs/visual_ShapeStim.js.html | 132 +- docs/visual_Slider.js.html | 543 +-- docs/visual_TextBox.js.html | 471 ++- docs/visual_TextStim.js.html | 329 +- docs/visual_VisualStim.js.html | 127 +- src/data/Shelf.js | 4 +- src/sound/AudioClip.js | 4 +- src/visual/Camera.js | 16 - 106 files changed, 30657 insertions(+), 7162 deletions(-) create mode 100644 docs/FaceDetector_FaceDetector.html create mode 100644 docs/data_MultiStairHandler.js.html create mode 100644 docs/data_QuestHandler.js.html create mode 100644 docs/data_Shelf.js.html create mode 100644 docs/module-data.MultiStairHandler.html create mode 100644 docs/module-data.QuestHandler.html create mode 100644 docs/module-data.Shelf.html create mode 100644 docs/module-sound.AudioClipPlayer.html create mode 100644 docs/module-sound.Transcriber.html create mode 100644 docs/module-sound.Transcript.html create mode 100644 docs/module-visual.Camera.html create mode 100644 docs/module-visual.FaceDetector.html create mode 100644 docs/module-visual.GratingStim.html create mode 100644 docs/module.data.MultiStairHandler.html create mode 100644 docs/module.data.QuestHandler.html create mode 100644 docs/sound_AudioClipPlayer.js.html create mode 100644 docs/sound_Transcriber.js.html create mode 100644 docs/util_Pixi.js.html create mode 100644 docs/visual_Camera.js.html create mode 100644 docs/visual_FaceDetector.js.html diff --git a/docs/FaceDetector_FaceDetector.html b/docs/FaceDetector_FaceDetector.html new file mode 100644 index 00000000..8e88a1db --- /dev/null +++ b/docs/FaceDetector_FaceDetector.html @@ -0,0 +1,166 @@ + + + + + JSDoc: Class: FaceDetector + + + + + + + + + + +
      + +

      Class: FaceDetector

      + + + + + + +
      + +
      + +

      FaceDetector()

      + + +
      + +
      +
      + + + + + + +

      new FaceDetector()

      + + + + + + + + + + + + + + + + + + +
      + + + + + + + + + + + + + + + + + + + + + + + + + + +
      Source:
      +
      + + + + + + + +
      + + + + + + + + + + + + + + + + + + + + + +
      + + + + + + + + + + + + + + + + + + + + +
      + +
      + + + + +
      + + + +
      + +
      + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
      + + + + + \ No newline at end of file diff --git a/docs/core_EventManager.js.html b/docs/core_EventManager.js.html index 4d915f91..891ef9b7 100644 --- a/docs/core_EventManager.js.html +++ b/docs/core_EventManager.js.html @@ -35,9 +35,8 @@

      Source: core/EventManager.js

      * @license Distributed under the terms of the MIT License */ -import {MonotonicClock, Clock} from '../util/Clock'; -import {PsychoJS} from './PsychoJS'; - +import { Clock, MonotonicClock } from "../util/Clock.js"; +import { PsychoJS } from "./PsychoJS.js"; /** * @class @@ -50,7 +49,6 @@

      Source: core/EventManager.js

      */ export class EventManager { - constructor(psychoJS) { this._psychoJS = psychoJS; @@ -75,14 +73,13 @@

      Source: core/EventManager.js

      pressed: [0, 0, 0], clocks: [new Clock(), new Clock(), new Clock()], // time elapsed from last reset of the button.Clocks: - times: [0.0, 0.0, 0.0] + times: [0.0, 0.0, 0.0], }, // clock reset when mouse is moved: - moveClock: new Clock() + moveClock: new Clock(), }; } - /** * Get the list of keys pressed by the participant. * @@ -97,9 +94,9 @@

      Source: core/EventManager.js

      * @return {string[]} the list of keys that were pressed. */ getKeys({ - keyList = null, - timeStamped = false - } = {}) + keyList = null, + timeStamped = false, + } = {}) { if (keyList != null) { @@ -151,7 +148,6 @@

      Source: core/EventManager.js

      return keys; } - /** * @typedef EventManager.ButtonInfo * @property {Array.number} pressed - the status of each mouse button [left, center, right]: 1 for pressed, 0 for released @@ -179,7 +175,6 @@

      Source: core/EventManager.js

      return this._mouseInfo; } - /** * Clear all events from the event buffer. * @@ -194,7 +189,6 @@

      Source: core/EventManager.js

      this.clearKeys(); } - /** * Clear all keys from the key buffer. * @@ -207,7 +201,6 @@

      Source: core/EventManager.js

      this._keyBuffer = []; } - /** * Start the move clock. * @@ -221,7 +214,6 @@

      Source: core/EventManager.js

      { } - /** * Stop the move clock. * @@ -235,7 +227,6 @@

      Source: core/EventManager.js

      { } - /** * Reset the move clock. * @@ -249,7 +240,6 @@

      Source: core/EventManager.js

      { } - /** * Add various mouse listeners to the Pixi renderer of the {@link Window}. * @@ -274,7 +264,6 @@

      Source: core/EventManager.js

      this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button down, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")"); }, false); - renderer.view.addEventListener("touchstart", (event) => { event.preventDefault(); @@ -289,7 +278,6 @@

      Source: core/EventManager.js

      this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button down, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")"); }, false); - renderer.view.addEventListener("pointerup", (event) => { event.preventDefault(); @@ -298,9 +286,20 @@

      Source: core/EventManager.js

      self._mouseInfo.buttons.times[event.button] = self._psychoJS._monotonicClock.getTime() - self._mouseInfo.buttons.clocks[event.button].getLastResetTime(); self._mouseInfo.pos = [event.offsetX, event.offsetY]; - this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button down, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")"); + this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button up, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")"); }, false); + renderer.view.addEventListener("pointerout", (event) => + { + event.preventDefault(); + + // if the pointer leaves the canvas: cancel all buttons + self._mouseInfo.buttons.pressed = [0, 0, 0]; + self._mouseInfo.buttons.times = [0.0, 0.0, 0.0]; + self._mouseInfo.pos = [event.offsetX, event.offsetY]; + + this._psychoJS.experimentLogger.data("Mouse: out, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")"); + }, false); renderer.view.addEventListener("touchend", (event) => { @@ -313,10 +312,9 @@

      Source: core/EventManager.js

      const touches = event.changedTouches; self._mouseInfo.pos = [touches[0].pageX, touches[0].pageY]; - this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button down, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")"); + this._psychoJS.experimentLogger.data("Mouse: " + event.button + " button up, pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")"); }, false); - renderer.view.addEventListener("pointermove", (event) => { event.preventDefault(); @@ -325,7 +323,6 @@

      Source: core/EventManager.js

      self._mouseInfo.pos = [event.offsetX, event.offsetY]; }, false); - renderer.view.addEventListener("touchmove", (event) => { event.preventDefault(); @@ -337,19 +334,16 @@

      Source: core/EventManager.js

      self._mouseInfo.pos = [touches[0].pageX, touches[0].pageY]; }, false); - // (*) wheel - renderer.view.addEventListener("wheel", event => + renderer.view.addEventListener("wheel", (event) => { self._mouseInfo.wheelRel[0] += event.deltaX; self._mouseInfo.wheelRel[1] += event.deltaY; this._psychoJS.experimentLogger.data("Mouse: wheel shift=(" + event.deltaX + "," + event.deltaY + "), pos=(" + self._mouseInfo.pos[0] + "," + self._mouseInfo.pos[1] + ")"); }, false); - } - /** * Add key listeners to the document. * @@ -364,14 +358,14 @@

      Source: core/EventManager.js

      // add a keydown listener // note: IE11 is not happy with document.addEventListener window.addEventListener("keydown", (event) => -// document.addEventListener("keydown", (event) => + // document.addEventListener("keydown", (event) => { const timestamp = MonotonicClock.getReferenceTime(); let code = event.code; // take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge): - if (typeof code === 'undefined') + if (typeof code === "undefined") { code = EventManager.keycode2w3c(event.keyCode); } @@ -380,17 +374,15 @@

      Source: core/EventManager.js

      code, key: event.key, keyCode: event.keyCode, - timestamp + timestamp, }); - self._psychoJS.logger.trace('keydown: ', event.key); - self._psychoJS.experimentLogger.data('Keydown: ' + event.key); + self._psychoJS.logger.trace("keydown: ", event.key); + self._psychoJS.experimentLogger.data("Keydown: " + event.key); event.stopPropagation(); }); - } - /** * Convert a keylist that uses pyglet key names to one that uses W3C KeyboardEvent.code values. * <p>This allows key lists that work in the builder environment to work in psychoJS web experiments.</p> @@ -406,7 +398,7 @@

      Source: core/EventManager.js

      let w3cKeyList = []; for (let i = 0; i < pygletKeyList.length; i++) { - if (typeof EventManager._pygletMap[pygletKeyList[i]] === 'undefined') + if (typeof EventManager._pygletMap[pygletKeyList[i]] === "undefined") { w3cKeyList.push(pygletKeyList[i]); } @@ -419,7 +411,6 @@

      Source: core/EventManager.js

      return w3cKeyList; } - /** * Convert a W3C Key Code into a pyglet key. * @@ -438,11 +429,10 @@

      Source: core/EventManager.js

      } else { - return 'N/A'; + return "N/A"; } } - /** * Convert a keycode to a W3C UI Event code. * <p>This is for legacy browsers.</p> @@ -460,7 +450,6 @@

      Source: core/EventManager.js

      } } - /** * <p>This map provides support for browsers that have not yet * adopted the W3C KeyboardEvent.code standard for detecting key presses. @@ -550,10 +539,9 @@

      Source: core/EventManager.js

      39: "ArrowRight", 40: "ArrowDown", 27: "Escape", - 32: "Space" + 32: "Space", }; - /** * This map associates pyglet key names to the corresponding W3C KeyboardEvent codes values. * <p>More information can be found [here]{@link https://www.w3.org/TR/uievents-code}</p> @@ -653,10 +641,9 @@

      Source: core/EventManager.js

      "num_multiply": "NumpadMultiply", "num_divide": "NumpadDivide", "num_equal": "NumpadEqual", - "num_numlock": "NumpadNumlock" + "num_numlock": "NumpadNumlock", }; - /** * <p>This map associates W3C KeyboardEvent.codes to the corresponding pyglet key names. * @@ -667,7 +654,6 @@

      Source: core/EventManager.js

      */ EventManager._reversePygletMap = {}; - /** * Utility class used by the experiment scripts to keep track of a clock and of the current status (whether or not we are currently checking the keyboard) * @@ -684,8 +670,8 @@

      Source: core/EventManager.js

      this.status = PsychoJS.Status.NOT_STARTED; this.keys = []; // the key(s) pressed - this.corr = 0; // was the resp correct this trial? (0=no, 1=yes) - this.rt = []; // response time(s) + this.corr = 0; // was the resp correct this trial? (0=no, 1=yes) + this.rt = []; // response time(s) this.clock = new Clock(); // we'll use this to measure the rt } } @@ -699,13 +685,13 @@

      Source: core/EventManager.js


      - Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
      diff --git a/docs/core_GUI.js.html b/docs/core_GUI.js.html index 3a3d2b17..1966e889 100644 --- a/docs/core_GUI.js.html +++ b/docs/core_GUI.js.html @@ -36,14 +36,13 @@

      Source: core/GUI.js

      * @license Distributed under the terms of the MIT License */ -import * as Tone from 'tone'; -import {PsychoJS} from './PsychoJS'; -import {ServerManager} from './ServerManager'; -import {Scheduler} from '../util/Scheduler'; -import {Clock} from '../util/Clock'; -import {ExperimentHandler} from '../data/ExperimentHandler'; -import * as util from '../util/Util'; - +import * as Tone from "tone"; +import { ExperimentHandler } from "../data/ExperimentHandler.js"; +import { Clock } from "../util/Clock.js"; +import { Scheduler } from "../util/Scheduler.js"; +import * as util from "../util/Util.js"; +import { PsychoJS } from "./PsychoJS.js"; +import { ServerManager } from "./ServerManager.js"; /** * @class @@ -55,7 +54,6 @@

      Source: core/GUI.js

      */ export class GUI { - get dialogComponent() { return this._dialogComponent; @@ -74,7 +72,6 @@

      Source: core/GUI.js

      this._dialogScalingFactor = 0; } - /** * <p>Create a dialog box that (a) enables the participant to set some * experimental values (e.g. the session name), (b) shows progress of resource @@ -99,22 +96,21 @@

      Source: core/GUI.js

      * @param {String} options.title - name of the project */ DlgFromDict({ - logoUrl, - text, - dictionary, - title - }) + logoUrl, + text, + dictionary, + title, + }) { // get info from URL: const infoFromUrl = util.getUrlParameters(); - this._progressMsg = '&nbsp;'; + this._progressMsg = "&nbsp;"; this._progressBarMax = 0; this._allResourcesDownloaded = false; this._requiredKeys = []; this._setRequiredKeys = new Map(); - // prepare PsychoJS component: this._dialogComponent = {}; this._dialogComponent.status = PsychoJS.Status.NOT_STARTED; @@ -132,141 +128,126 @@

      Source: core/GUI.js

      // if the experiment is licensed, and running on the license rather than on credit, // we use the license logo: - if (self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER && - typeof self._psychoJS.config.experiment.license !== 'undefined' && - self._psychoJS.config.experiment.runMode === 'LICENSE' && - typeof self._psychoJS.config.experiment.license.institutionLogo !== 'undefined') + if ( + self._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER + && typeof self._psychoJS.config.experiment.license !== "undefined" + && self._psychoJS.config.experiment.runMode === "LICENSE" + && typeof self._psychoJS.config.experiment.license.institutionLogo !== "undefined" + ) { logoUrl = self._psychoJS.config.experiment.license.institutionLogo; } // prepare jquery UI dialog box: - let htmlCode = - '<div id="expDialog" title="' + title + '">'; + let htmlCode = '<div id="expDialog" title="' + title + '">'; // uncomment for older version of the library: // htmlCode += '<p style="font-size: 0.8em; padding: 0.5em; margin-bottom: 0.5em; color: #FFAA00; border: 1px solid #FFAA00;">&#9888; This experiment uses a deprecated version of the PsychoJS library. Consider updating to a newer version (e.g. by updating PsychoPy and re-exporting the experiment).</p>'+ // logo: - if (typeof logoUrl === 'string') + if (typeof logoUrl === "string") { htmlCode += '<img id="dialog-logo" class="logo" alt="logo" src="' + logoUrl + '">'; } // information text: - if (typeof text === 'string' && text.length > 0) + if (typeof text === "string" && text.length > 0) { - htmlCode += '<p>' + text + '</p>'; + htmlCode += "<p>" + text + "</p>"; } - // add a combobox or text areas for each entry in the dictionary: // These may include Symbols as opposed to when using a for...in loop, // but only strings are allowed in PsychoPy Object.keys(dictionary).forEach((key, keyIdx) => + { + const value = dictionary[key]; + const keyId = "form-input-" + keyIdx; + + // only create an input if the key is not in the URL: + let inUrl = false; + const cleanedDictKey = key.trim().toLowerCase(); + infoFromUrl.forEach((urlValue, urlKey) => { - const value = dictionary[key]; - const keyId = 'form-input-' + keyIdx; + const cleanedUrlKey = urlKey.trim().toLowerCase(); + if (cleanedUrlKey === cleanedDictKey) + { + inUrl = true; + // break; + } + }); - // only create an input if the key is not in the URL: - let inUrl = false; - const cleanedDictKey = key.trim().toLowerCase(); - infoFromUrl.forEach((urlValue, urlKey) => - { - const cleanedUrlKey = urlKey.trim().toLowerCase(); - if (cleanedUrlKey === cleanedDictKey) - { - inUrl = true; - // break; - } - }); + if (!inUrl) + { + htmlCode += '<label for="' + keyId + '">' + key + "</label>"; - if (!inUrl) + // if the field is required: + if (key.slice(-1) === "*") { - htmlCode += '<label for="' + keyId + '">' + key + '</label>'; + self._requiredKeys.push(keyId); + } - // if the field is required: - if (key.slice(-1) === '*') - { - self._requiredKeys.push(keyId); - } + // if value is an array, we create a select drop-down menu: + if (Array.isArray(value)) + { + htmlCode += '<select name="' + key + '" id="' + keyId + '" class="text ui-widget-content' + + ' ui-corner-all">'; - // if value is an array, we create a select drop-down menu: - if (Array.isArray(value)) + // if the field is required, we add an empty option and select it: + if (key.slice(-1) === "*") { - htmlCode += '<select name="' + key + '" id="' + keyId + '" class="text ui-widget-content' + - ' ui-corner-all">'; - - // if the field is required, we add an empty option and select it: - if (key.slice(-1) === '*') - { - htmlCode += '<option disabled selected>...</option>'; - } - - for (const option of value) - { - htmlCode += '<option>' + option + '</option>'; - } - - htmlCode += '</select>'; - jQuery('#' + keyId).selectmenu({classes: {}}); + htmlCode += "<option disabled selected>...</option>"; } - // otherwise we use a single string input: - else /*if (typeof value === 'string')*/ + for (const option of value) { - htmlCode += '<input type="text" name="' + key + '" id="' + keyId; - htmlCode += '" value="' + value + '" class="text ui-widget-content ui-corner-all">'; + htmlCode += "<option>" + option + "</option>"; } + + htmlCode += "</select>"; + jQuery("#" + keyId).selectmenu({ classes: {} }); + } + // otherwise we use a single string input: + /*if (typeof value === 'string')*/ + else + { + htmlCode += '<input type="text" name="' + key + '" id="' + keyId; + htmlCode += '" value="' + value + '" class="text ui-widget-content ui-corner-all">'; } } - ); + }); - htmlCode += '<p class="validateTips">Fields marked with an asterisk (*) are required.</p>'; + if (this._requiredKeys.length > 0) + { + htmlCode += '<p class="validateTips">Fields marked with an asterisk (*) are required.</p>'; + } // add a progress bar: - htmlCode += '<hr><div id="progressMsg" class="progress">' + self._progressMsg + '</div>'; + htmlCode += '<hr><div id="progressMsg" class="progress">' + self._progressMsg + "</div>"; htmlCode += '<div id="progressbar"></div></div>'; - // replace root by the html code: - const dialogElement = document.getElementById('root'); + const dialogElement = document.getElementById("root"); dialogElement.innerHTML = htmlCode; - - // when the logo is loaded, we call _onDialogOpen again to reset the dimensions and position of - // the dialog box: - if (typeof logoUrl === 'string') - { - jQuery("#dialog-logo").on('load', () => - { - self._onDialogOpen('#expDialog')(); - }); - } - - // setup change event handlers for all required keys: this._requiredKeys.forEach((keyId) => + { + const input = document.getElementById(keyId); + if (input) { - const input = document.getElementById(keyId); - if (input) - { - input.oninput = (event) => GUI._onKeyChange(self, event); - } + input.oninput = (event) => GUI._onKeyChange(self, event); } - ); + }); // init and open the dialog box: - self._dialogComponent.button = 'Cancel'; - self._estimateDialogScalingFactor(); - const dialogSize = self._getDialogSize(); + self._dialogComponent.button = "Cancel"; jQuery("#expDialog").dialog({ - width: dialogSize[0], - maxHeight: dialogSize[1], + width: "500", autoOpen: true, - modal: true, + modal: false, closeOnEscape: false, resizable: false, draggable: false, @@ -275,75 +256,62 @@

      Source: core/GUI.js

      { id: "buttonCancel", text: "Cancel", - click: function () + click: function() { - self._dialogComponent.button = 'Cancel'; - jQuery("#expDialog").dialog('close'); - } + self._dialogComponent.button = "Cancel"; + jQuery("#expDialog").dialog("close"); + }, }, { id: "buttonOk", text: "Ok", - click: function () + click: function() { - // update dictionary: Object.keys(dictionary).forEach((key, keyIdx) => + { + const input = document.getElementById("form-input-" + keyIdx); + if (input) { - const input = document.getElementById('form-input-' + keyIdx); - if (input) - { - dictionary[key] = input.value; - } + dictionary[key] = input.value; } - ); - + }); - self._dialogComponent.button = 'OK'; - jQuery("#expDialog").dialog('close'); + self._dialogComponent.button = "OK"; + jQuery("#expDialog").dialog("close"); // Tackle browser demands on having user action initiate audio context Tone.start(); // switch to full screen if requested: self._psychoJS.window.adjustScreenSize(); - - // Clear events (and keypresses) accumulated during the dialog - self._psychoJS.eventManager.clearEvents(); - } - } - ], - // open the dialog in the middle of the screen: - open: self._onDialogOpen('#expDialog'), + // Clear events (and keypresses) accumulated during the dialog + self._psychoJS.eventManager.clearEvents(); + }, + }, + ], // close is called by both buttons and when the user clicks on the cross: - close: function () + close: function() { - //jQuery.unblockUI(); - jQuery(this).dialog('destroy').remove(); + // jQuery.unblockUI(); + jQuery(this).dialog("destroy").remove(); self._dialogComponent.status = PsychoJS.Status.FINISHED; - } - + }, }) - // change colour of title bar + // change colour of title bar .prev(".ui-dialog-titlebar").css("background", "green"); - // update the OK button status: self._updateOkButtonStatus(); - - // when the browser window is resize, we redimension and reposition the dialog: - self._dialogResize('#expDialog'); - - // block UI until user has pressed dialog button: // note: block UI does not allow for text to be entered in the dialog form boxes, alas! - //jQuery.blockUI({ message: "", baseZ: 1}); + // jQuery.blockUI({ message: "", baseZ: 1}); // show dialog box: - jQuery("#progressbar").progressbar({value: self._progressBarCurrentValue}); + jQuery("#progressbar").progressbar({ value: self._progressBarCurrentValue }); jQuery("#progressbar").progressbar("option", "max", self._progressBarMax); } @@ -358,7 +326,6 @@

      Source: core/GUI.js

      }; } - /** * @callback GUI.onOK */ @@ -378,55 +345,44 @@

      Source: core/GUI.js

      * @param {GUI.onOK} [options.onOK] - function called when the participant presses the OK button */ dialog({ - message, - warning, - error, - showOK = true, - onOK - } = {}) + message, + warning, + error, + showOK = true, + onOK, + } = {}) { - // close the previously opened dialog box, if there is one: - const expDialog = jQuery("#expDialog"); - if (expDialog.length) - { - expDialog.dialog("destroy").remove(); - } - const msgDialog = jQuery("#msgDialog"); - if (msgDialog.length) - { - msgDialog.dialog("destroy").remove(); - } + this.closeDialog(); let htmlCode; let titleColour; // we are displaying an error: - if (typeof error !== 'undefined') + if (typeof error !== "undefined") { this._psychoJS.logger.fatal(util.toString(error)); // deal with null error: if (!error) { - error = 'Unspecified JavaScript error'; + error = "Unspecified JavaScript error"; } let errorCode = null; // go through the error stack and look for errorCode if there is one: - let stackCode = '<ul>'; + let stackCode = "<ul>"; while (true) { - - if (typeof error === 'object' && 'errorCode' in error) + if (typeof error === "object" && "errorCode" in error) { errorCode = error.errorCode; } - if (typeof error === 'object' && 'context' in error) + if (typeof error === "object" && "context" in error) { - stackCode += '<li>' + error.context + '</li>'; + stackCode += "<li>" + error.context + "</li>"; error = error.error; } else @@ -437,11 +393,11 @@

      Source: core/GUI.js

      error = error.substring(1, 1000); } - stackCode += '<li><b>' + error + '</b></li>'; + stackCode += "<li><b>" + error + "</b></li>"; break; } } - stackCode += '</ul>'; + stackCode += "</ul>"; // if we found an errorCode, we replace the stack-based message by a more user-friendly one: if (errorCode) @@ -455,48 +411,42 @@

      Source: core/GUI.js

      htmlCode = '<div id="msgDialog" title="Error">'; htmlCode += '<p class="validateTips">Unfortunately we encountered the following error:</p>'; htmlCode += stackCode; - htmlCode += '<p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>'; - htmlCode += '</div>'; + htmlCode += "<p>Try to run the experiment again. If the error persists, contact the experiment designer.</p>"; + htmlCode += "</div>"; - titleColour = 'red'; + titleColour = "red"; } } - // we are displaying a message: - else if (typeof message !== 'undefined') + else if (typeof message !== "undefined") { - htmlCode = '<div id="msgDialog" title="Message">' + - '<p class="validateTips">' + message + '</p>' + - '</div>'; - titleColour = 'green'; + htmlCode = '<div id="msgDialog" title="Message">' + + '<p class="validateTips">' + message + "</p>" + + "</div>"; + titleColour = "green"; } - // we are displaying a warning: - else if (typeof warning !== 'undefined') + else if (typeof warning !== "undefined") { - htmlCode = '<div id="msgDialog" title="Warning">' + - '<p class="validateTips">' + warning + '</p>' + - '</div>'; - titleColour = 'orange'; + htmlCode = '<div id="msgDialog" title="Warning">' + + '<p class="validateTips">' + warning + "</p>" + + "</div>"; + titleColour = "orange"; } - // replace root by the html code: - const dialogElement = document.getElementById('root'); + const dialogElement = document.getElementById("root"); dialogElement.innerHTML = htmlCode; // init and open the dialog box: - this._estimateDialogScalingFactor(); - const dialogSize = this._getDialogSize(); const self = this; jQuery("#msgDialog").dialog({ - dialogClass: 'no-close', + dialogClass: "no-close", - width: dialogSize[0], - maxHeight: dialogSize[1], + width: "500", autoOpen: true, - modal: true, + modal: false, closeOnEscape: false, resizable: false, draggable: false, @@ -504,107 +454,43 @@

      Source: core/GUI.js

      buttons: (!showOK) ? [] : [{ id: "buttonOk", text: "Ok", - click: function () + click: function() { jQuery(this).dialog("destroy").remove(); // execute callback function: - if (typeof onOK !== 'undefined') + if (typeof onOK !== "undefined") { onOK(); } - } + }, }], - - // open the dialog in the middle of the screen: - open: self._onDialogOpen('#msgDialog'), - }) - // change colour of title bar + // change colour of title bar .prev(".ui-dialog-titlebar").css("background", titleColour); - - - // when the browser window is resize, we redimension and reposition the dialog: - self._dialogResize('#msgDialog'); } - /** - * Callback triggered when the jQuery UI dialog box is open. + * Close the previously opened dialog box, if there is one. * - * @name module:core.GUI#_onDialogOpen + * @name module:core.GUI#closeDialog * @function - * @param {String} dialogId - the dialog ID - * @returns {Function} function setting the dimension and position of the dialog box - * @private + * @public */ - _onDialogOpen(dialogId) + closeDialog() { - const self = this; - - return () => + const expDialog = jQuery("#expDialog"); + if (expDialog.length) { - const windowSize = [jQuery(window).width(), jQuery(window).height()]; - - // note: jQuery(dialogId) is the dialog-content, jQuery(dialogId).parent() is the actual widget - const parent = jQuery(dialogId).parent(); - parent.css({ - position: 'absolute', - left: Math.max(0, (windowSize[0] - parent.outerWidth()) / 2.0), - top: Math.max(0, (windowSize[1] - parent.outerHeight()) / 2.0) - }); - - // record width and height difference between dialog content and dialog: - self._contentDelta = [ - parent.css('width').slice(0, -2) - jQuery(dialogId).css('width').slice(0, -2), - parent.css('height').slice(0, -2) - jQuery(dialogId).css('height').slice(0, -2)]; - }; - } - - - /** - * Ensure that the browser window's resize events redimension and reposition the dialog UI. - * - * @name module:core.GUI#_dialogResize - * @function - * @param {String} dialogId - the dialog ID - * @private - */ - _dialogResize(dialogId) - { - const self = this; - - jQuery(window).resize(function () + expDialog.dialog("destroy").remove(); + } + const msgDialog = jQuery("#msgDialog"); + if (msgDialog.length) { - const parent = jQuery(dialogId).parent(); - const windowSize = [jQuery(window).width(), jQuery(window).height()]; - - // size (we need to redimension both the dialog and the dialog content): - const dialogSize = self._getDialogSize(); - parent.css({ - width: dialogSize[0], - maxHeight: dialogSize[1] - }); - - const isDifferent = self._estimateDialogScalingFactor(); - if (!isDifferent) - { - jQuery(dialogId).css({ - width: dialogSize[0] - self._contentDelta[0], - maxHeight: dialogSize[1] - self._contentDelta[1] - }); - } - - // position: - parent.css({ - position: 'absolute', - left: Math.max(0, (windowSize[0] - parent.outerWidth()) / 2.0), - top: Math.max(0, (windowSize[1] - parent.outerHeight()) / 2.0), - }); - }); + msgDialog.dialog("destroy").remove(); + } } - /** * Listener for resource event from the [Server Manager]{@link ServerManager}. * @@ -615,7 +501,7 @@

      Source: core/GUI.js

      */ _onResourceEvents(signal) { - this._psychoJS.logger.debug('signal: ' + util.toString(signal)); + this._psychoJS.logger.debug("signal: " + util.toString(signal)); // the download of the specified resources has started: if (signal.message === ServerManager.Event.DOWNLOADING_RESOURCES) @@ -626,20 +512,20 @@

      Source: core/GUI.js

      this._progressBarCurrentValue = 0; } - // all the resources have been downloaded: show the ok button else if (signal.message === ServerManager.Event.DOWNLOAD_COMPLETED) { this._allResourcesDownloaded = true; - jQuery("#progressMsg").text('all resources downloaded.'); + jQuery("#progressMsg").text("all resources downloaded."); this._updateOkButtonStatus(); } - // 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._progressBarCurrentValue === 'undefined') + if (typeof this._progressBarCurrentValue === "undefined") { this._progressBarCurrentValue = 0; } @@ -647,16 +533,15 @@

      Source: core/GUI.js

      if (signal.message === ServerManager.Event.RESOURCE_DOWNLOADED) { - jQuery("#progressMsg").text('downloaded ' + (this._progressBarCurrentValue / 2) + ' / ' + (this._progressBarMax / 2)); + jQuery("#progressMsg").text("downloaded " + (this._progressBarCurrentValue / 2) + " / " + (this._progressBarMax / 2)); } else { - jQuery("#progressMsg").text('downloading ' + (this._progressBarCurrentValue / 2) + ' / ' + (this._progressBarMax / 2)); + jQuery("#progressMsg").text("downloading " + (this._progressBarCurrentValue / 2) + " / " + (this._progressBarMax / 2)); } // $("#progressMsg").text(signal.resource + ': downloaded.'); jQuery("#progressbar").progressbar("option", "value", this._progressBarCurrentValue); } - // unknown message: we just display it else { @@ -664,7 +549,6 @@

      Source: core/GUI.js

      } } - /** * Update the status of the OK button. * @@ -675,14 +559,17 @@

      Source: core/GUI.js

      */ _updateOkButtonStatus(changeFocus = true) { - if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL || (this._allResourcesDownloaded && this._setRequiredKeys && this._setRequiredKeys.size >= this._requiredKeys.length)) + if ( + (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL) + || (this._allResourcesDownloaded && this._setRequiredKeys && this._setRequiredKeys.size >= this._requiredKeys.length) + ) { if (changeFocus) - { - jQuery("#buttonOk").button("option", "disabled", false).focus(); - } - else - { + { + jQuery("#buttonOk").button("option", "disabled", false).focus(); + } + else + { jQuery("#buttonOk").button("option", "disabled", false); } } @@ -699,61 +586,6 @@

      Source: core/GUI.js

      }); } - - /** - * Estimate the scaling factor for the dialog popup windows. - * - * @name module:core.GUI#_estimateDialogScalingFactor - * @function - * @private - * @returns {boolean} whether or not the scaling factor is different from the previously estimated one - */ - _estimateDialogScalingFactor() - { - const windowSize = [jQuery(window).width(), jQuery(window).height()]; - - // desktop: - let dialogScalingFactor = 1.0; - - // mobile or tablet: - if (windowSize[0] < 1080) - { - // landscape: - if (windowSize[0] > windowSize[1]) - { - dialogScalingFactor = 1.5; - }// portrait: - else - { - dialogScalingFactor = 2.0; - } - } - - const isDifferent = (dialogScalingFactor !== this._dialogScalingFactor); - this._dialogScalingFactor = dialogScalingFactor; - - return isDifferent; - } - - - /** - * Get the size of the dialog. - * - * @name module:core.GUI#_getDialogSize - * @private - * @returns {number[]} the size of the popup dialog window - */ - _getDialogSize() - { - const windowSize = [jQuery(window).width(), jQuery(window).height()]; - this._estimateDialogScalingFactor(); - - return [ - Math.min(GUI.dialogMaxSize[0], (windowSize[0] - GUI.dialogMargin[0]) / this._dialogScalingFactor), - Math.min(GUI.dialogMaxSize[1], (windowSize[1] - GUI.dialogMargin[1]) / this._dialogScalingFactor)]; - } - - /** * Listener for change event for required keys. * @@ -769,7 +601,7 @@

      Source: core/GUI.js

      const element = event.target; const value = element.value; - if (typeof value !== 'undefined' && value.length > 0) + if (typeof value !== "undefined" && value.length > 0) { gui._setRequiredKeys.set(event.target, true); } @@ -781,7 +613,6 @@

      Source: core/GUI.js

      gui._updateOkButtonStatus(false); } - /** * Get a more user-friendly html message. * @@ -796,91 +627,111 @@

      Source: core/GUI.js

      // INTERNAL_ERROR case 1: return { - htmlCode: '<div id="msgDialog" title="Error"><p>Oops we encountered an internal server error.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>', - titleColour: 'red' + htmlCode: + '<div id="msgDialog" title="Error"><p>Oops we encountered an internal server error.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>', + titleColour: "red", }; // MONGODB_ERROR + case 2: return { - htmlCode: '<div id="msgDialog" title="Error"><p>Oops we encountered a database error.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>', - titleColour: 'red' + htmlCode: + '<div id="msgDialog" title="Error"><p>Oops we encountered a database error.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>', + titleColour: "red", }; // STATUS_NONE + case 20: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> does not have any status and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the status must be changed to RUNNING for participants to be able to run it.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> does not have any status and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the status must be changed to RUNNING for participants to be able to run it.</p></div>`, + titleColour: "orange", }; // STATUS_INACTIVE + case 21: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is currently inactive and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the status must be changed to RUNNING for participants to be able to run it.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is currently inactive and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the status must be changed to RUNNING for participants to be able to run it.</p></div>`, + titleColour: "orange", }; // STATUS_DELETED + case 22: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> has been deleted and cannot be run.</p><p>If you are the experiment designer, either go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING, or generate a new experiment.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment has been deleted and cannot be run any longer.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> has been deleted and cannot be run.</p><p>If you are the experiment designer, either go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING, or generate a new experiment.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment has been deleted and cannot be run any longer.</p></div>`, + titleColour: "orange", }; // STATUS_ARCHIVED + case 23: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> has been archived and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment has been archived and cannot be run at the moment.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> has been archived and cannot be run.</p><p>If you are the experiment designer, go to your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a> and change the experiment status to either PILOTING or RUNNING.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment has been archived and cannot be run at the moment.</p></div>`, + titleColour: "orange", }; // PILOTING_NO_TOKEN + case 30: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is currently in PILOTING mode but the pilot token is missing from the URL.</p><p>If you are the experiment designer, you can pilot it by pressing the pilot button on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment status must be changed to RUNNING for participants to be able to run it.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is currently in PILOTING mode but the pilot token is missing from the URL.</p><p>If you are the experiment designer, you can pilot it by pressing the pilot button on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment status must be changed to RUNNING for participants to be able to run it.</p></div>`, + titleColour: "orange", }; // PILOTING_INVALID_TOKEN + case 31: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> cannot be run because the pilot token in the URL is invalid, possibly because it has expired.</p><p>If you are the experiment designer, you can generate a new token by pressing the pilot button on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment status must be changed to RUNNING for participants to be able to run it.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> cannot be run because the pilot token in the URL is invalid, possibly because it has expired.</p><p>If you are the experiment designer, you can generate a new token by pressing the pilot button on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment status must be changed to RUNNING for participants to be able to run it.</p></div>`, + titleColour: "orange", }; // LICENSE_EXPIRED + case 50: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is covered by a license that has expired. </p><p>If you are the experiment designer, you can either contact the license manager to inquire about the expiration, or you can run your experiments using credits. You will find all relevant details about the license on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>, where you will also be able to change its running mode to CREDIT.</p><p>Otherwise please contact the experiment designer to let him or her know that there is an issue with the experiment's license having expired.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is covered by a license that has expired. </p><p>If you are the experiment designer, you can either contact the license manager to inquire about the expiration, or you can run your experiments using credits. You will find all relevant details about the license on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>, where you will also be able to change its running mode to CREDIT.</p><p>Otherwise please contact the experiment designer to let him or her know that there is an issue with the experiment's license having expired.</p></div>`, + titleColour: "orange", }; // LICENSE_APPROVAL_NEEDED + case 51: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is covered by a license that requires one or more documents to be approved before the experiment can be run. </p><p>If you are the experiment designer, please contact the license manager and ask him or her which documents must be approved. You will find all relevant details about the license on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that there is an issue with the experiment's license requiring documents to be approved.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> is covered by a license that requires one or more documents to be approved before the experiment can be run. </p><p>If you are the experiment designer, please contact the license manager and ask him or her which documents must be approved. You will find all relevant details about the license on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that there is an issue with the experiment's license requiring documents to be approved.</p></div>`, + titleColour: "orange", }; // CREDIT_NOT_ENOUGH + case 60: return { - htmlCode: `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> does not have any assigned credit left and cannot be run.</p><p>If you are the experiment designer, you can assign more credits to it on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment requires more assigned credits to run.</p></div>`, - titleColour: 'orange' + htmlCode: + `<div id="msgDialog" title="Warning"><p><strong>${this._psychoJS.config.experiment.fullpath}</strong> does not have any assigned credit left and cannot be run.</p><p>If you are the experiment designer, you can assign more credits to it on your <a href="https://pavlovia.org/${this._psychoJS.config.experiment.fullpath}">experiment page</a>.</p><p>Otherwise please contact the experiment designer to let him or her know that the experiment requires more assigned credits to run.</p></div>`, + titleColour: "orange", }; default: return { - htmlCode: `<div id="msgDialog" title="Error"><p>Unfortunately we encountered an unspecified error (error code: ${errorCode}.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>`, - titleColour: 'red' + htmlCode: + `<div id="msgDialog" title="Error"><p>Unfortunately we encountered an unspecified error (error code: ${errorCode}.</p><p>Try to run the experiment again. If the error persists, contact the experiment designer.</p></div>`, + titleColour: "red", }; } } - } - /** * Maximal dimensions of the dialog window. * @@ -891,7 +742,6 @@

      Source: core/GUI.js

      */ GUI.dialogMaxSize = [500, 600]; - /** * Dialog window margins. * @@ -911,13 +761,13 @@

      Source: core/GUI.js


      - Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
      diff --git a/docs/core_Keyboard.js.html b/docs/core_Keyboard.js.html index 373e51f7..7463e7a1 100644 --- a/docs/core_Keyboard.js.html +++ b/docs/core_Keyboard.js.html @@ -35,11 +35,10 @@

      Source: core/Keyboard.js

      * @license Distributed under the terms of the MIT License */ -import {Clock, MonotonicClock} from "../util/Clock"; -import {PsychObject} from "../util/PsychObject"; -import {PsychoJS} from "./PsychoJS"; -import {EventManager} from "./EventManager"; - +import { Clock, MonotonicClock } from "../util/Clock.js"; +import { PsychObject } from "../util/PsychObject.js"; +import { EventManager } from "./EventManager.js"; +import { PsychoJS } from "./PsychoJS.js"; /** * @name module:core.KeyPress @@ -55,7 +54,7 @@

      Source: core/Keyboard.js

      { this.code = code; this.tDown = tDown; - this.name = (typeof name !== 'undefined') ? name : EventManager.w3c2pyglet(code); + this.name = (typeof name !== "undefined") ? name : EventManager.w3c2pyglet(code); // duration of the keypress (time between keydown and keyup events) or undefined if there was no keyup this.duration = undefined; @@ -65,7 +64,6 @@

      Source: core/Keyboard.js

      } } - /** * <p>This manager handles all keyboard events. It is a substitute for the keyboard component of EventManager. </p> * @@ -81,39 +79,35 @@

      Source: core/Keyboard.js

      */ export class Keyboard extends PsychObject { - constructor({ - psychoJS, - bufferSize = 10000, - waitForStart = false, - clock, - autoLog = false, - } = {}) + psychoJS, + bufferSize = 10000, + waitForStart = false, + clock, + autoLog = false, + } = {}) { - super(psychoJS); - if (typeof clock === 'undefined') + if (typeof clock === "undefined") { clock = new Clock(); - } //this._psychoJS.monotonicClock; + } // this._psychoJS.monotonicClock; - this._addAttribute('bufferSize', bufferSize); - this._addAttribute('waitForStart', waitForStart); - this._addAttribute('clock', clock); - this._addAttribute('autoLog', autoLog); + this._addAttribute("bufferSize", bufferSize); + this._addAttribute("waitForStart", waitForStart); + this._addAttribute("clock", clock); + this._addAttribute("autoLog", autoLog); // start recording key events if need be: - this._addAttribute('status', (waitForStart) ? PsychoJS.Status.NOT_STARTED : PsychoJS.Status.STARTED); + this._addAttribute("status", (waitForStart) ? PsychoJS.Status.NOT_STARTED : PsychoJS.Status.STARTED); // setup circular buffer: this.clearEvents(); // add key listeners: this._addKeyListeners(); - } - /** * Start recording keyboard events. * @@ -127,7 +121,6 @@

      Source: core/Keyboard.js

      this._status = PsychoJS.Status.STARTED; } - /** * Stop recording keyboard events. * @@ -141,7 +134,6 @@

      Source: core/Keyboard.js

      this._status = PsychoJS.Status.STOPPED; } - /** * @typedef Keyboard.KeyEvent * @@ -167,7 +159,6 @@

      Source: core/Keyboard.js

      return []; } - // iterate over the buffer, from start to end, and discard the null event: let filteredEvents = []; const bufferWrap = (this._bufferLength === this._bufferSize); @@ -185,7 +176,6 @@

      Source: core/Keyboard.js

      return filteredEvents; } - /** * Get the list of keys pressed or pushed by the participant. * @@ -202,12 +192,11 @@

      Source: core/Keyboard.js

      * (keydown with no subsequent keyup at the time getKeys is called). */ getKeys({ - keyList = [], - waitRelease = true, - clear = true - } = {}) + keyList = [], + waitRelease = true, + clear = true, + } = {}) { - // if nothing in the buffer, return immediately: if (this._bufferLength === 0) { @@ -231,7 +220,7 @@

      Source: core/Keyboard.js

      { // look for a corresponding, preceding keydown event: const precedingKeydownIndex = keyEvent.keydownIndex; - if (typeof precedingKeydownIndex !== 'undefined') + if (typeof precedingKeydownIndex !== "undefined") { const precedingKeydownEvent = this._circularBuffer[precedingKeydownIndex]; if (precedingKeydownEvent) @@ -278,13 +267,10 @@

      Source: core/Keyboard.js

      { this._circularBuffer[i] = null; } - } } - } while (i !== this._bufferIndex); - // if waitRelease = false, we iterate again over the map of unmatched keydown events: if (!waitRelease) { @@ -331,18 +317,15 @@

      Source: core/Keyboard.js

      } while (i !== this._bufferIndex);*/ } - // if clear = true and the keyList is empty, we clear all the events: if (clear && keyList.length === 0) { this.clearEvents(); } - return keyPresses; } - /** * Clear all events and resets the circular buffers. * @@ -361,7 +344,6 @@

      Source: core/Keyboard.js

      this._unmatchedKeydownMap = new Map(); } - /** * Test whether a list of KeyPress's contains one with a particular name. * @@ -380,10 +362,9 @@

      Source: core/Keyboard.js

      } const value = keypressList.find((keypress) => keypress.name === keyName); - return (typeof value !== 'undefined'); + return (typeof value !== "undefined"); } - /** * Add key listeners to the document. * @@ -396,10 +377,9 @@

      Source: core/Keyboard.js

      this._previousKeydownKey = undefined; const self = this; - // add a keydown listener: window.addEventListener("keydown", (event) => - // document.addEventListener("keydown", (event) => + // document.addEventListener("keydown", (event) => { // only consider non-repeat events, i.e. only the first keydown event associated with a participant // holding a key down: @@ -426,14 +406,13 @@

      Source: core/Keyboard.js

      let code = event.code; // take care of legacy Microsoft browsers (IE11 and pre-Chromium Edge): - if (typeof code === 'undefined') + if (typeof code === "undefined") { code = EventManager.keycode2w3c(event.keyCode); } let pigletKey = EventManager.w3c2pyglet(code); - self._bufferIndex = (self._bufferIndex + 1) % self._bufferSize; self._bufferLength = Math.min(self._bufferLength + 1, self._bufferSize); self._circularBuffer[self._bufferIndex] = { @@ -441,20 +420,19 @@

      Source: core/Keyboard.js

      key: event.key, pigletKey, status: Keyboard.KeyStatus.KEY_DOWN, - timestamp + timestamp, }; self._unmatchedKeydownMap.set(event.code, self._bufferIndex); - self._psychoJS.logger.trace('keydown: ', event.key); + self._psychoJS.logger.trace("keydown: ", event.key); event.stopPropagation(); }); - // add a keyup listener: window.addEventListener("keyup", (event) => - // document.addEventListener("keyup", (event) => + // document.addEventListener("keyup", (event) => { const timestamp = MonotonicClock.getReferenceTime(); // timestamp in seconds @@ -468,7 +446,7 @@

      Source: core/Keyboard.js

      let code = event.code; // take care of legacy Microsoft Edge: - if (typeof code === 'undefined') + if (typeof code === "undefined") { code = EventManager.keycode2w3c(event.keyCode); } @@ -482,28 +460,26 @@

      Source: core/Keyboard.js

      key: event.key, pigletKey, status: Keyboard.KeyStatus.KEY_UP, - timestamp + timestamp, }; // get the corresponding keydown event // note: if more keys are down than there are slots in the circular buffer, there might // not be a corresponding keydown event const correspondingKeydownIndex = self._unmatchedKeydownMap.get(event.code); - if (typeof correspondingKeydownIndex !== 'undefined') + if (typeof correspondingKeydownIndex !== "undefined") { self._circularBuffer[self._bufferIndex].keydownIndex = correspondingKeydownIndex; self._unmatchedKeydownMap.delete(event.code); } - self._psychoJS.logger.trace('keyup: ', event.key); + self._psychoJS.logger.trace("keyup: ", event.key); event.stopPropagation(); }); - } } - /** * Keyboard KeyStatus. * @@ -513,8 +489,8 @@

      Source: core/Keyboard.js

      * @public */ Keyboard.KeyStatus = { - KEY_DOWN: Symbol.for('KEY_DOWN'), - KEY_UP: Symbol.for('KEY_UP') + KEY_DOWN: Symbol.for("KEY_DOWN"), + KEY_UP: Symbol.for("KEY_UP"), };
    @@ -526,13 +502,13 @@

    Source: core/Keyboard.js


    - Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
    diff --git a/docs/core_Logger.js.html b/docs/core_Logger.js.html index 1d7ec8a4..24c30fcb 100644 --- a/docs/core_Logger.js.html +++ b/docs/core_Logger.js.html @@ -35,12 +35,11 @@

    Source: core/Logger.js

    * @license Distributed under the terms of the MIT License */ - -import log4javascript from 'log4javascript'; -import pako from 'pako'; -import * as util from '../util/Util'; -import {MonotonicClock} from '../util/Clock'; -import {ExperimentHandler} from '../data/ExperimentHandler'; +import log4javascript from "log4javascript"; +import pako from "pako"; +import { ExperimentHandler } from "../data/ExperimentHandler.js"; +import { MonotonicClock } from "../util/Clock.js"; +import * as util from "../util/Util.js"; /** * <p>This class handles a variety of loggers, e.g. a browser console one (mostly for debugging), @@ -54,13 +53,12 @@

    Source: core/Logger.js

    */ export class Logger { - constructor(psychoJS, threshold) { this._psychoJS = psychoJS; // browser console logger: - this.consoleLogger = log4javascript.getLogger('psychojs'); + this.consoleLogger = log4javascript.getLogger("psychojs"); const appender = new log4javascript.BrowserConsoleAppender(); appender.setLayout(this._customConsoleLayout()); @@ -69,7 +67,6 @@

    Source: core/Logger.js

    this.consoleLogger.addAppender(appender); this.consoleLogger.setLevel(threshold); - // server logger: this._serverLogs = []; this._serverLevel = Logger.ServerLevel.WARNING; @@ -93,12 +90,10 @@

    Source: core/Logger.js

    // throttling message index: index: 0, // whether or not the designer has already been warned: - designerWasWarned: false + designerWasWarned: false, }; } - - /** * Change the logging level. * @@ -112,8 +107,6 @@

    Source: core/Logger.js

    this._serverLevelValue = this._getValue(this._serverLevel); } - - /** * Log a server message at the EXP level. * @@ -128,8 +121,6 @@

    Source: core/Logger.js

    this.log(msg, Logger.ServerLevel.EXP, time, obj); } - - /** * Log a server message at the DATA level. * @@ -144,8 +135,6 @@

    Source: core/Logger.js

    this.log(msg, Logger.ServerLevel.DATA, time, obj); } - - /** * Log a server message. * @@ -165,7 +154,7 @@

    Source: core/Logger.js

    return; } - if (typeof time === 'undefined') + if (typeof time === "undefined") { time = MonotonicClock.getReferenceTime(); } @@ -182,12 +171,10 @@

    Source: core/Logger.js

    msg, level, time, - obj: util.toString(obj) + obj: util.toString(obj), }); } - - /** * Check whether or not a log messages must be throttled. * @@ -209,24 +196,26 @@

    Source: core/Logger.js

    // warn the designer if we are not already throttling: if (!this._throttling.isThrottling) { - const msg = `<p>[time= ${time.toFixed(3)}] More than ${this._throttling.threshold} messages were logged in the past ${this._throttling.window}s.</p>` + - `<p>We are now throttling: only 1 in ${this._throttling.factor} messages will be logged.</p>` + - `<p>You may want to change your experiment's logging level. Please see <a href="https://www.psychopy.org/api/logging.html">psychopy.org/api/logging.html</a> for details.</p>`; + const msg = `<p>[time= ${time.toFixed(3)}] More than ${this._throttling.threshold} messages were logged in the past ${this._throttling.window}s.</p>` + + `<p>We are now throttling: only 1 in ${this._throttling.factor} messages will be logged.</p>` + + `<p>You may want to change your experiment's logging level. Please see <a href="https://www.psychopy.org/api/logging.html">psychopy.org/api/logging.html</a> for details.</p>`; // console warning: this._psychoJS.logger.warn(msg); // in PILOTING mode and locally, we also warn the experimenter with a dialog box, // but only once: - if (!this._throttling.designerWasWarned && - (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL || - this._psychoJS.config.experiment.status === 'PILOTING')) + if ( + !this._throttling.designerWasWarned + && (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.LOCAL + || this._psychoJS.config.experiment.status === "PILOTING") + ) { this._throttling.designerWasWarned = true; this._psychoJS.gui.dialog({ warning: msg, - showOK: true + showOK: true, }); } @@ -235,7 +224,7 @@

    Source: core/Logger.js

    this._throttling.index = 0; } - ++ this._throttling.index; + ++this._throttling.index; if (this._throttling.index < this._throttling.factor) { // no logging @@ -248,8 +237,10 @@

    Source: core/Logger.js

    } else { - if (this._throttling.isThrottling && - (time - this._throttling.startOfThrottling) > this._throttling.minimumDuration) + if ( + this._throttling.isThrottling + && (time - this._throttling.startOfThrottling) > this._throttling.minimumDuration + ) { this._psychoJS.logger.info(`[time= ${time.toFixed(3)}] Log messages are not throttled any longer.`); this._throttling.isThrottling = false; @@ -260,8 +251,6 @@

    Source: core/Logger.js

    return false; } - - /** * Flush all server logs to the server. * @@ -274,39 +263,41 @@

    Source: core/Logger.js

    async flush() { const response = { - origin: 'Logger.flush', - context: 'when flushing participant\'s logs for experiment: ' + this._psychoJS.config.experiment.fullpath + origin: "Logger.flush", + context: "when flushing participant's logs for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.info('[PsychoJS] Flush server logs.'); + this._psychoJS.logger.info("[PsychoJS] Flush server logs."); // prepare the formatted logs: - let formattedLogs = ''; + let formattedLogs = ""; for (const log of this._serverLogs) { - let formattedLog = util.toString(log.time) + - '\t' + Symbol.keyFor(log.level) + - '\t' + log.msg; - if (log.obj !== 'undefined') + let formattedLog = util.toString(log.time) + + "\t" + Symbol.keyFor(log.level) + + "\t" + log.msg; + if (log.obj !== "undefined") { - formattedLog += '\t' + log.obj; + formattedLog += "\t" + log.obj; } - formattedLog += '\n'; + formattedLog += "\n"; formattedLogs += formattedLog; } // send logs to the server or display them in the console: - if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER && - this._psychoJS.config.experiment.status === 'RUNNING' && - !this._psychoJS._serverMsg.has('__pilotToken')) + if ( + this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER + && this._psychoJS.config.experiment.status === "RUNNING" + && !this._psychoJS._serverMsg.has("__pilotToken") + ) { // if the pako compression library is present, we compress the logs: - if (typeof pako !== 'undefined') + if (typeof pako !== "undefined") { try { - const utf16DeflatedLogs = pako.deflate(formattedLogs, {to: 'string'}); + const utf16DeflatedLogs = pako.deflate(formattedLogs, { to: "string" }); // const utf16DeflatedLogs = pako.deflate(unescape(encodeURIComponent(formattedLogs)), {to: 'string'}); const base64DeflatedLogs = btoa(utf16DeflatedLogs); @@ -314,24 +305,22 @@

    Source: core/Logger.js

    } catch (error) { - console.error('log compression error:', error); - throw Object.assign(response, {error: error}); + console.error("log compression error:", error); + throw Object.assign(response, { error: error }); } } - else // the pako compression library is not present, we do not compress the logs: + else { return await this._psychoJS.serverManager.uploadLog(formattedLogs, false); } } else { - this._psychoJS.logger.debug('\n' + formattedLogs); + this._psychoJS.logger.debug("\n" + formattedLogs); } } - - /** * Create a custom console layout. * @@ -344,59 +333,59 @@

    Source: core/Logger.js

    const detectedBrowser = util.detectBrowser(); const customLayout = new log4javascript.PatternLayout("%p %d{HH:mm:ss.SSS} %f{1} | %m"); - customLayout.setCustomField('location', function (layout, loggingReference) + customLayout.setCustomField("location", function(layout, loggingReference) { // we throw a fake exception to retrieve the stack trace try { // (0)(); - throw Error('fake exception'); + throw Error("fake exception"); } catch (e) { - const stackEntries = e.stack.replace(/^.*?\n/, '').replace(/(?:\n@:0)?\s+$/m, '').replace(/^\(/gm, '{anon}(').split("\n"); + const stackEntries = e.stack.replace(/^.*?\n/, "").replace(/(?:\n@:0)?\s+$/m, "").replace(/^\(/gm, "{anon}(").split("\n"); let relevantEntry; - if (detectedBrowser === 'Firefox') + if (detectedBrowser === "Firefox") { // look for entry immediately after those of log4javascript: for (let entry of stackEntries) { - if (entry.indexOf('log4javascript.min.js') <= 0) + if (entry.indexOf("log4javascript.min.js") <= 0) { relevantEntry = entry; break; } } - const buf = relevantEntry.split(':'); + const buf = relevantEntry.split(":"); const line = buf[buf.length - 2]; - const file = buf[buf.length - 3].split('/').pop(); - const method = relevantEntry.split('@')[0]; + 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') + else if (detectedBrowser === "Safari") { - return 'unknown'; + return "unknown"; } - else if (detectedBrowser === 'Chrome') + else if (detectedBrowser === "Chrome") { relevantEntry = stackEntries.pop(); - let buf = relevantEntry.split(' '); + let buf = relevantEntry.split(" "); let fileLine = buf.pop(); const method = buf.pop(); - buf = fileLine.split(':'); + buf = fileLine.split(":"); buf.pop(); const line = buf.pop(); - const file = buf.pop().split('/').pop(); + const file = buf.pop().split("/").pop(); - return method + ' ' + file + ':' + line; + return method + " " + file + ":" + line; } else { - return 'unknown'; + return "unknown"; } } }); @@ -404,8 +393,6 @@

    Source: core/Logger.js

    return customLayout; } - - /** * Get the integer value associated with a logging level. * @@ -421,8 +408,6 @@

    Source: core/Logger.js

    } } - - /** * Server logging level. * @@ -434,17 +419,16 @@

    Source: core/Logger.js

    * @note These are similar to PsychoPy's logging levels, as defined in logging.py */ Logger.ServerLevel = { - CRITICAL: Symbol.for('CRITICAL'), - ERROR: Symbol.for('ERROR'), - WARNING: Symbol.for('WARNING'), - DATA: Symbol.for('DATA'), - EXP: Symbol.for('EXP'), - INFO: Symbol.for('INFO'), - DEBUG: Symbol.for('DEBUG'), - NOTSET: Symbol.for('NOTSET') + CRITICAL: Symbol.for("CRITICAL"), + ERROR: Symbol.for("ERROR"), + WARNING: Symbol.for("WARNING"), + DATA: Symbol.for("DATA"), + EXP: Symbol.for("EXP"), + INFO: Symbol.for("INFO"), + DEBUG: Symbol.for("DEBUG"), + NOTSET: Symbol.for("NOTSET"), }; - /** * Server logging level values. * @@ -456,14 +440,14 @@

    Source: core/Logger.js

    * @protected */ Logger._ServerLevelValue = { - 'CRITICAL': 50, - 'ERROR': 40, - 'WARNING': 30, - 'DATA': 25, - 'EXP': 22, - 'INFO': 20, - 'DEBUG': 10, - 'NOTSET': 0 + "CRITICAL": 50, + "ERROR": 40, + "WARNING": 30, + "DATA": 25, + "EXP": 22, + "INFO": 20, + "DEBUG": 10, + "NOTSET": 0, };
@@ -475,13 +459,13 @@

Source: core/Logger.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/core_MinimalStim.js.html b/docs/core_MinimalStim.js.html index 27a18327..d649afcc 100644 --- a/docs/core_MinimalStim.js.html +++ b/docs/core_MinimalStim.js.html @@ -35,12 +35,9 @@

Source: core/MinimalStim.js

* @license Distributed under the terms of the MIT License */ - -import {PsychObject} from '../util/PsychObject'; -import {PsychoJS} from './PsychoJS'; -import * as util from '../util/Util'; - - +import { PsychObject } from "../util/PsychObject.js"; +import * as util from "../util/Util.js"; +import { PsychoJS } from "./PsychoJS.js"; /** * <p>MinimalStim is the base class for all stimuli.</p> @@ -56,7 +53,7 @@

Source: core/MinimalStim.js

*/ export class MinimalStim extends PsychObject { - constructor({name, win, autoDraw, autoLog} = {}) + constructor({ name, win, autoDraw, autoLog } = {}) { super(win._psychoJS, name); @@ -64,27 +61,25 @@

Source: core/MinimalStim.js

this._pixi = undefined; this._addAttribute( - 'win', + "win", win, - undefined + undefined, ); this._addAttribute( - 'autoDraw', + "autoDraw", autoDraw, - false + false, ); this._addAttribute( - 'autoLog', + "autoLog", autoLog, - (typeof win !== 'undefined' && win !== null) ? win.autoLog : false + (typeof win !== "undefined" && win !== null) ? win.autoLog : false, ); this._needUpdate = false; this.status = PsychoJS.Status.NOT_STARTED; } - - /** * Setter for the autoDraw attribute. * @@ -96,14 +91,13 @@

Source: core/MinimalStim.js

*/ setAutoDraw(autoDraw, log = false) { - this._setAttribute('autoDraw', autoDraw, log); + this._setAttribute("autoDraw", autoDraw, log); // autoDraw = true: add the stimulus to the draw list if it's not there already if (this._autoDraw) { this.draw(); } - // autoDraw = false: remove the stimulus from the draw list (and from the root container if it's already there) else { @@ -111,8 +105,6 @@

Source: core/MinimalStim.js

} } - - /** * Draw this stimulus on the next frame draw. * @@ -131,13 +123,13 @@

Source: core/MinimalStim.js

{ // update the stimulus if need be before we add its PIXI representation to the window container: this._updateIfNeeded(); - if (typeof this._pixi === 'undefined') + if (typeof this._pixi === "undefined") { - this.psychoJS.logger.warn('the Pixi.js representation of this stimulus is undefined.'); + this.psychoJS.logger.warn("the Pixi.js representation of this stimulus is undefined."); } else { - this.win._rootContainer.addChild(this._pixi); + this._win.addPixiObject(this._pixi); this.win._drawList.push(this); } } @@ -145,11 +137,11 @@

Source: core/MinimalStim.js

{ // the stimulus is already in the list, if it needs to be updated, we remove it // from the window container, update it, then put it back: - if (this._needUpdate && typeof this._pixi !== 'undefined') + if (this._needUpdate && typeof this._pixi !== "undefined") { - this.win._rootContainer.removeChild(this._pixi); + this._win.removePixiObject(this._pixi); this._updateIfNeeded(); - this.win._rootContainer.addChild(this._pixi); + this._win.addPixiObject(this._pixi); } } } @@ -157,8 +149,6 @@

Source: core/MinimalStim.js

this.status = PsychoJS.Status.STARTED; } - - /** * Hide this stimulus on the next frame draw. * @@ -176,17 +166,15 @@

Source: core/MinimalStim.js

this._win._drawList.splice(index, 1); // if the stimulus has a pixi representation, remove it from the root container: - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { - this._win._rootContainer.removeChild(this._pixi); + this._win.removePixiObject(this._pixi); } } this.status = PsychoJS.Status.STOPPED; } } - - /** * Determine whether an object is inside this stimulus. * @@ -200,14 +188,12 @@

Source: core/MinimalStim.js

contains(object, units) { throw { - origin: 'MinimalStim.contains', + origin: "MinimalStim.contains", context: `when determining whether stimulus: ${this._name} contains object: ${util.toString(object)}`, - error: 'this method is abstract and should not be called.' + error: "this method is abstract and should not be called.", }; } - - /** * Release the PIXI representation, if there is one. * @@ -219,18 +205,16 @@

Source: core/MinimalStim.js

*/ release(log = false) { - this._setAttribute('autoDraw', false, log); + this._setAttribute("autoDraw", false, log); this.status = PsychoJS.Status.STOPPED; - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { this._pixi.destroy(true); this._pixi = undefined; } } - - /** * Update the stimulus, if necessary. * @@ -244,9 +228,9 @@

Source: core/MinimalStim.js

_updateIfNeeded() { throw { - origin: 'MinimalStim._updateIfNeeded', - context: 'when updating stimulus: ' + this._name, - error: 'this method is abstract and should not be called.' + origin: "MinimalStim._updateIfNeeded", + context: "when updating stimulus: " + this._name, + error: "this method is abstract and should not be called.", }; } } @@ -260,13 +244,13 @@

Source: core/MinimalStim.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/core_Mouse.js.html b/docs/core_Mouse.js.html index 2fd6be22..35440cae 100644 --- a/docs/core_Mouse.js.html +++ b/docs/core_Mouse.js.html @@ -36,10 +36,9 @@

Source: core/Mouse.js

* @license Distributed under the terms of the MIT License */ -import {PsychoJS} from './PsychoJS'; -import {PsychObject} from '../util/PsychObject'; -import * as util from '../util/Util'; - +import { PsychObject } from "../util/PsychObject.js"; +import * as util from "../util/Util.js"; +import { PsychoJS } from "./PsychoJS.js"; /** * <p>This manager handles the interactions between the experiment's stimuli and the mouse.</p> @@ -57,12 +56,11 @@

Source: core/Mouse.js

*/ export class Mouse extends PsychObject { - constructor({ - name, - win, - autoLog = true - } = {}) + name, + win, + autoLog = true, + } = {}) { super(win._psychoJS, name); @@ -73,15 +71,14 @@

Source: core/Mouse.js

const units = win.units; const visible = 1; - this._addAttribute('win', win); - this._addAttribute('units', units); - this._addAttribute('visible', visible); - this._addAttribute('autoLog', autoLog); + this._addAttribute("win", win); + this._addAttribute("units", units); + this._addAttribute("visible", visible); + this._addAttribute("autoLog", autoLog); this.status = PsychoJS.Status.NOT_STARTED; } - /** * Get the current position of the mouse in mouse/Window units. * @@ -101,12 +98,11 @@

Source: core/Mouse.js

pos_px[1] = this.win.size[1] / 2 - pos_px[1]; // convert to window units: - this._lastPos = util.to_win(pos_px, 'pix', this._win); + this._lastPos = util.to_win(pos_px, "pix", this._win); return this._lastPos; } - /** * Get the position of the mouse relative to that at the last call to getRel * or getPos, in mouse/Window units. @@ -118,7 +114,7 @@

Source: core/Mouse.js

*/ getRel() { - if (typeof this._lastPos === 'undefined') + if (typeof this._lastPos === "undefined") { return this.getPos(); } @@ -131,7 +127,6 @@

Source: core/Mouse.js

} } - /** * Get the travel of the mouse scroll wheel since the last call to getWheelRel. * @@ -149,13 +144,12 @@

Source: core/Mouse.js

const wheelRel_px = mouseInfo.wheelRel.slice(); // convert to window units: - const wheelRel = util.to_win(wheelRel_px, 'pix', this._win); + const wheelRel = util.to_win(wheelRel_px, "pix", this._win); mouseInfo.wheelRel = [0, 0]; return wheelRel; } - /** * Get the status of each button (pressed or released) and, optionally, the time elapsed between the last call to [clickReset]{@link module:core.Mouse#clickReset} and the pressing or releasing of the buttons. * @@ -181,7 +175,6 @@

Source: core/Mouse.js

} } - /** * Helper method for checking whether a stimulus has had any button presses within bounds. * @@ -198,14 +191,14 @@

Source: core/Mouse.js

isPressedIn(...args) { // Look for options given in object literal form, cut out falsy inputs - const [{ shape: shapeMaybe, buttons: buttonsMaybe } = {}] = args.filter(v => !!v); + const [{ shape: shapeMaybe, buttons: buttonsMaybe } = {}] = args.filter((v) => !!v); // Helper to check if some object features a certain key - const hasKey = key => object => !!(object && object[key]); + const hasKey = (key) => (object) => !!(object && object[key]); // Shapes are expected to be instances of stimuli, or at // the very least objects featuring a `contains()` method - const isShape = hasKey('contains'); + const isShape = hasKey("contains"); // Go through arguments array looking for a shape if options object offers none const shapeFound = isShape(shapeMaybe) ? shapeMaybe : args.find(isShape); @@ -215,23 +208,23 @@

Source: core/Mouse.js

// Buttons values may be extracted from an object // featuring the `buttons` key, or found as integers // in the arguments array - const hasButtons = hasKey('buttons'); + const hasButtons = hasKey("buttons"); const { isInteger } = Number; // Prioritize buttons value given as part of an options object, // then look for the first occurrence in the arguments array of either // an integer or an extra object with a `buttons` key - const buttonsFound = isInteger(buttonsMaybe) ? buttonsMaybe : args.find(o => hasButtons(o) || isInteger(o)); + const buttonsFound = isInteger(buttonsMaybe) ? buttonsMaybe : args.find((o) => hasButtons(o) || isInteger(o)); // Worst case scenario `wanted` ends up being an empty object const { buttons: wanted = buttonsFound || buttonsMaybe } = buttonsFound || {}; // Will throw if stimulus is falsy or non-object like - if (typeof shape.contains === 'function') + if (typeof shape.contains === "function") { const mouseInfo = this.psychoJS.eventManager.getMouseInfo(); const { pressed } = mouseInfo.buttons; // If no specific button wanted, any pressed will do - const hasButtonPressed = isInteger(wanted) ? pressed[wanted] > 0 : pressed.some(v => v > 0); + const hasButtonPressed = isInteger(wanted) ? pressed[wanted] > 0 : pressed.some((v) => v > 0); return hasButtonPressed && shape.contains(this); } @@ -239,7 +232,6 @@

Source: core/Mouse.js

return false; } - /** * Determine whether the mouse has moved beyond a certain distance. * @@ -268,24 +260,26 @@

Source: core/Mouse.js

mouseMoved(distance, reset = false) { // make sure that _lastPos is defined: - if (typeof this._lastPos === 'undefined') + if (typeof this._lastPos === "undefined") { this.getPos(); } this._prevPos = this._lastPos.slice(); this.getPos(); - if (typeof reset === 'boolean' && reset == false) + if (typeof reset === "boolean" && reset == false) { - if (typeof distance === 'undefined') + if (typeof distance === "undefined") { return (this._prevPos[0] != this._lastPos[0]) || (this._prevPos[1] != this._lastPos[1]); } else { - if (typeof distance === 'number') + if (typeof distance === "number") { - this._movedistance = Math.sqrt((this._prevPos[0] - this._lastPos[0]) * (this._prevPos[0] - this._lastPos[0]) + (this._prevPos[1] - this._lastPos[1]) * (this._prevPos[1] - this._lastPos[1])); + this._movedistance = Math.sqrt( + (this._prevPos[0] - this._lastPos[0]) * (this._prevPos[0] - this._lastPos[0]) + (this._prevPos[1] - this._lastPos[1]) * (this._prevPos[1] - this._lastPos[1]), + ); return (this._movedistance > distance); } if (this._prevPos[0] + distance[0] - this._lastPos[0] > 0.0) @@ -299,21 +293,18 @@

Source: core/Mouse.js

return false; } } - - else if (typeof reset === 'boolean' && reset == true) + else if (typeof reset === "boolean" && reset == true) { // reset the moveClock: this.psychoJS.eventManager.getMouseInfo().moveClock.reset(); return false; } - - else if (reset === 'here') + else if (reset === "here") { // set to wherever we are this._prevPos = this._lastPos.clone(); return false; } - else if (reset instanceof Array) { // an (x,y) array @@ -322,36 +313,37 @@

Source: core/Mouse.js

if (!distance) { return false; - }// just resetting prevPos, not checking distance + } + // just resetting prevPos, not checking distance else { // checking distance of current pos to newly reset prevposition - if (typeof distance === 'number') + if (typeof distance === "number") { - this._movedistance = Math.sqrt((this._prevPos[0] - this._lastPos[0]) * (this._prevPos[0] - this._lastPos[0]) + (this._prevPos[1] - this._lastPos[1]) * (this._prevPos[1] - this._lastPos[1])); + this._movedistance = Math.sqrt( + (this._prevPos[0] - this._lastPos[0]) * (this._prevPos[0] - this._lastPos[0]) + (this._prevPos[1] - this._lastPos[1]) * (this._prevPos[1] - this._lastPos[1]), + ); return (this._movedistance > distance); } if (Math.abs(this._lastPos[0] - this._prevPos[0]) > distance[0]) { return true; - } // moved on X-axis + } // moved on X-axis if (Math.abs(this._lastPos[1] - this._prevPos[1]) > distance[1]) { return true; - } // moved on Y-axis + } // moved on Y-axis return false; } } - else { return false; } } - /** * Get the amount of time elapsed since the last mouse movement. * @@ -365,7 +357,6 @@

Source: core/Mouse.js

return this.psychoJS.eventManager.getMouseInfo().moveClock.getTime(); } - /** * Reset the clocks associated to the given mouse buttons. * @@ -383,10 +374,7 @@

Source: core/Mouse.js

mouseInfo.buttons.times[b] = 0.0; } } - - } - @@ -397,13 +385,13 @@

Source: core/Mouse.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/core_PsychoJS.js.html b/docs/core_PsychoJS.js.html index f371059a..c20dd874 100644 --- a/docs/core_PsychoJS.js.html +++ b/docs/core_PsychoJS.js.html @@ -36,18 +36,17 @@

Source: core/PsychoJS.js

* @license Distributed under the terms of the MIT License */ -import log4javascript from 'log4javascript'; -import {Scheduler} from '../util/Scheduler'; -import {ServerManager} from './ServerManager'; -import {ExperimentHandler} from '../data/ExperimentHandler'; -import {EventManager} from './EventManager'; -import {Window} from './Window'; -import {GUI} from './GUI'; -import {MonotonicClock} from '../util/Clock'; -import {Logger} from './Logger'; -import * as util from '../util/Util'; -// import {Shelf} from "../data/Shelf"; - +import log4javascript from "log4javascript"; +import { ExperimentHandler } from "../data/ExperimentHandler.js"; +import { MonotonicClock } from "../util/Clock.js"; +import { Scheduler } from "../util/Scheduler.js"; +import * as util from "../util/Util.js"; +import { EventManager } from "./EventManager.js"; +import { GUI } from "./GUI.js"; +import { Logger } from "./Logger.js"; +import { ServerManager } from "./ServerManager.js"; +import { Window } from "./Window.js"; +import {Shelf} from "../data/Shelf"; /** * <p>PsychoJS manages the lifecycle of an experiment. It initialises the PsychoJS library and its various components (e.g. the {@link ServerManager}, the {@link EventManager}), and is used by the experiment to schedule the various tasks.</p> @@ -59,7 +58,6 @@

Source: core/PsychoJS.js

*/ export class PsychoJS { - /** * Properties */ @@ -139,22 +137,21 @@

Source: core/PsychoJS.js

return this._browser; } - // get shelf() - // { - // return this._shelf; - // } - + get shelf() + { + return this._shelf; + } /** * @constructor * @public */ constructor({ - debug = true, - collectIP = false, - hosts = [], - topLevelStatus = true - } = {}) + debug = true, + collectIP = false, + hosts = [], + topLevelStatus = true, + } = {}) { // logging: this._logger = new Logger(this, (debug) ? log4javascript.Level.DEBUG : log4javascript.Level.INFO); @@ -162,7 +159,7 @@

Source: core/PsychoJS.js

// detect the browser: this._browser = util.detectBrowser(); - this.logger.info('[PsychoJS] Detected browser:', this._browser); + this.logger.info("[PsychoJS] Detected browser:", this._browser); // core clock: this._monotonicClock = new MonotonicClock(); @@ -170,12 +167,12 @@

Source: core/PsychoJS.js

// managers: this._eventManager = new EventManager(this); this._serverManager = new ServerManager({ - psychoJS: this + psychoJS: this, }); - // to be loading `configURL` files in `_configure` calls from - const hostsEvidently = new Set([...hosts, 'https://pavlovia.org/run/', 'https://run.pavlovia.org/']); - this._hosts = Array.from(hostsEvidently); + // add the pavlovia server to the list of hosts: + const hostsWithPavlovia = new Set([...hosts, "https://pavlovia.org/run/", "https://run.pavlovia.org/"]); + this._hosts = Array.from(hostsWithPavlovia); // GUI: this._gui = new GUI(this); @@ -189,8 +186,8 @@

Source: core/PsychoJS.js

// Window: this._window = undefined; - // // Shelf: - // this._shelf = new Shelf(this); + // Shelf: + this._shelf = new Shelf({psychoJS: this}); // redirection URLs: this._cancellationUrl = undefined; @@ -206,14 +203,13 @@

Source: core/PsychoJS.js

this._makeStatusTopLevel(); } - this.logger.info('[PsychoJS] Initialised.'); - this.logger.info('[PsychoJS] @version 2021.2.0'); + this.logger.info("[PsychoJS] Initialised."); + this.logger.info("[PsychoJS] @version 2022.2.0"); - // Hide #root::after - jQuery('#root').addClass('is-ready'); + // hide the initialisation message: + jQuery("#root").addClass("is-ready"); } - /** * Get the experiment's environment. * @@ -221,14 +217,13 @@

Source: core/PsychoJS.js

*/ getEnvironment() { - if (typeof this._config === 'undefined') + if (typeof this._config === "undefined") { return undefined; } return this._config.environment; } - /** * Open a PsychoJS Window. * @@ -248,22 +243,23 @@

Source: core/PsychoJS.js

* @public */ openWindow({ - name, - fullscr, - color, - units, - waitBlanking, - autoLog - } = {}) + name, + fullscr, + color, + gamma, + units, + waitBlanking, + autoLog, + } = {}) { - this.logger.info('[PsychoJS] Open Window.'); + this.logger.info("[PsychoJS] Open Window."); - if (typeof this._window !== 'undefined') + if (typeof this._window !== "undefined") { throw { - origin: 'PsychoJS.openWindow', - context: 'when opening a Window', - error: 'A Window has already been opened.' + origin: "PsychoJS.openWindow", + context: "when opening a Window", + error: "A Window has already been opened.", }; } @@ -272,13 +268,13 @@

Source: core/PsychoJS.js

name, fullscr, color, + gamma, units, waitBlanking, - autoLog + autoLog, }); } - /** * Set the completion and cancellation URL to which the participant will be redirect at the end of the experiment. * @@ -291,7 +287,6 @@

Source: core/PsychoJS.js

this._cancellationUrl = cancellationUrl; } - /** * Schedule a task. * @@ -301,12 +296,11 @@

Source: core/PsychoJS.js

*/ schedule(task, args) { - this.logger.debug('schedule task: ', task.toString().substring(0, 50), '...'); + this.logger.debug("schedule task: ", task.toString().substring(0, 50), "..."); this._scheduler.add(task, args); } - /** * @callback PsychoJS.condition * @return {boolean} true if the thenScheduler is to be run, false if the elseScheduler is to be run @@ -321,12 +315,11 @@

Source: core/PsychoJS.js

*/ scheduleCondition(condition, thenScheduler, elseScheduler) { - this.logger.debug('schedule condition: ', condition.toString().substring(0, 50), '...'); + this.logger.debug("schedule condition: ", condition.toString().substring(0, 50), "..."); this._scheduler.addConditional(condition, thenScheduler, elseScheduler); } - /** * Start the experiment. * @@ -349,11 +342,11 @@

Source: core/PsychoJS.js

* @async * @public */ - async start({configURL = 'config.json', expName = 'UNKNOWN', expInfo = {}, resources = []} = {}) + async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [], dataFileName } = {}) { this.logger.debug(); - const response = {origin: 'PsychoJS.start', context: 'when starting the experiment'}; + const response = { origin: "PsychoJS.start", context: "when starting the experiment" }; try { @@ -368,24 +361,25 @@

Source: core/PsychoJS.js

else { this._IP = { - IP: 'X', - hostname: 'X', - city: 'X', - region: 'X', - country: 'X', - location: 'X' + IP: "X", + hostname: "X", + city: "X", + region: "X", + country: "X", + location: "X", }; } // setup the experiment handler: this._experiment = new ExperimentHandler({ psychoJS: this, - extraInfo: expInfo + extraInfo: expInfo, + dataFileName }); // setup the logger: - //my.logger.console.setLevel(psychoJS.logging.WARNING); - //my.logger.server.set({'level':psychoJS.logging.WARNING, 'experimentInfo': my.expInfo}); + // my.logger.console.setLevel(psychoJS.logging.WARNING); + // my.logger.server.set({'level':psychoJS.logging.WARNING, 'experimentInfo': my.expInfo}); // if the experiment is running on the server: if (this.getEnvironment() === ExperimentHandler.Environment.SERVER) @@ -400,54 +394,49 @@

Source: core/PsychoJS.js

event.preventDefault(); // Chrome requires returnValue to be set: - event.returnValue = ''; + event.returnValue = ""; }; - window.addEventListener('beforeunload', this.beforeunloadCallback); - + window.addEventListener("beforeunload", this.beforeunloadCallback); // when the user closes the tab or browser, we attempt to close the session, // optionally save the results, and release the WebGL context // note: we communicate with the server using the Beacon API const self = this; - window.addEventListener('unload', (event) => + window.addEventListener("unload", (event) => { - if (self._config.session.status === 'OPEN') + if (self._config.session.status === "OPEN") { // save the incomplete results if need be: if (self._config.experiment.saveIncompleteResults) { - self._experiment.save({sync: true}); + self._experiment.save({ sync: true }); } // close the session: self._serverManager.closeSession(false, true); } - if (typeof self._window !== 'undefined') + if (typeof self._window !== "undefined") { self._window.close(); } }); - } - // start the asynchronous download of resources: - await this._serverManager.prepareResources(resources); + this._serverManager.prepareResources(resources); // start the experiment: - this.logger.info('[PsychoJS] Start Experiment.'); + this.logger.info("[PsychoJS] Start Experiment."); await this._scheduler.start(); } catch (error) { // this._gui.dialog({ error: { ...response, error } }); - this._gui.dialog({error: Object.assign(response, {error})}); + this._gui.dialog({ error: Object.assign(response, { error }) }); } } - - /** * Block the experiment until the specified resources have been downloaded. * @@ -467,8 +456,8 @@

Source: core/PsychoJS.js

waitForResources(resources = []) { const response = { - origin: 'PsychoJS.waitForResources', - context: 'while waiting for resources to be downloaded' + origin: "PsychoJS.waitForResources", + context: "while waiting for resources to be downloaded", }; try @@ -478,12 +467,10 @@

Source: core/PsychoJS.js

catch (error) { // this._gui.dialog({ error: { ...response, error } }); - this._gui.dialog({error: Object.assign(response, {error})}); + this._gui.dialog({ error: Object.assign(response, { error }) }); } } - - /** * Make the attributes of the given object those of PsychoJS and those of * the top level variable (e.g. window) as well. @@ -493,9 +480,9 @@

Source: core/PsychoJS.js

*/ importAttributes(obj) { - this.logger.debug('import attributes from: ', util.toString(obj)); + this.logger.debug("import attributes from: ", util.toString(obj)); - if (typeof obj === 'undefined') + if (typeof obj === "undefined") { return; } @@ -507,7 +494,6 @@

Source: core/PsychoJS.js

} } - /** * Close everything and exit nicely at the end of the experiment, * potentially redirecting to one of the URLs previously specified by setRedirectUrls. @@ -521,9 +507,9 @@

Source: core/PsychoJS.js

* @async * @public */ - async quit({message, isCompleted = false} = {}) + async quit({ message, isCompleted = false } = {}) { - this.logger.info('[PsychoJS] Quit.'); + this.logger.info("[PsychoJS] Quit."); this._experiment.experimentEnded = true; this._status = PsychoJS.Status.FINISHED; @@ -536,17 +522,17 @@

Source: core/PsychoJS.js

// remove the beforeunload listener: if (this.getEnvironment() === ExperimentHandler.Environment.SERVER) { - window.removeEventListener('beforeunload', this.beforeunloadCallback); + window.removeEventListener("beforeunload", this.beforeunloadCallback); } // save the results and the logs of the experiment: this.gui.dialog({ - warning: 'Closing the session. Please wait a few moments.', - showOK: false + warning: "Closing the session. Please wait a few moments.", + showOK: false, }); if (isCompleted || this._config.experiment.saveIncompleteResults) { - if (!this._serverMsg.has('__noOutput')) + if (!this._serverMsg.has("__noOutput")) { await this._experiment.save(); await this._logger.flush(); @@ -560,8 +546,8 @@

Source: core/PsychoJS.js

} // thank participant for waiting and either quit or redirect: - let text = 'Thank you for your patience.<br/><br/>'; - text += (typeof message !== 'undefined') ? message : 'Goodbye!'; + let text = "Thank you for your patience.<br/><br/>"; + text += (typeof message !== "undefined") ? message : "Goodbye!"; const self = this; this._gui.dialog({ message: text, @@ -580,26 +566,24 @@

Source: core/PsychoJS.js

this._window.closeFullScreen(); // redirect if redirection URLs have been provided: - if (isCompleted && typeof self._completionUrl !== 'undefined') + if (isCompleted && typeof self._completionUrl !== "undefined") { window.location = self._completionUrl; } - else if (!isCompleted && typeof self._cancellationUrl !== 'undefined') + else if (!isCompleted && typeof self._cancellationUrl !== "undefined") { window.location = self._cancellationUrl; } - } + }, }); - } catch (error) { console.error(error); - this._gui.dialog({error}); + this._gui.dialog({ error }); } } - /** * Configure PsychoJS for the running experiment. * @@ -611,68 +595,67 @@

Source: core/PsychoJS.js

async _configure(configURL, name) { const response = { - origin: 'PsychoJS.configure', - context: 'when configuring PsychoJS for the experiment' + origin: "PsychoJS.configure", + context: "when configuring PsychoJS for the experiment", }; try { this.status = PsychoJS.Status.CONFIGURING; - // if the experiment is running from the pavlovia.org server, we read the configuration file: + // if the experiment is running from an approved hosts, e.e pavlovia.org, + // we read the configuration file: const experimentUrl = window.location.href; - // go through each url in allow list const isHost = this._hosts.some(url => experimentUrl.indexOf(url) === 0); if (isHost) { const serverResponse = await this._serverManager.getConfiguration(configURL); this._config = serverResponse.config; - // legacy experiments had a psychoJsManager block instead of a pavlovia block, - // and the URL pointed to https://pavlovia.org/server - if ('psychoJsManager' in this._config) + // update the configuration for legacy experiments, which had a psychoJsManager + // block instead of a pavlovia block, with URL pointing to https://pavlovia.org/server + if ("psychoJsManager" in this._config) { delete this._config.psychoJsManager; this._config.pavlovia = { - URL: 'https://pavlovia.org' + URL: "https://pavlovia.org", }; } // tests for the presence of essential blocks in the configuration: - if (!('experiment' in this._config)) + if (!("experiment" in this._config)) { - throw 'missing experiment block in configuration'; + throw "missing experiment block in configuration"; } - if (!('name' in this._config.experiment)) + if (!("name" in this._config.experiment)) { - throw 'missing name in experiment block in configuration'; + throw "missing name in experiment block in configuration"; } - if (!('fullpath' in this._config.experiment)) + if (!("fullpath" in this._config.experiment)) { - throw 'missing fullpath in experiment block in configuration'; + throw "missing fullpath in experiment block in configuration"; } - if (!('pavlovia' in this._config)) + if (!("pavlovia" in this._config)) { - throw 'missing pavlovia block in configuration'; + throw "missing pavlovia block in configuration"; } - if (!('URL' in this._config.pavlovia)) + if (!("URL" in this._config.pavlovia)) { - throw 'missing URL in pavlovia block in configuration'; + throw "missing URL in pavlovia block in configuration"; } - if (!('gitlab' in this._config)) + if (!("gitlab" in this._config)) { - throw 'missing gitlab block in configuration'; + throw "missing gitlab block in configuration"; } - if (!('projectId' in this._config.gitlab)) + if (!("projectId" in this._config.gitlab)) { - throw 'missing projectId in gitlab block in configuration'; + throw "missing projectId in gitlab block in configuration"; } this._config.environment = ExperimentHandler.Environment.SERVER; - } - else // otherwise we create an ad-hoc configuration: + else { this._config = { environment: ExperimentHandler.Environment.LOCAL, @@ -680,8 +663,8 @@

Source: core/PsychoJS.js

name, saveFormat: ExperimentHandler.SaveFormat.CSV, saveIncompleteResults: true, - keys: [] - } + keys: [], + }, }; } @@ -689,24 +672,22 @@

Source: core/PsychoJS.js

this._serverMsg = new Map(); util.getUrlParameters().forEach((value, key) => { - if (key.indexOf('__') === 0) + if (key.indexOf("__") === 0) { this._serverMsg.set(key, value); } }); - this.status = PsychoJS.Status.CONFIGURED; - this.logger.debug('configuration:', util.toString(this._config)); + this.logger.debug("configuration:", util.toString(this._config)); } catch (error) { // throw { ...response, error }; - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - /** * Get the IP information of the participant, asynchronously. * @@ -716,33 +697,32 @@

Source: core/PsychoJS.js

async _getParticipantIPInfo() { const response = { - origin: 'PsychoJS._getParticipantIPInfo', - context: 'when getting the IP information of the participant' + origin: "PsychoJS._getParticipantIPInfo", + context: "when getting the IP information of the participant", }; - this.logger.debug('getting the IP information of the participant'); + this.logger.debug("getting the IP information of the participant"); this._IP = {}; try { - const geoResponse = await jQuery.get('http://www.geoplugin.net/json.gp'); + const geoResponse = await jQuery.get("http://www.geoplugin.net/json.gp"); const geoData = JSON.parse(geoResponse); this._IP = { IP: geoData.geoplugin_request, country: geoData.geoplugin_countryName, latitude: geoData.geoplugin_latitude, - longitude: geoData.geoplugin_longitude + longitude: geoData.geoplugin_longitude, }; - this.logger.debug('IP information of the participant: ' + util.toString(this._IP)); + this.logger.debug("IP information of the participant: " + util.toString(this._IP)); } catch (error) { // throw { ...response, error }; - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - /** * Capture all errors and display them in a pop-up error box. * @@ -750,41 +730,46 @@

Source: core/PsychoJS.js

*/ _captureErrors() { - this.logger.debug('capturing all errors and showing them in a pop up window'); + 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) + window.onerror = function(message, source, lineno, colno, error) { console.error(error); - document.body.setAttribute('data-error', JSON.stringify({ - message: message, - source: source, - lineno: lineno, - colno: colno, - error: error - })); - - self._gui.dialog({"error": error}); - + document.body.setAttribute( + "data-error", + JSON.stringify({ + message: message, + source: source, + lineno: lineno, + colno: colno, + error: error, + }), + ); + + self._gui.dialog({ "error": error }); + return true; }; - window.onunhandledrejection = function (error) + window.onunhandledrejection = function(error) { console.error(error?.reason); - if (error?.reason?.stack === undefined) { + if (error?.reason?.stack === undefined) + { // No stack? Error thrown by PsychoJS; stringify whole error - document.body.setAttribute('data-error', JSON.stringify(error?.reason)); - } else { + document.body.setAttribute("data-error", JSON.stringify(error?.reason)); + } + else + { // Yes stack? Error thrown by JS; stringify stack - document.body.setAttribute('data-error', JSON.stringify(error?.reason?.stack)); + document.body.setAttribute("data-error", JSON.stringify(error?.reason?.stack)); } - self._gui.dialog({error: error?.reason}); + self._gui.dialog({ error: error?.reason }); return true; }; } - /** * Make the various Status top level, in order to accommodate PsychoPy's Code Components. * @private @@ -796,10 +781,8 @@

Source: core/PsychoJS.js

window[status] = PsychoJS.Status[status]; } } - } - /** * PsychoJS status. * @@ -812,17 +795,16 @@

Source: core/PsychoJS.js

* STOPPED in PsychoJS, but the Symbol is the same as that of FINISHED. */ PsychoJS.Status = { - NOT_CONFIGURED: Symbol.for('NOT_CONFIGURED'), - CONFIGURING: Symbol.for('CONFIGURING'), - 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') - ERROR: Symbol.for('ERROR') + NOT_CONFIGURED: Symbol.for("NOT_CONFIGURED"), + CONFIGURING: Symbol.for("CONFIGURING"), + 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') + ERROR: Symbol.for("ERROR"), }; - @@ -833,13 +815,13 @@

Source: core/PsychoJS.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/core_ServerManager.js.html b/docs/core_ServerManager.js.html index 2d323309..e2d032df 100644 --- a/docs/core_ServerManager.js.html +++ b/docs/core_ServerManager.js.html @@ -35,13 +35,13 @@

Source: core/ServerManager.js

* @license Distributed under the terms of the MIT License */ -import { Howl } from 'howler'; -import {PsychoJS} from './PsychoJS'; -import {PsychObject} from '../util/PsychObject'; -import * as util from '../util/Util'; -import {ExperimentHandler} from "../data/ExperimentHandler"; -import {MonotonicClock} from "../util/Clock"; - +import { Howl } from "howler"; +import { ExperimentHandler } from "../data/ExperimentHandler.js"; +import { Clock, MonotonicClock } from "../util/Clock.js"; +import { PsychObject } from "../util/PsychObject.js"; +import * as util from "../util/Util.js"; +import { Scheduler } from "../util/Scheduler.js"; +import { PsychoJS } from "./PsychoJS.js"; /** * <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> @@ -56,7 +56,7 @@

Source: core/ServerManager.js

*/ export class ServerManager extends PsychObject { - /** + /**************************************************************************** * Used to indicate to the ServerManager that all resources must be registered (and * subsequently downloaded) * @@ -64,13 +64,12 @@

Source: core/ServerManager.js

* @readonly * @public */ - static ALL_RESOURCES = Symbol.for('ALL_RESOURCES'); - + static ALL_RESOURCES = Symbol.for("ALL_RESOURCES"); constructor({ - psychoJS, - autoLog = false - } = {}) + psychoJS, + autoLog = false, + } = {}) { super(psychoJS); @@ -79,20 +78,22 @@

Source: core/ServerManager.js

// resources is a map of <name: string, { path: string, status: ResourceStatus, data: any }> this._resources = new Map(); + this._nbLoadedResources = 0; + this._setupPreloadQueue(); - this._addAttribute('autoLog', autoLog); - this._addAttribute('status', ServerManager.Status.READY); - } + this._addAttribute("autoLog", autoLog); + this._addAttribute("status", ServerManager.Status.READY); + } - /** + /**************************************************************************** * @typedef ServerManager.GetConfigurationPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.<string, *>} [config] the configuration * @property {Object.<string, *>} [error] an error message if we could not read the configuration file */ - /** + /**************************************************************************** * Read the configuration file for the experiment. * * @name module:core.ServerManager#getConfiguration @@ -105,41 +106,40 @@

Source: core/ServerManager.js

getConfiguration(configURL) { const response = { - origin: 'ServerManager.getConfiguration', - context: 'when reading the configuration file: ' + configURL + origin: "ServerManager.getConfiguration", + context: "when reading the configuration file: " + configURL, }; - this._psychoJS.logger.debug('reading the configuration file: ' + configURL); + this._psychoJS.logger.debug("reading the configuration file: " + configURL); const self = this; return new Promise((resolve, reject) => { - jQuery.get(configURL, 'json') + jQuery.get(configURL, "json") .done((config, textStatus) => { // resolve({ ...response, config }); - resolve(Object.assign(response, {config})); + resolve(Object.assign(response, { config })); }) .fail((jqXHR, textStatus, errorThrown) => { self.setStatus(ServerManager.Status.ERROR); const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error('error:', errorMsg); + console.error("error:", errorMsg); - reject(Object.assign(response, {error: errorMsg})); + reject(Object.assign(response, { error: errorMsg })); }); }); } - - /** + /**************************************************************************** * @typedef ServerManager.OpenSessionPromise * @property {string} origin the calling method * @property {string} context the context * @property {string} [token] the session token * @property {Object.<string, *>} [error] an error message if we could not open the session */ - /** + /**************************************************************************** * Open a session for this experiment on the remote PsychoJS manager. * * @name module:core.ServerManager#openSession @@ -150,45 +150,47 @@

Source: core/ServerManager.js

openSession() { const response = { - origin: 'ServerManager.openSession', - context: 'when opening a session for experiment: ' + this._psychoJS.config.experiment.fullpath + origin: "ServerManager.openSession", + context: "when opening a session for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.debug('opening a session for experiment: ' + this._psychoJS.config.experiment.fullpath); + this._psychoJS.logger.debug("opening a session for experiment: " + this._psychoJS.config.experiment.fullpath); this.setStatus(ServerManager.Status.BUSY); // prepare POST query: let data = {}; - if (this._psychoJS._serverMsg.has('__pilotToken')) + if (this._psychoJS._serverMsg.has("__pilotToken")) { - data.pilotToken = this._psychoJS._serverMsg.get('__pilotToken'); + data.pilotToken = this._psychoJS._serverMsg.get("__pilotToken"); } // query pavlovia server: const self = this; return new Promise((resolve, reject) => { - const url = this._psychoJS.config.pavlovia.URL + '/api/v2/experiments/' + encodeURIComponent(self._psychoJS.config.experiment.fullpath) + '/sessions'; - jQuery.post(url, data, null, 'json') + const url = this._psychoJS.config.pavlovia.URL + + "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId + + "/sessions"; + jQuery.post(url, data, null, "json") .done((data, textStatus) => { - if (!('token' in data)) + if (!("token" in data)) { self.setStatus(ServerManager.Status.ERROR); - reject(Object.assign(response, {error: 'unexpected answer from server: no token'})); + reject(Object.assign(response, { error: "unexpected answer from server: no token" })); // reject({...response, error: 'unexpected answer from server: no token'}); } - if (!('experiment' in data)) + if (!("experiment" in data)) { self.setStatus(ServerManager.Status.ERROR); // reject({...response, error: 'unexpected answer from server: no experiment'}); - reject(Object.assign(response, {error: 'unexpected answer from server: no experiment'})); + reject(Object.assign(response, { error: "unexpected answer from server: no experiment" })); } self._psychoJS.config.session = { token: data.token, - status: 'OPEN' + status: "OPEN", }; self._psychoJS.config.experiment.status = data.experiment.status2; self._psychoJS.config.experiment.saveFormat = Symbol.for(data.experiment.saveFormat); @@ -197,7 +199,7 @@

Source: core/ServerManager.js

self._psychoJS.config.experiment.runMode = data.experiment.runMode; // secret keys for various services, e.g. Google Speech API - if ('keys' in data.experiment) + if ("keys" in data.experiment) { self._psychoJS.config.experiment.keys = data.experiment.keys; } @@ -208,28 +210,27 @@

Source: core/ServerManager.js

self.setStatus(ServerManager.Status.READY); // resolve({ ...response, token: data.token, status: data.status }); - resolve(Object.assign(response, {token: data.token, status: data.status})); + resolve(Object.assign(response, { token: data.token, status: data.status })); }) .fail((jqXHR, textStatus, errorThrown) => { self.setStatus(ServerManager.Status.ERROR); const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error('error:', errorMsg); + console.error("error:", errorMsg); - reject(Object.assign(response, {error: errorMsg})); + reject(Object.assign(response, { error: errorMsg })); }); }); } - - /** + /**************************************************************************** * @typedef ServerManager.CloseSessionPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.<string, *>} [error] an error message if we could not close the session (e.g. if it has not previously been opened) */ - /** + /**************************************************************************** * Close the session for this experiment on the remote PsychoJS manager. * * @name module:core.ServerManager#closeSession @@ -242,16 +243,18 @@

Source: core/ServerManager.js

async closeSession(isCompleted = false, sync = false) { const response = { - origin: 'ServerManager.closeSession', - context: 'when closing the session for experiment: ' + this._psychoJS.config.experiment.fullpath + origin: "ServerManager.closeSession", + context: "when closing the session for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.debug('closing the session for experiment: ' + this._psychoJS.config.experiment.name); + this._psychoJS.logger.debug("closing the session for experiment: " + this._psychoJS.config.experiment.name); this.setStatus(ServerManager.Status.BUSY); // prepare DELETE query: - const url = this._psychoJS.config.pavlovia.URL + '/api/v2/experiments/' + encodeURIComponent(this._psychoJS.config.experiment.fullpath) + '/sessions/' + this._psychoJS.config.session.token; + const url = this._psychoJS.config.pavlovia.URL + + "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId + + "/sessions/" + this._psychoJS.config.session.token; // synchronous query the pavlovia server: if (sync) @@ -262,7 +265,7 @@

Source: core/ServerManager.js

request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); request.send(JSON.stringify(data)); */ - /* This does not work in Chrome before of a CORS bug + /* This does not work in Chrome because of a CORS bug await fetch(url, { method: 'DELETE', headers: { 'Content-Type': 'application/json;charset=UTF-8' }, @@ -272,9 +275,9 @@

Source: core/ServerManager.js

}); */ const formData = new FormData(); - formData.append('isCompleted', isCompleted); - navigator.sendBeacon(url + '/delete', formData); - this._psychoJS.config.session.status = 'CLOSED'; + formData.append("isCompleted", isCompleted); + navigator.sendBeacon(url + "/delete", formData); + this._psychoJS.config.session.status = "CLOSED"; } // asynchronously query the pavlovia server: else @@ -284,33 +287,32 @@

Source: core/ServerManager.js

{ jQuery.ajax({ url, - type: 'delete', - data: {isCompleted}, - dataType: 'json' + type: "delete", + data: { isCompleted }, + dataType: "json", }) .done((data, textStatus) => { self.setStatus(ServerManager.Status.READY); - self._psychoJS.config.session.status = 'CLOSED'; + self._psychoJS.config.session.status = "CLOSED"; // resolve({ ...response, data }); - resolve(Object.assign(response, {data})); + resolve(Object.assign(response, { data })); }) .fail((jqXHR, textStatus, errorThrown) => { self.setStatus(ServerManager.Status.ERROR); const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error('error:', errorMsg); + console.error("error:", errorMsg); - reject(Object.assign(response, {error: errorMsg})); + reject(Object.assign(response, { error: errorMsg })); }); }); } } - - /** + /**************************************************************************** * Get the value of a resource. * * @name module:core.ServerManager#getResource @@ -326,59 +328,99 @@

Source: core/ServerManager.js

getResource(name, errorIfNotDownloaded = false) { const response = { - origin: 'ServerManager.getResource', - context: 'when getting the value of resource: ' + name + origin: "ServerManager.getResource", + context: "when getting the value of resource: " + name, }; const pathStatusData = this._resources.get(name); - if (typeof pathStatusData === 'undefined') + if (typeof pathStatusData === "undefined") { // throw { ...response, error: 'unknown resource' }; - throw Object.assign(response, {error: 'unknown resource'}); + throw Object.assign(response, { error: "unknown resource" }); } 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) + 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. + /**************************************************************************** + * Get the status of a single resource or the reduced status of an array of resources. + * + * <p>If an array of resources is given, getResourceStatus returns a single, reduced status + * that is the status furthest away from DOWNLOADED, with the status ordered as follow: + * ERROR (furthest from DOWNLOADED), REGISTERED, DOWNLOADING, and DOWNLOADED</p> + * <p>For example, given three resources: + * <ul> + * <li>if at least one of the resource status is ERROR, the reduced status is ERROR</li> + * <li>if at least one of the resource status is DOWNLOADING, the reduced status is DOWNLOADING</li> + * <li>if the status of all three resources is REGISTERED, the reduced status is REGISTERED</li> + * <li>if the status of all three resources is DOWNLOADED, the reduced status is DOWNLOADED</li> + * </ul> + * </p> * * @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 + * @param {string | string[]} names names of the resources whose statuses are requested + * @return {core.ServerManager.ResourceStatus} status of the resource if there is only one, or reduced status otherwise + * @throws {Object.<string, *>} if at least one of the names is not that of a previously + * registered resource */ - getResourceStatus(name) + getResourceStatus(names) { const response = { - origin: 'ServerManager.getResourceStatus', - context: 'when getting the status of resource: ' + name + origin: "ServerManager.getResourceStatus", + context: `when getting the status of resource(s): ${JSON.stringify(names)}`, }; - const pathStatusData = this._resources.get(name); - if (typeof pathStatusData === 'undefined') + // sanity checks: + if (typeof names === 'string') { - // throw { ...response, error: 'unknown resource' }; - throw Object.assign(response, {error: 'unknown resource'}); + names = [names]; } + if (!Array.isArray(names)) + { + throw Object.assign(response, { error: "names should be either a string or an array of strings" }); + } + const statusOrder = new Map([ + [Symbol.keyFor(ServerManager.ResourceStatus.ERROR), 0], + [Symbol.keyFor(ServerManager.ResourceStatus.REGISTERED), 1], + [Symbol.keyFor(ServerManager.ResourceStatus.DOWNLOADING), 2], + [Symbol.keyFor(ServerManager.ResourceStatus.DOWNLOADED), 3] + ]); + let reducedStatus = ServerManager.ResourceStatus.DOWNLOADED; + for (const name of names) + { + const pathStatusData = this._resources.get(name); - return pathStatusData.status; - } + if (typeof pathStatusData === "undefined") + { + // throw { ...response, error: 'unknown resource' }; + throw Object.assign(response, { + error: `unable to find a previously registered resource with name: ${name}` + }); + } + // update the reduced status according to the order given by statusOrder: + if (statusOrder.get(Symbol.keyFor(pathStatusData.status)) < + statusOrder.get(Symbol.keyFor(reducedStatus))) + { + reducedStatus = pathStatusData.status; + } + } - /** + return reducedStatus; + } + + /**************************************************************************** * Set the resource manager status. * * @name module:core.ServerManager#setStatus @@ -388,21 +430,19 @@

Source: core/ServerManager.js

setStatus(status) { const response = { - origin: 'ServerManager.setStatus', - context: 'when changing the status of the server manager to: ' + util.toString(status) + origin: "ServerManager.setStatus", + context: "when changing the status of the server manager to: " + util.toString(status), }; // check status: - const statusKey = (typeof status === 'symbol') ? Symbol.keyFor(status) : null; + const statusKey = (typeof status === "symbol") ? Symbol.keyFor(status) : null; if (!statusKey) - // throw { ...response, error: 'status must be a symbol' }; - { - throw Object.assign(response, {error: 'status must be a symbol'}); + { // throw { ...response, error: 'status must be a symbol' }; + throw Object.assign(response, { error: "status must be a symbol" }); } if (!ServerManager.Status.hasOwnProperty(statusKey)) - // throw { ...response, error: 'unknown status' }; - { - throw Object.assign(response, {error: 'unknown status'}); + { // throw { ...response, error: 'unknown status' }; + throw Object.assign(response, { error: "unknown status" }); } this._status = status; @@ -413,8 +453,7 @@

Source: core/ServerManager.js

return this._status; } - - /** + /**************************************************************************** * Reset the resource manager status to ServerManager.Status.READY. * * @name module:core.ServerManager#resetStatus @@ -427,8 +466,7 @@

Source: core/ServerManager.js

return this.setStatus(ServerManager.Status.READY); } - - /** + /**************************************************************************** * Prepare resources for the experiment: register them with the server manager and possibly * start downloading them right away. * @@ -442,18 +480,18 @@

Source: core/ServerManager.js

* </ul> * * @name module:core.ServerManager#prepareResources - * @param {Array.<{name: string, path: string, download: boolean} | Symbol>} [resources=[]] - the list of resources + * @param {String | Array.<{name: string, path: string, download: boolean} | String | Symbol>} [resources=[]] - the list of resources or a single resource * @function * @public */ async prepareResources(resources = []) { const response = { - origin: 'ServerManager.prepareResources', - context: 'when preparing resources for experiment: ' + this._psychoJS.config.experiment.name + 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); + this._psychoJS.logger.debug("preparing resources for experiment: " + this._psychoJS.config.experiment.name); try { @@ -462,19 +500,24 @@

Source: core/ServerManager.js

// register the resources: if (resources !== null) { + if (typeof resources === "string") + { + resources = [resources]; + } if (!Array.isArray(resources)) { - throw "resources should be an array of objects"; + throw "resources should be either (a) a string or (b) an array of string or objects"; } // whether all resources have been requested: - const allResources = (resources.length === 1 && resources[0] === ServerManager.ALL_RESOURCES); + 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) + 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(); @@ -485,53 +528,66 @@

Source: core/ServerManager.js

{ if (!this._resources.has(name)) { - const path = serverResponse.resourceDirectory + '/' + name; + const path = serverResponse.resourceDirectory + "/" + name; this._resources.set(name, { status: ServerManager.ResourceStatus.REGISTERED, path, - data: undefined + data: undefined, }); - this._psychoJS.logger.debug('registered resource:', name, path); + this._psychoJS.logger.debug(`registered resource: name= ${name}, path= ${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) + 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) + // convert those resources that are only a string to an object with name and path: + for (let r = 0; r < resources.length; ++r) + { + const resource = resources[r]; + if (typeof resource === "string") + { + resources[r] = { + name: resource, + path: resource, + download: true + } + } + } + + 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)) + 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; + path = "https://pavlovia.org/api/v2/proxy/" + path; } this._resources.set(name, { status: ServerManager.ResourceStatus.REGISTERED, path, - data: undefined + data: undefined, }); - this._psychoJS.logger.debug('registered resource:', name, path); + this._psychoJS.logger.debug(`registered resource: name= ${name}, path= ${path}`); // download resources by default: - if (typeof download === 'undefined' || download) + if (typeof download === "undefined" || download) { resourcesToDownload.add(name); } @@ -540,19 +596,42 @@

Source: core/ServerManager.js

} } - // download those registered resources for which download = true: - /*await*/ this._downloadResources(resourcesToDownload); + // download those registered resources for which download = true + // note: we return a Promise that will be resolved when all the resources are downloaded + if (resourcesToDownload.size === 0) + { + this.emit(ServerManager.Event.RESOURCE, { + message: ServerManager.Event.DOWNLOAD_COMPLETED, + }); + + return Promise.resolve(); + } + else + { + return new Promise((resolve, reject) => + { + const uuid = this.on(ServerManager.Event.RESOURCE, (signal) => + { + if (signal.message === ServerManager.Event.DOWNLOAD_COMPLETED) + { + this.off(ServerManager.Event.RESOURCE, uuid); + resolve(); + } + }); + + this._downloadResources(resourcesToDownload); + }); + } } catch (error) { - console.log('error', error); - throw Object.assign(response, {error}); + console.error("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 @@ -566,11 +645,11 @@

Source: core/ServerManager.js

this._waitForDownloadComponent = { status: PsychoJS.Status.NOT_STARTED, clock: new Clock(), - resources: new Set() + resources: new Set(), }; const self = this; - return () => + return async () => { const t = self._waitForDownloadComponent.clock.getTime(); @@ -583,82 +662,84 @@

Source: core/ServerManager.js

// if resources is an empty array, we consider all registered resources: if (resources.length === 0) { - for (const [name, {status, path, data}] of this._resources) + for (const [name, { status, path, data }] of this._resources) { - resources.append({ name, path }); + resources.push({ name, path }); } } - // only download those resources not already downloaded or downloading: + // only download those resources not already downloaded and not downloading: const resourcesToDownload = new Set(); - for (let {name, path} of resources) + for (let { name, path } of resources) { // 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) ) + 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; + 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') + if (typeof pathStatusData === "undefined") { self._resources.set(name, { status: ServerManager.ResourceStatus.REGISTERED, path, - data: undefined + data: undefined, }); self._waitForDownloadComponent.resources.add(name); resourcesToDownload.add(name); - self._psychoJS.logger.debug('registered resource:', name, path); + 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') - { + { // else if (typeof pathStatusData.data === 'undefined') self._waitForDownloadComponent.resources.add(name); } - } + self._waitForDownloadComponent.status = PsychoJS.Status.STARTED; + // start the download: self._downloadResources(resourcesToDownload); } - // check whether all resources have been downloaded: - for (const name of self._waitForDownloadComponent.resources) + if (self._waitForDownloadComponent.status === PsychoJS.Status.STARTED) { - 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') + // check whether all resources have been downloaded: + for (const name of self._waitForDownloadComponent.resources) { - return Scheduler.Event.FLIP_REPEAT; + const pathStatusData = this._resources.get(name); + + // the resource has not been downloaded yet: loop this component + if (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; + // all resources have been downloaded: move to the next component: + self._waitForDownloadComponent.status = PsychoJS.Status.FINISHED; + return Scheduler.Event.NEXT; + } }; - } - - /** + /**************************************************************************** * @typedef ServerManager.UploadDataPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.<string, *>} [error] an error message if we could not upload the data */ - /** + /**************************************************************************** * Asynchronously upload experiment data to the pavlovia server. * * @name module:core.ServerManager#uploadData @@ -673,24 +754,24 @@

Source: core/ServerManager.js

uploadData(key, value, sync = false) { const response = { - origin: 'ServerManager.uploadData', - context: 'when uploading participant\'s results for experiment: ' + this._psychoJS.config.experiment.fullpath + origin: "ServerManager.uploadData", + context: "when uploading participant's results for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.debug('uploading data for experiment: ' + this._psychoJS.config.experiment.fullpath); + this._psychoJS.logger.debug("uploading data for experiment: " + this._psychoJS.config.experiment.fullpath); this.setStatus(ServerManager.Status.BUSY); - const url = this._psychoJS.config.pavlovia.URL + - '/api/v2/experiments/' + encodeURIComponent(this._psychoJS.config.experiment.fullpath) + - '/sessions/' + this._psychoJS.config.session.token + - '/results'; + const url = this._psychoJS.config.pavlovia.URL + + "/api/v2/experiments/" + encodeURIComponent(this._psychoJS.config.experiment.fullpath) + + "/sessions/" + this._psychoJS.config.session.token + + "/results"; // synchronous query the pavlovia server: if (sync) { const formData = new FormData(); - formData.append('key', key); - formData.append('value', value); + formData.append("key", key); + formData.append("value", value); navigator.sendBeacon(url, formData); } // asynchronously query the pavlovia server: @@ -701,31 +782,29 @@

Source: core/ServerManager.js

{ const data = { key, - value + value, }; - jQuery.post(url, data, null, 'json') + jQuery.post(url, data, null, "json") .done((serverData, textStatus) => { self.setStatus(ServerManager.Status.READY); - resolve(Object.assign(response, {serverData})); + resolve(Object.assign(response, { serverData })); }) .fail((jqXHR, textStatus, errorThrown) => { self.setStatus(ServerManager.Status.ERROR); const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error('error:', errorMsg); + console.error("error:", errorMsg); - reject(Object.assign(response, {error: errorMsg})); + reject(Object.assign(response, { error: errorMsg })); }); }); } } - - - /** + /**************************************************************************** * Asynchronously upload experiment logs to the pavlovia server. * * @name module:core.ServerManager#uploadLog @@ -738,134 +817,190 @@

Source: core/ServerManager.js

uploadLog(logs, compressed = false) { const response = { - origin: 'ServerManager.uploadLog', - context: 'when uploading participant\'s log for experiment: ' + this._psychoJS.config.experiment.fullpath + origin: "ServerManager.uploadLog", + context: "when uploading participant's log for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.debug('uploading server log for experiment: ' + this._psychoJS.config.experiment.fullpath); + this._psychoJS.logger.debug("uploading server log for experiment: " + this._psychoJS.config.experiment.fullpath); this.setStatus(ServerManager.Status.BUSY); // prepare the POST query: 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 + '.log'; + 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 + ".log"; const data = { filename, logs, - compressed + compressed, }; // query the pavlovia server: const self = this; return new Promise((resolve, reject) => { - const url = self._psychoJS.config.pavlovia.URL + - '/api/v2/experiments/' + encodeURIComponent(self._psychoJS.config.experiment.fullpath) + - '/sessions/' + self._psychoJS.config.session.token + - '/logs'; + const url = self._psychoJS.config.pavlovia.URL + + "/api/v2/experiments/" + encodeURIComponent(self._psychoJS.config.experiment.fullpath) + + "/sessions/" + self._psychoJS.config.session.token + + "/logs"; - jQuery.post(url, data, null, 'json') + jQuery.post(url, data, null, "json") .done((serverData, textStatus) => { self.setStatus(ServerManager.Status.READY); - resolve(Object.assign(response, {serverData})); + resolve(Object.assign(response, { serverData })); }) .fail((jqXHR, textStatus, errorThrown) => { self.setStatus(ServerManager.Status.ERROR); const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error('error:', errorMsg); + console.error("error:", errorMsg); - reject(Object.assign(response, {error: errorMsg})); + reject(Object.assign(response, { error: errorMsg })); }); }); } - - - /** - * Asynchronously upload audio data to the pavlovia server. + /**************************************************************************** + * Synchronously or asynchronously upload audio data to the pavlovia server. * - * @name module:core.ServerManager#uploadAudio + * @name module:core.ServerManager#uploadAudioVideo * @function * @public - * @param {Blob} audioBlob - the audio blob to be uploaded - * @param {string} tag - additional tag + * @param @param {Object} options + * @param {Blob} options.mediaBlob - the audio or video blob to be uploaded + * @param {string} options.tag - additional tag + * @param {boolean} [options.waitForCompletion=false] - whether or not to wait for completion + * before returning + * @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the participant to wait for the data to be uploaded to the server + * @param {string} [options.dialogMsg="Please wait a few moments while the data is uploading to the server"] - default message informing the participant to wait for the data to be uploaded to the server * @returns {Promise<ServerManager.UploadDataPromise>} the response */ - async uploadAudio(audioBlob, tag) + async uploadAudioVideo({mediaBlob, tag, waitForCompletion = false, showDialog = false, dialogMsg = "Please wait a few moments while the data is uploading to the server"}) { const response = { - origin: 'ServerManager.uploadAudio', - context: 'when uploading audio data for experiment: ' + this._psychoJS.config.experiment.fullpath + origin: "ServerManager.uploadAudio", + context: "when uploading media 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')) + 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'; + throw "media 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._psychoJS.logger.debug(`uploading media data for experiment: ${this._psychoJS.config.experiment.fullpath}`); this.setStatus(ServerManager.Status.BUSY); + // open pop-up dialog: + if (showDialog) + { + this.psychoJS.gui.dialog({ + warning: dialogMsg, + showOK: false, + }); + } + // 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 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 + formData.append("media", mediaBlob, filename); + + let url = this._psychoJS.config.pavlovia.URL + + "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId + + "/sessions/" + this._psychoJS.config.session.token + + "/media"; + + // query the server: + let response = await fetch(url, { + method: "POST", + mode: "cors", + cache: "no-cache", + credentials: "same-origin", + redirect: "follow", + referrerPolicy: "no-referrer", + body: formData, }); - const jsonResponse = await response.json(); + const postMediaResponse = await response.json(); + this._psychoJS.logger.debug(`post media response: ${JSON.stringify(postMediaResponse)}`); // deal with server errors: if (!response.ok) { - throw jsonResponse; + throw postMediaResponse; + } + + // wait until the upload has completed: + if (waitForCompletion) + { + if (!("uploadToken" in postMediaResponse)) + { + throw "incorrect server response: missing uploadToken"; + } + const uploadToken = postMediaResponse['uploadToken']; + + while (true) + { + // wait a bit: + await new Promise(r => + { + setTimeout(r, 1000); + }); + + // check the status of the upload: + url = this._psychoJS.config.pavlovia.URL + + "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId + + "/sessions/" + this._psychoJS.config.session.token + + "/media/" + uploadToken + "/status"; + + response = await fetch(url, { + method: "GET", + mode: "cors", + cache: "no-cache", + credentials: "same-origin", + redirect: "follow", + referrerPolicy: "no-referrer" + }); + const checkStatusResponse = await response.json(); + this._psychoJS.logger.debug(`check upload status response: ${JSON.stringify(checkStatusResponse)}`); + + if (("status" in checkStatusResponse) && checkStatusResponse["status"] === "COMPLETED") + { + break; + } + } + } + + if (showDialog) + { + this.psychoJS.gui.closeDialog(); } this.setStatus(ServerManager.Status.READY); - return jsonResponse; + return postMediaResponse; } catch (error) { this.setStatus(ServerManager.Status.ERROR); console.error(error); - throw {...response, error}; + throw { ...response, error }; } - } - - - /** + /**************************************************************************** * List the resources available to the experiment. - + * * @name module:core.ServerManager#_listResources * @function * @private @@ -873,49 +1008,51 @@

Source: core/ServerManager.js

_listResources() { const response = { - origin: 'ServerManager._listResourcesSession', - context: 'when listing the resources for experiment: ' + this._psychoJS.config.experiment.fullpath + origin: "ServerManager._listResourcesSession", + context: "when listing the resources for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.debug('listing the resources for experiment: ' + - this._psychoJS.config.experiment.fullpath); + this._psychoJS.logger.debug( + "listing the resources for experiment: " + + this._psychoJS.config.experiment.fullpath, + ); this.setStatus(ServerManager.Status.BUSY); // prepare GET data: const data = { - 'token': this._psychoJS.config.session.token + "token": this._psychoJS.config.session.token, }; // query pavlovia server: const self = this; return new Promise((resolve, reject) => { - const url = this._psychoJS.config.pavlovia.URL + - '/api/v2/experiments/' + encodeURIComponent(this._psychoJS.config.experiment.fullpath) + - '/resources'; + const url = this._psychoJS.config.pavlovia.URL + + "/api/v2/experiments/" + encodeURIComponent(this._psychoJS.config.experiment.fullpath) + + "/resources"; - jQuery.get(url, data, null, 'json') + jQuery.get(url, data, null, "json") .done((data, textStatus) => { - if (!('resources' in data)) + if (!("resources" in data)) { self.setStatus(ServerManager.Status.ERROR); // reject({ ...response, error: 'unexpected answer from server: no resources' }); - reject(Object.assign(response, {error: 'unexpected answer from server: no resources'})); + reject(Object.assign(response, { error: "unexpected answer from server: no resources" })); } - if (!('resourceDirectory' in data)) + if (!("resourceDirectory" in data)) { self.setStatus(ServerManager.Status.ERROR); // reject({ ...response, error: 'unexpected answer from server: no resourceDirectory' }); - reject(Object.assign(response, {error: 'unexpected answer from server: no resourceDirectory'})); + reject(Object.assign(response, { error: "unexpected answer from server: no resourceDirectory" })); } self.setStatus(ServerManager.Status.READY); // resolve({ ...response, resources: data.resources, resourceDirectory: data.resourceDirectory }); resolve(Object.assign(response, { resources: data.resources, - resourceDirectory: data.resourceDirectory + resourceDirectory: data.resourceDirectory, })); }) .fail((jqXHR, textStatus, errorThrown) => @@ -923,17 +1060,14 @@

Source: core/ServerManager.js

self.setStatus(ServerManager.Status.ERROR); const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error('error:', errorMsg); + console.error("error:", errorMsg); - reject(Object.assign(response, {error: errorMsg})); + reject(Object.assign(response, { error: errorMsg })); }); }); - } - - - /** + /**************************************************************************** * Download the specified resources. * * <p>Note: we use the [preloadjs library]{@link https://www.createjs.com/preloadjs}.</p> @@ -943,172 +1077,99 @@

Source: core/ServerManager.js

* @protected * @param {Set} resources - a set of names of previously registered resources */ - _downloadResources(resources) + async _downloadResources(resources) { const response = { - origin: 'ServerManager._downloadResources', - context: 'when downloading resources for experiment: ' + this._psychoJS.config.experiment.name + origin: "ServerManager._downloadResources", + context: "when downloading resources for experiment: " + this._psychoJS.config.experiment.name, }; - this._psychoJS.logger.debug('downloading 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 + count: resources.size, }); - this._nbLoadedResources = 0; - - - // (*) set-up preload.js: - this._resourceQueue = new createjs.LoadQueue(true, '', true); - - const self = this; - - // 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 => - { - 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 - }); - }); - - // the loading of all given resources completed: - this._resourceQueue.addEventListener("complete", event => - { - self._resourceQueue.close(); - if (self._nbLoadedResources === resources.size) - { - self.setStatus(ServerManager.Status.READY); - self.emit(ServerManager.Event.RESOURCE, { - message: ServerManager.Event.DOWNLOAD_COMPLETED - }); - } - }); - - // error: we throw an exception - this._resourceQueue.addEventListener("error", event => - { - self.setStatus(ServerManager.Status.ERROR); - 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 = []; + // based on the resource extension either (a) add it to the preload manifest, (b) mark it for + // download by howler, or (c) add it to the document fonts + const preloadManifest = []; const soundResources = new Set(); + const fontResources = []; for (const name of resources) { - const nameParts = name.toLowerCase().split('.'); + const nameParts = name.toLowerCase().split("."); const extension = (nameParts.length > 1) ? nameParts.pop() : undefined; // warn the user if the resource does not have any extension: - if (typeof extension === 'undefined') + if (typeof extension === "undefined") { 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') + if (typeof pathStatusData === "undefined") { - throw Object.assign(response, {error: name + ' has not been previously registered'}); + 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'}); + 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) + const pathParts = pathStatusData.path.toLowerCase().split("."); + const pathExtension = (pathParts.length > 1) ? pathParts.pop() : undefined; + + // preload.js with forced binary: + if (["csv", "odp", "xls", "xlsx", "json"].indexOf(extension) > -1) { - manifest.push(/*new createjs.LoadItem().set(*/{ + preloadManifest.push(/*new createjs.LoadItem().set(*/ { id: name, src: pathStatusData.path, type: createjs.Types.BINARY, - crossOrigin: 'Anonymous' - }/*)*/); + crossOrigin: "Anonymous", + } /*)*/); } - /* ascii .csv are adequately handled in binary format + + /* note: ascii .csv are adequately handled in binary format, no need to treat them separately // forced text for .csv: else if (['csv'].indexOf(resourceExtension) > -1) manifest.push({ id: resourceName, src: resourceName, type: createjs.Types.TEXT }); */ - // sound files are loaded through howler.js: - else if (['mp3', 'mpeg', 'opus', 'ogg', 'oga', 'wav', 'aac', 'caf', 'm4a', 'weba', 'dolby', 'flac'].indexOf(extension) > -1) + // sound files: + else if (["mp3", "mpeg", "opus", "ogg", "oga", "wav", "aac", "caf", "m4a", "weba", "dolby", "flac"].indexOf(extension) > -1) { soundResources.add(name); - if (extension === 'wav') + if (extension === "wav") { this.psychoJS.logger.warn(`wav files are not supported by all browsers. We recommend you convert "${name}" to another format, e.g. mp3`); } } - // preload.js for the other extensions (download type decided by preload.js): + // font files + else if (["ttf", "otf", "woff", "woff2"].indexOf(pathExtension) > -1) + { + fontResources.push(name); + } + + // all other extensions handled by preload.js (download type decided by preload.js): else { - manifest.push(/*new createjs.LoadItem().set(*/{ + preloadManifest.push(/*new createjs.LoadItem().set(*/ { id: name, src: pathStatusData.path, - crossOrigin: 'Anonymous' - }/*)*/); + crossOrigin: "Anonymous", + } /*)*/); } } - - // (*) start loading non-sound resources: - if (manifest.length > 0) + // start loading resources marked for preload.js: + if (preloadManifest.length > 0) { - this._resourceQueue.loadManifest(manifest); + this._preloadQueue.loadManifest(preloadManifest); } else { @@ -1116,60 +1177,196 @@

Source: core/ServerManager.js

{ this.setStatus(ServerManager.Status.READY); this.emit(ServerManager.Event.RESOURCE, { - message: ServerManager.Event.DOWNLOAD_COMPLETED}); + message: ServerManager.Event.DOWNLOAD_COMPLETED, + }); } } + // start loading fonts: + for (const name of fontResources) + { + const pathStatusData = this._resources.get(name); + pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING; + this.emit(ServerManager.Event.RESOURCE, { + message: ServerManager.Event.DOWNLOADING_RESOURCE, + resource: name, + }); + + const pathExtension = pathStatusData.path.toLowerCase().split(".").pop(); + try + { + const newFont = await new FontFace(name, `url('${pathStatusData.path}') format('${pathExtension}')`).load(); + document.fonts.add(newFont); - // (*) prepare and start loading sound resources: + ++this._nbLoadedResources; + + pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED; + this.emit(ServerManager.Event.RESOURCE, { + message: ServerManager.Event.RESOURCE_DOWNLOADED, + resource: name, + }); + + if (this._nbLoadedResources === resources.size) + { + this.setStatus(ServerManager.Status.READY); + this.emit(ServerManager.Event.RESOURCE, { + message: ServerManager.Event.DOWNLOAD_COMPLETED, + }); + } + } + catch (error) + { + console.error(error); + this.setStatus(ServerManager.Status.ERROR); + pathStatusData.status = ServerManager.ResourceStatus.ERROR; + throw Object.assign(response, { + error: `unable to download resource: ${name}: ${error}` + }); + } + } + + // start loading resources marked for howler.js: + const self = this; for (const name of soundResources) { const pathStatusData = this._resources.get(name); - pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING; + pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADING; this.emit(ServerManager.Event.RESOURCE, { message: ServerManager.Event.DOWNLOADING_RESOURCE, - resource: name + resource: name, }); const howl = new Howl({ src: pathStatusData.path, preload: false, - autoplay: false + autoplay: false, }); - howl.on('load', (event) => + howl.on("load", (event) => { - ++ self._nbLoadedResources; + ++self._nbLoadedResources; pathStatusData.data = howl; - pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED; + pathStatusData.status = ServerManager.ResourceStatus.DOWNLOADED; self.emit(ServerManager.Event.RESOURCE, { message: ServerManager.Event.RESOURCE_DOWNLOADED, - resource: name + resource: name, }); if (self._nbLoadedResources === resources.size) { self.setStatus(ServerManager.Status.READY); self.emit(ServerManager.Event.RESOURCE, { - message: ServerManager.Event.DOWNLOAD_COMPLETED}); + message: ServerManager.Event.DOWNLOAD_COMPLETED, + }); } }); - howl.on('loaderror', (id, error) => + howl.on("loaderror", (id, error) => { // throw { ...response, error: 'unable to download resource: ' + name + ' (' + util.toString(error) + ')' }; - throw Object.assign(response, {error: 'unable to download resource: ' + name + ' (' + util.toString(error) + ')'}); + throw Object.assign(response, { error: "unable to download resource: " + name + " (" + util.toString(error) + ")" }); }); howl.load(); } + } + /**************************************************************************** + * Setup the preload.js queue, and the associated callbacks. + * + * @name module:core.ServerManager#_setupPreloadQueue + * @function + * @protected + */ + _setupPreloadQueue() + { + const response = { + origin: "ServerManager._setupPreloadQueue", + context: "when setting up a preload queue" + }; + + this._preloadQueue = new createjs.LoadQueue(true, "", true); + + const self = this; + + // the loading of a specific resource has started: + this._preloadQueue.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._preloadQueue.addEventListener("fileload", (event) => + { + 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, + }); + }); + + // the loading of all given resources completed: + this._preloadQueue.addEventListener("complete", (event) => + { + self._preloadQueue.close(); + if (self._nbLoadedResources === self._resources.size) + { + self.setStatus(ServerManager.Status.READY); + self.emit(ServerManager.Event.RESOURCE, { + message: ServerManager.Event.DOWNLOAD_COMPLETED, + }); + } + }); + + // error: we throw an exception + this._preloadQueue.addEventListener("error", (event) => + { + self.setStatus(ServerManager.Status.ERROR); + 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", + }); + } + } + }); } -} +} -/** +/**************************************************************************** * Server event * * <p>A server event is emitted by the manager to inform its listeners of either a change of status, or of a resource related event (e.g. download started, download is completed).</p> @@ -1183,36 +1380,35 @@

Source: core/ServerManager.js

/** * Event type: resource event */ - RESOURCE: Symbol.for('RESOURCE'), + RESOURCE: Symbol.for("RESOURCE"), /** * Event: resources have started to download */ - DOWNLOADING_RESOURCES: Symbol.for('DOWNLOADING_RESOURCES'), + DOWNLOADING_RESOURCES: Symbol.for("DOWNLOADING_RESOURCES"), /** * Event: a specific resource download has started */ - DOWNLOADING_RESOURCE: Symbol.for('DOWNLOADING_RESOURCE'), + DOWNLOADING_RESOURCE: Symbol.for("DOWNLOADING_RESOURCE"), /** * Event: a specific resource has been downloaded */ - RESOURCE_DOWNLOADED: Symbol.for('RESOURCE_DOWNLOADED'), + RESOURCE_DOWNLOADED: Symbol.for("RESOURCE_DOWNLOADED"), /** * Event: resources have all downloaded */ - DOWNLOADS_COMPLETED: Symbol.for('DOWNLOAD_COMPLETED'), + DOWNLOADS_COMPLETED: Symbol.for("DOWNLOAD_COMPLETED"), /** * Event type: status event */ - STATUS: Symbol.for('STATUS') + STATUS: Symbol.for("STATUS"), }; - -/** +/**************************************************************************** * Server status * * @name module:core.ServerManager#Status @@ -1224,21 +1420,20 @@

Source: core/ServerManager.js

/** * The manager is ready. */ - READY: Symbol.for('READY'), + READY: Symbol.for("READY"), /** * The manager is busy, e.g. it is downloaded resources. */ - BUSY: Symbol.for('BUSY'), + BUSY: Symbol.for("BUSY"), /** * The manager has encountered an error, e.g. it was unable to download a resource. */ - ERROR: Symbol.for('ERROR') + ERROR: Symbol.for("ERROR"), }; - -/** +/**************************************************************************** * Resource status * * @name module:core.ServerManager#ResourceStatus @@ -1248,24 +1443,24 @@

Source: core/ServerManager.js

*/ ServerManager.ResourceStatus = { /** - * The resource has been registered. + * There was an error during downloading, or the resource is in an unknown state. */ - REGISTERED: Symbol.for('REGISTERED'), + ERROR: Symbol.for("ERROR"), /** - * The resource is currently downloading. + * The resource has been registered. */ - DOWNLOADING: Symbol.for('DOWNLOADING'), + REGISTERED: Symbol.for("REGISTERED"), /** - * The resource has been downloaded. + * The resource is currently downloading. */ - DOWNLOADED: Symbol.for('DOWNLOADED'), + DOWNLOADING: Symbol.for("DOWNLOADING"), /** - * There was an error during downloading, or the resource is in an unknown state. + * The resource has been downloaded. */ - ERROR: Symbol.for('ERROR'), + DOWNLOADED: Symbol.for("DOWNLOADED"), }; @@ -1277,13 +1472,13 @@

Source: core/ServerManager.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/core_Window.js.html b/docs/core_Window.js.html index 1587c106..b632c70b 100644 --- a/docs/core_Window.js.html +++ b/docs/core_Window.js.html @@ -35,11 +35,12 @@

Source: core/Window.js

* @license Distributed under the terms of the MIT License */ -import * as PIXI from 'pixi.js-legacy'; -import {Color} from '../util/Color'; -import {PsychObject} from '../util/PsychObject'; -import {MonotonicClock} from '../util/Clock'; -import {Logger} from "./Logger"; +import * as PIXI from "pixi.js-legacy"; +import {AdjustmentFilter} from "@pixi/filter-adjustment"; +import { MonotonicClock } from "../util/Clock.js"; +import { Color } from "../util/Color.js"; +import { PsychObject } from "../util/PsychObject.js"; +import { Logger } from "./Logger.js"; /** * <p>Window displays the various stimuli of the experiment.</p> @@ -53,6 +54,8 @@

Source: core/Window.js

* @param {string} [options.name] the name of the window * @param {boolean} [options.fullscr= false] whether or not to go fullscreen * @param {Color} [options.color= Color('black')] the background color of the window + * @param {number} [options.gamma= 1] sets the divisor for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) + * @param {number} [options.contrast= 1] sets the contrast value * @param {string} [options.units= 'pix'] the units of the window * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done * before flipping @@ -60,7 +63,6 @@

Source: core/Window.js

*/ export class Window extends PsychObject { - /** * Getter for monitorFramePeriod. * @@ -74,30 +76,47 @@

Source: core/Window.js

} constructor({ - psychoJS, - name, - fullscr = false, - color = new Color('black'), - units = 'pix', - waitBlanking = false, - autoLog = true - } = {}) + psychoJS, + name, + fullscr = false, + color = new Color("black"), + gamma = 1, + contrast = 1, + units = "pix", + waitBlanking = false, + autoLog = true, + } = {}) { super(psychoJS, name); // messages to be logged at the next "flip": this._msgToBeLogged = []; + // storing AdjustmentFilter instance to access later; + this._adjustmentFilter = new AdjustmentFilter({ + gamma, + contrast + }); + // list of all elements, in the order they are currently drawn: this._drawList = []; - this._addAttribute('fullscr', fullscr); - this._addAttribute('color', color); - this._addAttribute('units', units); - this._addAttribute('waitBlanking', waitBlanking); - this._addAttribute('autoLog', autoLog); - this._addAttribute('size', []); - + this._addAttribute("fullscr", fullscr); + this._addAttribute("color", color, new Color("black"), () => { + if (this._backgroundSprite) { + this._backgroundSprite.tint = color.int; + } + }); + this._addAttribute("gamma", gamma, 1, () => { + this._adjustmentFilter.gamma = this._gamma; + }); + this._addAttribute("contrast", contrast, 1, () => { + this._adjustmentFilter.contrast = this._contrast; + }); + this._addAttribute("units", units); + this._addAttribute("waitBlanking", waitBlanking); + this._addAttribute("autoLog", autoLog); + this._addAttribute("size", []); // setup PIXI: this._setupPixi(); @@ -106,15 +125,14 @@

Source: core/Window.js

this._flipCallbacks = []; - // fullscreen listener: this._windowAlreadyInFullScreen = false; const self = this; - document.addEventListener('fullscreenchange', (event) => + document.addEventListener("fullscreenchange", (event) => { self._windowAlreadyInFullScreen = !!document.fullscreenElement; - console.log('windowAlreadyInFullScreen:', self._windowAlreadyInFullScreen); + console.log("windowAlreadyInFullScreen:", self._windowAlreadyInFullScreen); // the Window and all of the stimuli need to be updated: self._needUpdate = true; @@ -124,14 +142,12 @@

Source: core/Window.js

} }); - if (this._autoLog) { this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`); } } - /** * Close the window. * @@ -148,27 +164,28 @@

Source: core/Window.js

return; } + this._rootContainer.destroy(); + if (document.body.contains(this._renderer.view)) { document.body.removeChild(this._renderer.view); } // destroy the renderer and the WebGL context: - if (typeof this._renderer.gl !== 'undefined') + if (typeof this._renderer.gl !== "undefined") { - const extension = this._renderer.gl.getExtension('WEBGL_lose_context'); + const extension = this._renderer.gl.getExtension("WEBGL_lose_context"); extension.loseContext(); } this._renderer.destroy(); - window.removeEventListener('resize', this._resizeCallback); - window.removeEventListener('orientationchange', this._resizeCallback); + window.removeEventListener("resize", this._resizeCallback); + window.removeEventListener("orientationchange", this._resizeCallback); this._renderer = null; } - /** * Estimate the frame rate. * @@ -186,7 +203,6 @@

Source: core/Window.js

return fps; } - /** * Take the browser full screen if possible. * @@ -201,39 +217,37 @@

Source: core/Window.js

// test whether the window is already fullscreen. // this._windowAlreadyInFullScreen = (!window.screenTop && !window.screenY); - if (this.fullscr/* && !this._windowAlreadyInFullScreen*/) + if (this.fullscr /* && !this._windowAlreadyInFullScreen*/) { - this._psychoJS.logger.debug('Resizing Window: ', this._name, 'to full screen.'); + this._psychoJS.logger.debug("Resizing Window: ", this._name, "to full screen."); - if (typeof document.documentElement.requestFullscreen === 'function') + if (typeof document.documentElement.requestFullscreen === "function") { document.documentElement.requestFullscreen() .catch(() => { - this.psychoJS.logger.warn('Unable to go fullscreen.'); + this.psychoJS.logger.warn("Unable to go fullscreen."); }); } - else if (typeof document.documentElement.mozRequestFullScreen === 'function') + else if (typeof document.documentElement.mozRequestFullScreen === "function") { document.documentElement.mozRequestFullScreen(); } - else if (typeof document.documentElement.webkitRequestFullscreen === 'function') + else if (typeof document.documentElement.webkitRequestFullscreen === "function") { document.documentElement.webkitRequestFullscreen(); } - else if (typeof document.documentElement.msRequestFullscreen === 'function') + else if (typeof document.documentElement.msRequestFullscreen === "function") { document.documentElement.msRequestFullscreen(); } else { - this.psychoJS.logger.warn('Unable to go fullscreen.'); + this.psychoJS.logger.warn("Unable to go fullscreen."); } } - } - /** * Take the browser back from full screen if needed. * @@ -245,37 +259,35 @@

Source: core/Window.js

{ if (this.fullscr) { - this._psychoJS.logger.debug('Resizing Window: ', this._name, 'back from full screen.'); + this._psychoJS.logger.debug("Resizing Window: ", this._name, "back from full screen."); - if (typeof document.exitFullscreen === 'function') + if (typeof document.exitFullscreen === "function") { document.exitFullscreen() .catch(() => { - this.psychoJS.logger.warn('Unable to close fullscreen.'); + this.psychoJS.logger.warn("Unable to close fullscreen."); }); } - else if (typeof document.mozCancelFullScreen === 'function') + else if (typeof document.mozCancelFullScreen === "function") { document.mozCancelFullScreen(); } - else if (typeof document.webkitExitFullscreen === 'function') + else if (typeof document.webkitExitFullscreen === "function") { document.webkitExitFullscreen(); } - else if (typeof document.msExitFullscreen === 'function') + else if (typeof document.msExitFullscreen === "function") { document.msExitFullscreen(); } else { - this.psychoJS.logger.warn('Unable to close fullscreen.'); + this.psychoJS.logger.warn("Unable to close fullscreen."); } } - } - /** * Log a message. * @@ -290,15 +302,14 @@

Source: core/Window.js

* @param {Object} [obj] the object associated with the message */ logOnFlip({ - msg, - level = Logger.ServerLevel.EXP, - obj - } = {}) + msg, + level = Logger.ServerLevel.EXP, + obj, + } = {}) { - this._msgToBeLogged.push({msg, level, obj}); + this._msgToBeLogged.push({ msg, level, obj }); } - /** * Callback function for callOnFlip. * @@ -319,9 +330,30 @@

Source: core/Window.js

*/ callOnFlip(flipCallback, ...flipCallbackArgs) { - this._flipCallbacks.push({function: flipCallback, arguments: flipCallbackArgs}); + this._flipCallbacks.push({ function: flipCallback, arguments: flipCallbackArgs }); + } + + /** + * Add PIXI.DisplayObject to the container displayed on the scene (window) + * + * @name module:core.Window#addPixiObject + * @function + * @public + */ + addPixiObject (pixiObject) { + this._stimsContainer.addChild(pixiObject); } + /** + * Remove PIXI.DisplayObject from the container displayed on the scene (window) + * + * @name module:core.Window#removePixiObject + * @function + * @public + */ + removePixiObject (pixiObject) { + this._stimsContainer.removeChild(pixiObject); + } /** * Render the stimuli onto the canvas. @@ -337,13 +369,12 @@

Source: core/Window.js

return; } - this._frameCount++; // render the PIXI container: this._renderer.render(this._rootContainer); - if (typeof this._renderer.gl !== 'undefined') + if (typeof this._renderer.gl !== "undefined") { // this is to make sure that the GPU is done rendering, it may not be necessary // [http://www.html5gamedevs.com/topic/27849-detect-when-view-has-been-rendered/] @@ -359,7 +390,7 @@

Source: core/Window.js

// call the callOnFlip functions and remove them: for (let callback of this._flipCallbacks) { - callback['function'](...callback['arguments']); + callback["function"](...callback["arguments"]); } this._flipCallbacks = []; @@ -370,7 +401,6 @@

Source: core/Window.js

this._refresh(); } - /** * Update this window, if need be. * @@ -395,7 +425,6 @@

Source: core/Window.js

} } - /** * Recompute this window's draw list and _container children for the next animation frame. * @@ -411,16 +440,15 @@

Source: core/Window.js

// update it, then put it back for (const stimulus of this._drawList) { - if (stimulus._needUpdate && typeof stimulus._pixi !== 'undefined') + if (stimulus._needUpdate && typeof stimulus._pixi !== "undefined") { - this._rootContainer.removeChild(stimulus._pixi); + this._stimsContainer.removeChild(stimulus._pixi); stimulus._updateIfNeeded(); - this._rootContainer.addChild(stimulus._pixi); + this._stimsContainer.addChild(stimulus._pixi); } } } - /** * Force an update of all stimuli in this window's drawlist. * @@ -440,7 +468,6 @@

Source: core/Window.js

this._refresh(); } - /** * Setup PIXI. * @@ -462,18 +489,42 @@

Source: core/Window.js

width: this._size[0], height: this._size[1], backgroundColor: this.color.int, - resolution: window.devicePixelRatio + powerPreference: "high-performance", + resolution: window.devicePixelRatio, }); - this._renderer.view.style.transform = 'translatez(0)'; - this._renderer.view.style.position = 'absolute'; + this._renderer.view.style.transform = "translatez(0)"; + this._renderer.view.style.position = "absolute"; document.body.appendChild(this._renderer.view); // we also change the background color of the body since the dialog popup may be longer than the window's height: document.body.style.backgroundColor = this._color.hex; + // filters in PIXI work in a slightly unexpected fashion: + // when setting this._rootContainer.filters, filtering itself + // ignores backgroundColor of this._renderer and in addition to that + // all child elements of this._rootContainer ignore backgroundColor when blending. + // To circumvent that creating a separate PIXI.Sprite that serves as background color. + // Then placing all Stims to a separate this._stimsContainer which hovers on top of + // background sprite so that if we need to move all stims at once, the background sprite + // won't get affected. + this._backgroundSprite = new PIXI.Sprite(PIXI.Texture.WHITE); + this._backgroundSprite.tint = this.color.int; + this._backgroundSprite.width = this._size[0]; + this._backgroundSprite.height = this._size[1]; + this._backgroundSprite.anchor.set(.5); + this._stimsContainer = new PIXI.Container(); + this._stimsContainer.sortableChildren = true; + // create a top-level PIXI container: this._rootContainer = new PIXI.Container(); + this._rootContainer.addChild(this._backgroundSprite, this._stimsContainer); + + // sorts children according to their zIndex value. Higher zIndex means it will be moved towards the end of the array, + // and thus rendered on top of previous one. + this._rootContainer.sortableChildren = true; + this._rootContainer.interactive = true; + this._rootContainer.filters = [this._adjustmentFilter]; // set the initial size of the PIXI renderer and the position of the root container: Window._resizePixiRenderer(this); @@ -487,11 +538,10 @@

Source: core/Window.js

Window._resizePixiRenderer(this, e); this._fullRefresh(); }; - window.addEventListener('resize', this._resizeCallback); - window.addEventListener('orientationchange', this._resizeCallback); + window.addEventListener("resize", this._resizeCallback); + window.addEventListener("orientationchange", this._resizeCallback); } - /** * Adjust the size of the renderer and the position of the root container * in response to a change in the browser's size. @@ -504,17 +554,17 @@

Source: core/Window.js

*/ static _resizePixiRenderer(pjsWindow, event) { - pjsWindow._psychoJS.logger.debug('resizing Window: ', pjsWindow._name, 'event:', JSON.stringify(event)); + pjsWindow._psychoJS.logger.debug("resizing Window: ", pjsWindow._name, "event:", JSON.stringify(event)); // update the size of the PsychoJS Window: pjsWindow._size[0] = window.innerWidth; pjsWindow._size[1] = window.innerHeight; // update the PIXI renderer: - pjsWindow._renderer.view.style.width = pjsWindow._size[0] + 'px'; - pjsWindow._renderer.view.style.height = pjsWindow._size[1] + 'px'; - pjsWindow._renderer.view.style.left = '0px'; - pjsWindow._renderer.view.style.top = '0px'; + pjsWindow._renderer.view.style.width = pjsWindow._size[0] + "px"; + pjsWindow._renderer.view.style.height = pjsWindow._size[1] + "px"; + pjsWindow._renderer.view.style.left = "0px"; + pjsWindow._renderer.view.style.top = "0px"; pjsWindow._renderer.resize(pjsWindow._size[0], pjsWindow._size[1]); // setup the container such that (0,0) is at the centre of the window @@ -524,7 +574,6 @@

Source: core/Window.js

pjsWindow._rootContainer.scale.y = -1; } - /** * Send all logged messages to the {@link Logger}. * @@ -542,7 +591,6 @@

Source: core/Window.js

this._msgToBeLogged = []; } - } @@ -554,13 +602,13 @@

Source: core/Window.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/core_WindowMixin.js.html b/docs/core_WindowMixin.js.html index ab3a1765..bad7c8ee 100644 --- a/docs/core_WindowMixin.js.html +++ b/docs/core_WindowMixin.js.html @@ -35,7 +35,6 @@

Source: core/WindowMixin.js

* @license Distributed under the terms of the MIT License */ - /** * <p>This mixin implements various unit-handling measurement methods.</p> * @@ -47,16 +46,15 @@

Source: core/WindowMixin.js

* @mixin * */ -export let WindowMixin = (superclass) => class extends superclass -{ - constructor(args) +export let WindowMixin = (superclass) => + class extends superclass { - super(args); - } - - + constructor(args) + { + super(args); + } - /** + /** * Convert the given length from stimulus unit to pixel units. * * @name module:core.WindowMixin#_getLengthPix @@ -66,85 +64,83 @@

Source: core/WindowMixin.js

* @param {boolean} [integerCoordinates = false] - whether or not to round the length. * @return {number} - the length in pixel units */ - _getLengthPix(length, integerCoordinates = false) - { - let response = { - origin: 'WindowMixin._getLengthPix', - context: 'when converting a length from stimulus unit to pixel units' - }; - - let length_px; - - if (this._units === 'pix') - { - length_px = length; - } - else if (typeof this._units === 'undefined' || this._units === 'norm') - { - var winSize = this.win.size; - length_px = length * winSize[1] / 2; // TODO: how do we handle norm when width != height? - } - else if (this._units === 'height') - { - const minSize = Math.min(this.win.size[0], this.win.size[1]); - length_px = length * minSize; - } - else - { - // throw { ...response, error: 'unable to deal with unit: ' + this._units }; - throw Object.assign(response, {error: 'unable to deal with unit: ' + this._units}); - } - - if (integerCoordinates) - { - return Math.round(length_px); - } - else - { - return length_px; - } - } - - - /** - * Convert the given length from pixel units to the stimulus units - * - * @name module:core.WindowMixin#_getLengthUnits - * @function - * @protected - * @param {number} length_px - the length in pixel units - * @return {number} - the length in stimulus units - */ - _getLengthUnits(length_px) - { - let response = { - origin: 'WindowMixin._getLengthUnits', - context: 'when converting a length from pixel unit to stimulus units' - }; - - if (this._units === 'pix') - { - return length_px; - } - else if (typeof this._units === 'undefined' || this._units === 'norm') - { - const winSize = this.win.size; - return length_px / (winSize[1] / 2); // TODO: how do we handle norm when width != height? - } - else if (this._units === 'height') - { - const minSize = Math.min(this.win.size[0], this.win.size[1]); - return length_px / minSize; - } - else - { - // throw { ...response, error: 'unable to deal with unit: ' + this._units }; - throw Object.assign(response, {error: 'unable to deal with unit: ' + this._units}); - } - } - - - /** + _getLengthPix(length, integerCoordinates = false) + { + let response = { + origin: "WindowMixin._getLengthPix", + context: "when converting a length from stimulus unit to pixel units", + }; + + let length_px; + + if (this._units === "pix") + { + length_px = length; + } + else if (typeof this._units === "undefined" || this._units === "norm") + { + var winSize = this.win.size; + length_px = length * winSize[1] / 2; // TODO: how do we handle norm when width != height? + } + else if (this._units === "height") + { + const minSize = Math.min(this.win.size[0], this.win.size[1]); + length_px = length * minSize; + } + else + { + // throw { ...response, error: 'unable to deal with unit: ' + this._units }; + throw Object.assign(response, { error: "unable to deal with unit: " + this._units }); + } + + if (integerCoordinates) + { + return Math.round(length_px); + } + else + { + return length_px; + } + } + + /** + * Convert the given length from pixel units to the stimulus units + * + * @name module:core.WindowMixin#_getLengthUnits + * @function + * @protected + * @param {number} length_px - the length in pixel units + * @return {number} - the length in stimulus units + */ + _getLengthUnits(length_px) + { + let response = { + origin: "WindowMixin._getLengthUnits", + context: "when converting a length from pixel unit to stimulus units", + }; + + if (this._units === "pix") + { + return length_px; + } + else if (typeof this._units === "undefined" || this._units === "norm") + { + const winSize = this.win.size; + return length_px / (winSize[1] / 2); // TODO: how do we handle norm when width != height? + } + else if (this._units === "height") + { + const minSize = Math.min(this.win.size[0], this.win.size[1]); + return length_px / minSize; + } + else + { + // throw { ...response, error: 'unable to deal with unit: ' + this._units }; + throw Object.assign(response, { error: "unable to deal with unit: " + this._units }); + } + } + + /** * Convert the given length from stimulus units to pixel units * * @name module:core.WindowMixin#_getHorLengthPix @@ -153,35 +149,35 @@

Source: core/WindowMixin.js

* @param {number} length - the length in stimulus units * @return {number} - the length in pixels */ - _getHorLengthPix(length) - { - let response = { - origin: 'WindowMixin._getHorLengthPix', - context: 'when converting a length from stimulus units to pixel units' - }; - - if (this._units === 'pix') - { - return length; - } - else if (typeof this._units === 'undefined' || this._units === 'norm') - { - var winSize = this.win.size; - return length * winSize[0] / 2; - } - else if (this._units === 'height') - { - const minSize = Math.min(this.win.size[0], this.win.size[1]); - return length * minSize; - } - else - { - // throw { ...response, error: 'unable to deal with unit: ' + this._units }; - throw Object.assign(response, {error: 'unable to deal with unit: ' + this._units}); - } - } - - /** + _getHorLengthPix(length) + { + let response = { + origin: "WindowMixin._getHorLengthPix", + context: "when converting a length from stimulus units to pixel units", + }; + + if (this._units === "pix") + { + return length; + } + else if (typeof this._units === "undefined" || this._units === "norm") + { + var winSize = this.win.size; + return length * winSize[0] / 2; + } + else if (this._units === "height") + { + const minSize = Math.min(this.win.size[0], this.win.size[1]); + return length * minSize; + } + else + { + // throw { ...response, error: 'unable to deal with unit: ' + this._units }; + throw Object.assign(response, { error: "unable to deal with unit: " + this._units }); + } + } + + /** * Convert the given length from pixel units to the stimulus units * * @name module:core.WindowMixin#_getVerLengthPix @@ -190,35 +186,34 @@

Source: core/WindowMixin.js

* @param {number} length - the length in pixel units * @return {number} - the length in stimulus units */ - _getVerLengthPix(length) - { - let response = { - origin: 'WindowMixin._getVerLengthPix', - context: 'when converting a length from pixel unit to stimulus units' - }; - - if (this._units === 'pix') - { - return length; - } - else if (typeof this._units === 'undefined' || this._units === 'norm') - { - var winSize = this.win.size; - return length * winSize[1] / 2; - } - else if (this._units === 'height') - { - const minSize = Math.min(this.win.size[0], this.win.size[1]); - return length * minSize; - } - else - { - // throw { ...response, error: 'unable to deal with unit: ' + this._units }; - throw Object.assign(response, {error: 'unable to deal with unit: ' + this._units}); - } - } - -}; + _getVerLengthPix(length) + { + let response = { + origin: "WindowMixin._getVerLengthPix", + context: "when converting a length from pixel unit to stimulus units", + }; + + if (this._units === "pix") + { + return length; + } + else if (typeof this._units === "undefined" || this._units === "norm") + { + var winSize = this.win.size; + return length * winSize[1] / 2; + } + else if (this._units === "height") + { + const minSize = Math.min(this.win.size[0], this.win.size[1]); + return length * minSize; + } + else + { + // throw { ...response, error: 'unable to deal with unit: ' + this._units }; + throw Object.assign(response, { error: "unable to deal with unit: " + this._units }); + } + } + }; @@ -229,13 +224,13 @@

Source: core/WindowMixin.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/data_ExperimentHandler.js.html b/docs/data_ExperimentHandler.js.html index dc6182f8..90ee11fe 100644 --- a/docs/data_ExperimentHandler.js.html +++ b/docs/data_ExperimentHandler.js.html @@ -35,12 +35,10 @@

Source: data/ExperimentHandler.js

* @license Distributed under the terms of the MIT License */ - -import * as XLSX from 'xlsx'; -import {PsychObject} from '../util/PsychObject'; -import {MonotonicClock} from '../util/Clock'; -import * as util from '../util/Util'; - +import * as XLSX from "xlsx"; +import { MonotonicClock } from "../util/Clock.js"; +import { PsychObject } from "../util/PsychObject.js"; +import * as util from "../util/Util.js"; /** * <p>An ExperimentHandler keeps track of multiple loops and handlers. It is particularly useful @@ -57,11 +55,10 @@

Source: data/ExperimentHandler.js

*/ export class ExperimentHandler extends PsychObject { - /** * Getter for experimentEnded. * - * @name module:core.Window#experimentEnded + * @name module:data.ExperimentHandler#experimentEnded * @function * @public */ @@ -73,7 +70,7 @@

Source: data/ExperimentHandler.js

/** * Setter for experimentEnded. * - * @name module:core.Window#experimentEnded + * @name module:data.ExperimentHandler#experimentEnded * @function * @public */ @@ -82,7 +79,6 @@

Source: data/ExperimentHandler.js

this._experimentEnded = ended; } - /** * Legacy experiment getters. */ @@ -96,16 +92,36 @@

Source: data/ExperimentHandler.js

return this._trialsData; } - constructor({ - psychoJS, - name, - extraInfo - } = {}) + psychoJS, + name, + extraInfo, + dataFileName + } = {}) { super(psychoJS, name); - this._addAttribute('extraInfo', extraInfo); + this._addAttribute("extraInfo", extraInfo); + + // process the extra info: + this._experimentName = (typeof extraInfo.expName === "string" && extraInfo.expName.length > 0) + ? extraInfo.expName + : this.psychoJS.config.experiment.name; + this._participant = (typeof extraInfo.participant === "string" && extraInfo.participant.length > 0) + ? extraInfo.participant + : "PARTICIPANT"; + this._session = (typeof extraInfo.session === "string" && extraInfo.session.length > 0) + ? extraInfo.session + : "SESSION"; + this._datetime = (typeof extraInfo.date !== "undefined") + ? extraInfo.date + : MonotonicClock.getDateStr(); + + this._addAttribute( + "dataFileName", + dataFileName, + `${this._participant}_${this._experimentName}_${this._datetime}` + ); // loop handlers: this._loops = []; @@ -119,7 +135,6 @@

Source: data/ExperimentHandler.js

this._experimentEnded = false; } - /** * Whether or not the current entry (i.e. trial data) is empty. * <p>Note: this is mostly useful at the end of an experiment, in order to ensure that the last entry is saved.</p> @@ -128,13 +143,13 @@

Source: data/ExperimentHandler.js

* @function * @public * @returns {boolean} whether or not the current entry is empty + * @todo This really should be renamed: IsCurrentEntryNotEmpty */ isEntryEmpty() { return (Object.keys(this._currentTrialData).length > 0); } - /** * Add a loop. * @@ -153,7 +168,6 @@

Source: data/ExperimentHandler.js

loop.experimentHandler = this; } - /** * Remove the given loop from the list of unfinished loops, e.g. when it has completed. * @@ -171,7 +185,6 @@

Source: data/ExperimentHandler.js

} } - /** * Add the key/value pair. * @@ -200,7 +213,6 @@

Source: data/ExperimentHandler.js

this._currentTrialData[key] = value; } - /** * Inform this ExperimentHandler that the current trial has ended. Further calls to {@link addData} * will be associated with the next trial. @@ -208,11 +220,11 @@

Source: data/ExperimentHandler.js

* @name module:data.ExperimentHandler#nextEntry * @function * @public - * @param {Object[]} snapshots - array of loop snapshots + * @param {Object | Object[] | undefined} snapshots - array of loop snapshots */ nextEntry(snapshots) { - if (typeof snapshots !== 'undefined') + if (typeof snapshots !== "undefined") { // turn single snapshot into a one-element array: if (!Array.isArray(snapshots)) @@ -231,7 +243,6 @@

Source: data/ExperimentHandler.js

} } } - } // this is to support legacy generated JavaScript code and does not properly handle // loops within loops: @@ -264,7 +275,6 @@

Source: data/ExperimentHandler.js

this._currentTrialData = {}; } - /** * Save the results of the experiment. * @@ -279,16 +289,20 @@

Source: data/ExperimentHandler.js

* @public * @param {Object} options * @param {Array.<Object>} [options.attributes] - the attributes to be saved - * @param {Array.<Object>} [options.sync] - whether or not to communicate with the server in a synchronous manner + * @param {boolean} [options.sync=false] - whether or not to communicate with the server in a synchronous manner + * @param {string} [options.tag=''] - an optional tag to add to the filename to which the data is saved (for CSV and XLSX saving options) + * @param {boolean} [options.clear=false] - whether or not to clear all experiment results immediately after they are saved (this is useful when saving data in separate chunks, throughout an experiment) */ async save({ - attributes = [], - sync = false - } = {}) + attributes = [], + sync = false, + tag = "", + clear = false + } = {}) { - this._psychoJS.logger.info('[PsychoJS] Save experiment results.'); + this._psychoJS.logger.info("[PsychoJS] Save experiment results."); - // (*) get attributes: + // get attributes: if (attributes.length === 0) { attributes = this._trialsKeys.slice(); @@ -314,74 +328,82 @@

Source: data/ExperimentHandler.js

} } + let data = this._trialsData; + // if the experiment data have to be cleared, we first make a copy of them: + if (clear) + { + data = this._trialsData.slice(); + this._trialsData = []; + } - // (*) get various experiment info: - const info = this.extraInfo; - const __experimentName = (typeof info.expName !== 'undefined') ? info.expName : this.psychoJS.config.experiment.name; - const __participant = ((typeof info.participant === 'string' && info.participant.length > 0) ? info.participant : 'PARTICIPANT'); - const __session = ((typeof info.session === 'string' && info.session.length > 0) ? info.session : 'SESSION'); - const __datetime = ((typeof info.date !== 'undefined') ? info.date : MonotonicClock.getDateStr()); - const gitlabConfig = this._psychoJS.config.gitlab; - const __projectId = (typeof gitlabConfig !== 'undefined' && typeof gitlabConfig.projectId !== 'undefined') ? gitlabConfig.projectId : undefined; - - - // (*) save to a .csv file: + // save to a .csv file: if (this._psychoJS.config.experiment.saveFormat === ExperimentHandler.SaveFormat.CSV) { // note: we use the XLSX library as it automatically deals with header, takes care of quotes, // newlines, etc. - const worksheet = XLSX.utils.json_to_sheet(this._trialsData); + // TODO only save the given attributes + const worksheet = XLSX.utils.json_to_sheet(data); // prepend BOM - const csv = '\ufeff' + XLSX.utils.sheet_to_csv(worksheet); + const csv = "\ufeff" + XLSX.utils.sheet_to_csv(worksheet); // upload data to the pavlovia server or offer them for download: - const key = __participant + '_' + __experimentName + '_' + __datetime + '.csv'; - if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER && - this._psychoJS.config.experiment.status === 'RUNNING' && - !this._psychoJS._serverMsg.has('__pilotToken')) + const filenameWithoutPath = this._dataFileName.split(/[\\/]/).pop(); + const key = `${filenameWithoutPath}${tag}.csv`; + if ( + this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER + && this._psychoJS.config.experiment.status === "RUNNING" + && !this._psychoJS._serverMsg.has("__pilotToken") + ) { return /*await*/ this._psychoJS.serverManager.uploadData(key, csv, sync); } else { - util.offerDataForDownload(key, csv, 'text/csv'); + util.offerDataForDownload(key, csv, "text/csv"); } } - - - // (*) save in the database on the remote server: + // save to the database on the pavlovia server: else if (this._psychoJS.config.experiment.saveFormat === ExperimentHandler.SaveFormat.DATABASE) { + const gitlabConfig = this._psychoJS.config.gitlab; + const __projectId = (typeof gitlabConfig !== "undefined" && typeof gitlabConfig.projectId !== "undefined") ? gitlabConfig.projectId : undefined; + let documents = []; - for (let r = 0; r < this._trialsData.length; r++) + for (let r = 0; r < data.length; r++) { - let doc = {__projectId, __experimentName, __participant, __session, __datetime}; + let doc = { + __projectId, + __experimentName: this._experimentName, + __participant: this._participant, + __session: this._session, + __datetime: this._datetime + }; for (let h = 0; h < attributes.length; h++) { - doc[attributes[h]] = this._trialsData[r][attributes[h]]; + doc[attributes[h]] = data[r][attributes[h]]; } documents.push(doc); } // upload data to the pavlovia server or offer them for download: - if (this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER && - this._psychoJS.config.experiment.status === 'RUNNING' && - !this._psychoJS._serverMsg.has('__pilotToken')) + if ( + this._psychoJS.getEnvironment() === ExperimentHandler.Environment.SERVER + && this._psychoJS.config.experiment.status === "RUNNING" + && !this._psychoJS._serverMsg.has("__pilotToken") + ) { - const key = 'results'; // name of the mongoDB collection + const key = "results"; // name of the mongoDB collection return /*await*/ this._psychoJS.serverManager.uploadData(key, JSON.stringify(documents), sync); } else { - util.offerDataForDownload('results.json', JSON.stringify(documents), 'application/json'); + util.offerDataForDownload("results.json", JSON.stringify(documents), "application/json"); } - } } - /** * Get the attribute names and values for the current trial of a given loop. * <p> Only info relating to the trial execution are returned.</p> @@ -395,20 +417,20 @@

Source: data/ExperimentHandler.js

static _getLoopAttributes(loop) { // standard trial attributes: - const properties = ['thisRepN', 'thisTrialN', 'thisN', 'thisIndex', 'stepSizeCurrent', 'ran', 'order']; + const properties = ["thisRepN", "thisTrialN", "thisN", "thisIndex", "stepSizeCurrent", "ran", "order"]; let attributes = {}; const loopName = loop.name; for (const loopProperty in loop) { if (properties.includes(loopProperty)) { - const key = (loopProperty === 'stepSizeCurrent') ? loopName + '.stepSize' : loopName + '.' + loopProperty; + const key = (loopProperty === "stepSizeCurrent") ? loopName + ".stepSize" : loopName + "." + loopProperty; attributes[key] = loop[loopProperty]; } } // specific trial attributes: - if (typeof loop.getCurrentTrial === 'function') + if (typeof loop.getCurrentTrial === "function") { const currentTrial = loop.getCurrentTrial(); for (const trialProperty in currentTrial) @@ -432,7 +454,7 @@

Source: data/ExperimentHandler.js

else: names.append(loopName+'.thisTrial') vals.append(trial) - + // single StairHandler elif hasattr(loop, 'intensities'): names.append(loopName+'.intensity') @@ -443,10 +465,8 @@

Source: data/ExperimentHandler.js

return attributes; } - } - /** * Experiment result format * @@ -459,15 +479,14 @@

Source: data/ExperimentHandler.js

/** * Results are saved to a .csv file */ - CSV: Symbol.for('CSV'), + CSV: Symbol.for("CSV"), /** * Results are saved to a database */ - DATABASE: Symbol.for('DATABASE') + DATABASE: Symbol.for("DATABASE"), }; - /** * Experiment environment. * @@ -476,8 +495,8 @@

Source: data/ExperimentHandler.js

* @public */ ExperimentHandler.Environment = { - SERVER: Symbol.for('SERVER'), - LOCAL: Symbol.for('LOCAL') + SERVER: Symbol.for("SERVER"), + LOCAL: Symbol.for("LOCAL"), }; @@ -489,13 +508,13 @@

Source: data/ExperimentHandler.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/data_MultiStairHandler.js.html b/docs/data_MultiStairHandler.js.html new file mode 100644 index 00000000..711d9ee4 --- /dev/null +++ b/docs/data_MultiStairHandler.js.html @@ -0,0 +1,473 @@ + + + + + JSDoc: Source: data/MultiStairHandler.js + + + + + + + + + + +
+ +

Source: data/MultiStairHandler.js

+ + + + + + +
+
+
/** @module data */
+/**
+ * Multiple Staircase Trial Handler
+ *
+ * @author Alain Pitiot
+ * @version 2021.2.1
+ * @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
+ */
+
+
+import {TrialHandler} from "./TrialHandler.js";
+import {QuestHandler} from "./QuestHandler.js";
+import * as util from "../util/Util.js";
+import seedrandom from "seedrandom";
+
+
+/**
+ * <p>A handler dealing with multiple staircases, simultaneously.</p>
+ *
+ * <p>Note that, at the moment, using the MultiStairHandler requires the jsQuest.js
+ * library to be loaded as a resource, at the start of the experiment.</p>
+ *
+ * @class module.data.MultiStairHandler
+ * @extends TrialHandler
+ * @param {Object} options - the handler options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {string} options.varName - the name of the variable / intensity / contrast
+ * 	/ threshold manipulated by the staircases
+ * @param {module:data.MultiStairHandler.StaircaseType} [options.stairType="simple"] - the
+ * 	handler type
+ * @param {Array.<Object> | String} [options.conditions= [undefined] ] - if it is a string,
+ * 	we treat it as the name of a conditions resource
+ * @param {module:data.TrialHandler.Method} options.method - the trial method
+ * @param {number} [options.nTrials=50] - maximum number of trials
+ * @param {number} options.randomSeed - seed for the random number generator
+ * @param {string} options.name - name of the handler
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ */
+export class MultiStairHandler extends TrialHandler
+{
+	/**
+	 * @constructor
+	 * @public
+	 */
+	constructor({
+		psychoJS,
+		varName,
+		stairType,
+		conditions,
+		method = TrialHandler.Method.RANDOM,
+		nTrials = 50,
+		randomSeed,
+		name,
+		autoLog
+	} = {})
+	{
+		super({
+			psychoJS,
+			name,
+			autoLog,
+			seed: randomSeed,
+			// note: multiStairHandler is a sequential TrialHandler, we deal with randomness
+			// in _nextTrial
+			method: TrialHandler.Method.SEQUENTIAL,
+			trialList: Array(nTrials),
+			nReps: 1
+		});
+
+		// now that we have initialised a sequential TrialHandler, we update method:
+		this._multiMethod = method;
+		this._addAttribute("varName", varName);
+		this._addAttribute("stairType", stairType, MultiStairHandler.StaircaseType.SIMPLE);
+		this._addAttribute("conditions", conditions, [undefined]);
+		this._addAttribute("nTrials", nTrials);
+
+		if (typeof randomSeed !== "undefined")
+		{
+			this._randomNumberGenerator = seedrandom(randomSeed);
+		}
+		else
+		{
+			this._randomNumberGenerator = seedrandom();
+		}
+
+		this._prepareStaircases();
+		this._nextTrial();
+	}
+
+	/**
+	 * Add a response to the current staircase.
+	 *
+	 * @name module:data.MultiStairHandler#addResponse
+	 * @function
+	 * @public
+	 * @param{number} response - the response to the trial, must be either 0 (incorrect or
+	 * non-detected) or 1 (correct or detected)
+	 * @param{number | undefined} [value] - optional intensity / contrast / threshold
+	 * @returns {void}
+	 */
+	addResponse(response, value)
+	{
+		// check that response is either 0 or 1:
+		if (response !== 0 && response !== 1)
+		{
+			throw {
+				origin: "MultiStairHandler.addResponse",
+				context: "when adding a trial response",
+				error: `the response must be either 0 or 1, got: ${JSON.stringify(response)}`
+			};
+		}
+
+		this._psychoJS.experiment.addData(this._name+'.response', response);
+
+		if (!this._finished)
+		{
+			// update the current staircase, but do not add the response again:
+			this._currentStaircase.addResponse(response, value, false);
+
+			// move onto the next trial:
+			this._nextTrial();
+		}
+	}
+
+	/**
+	 * Validate the conditions.
+	 *
+	 * @name module:data.MultiStairHandler#_validateConditions
+	 * @function
+	 * @protected
+	 * @returns {void}
+	 */
+	_validateConditions()
+	{
+		try
+		{
+			// conditions must be a non empty array:
+			if (!Array.isArray(this._conditions) || this._conditions.length === 0)
+			{
+				throw "conditions should be a non empty array of objects";
+			}
+
+			// TODO this is temporary until we have implemented StairHandler:
+			if (this._stairType === MultiStairHandler.StaircaseType.SIMPLE)
+			{
+				throw "'simple' staircases are currently not supported";
+			}
+
+			for (const condition of this._conditions)
+			{
+				// each condition must be an object:
+				if (typeof condition !== "object")
+				{
+					throw "one of the conditions is not an object";
+				}
+
+				// each condition must include certain fields, such as startVal and label:
+				if (!("startVal" in condition))
+				{
+					throw "each condition should include a startVal field";
+				}
+				if (!("label" in condition))
+				{
+					throw "each condition should include a label field";
+				}
+
+				// for QUEST, we also need startValSd:
+				if (this._stairType === MultiStairHandler.StaircaseType.QUEST && !("startValSd" in condition))
+				{
+					throw "QUEST conditions must include a startValSd field";
+				}
+			}
+		}
+		catch (error)
+		{
+			throw {
+				origin: "MultiStairHandler._validateConditions",
+				context: "when validating the conditions",
+				error
+			};
+		}
+	}
+
+	/**
+	 * Setup the staircases, according to the conditions.
+	 *
+	 * @name module:data.MultiStairHandler#_prepareStaircases
+	 * @function
+	 * @protected
+	 * @returns {void}
+	 */
+	_prepareStaircases()
+	{
+		try
+		{
+			this._validateConditions();
+
+			this._staircases = [];
+
+			for (const condition of this._conditions)
+			{
+				let handler;
+
+				// QUEST handler:
+				if (this._stairType === MultiStairHandler.StaircaseType.QUEST)
+				{
+					const args = Object.assign({}, condition);
+					args.psychoJS = this._psychoJS;
+					args.varName = this._varName;
+					// label becomes name:
+					args.name = condition.label;
+					args.autoLog = this._autoLog;
+					if (typeof condition.nTrials === "undefined")
+					{
+						args.nTrials = this._nTrials;
+					}
+
+					handler = new QuestHandler(args);
+				}
+
+				// simple StairCase handler:
+				if (this._stairType === MultiStairHandler.StaircaseType.SIMPLE)
+				{
+					// TODO not supported just yet, an exception is raised in _validateConditions
+					continue;
+				}
+
+				this._staircases.push(handler);
+			}
+
+			this._currentPass = [];
+			this._currentStaircase = null;
+		}
+		catch (error)
+		{
+			throw {
+				origin: "MultiStairHandler._prepareStaircases",
+				context: "when preparing the staircases",
+				error
+			};
+		}
+	}
+
+	/**
+	 * Move onto the next trial.
+	 *
+	 * @name module:data.MultiStairHandler#_nextTrial
+	 * @function
+	 * @protected
+	 * @returns {void}
+	 */
+	_nextTrial()
+	{
+		try
+		{
+			// if the current pass is empty, get a new one:
+			if (this._currentPass.length === 0)
+			{
+				this._currentPass = this._staircases.filter( handler => !handler.finished );
+
+				if (this._multiMethod === TrialHandler.Method.SEQUENTIAL)
+				{
+					// nothing to do
+				}
+				else if (this._multiMethod === TrialHandler.Method.RANDOM)
+				{
+					this._currentPass = util.shuffle(this._currentPass, this._randomNumberGenerator);
+				}
+				else if (this._multiMethod === TrialHandler.Method.FULL_RANDOM)
+				{
+					if (this._currentPass.length > 0)
+					{
+						// select a handler at random:
+						const index = Math.floor(this._randomNumberGenerator() * this._currentPass.length);
+						const handler = this._currentPass[index];
+						this._currentPass = [handler];
+					}
+				}
+			}
+
+
+			// pick the next staircase in the pass:
+			this._currentStaircase = this._currentPass.shift();
+
+
+			// test for termination:
+			if (typeof this._currentStaircase === "undefined")
+			{
+				this._finished = true;
+
+				// update the snapshots associated with the current trial in the trial list:
+				for (let t = 0; t < this._snapshots.length - 1; ++t)
+				{
+					// the current trial is the last defined one:
+					if (typeof this._trialList[t + 1] === "undefined")
+					{
+						this._snapshots[t].finished = true;
+						break;
+					}
+				}
+
+				return;
+			}
+
+
+			// get the value, based on the type of the trial handler:
+			let value = Number.MIN_VALUE;
+			if (this._currentStaircase instanceof QuestHandler)
+			{
+				value = this._currentStaircase.getQuestValue();
+			}
+			// TODO add a test for simple staircase:
+			// if (this._currentStaircase instanceof StaircaseHandler)
+			// {
+			// value = this._currentStaircase.getStairValue();
+			// }
+
+
+			this._psychoJS.logger.debug(`selected staircase: ${this._currentStaircase.name}, estimated value for variable ${this._varName}: ${value}`);
+
+
+			// update the next undefined trial in the trial list, and the associated snapshot:
+			for (let t = 0; t < this._trialList.length; ++t)
+			{
+				if (typeof this._trialList[t] === "undefined")
+				{
+					this._trialList[t] = {
+						[this._name+"."+this._varName]: value,
+						[this._name+".intensity"]: value
+					};
+					for (const attribute of this._currentStaircase._userAttributes)
+					{
+						// "name" becomes "label" again:
+						if (attribute === "name")
+						{
+							this._trialList[t][this._name+".label"] = this._currentStaircase["_name"];
+						}
+						else if (attribute !== "trialList" && attribute !== "extraInfo")
+						{
+							this._trialList[t][this._name+"."+attribute] = this._currentStaircase["_" + attribute];
+						}
+					}
+
+					if (typeof this._snapshots[t] !== "undefined")
+					{
+						let fieldName = this._name + "." + this._varName;
+						this._snapshots[t][fieldName] = value;
+						this._snapshots[t].trialAttributes.push(fieldName);
+						fieldName = this._name + ".intensity";
+						this._snapshots[t][fieldName] = value;
+						this._snapshots[t].trialAttributes.push(fieldName);
+
+						for (const attribute of this._currentStaircase._userAttributes)
+						{
+							// "name" becomes "label" again:
+							if (attribute === 'name')
+							{
+								fieldName = this._name + ".label";
+								this._snapshots[t][fieldName] = this._currentStaircase["_name"];
+								this._snapshots[t].trialAttributes.push(fieldName);
+							}
+							else if (attribute !== 'trialList' && attribute !== 'extraInfo')
+							{
+								fieldName = this._name+"."+attribute;
+								this._snapshots[t][fieldName] = this._currentStaircase["_" + attribute];
+								this._snapshots[t].trialAttributes.push(fieldName);
+							}
+						}
+					}
+					break;
+				}
+			}
+		}
+		catch (error)
+		{
+			throw {
+				origin: "MultiStairHandler._nextTrial",
+				context: "when moving onto the next trial",
+				error
+			};
+		}
+	}
+}
+
+/**
+ * MultiStairHandler staircase type.
+ *
+ * @enum {Symbol}
+ * @readonly
+ * @public
+ */
+MultiStairHandler.StaircaseType = {
+	/**
+	 * Simple staircase handler.
+	 */
+	SIMPLE: Symbol.for("SIMPLE"),
+
+	/**
+	 * QUEST handler.
+	 */
+	QUEST: Symbol.for("QUEST")
+};
+
+/**
+ * Staircase status.
+ *
+ * @enum {Symbol}
+ * @readonly
+ * @public
+ */
+MultiStairHandler.StaircaseStatus = {
+	/**
+	 * The staircase is currently running.
+	 */
+	RUNNING: Symbol.for("RUNNING"),
+
+	/**
+	 * The staircase is now finished.
+	 */
+	FINISHED: Symbol.for("FINISHED")
+};
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + diff --git a/docs/data_QuestHandler.js.html b/docs/data_QuestHandler.js.html new file mode 100644 index 00000000..e7efbadf --- /dev/null +++ b/docs/data_QuestHandler.js.html @@ -0,0 +1,443 @@ + + + + + JSDoc: Source: data/QuestHandler.js + + + + + + + + + + +
+ +

Source: data/QuestHandler.js

+ + + + + + +
+
+
/** @module data */
+/**
+ * Quest Trial Handler
+ *
+ * @author Alain Pitiot & Thomas Pronk
+ * @version 2021.2.0
+ * @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
+ */
+
+
+import {TrialHandler} from "./TrialHandler.js";
+
+/**
+ * <p>A Trial Handler that implements the Quest algorithm for quick measurement of
+    psychophysical thresholds. QuestHandler relies on the [jsQuest]{@link https://github.com/kurokida/jsQUEST} library, a port of Prof Dennis Pelli's QUEST algorithm by [Daiichiro Kuroki]{@link https://github.com/kurokida}.</p>
+ *
+ * @class module.data.QuestHandler
+ * @extends TrialHandler
+ * @param {Object} options - the handler options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {string} options.varName - the name of the variable / intensity / contrast / threshold manipulated by QUEST
+ * @param {number} options.startVal - initial guess for the threshold
+ * @param {number} options.startValSd - standard deviation of the initial guess
+ * @param {number} options.minVal - minimum value for the threshold
+ * @param {number} options.maxVal - maximum value for the threshold
+ * @param {number} [options.pThreshold=0.82] - threshold criterion expressed as probability of getting a correct response
+ * @param {number} options.nTrials - maximum number of trials
+ * @param {number} options.stopInterval - minimum [5%, 95%] confidence interval required for the loop to stop
+ * @param {module:data.QuestHandler.Method} options.method - the QUEST method
+ * @param {number} [options.beta=3.5] - steepness of the QUEST psychometric function
+ * @param {number} [options.delta=0.01] - fraction of trials with blind responses
+ * @param {number} [options.gamma=0.5] - fraction of trails that would generate a correct response when the threshold is infinitely small
+ * @param {number} [options.grain=0.01] - quantization of the internal table
+ * @param {string} options.name - name of the handler
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ */
+export class QuestHandler extends TrialHandler
+{
+	/**
+	 * @constructor
+	 * @public
+	 */
+	constructor({
+		psychoJS,
+		varName,
+		startVal,
+		startValSd,
+		minVal,
+		maxVal,
+		pThreshold,
+		nTrials,
+		stopInterval,
+		method,
+		beta,
+		delta,
+		gamma,
+		grain,
+		name,
+		autoLog
+	} = {})
+	{
+		super({
+			psychoJS,
+			name,
+			autoLog,
+			method: TrialHandler.Method.SEQUENTIAL,
+			trialList: Array(nTrials),
+			nReps: 1
+		});
+
+		this._addAttribute("varName", varName);
+		this._addAttribute("startVal", startVal);
+		this._addAttribute("minVal", minVal, Number.MIN_VALUE);
+		this._addAttribute("maxVal", maxVal, Number.MAX_VALUE);
+		this._addAttribute("startValSd", startValSd);
+		this._addAttribute("pThreshold", pThreshold, 0.82);
+		this._addAttribute("nTrials", nTrials);
+		this._addAttribute("stopInterval", stopInterval, Number.MIN_VALUE);
+		this._addAttribute("beta", beta, 3.5);
+		this._addAttribute("delta", delta, 0.01);
+		this._addAttribute("gamma", gamma, 0.5);
+		this._addAttribute("grain", grain, 0.01);
+		this._addAttribute("method", method, QuestHandler.Method.QUANTILE);
+
+		// setup jsQuest:
+		this._setupJsQuest();
+		this._estimateQuestValue();
+	}
+
+	/**
+	 * Setter for the method attribute.
+	 *
+	 * @param {mixed} method - the method value, PsychoPy-style values ("mean", "median", 
+	 * "quantile") are converted to their respective QuestHandler.Method values
+	 * @param {boolean} log - whether or not to log the change of seed
+	 */
+	 setMethod(method, log)
+	 {
+		let methodMapping = {
+			"quantile": QuestHandler.Method.QUANTILE,
+			"mean": QuestHandler.Method.MEAN,
+			"mode": QuestHandler.Method.MODE
+		};
+		// If method is a key in methodMapping, convert method to corresponding value
+		if (methodMapping.hasOwnProperty(method)) 
+		{
+			method = methodMapping[method];
+		}
+		this._setAttribute("method", method, log);
+	}
+
+	/**
+	 * Add a response and update the PDF.
+	 *
+	 * @name module:data.QuestHandler#addResponse
+	 * @function
+	 * @public
+	 * @param{number} response	- the response to the trial, must be either 0 (incorrect or
+	 * non-detected) or 1 (correct or detected)
+	 * @param{number | undefined} value - optional intensity / contrast / threshold
+	 * @param{boolean} [doAddData = true] - whether or not to add the response as data to the
+	 * 	experiment
+	 * @returns {void}
+	 */
+	addResponse(response, value, doAddData = true)
+	{
+		// check that response is either 0 or 1:
+		if (response !== 0 && response !== 1)
+		{
+			throw {
+				origin: "QuestHandler.addResponse",
+				context: "when adding a trial response",
+				error: `the response must be either 0 or 1, got: ${JSON.stringify(response)}`
+			};
+		}
+
+		if (doAddData)
+		{
+			this._psychoJS.experiment.addData(this._name + '.response', response);
+		}
+
+		// update the QUEST pdf:
+		if (typeof value !== "undefined")
+		{
+			this._jsQuest = jsQUEST.QuestUpdate(this._jsQuest, value, response);
+		}
+		else
+		{
+			this._jsQuest = jsQUEST.QuestUpdate(this._jsQuest, this._questValue, response);
+		}
+
+		if (!this._finished)
+		{
+			this.next();
+
+			// estimate the next value of the QUEST variable
+			// (and update the trial list and snapshots):
+			this._estimateQuestValue();
+		}
+	}
+
+	/**
+	 * Simulate a response.
+	 *
+	 * @name module:data.QuestHandler#simulate
+	 * @function
+	 * @public
+	 * @param{number} trueValue - the true, known value of the threshold / contrast / intensity
+	 * @returns{number} the simulated response, 0 or 1
+	 */
+	simulate(trueValue)
+	{
+		const response = jsQUEST.QuestSimulate(this._jsQuest, this._questValue, trueValue);
+
+		// restrict to limits:
+		this._questValue = Math.max(this._minVal, Math.min(this._maxVal, this._questValue));
+
+		this._psychoJS.logger.debug(`simulated response: ${response}`);
+
+		return response;
+	}
+
+	/**
+	 * Get the mean of the Quest posterior PDF.
+	 *
+	 * @name module:data.QuestHandler#mean
+	 * @function
+	 * @public
+	 * @returns {number} the mean
+	 */
+	mean()
+	{
+		return jsQUEST.QuestMean(this._jsQuest);
+	}
+
+	/**
+	 * Get the standard deviation of the Quest posterior PDF.
+	 *
+	 * @name module:data.QuestHandler#sd
+	 * @function
+	 * @public
+	 * @returns {number} the standard deviation
+	 */
+	sd()
+	{
+		return jsQUEST.QuestSd(this._jsQuest);
+	}
+
+	/**
+	 * Get the mode of the Quest posterior PDF.
+	 *
+	 * @name module:data.QuestHandler#mode
+	 * @function
+	 * @public
+	 * @returns {number} the mode
+	 */
+	mode()
+	{
+		const [mode, pdf] = jsQUEST.QuestMode(this._jsQuest);
+		return mode;
+	}
+
+	/**
+	 * Get the standard deviation of the Quest posterior PDF.
+	 *
+	 * @name module:data.QuestHandler#quantile
+	 * @function
+	 * @public
+	 * @param{number} quantileOrder the quantile order
+	 * @returns {number} the quantile
+	 */
+	quantile(quantileOrder)
+	{
+		return jsQUEST.QuestQuantile(this._jsQuest, quantileOrder);
+	}
+
+	/**
+	 * Get the current value of the variable / contrast / threshold.
+	 *
+	 * @name module:data.QuestHandler#getQuestValue
+	 * @function
+	 * @public
+	 * @returns {number} the current QUEST value for the variable / contrast / threshold
+	 */
+	getQuestValue()
+	{
+		return this._questValue;
+	}
+
+	/**
+	 * Get an estimate of the 5%-95% confidence interval (CI).
+	 *
+	 * @name module:data.QuestHandler#confInterval
+	 * @function
+	 * @public
+	 * @param{boolean} [getDifference=false] - if true, return the width of the CI instead of the CI
+	 * @returns{number[] | number} the 5%-95% CI or the width of the CI
+	 */
+	confInterval(getDifference = false)
+	{
+		const CI = [
+			jsQUEST.QuestQuantile(this._jsQuest, 0.05),
+			jsQUEST.QuestQuantile(this._jsQuest, 0.95)
+		];
+
+		if (getDifference)
+		{
+			return Math.abs(CI[0] - CI[1]);
+		}
+		else
+		{
+			return CI;
+		}
+	}
+
+	/**
+	 * Setup the JS Quest object.
+	 *
+	 * @name module:data.QuestHandler#_setupJsQuest
+	 * @function
+	 * @protected
+	 * @returns {void}
+	 */
+	_setupJsQuest()
+	{
+		this._jsQuest = jsQUEST.QuestCreate(
+			this._startVal,
+			this._startValSd,
+			this._pThreshold,
+			this._beta,
+			this._delta,
+			this._gamma,
+			this._grain);
+	}
+
+	/**
+	 * Estimate the next value of the QUEST variable, based on the current value
+	 * and on the selected QUEST method.
+	 *
+	 * @name module:data.QuestHandler#_estimateQuestValue
+	 * @function
+	 * @protected
+	 * @returns {void}
+	 */
+	_estimateQuestValue()
+	{
+		// estimate the value based on the chosen QUEST method:
+		if (this._method === QuestHandler.Method.QUANTILE)
+		{
+			this._questValue = jsQUEST.QuestQuantile(this._jsQuest);
+		}
+		else if (this._method === QuestHandler.Method.MEAN)
+		{
+			this._questValue = jsQUEST.QuestMean(this._jsQuest);
+		}
+		else if (this._method === QuestHandler.Method.MODE)
+		{
+			const [mode, pdf] = jsQUEST.QuestMode(this._jsQuest);
+			this._questValue = mode;
+		}
+		else
+		{
+			throw {
+				origin: "QuestHandler._estimateQuestValue",
+				context: "when estimating the next value of the QUEST variable",
+				error: `unknown method: ${this._method}, please use: mean, mode, or quantile`
+			};
+		}
+
+		this._psychoJS.logger.debug(`estimated value for QUEST variable ${this._varName}: ${this._questValue}`);
+
+		// check whether we should finish the trial:
+		if (this.thisN > 0 && (this.nRemaining === 0 || this.confInterval(true) < this._stopInterval))
+		{
+			this._finished = true;
+
+			// update the snapshots associated with the current trial in the trial list:
+			for (let t = 0; t < this._snapshots.length - 1; ++t)
+			{
+				// the current trial is the last defined one:
+				if (typeof this._trialList[t + 1] === "undefined")
+				{
+					this._snapshots[t].finished = true;
+					break;
+				}
+			}
+
+			return;
+		}
+
+		// update the next undefined trial in the trial list, and the associated snapshot:
+		for (let t = 0; t < this._trialList.length; ++t)
+		{
+			if (typeof this._trialList[t] === "undefined")
+			{
+				this._trialList[t] = { [this._varName]: this._questValue };
+
+				if (typeof this._snapshots[t] !== "undefined")
+				{
+					this._snapshots[t][this._varName] = this._questValue;
+					this._snapshots[t].trialAttributes.push(this._varName);
+				}
+				break;
+			}
+		}
+	}
+}
+
+/**
+ * QuestHandler method
+ *
+ * @enum {Symbol}
+ * @readonly
+ * @public
+ */
+QuestHandler.Method = {
+	/**
+	 * Quantile threshold estimate.
+	 */
+	QUANTILE: Symbol.for("QUANTILE"),
+
+	/**
+	 * Mean threshold estimate.
+	 */
+	MEAN: Symbol.for("MEAN"),
+
+	/**
+	 * Mode threshold estimate.
+	 */
+	MODE: Symbol.for("MODE")
+};
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + diff --git a/docs/data_Shelf.js.html b/docs/data_Shelf.js.html new file mode 100644 index 00000000..7134c0dd --- /dev/null +++ b/docs/data_Shelf.js.html @@ -0,0 +1,695 @@ + + + + + JSDoc: Source: data/Shelf.js + + + + + + + + + + +
+ +

Source: data/Shelf.js

+ + + + + + +
+
+
/** @module data */
+/**
+ * Shelf handles persistent key/value pairs, which are stored in the shelf collection on the
+ * server, and accessed in a safe, concurrent fashion.
+ *
+ * @author Alain Pitiot
+ * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @license Distributed under the terms of the MIT License
+ */
+
+import {PsychObject} from "../util/PsychObject.js";
+import { PsychoJS } from "../core/PsychoJS.js";
+import {ExperimentHandler} from "./ExperimentHandler";
+import { Scheduler } from "../util/Scheduler.js";
+
+
+/**
+ * <p>Shelf handles persistent key/value pairs, which are stored in the shelf collection on the
+ * server, and accessed in a safe, concurrent fashion.</p>
+ *
+ * @name module:data.Shelf
+ * @class
+ * @extends PsychObject
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ */
+export class Shelf extends PsychObject
+{
+	/**
+	 * Maximum number of components in a key
+	 * @name module:data.Shelf.#MAX_KEY_LENGTH
+	 * @type {number}
+	 * @note this value should mirror that on the server, i.e. the server also checks that the key is valid
+	 */
+	static #MAX_KEY_LENGTH = 10;
+
+	constructor({psychoJS, autoLog = false } = {})
+	{
+		super(psychoJS);
+
+		this._addAttribute('autoLog', autoLog);
+		this._addAttribute('status', Shelf.Status.READY);
+	}
+
+	/**
+	 * Get the value associated with the given key.
+	 *
+	 * @name module:data.Shelf#getValue
+	 * @function
+	 * @public
+	 * @param {string[]} [key = [] ] 	key as an array of key components
+	 * @param [defaultValue]					default value
+	 * @return {Promise<any>}
+	 */
+	async getValue(key = [], defaultValue)
+	{
+		const response = {
+			origin: 'Shelf.getValue',
+			context: `when getting the value associated with key: ${JSON.stringify(key)}`
+		};
+
+		// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
+
+		try
+		{
+			this._checkAvailability("getValue");
+			this._status = Shelf.Status.BUSY;
+			this._checkKey(key);
+
+			// prepare the request:
+			const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/value`;
+			const data = {
+				key
+			};
+			if (typeof defaultValue !== 'undefined')
+			{
+				data['defaultValue'] = defaultValue;
+			}
+
+			// query the server:
+			const response = await fetch(url, {
+				method: 'PUT',
+				mode: 'cors',
+				cache: 'no-cache',
+				credentials: 'same-origin',
+				redirect: 'follow',
+				referrerPolicy: 'no-referrer',
+				headers: {
+					'Content-Type': 'application/json'
+				},
+				body: JSON.stringify(data)
+			});
+
+			// convert the response to json:
+			const document = await response.json();
+
+			if (response.status !== 200)
+			{
+				throw ('error' in document) ? document['error'] : document;
+			}
+
+			// return the updated value:
+			this._status = Shelf.Status.READY;
+			return document['value'];
+		}
+		catch (error)
+		{
+			this._status = Shelf.Status.ERROR;
+			throw {...response, error};
+		}
+	}
+
+	/**
+	 * Set the value associated with the given key.
+	 *
+	 * <p>This creates a new key/value pair if the key was previously unknown.</p>
+	 *
+	 * @name module:data.Shelf#setValue
+	 * @function
+	 * @public
+	 * @param {string[]} [key = [] ] key as an array of key components
+	 * @param value
+	 * @return {Promise<any>}
+	 */
+	async setValue(key = [], value)
+	{
+		const response = {
+			origin: 'Shelf.setValue',
+			context: `when setting the value associated with key: ${JSON.stringify(key)}`
+		};
+
+		// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
+
+		try
+		{
+			this._checkAvailability("setValue");
+			this._status = Shelf.Status.BUSY;
+			this._checkKey(key);
+
+			// prepare the request:
+			// const componentList = key.reduce((list, component) => list + '+' + component, '');
+			const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/value`;
+			const data = {
+				key,
+				value
+			};
+
+			// query the server:
+			const response = await fetch(url, {
+				method: 'POST',
+				mode: 'cors',
+				cache: 'no-cache',
+				credentials: 'same-origin',
+				redirect: 'follow',
+				referrerPolicy: 'no-referrer',
+				headers: {
+					'Content-Type': 'application/json'
+				},
+				body: JSON.stringify(data)
+			});
+
+			// convert the response to json:
+			const document = await response.json();
+
+			if (response.status !== 200)
+			{
+				throw ('error' in document) ? document['error'] : document;
+			}
+
+			// return the updated value:
+			this._status = Shelf.Status.READY;
+			return document['record']['value'];
+		}
+		catch (error)
+		{
+			this._status = Shelf.Status.ERROR;
+			throw {...response, error};
+		}
+	}
+
+	/**
+	 * Get the names of the fields in the dictionary record associated with the given key.
+	 *
+	 * @name module:data.Shelf#getDictionaryFieldNames
+	 * @function
+	 * @public
+	 * @param {string[]} [key = [] ] key as an array of key components
+	 * @return {Promise<any>}
+	 */
+	async getDictionaryFieldNames(key = [])
+	{
+		const response = {
+			origin: 'Shelf.getDictionaryFieldNames',
+			context: `when getting the names of the fields in the dictionary record associated with key: ${JSON.stringify(key)}`
+		};
+
+		// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
+
+		try
+		{
+			this._checkAvailability("getDictionaryFieldNames");
+			this._status = Shelf.Status.BUSY;
+			this._checkKey(key);
+
+			// prepare the request:
+			const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/dictionary/fields`;
+			const data = {
+				key
+			};
+
+			// query the server:
+			const response = await fetch(url, {
+				method: 'PUT',
+				mode: 'cors',
+				cache: 'no-cache',
+				credentials: 'same-origin',
+				redirect: 'follow',
+				referrerPolicy: 'no-referrer',
+				headers: {
+					'Content-Type': 'application/json'
+				},
+				body: JSON.stringify(data)
+			});
+
+			// convert the response to json:
+			const document = await response.json();
+
+			if (response.status !== 200)
+			{
+				throw ('error' in document) ? document['error'] : document;
+			}
+
+			// return the field names:
+			this._status = Shelf.Status.READY;
+			return document['fieldNames'];
+		}
+		catch (error)
+		{
+			this._status = Shelf.Status.ERROR;
+			throw {...response, error};
+		}
+	}
+
+	/**
+	 * Get the value of a given field in the dictionary record associated with the given key.
+	 *
+	 * @name module:data.Shelf#getDictionaryValue
+	 * @function
+	 * @public
+	 * @param {string[]} [key = [] ] 	key as an array of key components
+	 * @param {string} fieldName			the name of the field
+	 * @param [defaultValue]					default value
+	 * @return {Promise<any>}
+	 */
+	async getDictionaryValue(key = [], fieldName, defaultValue)
+	{
+		const response = {
+			origin: 'Shelf.getDictionaryFieldNames',
+			context: `when getting value of field: ${fieldName} in the dictionary record associated with key: ${JSON.stringify(key)}`
+		};
+
+		// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
+
+		try
+		{
+			this._checkAvailability("getDictionaryValue");
+			this._status = Shelf.Status.BUSY;
+			this._checkKey(key);
+
+			// prepare the request:
+			const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/dictionary/values`;
+			const data = {
+				key,
+				fieldName
+			};
+			if (typeof defaultValue !== 'undefined')
+			{
+				data['defaultValue'] = defaultValue;
+			}
+
+			// query the server:
+			const response = await fetch(url, {
+				method: 'PUT',
+				mode: 'cors',
+				cache: 'no-cache',
+				credentials: 'same-origin',
+				redirect: 'follow',
+				referrerPolicy: 'no-referrer',
+				headers: {
+					'Content-Type': 'application/json'
+				},
+				body: JSON.stringify(data)
+			});
+
+			// convert the response to json:
+			const document = await response.json();
+
+			if (response.status !== 200)
+			{
+				throw ('error' in document) ? document['error'] : document;
+			}
+
+			// return the value:
+			this._status = Shelf.Status.READY;
+			return document['value'];
+		}
+		catch (error)
+		{
+			this._status = Shelf.Status.ERROR;
+			throw {...response, error};
+		}
+	}
+
+	/**
+	 * Set a field in the dictionary record associated to the given key.
+	 *
+	 * @name module:data.Shelf#setDictionaryField
+	 * @function
+	 * @public
+	 * @param {string[]} [key = [] ] key as an array of key components
+	 * @param fieldName
+	 * @param fieldValue
+	 * @return {Promise<any>}
+	 */
+	async setDictionaryField(key = [], fieldName, fieldValue)
+	{
+		const response = {
+			origin: 'Shelf.setDictionaryField',
+			context: `when setting a field with name: ${fieldName} in the dictionary record associated with key: ${JSON.stringify(key)}`
+		};
+
+		// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
+
+		try
+		{
+			this._checkAvailability("setDictionaryField");
+			this._status = Shelf.Status.BUSY;
+			this._checkKey(key);
+
+			// prepare the request:
+			// const componentList = key.reduce((list, component) => list + '+' + component, '');
+			const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/dictionary/fields`;
+			const data = {
+				key,
+				fieldName,
+				fieldValue
+			};
+
+			// query the server:
+			const response = await fetch(url, {
+				method: 'POST',
+				mode: 'cors',
+				cache: 'no-cache',
+				credentials: 'same-origin',
+				redirect: 'follow',
+				referrerPolicy: 'no-referrer',
+				headers: {
+					'Content-Type': 'application/json'
+				},
+				body: JSON.stringify(data)
+			});
+
+			// convert the response to json:
+			const document = await response.json();
+
+			if (response.status !== 200)
+			{
+				throw ('error' in document) ? document['error'] : document;
+			}
+
+			// return the updated value:
+			this._status = Shelf.Status.READY;
+			return document['record']['value'];
+		}
+		catch (error)
+		{
+			this._status = Shelf.Status.ERROR;
+			throw {...response, error};
+		}
+	}
+
+	/**
+	 * Schedulable component that will block the experiment until the counter associated with the given key
+	 * has been incremented by the given amount.
+	 *
+	 * @name module:data.Shelf#incrementComponent
+	 * @function
+	 * @public
+	 * @param key
+	 * @param increment
+	 * @param callback
+	 * @returns {function(): module:util.Scheduler.Event|Symbol|*} a component that can be scheduled
+	 *
+	 * @example
+	 * const flowScheduler = new Scheduler(psychoJS);
+	 * var experimentCounter = '<>';
+	 * flowScheduler.add(psychoJS.shelf.incrementComponent(['counter'], 1, (value) => experimentCounter = value));
+	 */
+	incrementComponent(key = [], increment = 1, callback)
+	{
+		const response = {
+			origin: 'Shelf.incrementComponent',
+			context: 'when making a component to increment a shelf counter'
+		};
+
+		try
+		{
+			// TODO replace this._incrementComponent by a component with a unique name
+			let incrementComponent = {};
+			incrementComponent.status = PsychoJS.Status.NOT_STARTED;
+			return () =>
+			{
+				if (incrementComponent.status === PsychoJS.Status.NOT_STARTED)
+				{
+					incrementComponent.status = PsychoJS.Status.STARTED;
+					this.increment(key, increment)
+						.then( (newValue) =>
+						{
+							callback(newValue);
+							incrementComponent.status = PsychoJS.Status.FINISHED;
+						});
+				}
+
+				return (incrementComponent.status === PsychoJS.Status.FINISHED) ?
+					Scheduler.Event.NEXT :
+					Scheduler.Event.FLIP_REPEAT;
+			};
+		}
+		catch (error)
+		{
+			this._status = Shelf.Status.ERROR;
+			throw {...response, error};
+		}
+	}
+
+	/**
+	 * Increment the integer counter associated with the given key by the given amount.
+	 *
+	 * @name module:data.Shelf#increment
+	 * @function
+	 * @public
+	 * @param {string[]} [key = [] ] key as an array of key components
+	 * @param {number} [increment = 1] increment
+	 * @return {Promise<any>}
+	 */
+	async increment(key = [], increment = 1)
+	{
+		const response = {
+			origin: 'Shelf.increment',
+			context: `when incrementing the integer counter with key: ${JSON.stringify(key)}`
+		};
+
+		// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
+
+		try
+		{
+			this._checkAvailability("increment");
+			this._status = Shelf.Status.BUSY;
+			this._checkKey(key);
+
+			// prepare the request:
+			// const componentList = key.reduce((list, component) => list + '+' + component, '');
+			const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counter`;
+			const data = {
+				key,
+				increment
+			};
+
+			// query the server:
+			const response = await fetch(url, {
+				method: 'POST',
+				mode: 'cors',
+				cache: 'no-cache',
+				credentials: 'same-origin',
+				redirect: 'follow',
+				referrerPolicy: 'no-referrer',
+				headers: {
+					'Content-Type': 'application/json'
+				},
+				body: JSON.stringify(data)
+			});
+
+			// convert the response to json:
+			const document = await response.json();
+
+			if (response.status !== 200)
+			{
+				throw ('error' in document) ? document['error'] : document;
+			}
+
+			// return the updated value:
+			this._status = Shelf.Status.READY;
+			return document['value'];
+		}
+		catch (error)
+		{
+			this._status = Shelf.Status.ERROR;
+			throw {...response, error};
+		}
+	}
+
+	/**
+	 * Get the name of a group, using a counterbalanced design.
+	 *
+	 * @name module:data.Shelf#counterBalanceSelect
+	 * @function
+	 * @public
+	 * @param {string[]} [key = [] ] key as an array of key components
+	 * @param {string[]} groups				the names of the groups
+	 * @param {number[]} groupSizes		the size of the groups
+	 * @return {Promise<any>}
+	 */
+	async counterBalanceSelect(key = [], groups, groupSizes)
+	{
+		const response = {
+			origin: 'Shelf.counterBalanceSelect',
+			context: `when getting the name of a group, using a counterbalanced design, with key: ${JSON.stringify(key)}`
+		};
+
+		// TODO what to do if the status of shelf is currently BUSY? Wait until it is READY again?
+
+		try
+		{
+			this._checkAvailability("counterBalanceSelect");
+			this._status = Shelf.Status.BUSY;
+			this._checkKey(key);
+
+			// prepare the request:
+			// const componentList = key.reduce((list, component) => list + '+' + component, '');
+			const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance`;
+			const data = {
+				key,
+				groups,
+				groupSizes
+			};
+
+			// query the server:
+			const response = await fetch(url, {
+				method: 'PUT',
+				mode: 'cors',
+				cache: 'no-cache',
+				credentials: 'same-origin',
+				redirect: 'follow',
+				referrerPolicy: 'no-referrer',
+				headers: {
+					'Content-Type': 'application/json'
+				},
+				body: JSON.stringify(data)
+			});
+
+			// convert the response to json:
+			const document = await response.json();
+
+			if (response.status !== 200)
+			{
+				throw ('error' in document) ? document['error'] : document;
+			}
+
+			// return the updated value:
+			this._status = Shelf.Status.READY;
+			return [ document['group'], document['finished'] ];
+		}
+		catch (error)
+		{
+			this._status = Shelf.Status.ERROR;
+			throw {...response, error};
+		}
+	}
+
+	/**
+	 * Check whether it is possible to run a given shelf command.
+	 *
+	 * @name module:data.Shelf#_checkAvailability
+	 * @function
+	 * @public
+	 * @param {string} [methodName=""] name of the method requiring a check
+	 * @throw {Object.<string, *>} exception when it is not possible to run shelf commands
+	 */
+	_checkAvailability(methodName = "")
+	{
+		// Shelf requires access to the server, where the key/value pairs are stored:
+		if (this._psychoJS.config.environment !== ExperimentHandler.Environment.SERVER)
+		{
+			throw {
+				origin: 'Shelf._checkAvailability',
+				context: 'when checking whether Shelf is available',
+				error: 'the experiment has to be run on the server: shelf commands are not available locally'
+			}
+		}
+	}
+
+	/**
+	 * Check the validity of the key.
+	 *
+	 * @name module:data.Shelf#_checkKey
+	 * @function
+	 * @public
+	 * @param {object} key key whose validity is to be checked
+	 * @throw {Object.<string, *>} exception when the key is invalid
+	 */
+	_checkKey(key)
+	{
+		// the key must be a non empty array:
+		if (!Array.isArray(key) || key.length === 0)
+		{
+			throw 'the key must be a non empty array';
+		}
+
+		if (key.length > Shelf.#MAX_KEY_LENGTH)
+		{
+			throw 'the key consists of too many components';
+		}
+
+		// the only @<component> in the key should be @designer and @experiment
+		// TODO
+	}
+}
+
+
+/**
+ * Shelf status
+ *
+ * @name module:data.Shelf#Status
+ * @enum {Symbol}
+ * @readonly
+ * @public
+ */
+Shelf.Status = {
+	/**
+	 * The shelf is ready.
+	 */
+	READY: Symbol.for('READY'),
+
+	/**
+	 * The shelf is busy, e.g. storing or retrieving values.
+	 */
+	BUSY: Symbol.for('BUSY'),
+
+	/**
+	 * The shelf has encountered an error.
+	 */
+	ERROR: Symbol.for('ERROR')
+};
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + diff --git a/docs/data_TrialHandler.js.html b/docs/data_TrialHandler.js.html index 84726189..09ea2242 100644 --- a/docs/data_TrialHandler.js.html +++ b/docs/data_TrialHandler.js.html @@ -37,18 +37,17 @@

Source: data/TrialHandler.js

* @license Distributed under the terms of the MIT License */ - -import seedrandom from 'seedrandom'; -import * as XLSX from 'xlsx'; -import {PsychObject} from '../util/PsychObject'; -import * as util from '../util/Util'; +import seedrandom from "seedrandom"; +import * as XLSX from "xlsx"; +import { PsychObject } from "../util/PsychObject.js"; +import * as util from "../util/Util.js"; /** * <p>A Trial Handler handles the importing and sequencing of conditions.</p> * * @class * @extends PsychObject - * @param {Object} options + * @param {Object} options - the handler options * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance * @param {Array.<Object> | String} [options.trialList= [undefined] ] - if it is a string, we treat it as the name of a condition resource * @param {number} options.nReps - number of repetitions @@ -59,7 +58,6 @@

Source: data/TrialHandler.js

*/ export class TrialHandler extends PsychObject { - /** * Getter for experimentHandler. * @@ -84,7 +82,6 @@

Source: data/TrialHandler.js

this._experimentHandler = exp; } - /** * @constructor * @public @@ -92,27 +89,27 @@

Source: data/TrialHandler.js

* @todo extraInfo is not taken into account, we use the expInfo of the ExperimentHandler instead */ constructor({ - psychoJS, - trialList = [undefined], - nReps, - method = TrialHandler.Method.RANDOM, - extraInfo = [], - seed, - name, - autoLog = true - } = {}) + psychoJS, + trialList = [undefined], + nReps, + method = TrialHandler.Method.RANDOM, + extraInfo = [], + seed, + name, + autoLog = true, + } = {}) { super(psychoJS); - this._addAttribute('trialList', trialList); - this._addAttribute('nReps', nReps); - this._addAttribute('method', method); - this._addAttribute('extraInfo', extraInfo); - this._addAttribute('name', name); - this._addAttribute('autoLog', autoLog); - this._addAttribute('seed', seed); - this._prepareTrialList(trialList); - + this._addAttribute("trialList", trialList); + this._addAttribute("nReps", nReps); + this._addAttribute("method", method); + this._addAttribute("extraInfo", extraInfo); + this._addAttribute("name", name); + this._addAttribute("autoLog", autoLog); + this._addAttribute("seed", seed); + this._prepareTrialList(); + // number of stimuli this.nStim = this.trialList.length; @@ -140,7 +137,6 @@

Source: data/TrialHandler.js

// array of current snapshots: this._snapshots = []; - // setup the trial sequence: this._prepareSequence(); @@ -149,18 +145,17 @@

Source: data/TrialHandler.js

this._finished = false; } - /** * Helps go through each trial in the sequence one by one, mirrors PsychoPy. */ - next() { + next() + { const trialIterator = this[Symbol.iterator](); const { value } = trialIterator.next(); return value; } - /** * Iterator over the trial sequence. * @@ -196,7 +191,7 @@

Source: data/TrialHandler.js

if (this.thisRepN >= this.nReps) { this.thisTrial = null; - return {done: true}; + return { done: true }; } this.thisIndex = this._trialSequence[this.thisRepN][this.thisTrialN]; @@ -209,12 +204,11 @@

Source: data/TrialHandler.js

vals = (self.thisRepN, self.thisTrialN, self.thisTrial) logging.exp(msg % vals, obj=self.thisTrial)*/ - return {value: this.thisTrial, done: false}; - } + return { value: this.thisTrial, done: false }; + }, }; } - /** * Execute the callback for each trial in the sequence. * @@ -236,7 +230,6 @@

Source: data/TrialHandler.js

} } - /** * @typedef {Object} Snapshot * @property {TrialHandler} handler - the trialHandler @@ -281,12 +274,12 @@

Source: data/TrialHandler.js

getCurrentTrial: () => this.getTrial(currentIndex), getTrial: (index = 0) => this.getTrial(index), - addData: (key, value) => this.addData(key, value) + addData: (key, value) => this.addData(key, value), }; // add to the snapshots the current trial's attributes: const currentTrial = this.getCurrentTrial(); - const excludedAttributes = ['handler', 'name', 'nStim', 'nRemaining', 'thisRepN', 'thisTrialN', 'thisN', 'thisIndex', 'ran', 'finished']; + const excludedAttributes = ["handler", "name", "nStim", "nRemaining", "thisRepN", "thisTrialN", "thisN", "thisIndex", "ran", "finished"]; const trialAttributes = []; for (const attribute in currentTrial) { @@ -299,8 +292,8 @@

Source: data/TrialHandler.js

{ this._psychoJS.logger.warn(`attempt to replace the value of protected TrialHandler variable: ${attribute}`); } - snapshot.trialAttributes = trialAttributes; } + snapshot.trialAttributes = trialAttributes; // add the snapshot to the list: this._snapshots.push(snapshot); @@ -308,7 +301,6 @@

Source: data/TrialHandler.js

return snapshot; } - /** * Setter for the seed attribute. * @@ -317,9 +309,9 @@

Source: data/TrialHandler.js

*/ setSeed(seed, log) { - this._setAttribute('seed', seed, log); + this._setAttribute("seed", seed, log); - if (typeof seed !== 'undefined') + if (typeof seed !== "undefined") { this._randomNumberGenerator = seedrandom(seed); } @@ -329,7 +321,6 @@

Source: data/TrialHandler.js

} } - /** * Set the internal state of this trial handler from the given snapshot. * @@ -340,7 +331,7 @@

Source: data/TrialHandler.js

static fromSnapshot(snapshot) { // if snapshot is undefined, do nothing: - if (typeof snapshot === 'undefined') + if (typeof snapshot === "undefined") { return; } @@ -357,14 +348,23 @@

Source: data/TrialHandler.js

snapshot.handler.thisTrial = snapshot.handler.getCurrentTrial(); - // add to the trial handler the snapshot's trial attributes: + // add the snapshot's trial attributes to a global variable, whose name is derived from + // that of the handler: loops -> thisLoop (note the dropped s): + let name = snapshot.name; + if (name[name.length - 1] === "s") + { + name = name.substr(0, name.length - 1); + } + name = `this${name[0].toUpperCase()}${name.substr(1)}`; + + const value = {}; for (const attribute of snapshot.trialAttributes) { - snapshot.handler[attribute] = snapshot[attribute]; + value[attribute] = snapshot[attribute]; } + window[name] = value; } - /** * Getter for the finished attribute. * @@ -375,7 +375,6 @@

Source: data/TrialHandler.js

return this._finished; } - /** * Setter for the finished attribute. * @@ -384,14 +383,13 @@

Source: data/TrialHandler.js

set finished(isFinished) { this._finished = isFinished; - - this._snapshots.forEach( snapshot => + + this._snapshots.forEach((snapshot) => { snapshot.finished = isFinished; }); } - /** * Get the trial index. * @@ -403,7 +401,6 @@

Source: data/TrialHandler.js

return this.thisIndex; } - /** * Set the trial index. * @@ -414,7 +411,6 @@

Source: data/TrialHandler.js

this.thisIndex = index; } - /** * Get the attributes of the trials. * @@ -440,7 +436,6 @@

Source: data/TrialHandler.js

return Object.keys(this.trialList[0]); } - /** * Get the current trial. * @@ -452,7 +447,6 @@

Source: data/TrialHandler.js

return this.trialList[this.thisIndex]; } - /** * Get the nth trial. * @@ -469,7 +463,6 @@

Source: data/TrialHandler.js

return this.trialList[index]; } - /** * Get the nth future or past trial, without advancing through the trial list. * @@ -488,7 +481,6 @@

Source: data/TrialHandler.js

return this.trialList[this.thisIndex + n]; } - /** * Get the nth previous trial. * <p> Note: this is useful for comparisons in n-back tasks.</p> @@ -502,7 +494,6 @@

Source: data/TrialHandler.js

return getFutureTrial(-abs(n)); } - /** * Add a key/value pair to data about the current trial held by the experiment handler * @@ -518,7 +509,6 @@

Source: data/TrialHandler.js

} } - /** * Import a list of conditions from a .xls, .xlsx, .odp, or .csv resource. * @@ -558,8 +548,8 @@

Source: data/TrialHandler.js

{ try { - let resourceExtension = resourceName.split('.').pop(); - if (['csv', 'odp', 'xls', 'xlsx'].indexOf(resourceExtension) > -1) + const resourceExtension = resourceName.split(".").pop(); + if (["csv", "odp", "xls", "xlsx"].indexOf(resourceExtension) > -1) { // (*) read conditions from resource: const resourceValue = serverManager.getResource(resourceName, true); @@ -568,20 +558,20 @@

Source: data/TrialHandler.js

// which is then read in as a string const decodedResourceMaybe = new Uint8Array(resourceValue); // Could be set to 'buffer' for ASCII .csv - const type = resourceExtension === 'csv' ? 'string' : 'array'; - const decodedResource = type === 'string' ? (new TextDecoder()).decode(decodedResourceMaybe) : decodedResourceMaybe; + const type = resourceExtension === "csv" ? "string" : "array"; + const decodedResource = type === "string" ? (new TextDecoder()).decode(decodedResourceMaybe) : decodedResourceMaybe; const workbook = XLSX.read(decodedResource, { type }); // we consider only the first worksheet: if (workbook.SheetNames.length === 0) { - throw 'workbook should contain at least one worksheet'; + throw "workbook should contain at least one worksheet"; } const sheetName = workbook.SheetNames[0]; const worksheet = workbook.Sheets[sheetName]; // worksheet to array of arrays (the first array contains the fields): - const sheet = XLSX.utils.sheet_to_json(worksheet, {header: 1, blankrows: false}); + const sheet = XLSX.utils.sheet_to_json(worksheet, { header: 1, blankrows: false }); const fields = sheet.shift(); // (*) select conditions: @@ -589,9 +579,9 @@

Source: data/TrialHandler.js

// (*) return the selected conditions as an array of 'object as map': // [ - // {field0: value0-0, field1: value0-1, ...} - // {field0: value1-0, field1: value1-1, ...} - // ... + // {field0: value0-0, field1: value0-1, ...} + // {field0: value1-0, field1: value1-1, ...} + // ... // ] let trialList = new Array(selectedRows.length - 1); for (let r = 0; r < selectedRows.length; ++r) @@ -614,7 +604,7 @@

Source: data/TrialHandler.js

value = arrayMaybe; } - if (typeof value === 'string') + if (typeof value === "string") { const numberMaybe = Number.parseFloat(value); @@ -626,7 +616,7 @@

Source: data/TrialHandler.js

else { // Parse doubly escaped line feeds - value = value.replace(/(\n)/g, '\n'); + value = value.replace(/(\n)/g, "\n"); } } @@ -637,74 +627,69 @@

Source: data/TrialHandler.js

return trialList; } - else { - throw 'extension: ' + resourceExtension + ' currently not supported.'; + throw "extension: " + resourceExtension + " currently not supported."; } } catch (error) { throw { - origin: 'TrialHandler.importConditions', + origin: "TrialHandler.importConditions", context: `when importing condition: ${resourceName}`, - error + error, }; } } - /** * Prepare the trial list. * + * @function * @protected - * @param {Array.<Object> | String} trialList - a list of trials, or the name of a condition resource + * @returns {void} */ - _prepareTrialList(trialList) + _prepareTrialList() { const response = { - origin: 'TrialHandler._prepareTrialList', - context: 'when preparing the trial list' + origin: "TrialHandler._prepareTrialList", + context: "when preparing the trial list", }; // we treat undefined trialList as a list with a single empty entry: - if (typeof trialList === 'undefined') + if (typeof this._trialList === "undefined") { this.trialList = [undefined]; } - // if trialList is an array, we make sure it is not empty: - else if (Array.isArray(trialList)) + else if (Array.isArray(this._trialList)) { - if (trialList.length === 0) + if (this._trialList.length === 0) { this.trialList = [undefined]; } } - // if trialList is a string, we treat it as the name of the condition resource: - else if (typeof trialList === 'string') + else if (typeof this._trialList === "string") { - this.trialList = TrialHandler.importConditions(this.psychoJS.serverManager, trialList); + this.trialList = TrialHandler.importConditions(this.psychoJS.serverManager, this._trialList); } - // unknown type: else { throw Object.assign(response, { - error: 'unable to prepare trial list: unknown type: ' + (typeof trialList) + error: `unable to prepare trial list: unknown type: ${(typeof this._trialList)}` }); } } - /* * Prepare the sequence of trials. * * <p>The returned sequence is a matrix (an array of arrays) of trial indices * with nStim columns and nReps rows. Note that this is the transpose of the * matrix return by PsychoPY. - * + * * Example: with 3 trial and 5 repetitions, we get: * - sequential: * [[0 1 2] @@ -727,21 +712,20 @@

Source: data/TrialHandler.js

_prepareSequence() { const response = { - origin: 'TrialHandler._prepareSequence', - context: 'when preparing a sequence of trials' + origin: "TrialHandler._prepareSequence", + context: "when preparing a sequence of trials", }; - // get an array of the indices of the elements of trialList : + // get an array of the indices of the elements of trialList: const indices = Array.from(this.trialList.keys()); - if (this.method === TrialHandler.Method.SEQUENTIAL) + if (this._method === TrialHandler.Method.SEQUENTIAL) { this._trialSequence = Array(this.nReps).fill(indices); // transposed version: - //this._trialSequence = indices.reduce( (seq, e) => { seq.push( Array(this.nReps).fill(e) ); return seq; }, [] ); + // this._trialSequence = indices.reduce( (seq, e) => { seq.push( Array(this.nReps).fill(e) ); return seq; }, [] ); } - - else if (this.method === TrialHandler.Method.RANDOM) + else if (this._method === TrialHandler.Method.RANDOM) { this._trialSequence = []; for (let i = 0; i < this.nReps; ++i) @@ -749,11 +733,10 @@

Source: data/TrialHandler.js

this._trialSequence.push(util.shuffle(indices.slice(), this._randomNumberGenerator)); } } - - else if (this.method === TrialHandler.Method.FULL_RANDOM) + else if (this._method === TrialHandler.Method.FULL_RANDOM) { // create a flat sequence with nReps repeats of indices: - let flatSequence = []; + const flatSequence = []; for (let i = 0; i < this.nReps; ++i) { flatSequence.push.apply(flatSequence, indices); @@ -771,15 +754,13 @@

Source: data/TrialHandler.js

} else { - throw Object.assign(response, {error: 'unknown method'}); + throw Object.assign(response, { error: "unknown method" }); } return this._trialSequence; } - } - /** * TrialHandler method * @@ -791,22 +772,22 @@

Source: data/TrialHandler.js

/** * Conditions are presented in the order they are given. */ - SEQUENTIAL: Symbol.for('SEQUENTIAL'), + SEQUENTIAL: Symbol.for("SEQUENTIAL"), /** * Conditions are shuffled within each repeat. */ - RANDOM: Symbol.for('RANDOM'), + RANDOM: Symbol.for("RANDOM"), /** * Conditions are fully randomised across all repeats. */ - FULL_RANDOM: Symbol.for('FULL_RANDOM'), + FULL_RANDOM: Symbol.for("FULL_RANDOM"), /** * Same as above, but named to reflect PsychoPy boileplate. */ - FULLRANDOM: Symbol.for('FULL_RANDOM') + FULLRANDOM: Symbol.for("FULL_RANDOM"), }; @@ -818,13 +799,13 @@

Source: data/TrialHandler.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/index.html b/docs/index.html index db316065..5a1e925a 100644 --- a/docs/index.html +++ b/docs/index.html @@ -19,36 +19,64 @@

Home

+ + + + + + + +

+ + + + + + + + + + + + + +
-
+

PsychoJS

+

Automated Test (short) +Automated Test (full) +Contributor Covenant

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.

You can create online experiments from the PsychoPy Builder, you can find and adapt existing experiments on pavlovia.org, or create them from scratch: the PsychoJS API is available here.

PsychoJS is an open-source project. You can contribute by submitting pull requests to the PsychoJS GitHub repository, and discuss issues and current and future features on the Online category of the PsychoPy Forum.

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.

+

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

The recommended approach to creating experiments is to use PsychoPy Builder to generate the javascript and html files. Many of the existing Builder experiments should "just work", subject to the Components being compatible between PsychoPy and PsychoJS.

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.

+

We built the PsychoJS library to make the JavaScript experiment files look and behave in very much the same way as 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 having 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 tasks 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). +taskshe hood PsychoJS relies on PixiJS to present stimuli and collect responses. PixiJS is a high performance, multi-platform 2D 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. PsychoPy Builder offers the possibility of uploading the experiment directly to pavlovia.org.

Which PsychoPy Components are supported by PsychoJS?

-

The list of PsychoPy Builder Components supported by PsychoJS see the PsychoPy/JS online status page

+

For the list of PsychoPy Builder Components supported by PsychoJS see this PsychoPy/JS online status page.

+

API

+

The documentation of the PsychoJS API is available 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-):

+It is now a collaborative effort, supported by the Chan Zuckerberg Initiative (2020-2021) and Open Science Tools (2020-):

+ +
+ + + + + + + +
+ +
+ + + +
+ +
+
+ + + + + +
+ + + + + + +

Classes

+ +
+
ExperimentHandler
+
+ +
MultiStairHandler
+
+ +
QuestHandler
+
+ +
Shelf
+
+ +
TrialHandler
+
+
+ + + + + + + + + + + + + +

Type Definitions

+ + + +

Snapshot

+ + + + + + +
Type:
+
    +
  • + +Object + + +
  • +
+ + + + + +
Properties:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
handler + + +TrialHandler + + + + the trialHandler
name + + +string + + + + the trialHandler name
nStim + + +number + + + + the number of stimuli
nTotal + + +number + + + + the total number of trials that will be run
nRemaining + + +number + + + + the total number of trial remaining
thisRepN + + +number + + + + the current repeat
thisTrialN + + +number + + + + the current trial number within the current repeat
thisN + + +number + + + + the total number of trials completed so far
thisIndex + + +number + + + + the index of the current trial in the conditions list
ran + + +number + + + + whether or not the trial ran
finished + + +number + + + + whether or not the trials finished
trialAttributes + + +Object + + + + a list of trial attributes
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + +
+ +
+
+ + + + + +
+ + + + + + +

Classes

+ +
+
ExperimentHandler
+
+ +
MultiStairHandler
+
+ +
QuestHandler
+
+ +
Shelf
+
+ +
TrialHandler
+
+
+ + + + + + + + + + + + + +

Type Definitions

+ + + +

Snapshot

+ + + + + + +
Type:
+
    +
  • + +Object + + +
  • +
+ + + + + +
Properties:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
handler + + +TrialHandler + + + + the trialHandler
name + + +string + + + + the trialHandler name
nStim + + +number + + + + the number of stimuli
nTotal + + +number + + + + the total number of trials that will be run
nRemaining + + +number + + + + the total number of trial remaining
thisRepN + + +number + + + + the current repeat
thisTrialN + + +number + + + + the current trial number within the current repeat
thisN + + +number + + + + the total number of trials completed so far
thisIndex + + +number + + + + the index of the current trial in the conditions list
ran + + +number + + + + whether or not the trial ran
finished + + +number + + + + whether or not the trials finished
trialAttributes + + +Object + + + + a list of trial attributes
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + +
+ +
+ + + + + + + +
+ +
+ + + +
+ +
+
+ + + + + +
+ + + + + + +

Classes

+ +
+
ExperimentHandler
+
+ +
MultiStairHandler
+
+ +
QuestHandler
+
+ +
Shelf
+
+
TrialHandler
@@ -427,7 +1762,7 @@
Properties:
Source:
@@ -457,13 +1792,13 @@
Properties:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-sound.AudioClip.html b/docs/module-sound.AudioClip.html index 24a9ff04..d6c7abd4 100644 --- a/docs/module-sound.AudioClip.html +++ b/docs/module-sound.AudioClip.html @@ -50,7 +50,7 @@

new AudioCli
-

AudioClip encapsulate an audio recording.

+

AudioClip encapsulates an audio recording.

@@ -391,7 +391,7 @@

Properties
Source:
@@ -495,7 +495,7 @@
Type:
Source:
@@ -575,7 +575,7 @@

downloadSource:
@@ -611,7 +611,7 @@

downloadstartPlayback()

+

getDuration() → {Promise.<number>}

@@ -619,7 +619,7 @@

startPla
- Start playing the audio clip. + Get the duration of the audio clip, in seconds.
@@ -655,6 +655,165 @@

startPla + + + + + + +
Source:
+
+ + + + + + + + + + + + + + + + + + + + + + + +

Returns:
+ + +
+ the duration of the audio clip +
+ + + +
+
+ Type +
+
+ +Promise.<number> + + +
+
+ + + + + + + + + + + + + +

setVolume(volume)

+ + + + + + +
+ Set the volume of the playback. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
volume + + +number + + + + the volume of the playback (must be between 0.0 and 1.0)
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + @@ -706,6 +865,94 @@

startPla +
+ Start playing the audio clip. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

startPlayback(fadeDurationopt)

+ + + + + +
Stop playing the audio clip.
@@ -718,6 +965,75 @@

startPla +

Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
fadeDuration + + +number + + + + + + <optional>
+ + + + + +
+ + 17 + + how long the fading out should last, in ms
+ + @@ -751,7 +1067,7 @@

startPla
Source:
@@ -839,7 +1155,7 @@

uploadSource:
@@ -885,13 +1201,13 @@

upload
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-sound.AudioClipPlayer.html b/docs/module-sound.AudioClipPlayer.html new file mode 100644 index 00000000..54949a5c --- /dev/null +++ b/docs/module-sound.AudioClipPlayer.html @@ -0,0 +1,1617 @@ + + + + + JSDoc: Class: AudioClipPlayer + + + + + + + + + + +
+ +

Class: AudioClipPlayer

+ + + + + + +
+ +
+ +

+ sound.AudioClipPlayer(options)

+ + +
+ +
+
+ + + + + + +

new AudioClipPlayer(options)

+ + + + + + +
+

This class handles the playback of an audio clip, e.g. a microphone recording.

+
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
psychoJS + + +module:core.PsychoJS + + + + + + + + + + + + the PsychoJS instance
audioClip + + +Object + + + + + + + + + + + + the module:sound.AudioClip
startTime + + +number + + + + + + <optional>
+ + + + + +
+ + 0 + + start of playback (in seconds)
stopTime + + +number + + + + + + <optional>
+ + + + + +
+ + -1 + + end of playback (in seconds)
stereo + + +boolean + + + + + + <optional>
+ + + + + +
+ + true + + whether or not to play the sound or track in stereo
volume + + +number + + + + + + <optional>
+ + + + + +
+ + 1.0 + + volume of the sound (must be between 0 and 1.0)
loops + + +number + + + + + + <optional>
+ + + + + +
+ + 0 + + how many times to repeat the track or tone after it has played *
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + +

Extends

+ + + + +
    +
  • SoundPlayer
  • +
+ + + + + + + + + + + + + + + + + +

Methods

+ + + + + + + +

(static) accept(sound) → {Object|undefined}

+ + + + + + +
+ Determine whether this player can play the given sound. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
sound + + +module:sound.Sound + + + + the sound object, which should be an AudioClip
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ an instance of AudioClipPlayer if sound is an AudioClip or undefined otherwise +
+ + + +
+
+ Type +
+
+ +Object +| + +undefined + + +
+
+ + + + + + + + + + + + + +

getDuration() → {number}

+ + + + + + +
+ Get the duration of the AudioClip, in seconds. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ the duration of the clip, in seconds +
+ + + +
+
+ Type +
+
+ +number + + +
+
+ + + + + + + + + + + + + +

play(loops, fadeDurationopt)

+ + + + + + +
+ Start playing the sound. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
loops + + +number + + + + + + + + + + + + how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped.
fadeDuration + + +number + + + + + + <optional>
+ + + + + +
+ + 17 + + how long should the fading in last in ms
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

setDuration(duration_s)

+ + + + + + +
+ Set the duration of the audio clip. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
duration_s + + +number + + + + the duration of the clip in seconds
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

setLoops(loops)

+ + + + + + +
+ Set the number of loops. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
loops + + +number + + + + how many times to repeat the clip after it has played once. If loops == -1, the clip will repeat indefinitely until stopped.
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

setVolume(volume, muteopt)

+ + + + + + +
+ Set the volume of the playback. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
volume + + +number + + + + + + + + + + + + the volume of the playback (must be between 0.0 and 1.0)
mute + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to mute the playback
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

stop(fadeDurationopt)

+ + + + + + +
+ Stop playing the sound immediately. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
fadeDuration + + +number + + + + + + <optional>
+ + + + + +
+ + 17 + + how long the fading out should last, in ms
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + \ No newline at end of file diff --git a/docs/module-sound.Microphone.html b/docs/module-sound.Microphone.html index 04016a02..0ffeb647 100644 --- a/docs/module-sound.Microphone.html +++ b/docs/module-sound.Microphone.html @@ -491,7 +491,7 @@

flushSource:
@@ -553,7 +553,7 @@

pauseSource:
@@ -617,7 +617,7 @@

resumeSource:
@@ -682,7 +682,7 @@

startSource:
@@ -744,7 +744,7 @@

stopSource:
@@ -826,7 +826,7 @@

(protected)
Source:
@@ -914,7 +914,7 @@

(protected)
Source:
@@ -1051,7 +1051,7 @@
Parameters:
Source:
@@ -1243,7 +1243,7 @@
Parameters:
Source:
@@ -1380,7 +1380,7 @@
Parameters:
Source:
@@ -1426,13 +1426,13 @@
Parameters:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-sound.Sound.html b/docs/module-sound.Sound.html index 8f6446e0..b97c926f 100644 --- a/docs/module-sound.Sound.html +++ b/docs/module-sound.Sound.html @@ -605,7 +605,7 @@
Properties
Source:
@@ -736,7 +736,7 @@

(protected) Source:
@@ -875,7 +875,7 @@

getDuratio
Source:
@@ -1092,7 +1092,7 @@

Parameters:
Source:
@@ -1288,7 +1288,7 @@
Parameters:
Source:
@@ -1484,7 +1484,7 @@
Parameters:
Source:
@@ -1676,7 +1676,7 @@
Parameters:
Source:
@@ -1907,7 +1907,7 @@
Parameters:
Source:
@@ -2113,7 +2113,7 @@
Properties
Source:
@@ -2159,13 +2159,13 @@
Properties

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-sound.SoundPlayer.html b/docs/module-sound.SoundPlayer.html index 51ecf49a..03333f3b 100644 --- a/docs/module-sound.SoundPlayer.html +++ b/docs/module-sound.SoundPlayer.html @@ -73,7 +73,7 @@

Source:
@@ -220,7 +220,7 @@

Parameters:
Source:
@@ -333,7 +333,7 @@

(abstract) Source:
@@ -482,7 +482,7 @@

Parameters:
Source:
@@ -570,7 +570,7 @@

(abstract) Source:
@@ -707,7 +707,7 @@

Parameters:
Source:
@@ -899,7 +899,7 @@
Parameters:
Source:
@@ -987,7 +987,7 @@

(abstract) stopSource:
@@ -1033,13 +1033,13 @@

(abstract) stop
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-sound.TonePlayer.html b/docs/module-sound.TonePlayer.html index 0950e9e5..af7ebd80 100644 --- a/docs/module-sound.TonePlayer.html +++ b/docs/module-sound.TonePlayer.html @@ -489,7 +489,7 @@

(protected
Source:
@@ -629,7 +629,7 @@
Parameters:
Source:
@@ -742,7 +742,7 @@

getDuratio
Source:
@@ -913,7 +913,7 @@

Parameters:
Source:
@@ -1050,7 +1050,7 @@
Parameters:
Source:
@@ -1187,7 +1187,7 @@
Parameters:
Source:
@@ -1379,7 +1379,7 @@
Parameters:
Source:
@@ -1467,7 +1467,7 @@

stopSource:
@@ -1513,13 +1513,13 @@

stopHome

Modules

Classes

Interfaces

Mixins

+

Home

Modules

Classes

Interfaces

Mixins


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-sound.TrackPlayer.html b/docs/module-sound.TrackPlayer.html index eed5e7e4..f487905b 100644 --- a/docs/module-sound.TrackPlayer.html +++ b/docs/module-sound.TrackPlayer.html @@ -438,7 +438,7 @@
Properties
Source:
@@ -616,7 +616,7 @@
Parameters:
Source:
@@ -729,7 +729,7 @@

getDuratio
Source:
@@ -943,7 +943,7 @@

Parameters:
Source:
@@ -987,7 +987,7 @@

setDuratio
- Set the duration of the default sprite. + Set the duration of the track.
@@ -1080,7 +1080,7 @@

Parameters:
Source:
@@ -1217,7 +1217,7 @@
Parameters:
Source:
@@ -1409,7 +1409,7 @@
Parameters:
Source:
@@ -1566,7 +1566,7 @@
Parameters:
Source:
@@ -1612,13 +1612,13 @@
Parameters:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-sound.Transcriber.html b/docs/module-sound.Transcriber.html new file mode 100644 index 00000000..2df390bb --- /dev/null +++ b/docs/module-sound.Transcriber.html @@ -0,0 +1,1395 @@ + + + + + JSDoc: Class: Transcriber + + + + + + + + + + +
+ +

Class: Transcriber

+ + + + + + +
+ +
+ +

+ sound.Transcriber(options)

+ + +
+ +
+
+ + + + + + +

new Transcriber(options)

+ + + + + + +
+

This manager handles the transcription of speech into text.

+
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
psychoJS + + +module:core.PsychoJS + + + + + + + + + + + + the PsychoJS instance
name + + +String + + + + + + + + + + + + the name used when logging messages
bufferSize + + +number + + + + + + <optional>
+ + + + + +
+ + 10000 + + the maximum size of the circular transcript buffer
continuous + + +Array.<String> + + + + + + <optional>
+ + + + + +
+ + true + + whether or not to continuously recognise
lang + + +Array.<String> + + + + + + <optional>
+ + + + + +
+ + 'en-US' + + the spoken language
interimResults + + +Array.<String> + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to make interim results available
maxAlternatives + + +Array.<String> + + + + + + <optional>
+ + + + + +
+ + 1 + + the maximum number of recognition alternatives
tokens + + +Array.<String> + + + + + + <optional>
+ + + + + +
+ + [] + + the tokens to be recognised. This is experimental technology, not available in all browser.
clock + + +Clock + + + + + + <optional>
+ + + + + +
+ + an optional clock
autoLog + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to log
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
To Do:
+
+
    +
  • deal with alternatives, interim results, and recognition errors
  • +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +

Methods

+ + + + + + + +

(protected) _onChange()

+ + + + + + +
+ Callback for changes to the recognition settings. + +

Changes to the recognition settings require the recognition to stop and be re-started.

+
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

(protected) _prepareTranscription()

+ + + + + + +
+ Prepare the transcription. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

clearTranscripts()

+ + + + + + +
+ Clear all transcripts and resets the circular buffers. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

getTranscripts(options) → {Array.<Transcript>}

+ + + + + + +
+ Get the list of transcripts still in the buffer, i.e. those that have not been +previously cleared by calls to getTranscripts with clear = true. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
transcriptList + + +Array.<string> + + + + + + <optional>
+ + + + + +
+ + [] + + the list of transcripts texts to consider. If transcriptList is empty, we consider all transcripts.
clear + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to keep in the buffer the transcripts for a subsequent call to getTranscripts. If a keyList has been given and clear = true, we only remove from the buffer those keys in keyList
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ the list of transcripts still in the buffer +
+ + + +
+
+ Type +
+
+ +Array.<Transcript> + + +
+
+ + + + + + + + + + + + + +

start() → {Promise}

+ + + + + + +
+ Start the transcription. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ promise fulfilled when the transcription actually started +
+ + + +
+
+ Type +
+
+ +Promise + + +
+
+ + + + + + + + + + + + + +

stop() → {Promise}

+ + + + + + +
+ Stop the transcription. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ promise fulfilled when the speech recognition actually stopped +
+ + + +
+
+ Type +
+
+ +Promise + + +
+
+ + + + + + + + + + + + + +
+ +
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + \ No newline at end of file diff --git a/docs/module-sound.Transcript.html b/docs/module-sound.Transcript.html new file mode 100644 index 00000000..4e45aa0b --- /dev/null +++ b/docs/module-sound.Transcript.html @@ -0,0 +1,171 @@ + + + + + JSDoc: Class: Transcript + + + + + + + + + + +
+ +

Class: Transcript

+ + + + + + +
+ +
+ +

+ sound.Transcript()

+ + +
+ +
+
+ + + + + + +

new Transcript()

+ + + + + + +
+ Transcript returned by the transcriber +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + \ No newline at end of file diff --git a/docs/module-sound.html b/docs/module-sound.html index 9685f8a8..0afc66f8 100644 --- a/docs/module-sound.html +++ b/docs/module-sound.html @@ -66,6 +66,12 @@

Classes

TrackPlayer
+ +
Transcriber
+
+ +
Transcript
+

@@ -99,13 +105,13 @@

Interfaces


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.Clock.html b/docs/module-util.Clock.html index 59db5d0c..64d74724 100644 --- a/docs/module-util.Clock.html +++ b/docs/module-util.Clock.html @@ -94,7 +94,7 @@

new ClockSource:
@@ -277,7 +277,7 @@
Parameters:
Source:
@@ -434,7 +434,7 @@
Parameters:
Source:
@@ -480,13 +480,13 @@
Parameters:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.Color.html b/docs/module-util.Color.html index 57892e96..ee26b446 100644 --- a/docs/module-util.Color.html +++ b/docs/module-util.Color.html @@ -226,7 +226,7 @@
Parameters:
Source:
@@ -337,7 +337,7 @@
Type:
Source:
@@ -409,7 +409,7 @@
Type:
Source:
@@ -489,7 +489,7 @@

(static) hexSource:
@@ -648,7 +648,7 @@
Parameters:
Source:
@@ -807,7 +807,7 @@
Parameters:
Source:
@@ -917,7 +917,7 @@

(static) intSource:
@@ -1027,7 +1027,7 @@

(static) rgbSource:
@@ -1137,7 +1137,7 @@

(static) rgb25
Source:
@@ -1296,7 +1296,7 @@

Parameters:
Source:
@@ -1455,7 +1455,7 @@
Parameters:
Source:
@@ -1513,6 +1513,116 @@
Returns:
+

(static) rgbFull() → {Array.<number>}

+ + + + + + +
+ Get the [-1,1] RGB triplet equivalent of this Color. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ the [-1,1] RGB triplet equivalent +
+ + + +
+
+ Type +
+
+ +Array.<number> + + +
+
+ + + + + + + + + + + + +

(static) rgbToHex(rgb) → {string}

@@ -1614,7 +1724,7 @@
Parameters:
Source:
@@ -1883,7 +1993,7 @@

(static) toS
Source:
@@ -1951,13 +2061,13 @@

Returns:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.ColorMixin.html b/docs/module-util.ColorMixin.html index 2c88fb37..2a2d6627 100644 --- a/docs/module-util.ColorMixin.html +++ b/docs/module-util.ColorMixin.html @@ -73,7 +73,7 @@

Source:
@@ -242,7 +242,7 @@

Parameters:
Source:
@@ -434,7 +434,7 @@
Parameters:
Source:
@@ -626,7 +626,7 @@
Parameters:
Source:
@@ -672,13 +672,13 @@
Parameters:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.CountdownTimer.html b/docs/module-util.CountdownTimer.html index f9e6c8e1..73d93464 100644 --- a/docs/module-util.CountdownTimer.html +++ b/docs/module-util.CountdownTimer.html @@ -163,7 +163,7 @@
Parameters:
Source:
@@ -346,7 +346,7 @@
Parameters:
Source:
@@ -434,7 +434,7 @@

getTimeSource:
@@ -606,7 +606,7 @@
Parameters:
Source:
@@ -652,13 +652,13 @@
Parameters:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.EventEmitter.html b/docs/module-util.EventEmitter.html index de2709d9..f41e7692 100644 --- a/docs/module-util.EventEmitter.html +++ b/docs/module-util.EventEmitter.html @@ -96,7 +96,7 @@

new Event
Source:
@@ -285,7 +285,7 @@

Parameters:
Source:
@@ -467,7 +467,7 @@
Parameters:
Source:
@@ -627,7 +627,7 @@
Parameters:
Source:
@@ -797,7 +797,7 @@
Parameters:
Source:
@@ -950,7 +950,7 @@
Parameters:
Source:
@@ -994,13 +994,13 @@
Parameters:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.MixinBuilder.html b/docs/module-util.MixinBuilder.html index a78343f7..7708f4d6 100644 --- a/docs/module-util.MixinBuilder.html +++ b/docs/module-util.MixinBuilder.html @@ -145,7 +145,7 @@
Parameters:
Source:
@@ -215,13 +215,13 @@
Example

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.MonotonicClock.html b/docs/module-util.MonotonicClock.html index e3a0bf89..70c174b9 100644 --- a/docs/module-util.MonotonicClock.html +++ b/docs/module-util.MonotonicClock.html @@ -163,7 +163,7 @@
Parameters:
Source:
@@ -348,7 +348,7 @@
Parameters:
Source:
@@ -460,7 +460,7 @@

(static) g
Source:
@@ -570,7 +570,7 @@

getLa
Source:
@@ -680,7 +680,7 @@

getRe
Source:
@@ -790,7 +790,7 @@

getTimeSource:
@@ -858,13 +858,13 @@
Returns:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.PsychObject.html b/docs/module-util.PsychObject.html index d2ba8091..187e567a 100644 --- a/docs/module-util.PsychObject.html +++ b/docs/module-util.PsychObject.html @@ -167,7 +167,7 @@
Parameters:
Source:
@@ -272,7 +272,7 @@

psychoJSSource:
@@ -334,7 +334,7 @@

psychoJSSource:
@@ -570,7 +570,7 @@
Parameters:
Source:
@@ -873,7 +873,7 @@
Parameters:
Source:
@@ -986,7 +986,7 @@

toStringSource:
@@ -1054,13 +1054,13 @@
Returns:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.Scheduler.html b/docs/module-util.Scheduler.html index a3bf3e29..6e451526 100644 --- a/docs/module-util.Scheduler.html +++ b/docs/module-util.Scheduler.html @@ -162,7 +162,7 @@
Parameters:
Source:
@@ -256,7 +256,7 @@

addSource:
@@ -320,7 +320,7 @@

addCond
Source:
@@ -392,7 +392,7 @@

Type:
Source:
@@ -456,7 +456,7 @@

startSource:
@@ -518,7 +518,7 @@

statusSource:
@@ -590,7 +590,7 @@
Type:
Source:
@@ -652,7 +652,7 @@

stopSource:
@@ -734,7 +734,7 @@

ConditionSource:
@@ -901,7 +901,7 @@
Parameters:
Source:
@@ -945,13 +945,13 @@
Parameters:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-util.html b/docs/module-util.html index 20b650d5..d819e750 100644 --- a/docs/module-util.html +++ b/docs/module-util.html @@ -203,7 +203,7 @@
Parameters:
Source:
@@ -342,7 +342,7 @@
Parameters:
Source:
@@ -535,7 +535,7 @@
Parameters:
Source:
@@ -636,7 +636,7 @@

(static) Source:
@@ -798,7 +798,7 @@
Parameters:
Source:
@@ -957,7 +957,7 @@
Parameters:
Source:
@@ -1015,6 +1015,222 @@
Returns:
+

(static) getDownloadSpeed(psychoJS, nbDownloadsopt) → {number}

+ + + + + + +
+ Get an estimate of the download speed, by repeatedly downloading an image file from a distant +server. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
psychoJS + + +PsychoJS + + + + + + + + + + + + the instance of PsychoJS
nbDownloads + + +number + + + + + + <optional>
+ + + + + +
+ + 1 + + the number of image downloads over which to average + the download speed
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ the download speed, in megabits per second +
+ + + +
+
+ Type +
+
+ +number + + +
+
+ + + + + + + + + + + + +

(static) getErrorStack() → {string}

@@ -1067,7 +1283,7 @@

(static) Source:
@@ -1249,7 +1465,7 @@
Parameters:
Source:
@@ -1439,7 +1655,7 @@
Parameters:
Source:
@@ -1527,7 +1743,7 @@

(static) Source:
@@ -1727,7 +1943,7 @@
Parameters:
Source:
@@ -1886,7 +2102,7 @@
Parameters:
Source:
@@ -2046,7 +2262,7 @@
Parameters:
Source:
@@ -2104,7 +2320,7 @@
Returns:
-

(static) IsPointInsidePolygon(point, vertices) → {boolean}

+

(static) isNumeric(input) → {boolean}

@@ -2112,8 +2328,7 @@

(static
- Check whether a point lies within a polygon -

We are using the algorithm described here: https://wrf.ecse.rpi.edu//Research/Short_Notes/pnpoly.html

+ Check whether a value looks like a number
@@ -2149,36 +2364,13 @@
Parameters:
- point - - - - - -Array.<number> - - - - - - - - - - the point - - - - - - - vertices + input -Object +* @@ -2188,7 +2380,7 @@
Parameters:
- the vertices defining the polygon + Some value @@ -2229,7 +2421,7 @@
Parameters:
Source:
@@ -2258,7 +2450,7 @@
Returns:
- whether or not the point lies within the polygon + Whether or not the value can be converted into a number
@@ -2287,7 +2479,7 @@
Returns:
-

(static) makeUuid() → {string}

+

(static) IsPointInsidePolygon(point, vertices) → {boolean}

@@ -2295,8 +2487,8 @@

(static) mak
- Get a Universally Unique Identifier (RFC4122 version 4) -

See details here: https://www.ietf.org/rfc/rfc4122.txt

+ Check whether a point lies within a polygon +

We are using the algorithm described here: https://wrf.ecse.rpi.edu//Research/Short_Notes/pnpoly.html

@@ -2307,11 +2499,194 @@

(static) mak +

Parameters:
+ + + + + + + + + -
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
point + + +Array.<number> + + + + the point
vertices + + +Object + + + + the vertices defining the polygon
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ whether or not the point lies within the polygon +
+ + + +
+
+ Type +
+
+ +boolean + + +
+
+ + + + + + + + + + + + + +

(static) makeUuid() → {string}

+ + + + + + +
+ Get a Universally Unique Identifier (RFC4122 version 4) +

See details here: https://www.ietf.org/rfc/rfc4122.txt

+
+ + + + + + + + + + + + + +
@@ -2340,7 +2715,7 @@

(static) mak
Source:
@@ -2545,7 +2920,7 @@

Parameters:
Source:
@@ -2682,7 +3057,7 @@
Parameters:
Source:
@@ -2741,6 +3116,208 @@
Returns:
+

(static) randchoice(array, randomNumberGeneratoropt) → {Array.<Object>}

+ + + + + + +
+ Pick a random value from an array, uses `util.shuffle` to shuffle the array and returns the last value. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDescription
array + + +Array.<Object> + + + + + + + + + + the input 1-D array
randomNumberGenerator + + +function + + + + + + <optional>
+ + + + + +
A function used to generated random numbers in the interal [0, 1). Defaults to Math.random
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ a chosen value from the array +
+ + + +
+
+ Type +
+
+ +Array.<Object> + + +
+
+ + + + + + + + + + + + +

(static) randint(minopt, max) → {number}

@@ -2897,7 +3474,7 @@
Parameters:
Source:
@@ -3154,7 +3731,7 @@
Parameters:
Source:
@@ -3338,7 +3915,7 @@
Parameters:
Source:
@@ -3543,7 +4120,7 @@
Parameters:
Source:
@@ -3749,7 +4326,7 @@
Parameters:
Source:
@@ -4041,7 +4618,7 @@
Parameters:
Source:
@@ -4200,7 +4777,7 @@
Parameters:
Source:
@@ -4397,7 +4974,7 @@
Parameters:
Source:
@@ -4602,7 +5179,7 @@
Parameters:
Source:
@@ -4807,7 +5384,7 @@
Parameters:
Source:
@@ -5091,7 +5668,7 @@
Parameters:
Source:
@@ -5375,7 +5952,7 @@
Parameters:
Source:
@@ -5603,7 +6180,7 @@
Parameters:
Source:
@@ -5808,7 +6385,7 @@
Parameters:
Source:
@@ -5974,7 +6551,7 @@
Parameters:
Source:
@@ -6138,7 +6715,7 @@
Parameters:
Source:
@@ -6322,7 +6899,7 @@
Parameters:
Source:
@@ -6390,13 +6967,13 @@
Returns:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.ButtonStim.html b/docs/module-visual.ButtonStim.html index c0aa23cd..90c9cca6 100644 --- a/docs/module-visual.ButtonStim.html +++ b/docs/module-visual.ButtonStim.html @@ -826,7 +826,7 @@
Properties
Source:
@@ -931,7 +931,7 @@

isClickedSource:
@@ -993,7 +993,7 @@

numClicksSource:
@@ -1027,13 +1027,13 @@

numClicks
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.Camera.html b/docs/module-visual.Camera.html new file mode 100644 index 00000000..f7350eb1 --- /dev/null +++ b/docs/module-visual.Camera.html @@ -0,0 +1,2416 @@ + + + + + JSDoc: Class: Camera + + + + + + + + + + +
+ +

Class: Camera

+ + + + + + +
+ +
+ +

+ visual.Camera(options)

+ + +
+ +
+
+ + + + + + +

new Camera(options)

+ + + + + + +
+

This manager handles the recording of video signal.

+
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
win + + +module:core.Window + + + + + + + + + + + + the associated Window
format + + +string + + + + + + <optional>
+ + + + + +
+ + 'video/webm;codecs=vp9' + + the video format
showDialog + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to open a dialog box to inform the + participant to wait for the camera to be initialised
dialogMsg + + +string + + + + + + <optional>
+ + + + + +
+ + "Please wait a few moments while the camera initialises" + + default message informing the participant to wait for the camera to initialise
clock + + +Clock + + + + + + <optional>
+ + + + + +
+ + an optional clock
autoLog + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to log
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
To Do:
+
+
    +
  • add video constraints as parameter
  • +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +

Methods

+ + + + + + + +

(protected) _onChange()

+ + + + + + +
+ Callback for changes to the recording settings. + +

Changes to the settings require the recording to stop and be re-started.

+
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

(protected) _prepareRecording()

+ + + + + + +
+ Prepare the recording. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

download(filename)

+ + + + + + +
+ Offer the audio recording to the participant as a video file to download. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
filename + + +string + + + + the filename of the video file
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

flush() → {Promise}

+ + + + + + +
+ Submit a request to flush the recording. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ promise fulfilled when the data has actually been made available +
+ + + +
+
+ Type +
+
+ +Promise + + +
+
+ + + + + + + + + + + + + +

getRecording(tag, flushopt)

+ + + + + + +
+ Get the current video recording as a VideoClip in the given format. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
tag + + +string + + + + + + + + + + + + an optional tag for the video clip
flush + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to first flush the recording
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

getStream() → {MediaStream}

+ + + + + + +
+ Get the underlying video stream. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ the video stream +
+ + + +
+
+ Type +
+
+ +MediaStream + + +
+
+ + + + + + + + + + + + + +

getVideo() → {HTMLVideoElement}

+ + + + + + +
+ Get a video element pointing to the Camera stream. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ a video element +
+ + + +
+
+ Type +
+
+ +HTMLVideoElement + + +
+
+ + + + + + + + + + + + + +

isReady() → {boolean}

+ + + + + + +
+ Query whether or not the camera is ready to record. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ whether or not the camera is ready to record +
+ + + +
+
+ Type +
+
+ +boolean + + +
+
+ + + + + + + + + + + + + +

pause() → {Promise}

+ + + + + + +
+ Submit a request to pause the recording. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ promise fulfilled when the recording actually paused +
+ + + +
+
+ Type +
+
+ +Promise + + +
+
+ + + + + + + + + + + + + +

resume(options) → {Promise}

+ + + + + + +
+ Submit a request to resume the recording. + +

resume has no effect if the recording was not previously paused.

+
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
clear + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to empty the video buffer before + resuming the recording
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ promise fulfilled when the recording actually resumed +
+ + + +
+
+ Type +
+
+ +Promise + + +
+
+ + + + + + + + + + + + + +

start() → {Promise}

+ + + + + + +
+ Submit a request to start the recording. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ promise fulfilled when the recording actually started +
+ + + +
+
+ Type +
+
+ +Promise + + +
+
+ + + + + + + + + + + + + +

stop(options) → {Promise}

+ + + + + + +
+ Submit a request to stop the recording. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDescription
filename + + +string + + + + + + <optional>
+ + + + + +
the name of the file to which the video recording + will be saved
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ promise fulfilled when the recording actually stopped, and the recorded + data was made available +
+ + + +
+
+ Type +
+
+ +Promise + + +
+
+ + + + + + + + + + + + + +

upload(@param)

+ + + + + + +
+ Upload the video recording to the pavlovia server. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
@param + + +Object + + + + + + + + + + + + options
options.tag + + +string + + + + + + + + + + + + an optional tag for the video file
options.waitForCompletion + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to wait for completion + before returning
options.showDialog + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to open a dialog box to inform the participant to wait for the data to be uploaded to the server
options.dialogMsg + + +string + + + + + + <optional>
+ + + + + +
+ + "" + + default message informing the participant to wait for the data to be uploaded to the server
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time) +
+ + + + + \ No newline at end of file diff --git a/docs/module-visual.FaceDetector.html b/docs/module-visual.FaceDetector.html new file mode 100644 index 00000000..60f9ec1f --- /dev/null +++ b/docs/module-visual.FaceDetector.html @@ -0,0 +1,1708 @@ + + + + + JSDoc: Class: FaceDetector + + + + + + + + + + +
+ +

Class: FaceDetector

+ + + + + + +
+ +
+ +

+ visual.FaceDetector(options, @param, @param)

+ + +
+ +
+
+ + + + + + +

new FaceDetector(options, @param, @param)

+ + + + + + +
+

This manager handles the detecting of faces in video streams. FaceDetector relies on the +Face-API library developed by +Vincent Muehler

+
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
options + + +Object + + + + + + + + + + + + +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
name + + +String + + + + the name used when logging messages from the detector
+ +
@param + + +module:core.Window + + + + + + + + + + + + options.win - the associated Window
@param + + +string +| + +HTMLVideoElement +| + +module:visual.Camera + + + + + + + + + + + + input - the name of a +movie resource or of a HTMLVideoElement or of a Camera component
options.faceApiUrl + + +string + + + + + + <optional>
+ + + + + +
+ + 'face-api.js' + + the Url of the face-api library
options.modelDir + + +string + + + + + + <optional>
+ + + + + +
+ + 'models' + + the directory where to find the face detection models
options.units + + +string + + + + + + <optional>
+ + + + + +
+ + "norm" + + the units of the stimulus (e.g. for size, position, vertices)
options.pos + + +Array.<number> + + + + + + <optional>
+ + + + + +
+ + [0, 0] + + the position of the center of the stimulus
options.units + + +string + + + + + + <optional>
+ + + + + +
+ + 'norm' + + the units of the stimulus vertices, size and position
options.ori + + +number + + + + + + <optional>
+ + + + + +
+ + 0.0 + + the orientation (in degrees)
options.size + + +number + + + + + + <optional>
+ + + + + +
+ + the size of the rendered image (the size of the image will be used if size is not specified)
options.opacity + + +number + + + + + + <optional>
+ + + + + +
+ + 1.0 + + the opacity
options.autoDraw + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not the stimulus should be automatically drawn on every frame flip
options.autoLog + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to log
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + +

Methods

+ + + + + + + +

(protected) _estimateBoundingBox()

+ + + + + + +
+ Estimate the bounding box. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

(protected) _initFaceApi()

+ + + + + + +
+ Init the Face-API library. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

(protected) _updateIfNeeded()

+ + + + + + +
+ Update the visual representation of the detected faces, if necessary. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

isReady() → {boolean}

+ + + + + + +
+ Query whether or not the face detector is ready to detect. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ whether or not the face detector is ready to detect +
+ + + +
+
+ Type +
+
+ +boolean + + +
+
+ + + + + + + + + + + + + +

setCamera(input, logopt)

+ + + + + + +
+ Setter for the video attribute. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
input + + +string +| + +HTMLVideoElement +| + +module:visual.Camera + + + + + + + + + + + + the name of a +movie resource or a HTMLVideoElement or a Camera component
log + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether of not to log
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

start(period, detectionCallback, logopt)

+ + + + + + +
+ Start detecting faces. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
period + + +number + + + + + + + + + + + + the detection period, in ms (e.g. 100 ms for 10Hz)
detectionCallback + + + + + + + + + + the callback triggered when detection results are available
log + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether of not to log
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

stop(logopt)

+ + + + + + +
+ Stop detecting faces. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
log + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether of not to log
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time) +
+ + + + + \ No newline at end of file diff --git a/docs/module-visual.Form.html b/docs/module-visual.Form.html index 8a69a6b9..2787d6dd 100644 --- a/docs/module-visual.Form.html +++ b/docs/module-visual.Form.html @@ -910,7 +910,7 @@
Properties
Source:
@@ -1020,7 +1020,7 @@

containsSource:
@@ -1087,7 +1087,7 @@

setOriSource:
@@ -1154,7 +1154,7 @@

setPosSource:
@@ -1221,7 +1221,7 @@

setSizeSource:
@@ -1306,7 +1306,7 @@

(protect
Source:
@@ -1351,7 +1351,8 @@

(protected)
Generate a callback that prepares updates to the stimulus. -This is typically called in the constructor of a stimulus, when attributes are added with _addAttribute. +This is typically called in the constructor of a stimulus, when attributes are added + with _addAttribute.
@@ -1423,7 +1424,8 @@

Parameters:
- whether or not the PIXI representation must also be updated + whether or not the PIXI representation must + also be updated @@ -1462,7 +1464,8 @@
Parameters:
- whether or not to immediately estimate the bounding box + whether or not to immediately estimate + the bounding box @@ -1508,7 +1511,7 @@
Parameters:
Source:
@@ -1718,7 +1721,7 @@
Parameters:
Source:
@@ -1806,7 +1809,7 @@

drawSource:
@@ -1894,7 +1897,7 @@

formCompl
Source:
@@ -2004,7 +2007,7 @@

getDataSource:
@@ -2114,7 +2117,7 @@

hideSource:
@@ -2207,7 +2210,7 @@

refreshSource:
@@ -2295,7 +2298,7 @@

resetSource:
@@ -2341,13 +2344,13 @@

reset
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.GratingStim.html b/docs/module-visual.GratingStim.html new file mode 100644 index 00000000..51e40c58 --- /dev/null +++ b/docs/module-visual.GratingStim.html @@ -0,0 +1,4450 @@ + + + + + JSDoc: Class: GratingStim + + + + + + + + + + +
+ +

Class: GratingStim

+ + + + + + +
+ +
+ +

+ visual.GratingStim(options)

+ + +
+ +
+
+ + + + + + +

new GratingStim(options)

+ + + + + + +
+ Grating Stimulus. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
name + + +String + + + + + + + + + + + + the name used when logging messages from this stimulus
win + + +Window + + + + + + + + + + + + the associated Window
tex + + +String +| + +HTMLImageElement + + + + + + <optional>
+ + + + + +
+ + "sin" + + the name of the predefined grating texture or image resource or the HTMLImageElement corresponding to the texture
mask + + +String +| + +HTMLImageElement + + + + + + <optional>
+ + + + + +
+ + the name of the mask resource or HTMLImageElement corresponding to the mask
units + + +String + + + + + + <optional>
+ + + + + +
+ + "norm" + + the units of the stimulus (e.g. for size, position, vertices)
sf + + +number + + + + + + <optional>
+ + + + + +
+ + 1.0 + + spatial frequency of the function used in grating stimulus
phase + + +number + + + + + + <optional>
+ + + + + +
+ + 0.0 + + phase of the function used in grating stimulus, multiples of period of that function
pos + + +Array.<number> + + + + + + <optional>
+ + + + + +
+ + [0, 0] + + the position of the center of the stimulus
ori + + +number + + + + + + <optional>
+ + + + + +
+ + 0.0 + + the orientation (in degrees)
size + + +number + + + + + + <optional>
+ + + + + +
+ + the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified)
color + + +Color + + + + + + <optional>
+ + + + + +
+ + "white" + + Foreground color of the stimulus. Can be String like "red" or "#ff0000" or Number like 0xff0000.
opacity + + +number + + + + + + <optional>
+ + + + + +
+ + 1.0 + + Set the opacity of the stimulus. Determines how visible the stimulus is relative to background.
contrast + + +number + + + + + + <optional>
+ + + + + +
+ + 1.0 + + Set the contrast of the stimulus, i.e. scales how far the stimulus deviates from the middle grey. Ranges [-1, 1].
depth + + +number + + + + + + <optional>
+ + + + + +
+ + 0 + + the depth (i.e. the z order)
interpolate + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + Whether to interpolate (linearly) the texture in the stimulus. Currently supports only image based gratings.
blendmode + + +String + + + + + + <optional>
+ + + + + +
+ + "avg" + + blend mode of the stimulus, determines how the stimulus is blended with the background. Supported values: "avg", "add", "mul", "screen".
autoDraw + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not the stimulus should be automatically drawn on every frame flip
autoLog + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to log
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + +

Extends

+ + + + +
    +
  • VisualStim
  • +
+ + + + + + + + + + + + + + + +

Members

+ + + +

(static) DEFAULT_STIM_SIZE_PX :Array

+ + + + +
+ Default size of the Grating Stimuli in pixels. +
+ + + +
Type:
+
    +
  • + +Array + + +
  • +
+ + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
Default Value:
+
    +
  • [256, 256]
  • +
+ + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

(static) SHADERS :Object

+ + + + +
+ An object that keeps shaders source code and default uniform values for them. +Shader source code is later used for construction of shader programs to create respective visual stimuli. +
+ + + +
Type:
+
    +
  • + +Object + + +
  • +
+ + + + + +
Properties:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
imageShader + + +Object + + + + Renders provided image with applied effects (coloring, phase, frequency). +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for the image based grating stimuli.
uniforms + + +Object + + + + default uniforms for the image based shader. +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uFreq + + +float + + + + + + 1.0 + + how much times image repeated within grating stimuli.
uPhase + + +float + + + + + + 0.0 + + offset of the image along X axis.
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
sin + + +Object + + + + Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. +https://en.wikipedia.org/wiki/Sine_wave +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for the sine wave stimuli
uniforms + + +Object + + + + default uniforms for sine wave shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uFreq + + +float + + + + + + 1.0 + + frequency of sine wave.
uPhase + + +float + + + + + + 0.0 + + phase of sine wave.
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
sqr + + +Object + + + + Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. +https://en.wikipedia.org/wiki/Square_wave +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for the square wave stimuli
uniforms + + +Object + + + + default uniforms for square wave shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uFreq + + +float + + + + + + 1.0 + + frequency of square wave.
uPhase + + +float + + + + + + 0.0 + + phase of square wave.
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
saw + + +Object + + + + Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. +https://en.wikipedia.org/wiki/Sawtooth_wave +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for the sawtooth wave stimuli
uniforms + + +Object + + + + default uniforms for sawtooth wave shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uFreq + + +float + + + + + + 1.0 + + frequency of sawtooth wave.
uPhase + + +float + + + + + + 0.0 + + phase of sawtooth wave.
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
tri + + +Object + + + + Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. +https://en.wikipedia.org/wiki/Triangle_wave +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for the triangle wave stimuli
uniforms + + +Object + + + + default uniforms for triangle wave shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uFreq + + +float + + + + + + 1.0 + + frequency of triangle wave.
uPhase + + +float + + + + + + 0.0 + + phase of triangle wave.
uPeriod + + +float + + + + + + 1.0 + + period of triangle wave.
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
sinXsin + + +Object + + + + Creates an image of two 2d sine waves multiplied with each other. +https://en.wikipedia.org/wiki/Sine_wave +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for the two multiplied sine waves stimuli
uniforms + + +Object + + + + default uniforms for shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uFreq + + +float + + + + + + 1.0 + + frequency of sine wave (both of them).
uPhase + + +float + + + + + + 0.0 + + phase of sine wave (both of them).
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
sqrXsqr + + +Object + + + + Creates an image of two 2d square waves multiplied with each other. +https://en.wikipedia.org/wiki/Square_wave +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for the two multiplied sine waves stimuli
uniforms + + +Object + + + + default uniforms for shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uFreq + + +float + + + + + + 1.0 + + frequency of sine wave (both of them).
uPhase + + +float + + + + + + 0.0 + + phase of sine wave (both of them).
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
circle + + +Object + + + + Creates a filled circle shape with sharp edges. +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for filled circle.
uniforms + + +Object + + + + default uniforms for shader. +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uRadius + + +float + + + + + + 1.0 + + Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim +and 1.0 is circle that spans from edge to edge of the stim.
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
gauss + + +Object + + + + Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. +https://en.wikipedia.org/wiki/Gaussian_function +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for Gaussian shader
uniforms + + +Object + + + + default uniforms for shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uA + + +float + + + + + + 1.0 + + A constant for gaussian formula (see link).
uB + + +float + + + + + + 0.0 + + B constant for gaussian formula (see link).
uC + + +float + + + + + + 0.16 + + C constant for gaussian formula (see link).
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
cross + + +Object + + + + Creates a filled cross shape with sharp edges. +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for cross shader
uniforms + + +Object + + + + default uniforms for shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uThickness + + +float + + + + + + 0.2 + + Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes +invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim.
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
radRamp + + +Object + + + + Creates 2d radial ramp image. +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for radial ramp shader
uniforms + + +Object + + + + default uniforms for shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uSqueeze + + +float + + + + + + 1.0 + + coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large +it fills the entire stim and Infinity makes it so tiny it's invisible.
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
raisedCos + + +Object + + + + Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. +https://en.wikipedia.org/wiki/Raised-cosine_filter +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shader + + +String + + + + shader source code for raised-cosine shader
uniforms + + +Object + + + + default uniforms for shader +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDefaultDescription
uBeta + + +float + + + + + + 0.25 + + roll-off factor (see link).
uPeriod + + +float + + + + + + 0.625 + + reciprocal of the symbol-rate (see link).
uAlpha + + +float + + + + + + 1.0 + + value of the alpha channel.
+ +
+ +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setBlendmode

+ + + + +
+ Set blend mode of the grating stimulus. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setColor

+ + + + +
+ Set foreground color value for the grating stimulus. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setColorSpace

+ + + + +
+ Set color space value for the grating stimulus. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setInterpolate

+ + + + +
+ Whether to interpolate (linearly) the texture in the stimulus. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setMask

+ + + + +
+ Setter for the mask attribute. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setOpacity

+ + + + +
+ Determines how visible the stimulus is relative to background. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setPhase

+ + + + +
+ Set phase value for the function. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setSF

+ + + + +
+ Set spatial frequency value for the function. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setTex

+ + + + +
+ Setter for the tex attribute. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + +

Methods

+ + + + + + + +

(protected) _estimateBoundingBox()

+ + + + + + +
+ Estimate the bounding box. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +

(protected) _getPixiMeshFromPredefinedShaders(shaderName, uniforms) → {Pixi.Mesh}

+ + + + + + +
+ Generate PIXI.Mesh object based on provided shader function name and uniforms. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
shaderName + + +String + + + + name of the shader. Must be one of the SHADERS
uniforms + + +Object + + + + a set of uniforms to supply to the shader. Mixed together with default uniform values.
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ Pixi.Mesh object that represents shader and later added to the scene. +
+ + + +
+
+ Type +
+
+ +Pixi.Mesh + + +
+
+ + + + + + + + + + + + + +
+ +
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time) +
+ + + + + \ No newline at end of file diff --git a/docs/module-visual.ImageStim.html b/docs/module-visual.ImageStim.html index ae9d0f59..f857d66e 100644 --- a/docs/module-visual.ImageStim.html +++ b/docs/module-visual.ImageStim.html @@ -910,7 +910,7 @@
Properties
Source:
@@ -1015,7 +1015,7 @@

setImageSource:
@@ -1077,7 +1077,7 @@

setMaskSource:
@@ -1157,7 +1157,7 @@

(protect
Source:
@@ -1245,7 +1245,7 @@

(protect
Source:
@@ -1291,13 +1291,13 @@

(protect
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.MovieStim.html b/docs/module-visual.MovieStim.html index f28a22ae..1126cc5b 100644 --- a/docs/module-visual.MovieStim.html +++ b/docs/module-visual.MovieStim.html @@ -29,7 +29,7 @@

Class: MovieStim

- visual.MovieStim(options)

+ visual.MovieStim(options, movie)

@@ -42,7 +42,7 @@

-

new MovieStim(options)

+

new MovieStim(options, movie)

@@ -74,8 +74,12 @@
Parameters:
Type + Attributes + + Default + Description @@ -99,7 +103,19 @@
Parameters:
+ + + + + + + + + + + + @@ -116,12 +132,8 @@
Properties
Type - Attributes - - Default - Description @@ -145,19 +157,7 @@
Properties
- - - - - - - - - - - - the name used when logging messages from this stimulus @@ -180,25 +180,20 @@
Properties
- - - - - - - - - - - - the associated Window + + + + + + + @@ -212,6 +207,9 @@
Properties
| HTMLVideoElement +| + +module:visual.Camera @@ -233,14 +231,15 @@
Properties
- the name of the movie resource or the HTMLVideoElement corresponding to the movie + the name of a +movie resource or of a HTMLVideoElement or of a Camera component - units + options.units @@ -279,7 +278,7 @@
Properties
- pos + options.pos @@ -318,7 +317,7 @@
Properties
- units + options.units @@ -357,7 +356,7 @@
Properties
- ori + options.ori @@ -396,7 +395,7 @@
Properties
- size + options.size @@ -433,7 +432,7 @@
Properties
- color + options.color @@ -472,7 +471,7 @@
Properties
- opacity + options.opacity @@ -511,7 +510,7 @@
Properties
- contrast + options.contrast @@ -550,7 +549,7 @@
Properties
- interpolate + options.interpolate @@ -589,7 +588,7 @@
Properties
- flipHoriz + options.flipHoriz @@ -628,7 +627,7 @@
Properties
- flipVert + options.flipVert @@ -667,7 +666,7 @@
Properties
- loop + options.loop @@ -706,7 +705,7 @@
Properties
- volume + options.volume @@ -745,7 +744,7 @@
Properties
- noAudio + options.noAudio @@ -784,7 +783,7 @@
Properties
- autoPlay + options.autoPlay @@ -823,7 +822,7 @@
Properties
- autoDraw + options.autoDraw @@ -862,7 +861,7 @@
Properties
- autoLog + options.autoLog @@ -901,13 +900,6 @@
Properties
- - - - - - - @@ -942,7 +934,7 @@
Properties
Source:
@@ -1054,7 +1046,7 @@

setMovieSource:
@@ -1088,13 +1080,13 @@

setMovie
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.Polygon.html b/docs/module-visual.Polygon.html index d4ca2a1d..5b140d8e 100644 --- a/docs/module-visual.Polygon.html +++ b/docs/module-visual.Polygon.html @@ -820,7 +820,7 @@
Properties
Source:
@@ -925,7 +925,7 @@

setEdgesSource:
@@ -987,7 +987,7 @@

setRadiusSource:
@@ -1021,13 +1021,13 @@

setRadius
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.Rect.html b/docs/module-visual.Rect.html index d2afb43c..4f263730 100644 --- a/docs/module-visual.Rect.html +++ b/docs/module-visual.Rect.html @@ -826,7 +826,7 @@
Properties
Source:
@@ -931,7 +931,7 @@

setHeightSource:
@@ -993,7 +993,7 @@

setWidthSource:
@@ -1027,13 +1027,13 @@

setWidth
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.ShapeStim.html b/docs/module-visual.ShapeStim.html index 860231f8..c816cb9b 100644 --- a/docs/module-visual.ShapeStim.html +++ b/docs/module-visual.ShapeStim.html @@ -824,7 +824,7 @@
Properties
Source:
@@ -929,7 +929,7 @@

(static, readonl
Source:
@@ -991,7 +991,7 @@

(protected) <
Source:
@@ -1055,7 +1055,7 @@

containsSource:
@@ -1117,7 +1117,7 @@

setVertice
Source:
@@ -1197,7 +1197,7 @@

(protect
Source:
@@ -1243,13 +1243,13 @@

(protect
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.Slider.html b/docs/module-visual.Slider.html index f4b93a72..619f4232 100644 --- a/docs/module-visual.Slider.html +++ b/docs/module-visual.Slider.html @@ -1104,7 +1104,7 @@
Properties
Source:
@@ -1227,7 +1227,7 @@

containsSource:
@@ -1289,7 +1289,7 @@

getRatingSource:
@@ -1351,7 +1351,7 @@

getRTSource:
@@ -1418,7 +1418,7 @@

refreshSource:
@@ -1480,7 +1480,7 @@

resetSource:
@@ -1545,7 +1545,7 @@

setMarker
Source:
@@ -1612,7 +1612,7 @@

setOriSource:
@@ -1679,7 +1679,7 @@

setPosSource:
@@ -1743,7 +1743,7 @@

setRatingSource:
@@ -1807,7 +1807,7 @@

setReadOnl
Source:
@@ -1874,7 +1874,7 @@

setSizeSource:
@@ -1946,7 +1946,7 @@
Type:
Source:
@@ -2018,7 +2018,7 @@
Type:
Source:
@@ -2090,7 +2090,7 @@
Type:
Source:
@@ -2175,7 +2175,7 @@

(protect
Source:
@@ -2220,7 +2220,8 @@

(protected)
Generate a callback that prepares updates to the stimulus. -This is typically called in the constructor of a stimulus, when attributes are added with _addAttribute. +This is typically called in the constructor of a stimulus, when attributes are added + with _addAttribute.
@@ -2292,7 +2293,8 @@

Parameters:
- whether or not the PIXI representation must also be updated + whether or not the PIXI representation must + also be updated @@ -2331,7 +2333,8 @@
Parameters:
- whether or not to immediately estimate the bounding box + whether or not to immediately estimate + the bounding box @@ -2377,7 +2380,7 @@
Parameters:
Source:
@@ -2483,7 +2486,7 @@

(protecte
Source:
@@ -2513,6 +2516,116 @@

(protecte + + + + + + +

isMarkerDragging() → {boolean}

+ + + + + + +
+ Query whether or not the marker is currently being dragged. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ whether or not the marker is being dragged +
+ + + +
+
+ Type +
+
+ +boolean + + +
+
+ + + + + + + @@ -2714,7 +2827,7 @@
Parameters:
Source:
@@ -2760,13 +2873,13 @@
Parameters:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.TextBox.html b/docs/module-visual.TextBox.html index f8e4d51b..126ef22a 100644 --- a/docs/module-visual.TextBox.html +++ b/docs/module-visual.TextBox.html @@ -347,7 +347,7 @@
Properties
- the background color + color of the text @@ -737,7 +737,7 @@
Properties
- whether or not a textarea is used + whether or not a multiline element is used @@ -859,6 +859,80 @@
Properties
+ + + fillColor + + + + + +Color + + + + + + + + + <optional>
+ + + + + + + + + + + + + + + fill color of the text-box + + + + + + + borderColor + + + + + +Color + + + + + + + + + <optional>
+ + + + + + + + + + + + + + + border color of the text-box + + + + clipMask @@ -975,6 +1049,45 @@
Properties
+ + + + fitToContent + + + + + +boolean + + + + + + + + + <optional>
+ + + + + + + + + + + + false + + + + + whether or not to resize itself automaitcally to fit to the text content + + + @@ -1027,7 +1140,7 @@
Properties
Source:
@@ -1088,6 +1201,68 @@

Members

+

(protected) _addEventListeners

+ + + + +
+ Add event listeners to text-box object. Method is called internally upon object construction. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + +

(protected) _getDefaultLetterHeight

@@ -1132,7 +1307,7 @@

(prot
Source:
@@ -1194,7 +1369,7 @@

clearSource:
@@ -1256,7 +1431,7 @@

getTextSource:
@@ -1318,7 +1493,7 @@

resetSource:
@@ -1336,13 +1511,13 @@

resetsetSize

+

setAlignment

- Setter for the size attribute. + Setter for the alignment attribute.
@@ -1380,7 +1555,7 @@

setSizeSource:
@@ -1398,13 +1573,13 @@

setSizesetText

+

setAnchor

- For tweaking the underlying input value. + Setter for the anchor attribute.
@@ -1442,7 +1617,7 @@

setTextSource:
@@ -1459,34 +1634,78 @@

setTextsetBorderColor

+ + + + +
+ Setter for the borderColor attribute. +
+ + + + + + + +
+ -

Methods

- - + -

(protected) _estimateBoundingBox()

+ + + + + + + + + + + +
Source:
+
+ + + + + + + +
-
- Estimate the bounding box. -
+ + +

setColor

+ + +
+ Setter for the color attribute. +
+ @@ -1522,7 +1741,459 @@

(protect
Source:
+ + + + + + + +

+ + + + + + + + +

setFillColor

+ + + + +
+ Setter for the fillColor attribute. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setFitToContent

+ + + + +
+ Setter for the fitToContent attribute. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setFont

+ + + + +
+ Set the font for textbox. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setLetterHeight

+ + + + +
+ Set letterHeight (font size) for textbox. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setSize

+ + + + +
+ Setter for the size attribute. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + +

setText

+ + + + +
+ For tweaking the underlying input value. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + +

Methods

+ + + + + + + +

(protected) _estimateBoundingBox()

+ + + + + + +
+ Estimate the bounding box. +
+ + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
@@ -1610,7 +2281,7 @@

(protected) Source:
@@ -1678,13 +2349,13 @@

Returns:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.TextStim.html b/docs/module-visual.TextStim.html index 232fcd2b..f368435d 100644 --- a/docs/module-visual.TextStim.html +++ b/docs/module-visual.TextStim.html @@ -1019,7 +1019,7 @@
Properties
Source:
@@ -1131,7 +1131,7 @@

(prot
Source:
@@ -1193,7 +1193,7 @@

(protect
Source:
@@ -1219,8 +1219,8 @@

getText
Get the metrics estimated for the text and style. -Note: getTextMetrics does not require the PIXI representation of the stimulus to be instantiated, -unlike getSize(). +Note: getTextMetrics does not require the PIXI representation of the stimulus +to be instantiated, unlike getSize().
@@ -1258,7 +1258,69 @@

getText
Source:
+ + + + + + + +

+ + + + + + + + +

setColor

+ + + + +
+ Setter for the color attribute. +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
@@ -1338,7 +1400,7 @@

(protect
Source:
@@ -1368,6 +1430,185 @@

(protect + + + + + + +

(protected) getBoundingBox(tightopt) → {Array.<number>}

+ + + + + + +
+ Get the bounding gox. +
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
tight + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to fit as closely as possible to the text
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + +
Returns:
+ + +
+ - the bounding box, in the units of this TextStim +
+ + + +
+
+ Type +
+
+ +Array.<number> + + +
+
+ + + + + + + @@ -1384,13 +1625,13 @@

(protect
- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.VisualStim.html b/docs/module-visual.VisualStim.html index e3c317e0..f2c3536f 100644 --- a/docs/module-visual.VisualStim.html +++ b/docs/module-visual.VisualStim.html @@ -602,7 +602,7 @@
Properties
Source:
@@ -707,7 +707,7 @@

containsSource:
@@ -771,7 +771,7 @@

refreshSource:
@@ -833,7 +833,7 @@

setOriSource:
@@ -895,7 +895,7 @@

setPosSource:
@@ -957,7 +957,7 @@

setSizeSource:
@@ -1037,7 +1037,7 @@

(protect
Source:
@@ -1082,7 +1082,8 @@

(protected)
Generate a callback that prepares updates to the stimulus. -This is typically called in the constructor of a stimulus, when attributes are added with _addAttribute. +This is typically called in the constructor of a stimulus, when attributes are added + with _addAttribute.
@@ -1154,7 +1155,8 @@

Parameters:
- whether or not the PIXI representation must also be updated + whether or not the PIXI representation must + also be updated @@ -1193,7 +1195,8 @@
Parameters:
- whether or not to immediately estimate the bounding box + whether or not to immediately estimate + the bounding box @@ -1234,7 +1237,7 @@
Parameters:
Source:
@@ -1340,7 +1343,7 @@

(protected) c
Source:
@@ -1408,13 +1411,13 @@

Returns:

- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module-visual.html b/docs/module-visual.html index 42237323..29cecb20 100644 --- a/docs/module-visual.html +++ b/docs/module-visual.html @@ -52,9 +52,18 @@

Classes

ButtonStim
+
Camera
+
+ +
FaceDetector
+
+
Form
+
GratingStim
+
+
ImageStim
@@ -107,13 +116,13 @@

Classes


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:29 GMT+0200 (Central European Summer Time)
diff --git a/docs/module.data.MultiStairHandler.html b/docs/module.data.MultiStairHandler.html new file mode 100644 index 00000000..b95015e1 --- /dev/null +++ b/docs/module.data.MultiStairHandler.html @@ -0,0 +1,600 @@ + + + + + JSDoc: Class: MultiStairHandler + + + + + + + + + + +
+ +

Class: MultiStairHandler

+ + + + + + +
+ +
+ +

MultiStairHandler(options)

+ + +
+ +
+
+ + + + + + +

new MultiStairHandler(options)

+ + + + + + +
+

A handler dealing with multiple staircases, simultaneously.

+ +

Note that, at the moment, using the MultiStairHandler requires the jsQuest.js +library to be loaded as a resource, at the start of the experiment.

+
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + the handler options +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
psychoJS + + +module:core.PsychoJS + + + + + + + + + + + + the PsychoJS instance
varName + + +string + + + + + + + + + + + + the name of the variable / intensity / contrast + / threshold manipulated by the staircases
stairType + + +module:data.MultiStairHandler.StaircaseType + + + + + + <optional>
+ + + + + +
+ + "simple" + + the + handler type
conditions + + +Array.<Object> +| + +String + + + + + + <optional>
+ + + + + +
+ + [undefined] + + if it is a string, + we treat it as the name of a conditions resource
method + + +module:data.TrialHandler.Method + + + + + + + + + + + + the trial method
nTrials + + +number + + + + + + <optional>
+ + + + + +
+ + 50 + + maximum number of trials
randomSeed + + +number + + + + + + + + + + + + seed for the random number generator
name + + +string + + + + + + + + + + + + name of the handler
autoLog + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to log
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + +

Extends

+ + + + +
    +
  • TrialHandler
  • +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + \ No newline at end of file diff --git a/docs/module.data.QuestHandler.html b/docs/module.data.QuestHandler.html new file mode 100644 index 00000000..ced475ca --- /dev/null +++ b/docs/module.data.QuestHandler.html @@ -0,0 +1,845 @@ + + + + + JSDoc: Class: QuestHandler + + + + + + + + + + +
+ +

Class: QuestHandler

+ + + + + + +
+ +
+ +

QuestHandler(options)

+ + +
+ +
+
+ + + + + + +

new QuestHandler(options)

+ + + + + + +
+

A Trial Handler that implements the Quest algorithm for quick measurement of + psychophysical thresholds. QuestHandler relies on the jsQuest library, a port of Prof Dennis Pelli's QUEST algorithm by Daiichiro Kuroki.

+
+ + + + + + + + + +
Parameters:
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeDescription
options + + +Object + + + + the handler options +
Properties
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
NameTypeAttributesDefaultDescription
psychoJS + + +module:core.PsychoJS + + + + + + + + + + + + the PsychoJS instance
varName + + +string + + + + + + + + + + + + the name of the variable / intensity / contrast / threshold manipulated by QUEST
startVal + + +number + + + + + + + + + + + + initial guess for the threshold
startValSd + + +number + + + + + + + + + + + + standard deviation of the initial guess
minVal + + +number + + + + + + + + + + + + minimum value for the threshold
maxVal + + +number + + + + + + + + + + + + maximum value for the threshold
pThreshold + + +number + + + + + + <optional>
+ + + + + +
+ + 0.82 + + threshold criterion expressed as probability of getting a correct response
nTrials + + +number + + + + + + + + + + + + maximum number of trials
stopInterval + + +number + + + + + + + + + + + + minimum [5%, 95%] confidence interval required for the loop to stop
method + + +module:data.QuestHandler.Method + + + + + + + + + + + + the QUEST method
beta + + +number + + + + + + <optional>
+ + + + + +
+ + 3.5 + + steepness of the QUEST psychometric function
delta + + +number + + + + + + <optional>
+ + + + + +
+ + 0.01 + + fraction of trials with blind responses
gamma + + +number + + + + + + <optional>
+ + + + + +
+ + 0.5 + + fraction of trails that would generate a correct response when the threshold is infinitely small
grain + + +number + + + + + + <optional>
+ + + + + +
+ + 0.01 + + quantization of the internal table
name + + +string + + + + + + + + + + + + name of the handler
autoLog + + +boolean + + + + + + <optional>
+ + + + + +
+ + false + + whether or not to log
+ +
+ + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + +
Source:
+
+ + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
+ + +

Extends

+ + + + +
    +
  • TrialHandler
  • +
+ + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + \ No newline at end of file diff --git a/docs/sound_AudioClip.js.html b/docs/sound_AudioClip.js.html index aa98c2ed..d3b28f46 100644 --- a/docs/sound_AudioClip.js.html +++ b/docs/sound_AudioClip.js.html @@ -27,7 +27,7 @@

Source: sound/AudioClip.js

/**
- * AudioClip encapsulate an audio recording.
+ * AudioClip encapsulates an audio recording.
  *
  * @author Alain Pitiot and Sotiri Bakagiannis
  * @version 2021.2.0
@@ -35,14 +35,13 @@ 

Source: sound/AudioClip.js

* @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'; - +import { PsychoJS } from "../core/PsychoJS.js"; +import { ExperimentHandler } from "../data/ExperimentHandler.js"; +import { PsychObject } from "../util/PsychObject.js"; +import * as util from "../util/Util.js"; /** - * <p>AudioClip encapsulate an audio recording.</p> + * <p>AudioClip encapsulates an audio recording.</p> * * @name module:sound.AudioClip * @class @@ -56,17 +55,19 @@

Source: sound/AudioClip.js

*/ export class AudioClip extends PsychObject { - - constructor({psychoJS, name, sampleRateHz, format, data, autoLog} = {}) + 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); + 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); + + // add a volume attribute, for playback: + this._addAttribute("volume", 1.0); if (this._autoLog) { @@ -77,6 +78,18 @@

Source: sound/AudioClip.js

this._decodeAudio(); } + /** + * Set the volume of the playback. + * + * @name module:sound.AudioClip#setVolume + * @function + * @public + * @param {number} volume - the volume of the playback (must be between 0.0 and 1.0) + */ + setVolume(volume) + { + this._volume = volume; + } /** * Start playing the audio clip. @@ -87,35 +100,64 @@

Source: sound/AudioClip.js

*/ async startPlayback() { - this._psychoJS.logger.debug('request to play the audio clip'); + this._psychoJS.logger.debug("request to play the audio clip"); // wait for the decoding to complete: await this._decodeAudio(); - // play the audio buffer: - if (!this._source) - { - this._source = this._audioContext.createBufferSource(); - } + // note: we need to prepare the audio graph anew each time since, for instance, an + // AudioBufferSourceNode can only be played once + // ref: https://developer.mozilla.org/en-US/docs/Web/API/AudioBufferSourceNode + // create a source node from the in-memory audio data in _audioBuffer: + this._source = this._audioContext.createBufferSource(); this._source.buffer = this._audioBuffer; - this._source.connect(this._audioContext.destination); + + // create a gain node, so we can control the volume: + this._gainNode = this._audioContext.createGain(); + + // connect the nodes: + this._source.connect(this._gainNode); + this._gainNode.connect(this._audioContext.destination); + + // set the volume: + this._gainNode.gain.value = this._volume; + + // start the playback: this._source.start(); } - /** * Stop playing the audio clip. * * @name module:sound.AudioClip#startPlayback * @function * @public + * @param {number} [fadeDuration = 17] - how long the fading out should last, in ms */ - async stopPlayback() + async stopPlayback(fadeDuration = 17) { - // TODO + // TODO deal with fade duration + + // stop the playback: + this._source.stop(); } + /** + * Get the duration of the audio clip, in seconds. + * + * @name module:sound.AudioClip#getDuration + * @function + * @public + * @returns {Promise<number>} the duration of the audio clip + */ + async getDuration() + { + // wait for the decoding to complete: + await this._decodeAudio(); + + return this._audioBuffer.duration; + } /** * Upload the audio clip to the pavlovia server. @@ -126,27 +168,29 @@

Source: sound/AudioClip.js

*/ upload() { - this._psychoJS.logger.debug('request to upload the audio clip to pavlovia.org'); + 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')) + 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); + return this._psychoJS.serverManager.uploadAudioVideo({ + mediaBlob: this._data, + tag: filename + }); } - - /** * Offer the audio clip to the participant as a sound file to download. * @@ -154,9 +198,9 @@

Source: sound/AudioClip.js

* @function * @public */ - download(filename = 'audio.webm') + download(filename = "audio.webm") { - const anchor = document.createElement('a'); + const anchor = document.createElement("a"); anchor.href = window.URL.createObjectURL(this._data); anchor.download = filename; document.body.appendChild(anchor); @@ -164,21 +208,24 @@

Source: sound/AudioClip.js

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 {Symbol} options.engine - the speech-to-text engine * @param {String} options.languageCode - the BCP-47 language code for the recognition, - * e.g. 'en-gb' - * @return {Promise<void>} + * e.g. 'en-GB' + * @return {Promise} a promise resolving to the transcript and associated + * transcription confidence */ - async transcribe({engine, languageCode} = {}) + async transcribe({ engine, languageCode } = {}) { - this._psychoJS.logger.debug('request to transcribe the audio clip'); + const response = { + origin: "AudioClip.transcribe", + context: `when transcribing audio clip: ${this._name}`, + }; + + this._psychoJS.logger.debug(response); // get the secret key from the experiment configuration: const fullEngineName = `sound.AudioClip.Engine.${Symbol.keyFor(engine)}`; @@ -190,19 +237,43 @@

Source: sound/AudioClip.js

transcriptionKey = key.value; } } - if (typeof transcriptionKey === 'undefined') + if (typeof transcriptionKey === "undefined") { throw { - origin: 'AudioClip.transcribe', - context: `when transcribing audio clip: ${this._name}`, - error: `missing key for engine: ${fullEngineName}` + ...response, + error: `missing key for engine: ${fullEngineName}`, }; } - // wait for the decoding to complete: await this._decodeAudio(); + // dispatch on engine: + if (engine === AudioClip.Engine.GOOGLE) + { + return this._GoogleTranscribe(transcriptionKey, languageCode); + } + else + { + throw { + ...response, + error: `unsupported speech-to-text engine: ${engine}`, + }; + } + } + + /** + * Transcribe the audio clip using the Google Cloud Speech-To-Text Engine. + * + * ref: https://cloud.google.com/speech-to-text/docs/reference/rest/v1/speech/recognize + * + * @param {String} transcriptionKey - the secret key to the Google service + * @param {String} languageCode - the BCP-47 language code for the recognition, e.g. 'en-GB' + * @return {Promise} a promise resolving to the transcript and associated + * transcription confidence + */ + _GoogleTranscribe(transcriptionKey, languageCode) + { return new Promise(async (resolve, reject) => { // convert the Float32 PCM audio data to UInt16: @@ -221,31 +292,31 @@

Source: sound/AudioClip.js

// query the Google speech-to-text service: const body = { config: { - encoding: 'LINEAR16', + encoding: "LINEAR16", sampleRateHertz: this._sampleRateHz, - languageCode + languageCode, }, audio: { - content: base64Data + content: base64Data, }, }; const url = `https://speech.googleapis.com/v1/speech:recognize?key=${transcriptionKey}`; const response = await fetch(url, { - method: 'POST', + method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, - body: JSON.stringify(body) + 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)); + this._psychoJS.logger.debug("speech.googleapis.com response:", JSON.stringify(decodedResponse)); // TODO deal with more than one results and/or alternatives - if (('results' in decodedResponse) && (decodedResponse.results.length > 0)) + if (("results" in decodedResponse) && (decodedResponse.results.length > 0)) { resolve(decodedResponse.results[0].alternatives[0]); } @@ -253,23 +324,20 @@

Source: sound/AudioClip.js

{ // no transcription available: resolve({ - transcript: '', - confidence: -1 + transcript: "", + confidence: -1, }); } }); } - /** * Decode the formatted audio data (e.g. webm) into a 32bit float PCM audio buffer. * - * @returns {Promise<unknown>} - * @private */ _decodeAudio() { - this._psychoJS.logger.debug('request to decode the data of the audio clip'); + 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) @@ -277,12 +345,11 @@

Source: sound/AudioClip.js

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) + return new Promise(function(resolve, reject) { self._decodingCallbacks.push(resolve); @@ -290,16 +357,16 @@

Source: sound/AudioClip.js

}.bind(this)); } - // otherwise, start decoding the input formatted audio data: this._status = AudioClip.Status.DECODING; this._audioData = null; + this._source = null; + this._gainNode = null; this._decodingCallbacks = []; this._audioContext = new (window.AudioContext || window.webkitAudioContext)({ - sampleRate: this._sampleRateHz + sampleRate: this._sampleRateHz, }); - this._source = null; const reader = new window.FileReader(); reader.onloadend = async () => @@ -333,84 +400,84 @@

Source: sound/AudioClip.js

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: + * @note this is heavily inspired by the following post by @Grantlyk: * https://gist.github.com/jonleighton/958841#gistcomment-1953137 - * - * the following only works for small buffers: + * It is necessary since the following approach only works for small buffers: * const dataAsString = String.fromCharCode.apply(null, new Uint8Array(buffer)); * base64Data = window.btoa(dataAsString); * - * @param arrayBuffer + * @param arrayBuffer - the input buffer * @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]; - } + 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]; - // Deal with the remaining bytes and padding - if (byteRemainder === 1) { - chunk = bytes[mainLength]; + // 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]; + } - a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2 + // Deal with the remaining bytes and padding + if (byteRemainder === 1) + { + chunk = bytes[mainLength]; - // Set the 4 least significant bits to zero - b = (chunk & 3) << 4; // 3 = 2^2 - 1 + a = (chunk & 252) >> 2; // 252 = (2^6 - 1) << 2 - base64 += `${encodings[a]}${encodings[b]}==`; - } else if (byteRemainder === 2) { - chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1]; + // Set the 4 least significant bits to zero + b = (chunk & 3) << 4; // 3 = 2^2 - 1 - a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10 - b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4 + base64 += `${encodings[a]}${encodings[b]}==`; + } + else if (byteRemainder === 2) + { + chunk = (bytes[mainLength] << 8) | bytes[mainLength + 1]; - // Set the 2 least significant bits to zero - c = (chunk & 15) << 2; // 15 = 2^4 - 1 + a = (chunk & 64512) >> 10; // 64512 = (2^6 - 1) << 10 + b = (chunk & 1008) >> 4; // 1008 = (2^6 - 1) << 4 - base64 += `${encodings[a]}${encodings[b]}${encodings[c]}=`; - } + // Set the 2 least significant bits to zero + c = (chunk & 15) << 2; // 15 = 2^4 - 1 - return base64; -} + base64 += `${encodings[a]}${encodings[b]}${encodings[c]}=`; + } + return base64; + } } - /** * Recognition engines. * @@ -423,10 +490,9 @@

Source: sound/AudioClip.js

/** * Google Cloud Speech-to-Text. */ - GOOGLE: Symbol.for('GOOGLE') + GOOGLE: Symbol.for("GOOGLE"), }; - /** * AudioClip status. * @@ -435,11 +501,11 @@

Source: sound/AudioClip.js

* @public */ AudioClip.Status = { - CREATED: Symbol.for('CREATED'), + CREATED: Symbol.for("CREATED"), - DECODING: Symbol.for('DECODING'), + DECODING: Symbol.for("DECODING"), - READY: Symbol.for('READY') + READY: Symbol.for("READY"), };
@@ -451,13 +517,13 @@

Source: sound/AudioClip.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/sound_AudioClipPlayer.js.html b/docs/sound_AudioClipPlayer.js.html new file mode 100644 index 00000000..2e1f7674 --- /dev/null +++ b/docs/sound_AudioClipPlayer.js.html @@ -0,0 +1,235 @@ + + + + + JSDoc: Source: sound/AudioClipPlayer.js + + + + + + + + + + +
+ +

Source: sound/AudioClipPlayer.js

+ + + + + + +
+
+
/**
+ * AudioClip Player.
+ *
+ * @author Alain Pitiot
+ * @version 2021.2.0
+ * @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
+ */
+
+import { AudioClip } from "./AudioClip.js";
+import { SoundPlayer } from "./SoundPlayer.js";
+
+/**
+ * <p>This class handles the playback of an audio clip, e.g. a microphone recording.</p>
+ *
+ * @name module:sound.AudioClipPlayer
+ * @class
+ * @extends SoundPlayer
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {Object} options.audioClip - the module:sound.AudioClip
+ * @param {number} [options.startTime= 0] - start of playback (in seconds)
+ * @param {number} [options.stopTime= -1] - end of playback (in seconds)
+ * @param {boolean} [options.stereo= true] whether or not to play the sound or track in stereo
+ * @param {number} [options.volume= 1.0] - volume of the sound (must be between 0 and 1.0)
+ * @param {number} [options.loops= 0] - how many times to repeat the track or tone after it has played *
+ */
+export class AudioClipPlayer extends SoundPlayer
+{
+	constructor({
+		psychoJS,
+		audioClip,
+		startTime = 0,
+		stopTime = -1,
+		stereo = true,
+		volume = 0,
+		loops = 0,
+	} = {})
+	{
+		super(psychoJS);
+
+		this._addAttribute("audioClip", audioClip);
+		this._addAttribute("startTime", startTime);
+		this._addAttribute("stopTime", stopTime);
+		this._addAttribute("stereo", stereo);
+		this._addAttribute("loops", loops);
+		this._addAttribute("volume", volume);
+
+		this._currentLoopIndex = -1;
+	}
+
+	/**
+	 * Determine whether this player can play the given sound.
+	 *
+	 * @name module:sound.AudioClipPlayer.accept
+	 * @function
+	 * @static
+	 * @public
+	 * @param {module:sound.Sound} sound - the sound object, which should be an AudioClip
+	 * @return {Object|undefined} an instance of AudioClipPlayer if sound is an AudioClip or undefined otherwise
+	 */
+	static accept(sound)
+	{
+		if (sound.value instanceof AudioClip)
+		{
+			// build the player:
+			const player = new AudioClipPlayer({
+				psychoJS: sound.psychoJS,
+				audioClip: sound.value,
+				startTime: sound.startTime,
+				stopTime: sound.stopTime,
+				stereo: sound.stereo,
+				loops: sound.loops,
+				volume: sound.volume,
+			});
+			return player;
+		}
+
+		// AudioClipPlayer is not an appropriate player for the given sound:
+		return undefined;
+	}
+
+	/**
+	 * Get the duration of the AudioClip, in seconds.
+	 *
+	 * @name module:sound.AudioClipPlayer#getDuration
+	 * @function
+	 * @public
+	 * @return {number} the duration of the clip, in seconds
+	 */
+	getDuration()
+	{
+		return this._audioClip.getDuration();
+	}
+
+	/**
+	 * Set the duration of the audio clip.
+	 *
+	 * @name module:sound.AudioClipPlayer#setDuration
+	 * @function
+	 * @public
+	 * @param {number} duration_s - the duration of the clip in seconds
+	 */
+	setDuration(duration_s)
+	{
+		// TODO
+
+		throw {
+			origin: "AudioClipPlayer.setDuration",
+			context: "when setting the duration of the playback for audio clip player: " + this._name,
+			error: "not implemented yet",
+		};
+	}
+
+	/**
+	 * Set the volume of the playback.
+	 *
+	 * @name module:sound.AudioClipPlayer#setVolume
+	 * @function
+	 * @public
+	 * @param {number} volume - the volume of the playback (must be between 0.0 and 1.0)
+	 * @param {boolean} [mute= false] - whether or not to mute the playback
+	 */
+	setVolume(volume, mute = false)
+	{
+		this._volume = volume;
+
+		this._audioClip.setVolume((mute) ? 0.0 : volume);
+	}
+
+	/**
+	 * Set the number of loops.
+	 *
+	 * @name module:sound.AudioClipPlayer#setLoops
+	 * @function
+	 * @public
+	 * @param {number} loops - how many times to repeat the clip after it has played once. If loops == -1, the clip will repeat indefinitely until stopped.
+	 */
+	setLoops(loops)
+	{
+		this._loops = loops;
+		this._currentLoopIndex = -1;
+
+		// TODO
+	}
+
+	/**
+	 * Start playing the sound.
+	 *
+	 * @name module:sound.AudioClipPlayer#play
+	 * @function
+	 * @public
+	 * @param {number} loops - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped.
+	 * @param {number} [fadeDuration = 17] - how long should the fading in last in ms
+	 */
+	play(loops, fadeDuration = 17)
+	{
+		if (typeof loops !== "undefined")
+		{
+			this.setLoops(loops);
+		}
+
+		// handle repeats:
+		if (loops > 0)
+		{
+			// TODO
+		}
+
+		this._audioClip.startPlayback();
+	}
+
+	/**
+	 * Stop playing the sound immediately.
+	 *
+	 * @name module:sound.AudioClipPlayer#stop
+	 * @function
+	 * @public
+	 * @param {number} [fadeDuration = 17] - how long the fading out should last, in ms
+	 */
+	stop(fadeDuration = 17)
+	{
+		this._audioClip.stopPlayback(fadeDuration);
+	}
+}
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + diff --git a/docs/sound_Microphone.js.html b/docs/sound_Microphone.js.html index ec4a58cf..8bac8686 100644 --- a/docs/sound_Microphone.js.html +++ b/docs/sound_Microphone.js.html @@ -35,12 +35,12 @@

Source: sound/Microphone.js

* @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"; +import { PsychoJS } from "../core/PsychoJS.js"; +import { ExperimentHandler } from "../data/ExperimentHandler.js"; +import { Clock } from "../util/Clock.js"; +import { PsychObject } from "../util/PsychObject.js"; +import * as util from "../util/Util.js"; +import { AudioClip } from "./AudioClip.js"; /** * <p>This manager handles the recording of audio signal.</p> @@ -57,18 +57,17 @@

Source: sound/Microphone.js

*/ export class Microphone extends PsychObject { - - constructor({win, name, format, sampleRateHz, clock, autoLog} = {}) + constructor({ win, name, format, sampleRateHz, clock, autoLog } = {}) { super(win._psychoJS); - this._addAttribute('win', win, undefined); - this._addAttribute('name', name, 'microphone'); - this._addAttribute('format', format, 'audio/webm;codecs=opus', this._onChange); - this._addAttribute('sampleRateHz', sampleRateHz, 48000, this._onChange); - this._addAttribute('clock', clock, new Clock()); - this._addAttribute('autoLog', false, autoLog); - this._addAttribute('status', PsychoJS.Status.NOT_STARTED); + this._addAttribute("win", win, undefined); + this._addAttribute("name", name, "microphone"); + this._addAttribute("format", format, "audio/webm;codecs=opus", this._onChange); + this._addAttribute("sampleRateHz", sampleRateHz, 48000, this._onChange); + this._addAttribute("clock", clock, new Clock()); + this._addAttribute("autoLog", autoLog, autoLog); + this._addAttribute("status", PsychoJS.Status.NOT_STARTED); // prepare the recording: this._prepareRecording(); @@ -79,7 +78,6 @@

Source: sound/Microphone.js

} } - /** * Submit a request to start the recording. * @@ -96,19 +94,18 @@

Source: sound/Microphone.js

// with a new recording: if (this._status === PsychoJS.Status.PAUSED) { - return this.resume({clear: true}); + return this.resume({ clear: true }); } - if (this._status !== PsychoJS.Status.STARTED) { - this._psychoJS.logger.debug('request to start audio recording'); + 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'; + throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record audio"; } this._recorder.start(); @@ -124,21 +121,18 @@

Source: sound/Microphone.js

} catch (error) { - this._psychoJS.logger.error('unable to start the audio recording: ' + JSON.stringify(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 + origin: "Microphone.start", + context: "when starting the audio recording for microphone: " + this._name, + error, }; } - } - } - /** * Submit a request to stop the recording. * @@ -150,14 +144,14 @@

Source: sound/Microphone.js

* @return {Promise} promise fulfilled when the recording actually stopped, and the recorded * data was made available */ - stop({filename} = {}) + stop({ filename } = {}) { if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED) { - this._psychoJS.logger.debug('request to stop audio recording'); + this._psychoJS.logger.debug("request to stop audio recording"); this._stopOptions = { - filename + filename, }; // note: calling the stop method of the MediaRecorder will first raise a dataavailable event, @@ -176,7 +170,6 @@

Source: sound/Microphone.js

} } - /** * Submit a request to pause the recording. * @@ -188,13 +181,13 @@

Source: sound/Microphone.js

{ if (this._status === PsychoJS.Status.STARTED) { - this._psychoJS.logger.debug('request to pause audio recording'); + 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'; + 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 @@ -210,20 +203,18 @@

Source: sound/Microphone.js

} catch (error) { - self._psychoJS.logger.error('unable to pause the audio recording: ' + JSON.stringify(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 + origin: "Microphone.pause", + context: "when pausing the audio recording for microphone: " + this._name, + error, }; } - } } - /** * Submit a request to resume the recording. * @@ -235,17 +226,17 @@

Source: sound/Microphone.js

* resuming the recording * @return {Promise} promise fulfilled when the recording actually resumed */ - resume({clear = false } = {}) + resume({ clear = false } = {}) { if (this._status === PsychoJS.Status.PAUSED) { - this._psychoJS.logger.debug('request to resume audio recording'); + 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'; + 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: @@ -267,20 +258,18 @@

Source: sound/Microphone.js

} catch (error) { - self._psychoJS.logger.error('unable to resume the audio recording: ' + JSON.stringify(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 + origin: "Microphone.resume", + context: "when resuming the audio recording for microphone: " + this._name, + error, }; } - } } - /** * Submit a request to flush the recording. * @@ -292,7 +281,7 @@

Source: sound/Microphone.js

{ if (this._status === PsychoJS.Status.STARTED || this._status === PsychoJS.Status.PAUSED) { - this._psychoJS.logger.debug('request to flush audio recording'); + this._psychoJS.logger.debug("request to flush audio recording"); // note: calling the requestData method of the MediaRecorder will raise a // dataavailable event @@ -309,7 +298,6 @@

Source: sound/Microphone.js

} } - /** * Offer the audio recording to the participant as a sound file to download. * @@ -318,11 +306,11 @@

Source: sound/Microphone.js

* @public * @param {string} filename the filename */ - download(filename = 'audio.webm') + download(filename = "audio.webm") { const audioBlob = new Blob(this._audioBuffer); - const anchor = document.createElement('a'); + const anchor = document.createElement("a"); anchor.href = window.URL.createObjectURL(audioBlob); anchor.download = filename; document.body.appendChild(anchor); @@ -330,7 +318,6 @@

Source: sound/Microphone.js

document.body.removeChild(anchor); } - /** * Upload the audio recording to the pavlovia server. * @@ -339,10 +326,10 @@

Source: sound/Microphone.js

* @public * @param {string} tag an optional tag for the audio file */ - async upload({tag} = {}) + async upload({ tag } = {}) { // default tag: the name of this Microphone object - if (typeof tag === 'undefined') + if (typeof tag === "undefined") { tag = this._name; } @@ -350,22 +337,25 @@

Source: sound/Microphone.js

// 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')) + 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); + return this._psychoJS.serverManager.uploadAudioVideo({ + mediaBlob: audioBlob, + tag + }); } - /** * Get the current audio recording as an AudioClip in the given format. * @@ -375,27 +365,25 @@

Source: sound/Microphone.js

* @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} = {}) + async getRecording({ tag, flush = false } = {}) { // default tag: the name of this Microphone object - if (typeof tag === 'undefined') + 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) + data: new Blob(this._audioBuffer), }); return audioClip; } - /** * Callback for changes to the recording settings. * @@ -417,7 +405,6 @@

Source: sound/Microphone.js

this.start(); } - /** * Prepare the recording. * @@ -431,26 +418,21 @@

Source: sound/Microphone.js

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 - } - ] - } + 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)) + if (typeof this._format === "string" && MediaRecorder.isTypeSupported(this._format)) { options = { type: this._format }; } @@ -461,7 +443,6 @@

Source: sound/Microphone.js

this._recorder = new MediaRecorder(stream, options); - // setup the callbacks: const self = this; @@ -473,7 +454,7 @@

Source: sound/Microphone.js

self._audioBuffer.length = 0; self._clock.reset(); self._status = PsychoJS.Status.STARTED; - self._psychoJS.logger.debug('audio recording started'); + self._psychoJS.logger.debug("audio recording started"); // resolve the Microphone.start promise: if (self._startCallback) @@ -486,7 +467,7 @@

Source: sound/Microphone.js

this._recorder.onpause = () => { self._status = PsychoJS.Status.PAUSED; - self._psychoJS.logger.debug('audio recording paused'); + self._psychoJS.logger.debug("audio recording paused"); // resolve the Microphone.pause promise: if (self._pauseCallback) @@ -499,7 +480,7 @@

Source: sound/Microphone.js

this._recorder.onresume = () => { self._status = PsychoJS.Status.STARTED; - self._psychoJS.logger.debug('audio recording resumed'); + self._psychoJS.logger.debug("audio recording resumed"); // resolve the Microphone.resume promise: if (self._resumeCallback) @@ -515,7 +496,7 @@

Source: sound/Microphone.js

// add data to the buffer: self._audioBuffer.push(data); - self._psychoJS.logger.debug('audio data added to the buffer'); + self._psychoJS.logger.debug("audio data added to the buffer"); // resolve the data available promise, if needed: if (self._dataAvailableCallback) @@ -527,7 +508,7 @@

Source: sound/Microphone.js

// called upon Microphone.stop(), after data has been made available: this._recorder.onstop = () => { - self._psychoJS.logger.debug('audio recording stopped'); + self._psychoJS.logger.debug("audio recording stopped"); self._status = PsychoJS.Status.NOT_STARTED; // resolve the Microphone.stop promise: @@ -539,7 +520,7 @@

Source: sound/Microphone.js

// treat stop options if there are any: // download to a file, immediately offered to the participant: - if (typeof self._stopOptions.filename === 'string') + if (typeof self._stopOptions.filename === "string") { self.download(self._stopOptions.filename); } @@ -549,15 +530,11 @@

Source: sound/Microphone.js

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

@@ -568,13 +545,13 @@

Source: sound/Microphone.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/sound_Sound.js.html b/docs/sound_Sound.js.html index c0de238d..18991e35 100644 --- a/docs/sound_Sound.js.html +++ b/docs/sound_Sound.js.html @@ -36,12 +36,11 @@

Source: sound/Sound.js

* @license Distributed under the terms of the MIT License */ -import {PsychoJS} from '../core/PsychoJS'; -import {PsychObject} from '../util/PsychObject'; -import {TonePlayer} from './TonePlayer'; -import {TrackPlayer} from './TrackPlayer'; -import {AudioClipPlayer} from './AudioClipPlayer'; - +import { PsychoJS } from "../core/PsychoJS.js"; +import { PsychObject } from "../util/PsychObject.js"; +import { AudioClipPlayer } from "./AudioClipPlayer.js"; +import { TonePlayer } from "./TonePlayer.js"; +import { TrackPlayer } from "./TrackPlayer.js"; /** * <p>This class handles sound playing (tones and tracks)</p> @@ -82,35 +81,35 @@

Source: sound/Sound.js

export class Sound extends PsychObject { constructor({ - name, - win, - value = 'C', - octave = 4, - secs = 0.5, - startTime = 0, - stopTime = -1, - stereo = true, - volume = 1.0, - loops = 0, - //hamming = true, - autoLog = true - } = {}) + name, + win, + value = "C", + octave = 4, + secs = 0.5, + startTime = 0, + stopTime = -1, + stereo = true, + volume = 1.0, + loops = 0, + // hamming = true, + autoLog = true, + } = {}) { super(win._psychoJS, name); // the SoundPlayer, e.g. TonePlayer: this._player = undefined; - this._addAttribute('win', win); - this._addAttribute('value', value); - this._addAttribute('octave', octave); - this._addAttribute('secs', secs); - this._addAttribute('startTime', startTime); - this._addAttribute('stopTime', stopTime); - this._addAttribute('stereo', stereo); - this._addAttribute('volume', volume); - this._addAttribute('loops', loops); - this._addAttribute('autoLog', autoLog); + this._addAttribute("win", win); + this._addAttribute("value", value); + this._addAttribute("octave", octave); + this._addAttribute("secs", secs); + this._addAttribute("startTime", startTime); + this._addAttribute("stopTime", stopTime); + this._addAttribute("stereo", stereo); + this._addAttribute("volume", volume); + this._addAttribute("loops", loops); + this._addAttribute("autoLog", autoLog); // identify an appropriate player: this._getPlayer(); @@ -118,7 +117,6 @@

Source: sound/Sound.js

this.status = PsychoJS.Status.NOT_STARTED; } - /** * Start playing the sound. * @@ -135,7 +133,6 @@

Source: sound/Sound.js

this._player.play(loops); } - /** * Stop playing the sound immediately. * @@ -144,14 +141,13 @@

Source: sound/Sound.js

* @param {boolean} [options.log= true] - whether or not to log */ stop({ - log = true - } = {}) + log = true, + } = {}) { this._player.stop(); this.status = PsychoJS.Status.STOPPED; } - /** * Get the duration of the sound, in seconds. * @@ -163,7 +159,6 @@

Source: sound/Sound.js

return this._player.getDuration(); } - /** * Set the playing volume of the sound. * @@ -174,15 +169,14 @@

Source: sound/Sound.js

*/ setVolume(volume, mute = false, log = true) { - this._setAttribute('volume', volume, log); + this._setAttribute("volume", volume, log); - if (typeof this._player !== 'undefined') + if (typeof this._player !== "undefined") { this._player.setVolume(volume, mute); } } - /** * Set the sound value on demand past initialisation. * @@ -194,9 +188,9 @@

Source: sound/Sound.js

{ if (sound instanceof Sound) { - this._setAttribute('value', sound.value, log); + this._setAttribute("value", sound.value, log); - if (typeof this._player !== 'undefined') + if (typeof this._player !== "undefined") { this._player = this._player.constructor.accept(this); } @@ -206,13 +200,12 @@

Source: sound/Sound.js

} throw { - origin: 'Sound.setSound', - context: 'when replacing the current sound', - error: 'invalid input, need an instance of the Sound class.' + origin: "Sound.setSound", + context: "when replacing the current sound", + error: "invalid input, need an instance of the Sound class.", }; } - /** * Set the number of loops. * @@ -222,15 +215,14 @@

Source: sound/Sound.js

*/ setLoops(loops = 0, log = true) { - this._setAttribute('loops', loops, log); + this._setAttribute("loops", loops, log); - if (typeof this._player !== 'undefined') + if (typeof this._player !== "undefined") { this._player.setLoops(loops); } } - /** * Set the duration (in seconds) * @@ -240,15 +232,14 @@

Source: sound/Sound.js

*/ setSecs(secs = 0.5, log = true) { - this._setAttribute('secs', secs, log); + this._setAttribute("secs", secs, log); - if (typeof this._player !== 'undefined') + if (typeof this._player !== "undefined") { this._player.setDuration(secs); } } - /** * Identify the appropriate player for the sound. * @@ -259,28 +250,26 @@

Source: sound/Sound.js

_getPlayer() { const acceptFns = [ - sound => TonePlayer.accept(sound), - sound => TrackPlayer.accept(sound), - sound => AudioClipPlayer.accept(sound) + (sound) => TonePlayer.accept(sound), + (sound) => TrackPlayer.accept(sound), + (sound) => AudioClipPlayer.accept(sound), ]; for (const acceptFn of acceptFns) { this._player = acceptFn(this); - if (typeof this._player !== 'undefined') + if (typeof this._player !== "undefined") { return this._player; } } throw { - origin: 'SoundPlayer._getPlayer', - context: 'when finding a player for the sound', - error: 'could not find an appropriate player.' + origin: "SoundPlayer._getPlayer", + context: "when finding a player for the sound", + error: "could not find an appropriate player.", }; } - - } @@ -292,13 +281,13 @@

Source: sound/Sound.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/sound_SoundPlayer.js.html b/docs/sound_SoundPlayer.js.html index 0ad77698..3cfca6c6 100644 --- a/docs/sound_SoundPlayer.js.html +++ b/docs/sound_SoundPlayer.js.html @@ -35,8 +35,7 @@

Source: sound/SoundPlayer.js

* @license Distributed under the terms of the MIT License */ -import {PsychObject} from '../util/PsychObject'; - +import { PsychObject } from "../util/PsychObject.js"; /** * <p>SoundPlayer is an interface for the sound players, who are responsible for actually playing the sounds, i.e. the tracks or the tones.</p> @@ -53,7 +52,6 @@

Source: sound/SoundPlayer.js

super(psychoJS); } - /** * Determine whether this player can play the given sound. * @@ -68,13 +66,12 @@

Source: sound/SoundPlayer.js

static accept(sound) { throw { - origin: 'SoundPlayer.accept', - context: 'when evaluating whether this player can play a given sound', - error: 'this method is abstract and should not be called.' + origin: "SoundPlayer.accept", + context: "when evaluating whether this player can play a given sound", + error: "this method is abstract and should not be called.", }; } - /** * Start playing the sound. * @@ -87,13 +84,12 @@

Source: sound/SoundPlayer.js

play(loops) { throw { - origin: 'SoundPlayer.play', - context: 'when starting the playback of a sound', - error: 'this method is abstract and should not be called.' + origin: "SoundPlayer.play", + context: "when starting the playback of a sound", + error: "this method is abstract and should not be called.", }; } - /** * Stop playing the sound immediately. * @@ -105,13 +101,12 @@

Source: sound/SoundPlayer.js

stop() { throw { - origin: 'SoundPlayer.stop', - context: 'when stopping the playback of a sound', - error: 'this method is abstract and should not be called.' + origin: "SoundPlayer.stop", + context: "when stopping the playback of a sound", + error: "this method is abstract and should not be called.", }; } - /** * Get the duration of the sound, in seconds. * @@ -123,13 +118,12 @@

Source: sound/SoundPlayer.js

getDuration() { throw { - origin: 'SoundPlayer.getDuration', - context: 'when getting the duration of the sound', - error: 'this method is abstract and should not be called.' + origin: "SoundPlayer.getDuration", + context: "when getting the duration of the sound", + error: "this method is abstract and should not be called.", }; } - /** * Set the duration of the sound, in seconds. * @@ -141,13 +135,12 @@

Source: sound/SoundPlayer.js

setDuration(duration_s) { throw { - origin: 'SoundPlayer.setDuration', - context: 'when setting the duration of the sound', - error: 'this method is abstract and should not be called.' + origin: "SoundPlayer.setDuration", + context: "when setting the duration of the sound", + error: "this method is abstract and should not be called.", }; } - /** * Set the number of loops. * @@ -160,13 +153,12 @@

Source: sound/SoundPlayer.js

setLoops(loops) { throw { - origin: 'SoundPlayer.setLoops', - context: 'when setting the number of loops', - error: 'this method is abstract and should not be called.' + origin: "SoundPlayer.setLoops", + context: "when setting the number of loops", + error: "this method is abstract and should not be called.", }; } - /** * Set the volume of the tone. * @@ -180,12 +172,11 @@

Source: sound/SoundPlayer.js

setVolume(volume, mute = false) { throw { - origin: 'SoundPlayer.setVolume', - context: 'when setting the volume of the sound', - error: 'this method is abstract and should not be called.' + origin: "SoundPlayer.setVolume", + context: "when setting the volume of the sound", + error: "this method is abstract and should not be called.", }; } - } @@ -197,13 +188,13 @@

Source: sound/SoundPlayer.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/sound_TonePlayer.js.html b/docs/sound_TonePlayer.js.html index b9c37194..ac0d4e4c 100644 --- a/docs/sound_TonePlayer.js.html +++ b/docs/sound_TonePlayer.js.html @@ -35,9 +35,9 @@

Source: sound/TonePlayer.js

* @license Distributed under the terms of the MIT License */ -import * as Tone from 'tone'; -import {SoundPlayer} from './SoundPlayer'; - +import * as Tone from "tone"; +import { isNumeric } from "../util/Util.js"; +import { SoundPlayer } from "./SoundPlayer.js"; /** * <p>This class handles the playing of tones.</p> @@ -55,23 +55,23 @@

Source: sound/TonePlayer.js

export class TonePlayer extends SoundPlayer { constructor({ - psychoJS, - note = 'C4', - duration_s = 0.5, - volume = 1.0, - loops = 0, - soundLibrary = TonePlayer.SoundLibrary.TONE_JS, - autoLog = true - } = {}) + psychoJS, + note = "C4", + duration_s = 0.5, + volume = 1.0, + loops = 0, + soundLibrary = TonePlayer.SoundLibrary.TONE_JS, + autoLog = true, + } = {}) { super(psychoJS); - this._addAttribute('note', note); - this._addAttribute('duration_s', duration_s); - this._addAttribute('volume', volume); - this._addAttribute('loops', loops); - this._addAttribute('soundLibrary', soundLibrary); - this._addAttribute('autoLog', autoLog); + this._addAttribute("note", note); + this._addAttribute("duration_s", duration_s); + this._addAttribute("volume", volume); + this._addAttribute("loops", loops); + this._addAttribute("soundLibrary", soundLibrary); + this._addAttribute("autoLog", autoLog); // initialise the sound library: this._initSoundLibrary(); @@ -85,7 +85,6 @@

Source: sound/TonePlayer.js

} } - /** * Determine whether this player can play the given sound. * @@ -102,39 +101,39 @@

Source: sound/TonePlayer.js

static accept(sound) { // if the sound's value is an integer, we interpret it as a frequency: - if ($.isNumeric(sound.value)) + if (isNumeric(sound.value)) { return new TonePlayer({ psychoJS: sound.psychoJS, note: sound.value, duration_s: sound.secs, volume: sound.volume, - loops: sound.loops + loops: sound.loops, }); } // if the sound's value is a string, we check whether it is a note: - if (typeof sound.value === 'string') + if (typeof sound.value === "string") { // mapping between the PsychoPY notes and the standard ones: let psychopyToToneMap = new Map(); - for (const note of ['A', 'B', 'C', 'D', 'E', 'F', 'G']) + for (const note of ["A", "B", "C", "D", "E", "F", "G"]) { psychopyToToneMap.set(note, note); - psychopyToToneMap.set(note + 'fl', note + 'b'); - psychopyToToneMap.set(note + 'sh', note + '#'); + psychopyToToneMap.set(note + "fl", note + "b"); + psychopyToToneMap.set(note + "sh", note + "#"); } // check whether the sound's value is a recognised note: const note = psychopyToToneMap.get(sound.value); - if (typeof note !== 'undefined') + if (typeof note !== "undefined") { return new TonePlayer({ psychoJS: sound.psychoJS, note: note + sound.octave, duration_s: sound.secs, volume: sound.volume, - loops: sound.loops + loops: sound.loops, }); } } @@ -143,7 +142,6 @@

Source: sound/TonePlayer.js

return undefined; } - /** * Get the duration of the sound. * @@ -157,7 +155,6 @@

Source: sound/TonePlayer.js

return this.duration_s; } - /** * Set the duration of the tone. * @@ -171,7 +168,6 @@

Source: sound/TonePlayer.js

this.duration_s = duration_s; } - /** * Set the number of loops. * @@ -185,7 +181,6 @@

Source: sound/TonePlayer.js

this._loops = loops; } - /** * Set the volume of the tone. * @@ -201,7 +196,7 @@

Source: sound/TonePlayer.js

if (this._soundLibrary === TonePlayer.SoundLibrary.TONE_JS) { - if (typeof this._volumeNode !== 'undefined') + if (typeof this._volumeNode !== "undefined") { this._volumeNode.mute = mute; this._volumeNode.volume.value = -60 + volume * 66; @@ -218,7 +213,6 @@

Source: sound/TonePlayer.js

} } - /** * Start playing the sound. * @@ -229,7 +223,7 @@

Source: sound/TonePlayer.js

*/ play(loops) { - if (typeof loops !== 'undefined') + if (typeof loops !== "undefined") { this._loops = loops; } @@ -250,7 +244,7 @@

Source: sound/TonePlayer.js

playToneCallback = () => { self._webAudioOscillator = self._audioContext.createOscillator(); - self._webAudioOscillator.type = 'sine'; + self._webAudioOscillator.type = "sine"; self._webAudioOscillator.frequency.value = 440; self._webAudioOscillator.connect(self._audioContext.destination); const contextCurrentTime = self._audioContext.currentTime; @@ -264,7 +258,6 @@

Source: sound/TonePlayer.js

{ playToneCallback(); } - // repeat forever: else if (this.loops === -1) { @@ -272,22 +265,21 @@

Source: sound/TonePlayer.js

playToneCallback, this.duration_s, Tone.now(), - Infinity + Infinity, ); } - else // repeat this._loops times: + else { this._toneId = Tone.Transport.scheduleRepeat( playToneCallback, this.duration_s, Tone.now(), - this.duration_s * (this._loops + 1) + this.duration_s * (this._loops + 1), ); } } - /** * Stop playing the sound immediately. * @@ -315,7 +307,6 @@

Source: sound/TonePlayer.js

} } - /** * Initialise the sound library. * @@ -329,24 +320,24 @@

Source: sound/TonePlayer.js

_initSoundLibrary() { const response = { - origin: 'TonePlayer._initSoundLibrary', - context: 'when initialising the sound library' + origin: "TonePlayer._initSoundLibrary", + context: "when initialising the sound library", }; if (this._soundLibrary === TonePlayer.SoundLibrary.TONE_JS) { // check that Tone.js is available: - if (typeof Tone === 'undefined') + if (typeof Tone === "undefined") { throw Object.assign(response, { - error: "Tone.js is not available. A different sound library must be selected. Please contact the experiment designer." + error: "Tone.js is not available. A different sound library must be selected. Please contact the experiment designer.", }); } // start the Tone Transport if it has not started already: - if (typeof Tone !== 'undefined' && Tone.Transport.state !== 'started') + if (typeof Tone !== "undefined" && Tone.Transport.state !== "started") { - this.psychoJS.logger.info('[PsychoJS] start Tone Transport'); + this.psychoJS.logger.info("[PsychoJS] start Tone Transport"); Tone.Transport.start(Tone.now()); // this is necessary to prevent Tone from introducing a delay when triggering a note @@ -357,14 +348,14 @@

Source: sound/TonePlayer.js

// create a synth: we use a triangular oscillator with hardly any envelope: this._synthOtions = { oscillator: { - type: 'square' //'triangle' + type: "square", // 'triangle' }, envelope: { attack: 0.001, // 1ms - decay: 0.001, // 1ms + decay: 0.001, // 1ms sustain: 1, - release: 0.001 // 1ms - } + release: 0.001, // 1ms + }, }; this._synth = new Tone.Synth(this._synthOtions); @@ -373,7 +364,7 @@

Source: sound/TonePlayer.js

this._synth.connect(this._volumeNode); // connect the volume node to the master output: - if (typeof this._volumeNode.toDestination === 'function') + if (typeof this._volumeNode.toDestination === "function") { this._volumeNode.toDestination(); } @@ -385,15 +376,15 @@

Source: sound/TonePlayer.js

else { // create an AudioContext: - if (typeof this._audioContext === 'undefined') + if (typeof this._audioContext === "undefined") { const AudioContext = window.AudioContext || window.webkitAudioContext; // if AudioContext is not available (e.g. on IE), we throw an exception: - if (typeof AudioContext === 'undefined') + if (typeof AudioContext === "undefined") { throw Object.assign(response, { - error: `AudioContext is not available on your browser, ${this._psychoJS.browser}, please contact the experiment designer.` + error: `AudioContext is not available on your browser, ${this._psychoJS.browser}, please contact the experiment designer.`, }); } @@ -401,17 +392,15 @@

Source: sound/TonePlayer.js

} } } - } - /** * * @type {{TONE_JS: *, AUDIO_CONTEXT: *}} */ TonePlayer.SoundLibrary = { - AUDIO_CONTEXT: Symbol.for('AUDIO_CONTEXT'), - TONE_JS: Symbol.for('TONE_JS') + AUDIO_CONTEXT: Symbol.for("AUDIO_CONTEXT"), + TONE_JS: Symbol.for("TONE_JS"), }; @@ -423,13 +412,13 @@

Source: sound/TonePlayer.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/sound_TrackPlayer.js.html b/docs/sound_TrackPlayer.js.html index 4440ed85..12cf8285 100644 --- a/docs/sound_TrackPlayer.js.html +++ b/docs/sound_TrackPlayer.js.html @@ -35,8 +35,7 @@

Source: sound/TrackPlayer.js

* @license Distributed under the terms of the MIT License */ -import {SoundPlayer} from './SoundPlayer'; - +import { SoundPlayer } from "./SoundPlayer.js"; /** * <p>This class handles the playback of sound tracks.</p> @@ -58,28 +57,27 @@

Source: sound/TrackPlayer.js

export class TrackPlayer extends SoundPlayer { constructor({ - psychoJS, - howl, - startTime = 0, - stopTime = -1, - stereo = true, - volume = 0, - loops = 0 - } = {}) + psychoJS, + howl, + startTime = 0, + stopTime = -1, + stereo = true, + volume = 0, + loops = 0, + } = {}) { super(psychoJS); - this._addAttribute('howl', howl); - this._addAttribute('startTime', startTime); - this._addAttribute('stopTime', stopTime); - this._addAttribute('stereo', stereo); - this._addAttribute('loops', loops); - this._addAttribute('volume', volume); + this._addAttribute("howl", howl); + this._addAttribute("startTime", startTime); + this._addAttribute("stopTime", stopTime); + this._addAttribute("stereo", stereo); + this._addAttribute("loops", loops); + this._addAttribute("volume", volume); this._currentLoopIndex = -1; } - /** * Determine whether this player can play the given sound. * @@ -94,10 +92,10 @@

Source: sound/TrackPlayer.js

static accept(sound) { // if the sound's value is a string, we check whether it is the name of a resource: - if (typeof sound.value === 'string') + if (typeof sound.value === "string") { const howl = sound.psychoJS.serverManager.getResource(sound.value); - if (typeof howl !== 'undefined') + if (typeof howl !== "undefined") { // build the player: const player = new TrackPlayer({ @@ -107,7 +105,7 @@

Source: sound/TrackPlayer.js

stopTime: sound.stopTime, stereo: sound.stereo, loops: sound.loops, - volume: sound.volume + volume: sound.volume, }); return player; } @@ -117,7 +115,6 @@

Source: sound/TrackPlayer.js

return undefined; } - /** * Get the duration of the sound, in seconds. * @@ -131,9 +128,8 @@

Source: sound/TrackPlayer.js

return this._howl.duration(); } - /** - * Set the duration of the default sprite. + * Set the duration of the track. * * @name module:sound.TrackPlayer#setDuration * @function @@ -142,14 +138,13 @@

Source: sound/TrackPlayer.js

*/ setDuration(duration_s) { - if (typeof this._howl !== 'undefined') + if (typeof this._howl !== "undefined") { // Unfortunately Howler.js provides duration setting method this._howl._duration = duration_s; } } - /** * Set the volume of the tone. * @@ -167,7 +162,6 @@

Source: sound/TrackPlayer.js

this._howl.mute(mute); } - /** * Set the number of loops. * @@ -191,7 +185,6 @@

Source: sound/TrackPlayer.js

} } - /** * Start playing the sound. * @@ -203,7 +196,7 @@

Source: sound/TrackPlayer.js

*/ play(loops, fadeDuration = 17) { - if (typeof loops !== 'undefined') + if (typeof loops !== "undefined") { this.setLoops(loops); } @@ -212,7 +205,7 @@

Source: sound/TrackPlayer.js

if (loops > 0) { const self = this; - this._howl.on('end', (event) => + this._howl.on("end", (event) => { ++this._currentLoopIndex; if (self._currentLoopIndex > self._loops) @@ -233,7 +226,6 @@

Source: sound/TrackPlayer.js

this._howl.fade(0, this._volume, fadeDuration, this._id); } - /** * Stop playing the sound immediately. * @@ -244,13 +236,13 @@

Source: sound/TrackPlayer.js

*/ stop(fadeDuration = 17) { - this._howl.once('fade', (id) => { + this._howl.once("fade", (id) => + { this._howl.stop(id); - this._howl.off('end'); + this._howl.off("end"); }); this._howl.fade(this._howl.volume(), 0, fadeDuration, this._id); } - } @@ -262,13 +254,13 @@

Source: sound/TrackPlayer.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/sound_Transcriber.js.html b/docs/sound_Transcriber.js.html new file mode 100644 index 00000000..fd7f1f76 --- /dev/null +++ b/docs/sound_Transcriber.js.html @@ -0,0 +1,444 @@ + + + + + JSDoc: Source: sound/Transcriber.js + + + + + + + + + + +
+ +

Source: sound/Transcriber.js

+ + + + + + +
+
+
/**
+ * Manager handling the transcription of Speech into Text.
+ *
+ * @author Sotiri Bakagiannis and Alain Pitiot
+ * @version 2021.2.0
+ * @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";
+
+
+/**
+ * Transcript returned by the transcriber
+ *
+ * @name module:sound.Transcript
+ * @class
+ */
+export class Transcript
+{
+	constructor(transcriber, text = '', confidence = 0.0)
+	{
+		// recognised text:
+		this.text = text;
+
+		// confidence in the recognition:
+		this.confidence = confidence;
+
+		// time the speech started, relative to the Transcriber clock:
+		this.speechStart = transcriber._speechStart;
+
+		// time the speech ended, relative to the Transcriber clock:
+		this.speechEnd = transcriber._speechEnd;
+
+		// time a recognition result was produced, relative to the Transcriber clock:
+		this.time = transcriber._recognitionTime;
+	}
+}
+
+
+/**
+ * <p>This manager handles the transcription of speech into text.</p>
+ *
+ * @name module:sound.Transcriber
+ * @class
+ * @param {Object} options
+ * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance
+ * @param {String} options.name - the name used when logging messages
+ * @param {number} [options.bufferSize= 10000] - the maximum size of the circular transcript buffer
+ * @param {String[]} [options.continuous= true] - whether or not to continuously recognise
+ * @param {String[]} [options.lang= 'en-US'] - the spoken language
+ * @param {String[]} [options.interimResults= false] - whether or not to make interim results available
+ * @param {String[]} [options.maxAlternatives= 1] - the maximum number of recognition alternatives
+ * @param {String[]} [options.tokens= [] ] - the tokens to be recognised. This is experimental technology, not available in all browser.
+ * @param {Clock} [options.clock= undefined] - an optional clock
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ *
+ * @todo deal with alternatives, interim results, and recognition errors
+ */
+export class Transcriber extends PsychObject
+{
+
+	constructor({psychoJS, name, bufferSize, continuous, lang, interimResults, maxAlternatives, tokens, clock, autoLog} = {})
+	{
+		super(psychoJS);
+
+		this._addAttribute('name', name, 'transcriber');
+		this._addAttribute('bufferSize', bufferSize, 10000);
+		this._addAttribute('continuous', continuous, true, this._onChange);
+		this._addAttribute('lang', lang, 'en-US', this._onChange);
+		this._addAttribute('interimResults', interimResults, false, this._onChange);
+		this._addAttribute('maxAlternatives', maxAlternatives, 1, this._onChange);
+		this._addAttribute('tokens', tokens, [], this._onChange);
+		this._addAttribute('clock', clock, new Clock());
+		this._addAttribute('autoLog', false, autoLog);
+		this._addAttribute('status', PsychoJS.Status.NOT_STARTED);
+
+		// prepare the transcription:
+		this._prepareTranscription();
+
+		if (this._autoLog)
+		{
+			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
+		}
+	}
+
+
+	/**
+	 * Start the transcription.
+	 *
+	 * @name module:sound.Transcriber#start
+	 * @function
+	 * @public
+	 * @return {Promise} promise fulfilled when the transcription actually started
+	 */
+	start()
+	{
+		if (this._status !== PsychoJS.Status.STARTED)
+		{
+			this._psychoJS.logger.debug('request to start speech to text transcription');
+
+			try
+			{
+				if (!this._recognition)
+				{
+					throw 'the speech recognition has not been initialised yet, possibly because the participant has not given the authorisation to record audio';
+				}
+
+				this._recognition.start();
+
+				// return a promise, which will be satisfied when the transcription 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)
+			{
+				// TODO Strangely, start sometimes fails with the message that the recognition has already started. It is most probably a bug in the implementation of the Web Speech API. We need to catch this particular error and no throw on this occasion
+
+				this._psychoJS.logger.error('unable to start the speech to text transcription: ' + JSON.stringify(error));
+				this._status = PsychoJS.Status.ERROR;
+
+				throw {
+					origin: 'Transcriber.start',
+					context: 'when starting the speech to text transcription with transcriber: ' + this._name,
+					error
+				};
+			}
+
+		}
+
+	}
+
+
+	/**
+	 * Stop the transcription.
+	 *
+	 * @name module:sound.Transcriber#stop
+	 * @function
+	 * @public
+	 * @return {Promise} promise fulfilled when the speech recognition actually stopped
+	 */
+	stop()
+	{
+		if (this._status === PsychoJS.Status.STARTED)
+		{
+			this._psychoJS.logger.debug('request to stop speech to text transcription');
+
+			this._recognition.stop();
+
+			// return a promise, which will be satisfied when the recognition actually stops:
+			const self = this;
+			return new Promise((resolve, reject) =>
+			{
+				self._stopCallback = resolve;
+				self._errorCallback = reject;
+			});
+		}
+	}
+
+
+	/**
+	 * Get the list of transcripts still in the buffer, i.e. those that have not been
+	 * previously cleared by calls to getTranscripts with clear = true.
+	 *
+	 * @name module:sound.Transcriber#getTranscripts
+	 * @function
+	 * @public
+	 * @param {Object} options
+	 * @param {string[]} [options.transcriptList= []]] - the list of transcripts texts to consider. If transcriptList is empty, we consider all transcripts.
+	 * @param {boolean} [options.clear= false] - whether or not to keep in the buffer the transcripts for a subsequent call to getTranscripts. If a keyList has been given and clear = true, we only remove from the buffer those keys in keyList
+	 * @return {Transcript[]} the list of transcripts still in the buffer
+	 */
+	getTranscripts({
+									 transcriptList = [],
+									 clear = true
+								 } = {})
+	{
+		// if nothing in the buffer, return immediately:
+		if (this._bufferLength === 0)
+		{
+			return [];
+		}
+
+
+		// iterate over the buffer, from start to end, and discard the null transcripts (i.e. those
+		// previously cleared):
+		const filteredTranscripts = [];
+		const bufferWrap = (this._bufferLength === this._bufferSize);
+		let i = bufferWrap ? this._bufferIndex : -1;
+		do
+		{
+			i = (i + 1) % this._bufferSize;
+
+			const transcript = this._circularBuffer[i];
+			if (transcript)
+			{
+				// if the transcriptList is empty of the transcript text is in the transcriptList:
+				if (transcriptList.length === 0 || transcriptList.includes(transcript.text))
+				{
+					filteredTranscripts.push(transcript);
+
+					if (clear)
+					{
+						this._circularBuffer[i] = null;
+					}
+				}
+			}
+		} while (i !== this._bufferIndex);
+
+		return filteredTranscripts;
+	}
+
+
+	/**
+	 * Clear all transcripts and resets the circular buffers.
+	 *
+	 * @name module:sound.Transcriber#clearTranscripts
+	 * @function
+	 */
+	clearTranscripts()
+	{
+		// circular buffer of transcripts:
+		this._circularBuffer = new Array(this._bufferSize);
+		this._bufferLength = 0;
+		this._bufferIndex = -1;
+	}
+
+
+	/**
+	 * Callback for changes to the recognition settings.
+	 *
+	 * <p>Changes to the recognition settings require the recognition to stop and be re-started.</p>
+	 *
+	 * @name module:sound.Transcriber#_onChange
+	 * @function
+	 * @protected
+	 */
+	_onChange()
+	{
+		if (this._status === PsychoJS.Status.STARTED)
+		{
+			this.stop();
+		}
+
+		this._prepareTranscription();
+
+		this.start();
+	}
+
+
+	/**
+	 * Prepare the transcription.
+	 *
+	 * @name module:sound.Transcriber#_prepareTranscription
+	 * @function
+	 * @protected
+	 */
+	_prepareTranscription()
+	{
+		// setup the circular buffer of transcripts:
+		this.clearTranscripts();
+
+
+		// recognition settings:
+		const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
+		this._recognition = new SpeechRecognition();
+		this._recognition.continuous = this._continuous;
+		this._recognition.lang = this._lang;
+		this._recognition.interimResults = this._interimResults;
+		this._recognition.maxAlternatives = this._maxAlternatives;
+
+		// grammar list with tokens added:
+		if (Array.isArray(this._tokens) && this._tokens.length > 0)
+		{
+			const SpeechGrammarList = window.SpeechGrammarList || window.webkitSpeechGrammarList;
+
+			// note: we accepts JSGF encoded strings, and relative weight indicator between 0.0 and 1.0
+			// ref: https://www.w3.org/TR/jsgf/
+			const name = 'NULL';
+			const grammar = `#JSGF V1.0; grammar ${name}; public <${name}> = ${this._tokens.join('|')};`
+			const grammarList = new SpeechGrammarList();
+			grammarList.addFromString(grammar, 1);
+			this._recognition.grammars = grammarList;
+		}
+
+
+		// setup the callbacks:
+		const self = this;
+
+		// called when the start of a speech is detected:
+		this._recognition.onspeechstart = (e) =>
+		{
+			this._currentSpeechStart = this._clock.getTime();
+			self._psychoJS.logger.debug('speech started');
+		}
+
+		// called when the end of a speech is detected:
+		this._recognition.onspeechend = () =>
+		{
+			this._currentSpeechEnd = this._clock.getTime();
+			// this._recognition.stop();
+			self._psychoJS.logger.debug('speech ended');
+		}
+
+		// called when the recognition actually started:
+		this._recognition.onstart = () =>
+		{
+			this._clock.reset();
+			this._status = PsychoJS.Status.STARTED;
+			self._psychoJS.logger.debug('speech recognition started');
+
+			// resolve the Transcriber.start promise, if need be:
+			if (self._startCallback())
+			{
+				self._startCallback({
+					time: self._psychoJS.monotonicClock.getTime()
+				});
+			}
+		}
+
+		// called whenever stop() or abort() are called:
+		this._recognition.onend = () =>
+		{
+			this._status = PsychoJS.Status.STOPPED;
+			self._psychoJS.logger.debug('speech recognition ended');
+
+			// resolve the Transcriber.stop promise, if need be:
+			if (self._stopCallback)
+			{
+				self._stopCallback({
+					time: self._psychoJS.monotonicClock.getTime()
+				});
+			}
+		}
+
+		// called whenever a new result is available:
+		this._recognition.onresult = (event) =>
+		{
+			this._recognitionTime = this._clock.getTime();
+
+			// do not process the results if the Recogniser is not STARTED:
+			if (self._status !== PsychoJS.Status.STARTED)
+			{
+				return;
+			}
+
+			// in continuous recognition mode, we need to get the result at resultIndex,
+			// otherwise we pick the first result
+			const resultIndex = (self._continuous) ? event.resultIndex : 0;
+
+			// TODO at the moment we consider only the first alternative:
+			const alternativeIndex = 0;
+
+			const results = event.results;
+			const text = results[resultIndex][alternativeIndex].transcript;
+			const confidence = results[resultIndex][alternativeIndex].confidence;
+
+			// create a new transcript:
+			const transcript = new Transcript(self, text, confidence);
+
+			// insert it in the circular transcript buffer:
+			self._bufferIndex = (self._bufferIndex + 1) % self._bufferSize;
+			self._bufferLength = Math.min(self._bufferLength + 1, self._bufferSize);
+			self._circularBuffer[self._bufferIndex] = transcript;
+
+			self._psychoJS.logger.debug('speech recognition transcript: ', JSON.stringify(transcript));
+		}
+
+		// called upon recognition errors:
+		this._recognition.onerror = (event) =>
+		{
+			// lack of speech is not an error:
+			if (event.error === 'no-speech')
+			{
+				return;
+			}
+
+			self._psychoJS.logger.error('speech recognition error: ', JSON.stringify(event));
+			self._status = PsychoJS.Status.ERROR;
+		}
+
+	}
+
+}
+
+
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + diff --git a/docs/util_Clock.js.html b/docs/util_Clock.js.html index db55ea54..521a7a1a 100644 --- a/docs/util_Clock.js.html +++ b/docs/util_Clock.js.html @@ -35,7 +35,6 @@

Source: util/Clock.js

* @license Distributed under the terms of the MIT License */ - /** * <p>MonotonicClock offers a convenient way to keep track of time during experiments. An experiment can have as many independent clocks as needed, e.g. one to time responses, another one to keep track of stimuli, etc.</p> * @@ -50,7 +49,6 @@

Source: util/Clock.js

this._timeAtLastReset = startTime; } - /** * Get the current time on this clock. * @@ -64,7 +62,6 @@

Source: util/Clock.js

return MonotonicClock.getReferenceTime() - this._timeAtLastReset; } - /** * Get the current offset being applied to the high resolution timebase used by this Clock. * @@ -78,7 +75,6 @@

Source: util/Clock.js

return this._timeAtLastReset; } - /** * Get the time elapsed since the reference point. * @@ -93,7 +89,6 @@

Source: util/Clock.js

// return (new Date().getTime()) / 1000.0 - MonotonicClock._referenceTime; } - /** * Get the current timestamp with language-sensitive formatting rules applied. * @@ -107,18 +102,18 @@

Source: util/Clock.js

* @param {object} options - An object with detailed date and time styling information. * @return {string} The current timestamp in the chosen format. */ - static getDate(locales = 'en-CA', optionsMaybe) + static getDate(locales = "en-CA", optionsMaybe) { const date = new Date(); const options = Object.assign({ hour12: false, - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: 'numeric', - minute: 'numeric', - second: 'numeric', - fractionalSecondDigits: 3 + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "numeric", + minute: "numeric", + second: "numeric", + fractionalSecondDigits: 3, }, optionsMaybe); const dateTimeFormat = new Intl.DateTimeFormat(locales, options); @@ -142,15 +137,14 @@

Source: util/Clock.js

// yyyy-mm-dd, hh:mm:ss.sss return MonotonicClock.getDate() // yyyy-mm-dd_hh:mm:ss.sss - .replace(', ', '_') + .replace(", ", "_") // yyyy-mm-dd_hh[h]mm:ss.sss - .replace(':', 'h') + .replace(":", "h") // yyyy-mm-dd_hh[h]mm.ss.sss - .replace(':', '.'); + .replace(":", "."); } } - /** * The clock's referenceTime is the time when the module was loaded (in seconds). * @@ -163,7 +157,6 @@

Source: util/Clock.js

// MonotonicClock._referenceTime = new Date().getTime() / 1000.0; - /** * <p>Clock is a MonotonicClock that also offers the possibility of being reset.</p> * @@ -192,7 +185,6 @@

Source: util/Clock.js

this._timeAtLastReset = MonotonicClock.getReferenceTime() + newTime; } - /** * Add more time to the clock's 'start' time (t0). * @@ -210,7 +202,6 @@

Source: util/Clock.js

} } - /** * <p>CountdownTimer is a clock counts down from the time of last reset.</p. * @@ -233,7 +224,6 @@

Source: util/Clock.js

} } - /** * Add more time to the clock's 'start' time (t0). * @@ -250,7 +240,6 @@

Source: util/Clock.js

this._timeAtLastReset += deltaTime; } - /** * Reset the time on the countdown. * @@ -262,7 +251,7 @@

Source: util/Clock.js

*/ reset(newTime = undefined) { - if (typeof newTime == 'undefined') + if (typeof newTime == "undefined") { this._timeAtLastReset = MonotonicClock.getReferenceTime() + this._countdown_duration; } @@ -273,7 +262,6 @@

Source: util/Clock.js

} } - /** * Get the time currently left on the countdown. * @@ -287,7 +275,6 @@

Source: util/Clock.js

return this._timeAtLastReset - MonotonicClock.getReferenceTime(); } } - @@ -298,13 +285,13 @@

Source: util/Clock.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/util_Color.js.html b/docs/util_Color.js.html index dda5aceb..9e9620f9 100644 --- a/docs/util_Color.js.html +++ b/docs/util_Color.js.html @@ -35,7 +35,6 @@

Source: util/Color.js

* @license Distributed under the terms of the MIT License */ - /** * <p>This class handles multiple color spaces, and offers various * static methods for converting colors from one space to another.</p> @@ -60,27 +59,26 @@

Source: util/Color.js

*/ export class Color { - - constructor(obj = 'black', colorspace = Color.COLOR_SPACE.RGB) + constructor(obj = "black", colorspace = Color.COLOR_SPACE.RGB) { const response = { - origin: 'Color', - context: 'when defining a color' + origin: "Color", + context: "when defining a color", }; // named color (e.g. 'seagreen') or string hexadecimal representation (e.g. '#FF0000'): // note: we expect the color space to be RGB - if (typeof obj == 'string') + if (typeof obj == "string") { if (colorspace !== Color.COLOR_SPACE.RGB) { throw Object.assign(response, { - error: 'the colorspace must be RGB for a named color' + error: "the colorspace must be RGB for a named color", }); } // hexademical representation: - if (obj[0] === '#') + if (obj[0] === "#") { this._hex = obj; } @@ -89,7 +87,7 @@

Source: util/Color.js

{ if (!(obj.toLowerCase() in Color.NAMED_COLORS)) { - throw Object.assign(response, {error: 'unknown named color: ' + obj}); + throw Object.assign(response, { error: "unknown named color: " + obj }); } this._hex = Color.NAMED_COLORS[obj.toLowerCase()]; @@ -97,23 +95,21 @@

Source: util/Color.js

this._rgb = Color.hexToRgb(this._hex); } - // hexadecimal number representation (e.g. 0xFF0000) // note: we expect the color space to be RGB - else if (typeof obj == 'number') + else if (typeof obj == "number") { if (colorspace !== Color.COLOR_SPACE.RGB) { throw Object.assign(response, { - error: 'the colorspace must be RGB for' + - ' a' + - ' named color' + error: "the colorspace must be RGB for" + + " a" + + " named color", }); } this._rgb = Color._intToRgb(obj); } - // array of numbers: else if (Array.isArray(obj)) { @@ -152,16 +148,16 @@

Source: util/Color.js

break; default: - throw Object.assign(response, {error: 'unknown colorspace: ' + colorspace}); + throw Object.assign(response, { error: "unknown colorspace: " + colorspace }); } } - else if (obj instanceof Color) { this._rgb = obj._rgb.slice(); } - } + this._rgbFull = this._rgb.map(c => c * 2 - 1); + } /** * Get the [0,1] RGB triplet equivalent of this Color. @@ -176,6 +172,18 @@

Source: util/Color.js

return this._rgb; } + /** + * Get the [-1,1] RGB triplet equivalent of this Color. + * + * @name module:util.Color.rgbFull + * @function + * @public + * @return {Array.<number>} the [-1,1] RGB triplet equivalent + */ + get rgbFull() + { + return this._rgbFull; + } /** * Get the [0,255] RGB triplet equivalent of this Color. @@ -190,7 +198,6 @@

Source: util/Color.js

return [Math.round(this._rgb[0] * 255.0), Math.round(this._rgb[1] * 255.0), Math.round(this._rgb[2] * 255.0)]; } - /** * Get the hexadecimal color code equivalent of this Color. * @@ -201,7 +208,7 @@

Source: util/Color.js

*/ get hex() { - if (typeof this._hex === 'undefined') + if (typeof this._hex === "undefined") { this._hex = Color._rgbToHex(this._rgb); } @@ -218,14 +225,13 @@

Source: util/Color.js

*/ get int() { - if (typeof this._int === 'undefined') + if (typeof this._int === "undefined") { this._int = Color._rgbToInt(this._rgb); } return this._int; } - /* get hsv() { if (typeof this._hsv === 'undefined') @@ -244,7 +250,6 @@

Source: util/Color.js

} */ - /** * String representation of the color, i.e. the hexadecimal representation. * @@ -258,7 +263,6 @@

Source: util/Color.js

return this.hex; } - /** * Get the [0,255] RGB triplet equivalent of the hexadecimal color code. * @@ -275,16 +279,15 @@

Source: util/Color.js

if (result == null) { throw { - origin: 'Color.hexToRgb255', - context: 'when converting an hexadecimal color code to its 255- or [0,1]-based RGB color representation', - error: 'unable to parse the argument: wrong type or wrong code' + origin: "Color.hexToRgb255", + context: "when converting an hexadecimal color code to its 255- or [0,1]-based RGB color representation", + error: "unable to parse the argument: wrong type or wrong code", }; } return [parseInt(result[1], 16), parseInt(result[2], 16), parseInt(result[3], 16)]; } - /** * Get the [0,1] RGB triplet equivalent of the hexadecimal color code. * @@ -301,7 +304,6 @@

Source: util/Color.js

return [r255 / 255.0, g255 / 255.0, b255 / 255.0]; } - /** * Get the hexadecimal color code equivalent of the [0, 255] RGB triplet. * @@ -315,8 +317,8 @@

Source: util/Color.js

static rgb255ToHex(rgb255) { const response = { - origin: 'Color.rgb255ToHex', - context: 'when converting an rgb triplet to its hexadecimal color representation' + origin: "Color.rgb255ToHex", + context: "when converting an rgb triplet to its hexadecimal color representation", }; try @@ -326,11 +328,10 @@

Source: util/Color.js

} catch (error) { - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - /** * Get the hexadecimal color code equivalent of the [0, 1] RGB triplet. * @@ -344,8 +345,8 @@

Source: util/Color.js

static rgbToHex(rgb) { const response = { - origin: 'Color.rgbToHex', - context: 'when converting an rgb triplet to its hexadecimal color representation' + origin: "Color.rgbToHex", + context: "when converting an rgb triplet to its hexadecimal color representation", }; try @@ -355,11 +356,10 @@

Source: util/Color.js

} catch (error) { - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - /** * Get the integer equivalent of the [0, 1] RGB triplet. * @@ -373,8 +373,8 @@

Source: util/Color.js

static rgbToInt(rgb) { const response = { - origin: 'Color.rgbToInt', - context: 'when converting an rgb triplet to its integer representation' + origin: "Color.rgbToInt", + context: "when converting an rgb triplet to its integer representation", }; try @@ -384,11 +384,10 @@

Source: util/Color.js

} catch (error) { - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - /** * Get the integer equivalent of the [0, 255] RGB triplet. * @@ -402,8 +401,8 @@

Source: util/Color.js

static rgb255ToInt(rgb255) { const response = { - origin: 'Color.rgb255ToInt', - context: 'when converting an rgb triplet to its integer representation' + origin: "Color.rgb255ToInt", + context: "when converting an rgb triplet to its integer representation", }; try { @@ -412,11 +411,10 @@

Source: util/Color.js

} catch (error) { - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - /** * Get the hexadecimal color code equivalent of the [0, 255] RGB triplet. * @@ -434,7 +432,6 @@

Source: util/Color.js

return "#" + ((1 << 24) + (rgb255[0] << 16) + (rgb255[1] << 8) + rgb255[2]).toString(16).slice(1); } - /** * Get the hexadecimal color code equivalent of the [0, 1] RGB triplet. * @@ -453,7 +450,6 @@

Source: util/Color.js

return Color._rgb255ToHex(rgb255); } - /** * Get the integer equivalent of the [0, 1] RGB triplet. * @@ -472,7 +468,6 @@

Source: util/Color.js

return Color._rgb255ToInt(rgb255); } - /** * Get the integer equivalent of the [0, 255] RGB triplet. * @@ -490,7 +485,6 @@

Source: util/Color.js

return rgb255[0] * 0x10000 + rgb255[1] * 0x100 + rgb255[2]; } - /** * Get the [0, 255] based RGB triplet equivalent of the integer color code. * @@ -512,7 +506,6 @@

Source: util/Color.js

return [r255, g255, b255]; } - /** * Get the [0, 1] based RGB triplet equivalent of the integer color code. * @@ -545,20 +538,21 @@

Source: util/Color.js

*/ static _checkTypeAndRange(arg, range = undefined) { - if (!Array.isArray(arg) || arg.length !== 3 || - typeof arg[0] !== 'number' || typeof arg[1] !== 'number' || typeof arg[2] !== 'number') + if ( + !Array.isArray(arg) || arg.length !== 3 + || typeof arg[0] !== "number" || typeof arg[1] !== "number" || typeof arg[2] !== "number" + ) { - throw 'the argument should be an array of numbers of length 3'; + throw "the argument should be an array of numbers of length 3"; } - if (typeof range !== 'undefined' && (arg[0] < range[0] || arg[0] > range[1] || arg[1] < range[0] || arg[1] > range[1] || arg[2] < range[0] || arg[2] > range[1])) + if (typeof range !== "undefined" && (arg[0] < range[0] || arg[0] > range[1] || arg[1] < range[0] || arg[1] > range[1] || arg[2] < range[0] || arg[2] > range[1])) { - throw 'the color components should all belong to [' + range[0] + ', ' + range[1] + ']'; + throw "the color components should all belong to [" + range[0] + ", " + range[1] + "]"; } } } - /** * Color spaces. * @@ -571,13 +565,12 @@

Source: util/Color.js

/** * RGB colorspace: [r,g,b] with r,g,b in [-1, 1] */ - RGB: Symbol.for('RGB'), + RGB: Symbol.for("RGB"), /** * RGB255 colorspace: [r,g,b] with r,g,b in [0, 255] */ - RGB255: Symbol.for('RGB255'), - + RGB255: Symbol.for("RGB255"), /* HSV: Symbol.for('HSV'), DKL: Symbol.for('DKL'), @@ -585,7 +578,6 @@

Source: util/Color.js

*/ }; - /** * Named colors. * @@ -595,153 +587,153 @@

Source: util/Color.js

* @public */ Color.NAMED_COLORS = { - 'aliceblue': '#F0F8FF', - 'antiquewhite': '#FAEBD7', - 'aqua': '#00FFFF', - 'aquamarine': '#7FFFD4', - 'azure': '#F0FFFF', - 'beige': '#F5F5DC', - 'bisque': '#FFE4C4', - 'black': '#000000', - 'blanchedalmond': '#FFEBCD', - 'blue': '#0000FF', - 'blueviolet': '#8A2BE2', - 'brown': '#A52A2A', - 'burlywood': '#DEB887', - 'cadetblue': '#5F9EA0', - 'chartreuse': '#7FFF00', - 'chocolate': '#D2691E', - 'coral': '#FF7F50', - 'cornflowerblue': '#6495ED', - 'cornsilk': '#FFF8DC', - 'crimson': '#DC143C', - 'cyan': '#00FFFF', - 'darkblue': '#00008B', - 'darkcyan': '#008B8B', - 'darkgoldenrod': '#B8860B', - 'darkgray': '#A9A9A9', - 'darkgrey': '#A9A9A9', - 'darkgreen': '#006400', - 'darkkhaki': '#BDB76B', - 'darkmagenta': '#8B008B', - 'darkolivegreen': '#556B2F', - 'darkorange': '#FF8C00', - 'darkorchid': '#9932CC', - 'darkred': '#8B0000', - 'darksalmon': '#E9967A', - 'darkseagreen': '#8FBC8B', - 'darkslateblue': '#483D8B', - 'darkslategray': '#2F4F4F', - 'darkslategrey': '#2F4F4F', - 'darkturquoise': '#00CED1', - 'darkviolet': '#9400D3', - 'deeppink': '#FF1493', - 'deepskyblue': '#00BFFF', - 'dimgray': '#696969', - 'dimgrey': '#696969', - 'dodgerblue': '#1E90FF', - 'firebrick': '#B22222', - 'floralwhite': '#FFFAF0', - 'forestgreen': '#228B22', - 'fuchsia': '#FF00FF', - 'gainsboro': '#DCDCDC', - 'ghostwhite': '#F8F8FF', - 'gold': '#FFD700', - 'goldenrod': '#DAA520', - 'gray': '#808080', - 'grey': '#808080', - 'green': '#008000', - 'greenyellow': '#ADFF2F', - 'honeydew': '#F0FFF0', - 'hotpink': '#FF69B4', - 'indianred': '#CD5C5C', - 'indigo': '#4B0082', - 'ivory': '#FFFFF0', - 'khaki': '#F0E68C', - 'lavender': '#E6E6FA', - 'lavenderblush': '#FFF0F5', - 'lawngreen': '#7CFC00', - 'lemonchiffon': '#FFFACD', - 'lightblue': '#ADD8E6', - 'lightcoral': '#F08080', - 'lightcyan': '#E0FFFF', - 'lightgoldenrodyellow': '#FAFAD2', - 'lightgray': '#D3D3D3', - 'lightgrey': '#D3D3D3', - 'lightgreen': '#90EE90', - 'lightpink': '#FFB6C1', - 'lightsalmon': '#FFA07A', - 'lightseagreen': '#20B2AA', - 'lightskyblue': '#87CEFA', - 'lightslategray': '#778899', - 'lightslategrey': '#778899', - 'lightsteelblue': '#B0C4DE', - 'lightyellow': '#FFFFE0', - 'lime': '#00FF00', - 'limegreen': '#32CD32', - 'linen': '#FAF0E6', - 'magenta': '#FF00FF', - 'maroon': '#800000', - 'mediumaquamarine': '#66CDAA', - 'mediumblue': '#0000CD', - 'mediumorchid': '#BA55D3', - 'mediumpurple': '#9370DB', - 'mediumseagreen': '#3CB371', - 'mediumslateblue': '#7B68EE', - 'mediumspringgreen': '#00FA9A', - 'mediumturquoise': '#48D1CC', - 'mediumvioletred': '#C71585', - 'midnightblue': '#191970', - 'mintcream': '#F5FFFA', - 'mistyrose': '#FFE4E1', - 'moccasin': '#FFE4B5', - 'navajowhite': '#FFDEAD', - 'navy': '#000080', - 'oldlace': '#FDF5E6', - 'olive': '#808000', - 'olivedrab': '#6B8E23', - 'orange': '#FFA500', - 'orangered': '#FF4500', - 'orchid': '#DA70D6', - 'palegoldenrod': '#EEE8AA', - 'palegreen': '#98FB98', - 'paleturquoise': '#AFEEEE', - 'palevioletred': '#DB7093', - 'papayawhip': '#FFEFD5', - 'peachpuff': '#FFDAB9', - 'peru': '#CD853F', - 'pink': '#FFC0CB', - 'plum': '#DDA0DD', - 'powderblue': '#B0E0E6', - 'purple': '#800080', - 'red': '#FF0000', - 'rosybrown': '#BC8F8F', - 'royalblue': '#4169E1', - 'saddlebrown': '#8B4513', - 'salmon': '#FA8072', - 'sandybrown': '#F4A460', - 'seagreen': '#2E8B57', - 'seashell': '#FFF5EE', - 'sienna': '#A0522D', - 'silver': '#C0C0C0', - 'skyblue': '#87CEEB', - 'slateblue': '#6A5ACD', - 'slategray': '#708090', - 'slategrey': '#708090', - 'snow': '#FFFAFA', - 'springgreen': '#00FF7F', - 'steelblue': '#4682B4', - 'tan': '#D2B48C', - 'teal': '#008080', - 'thistle': '#D8BFD8', - 'tomato': '#FF6347', - 'turquoise': '#40E0D0', - 'violet': '#EE82EE', - 'wheat': '#F5DEB3', - 'white': '#FFFFFF', - 'whitesmoke': '#F5F5F5', - 'yellow': '#FFFF00', - 'yellowgreen': '#9ACD32' + "aliceblue": "#F0F8FF", + "antiquewhite": "#FAEBD7", + "aqua": "#00FFFF", + "aquamarine": "#7FFFD4", + "azure": "#F0FFFF", + "beige": "#F5F5DC", + "bisque": "#FFE4C4", + "black": "#000000", + "blanchedalmond": "#FFEBCD", + "blue": "#0000FF", + "blueviolet": "#8A2BE2", + "brown": "#A52A2A", + "burlywood": "#DEB887", + "cadetblue": "#5F9EA0", + "chartreuse": "#7FFF00", + "chocolate": "#D2691E", + "coral": "#FF7F50", + "cornflowerblue": "#6495ED", + "cornsilk": "#FFF8DC", + "crimson": "#DC143C", + "cyan": "#00FFFF", + "darkblue": "#00008B", + "darkcyan": "#008B8B", + "darkgoldenrod": "#B8860B", + "darkgray": "#A9A9A9", + "darkgrey": "#A9A9A9", + "darkgreen": "#006400", + "darkkhaki": "#BDB76B", + "darkmagenta": "#8B008B", + "darkolivegreen": "#556B2F", + "darkorange": "#FF8C00", + "darkorchid": "#9932CC", + "darkred": "#8B0000", + "darksalmon": "#E9967A", + "darkseagreen": "#8FBC8B", + "darkslateblue": "#483D8B", + "darkslategray": "#2F4F4F", + "darkslategrey": "#2F4F4F", + "darkturquoise": "#00CED1", + "darkviolet": "#9400D3", + "deeppink": "#FF1493", + "deepskyblue": "#00BFFF", + "dimgray": "#696969", + "dimgrey": "#696969", + "dodgerblue": "#1E90FF", + "firebrick": "#B22222", + "floralwhite": "#FFFAF0", + "forestgreen": "#228B22", + "fuchsia": "#FF00FF", + "gainsboro": "#DCDCDC", + "ghostwhite": "#F8F8FF", + "gold": "#FFD700", + "goldenrod": "#DAA520", + "gray": "#808080", + "grey": "#808080", + "green": "#008000", + "greenyellow": "#ADFF2F", + "honeydew": "#F0FFF0", + "hotpink": "#FF69B4", + "indianred": "#CD5C5C", + "indigo": "#4B0082", + "ivory": "#FFFFF0", + "khaki": "#F0E68C", + "lavender": "#E6E6FA", + "lavenderblush": "#FFF0F5", + "lawngreen": "#7CFC00", + "lemonchiffon": "#FFFACD", + "lightblue": "#ADD8E6", + "lightcoral": "#F08080", + "lightcyan": "#E0FFFF", + "lightgoldenrodyellow": "#FAFAD2", + "lightgray": "#D3D3D3", + "lightgrey": "#D3D3D3", + "lightgreen": "#90EE90", + "lightpink": "#FFB6C1", + "lightsalmon": "#FFA07A", + "lightseagreen": "#20B2AA", + "lightskyblue": "#87CEFA", + "lightslategray": "#778899", + "lightslategrey": "#778899", + "lightsteelblue": "#B0C4DE", + "lightyellow": "#FFFFE0", + "lime": "#00FF00", + "limegreen": "#32CD32", + "linen": "#FAF0E6", + "magenta": "#FF00FF", + "maroon": "#800000", + "mediumaquamarine": "#66CDAA", + "mediumblue": "#0000CD", + "mediumorchid": "#BA55D3", + "mediumpurple": "#9370DB", + "mediumseagreen": "#3CB371", + "mediumslateblue": "#7B68EE", + "mediumspringgreen": "#00FA9A", + "mediumturquoise": "#48D1CC", + "mediumvioletred": "#C71585", + "midnightblue": "#191970", + "mintcream": "#F5FFFA", + "mistyrose": "#FFE4E1", + "moccasin": "#FFE4B5", + "navajowhite": "#FFDEAD", + "navy": "#000080", + "oldlace": "#FDF5E6", + "olive": "#808000", + "olivedrab": "#6B8E23", + "orange": "#FFA500", + "orangered": "#FF4500", + "orchid": "#DA70D6", + "palegoldenrod": "#EEE8AA", + "palegreen": "#98FB98", + "paleturquoise": "#AFEEEE", + "palevioletred": "#DB7093", + "papayawhip": "#FFEFD5", + "peachpuff": "#FFDAB9", + "peru": "#CD853F", + "pink": "#FFC0CB", + "plum": "#DDA0DD", + "powderblue": "#B0E0E6", + "purple": "#800080", + "red": "#FF0000", + "rosybrown": "#BC8F8F", + "royalblue": "#4169E1", + "saddlebrown": "#8B4513", + "salmon": "#FA8072", + "sandybrown": "#F4A460", + "seagreen": "#2E8B57", + "seashell": "#FFF5EE", + "sienna": "#A0522D", + "silver": "#C0C0C0", + "skyblue": "#87CEEB", + "slateblue": "#6A5ACD", + "slategray": "#708090", + "slategrey": "#708090", + "snow": "#FFFAFA", + "springgreen": "#00FF7F", + "steelblue": "#4682B4", + "tan": "#D2B48C", + "teal": "#008080", + "thistle": "#D8BFD8", + "tomato": "#FF6347", + "turquoise": "#40E0D0", + "violet": "#EE82EE", + "wheat": "#F5DEB3", + "white": "#FFFFFF", + "whitesmoke": "#F5F5F5", + "yellow": "#FFFF00", + "yellowgreen": "#9ACD32", }; @@ -753,13 +745,13 @@

Source: util/Color.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/util_ColorMixin.js.html b/docs/util_ColorMixin.js.html index 110bf767..8832bfbf 100644 --- a/docs/util_ColorMixin.js.html +++ b/docs/util_ColorMixin.js.html @@ -35,9 +35,7 @@

Source: util/ColorMixin.js

* @license Distributed under the terms of the MIT License */ - -import {Color} from './Color'; - +import { Color } from "./Color.js"; /** * <p>This mixin implement color and contrast changes for visual stimuli</p> @@ -45,15 +43,15 @@

Source: util/ColorMixin.js

* @name module:util.ColorMixin * @mixin */ -export let ColorMixin = (superclass) => class extends superclass -{ - constructor(args) +export let ColorMixin = (superclass) => + class extends superclass { - super(args); - } - + constructor(args) + { + super(args); + } - /** + /** * Setter for Color attribute. * * @name module:util.ColorMixin#setColor @@ -62,16 +60,15 @@

Source: util/ColorMixin.js

* @param {Color} color - the new color * @param {boolean} [log= false] - whether or not to log */ - setColor(color, log) - { - this._setAttribute('color', color, log); - - this._needUpdate = true; - this._needPixiUpdate = true; - } + setColor(color, log) + { + this._setAttribute("color", color, log); + this._needUpdate = true; + this._needPixiUpdate = true; + } - /** + /** * Setter for Contrast attribute. * * @name module:util.ColorMixin#setContrast @@ -80,16 +77,15 @@

Source: util/ColorMixin.js

* @param {number} contrast - the new contrast (must be between 0 and 1) * @param {boolean} [log= false] - whether or not to log */ - setContrast(contrast, log) - { - this._setAttribute('contrast', contrast, log); - - this._needUpdate = true; - this._needPixiUpdate = true; - } + setContrast(contrast, log) + { + this._setAttribute("contrast", contrast, log); + this._needUpdate = true; + this._needPixiUpdate = true; + } - /** + /** * Get a new contrasted Color. * * @name module:util.ColorMixin#getContrastedColor @@ -98,13 +94,12 @@

Source: util/ColorMixin.js

* @param {string|number|Array.<number>} color - the color * @param {number} contrast - the contrast (must be between 0 and 1) */ - getContrastedColor(color, contrast) - { - const rgb = color.rgb.map(c => (c * 2.0 - 1.0) * contrast); - return new Color(rgb, Color.COLOR_SPACE.RGB); - } - -}; + getContrastedColor(color, contrast) + { + const rgb = color.rgb.map((c) => (c * 2.0 - 1.0) * contrast); + return new Color(rgb, Color.COLOR_SPACE.RGB); + } + }; @@ -115,13 +110,13 @@

Source: util/ColorMixin.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/util_EventEmitter.js.html b/docs/util_EventEmitter.js.html index 83f200e6..32a3c3b7 100644 --- a/docs/util_EventEmitter.js.html +++ b/docs/util_EventEmitter.js.html @@ -35,9 +35,7 @@

Source: util/EventEmitter.js

* @license Distributed under the terms of the MIT License */ - -import * as util from './Util'; - +import * as util from "./Util.js"; /** * <p>EventEmitter implements the classic observer/observable pattern.</p> @@ -62,7 +60,6 @@

Source: util/EventEmitter.js

this._onceUuids = new Map(); } - /** * Listener called when this instance emits an event for which it is registered. * @@ -70,7 +67,6 @@

Source: util/EventEmitter.js

* @param {object} data - the data passed to the listener */ - /** * Register a new listener for events with the given name emitted by this instance. * @@ -84,9 +80,9 @@

Source: util/EventEmitter.js

on(name, listener) { // check that the listener is a function: - if (typeof listener !== 'function') + if (typeof listener !== "function") { - throw new TypeError('listener must be a function'); + throw new TypeError("listener must be a function"); } // generate a new uuid: @@ -97,12 +93,11 @@

Source: util/EventEmitter.js

{ this._listeners.set(name, []); } - this._listeners.get(name).push({uuid, listener}); + this._listeners.get(name).push({ uuid, listener }); return uuid; } - /** * Register a new listener for the given event name, and remove it as soon as the event has been emitted. * @@ -126,7 +121,6 @@

Source: util/EventEmitter.js

return uuid; } - /** * Remove the listener with the given uuid associated to the given event name. * @@ -142,13 +136,12 @@

Source: util/EventEmitter.js

if (relevantUuidListeners && relevantUuidListeners.length) { - this._listeners.set(name, relevantUuidListeners.filter(uuidlistener => (uuidlistener.uuid != uuid))); + this._listeners.set(name, relevantUuidListeners.filter((uuidlistener) => (uuidlistener.uuid != uuid))); return true; } return false; } - /** * Emit an event with a given name and associated data. * @@ -166,11 +159,11 @@

Source: util/EventEmitter.js

{ let onceUuids = this._onceUuids.get(name); let self = this; - relevantUuidListeners.forEach(({uuid, listener}) => + relevantUuidListeners.forEach(({ uuid, listener }) => { listener(data); - if (typeof onceUuids !== 'undefined' && onceUuids.includes(uuid)) + if (typeof onceUuids !== "undefined" && onceUuids.includes(uuid)) { self.off(name, uuid); } @@ -180,8 +173,6 @@

Source: util/EventEmitter.js

return false; } - - } @@ -193,13 +184,13 @@

Source: util/EventEmitter.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/util_Pixi.js.html b/docs/util_Pixi.js.html new file mode 100644 index 00000000..236a8a41 --- /dev/null +++ b/docs/util_Pixi.js.html @@ -0,0 +1,87 @@ + + + + + JSDoc: Source: util/Pixi.js + + + + + + + + + + +
+ +

Source: util/Pixi.js

+ + + + + + +
+
+
/**
+ * PIXI utilities.
+ *
+ * @authors Alain Pitiot, Sotiri Bakagiannis, Thomas Pronk
+ * @version 2021.2.0
+ * @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
+ */
+
+import * as PIXI from "pixi.js-legacy";
+import { to_px } from "./Util.js";
+
+/**
+ * Convert a position to a PIXI Point.
+ *
+ * @name module:util.to_pixiPoint
+ * @function
+ * @public
+ * @param {number[]} pos - the input position
+ * @param {string} posUnit - the position units
+ * @param {Window} win - the associated Window
+ * @param {boolean} [integerCoordinates = false] - whether or not to round the PIXI Point coordinates.
+ * @returns {number[]} the position as a PIXI Point
+ */
+export function to_pixiPoint(pos, posUnit, win, integerCoordinates = false)
+{
+	const pos_px = to_px(pos, posUnit, win);
+	if (integerCoordinates)
+	{
+		return new PIXI.Point(Math.round(pos_px[0]), Math.round(pos_px[1]));
+	}
+	else
+	{
+		return new PIXI.Point(pos_px[0], pos_px[1]);
+	}
+}
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + diff --git a/docs/util_PsychObject.js.html b/docs/util_PsychObject.js.html index d36549a2..673f0c9a 100644 --- a/docs/util_PsychObject.js.html +++ b/docs/util_PsychObject.js.html @@ -36,10 +36,8 @@

Source: util/PsychObject.js

* @license Distributed under the terms of the MIT License */ - -import {EventEmitter} from './EventEmitter'; -import * as util from './Util'; - +import { EventEmitter } from "./EventEmitter.js"; +import * as util from "./Util.js"; /** * <p>PsychoObject is the base class for all PsychoJS objects. @@ -60,14 +58,13 @@

Source: util/PsychObject.js

this._userAttributes = new Set(); // name: - if (typeof name === 'undefined') + if (typeof name === "undefined") { name = this.constructor.name; } - this._addAttribute('name', name); + this._addAttribute("name", name); } - /** * Get the PsychoJS instance. * @@ -79,7 +76,6 @@

Source: util/PsychObject.js

return this._psychoJS; } - /** * Setter for the PsychoJS attribute. * @@ -91,7 +87,6 @@

Source: util/PsychObject.js

this._psychoJS = psychoJS; } - /** * String representation of the PsychObject. * @@ -102,38 +97,37 @@

Source: util/PsychObject.js

*/ toString() { - let representation = this.constructor.name + '( '; + let representation = this.constructor.name + "( "; let addComma = false; for (const attribute of this._userAttributes) { if (addComma) { - representation += ', '; + representation += ", "; } addComma = true; - let value = util.toString(this['_' + attribute]); + let value = util.toString(this["_" + attribute]); const l = value.length; if (l > 50) { - if (value[l - 1] === ')') + if (value[l - 1] === ")") { - value = value.substring(0, 50) + '~)'; + value = value.substring(0, 50) + "~)"; } else { - value = value.substring(0, 50) + '~'; + value = value.substring(0, 50) + "~"; } } - representation += attribute + '=' + value; + representation += attribute + "=" + value; } - representation += ' )'; + representation += " )"; return representation; } - /** * Set the value of an attribute. * @@ -149,31 +143,30 @@

Source: util/PsychObject.js

_setAttribute(attributeName, attributeValue, log = false, operation = undefined, stealth = false) { const response = { - origin: 'PsychObject.setAttribute', - context: 'when setting the attribute of an object' + origin: "PsychObject.setAttribute", + context: "when setting the attribute of an object", }; - if (typeof attributeName == 'undefined') + if (typeof attributeName == "undefined") { throw Object.assign(response, { - error: 'the attribute name cannot be' + - ' undefined' + error: "the attribute name cannot be" + + " undefined", }); } - if (typeof attributeValue == 'undefined') + if (typeof attributeValue == "undefined") { - this._psychoJS.logger.warn('setting the value of attribute: ' + attributeName + ' in PsychObject: ' + this._name + ' as: undefined'); + this._psychoJS.logger.warn("setting the value of attribute: " + attributeName + " in PsychObject: " + this._name + " as: undefined"); } // (*) apply operation to old and new values: - if (typeof operation !== 'undefined' && this.hasOwnProperty('_' + attributeName)) + if (typeof operation !== "undefined" && this.hasOwnProperty("_" + attributeName)) { - let oldValue = this['_' + attributeName]; + let oldValue = this["_" + attributeName]; // operations can only be applied to numbers and array of numbers (which can be empty): - if (typeof attributeValue == 'number' || (Array.isArray(attributeValue) && (attributeValue.length === 0 || typeof attributeValue[0] == 'number'))) + if (typeof attributeValue == "number" || (Array.isArray(attributeValue) && (attributeValue.length === 0 || typeof attributeValue[0] == "number"))) { - // value is an array: if (Array.isArray(attributeValue)) { @@ -183,160 +176,158 @@

Source: util/PsychObject.js

if (attributeValue.length !== oldValue.length) { throw Object.assign(response, { - error: 'old and new' + - ' value should have' + - ' the same size when they are both arrays' + error: "old and new" + + " value should have" + + " the same size when they are both arrays", }); } switch (operation) { - case '': + case "": // no change to value; break; - case '+': + case "+": attributeValue = attributeValue.map((v, i) => oldValue[i] + v); break; - case '*': + case "*": attributeValue = attributeValue.map((v, i) => oldValue[i] * v); break; - case '-': + case "-": attributeValue = attributeValue.map((v, i) => oldValue[i] - v); break; - case '/': + case "/": attributeValue = attributeValue.map((v, i) => oldValue[i] / v); break; - case '**': + case "**": attributeValue = attributeValue.map((v, i) => oldValue[i] ** v); break; - case '%': + case "%": attributeValue = attributeValue.map((v, i) => oldValue[i] % v); break; default: throw Object.assign(response, { - error: 'unsupported' + - ' operation: ' + operation + ' when setting: ' + attributeName + ' in: ' + this.name + error: "unsupported" + + " operation: " + operation + " when setting: " + attributeName + " in: " + this.name, }); } - } - else // old value is a scalar + else { switch (operation) { - case '': + case "": // no change to value; break; - case '+': - attributeValue = attributeValue.map(v => oldValue + v); + case "+": + attributeValue = attributeValue.map((v) => oldValue + v); break; - case '*': - attributeValue = attributeValue.map(v => oldValue * v); + case "*": + attributeValue = attributeValue.map((v) => oldValue * v); break; - case '-': - attributeValue = attributeValue.map(v => oldValue - v); + case "-": + attributeValue = attributeValue.map((v) => oldValue - v); break; - case '/': - attributeValue = attributeValue.map(v => oldValue / v); + case "/": + attributeValue = attributeValue.map((v) => oldValue / v); break; - case '**': - attributeValue = attributeValue.map(v => oldValue ** v); + case "**": + attributeValue = attributeValue.map((v) => oldValue ** v); break; - case '%': - attributeValue = attributeValue.map(v => oldValue % v); + case "%": + attributeValue = attributeValue.map((v) => oldValue % v); break; default: throw Object.assign(response, { - error: 'unsupported' + - ' value: ' + JSON.stringify(attributeValue) + ' for' + - ' operation: ' + operation + ' when setting: ' + attributeName + ' in: ' + this.name + error: "unsupported" + + " value: " + JSON.stringify(attributeValue) + " for" + + " operation: " + operation + " when setting: " + attributeName + " in: " + this.name, }); } } } - else // value is a scalar + else { // old value is an array if (Array.isArray(oldValue)) { switch (operation) { - case '': - attributeValue = oldValue.map(v => attributeValue); + case "": + attributeValue = oldValue.map((v) => attributeValue); break; - case '+': - attributeValue = oldValue.map(v => v + attributeValue); + case "+": + attributeValue = oldValue.map((v) => v + attributeValue); break; - case '*': - attributeValue = oldValue.map(v => v * attributeValue); + case "*": + attributeValue = oldValue.map((v) => v * attributeValue); break; - case '-': - attributeValue = oldValue.map(v => v - attributeValue); + case "-": + attributeValue = oldValue.map((v) => v - attributeValue); break; - case '/': - attributeValue = oldValue.map(v => v / attributeValue); + case "/": + attributeValue = oldValue.map((v) => v / attributeValue); break; - case '**': - attributeValue = oldValue.map(v => v ** attributeValue); + case "**": + attributeValue = oldValue.map((v) => v ** attributeValue); break; - case '%': - attributeValue = oldValue.map(v => v % attributeValue); + case "%": + attributeValue = oldValue.map((v) => v % attributeValue); break; default: throw Object.assign(response, { - error: 'unsupported' + - ' operation: ' + operation + ' when setting: ' + attributeName + ' in: ' + this.name + error: "unsupported" + + " operation: " + operation + " when setting: " + attributeName + " in: " + this.name, }); } - } - else // old value is a scalar + else { switch (operation) { - case '': + case "": // no change to value; break; - case '+': + case "+": attributeValue = oldValue + attributeValue; break; - case '*': + case "*": attributeValue = oldValue * attributeValue; break; - case '-': + case "-": attributeValue = oldValue - attributeValue; break; - case '/': + case "/": attributeValue = oldValue / attributeValue; break; - case '**': + case "**": attributeValue = oldValue ** attributeValue; break; - case '%': + case "%": attributeValue = oldValue % attributeValue; break; default: throw Object.assign(response, { - error: 'unsupported' + - ' value: ' + JSON.stringify(attributeValue) + ' for operation: ' + operation + ' when setting: ' + attributeName + ' in: ' + this.name + error: "unsupported" + + " value: " + JSON.stringify(attributeValue) + " for operation: " + operation + " when setting: " + attributeName + " in: " + this.name, }); } } } - } else { - throw Object.assign(response, {error: 'operation: ' + operation + ' is invalid for old value: ' + JSON.stringify(oldValue) + ' and new value: ' + JSON.stringify(attributeValue)}); + throw Object.assign(response, { + error: "operation: " + operation + " is invalid for old value: " + JSON.stringify(oldValue) + " and new value: " + JSON.stringify(attributeValue), + }); } } - // (*) log if appropriate: - if (!stealth && (log || this._autoLog) && (typeof this.win !== 'undefined')) + if (!stealth && (log || this._autoLog) && (typeof this.win !== "undefined")) { const msg = this.name + ": " + attributeName + " = " + util.toString(attributeValue); this.win.logOnFlip({ @@ -345,13 +336,12 @@

Source: util/PsychObject.js

}); } - // (*) set the value of the attribute and return whether it has changed: - const previousAttributeValue = this['_' + attributeName]; - this['_' + attributeName] = attributeValue; + const previousAttributeValue = this["_" + attributeName]; + this["_" + attributeName] = attributeValue; // Things seem OK without this check except for 'vertices' - if (typeof previousAttributeValue === 'undefined') + if (typeof previousAttributeValue === "undefined") { // Not that any of the following lines should throw, but evaluating // `this._vertices.map` on `ShapeStim._getVertices_px()` seems to @@ -370,10 +360,9 @@

Source: util/PsychObject.js

// `Util.toString()` might try, but fail to stringify in a meaningful way are assigned // an 'Object (circular)' string representation. For being opaque as to their raw // value, those types of input are liable to produce PIXI updates. - return prev === 'Object (circular)' || next === 'Object (circular)' || prev !== next; + return prev === "Object (circular)" || next === "Object (circular)" || prev !== next; } - /** * Add an attribute to this instance (e.g. define setters and getters) and affect a value to it. * @@ -383,20 +372,21 @@

Source: util/PsychObject.js

* @param {object} [defaultValue] - the default value for the attribute * @param {function} [onChange] - function called upon changes to the attribute value */ - _addAttribute(name, value, defaultValue = undefined, onChange = () => {}) + _addAttribute(name, value, defaultValue = undefined, onChange = () => + {}) { - const getPropertyName = 'get' + name[0].toUpperCase() + name.substr(1); - if (typeof this[getPropertyName] === 'undefined') + const getPropertyName = "get" + name[0].toUpperCase() + name.substr(1); + if (typeof this[getPropertyName] === "undefined") { - this[getPropertyName] = () => this['_' + name]; + this[getPropertyName] = () => this["_" + name]; } - const setPropertyName = 'set' + name[0].toUpperCase() + name.substr(1); - if (typeof this[setPropertyName] === 'undefined') + const setPropertyName = "set" + name[0].toUpperCase() + name.substr(1); + if (typeof this[setPropertyName] === "undefined") { this[setPropertyName] = (value, log = false) => { - if (typeof value === 'undefined' || value === null) + if (typeof value === "undefined" || value === null) { value = defaultValue; } @@ -410,7 +400,7 @@

Source: util/PsychObject.js

else { // deal with default value: - if (typeof value === 'undefined' || value === null) + if (typeof value === "undefined" || value === null) { value = defaultValue; } @@ -425,18 +415,16 @@

Source: util/PsychObject.js

set(value) { this[setPropertyName](value); - } + }, }); - // note: we use this[name] instead of this['_' + name] since a this.set<Name> method may available // in the object, in which case we need to call it this[name] = value; - //this['_' + name] = value; + // this['_' + name] = value; this._userAttributes.add(name); } - } @@ -448,13 +436,13 @@

Source: util/PsychObject.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/util_Scheduler.js.html b/docs/util_Scheduler.js.html index 3351df52..6c26b5d8 100644 --- a/docs/util_Scheduler.js.html +++ b/docs/util_Scheduler.js.html @@ -35,7 +35,6 @@

Source: util/Scheduler.js

* @license Distributed under the terms of the MIT License */ - /** * <p>A scheduler helps run the main loop by managing scheduled functions, * called tasks, after each frame is displayed.</p> @@ -81,7 +80,6 @@

Source: util/Scheduler.js

this._status = Scheduler.Status.STOPPED; } - /** * Get the status of the scheduler. * @@ -94,7 +92,6 @@

Source: util/Scheduler.js

return this._status; } - /** * Task to be run by the scheduler. * @@ -115,7 +112,6 @@

Source: util/Scheduler.js

this._argsList.push(args); } - /** * Condition evaluated when the task is run. * @@ -136,7 +132,7 @@

Source: util/Scheduler.js

addConditional(condition, thenScheduler, elseScheduler) { const self = this; - let task = function () + let task = function() { if (condition()) { @@ -153,7 +149,6 @@

Source: util/Scheduler.js

this.add(task); } - /** * Start this scheduler. * @@ -201,7 +196,6 @@

Source: util/Scheduler.js

requestAnimationFrame(update); } - /** * Stop this scheduler. * @@ -215,7 +209,6 @@

Source: util/Scheduler.js

this._stopAtNextUpdate = true; } - /** * Run the next scheduled tasks, in sequence, until a rendering of the scene is requested. * @@ -237,9 +230,8 @@

Source: util/Scheduler.js

} // if there is no current task, we look for the next one in the list or quit if there is none: - if (typeof this._currentTask == 'undefined') + if (typeof this._currentTask == "undefined") { - // a task is available in the taskList: if (this._taskList.length > 0) { @@ -287,15 +279,12 @@

Source: util/Scheduler.js

this._currentTask = undefined; this._currentArgs = undefined; } - } return state; } - } - /** * Events. * @@ -308,25 +297,24 @@

Source: util/Scheduler.js

/** * Move onto the next task *without* rendering the scene first. */ - NEXT: Symbol.for('NEXT'), + NEXT: Symbol.for("NEXT"), /** * Render the scene and repeat the task. */ - FLIP_REPEAT: Symbol.for('FLIP_REPEAT'), + FLIP_REPEAT: Symbol.for("FLIP_REPEAT"), /** * Render the scene and move onto the next task. */ - FLIP_NEXT: Symbol.for('FLIP_NEXT'), + FLIP_NEXT: Symbol.for("FLIP_NEXT"), /** * Quit the scheduler. */ - QUIT: Symbol.for('QUIT') + QUIT: Symbol.for("QUIT"), }; - /** * Status. * @@ -339,12 +327,12 @@

Source: util/Scheduler.js

/** * The Scheduler is running. */ - RUNNING: Symbol.for('RUNNING'), + RUNNING: Symbol.for("RUNNING"), /** * The Scheduler is stopped. */ - STOPPED: Symbol.for('STOPPED') + STOPPED: Symbol.for("STOPPED"), }; @@ -356,13 +344,13 @@

Source: util/Scheduler.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/util_Util.js.html b/docs/util_Util.js.html index ec8ea611..051c28fb 100644 --- a/docs/util_Util.js.html +++ b/docs/util_Util.js.html @@ -35,9 +35,6 @@

Source: util/Util.js

* @license Distributed under the terms of the MIT License */ -import * as PIXI from 'pixi.js-legacy'; - - /** * Syntactic sugar for Mixins * @@ -73,7 +70,6 @@

Source: util/Util.js

} } - /** * Convert the resulting value of a promise into a tupple. * @@ -87,11 +83,10 @@

Source: util/Util.js

export function promiseToTupple(promise) { return promise - .then(data => [null, data]) - .catch(error => [error, null]); + .then((data) => [null, data]) + .catch((error) => [error, null]); } - /** * Get a Universally Unique Identifier (RFC4122 version 4) * <p> See details here: https://www.ietf.org/rfc/rfc4122.txt</p> @@ -103,14 +98,13 @@

Source: util/Util.js

*/ export function makeUuid() { - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0, v = (c === 'x') ? r : (r & 0x3 | 0x8); + const r = Math.random() * 16 | 0, v = (c === "x") ? r : (r & 0x3 | 0x8); return v.toString(16); }); } - /** * Get the error stack of the calling, exception-throwing function. * @@ -123,7 +117,7 @@

Source: util/Util.js

{ try { - throw Error(''); + throw Error(""); } catch (error) { @@ -131,11 +125,10 @@

Source: util/Util.js

let stack = error.stack.split("\n"); stack.splice(1, 1); - return JSON.stringify(stack.join('\n')); + return JSON.stringify(stack.join("\n")); } } - /** * Test if x is an 'empty' value. * @@ -147,7 +140,7 @@

Source: util/Util.js

*/ export function isEmpty(x) { - if (typeof x === 'undefined') + if (typeof x === "undefined") { return true; } @@ -159,7 +152,7 @@

Source: util/Util.js

{ return true; } - if (x.length === 1 && typeof x[0] === 'undefined') + if (x.length === 1 && typeof x[0] === "undefined") { return true; } @@ -167,7 +160,6 @@

Source: util/Util.js

return false; } - /** * Detect the user's browser. * @@ -183,71 +175,70 @@

Source: util/Util.js

export function detectBrowser() { // Opera 8.0+ - const isOpera = (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(' OPR/') >= 0; + const isOpera = (!!window.opr && !!opr.addons) || !!window.opera || navigator.userAgent.indexOf(" OPR/") >= 0; if (isOpera) { - return 'Opera'; + return "Opera"; } // Firefox 1.0+ - const isFirefox = (typeof InstallTrigger !== 'undefined'); + const isFirefox = (typeof InstallTrigger !== "undefined"); if (isFirefox) { - return 'Firefox'; + return "Firefox"; } - // Safari 3.0+ "[object HTMLElementConstructor]" - const isSafari = /constructor/i.test(window.HTMLElement) || (function (p) + // Safari 3.0+ "[object HTMLElementConstructor]" + const isSafari = /constructor/i.test(window.HTMLElement) || (function(p) { return p.toString() === "[object SafariRemoteNotification]"; - })(!window['safari'] || (typeof safari !== 'undefined' && safari.pushNotification)); + })(!window["safari"] || (typeof safari !== "undefined" && safari.pushNotification)); if (isSafari) { - return 'Safari'; + return "Safari"; } // Internet Explorer 6-11 // const isIE6 = !window.XMLHttpRequest; // const isIE7 = document.all && window.XMLHttpRequest && !XDomainRequest && !window.opera; // const isIE8 = document.documentMode==8; - const isIE = /*@cc_on!@*/false || !!document.documentMode; + const isIE = /*@cc_on!@*/ false || !!document.documentMode; if (isIE) { - return 'IE'; + return "IE"; } // Edge 20+ const isEdge = !isIE && !!window.StyleMedia; if (isEdge) { - return 'Edge'; + return "Edge"; } // Chrome 1+ const isChrome = window.chrome; if (isChrome) { - return 'Chrome'; + return "Chrome"; } // Chromium-based Edge: const isEdgeChromium = isChrome && (navigator.userAgent.indexOf("Edg") !== -1); if (isEdgeChromium) { - return 'EdgeChromium'; + return "EdgeChromium"; } // Blink engine detection const isBlink = (isChrome || isOpera) && !!window.CSS; if (isBlink) { - return 'Blink'; + return "Blink"; } - return 'unknown'; + return "unknown"; } - /** * Convert obj to its numerical form. * @@ -267,24 +258,23 @@

Source: util/Util.js

export function toNumerical(obj) { const response = { - origin: 'util.toNumerical', - context: 'when converting an object to its numerical form' + origin: "util.toNumerical", + context: "when converting an object to its numerical form", }; try { - if (obj === null) { - throw 'unable to convert null to a number'; + throw "unable to convert null to a number"; } - if (typeof obj === 'undefined') + if (typeof obj === "undefined") { - throw 'unable to convert undefined to a number'; + throw "unable to convert undefined to a number"; } - if (typeof obj === 'number') + if (typeof obj === "number") { return obj; } @@ -313,20 +303,32 @@

Source: util/Util.js

return arrayMaybe.map(convertToNumber); } - if (typeof obj === 'string') + if (typeof obj === "string") { return convertToNumber(obj); } - throw 'unable to convert the object to a number'; + throw "unable to convert the object to a number"; } catch (error) { throw Object.assign(response, { error }); } - } +/** + * Check whether a value looks like a number + * + * @name module:util.isNumeric + * @function + * @public + * @param {*} input - Some value + * @return {boolean} Whether or not the value can be converted into a number + */ +export function isNumeric(input) +{ + return Number.isNaN(Number(input)) === false; +} /** * Check whether a point lies within a polygon @@ -359,7 +361,6 @@

Source: util/Util.js

return isInside; } - /** * Shuffle an array in place using the Fisher-Yastes's modern algorithm * <p>See details here: https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm</p> @@ -373,7 +374,8 @@

Source: util/Util.js

*/ export function shuffle(array, randomNumberGenerator = undefined) { - if (randomNumberGenerator === undefined) { + if (randomNumberGenerator === undefined) + { randomNumberGenerator = Math.random; } for (let i = array.length - 1; i > 0; i--) @@ -384,7 +386,25 @@

Source: util/Util.js

return array; } - +/** + * Pick a random value from an array, uses `util.shuffle` to shuffle the array and returns the last value. + * + * @name module:util.randchoice + * @function + * @public + * @param {Object[]} array - the input 1-D array + * @param {Function} [randomNumberGenerator = undefined] - A function used to generated random numbers in the interal [0, 1). Defaults to Math.random + * @return {Object[]} a chosen value from the array + */ +export function randchoice(array, randomNumberGenerator = undefined) +{ + if (randomNumberGenerator === undefined) + { + randomNumberGenerator = Math.random; + } + const j = Math.floor(randomNumberGenerator() * array.length); + return array[j] +} /** * Get the position of the object, in pixel units @@ -399,21 +419,21 @@

Source: util/Util.js

export function getPositionFromObject(object, units) { const response = { - origin: 'util.getPositionFromObject', - context: 'when getting the position of an object' + origin: "util.getPositionFromObject", + context: "when getting the position of an object", }; try { - if (typeof object === 'undefined') + if (typeof object === "undefined") { - throw 'cannot get the position of an undefined object'; + throw "cannot get the position of an undefined object"; } let objectWin = undefined; // the object has a getPos function: - if (typeof object.getPos === 'function') + if (typeof object.getPos === "function") { units = object.units; objectWin = object.win; @@ -425,12 +445,10 @@

Source: util/Util.js

} catch (error) { - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - - /** * Convert the position to pixel units. * @@ -446,28 +464,28 @@

Source: util/Util.js

export function to_px(pos, posUnit, win, integerCoordinates = false) { const response = { - origin: 'util.to_px', - context: 'when converting a position to pixel units' + origin: "util.to_px", + context: "when converting a position to pixel units", }; let pos_px; - if (posUnit === 'pix') + if (posUnit === "pix") { pos_px = pos; } - else if (posUnit === 'norm') + else if (posUnit === "norm") { pos_px = [pos[0] * win.size[0] / 2.0, pos[1] * win.size[1] / 2.0]; } - else if (posUnit === 'height') + else if (posUnit === "height") { const minSize = Math.min(win.size[0], win.size[1]); pos_px = [pos[0] * minSize, pos[1] * minSize]; } else { - throw Object.assign(response, {error: `unknown position units: ${posUnit}`}); + throw Object.assign(response, { error: `unknown position units: ${posUnit}` }); } if (integerCoordinates) @@ -480,7 +498,6 @@

Source: util/Util.js

} } - /** * Convert the position to norm units. * @@ -494,26 +511,25 @@

Source: util/Util.js

*/ export function to_norm(pos, posUnit, win) { - const response = {origin: 'util.to_norm', context: 'when converting a position to norm units'}; + const response = { origin: "util.to_norm", context: "when converting a position to norm units" }; - if (posUnit === 'norm') + if (posUnit === "norm") { return pos; } - if (posUnit === 'pix') + if (posUnit === "pix") { return [pos[0] / (win.size[0] / 2.0), pos[1] / (win.size[1] / 2.0)]; } - if (posUnit === 'height') + if (posUnit === "height") { const minSize = Math.min(win.size[0], win.size[1]); return [pos[0] * minSize / (win.size[0] / 2.0), pos[1] * minSize / (win.size[1] / 2.0)]; } - throw Object.assign(response, {error: `unknown position units: ${posUnit}`}); + throw Object.assign(response, { error: `unknown position units: ${posUnit}` }); } - /** * Convert the position to height units. * @@ -528,29 +544,28 @@

Source: util/Util.js

export function to_height(pos, posUnit, win) { const response = { - origin: 'util.to_height', - context: 'when converting a position to height units' + origin: "util.to_height", + context: "when converting a position to height units", }; - if (posUnit === 'height') + if (posUnit === "height") { return pos; } - if (posUnit === 'pix') + if (posUnit === "pix") { const minSize = Math.min(win.size[0], win.size[1]); return [pos[0] / minSize, pos[1] / minSize]; } - if (posUnit === 'norm') + if (posUnit === "norm") { const minSize = Math.min(win.size[0], win.size[1]); return [pos[0] * win.size[0] / 2.0 / minSize, pos[1] * win.size[1] / 2.0 / minSize]; } - throw Object.assign(response, {error: `unknown position units: ${posUnit}`}); + throw Object.assign(response, { error: `unknown position units: ${posUnit}` }); } - /** * Convert the position to window units. * @@ -564,19 +579,19 @@

Source: util/Util.js

*/ export function to_win(pos, posUnit, win) { - const response = {origin: 'util.to_win', context: 'when converting a position to window units'}; + const response = { origin: "util.to_win", context: "when converting a position to window units" }; try { - if (win._units === 'pix') + if (win._units === "pix") { return to_px(pos, posUnit, win); } - if (win._units === 'norm') + if (win._units === "norm") { return to_norm(pos, posUnit, win); } - if (win._units === 'height') + if (win._units === "height") { return to_height(pos, posUnit, win); } @@ -585,11 +600,10 @@

Source: util/Util.js

} catch (error) { - throw Object.assign(response, {response, error}); + throw Object.assign(response, { response, error }); } } - /** * Convert the position to given units. * @@ -604,19 +618,19 @@

Source: util/Util.js

*/ export function to_unit(pos, posUnit, win, targetUnit) { - const response = {origin: 'util.to_unit', context: 'when converting a position to different units'}; + const response = { origin: "util.to_unit", context: "when converting a position to different units" }; try { - if (targetUnit === 'pix') + if (targetUnit === "pix") { return to_px(pos, posUnit, win); } - if (targetUnit === 'norm') + if (targetUnit === "norm") { return to_norm(pos, posUnit, win); } - if (targetUnit === 'height') + if (targetUnit === "height") { return to_height(pos, posUnit, win); } @@ -625,37 +639,10 @@

Source: util/Util.js

} catch (error) { - throw Object.assign(response, {error}); - } -} - - -/** - * Convert a position to a PIXI Point. - * - * @name module:util.to_pixiPoint - * @function - * @public - * @param {number[]} pos - the input position - * @param {string} posUnit - the position units - * @param {Window} win - the associated Window - * @param {boolean} [integerCoordinates = false] - whether or not to round the PIXI Point coordinates. - * @returns {number[]} the position as a PIXI Point - */ -export function to_pixiPoint(pos, posUnit, win, integerCoordinates = false) -{ - const pos_px = to_px(pos, posUnit, win); - if (integerCoordinates) - { - return new PIXI.Point(Math.round(pos_px[0]), Math.round(pos_px[1])); - } - else - { - return new PIXI.Point(pos_px[0], pos_px[1]); + throw Object.assign(response, { error }); } } - /** * Convert an object to its string representation, taking care of symbols. * @@ -669,23 +656,23 @@

Source: util/Util.js

*/ export function toString(object) { - if (typeof object === 'undefined') + if (typeof object === "undefined") { - return 'undefined'; + return "undefined"; } if (!object) { - return 'null'; + return "null"; } - if (typeof object === 'string') + if (typeof object === "string") { return object; } // if the object is a class and has a toString method: - if (object.constructor.toString().substring(0, 5) === 'class' && typeof object.toString === 'function') + if (object.constructor.toString().substring(0, 5) === "class" && typeof object.toString === "function") { return object.toString(); } @@ -694,7 +681,7 @@

Source: util/Util.js

{ const symbolReplacer = (key, value) => { - if (typeof value === 'symbol') + if (typeof value === "symbol") { value = Symbol.keyFor(value); } @@ -704,30 +691,28 @@

Source: util/Util.js

} catch (e) { - return 'Object (circular)'; + return "Object (circular)"; } } - if (!String.prototype.format) { - String.prototype.format = function () + String.prototype.format = function() { var args = arguments; return this - .replace(/{(\d+)}/g, function (match, number) + .replace(/{(\d+)}/g, function(match, number) { - return typeof args[number] != 'undefined' ? args[number] : match; + return typeof args[number] != "undefined" ? args[number] : match; }) - .replace(/{([$_a-zA-Z][$_a-zA-Z0-9]*)}/g, function (match, name) + .replace(/{([$_a-zA-Z][$_a-zA-Z0-9]*)}/g, function(match, name) { - //console.log("n=" + name + " args[0][name]=" + args[0][name]); + // console.log("n=" + name + " args[0][name]=" + args[0][name]); return args.length > 0 && args[0][name] !== undefined ? args[0][name] : match; }); }; } - /** * Get the most informative error from the server response from a jquery server request. * @@ -740,17 +725,17 @@

Source: util/Util.js

*/ export function getRequestError(jqXHR, textStatus, errorThrown) { - let errorMsg = 'unknown error'; + let errorMsg = "unknown error"; - if (typeof jqXHR.responseJSON !== 'undefined') + if (typeof jqXHR.responseJSON !== "undefined") { errorMsg = jqXHR.responseJSON; } - else if (typeof jqXHR.responseText !== 'undefined') + else if (typeof jqXHR.responseText !== "undefined") { errorMsg = jqXHR.responseText; } - else if (typeof errorThrown !== 'undefined') + else if (typeof errorThrown !== "undefined") { errorMsg = errorThrown; } @@ -758,7 +743,6 @@

Source: util/Util.js

return errorMsg; } - /** * Test whether an object is either an integer or the string representation of an integer. * <p>This is adapted from: https://stackoverflow.com/a/14794066</p> @@ -780,7 +764,6 @@

Source: util/Util.js

return (x | 0) === x; } - /** * Get the URL parameters. * @@ -807,7 +790,6 @@

Source: util/Util.js

return urlMap;*/ } - /** * Add info extracted from the URL to the given dictionary. * @@ -828,7 +810,7 @@

Source: util/Util.js

// for (const [key, value] of infoFromUrl) infoFromUrl.forEach((value, key) => { - if (key.indexOf('__') !== 0) + if (key.indexOf("__") !== 0) { info[key] = value; } @@ -837,7 +819,6 @@

Source: util/Util.js

return info; } - /** * Select values from an array. * @@ -860,28 +841,32 @@

Source: util/Util.js

*/ export function selectFromArray(array, selection) { - - // if selection is an integer, or a string representing an integer, we treat it as an index in the array - // and return that entry: + // if selection is an integer, or a string representing an integer, we treat it + // as an index in the array and return that entry: if (isInt(selection)) { return [array[parseInt(selection)]]; - }// if selection is an array, we treat it as a list of indices + } + + // if selection is an array, we treat it as a list of indices // and return an array with the entries corresponding to those indices: else if (Array.isArray(selection)) { - // Pick out `array` items matching indices contained in `selection` in order - return selection.map(i => array[i]); - }// if selection is a string, we decode it: - else if (typeof selection === 'string') + return selection.map( (i) => array[i] ); + } + + // if selection is a string: + else if (typeof selection === "string") { - if (selection.indexOf(',') > -1) + if (selection.indexOf(",") > -1) { - return selection.split(',').map(a => selectFromArray(array, a)); - }// return flattenArray( selection.split(',').map(a => selectFromArray(array, a)) ); - else if (selection.indexOf(':') > -1) + const selectionAsArray = selection.split(",").map( (i) => parseInt(i) ); + return selectFromArray(array, selectionAsArray); + } + + else if (selection.indexOf(":") > -1) { - let sliceParams = selection.split(':').map(a => parseInt(a)); + let sliceParams = selection.split(":").map((a) => parseInt(a)); if (sliceParams.length === 3) { return sliceArray(array, sliceParams[0], sliceParams[2], sliceParams[1]); @@ -892,18 +877,16 @@

Source: util/Util.js

} } } - else { throw { - origin: 'selectFromArray', - context: 'when selecting entries from an array', - error: 'unknown selection type: ' + (typeof selection) + origin: "selectFromArray", + context: "when selecting entries from an array", + error: "unknown selection type: " + (typeof selection), }; } } - /** * Recursively flatten an array of arrays. * @@ -921,11 +904,10 @@

Source: util/Util.js

flat.push((Array.isArray(next) && Array.isArray(next[0])) ? flattenArray(next) : next); return flat; }, - [] + [], ); } - /** * Slice an array. * @@ -972,7 +954,6 @@

Source: util/Util.js

} } - /** * Offer data as download in the browser. * @@ -985,14 +966,14 @@

Source: util/Util.js

*/ export function offerDataForDownload(filename, data, type) { - const blob = new Blob([data], {type}); + const blob = new Blob([data], { type }); if (window.navigator.msSaveOrOpenBlob) { window.navigator.msSaveBlob(blob, filename); } else { - const anchor = document.createElement('a'); + const anchor = document.createElement("a"); anchor.href = window.URL.createObjectURL(blob); anchor.download = filename; document.body.appendChild(anchor); @@ -1001,7 +982,6 @@

Source: util/Util.js

} } - /** * Convert a string representing a JSON array, e.g. "[1, 2]" into an array, e.g. ["1","2"]. * This approach overcomes the built-in JSON parsing limitations when it comes to eg. floats @@ -1035,14 +1015,13 @@

Source: util/Util.js

// Reformat content for each match const matches = matchesMaybe.map((data) => - { - return data - // Remove the square brackets - .replace(/[\[\]]+/g, '') - // Eat up space after comma - .split(/[, ]+/); - } - ); + { + return data + // Remove the square brackets + .replace(/[\[\]]+/g, "") + // Eat up space after comma + .split(/[, ]+/); + }); if (max < 2) { @@ -1052,7 +1031,6 @@

Source: util/Util.js

return matches; } - /** * Generates random integers a-la NumPy's in the "half-open" interval [min, max). In other words, from min inclusive to max exclusive. When max is undefined, as is the case by default, results are chosen from [0, min). An error is thrown if max is less than min. * @@ -1068,7 +1046,7 @@

Source: util/Util.js

let lo = min; let hi = max; - if (typeof max === 'undefined') + if (typeof max === "undefined") { hi = lo; lo = 0; @@ -1077,16 +1055,15 @@

Source: util/Util.js

if (hi < lo) { throw { - origin: 'util.randint', - context: 'when generating a random integer', - error: 'min should be <= max' + origin: "util.randint", + context: "when generating a random integer", + error: "min should be <= max", }; } return Math.floor(Math.random() * (hi - lo)) + lo; } - /** * Round to a certain number of decimal places. * @@ -1105,7 +1082,6 @@

Source: util/Util.js

return +(Math.round(`${input}e+${places}`) + `e-${places}`); } - /** * Calculate the sum of the elements in the input array. * @@ -1129,14 +1105,13 @@

Source: util/Util.js

return input // type cast everything as a number - .map(value => Number(value)) + .map((value) => Number(value)) // drop non numeric looking entries (note: needs transpiling for IE11) - .filter(value => Number.isNaN(value) === false) + .filter((value) => Number.isNaN(value) === false) // add up each successive entry, starting with start .reduce(add, start); } - /** * Calculate the average of the elements in the input array. * @@ -1163,10 +1138,9 @@

Source: util/Util.js

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 @@ -1178,44 +1152,43 @@

Source: util/Util.js

export function sort(input) { const response = { - origin: 'util.sort', - context: 'when sorting the elements of an array' + origin: "util.sort", + context: "when sorting the elements of an array", }; try { if (!Array.isArray(input)) { - throw 'the input argument should be an array'; + 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"); + 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"); + 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'; + + throw "the input array should either consist entirely of strings or of numbers"; } catch (error) { - throw {...response, 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 - * + * * <p> Note: this is a JavaScript implement of the Python range function, which explains the unusual management of arguments.</p> * * @name module:util.range @@ -1229,8 +1202,8 @@

Source: util/Util.js

export function range(...args) { const response = { - origin: 'util.range', - context: 'when building a range of numbers' + origin: "util.range", + context: "when building a range of numbers", }; try @@ -1240,9 +1213,10 @@

Source: util/Util.js

switch (args.length) { case 0: - throw 'at least one argument is required'; + throw "at least one argument is required"; // 1 arg: start = 0, stop = arg, step = 1 + case 1: start = 0; stop = args[0]; @@ -1250,6 +1224,7 @@

Source: util/Util.js

break; // 2 args: start = arg1, stop = arg2 + case 2: start = args[0]; stop = args[1]; @@ -1257,6 +1232,7 @@

Source: util/Util.js

break; // 3 args: + case 3: start = args[0]; stop = args[1]; @@ -1264,17 +1240,20 @@

Source: util/Util.js

break; default: - throw 'range requires at least one and at most 3 arguments' + throw "range requires at least one and at most 3 arguments"; } - if (!Number.isInteger(start)) { - throw 'start should be an integer'; + if (!Number.isInteger(start)) + { + throw "start should be an integer"; } - if (!Number.isInteger(stop)) { - throw 'stop 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 (!Number.isInteger(step)) + { + throw "step should be an integer"; } // if start >= stop, the range is empty: @@ -1292,14 +1271,13 @@

Source: util/Util.js

} catch (error) { - throw {...response, error}; + throw { ...response, error }; } } - /** * Create a boolean function that compares an input element to the given value. - * + * * @name module:util._match * @function * @private @@ -1309,16 +1287,16 @@

Source: util/Util.js

function _match(value) { const response = { - origin: 'util._match', - context: 'when creating a function that compares an input element to the given value' + origin: "util._match", + context: "when creating a function that compares an input element to the given value", }; try { // function: - if (typeof value === 'function') + if (typeof value === "function") { - throw 'the value cannot be a function'; + throw "the value cannot be a function"; } // NaN: @@ -1334,19 +1312,19 @@

Source: util/Util.js

} // object: we compare using JSON.stringify - if (typeof value === 'object') + if (typeof value === "object") { const jsonValue = JSON.stringify(value); - if (typeof jsonValue === 'undefined') + if (typeof jsonValue === "undefined") { - throw 'value could not be converted to a JSON string'; + throw "value could not be converted to a JSON string"; } return (element) => { const jsonElement = JSON.stringify(element); return (jsonElement === jsonValue); - } + }; } // everything else: @@ -1354,16 +1332,15 @@

Source: util/Util.js

} catch (error) { - throw {...response, error}; - } - } - + throw { ...response, error }; + } +} - /** +/** * Count the number of elements in the input array that match the given value. - * + * * <p> Note: count is able to handle NaN, null, as well as any value convertible to a JSON string.</p> - * + * * @name module:util.count * @function * @public @@ -1371,44 +1348,43 @@

Source: util/Util.js

* @param {Number|string|object|null} value the matching value * @returns the number of matching elements */ - export function count(input, value) - { +export function count(input, value) +{ const response = { - origin: 'util.count', - context: 'when counting how many elements in the input array match the given value' + 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'; + throw "the input argument should be an array"; } const match = _match(value); let nbMatches = 0; - input.forEach(element => + input.forEach((element) => + { + if (match(element)) { - if (match(element)) - { - ++ nbMatches; - } - }); + ++nbMatches; + } + }); return nbMatches; } catch (error) { - throw {...response, error}; + throw { ...response, error }; } - } - +} - /** +/** * Get the index in the input array of the first element that matches the given value. - * + * * <p> Note: index is able to handle NaN, null, as well as any value convertible to a JSON string.</p> - * + * * @name module:util.index * @function * @public @@ -1417,18 +1393,18 @@

Source: util/Util.js

* @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) - { +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' + 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'; + throw "the input argument should be an array"; } const match = _match(value); @@ -1436,18 +1412,16 @@

Source: util/Util.js

if (index === -1) { - throw 'no element in the input array matches the value'; + throw "no element in the input array matches the value"; } return index; - } catch (error) { - throw {...response, error}; + throw { ...response, error }; } - } - +} /** * Return the file extension corresponding to an audio mime type. @@ -1462,28 +1436,88 @@

Source: util/Util.js

*/ export function extensionFromMimeType(mimeType) { - if (typeof mimeType !== 'string') + if (typeof mimeType !== "string") + { + return ".dat"; + } + + if (mimeType.indexOf("audio/webm") === 0) { - return '.dat'; + return ".webm"; } - if (mimeType.indexOf('audio/webm') === 0) + if (mimeType.indexOf("audio/ogg") === 0) { - return '.webm'; + return ".ogg"; } - if (mimeType.indexOf('audio/ogg') === 0) + if (mimeType.indexOf("audio/wav") === 0) { - return '.ogg'; + return ".wav"; } - if (mimeType.indexOf('audio/wav') === 0) + if (mimeType.indexOf("video/webm") === 0) { - return '.wav'; + return ".webm"; } return '.dat'; } + +/** + * Get an estimate of the download speed, by repeatedly downloading an image file from a distant + * server. + * + * @name module:util.getDownloadSpeed + * @function + * @public + * @param {PsychoJS} psychoJS the instance of PsychoJS + * @param {number} [nbDownloads = 1] the number of image downloads over which to average + * the download speed + * @return {number} the download speed, in megabits per second + */ +export async function getDownloadSpeed(psychoJS, nbDownloads = 1) +{ + // url of the image to download and size of the image in bits: + // TODO use a variety of files, with different sizes + const imageUrl = "https://upload.wikimedia.org/wikipedia/commons/a/a6/Brandenburger_Tor_abends.jpg"; + const imageSize_b = 2707459 * 8; + + return new Promise( (resolve, reject) => + { + let downloadTimeAccumulator = 0; + let downloadCounter = 0; + + const download = new Image(); + download.onload = () => + { + const toc = performance.now(); + downloadTimeAccumulator += (toc-tic); + ++ downloadCounter; + + if (downloadCounter === nbDownloads) + { + const speed_bps = (imageSize_b * nbDownloads) / (downloadTimeAccumulator / 1000); + resolve(speed_bps / 1024 / 1024); + } + else + { + tic = performance.now(); + download.src = `${imageUrl}?salt=${tic}`; + } + } + + download.onerror = (event) => + { + const errorMsg = `unable to estimate the download speed: ${JSON.stringify(event)}`; + psychoJS.logger.error(errorMsg); + reject(errorMsg); + } + + let tic = performance.now(); + download.src = `${imageUrl}?salt=${tic}`; + }); +} @@ -1494,13 +1528,13 @@

Source: util/Util.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_ButtonStim.js.html b/docs/visual_ButtonStim.js.html index 76fa7cff..b53e81a0 100644 --- a/docs/visual_ButtonStim.js.html +++ b/docs/visual_ButtonStim.js.html @@ -35,10 +35,8 @@

Source: visual/ButtonStim.js

* @license Distributed under the terms of the MIT License */ - -import {TextBox} from './TextBox.js'; -import {Mouse} from '../core/Mouse.js'; - +import { Mouse } from "../core/Mouse.js"; +import { TextBox } from "./TextBox.js"; /** * <p>ButtonStim visual stimulus.</p> @@ -67,28 +65,76 @@

Source: visual/ButtonStim.js

*/ export class ButtonStim extends TextBox { - constructor({win, name, text, font, pos, size, padding, anchor = 'center', units, color, fillColor = 'darkgrey', borderColor, borderWidth = 0, opacity, letterHeight, bold = true, italic, autoDraw, autoLog} = {}) + constructor( + { + win, + name, + text, + font, + pos, + size, + padding, + anchor = "center", + units, + color, + fillColor = "darkgrey", + borderColor, + borderWidth = 0, + opacity, + letterHeight, + bold = true, + italic, + autoDraw, + autoLog, + } = {}, + ) { - super({win, name, text, font, pos, size, padding, anchor, units, color, fillColor, borderColor, borderWidth, opacity, letterHeight, bold, italic, alignment: 'center', autoDraw, autoLog}); - - this.psychoJS.logger.debug('create a new Button with name: ', name); - - this.listener = new Mouse({name, win, autoLog}); + super({ + win, + name, + text, + font, + pos, + size, + padding, + anchor, + units, + color, + fillColor, + borderColor, + borderWidth, + opacity, + letterHeight, + bold, + italic, + alignment: "center", + autoDraw, + autoLog, + }); + + this.psychoJS.logger.debug("create a new Button with name: ", name); + + this.listener = new Mouse({ name, win, autoLog }); this._addAttribute( - 'wasClicked', - false + "wasClicked", + false, ); // Arrays to store times of clicks on and off this._addAttribute( - 'timesOn', - [] + "timesOn", + [], ); this._addAttribute( - 'timesOff', - [] + "timesOff", + [], + ); + + this._addAttribute( + "numClicks", + 0, ); if (this._autoLog) @@ -97,8 +143,6 @@

Source: visual/ButtonStim.js

} } - - /** * How many times has this button been clicked on? * @@ -110,8 +154,6 @@

Source: visual/ButtonStim.js

return this.timesOn.length; } - - /** * Is this button currently being clicked on? * @@ -122,7 +164,6 @@

Source: visual/ButtonStim.js

{ return this.listener.isPressedIn(this, [1, 0, 0]); } - } @@ -134,13 +175,13 @@

Source: visual/ButtonStim.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_Camera.js.html b/docs/visual_Camera.js.html new file mode 100644 index 00000000..ef470b14 --- /dev/null +++ b/docs/visual_Camera.js.html @@ -0,0 +1,657 @@ + + + + + JSDoc: Source: visual/Camera.js + + + + + + + + + + +
+ +

Source: visual/Camera.js

+ + + + + + +
+
+
/**
+ * Manager handling the recording of video signal.
+ *
+ * @author Alain Pitiot
+ * @version 2021.2.0
+ * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @license Distributed under the terms of the MIT License
+ */
+
+import {Clock} from "../util/Clock.js";
+import {PsychObject} from "../util/PsychObject.js";
+import {PsychoJS} from "../core/PsychoJS.js";
+import * as util from "../util/Util.js";
+import {ExperimentHandler} from "../data/ExperimentHandler.js";
+// import {VideoClip} from "./VideoClip";
+
+
+/**
+ * <p>This manager handles the recording of video signal.</p>
+ *
+ * @name module:visual.Camera
+ * @class
+ * @param {Object} options
+ * @param {module:core.Window} options.win - the associated Window
+ * @param {string} [options.format='video/webm;codecs=vp9'] the video format
+ * @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the
+ * 	participant to wait for the camera to be initialised
+ * @param {string} [options.dialogMsg="Please wait a few moments while the camera initialises"] -
+ * 	default message informing the participant to wait for the camera to initialise
+ * @param {Clock} [options.clock= undefined] - an optional clock
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ *
+ * @todo add video constraints as parameter
+ */
+export class Camera extends PsychObject
+{
+	constructor({win, name, format, showDialog, dialogMsg = "Please wait a few moments while the camera initialises", clock, autoLog} = {})
+	{
+		super(win._psychoJS);
+
+		this._addAttribute("win", win, undefined);
+		this._addAttribute("name", name, "camera");
+		this._addAttribute("format", format, "video/webm;codecs=vp9", this._onChange);
+		this._addAttribute("clock", clock, new Clock());
+		this._addAttribute("autoLog", autoLog, false);
+		this._addAttribute("status", PsychoJS.Status.NOT_STARTED);
+
+		// open pop-up dialog:
+		if (showDialog)
+		{
+			this.psychoJS.gui.dialog({
+				warning: dialogMsg,
+				showOK: false,
+			});
+		}
+
+		// prepare the recording:
+		this._prepareRecording().then( () =>
+		{
+			if (showDialog)
+			{
+				this.psychoJS.gui.closeDialog();
+			}
+		})
+
+		if (this._autoLog)
+		{
+			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
+		}
+	}
+
+	/**
+	 * Query whether or not the camera is ready to record.
+	 *
+	 * @name module:visual.Camera#isReady
+	 * @function
+	 * @public
+	 * @returns {boolean} whether or not the camera is ready to record
+	 */
+	isReady()
+	{
+		return (this._recorder !== null);
+	}
+
+	/**
+	 * Get the underlying video stream.
+	 *
+	 * @name module:visual.Camera#getStream
+	 * @function
+	 * @public
+	 * @returns {MediaStream} the video stream
+	 */
+	getStream()
+	{
+		return this._stream;
+	}
+
+	/**
+	 * Get a video element pointing to the Camera stream.
+	 *
+	 * @name module:visual.Camera#getVideo
+	 * @function
+	 * @public
+	 * @returns {HTMLVideoElement} a video element
+	 */
+	getVideo()
+	{
+		// note: we need to return a new video each time, since the camera feed can be used by
+		// several stimuli and one of them might pause the feed
+
+		// create a video with the appropriate size:
+		const video = document.createElement("video");
+		this._videos.push(video);
+
+		video.width = this._streamSettings.width;
+		video.height = this._streamSettings.height;
+		video.autoplay = true;
+
+		// prevent clicking:
+		video.onclick = (mouseEvent) =>
+		{
+			mouseEvent.preventDefault();
+			return false;
+		};
+
+		// use the camera stream as source for the video:
+		video.srcObject = this._stream;
+
+		return video;
+	}
+
+	/**
+	 * Submit a request to start the recording.
+	 *
+	 * @name module:visual.Camera#start
+	 * @function
+	 * @public
+	 * @return {Promise} promise fulfilled when the recording actually started
+	 */
+	start()
+	{
+		// if the camera 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 video recording");
+
+			try
+			{
+				if (!this._recorder)
+				{
+					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
+				}
+
+				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 video recording: " + JSON.stringify(error));
+				this._status = PsychoJS.Status.ERROR;
+
+				throw {
+					origin: "Camera.start",
+					context: "when starting the video recording for camera: " + this._name,
+					error
+				};
+			}
+
+		}
+
+	}
+
+	/**
+	 * Submit a request to stop the recording.
+	 *
+	 * @name module:visual.Camera#stop
+	 * @function
+	 * @public
+	 * @param {Object} options
+	 * @param {string} [options.filename] the name of the file to which the video 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 video recording");
+
+			// stop the videos:
+			for (const video of this._videos)
+			{
+				video.pause();
+			}
+
+			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:visual.Camera#pause
+	 * @function
+	 * @public
+	 * @return {Promise} promise fulfilled when the recording actually paused
+	 */
+	pause()
+	{
+		if (this._status === PsychoJS.Status.STARTED)
+		{
+			this._psychoJS.logger.debug("request to pause video recording");
+
+			try
+			{
+				if (!this._recorder)
+				{
+					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
+				}
+
+				// 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 video recording: " + JSON.stringify(error));
+				this._status = PsychoJS.Status.ERROR;
+
+				throw {
+					origin: "Camera.pause",
+					context: "when pausing the video recording for camera: " + this._name,
+					error
+				};
+			}
+
+		}
+	}
+
+	/**
+	 * Submit a request to resume the recording.
+	 *
+	 * <p>resume has no effect if the recording was not previously paused.</p>
+	 *
+	 * @name module:visual.Camera#resume
+	 * @function
+	 * @param {Object} options
+	 * @param {boolean} [options.clear= false] whether or not to empty the video 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 video recording");
+
+			try
+			{
+				if (!this._recorder)
+				{
+					throw "the recorder has not been created yet, possibly because the participant has not given the authorisation to record video";
+				}
+
+				// empty the audio buffer is needed:
+				if (clear)
+				{
+					this._audioBuffer = [];
+					this._videoBuffer.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 video recording: " + JSON.stringify(error));
+				this._status = PsychoJS.Status.ERROR;
+
+				throw {
+					origin: "Camera.resume",
+					context: "when resuming the video recording for camera: " + this._name,
+					error
+				};
+			}
+
+		}
+	}
+
+	/**
+	 * Submit a request to flush the recording.
+	 *
+	 * @name module:visual.Camera#flush
+	 * @function
+	 * @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 video 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 video file to download.
+	 *
+	 * @name module:visual.Camera#download
+	 * @function
+	 * @public
+	 * @param {string} filename - the filename of the video file
+	 */
+	download(filename = "video.webm")
+	{
+		const videoBlob = new Blob(this._videoBuffer);
+
+		const anchor = document.createElement("a");
+		anchor.href = window.URL.createObjectURL(videoBlob);
+		anchor.download = filename;
+		document.body.appendChild(anchor);
+		anchor.click();
+		document.body.removeChild(anchor);
+	}
+
+	/**
+	 * Upload the video recording to the pavlovia server.
+	 *
+	 * @name module:visual.Camera#upload
+	 * @function
+	 * @public
+	 * @param @param {Object} options
+	 * @param {string} options.tag an optional tag for the video file
+	 * @param {boolean} [options.waitForCompletion= false] whether or not to wait for completion
+	 * 	before returning
+	 * @param {boolean} [options.showDialog=false] - whether or not to open a dialog box to inform the participant to wait for the data to be uploaded to the server
+	 * @param {string} [options.dialogMsg=""] - default message informing the participant to wait for the data to be uploaded to the server
+	 */
+	async upload({tag, waitForCompletion = false, showDialog = false, dialogMsg = ""} = {})
+	{
+		// default tag: the name of this Camera object
+		if (typeof tag === "undefined")
+		{
+			tag = this._name;
+		}
+
+		// add a format-dependent video extension to the tag:
+		tag += util.extensionFromMimeType(this._format);
+
+
+		// if the video recording cannot be uploaded, e.g. the experiment is running locally, or
+		// if it is piloting mode, then we offer the video 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 videoBlob = new Blob(this._videoBuffer);
+		return this._psychoJS.serverManager.uploadAudioVideo({
+			mediaBlob: videoBlob,
+			tag,
+			waitForCompletion,
+			showDialog,
+			dialogMsg});
+	}
+
+	/**
+	 * Get the current video recording as a VideoClip in the given format.
+	 *
+	 * @name module:visual.Camera#getRecording
+	 * @function
+	 * @public
+	 * @param {string} tag an optional tag for the video 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;
+		}
+
+		// TODO
+	}
+
+	/**
+	 * Callback for changes to the recording settings.
+	 *
+	 * <p>Changes to the settings require the recording to stop and be re-started.</p>
+	 *
+	 * @name module:visual.Camera#_onChange
+	 * @function
+	 * @protected
+	 */
+	_onChange()
+	{
+		if (this._status === PsychoJS.Status.STARTED)
+		{
+			this.stop();
+		}
+
+		this._prepareRecording();
+
+		this.start();
+	}
+
+	/**
+	 * Prepare the recording.
+	 *
+	 * @name module:visual.Camera#_prepareRecording
+	 * @function
+	 * @protected
+	 */
+	async _prepareRecording()
+	{
+		// empty the video buffer:
+		this._videoBuffer = [];
+		this._recorder = null;
+		this._videos = [];
+
+		// create a new stream with ideal dimensions:
+		// TODO use size constraints
+		this._stream = await navigator.mediaDevices.getUserMedia({
+			video: true
+		});
+
+		// check the actual width and height:
+		this._streamSettings = this._stream.getVideoTracks()[0].getSettings();
+		this._psychoJS.logger.debug(`camera stream settings: ${JSON.stringify(this._streamSettings)}`);
+
+
+		// 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 video format, ${this._format}, is not supported by this browser, using the default format instead`);
+		}
+
+
+		// create a video recorder:
+		this._recorder = new MediaRecorder(this._stream, options);
+
+
+		// setup the callbacks:
+		const self = this;
+
+		// called upon Camera.start(), at which point the audio data starts being gathered
+		// into a blob:
+		this._recorder.onstart = () =>
+		{
+			self._videoBuffer = [];
+			self._videoBuffer.length = 0;
+			self._clock.reset();
+			self._status = PsychoJS.Status.STARTED;
+			self._psychoJS.logger.debug("video recording started");
+
+			// resolve the Microphone.start promise:
+			if (self._startCallback)
+			{
+				self._startCallback(self._psychoJS.monotonicClock.getTime());
+			}
+		};
+
+		// called upon Camera.pause():
+		this._recorder.onpause = () =>
+		{
+			self._status = PsychoJS.Status.PAUSED;
+			self._psychoJS.logger.debug("video recording paused");
+
+			// resolve the Microphone.pause promise:
+			if (self._pauseCallback)
+			{
+				self._pauseCallback(self._psychoJS.monotonicClock.getTime());
+			}
+		};
+
+		// called upon Camera.resume():
+		this._recorder.onresume = () =>
+		{
+			self._status = PsychoJS.Status.STARTED;
+			self._psychoJS.logger.debug("video recording resumed");
+
+			// resolve the Microphone.resume promise:
+			if (self._resumeCallback)
+			{
+				self._resumeCallback(self._psychoJS.monotonicClock.getTime());
+			}
+		};
+
+		// called when video data is available, typically upon Camera.stop() or Camera.flush():
+		this._recorder.ondataavailable = (event) =>
+		{
+			const data = event.data;
+
+			// add data to the buffer:
+			self._videoBuffer.push(data);
+			self._psychoJS.logger.debug("video data added to the buffer");
+
+			// resolve the data available promise, if needed:
+			if (self._dataAvailableCallback)
+			{
+				self._dataAvailableCallback(self._psychoJS.monotonicClock.getTime());
+			}
+		};
+
+		// called upon Camera.stop(), after data has been made available:
+		this._recorder.onstop = () =>
+		{
+			self._psychoJS.logger.debug("video 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("video recording error: " + JSON.stringify(event));
+			self._status = PsychoJS.Status.ERROR;
+		};
+
+	}
+
+}
+
+
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + diff --git a/docs/visual_FaceDetector.js.html b/docs/visual_FaceDetector.js.html new file mode 100644 index 00000000..faf1b22e --- /dev/null +++ b/docs/visual_FaceDetector.js.html @@ -0,0 +1,375 @@ + + + + + JSDoc: Source: visual/FaceDetector.js + + + + + + + + + + +
+ +

Source: visual/FaceDetector.js

+ + + + + + +
+
+
/**
+ * Manager handling the detecting of faces in video streams.
+ *
+ * @author Alain Pitiot
+ * @version 2021.2.0
+ * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @license Distributed under the terms of the MIT License
+ */
+
+import {PsychoJS} from "../core/PsychoJS.js";
+import * as util from "../util/Util.js";
+import { to_pixiPoint } from "../util/Pixi.js";
+import {Color} from "../util/Color.js";
+import {Camera} from "./Camera.js";
+import {VisualStim} from "./VisualStim.js";
+import * as PIXI from "pixi.js-legacy";
+
+
+/**
+ * <p>This manager handles the detecting of faces in video streams. FaceDetector relies on the
+ * [Face-API library]{@link https://github.com/justadudewhohacks/face-api.js} developed by
+ * [Vincent Muehler]{@link https://github.com/justadudewhohacks}</p>
+ *
+ * @name module:visual.FaceDetector
+ * @class
+ * @param {Object} options
+ * @param {String} options.name - the name used when logging messages from the detector
+ * @param @param {module:core.Window} options.win - the associated Window
+ * @param @param {string | HTMLVideoElement | module:visual.Camera} input - the name of a
+ * movie resource or of a HTMLVideoElement or of a Camera component
+ * @param {string} [options.faceApiUrl= 'face-api.js'] - the Url of the face-api library
+ * @param {string} [options.modelDir= 'models'] - the directory where to find the face detection models
+ * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices)
+ * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus
+ * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position
+ * @param {number} [options.ori= 0.0] - the orientation (in degrees)
+ * @param {number} [options.size] - the size of the rendered image (the size of the image will be used if size is not specified)
+ * @param {number} [options.opacity= 1.0] - the opacity
+ * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip
+ * @param {boolean} [options.autoLog= false] - whether or not to log
+ */
+export class FaceDetector extends VisualStim
+{
+	/**
+	 * @constructor
+	 * @public
+	 */
+	constructor({name, win, input, modelDir, faceApiUrl, units, ori, opacity, pos, size, autoDraw, autoLog} = {})
+	{
+		super({name, win, units, ori, opacity, pos, size, autoDraw, autoLog});
+
+		// TODO deal with onChange (see MovieStim and Camera)
+		this._addAttribute("input", input, undefined);
+		this._addAttribute("faceApiUrl", faceApiUrl, "face-api.js");
+		this._addAttribute("modelDir", modelDir, "models");
+		this._addAttribute("autoLog", autoLog, false);
+		this._addAttribute("status", PsychoJS.Status.NOT_STARTED);
+
+		// init face-api:
+		this._initFaceApi();
+
+		if (this._autoLog)
+		{
+			this._psychoJS.experimentLogger.exp(`Created ${this.name} = ${this.toString()}`);
+		}
+	}
+
+	/**
+	 * Query whether or not the face detector is ready to detect.
+	 *
+	 * @name module:visual.FaceDetector#isReady
+	 * @function
+	 * @public
+	 * @returns {boolean} whether or not the face detector is ready to detect
+	 */
+	isReady()
+	{
+		return this._modelsLoaded;
+	}
+
+
+	/**
+	 * Setter for the video attribute.
+	 *
+	 * @name module:visual.FaceDetector#setCamera
+	 * @function
+	 * @public
+	 * @param {string | HTMLVideoElement | module:visual.Camera} input - the name of a
+	 * movie resource or a HTMLVideoElement or a Camera component
+	 * @param {boolean} [log= false] - whether of not to log
+	 */
+	setInput(input, log = false)
+	{
+		const response = {
+			origin: "FaceDetector.setInput",
+			context: "when setting the video of FaceDetector: " + this._name
+		};
+
+		try
+		{
+			// movie is undefined: that's fine but we raise a warning in case this is
+			// a symptom of an actual problem
+			if (typeof input === "undefined")
+			{
+				this.psychoJS.logger.warn("setting the movie of MovieStim: " + this._name + " with argument: undefined.");
+				this.psychoJS.logger.debug("set the movie of MovieStim: " + this._name + " as: undefined");
+			}
+			else
+			{
+				// if movie is a string, then it should be the name of a resource, which we get:
+				if (typeof input === "string")
+				{
+					// TODO create a movie with that resource, and use the movie as input
+				}
+
+				// if movie is an instance of camera, get a video element from it:
+				else if (input instanceof Camera)
+				{
+					const video = input.getVideo();
+					// TODO remove previous one if there is one
+					// document.body.appendChild(video);
+					input = video;
+				}
+
+				// check that video is now an HTMLVideoElement
+				if (!(input instanceof HTMLVideoElement))
+				{
+					throw input.toString() + " is not a video";
+				}
+
+				this.psychoJS.logger.debug(`set the video of FaceDetector: ${this._name} as: src= ${input.src}, size= ${input.videoWidth}x${input.videoHeight}, duration= ${input.duration}s`);
+
+				// ensure we have only one onended listener per HTMLVideoElement, since we can have several
+				// MovieStim with the same underlying HTMLVideoElement
+				// https://stackoverflow.com/questions/11455515
+				if (!input.onended)
+				{
+					input.onended = () =>
+					{
+						this.status = PsychoJS.Status.FINISHED;
+					};
+				}
+			}
+
+			this._setAttribute("input", input, log);
+			this._needUpdate = true;
+			this._needPixiUpdate = true;
+		}
+		catch (error)
+		{
+			throw Object.assign(response, {error});
+		}
+	}
+
+
+	/**
+	 * Start detecting faces.
+	 *
+	 * @name module:visual.FaceDetector#start
+	 * @function
+	 * @public
+	 * @param {number} period - the detection period, in ms (e.g. 100 ms for 10Hz)
+	 * @param detectionCallback - the callback triggered when detection results are available
+	 * @param {boolean} [log= false] - whether of not to log
+	 */
+	start(period, detectionCallback, log = false)
+	{
+		this.status = PsychoJS.Status.STARTED;
+
+		if (typeof this._detectionId !== "undefined")
+		{
+			clearInterval(this._detectionId);
+			this._detectionId = undefined;
+		}
+
+		this._detectionId = setInterval(
+			async () =>
+			{
+				this._detections = await faceapi.detectAllFaces(
+					this._input,
+					new faceapi.TinyFaceDetectorOptions()
+				)
+				.withFaceLandmarks()
+				.withFaceExpressions();
+
+				this._needUpdate = true;
+				this._needPixiUpdate = true;
+
+				detectionCallback(this._detections);
+			},
+			period);
+	}
+
+
+	/**
+	 * Stop detecting faces.
+	 *
+	 * @name module:visual.FaceDetector#stop
+	 * @function
+	 * @public
+	 * @param {boolean} [log= false] - whether of not to log
+	 */
+	stop(log = false)
+	{
+		this.status = PsychoJS.Status.NOT_STARTED;
+
+		if (typeof this._detectionId !== "undefined")
+		{
+			clearInterval(this._detectionId);
+			this._detectionId = undefined;
+		}
+	}
+
+
+	/**
+	 * Init the Face-API library.
+	 *
+	 * @name module:visual.FaceDetector#_initFaceApi
+	 * @function
+	 * @protected
+	 */
+	async _initFaceApi()
+	{
+/*
+		// load the library:
+		await this._psychoJS.serverManager.prepareResources([
+			{
+				"name": "face-api.js",
+				"path": this.faceApiUrl,
+				"download": true
+			}
+		]);
+*/
+
+		// load the models:
+		this._modelsLoaded = false;
+		await faceapi.nets.tinyFaceDetector.loadFromUri(this._modelDir);
+		await faceapi.nets.faceLandmark68Net.loadFromUri(this._modelDir);
+		await faceapi.nets.faceRecognitionNet.loadFromUri(this._modelDir);
+		await faceapi.nets.faceExpressionNet.loadFromUri(this._modelDir);
+		this._modelsLoaded = true;
+	}
+
+
+	/**
+	 * Update the visual representation of the detected faces, if necessary.
+	 *
+	 * @name module:visual.FaceDetector#_updateIfNeeded
+	 * @function
+	 * @protected
+	 */
+	_updateIfNeeded()
+	{
+		if (!this._needUpdate)
+		{
+			return;
+		}
+		this._needUpdate = false;
+
+		if (this._needPixiUpdate)
+		{
+			this._needPixiUpdate = false;
+
+			if (typeof this._pixi !== "undefined")
+			{
+				this._pixi.destroy(true);
+			}
+			this._pixi = new PIXI.Container();
+			this._pixi.interactive = true;
+
+			this._body = new PIXI.Graphics();
+			this._body.interactive = true;
+			this._pixi.addChild(this._body);
+
+			const size_px = util.to_px(this.size, this.units, this.win);
+			if (typeof this._detections !== "undefined")
+			{
+				for (const detection of this._detections)
+				{
+					const landmarks = detection.landmarks;
+					const imageWidth = detection.alignedRect.imageWidth;
+					const imageHeight = detection.alignedRect.imageHeight;
+
+					for (const position of landmarks.positions)
+					{
+						this._body.beginFill(new Color("red").int, this._opacity);
+						this._body.drawCircle(
+							position._x / imageWidth * size_px[0] - size_px[0] / 2,
+							position._y / imageHeight * size_px[1] - size_px[1] / 2,
+							2);
+						this._body.endFill();
+					}
+				}
+			}
+
+		}
+
+
+		this._pixi.scale.x = 1;
+		this._pixi.scale.y = -1;
+
+		this._pixi.rotation = -this.ori * Math.PI / 180;
+		this._pixi.position = to_pixiPoint(this.pos, this.units, this.win);
+
+		this._pixi.alpha = this._opacity;
+	}
+
+
+	/**
+	 * Estimate the bounding box.
+	 *
+	 * @name module:visual.FaceDetector#_estimateBoundingBox
+	 * @function
+	 * @override
+	 * @protected
+	 */
+	_estimateBoundingBox()
+	{
+		// TODO
+	}
+
+}
+
+
+
+
+
+ + + + +
+ + + +
+ +
+ Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time) +
+ + + + + diff --git a/docs/visual_Form.js.html b/docs/visual_Form.js.html index 5a007b66..5013c00c 100644 --- a/docs/visual_Form.js.html +++ b/docs/visual_Form.js.html @@ -35,18 +35,16 @@

Source: visual/Form.js

* @license Distributed under the terms of the MIT License */ - -import * as PIXI from 'pixi.js-legacy'; -import {Color} from '../util/Color'; -import {ColorMixin} from '../util/ColorMixin'; -import * as util from '../util/Util'; -import {TrialHandler} from '../data/TrialHandler'; -import {TextStim} from './TextStim'; -import {TextBox} from './TextBox'; -import {VisualStim} from './VisualStim'; -import {Slider} from './Slider'; - - +import * as PIXI from "pixi.js-legacy"; +import { TrialHandler } from "../data/TrialHandler.js"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { Slider } from "./Slider.js"; +import { TextBox } from "./TextBox.js"; +import { TextStim } from "./TextStim.js"; +import { VisualStim } from "./VisualStim.js"; /** * Form stimulus. @@ -85,99 +83,127 @@

Source: visual/Form.js

*/ export class Form extends util.mix(VisualStim).with(ColorMixin) { - constructor({name, win, pos, size, units, borderColor, fillColor, itemColor, markerColor, responseColor, color, contrast, opacity, depth, items, randomize, itemPadding, font, fontFamily, bold, italic, fontSize, clipMask, autoDraw, autoLog} = {}) + constructor( + { + name, + win, + pos, + size, + units, + borderColor, + fillColor, + itemColor, + markerColor, + responseColor, + color, + contrast, + opacity, + depth, + items, + randomize, + itemPadding, + font, + fontFamily, + bold, + italic, + fontSize, + clipMask, + autoDraw, + autoLog, + } = {}, + ) { - super({name, win, units, opacity, depth, pos, size, clipMask, autoDraw, autoLog}); + super({ name, win, units, opacity, depth, pos, size, clipMask, autoDraw, autoLog }); this._addAttribute( - 'itemPadding', + "itemPadding", itemPadding, - util.to_unit([20, 0], 'pix', win, this._units)[0], - this._onChange(true, false) + util.to_unit([20, 0], "pix", win, this._units)[0], + this._onChange(true, false), ); // colors: this._addAttribute( - 'color', + "color", // Same as itemColor color, undefined, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'borderColor', + "borderColor", borderColor, fillColor, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'fillColor', + "fillColor", fillColor, undefined, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'itemColor', + "itemColor", itemColor, undefined, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'markerColor', + "markerColor", markerColor, undefined, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'responseColor', + "responseColor", responseColor, undefined, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'contrast', + "contrast", contrast, 1.0, - this._onChange(true, false) + this._onChange(true, false), ); // fonts: this._addAttribute( - 'font', + "font", font, - 'Arial', - this._onChange(true, true) + "Arial", + this._onChange(true, true), ); // Not in use at present this._addAttribute( - 'fontFamily', + "fontFamily", fontFamily, - 'Helvetica', - this._onChange(true, true) + "Helvetica", + this._onChange(true, true), ); this._addAttribute( - 'fontSize', + "fontSize", fontSize, - (this._units === 'pix') ? 14 : 0.03, - this._onChange(true, true) + (this._units === "pix") ? 14 : 0.03, + this._onChange(true, true), ); this._addAttribute( - 'bold', + "bold", bold, false, - this._onChange(true, true) + this._onChange(true, true), ); this._addAttribute( - 'italic', + "italic", italic, false, - this._onChange(true, true) + this._onChange(true, true), ); // callback to deal with changes to items: @@ -193,16 +219,17 @@

Source: visual/Form.js

}; this._addAttribute( - 'items', + "items", items, [], - onItemChange); + onItemChange, + ); this._addAttribute( - 'randomize', + "randomize", randomize, false, - onItemChange); - + onItemChange, + ); this._scrollbarWidth = 0.02; this._responseTextHeightRatio = 0.8; @@ -219,8 +246,6 @@

Source: visual/Form.js

} } - - /** * Force a refresh of the stimulus. * @@ -245,8 +270,6 @@

Source: visual/Form.js

} } - - /** * Overridden draw that also calls the draw method of all form elements. * @@ -287,8 +310,6 @@

Source: visual/Form.js

this._scrollbar.draw(); } - - /** * Overridden hide that also calls the hide method of all form elements. * @@ -303,7 +324,7 @@

Source: visual/Form.js

super.hide(); // hide the stimuli: - if (typeof this._items !== 'undefined') + if (typeof this._items !== "undefined") { for (let i = 0; i < this._items.length; ++i) { @@ -325,8 +346,6 @@

Source: visual/Form.js

} } - - /** * Reset the form. * @@ -336,7 +355,7 @@

Source: visual/Form.js

*/ reset() { - this.psychoJS.logger.debug('reset Form: ', this._name); + this.psychoJS.logger.debug("reset Form: ", this._name); // reset the stimuli: for (let i = 0; i < this._items.length; ++i) @@ -354,8 +373,6 @@

Source: visual/Form.js

this._needUpdate = true; } - - /** * Collate the questions and responses into a single dataset. * @@ -379,9 +396,9 @@

Source: visual/Form.js

item.response = responseStim.getRating(); item.rt = responseStim.getRT(); - if (typeof item.response === 'undefined') + if (typeof item.response === "undefined") { - ++ nbIncompleteResponse; + ++nbIncompleteResponse; } } else if (item.type === Form.Types.FREE_TEXT) @@ -391,7 +408,7 @@

Source: visual/Form.js

if (item.response.length === 0) { - ++ nbIncompleteResponse; + ++nbIncompleteResponse; } } } @@ -399,9 +416,8 @@

Source: visual/Form.js

this._items._complete = (nbIncompleteResponse === 0); - // return a copy of this._items: - return this._items.map(item => Object.assign({}, item)); + return this._items.map((item) => Object.assign({}, item)); } /** * Check if the form is complete. @@ -413,7 +429,7 @@

Source: visual/Form.js

*/ formComplete() { - //same as complete but might be used by some experiments before 2020.2 + // same as complete but might be used by some experiments before 2020.2 this.getData(); return this._items._complete; } @@ -426,15 +442,21 @@

Source: visual/Form.js

* @param {module:data.ExperimentHandler} experiment - the experiment into which to insert the form data * @param {string} [format= 'rows'] - whether to insert the data as rows or as columns */ - addDataToExp(experiment, format = 'rows') + addDataToExp(experiment, format = "rows") { - const addAsColumns = ['cols', 'columns'].includes(format.toLowerCase()); + const addAsColumns = ["cols", "columns"].includes(format.toLowerCase()); const data = this.getData(); const _doNotSave = [ - 'itemCtrl', 'responseCtrl', - 'itemColor', 'options', 'ticks', 'tickLabels', - 'responseWidth', 'responseColor', 'layout' + "itemCtrl", + "responseCtrl", + "itemColor", + "options", + "ticks", + "tickLabels", + "responseWidth", + "responseColor", + "layout", ]; for (const item of this.getData()) @@ -447,7 +469,7 @@

Source: visual/Form.js

const columnName = (addAsColumns) ? `${this._name}[${index}]${field}` : `${this._name}${field}`; experiment.addData(columnName, item[field]); } - ++ index; + ++index; } if (!addAsColumns) @@ -462,8 +484,6 @@

Source: visual/Form.js

} } - - /** * Import and process the form items from either a spreadsheet resource files (.csv, .xlsx, etc.) or from an array. * @@ -474,8 +494,8 @@

Source: visual/Form.js

_processItems() { const response = { - origin: 'Form._processItems', - context: 'when processing the form items' + origin: "Form._processItems", + context: "when processing the form items", }; try @@ -483,7 +503,7 @@

Source: visual/Form.js

if (this._autoLog) { // note: we use the same log message as PsychoPy even though we called this method differently - this._psychoJS.experimentLogger.exp('Importing items...'); + this._psychoJS.experimentLogger.exp("Importing items..."); } // import the items: @@ -501,12 +521,10 @@

Source: visual/Form.js

catch (error) { // throw { ...response, error }; - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - - /** * Import the form items from either a spreadsheet resource files (.csv, .xlsx, etc.) or from an array. * @@ -517,8 +535,8 @@

Source: visual/Form.js

_importItems() { const response = { - origin: 'Form._importItems', - context: 'when importing the form items' + origin: "Form._importItems", + context: "when importing the form items", }; try @@ -526,17 +544,15 @@

Source: visual/Form.js

const itemsType = typeof this._items; // we treat undefined items as a list with a single default entry: - if (itemsType === 'undefined') + if (itemsType === "undefined") { this._items = [Form._defaultItems]; } - // if items is a string, we treat it as the name of a resource file and import it: - else if (itemsType === 'string') + else if (itemsType === "string") { this._items = TrialHandler.importConditions(this._psychoJS.serverManager, this._items); } - // unknown items type: else { @@ -548,17 +564,14 @@

Source: visual/Form.js

{ this._items = [Form._defaultItems]; } - } catch (error) { // throw { ...response, error }; - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - - /** * Sanitize the form items: check that the keys are valid, and fill in default values. * @@ -569,8 +582,8 @@

Source: visual/Form.js

_sanitizeItems() { const response = { - origin: 'Form._sanitizeItems', - context: 'when sanitizing the form items' + origin: "Form._sanitizeItems", + context: "when sanitizing the form items", }; try @@ -579,7 +592,7 @@

Source: visual/Form.js

for (const item of this._items) { // old style forms have questionText instead of itemText: - if (typeof item.questionText !== 'undefined') + if (typeof item.questionText !== "undefined") { item.itemText = item.questionText; delete item.questionText; @@ -588,12 +601,11 @@

Source: visual/Form.js

delete item.questionWidth; // for items of type 'rating, the ticks are in 'options' instead of in 'ticks': - if (item.type === 'rating' || item.type === 'slider') + if (item.type === "rating" || item.type === "slider") { item.ticks = item.options; item.options = undefined; } - } } @@ -611,9 +623,8 @@

Source: visual/Form.js

missingKeys.add(key); item[key] = Form._defaultItems[key]; } - // undefined value: - else if (typeof item[key] === 'undefined') + else if (typeof item[key] === "undefined") { // TODO: options = '' for FREE_TEXT item[key] = Form._defaultItems[key]; @@ -623,16 +634,17 @@

Source: visual/Form.js

if (missingKeys.size > 0) { - this._psychoJS.logger.warn(`Missing headers: ${Array.from(missingKeys).join(', ')}\nNote, headers are case sensitive and must match: ${Array.from(defaultKeys).join(', ')}`); + this._psychoJS.logger.warn( + `Missing headers: ${Array.from(missingKeys).join(", ")}\nNote, headers are case sensitive and must match: ${Array.from(defaultKeys).join(", ")}`, + ); } - // check the types and options: const formTypes = Object.getOwnPropertyNames(Form.Types); for (const item of this._items) { // convert type to upper case, replace spaces by underscores - item.type = item.type.toUpperCase().replace(' ', '_'); + item.type = item.type.toUpperCase().replace(" ", "_"); // check that the type is valid: if (!formTypes.includes(item.type)) @@ -641,9 +653,9 @@

Source: visual/Form.js

} // Support the 'radio' type found on older versions of PsychoPy - if (item.type === 'RADIO') + if (item.type === "RADIO") { - item.type = 'CHOICE'; + item.type = "CHOICE"; } // convert item type to symbol: @@ -652,18 +664,17 @@

Source: visual/Form.js

// turn the option into an array and check length, where applicable: if (item.type === Form.Types.CHOICE) { - item.options = item.options.split(','); + item.options = item.options.split(","); if (item.options.length < 2) { throw `at least two choices should be provided for choice item: ${item.itemText}`; } } - // turn the ticks and tickLabels into arrays, where applicable: else if (item.type === Form.Types.RATING || item.type === Form.Types.SLIDER) { - item.ticks = item.ticks.split(',').map( (_,t) => parseInt(t) ); - item.tickLabels = (item.tickLabels.length > 0) ? item.tickLabels.split(',') : []; + item.ticks = item.ticks.split(",").map((_, t) => parseInt(t)); + item.tickLabels = (item.tickLabels.length > 0) ? item.tickLabels.split(",") : []; } // TODO @@ -672,7 +683,7 @@

Source: visual/Form.js

} // check the layout: - const formLayouts = ['HORIZ', 'VERT']; + const formLayouts = ["HORIZ", "VERT"]; for (const item of this._items) { // convert layout to upper case: @@ -685,18 +696,16 @@

Source: visual/Form.js

} // convert item layout to symbol: - item.layout = (item.layout === 'HORIZ') ? Form.Layout.HORIZONTAL : Form.Layout.VERTICAL; + item.layout = (item.layout === "HORIZ") ? Form.Layout.HORIZONTAL : Form.Layout.VERTICAL; } } catch (error) { // throw { ...response, error }; - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - - /** * Estimate the bounding box. * @@ -712,12 +721,10 @@

Source: visual/Form.js

this._pos[0] - this._size[0] / 2.0, this._pos[1] - this._size[1] / 2.0, this._size[0], - this._size[1] + this._size[1], ); } - - /** * Setup the stimuli, and the scrollbar. * @@ -733,7 +740,7 @@

Source: visual/Form.js

} // clean up the previously setup stimuli: - if (typeof this._visual !== 'undefined') + if (typeof this._visual !== "undefined") { for (const textStim of this._visual.textStims) { @@ -751,31 +758,30 @@

Source: visual/Form.js

textStims: [], responseStims: [], visibles: [], - stimuliTotalHeight: 0 + stimuliTotalHeight: 0, }; // instantiate the clip mask that will be used by all stimuli: this._stimuliClipMask = new PIXI.Graphics(); - // default stimulus options: const textStimOption = { win: this._win, - name: 'item text', + name: "item text", font: this.font, units: this._units, - alignHoriz: 'left', - alignVert: 'top', + alignHoriz: "left", + alignVert: "top", height: this._fontSize, color: this.itemColor, ori: 0, opacity: 1, depth: this._depth + 1, - clipMask: this._stimuliClipMask + clipMask: this._stimuliClipMask, }; const sliderOption = { win: this._win, - name: 'choice response', + name: "choice response", units: this._units, flip: false, // Not part of Slider options as things stand @@ -790,13 +796,13 @@

Source: visual/Form.js

opacity: 1, depth: this._depth + 1, clipMask: this._stimuliClipMask, - granularity: 1 + granularity: 1, }; const textBoxOption = { win: this._win, - name: 'free text response', + name: "free text response", units: this._units, - anchor: 'left-top', + anchor: "left-top", flip: false, opacity: 1, depth: this._depth + 1, @@ -804,7 +810,7 @@

Source: visual/Form.js

letterHeight: this._fontSize * this._responseTextHeightRatio, bold: false, italic: false, - alignment: 'left', + alignment: "left", color: this.responseColor, fillColor: this.fillColor, contrast: 1.0, @@ -812,17 +818,16 @@

Source: visual/Form.js

borderWidth: 0.002, padding: 0.01, editable: true, - clipMask: this._stimuliClipMask + clipMask: this._stimuliClipMask, }; // we use for the slider's tick size the height of a word: - const textStim = new TextStim(Object.assign(textStimOption, { text: 'Ag', pos: [0, 0]})); + const textStim = new TextStim(Object.assign(textStimOption, { text: "Ag", pos: [0, 0] })); const textMetrics_px = textStim.getTextMetrics(); const sliderTickSize = this._getLengthUnits(textMetrics_px.height) / 2; textStim.release(false); - - let stimulusOffset = - this._itemPadding; + let stimulusOffset = -this._itemPadding; for (const item of this._items) { // initially, all items are invisible: @@ -833,8 +838,10 @@

Source: visual/Form.js

// - description: <padding> + <item> + <padding> + <scrollbar> = this._size[0] // - choice with vert layout: <padding> + <item> + <padding> + <scrollbar> = this._size[0] let rowWidth; - if (item.type === Form.Types.HEADING || item.type === Form.Types.DESCRIPTION || - (item.type === Form.Types.CHOICE && item.layout === Form.Layout.VERTICAL)) + if ( + item.type === Form.Types.HEADING || item.type === Form.Types.DESCRIPTION + || (item.type === Form.Types.CHOICE && item.layout === Form.Layout.VERTICAL) + ) { rowWidth = (this._size[0] - this._itemPadding * 2 - this._scrollbarWidth); } @@ -845,12 +852,13 @@

Source: visual/Form.js

} // item text - const itemWidth = rowWidth * item.itemWidth; + const itemWidth = rowWidth * item.itemWidth; const textStim = new TextStim( Object.assign(textStimOption, { text: item.itemText, - wrapWidth: itemWidth - })); + wrapWidth: itemWidth, + }), + ); textStim._relativePos = [this._itemPadding, stimulusOffset]; const textHeight = textStim.boundingBox.height; this._visual.textStims.push(textStim); @@ -874,7 +882,7 @@

Source: visual/Form.js

} else { - sliderSize = [sliderTickSize, (sliderTickSize*1.5) * item.options.length]; + sliderSize = [sliderTickSize, (sliderTickSize * 1.5) * item.options.length]; compact = false; flip = true; } @@ -909,23 +917,23 @@

Source: visual/Form.js

labels, ticks, compact, - flip - }) + flip, + }), ); responseHeight = responseStim.boundingBox.height; if (item.layout === Form.Layout.HORIZONTAL) { responseStim._relativePos = [ this._itemPadding * 2 + itemWidth + responseWidth / 2, - stimulusOffset - //- Math.max(0, (textHeight - responseHeight) / 2) // (vertical centering) + stimulusOffset, + // - Math.max(0, (textHeight - responseHeight) / 2) // (vertical centering) ]; } else { responseStim._relativePos = [ - this._itemPadding * 2 + itemWidth, //this._itemPadding + sliderTickSize, - stimulusOffset - responseHeight / 2 - textHeight - this._itemPadding + this._itemPadding * 2 + itemWidth, // this._itemPadding + sliderTickSize, + stimulusOffset - responseHeight / 2 - textHeight - this._itemPadding, ]; // since rowHeight will be the max of itemHeight and responseHeight, we need to alter responseHeight @@ -933,20 +941,19 @@

Source: visual/Form.js

responseHeight += textHeight + this._itemPadding; } } - // FREE TEXT else if (item.type === Form.Types.FREE_TEXT) { responseStim = new TextBox( Object.assign(textBoxOption, { text: item.options, - size: [responseWidth, -1] - }) + size: [responseWidth, -1], + }), ); responseHeight = responseStim.boundingBox.height; responseStim._relativePos = [ this._itemPadding * 2 + itemWidth, - stimulusOffset + stimulusOffset, ]; } @@ -959,13 +966,12 @@

Source: visual/Form.js

} this._visual.stimuliTotalHeight = stimulusOffset; - // scrollbar // note: we add this Form as a dependent stimulus such that the Form is redrawn whenever // the slider is updated this._scrollbar = new Slider({ win: this._win, - name: 'scrollbar', + name: "scrollbar", units: this._units, color: this.itemColor, depth: this._depth + 1, @@ -973,24 +979,20 @@

Source: visual/Form.js

size: [this._scrollbarWidth, this._size[1]], style: [Slider.Style.SLIDER], ticks: [0, -this._visual.stimuliTotalHeight / this._size[1]], - dependentStims: [this] + dependentStims: [this], }); this._prevScrollbarMarkerPos = 0; this._scrollbar.setMarkerPos(this._prevScrollbarMarkerPos); - // estimate the bounding box: this._estimateBoundingBox(); - if (this._autoLog) { this._psychoJS.experimentLogger.exp(`Layout set for: ${this.name}`); } } - - /** * Update the form visual representation, if necessary. * @@ -1018,30 +1020,30 @@

Source: visual/Form.js

[this._leftEdge, this._topEdge], this.units, this.win, - true); + true, + ); [this._rightEdge_px, this._bottomEdge_px] = util.to_px( [this._rightEdge, this._bottomEdge], this.units, this.win, - true); + true, + ); this._itemPadding_px = this._getLengthPix(this._itemPadding); this._scrollbarWidth_px = this._getLengthPix(this._scrollbarWidth, true); this._size_px = util.to_px(this._size, this.units, this.win, true); - // update the stimuli clip mask // note: the clip mask is in screen coordinates this._stimuliClipMask.clear(); this._stimuliClipMask.beginFill(0xFFFFFF); this._stimuliClipMask.drawRect( - this._win._rootContainer.position.x + this._leftEdge_px + 2, - this._win._rootContainer.position.y + this._bottomEdge_px + 2, + this._win._stimsContainer.position.x + this._leftEdge_px + 2, + this._win._stimsContainer.position.y + this._bottomEdge_px + 2, this._size_px[0] - 4, - this._size_px[1] - 6 + this._size_px[1] - 6, ); this._stimuliClipMask.endFill(); - // position the scrollbar and get the scrollbar offset, in form units: this._scrollbar.setPos([this._rightEdge - this._scrollbarWidth / 2, this._pos[1]], false); this._scrollbar.setOpacity(0.5); @@ -1052,8 +1054,6 @@

Source: visual/Form.js

this._updateDecorations(); } - - /** * Update the visible stimuli. * @@ -1069,7 +1069,7 @@

Source: visual/Form.js

const textStim = this._visual.textStims[i]; const textStimPos = [ this._leftEdge + textStim._relativePos[0], - this._topEdge + textStim._relativePos[1] - this._scrollbarOffset + this._topEdge + textStim._relativePos[1] - this._scrollbarOffset, ]; textStim.setPos(textStimPos); @@ -1079,7 +1079,7 @@

Source: visual/Form.js

{ const responseStimPos = [ this._leftEdge + responseStim._relativePos[0], - this._topEdge + responseStim._relativePos[1] - this._scrollbarOffset + this._topEdge + responseStim._relativePos[1] - this._scrollbarOffset, ]; responseStim.setPos(responseStimPos); } @@ -1105,11 +1105,8 @@

Source: visual/Form.js

this._visual.visibles[i] = false; } } - } - - /** * Update the form decorations (bounding box, lines between items, etc.) * @@ -1119,7 +1116,7 @@

Source: visual/Form.js

*/ _updateDecorations() { - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { this._pixi.destroy(true); } @@ -1128,7 +1125,7 @@

Source: visual/Form.js

this._pixi.scale.x = 1; this._pixi.scale.y = 1; this._pixi.rotation = 0; - this._pixi.position = util.to_pixiPoint(this.pos, this.units, this.win); + this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); this._pixi.alpha = this._opacity; this._pixi.zIndex = this._depth; @@ -1136,7 +1133,6 @@

Source: visual/Form.js

// apply the form clip mask (n.b., that is not the stimuli clip mask): this._pixi.mask = this._clipMask; - // form background: this._pixi.lineStyle(1, new Color(this.borderColor).int, this._opacity, 0.5); // this._decorations.beginFill(this._barFillColor.int, this._opacity); @@ -1149,7 +1145,7 @@

Source: visual/Form.js

this._decorations = new PIXI.Graphics(); this._pixi.addChild(this._decorations); this._decorations.mask = this._stimuliClipMask; - this._decorations.lineStyle(1, new Color('gray').int, this._opacity, 0.5); + this._decorations.lineStyle(1, new Color("gray").int, this._opacity, 0.5); this._decorations.alpha = 0.5; for (let i = 0; i < this._items.length; ++i) @@ -1163,27 +1159,23 @@

Source: visual/Form.js

const textStim = this._visual.textStims[i]; const textStimPos = [ this._leftEdge + textStim._relativePos[0], - this._topEdge + textStim._relativePos[1] - this._scrollbarOffset + this._topEdge + textStim._relativePos[1] - this._scrollbarOffset, ]; const textStimPos_px = util.to_px(textStimPos, this._units, this._win); - this._decorations.beginFill(new Color('darkgray').int); + this._decorations.beginFill(new Color("darkgray").int); this._decorations.drawRect( textStimPos_px[0] - this._itemPadding_px / 2, textStimPos_px[1] + this._itemPadding_px / 2, this._size_px[0] - this._itemPadding_px - this._scrollbarWidth_px, - -this._getLengthPix(this._visual.rowHeights[i]) - this._itemPadding_px + -this._getLengthPix(this._visual.rowHeights[i]) - this._itemPadding_px, ); this._decorations.endFill(); } } } - - } } - - /** * Form item types. * @@ -1192,17 +1184,15 @@

Source: visual/Form.js

* @public */ Form.Types = { - HEADING: Symbol.for('HEADING'), - DESCRIPTION: Symbol.for('DESCRIPTION'), - RATING: Symbol.for('RATING'), - SLIDER: Symbol.for('SLIDER'), - FREE_TEXT: Symbol.for('FREE_TEXT'), - CHOICE: Symbol.for('CHOICE'), - RADIO: Symbol.for('RADIO') + HEADING: Symbol.for("HEADING"), + DESCRIPTION: Symbol.for("DESCRIPTION"), + RATING: Symbol.for("RATING"), + SLIDER: Symbol.for("SLIDER"), + FREE_TEXT: Symbol.for("FREE_TEXT"), + CHOICE: Symbol.for("CHOICE"), + RADIO: Symbol.for("RADIO"), }; - - /** * Form item layout. * @@ -1211,12 +1201,10 @@

Source: visual/Form.js

* @public */ Form.Layout = { - HORIZONTAL: Symbol.for('HORIZONTAL'), - VERTICAL: Symbol.for('VERTICAL') + HORIZONTAL: Symbol.for("HORIZONTAL"), + VERTICAL: Symbol.for("VERTICAL"), }; - - /** * Default form item. * @@ -1225,21 +1213,19 @@

Source: visual/Form.js

* */ Form._defaultItems = { - 'itemText': 'Default question', - 'type': 'rating', - 'options': 'Yes, No', - 'tickLabels': '', - 'itemWidth': 0.7, - 'itemColor': 'white', - - 'responseWidth': 0.3, - 'responseColor': 'white', - - 'index': 0, - 'layout': 'horiz' + "itemText": "Default question", + "type": "rating", + "options": "Yes, No", + "tickLabels": "", + "itemWidth": 0.7, + "itemColor": "white", + + "responseWidth": 0.3, + "responseColor": "white", + + "index": 0, + "layout": "horiz", }; - - @@ -1250,13 +1236,13 @@

Source: visual/Form.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_GratingStim.js.html b/docs/visual_GratingStim.js.html index 80cfac39..4dcd2dbd 100644 --- a/docs/visual_GratingStim.js.html +++ b/docs/visual_GratingStim.js.html @@ -36,12 +36,13 @@

Source: visual/GratingStim.js

*/ import * as PIXI from "pixi.js-legacy"; +import {AdjustmentFilter} from "@pixi/filter-adjustment"; import { Color } from "../util/Color.js"; -import { ColorMixin } from "../util/ColorMixin.js"; import { to_pixiPoint } from "../util/Pixi.js"; import * as util from "../util/Util.js"; import { VisualStim } from "./VisualStim.js"; import defaultQuadVert from "./shaders/defaultQuad.vert"; +import imageShader from "./shaders/imageShader.frag"; import sinShader from "./shaders/sinShader.frag"; import sqrShader from "./shaders/sqrShader.frag"; import sawShader from "./shaders/sawShader.frag"; @@ -60,7 +61,6 @@

Source: visual/GratingStim.js

* @name module:visual.GratingStim * @class * @extends VisualStim - * @mixes ColorMixin * @param {Object} options * @param {String} options.name - the name used when logging messages from this stimulus * @param {Window} options.win - the associated Window @@ -68,33 +68,42 @@

Source: visual/GratingStim.js

* @param {String | HTMLImageElement} [options.mask] - the name of the mask resource or HTMLImageElement corresponding to the mask * @param {String} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) * @param {number} [options.sf=1.0] - spatial frequency of the function used in grating stimulus - * @param {number} [options.phase=1.0] - phase of the function used in grating stimulus + * @param {number} [options.phase=0.0] - phase of the function used in grating stimulus, multiples of period of that function * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {number} [options.ori= 0.0] - the orientation (in degrees) * @param {number} [options.size] - the size of the rendered image (DEFAULT_STIM_SIZE_PX will be used if size is not specified) - * @param {Color} [options.color= "white"] the background color - * @param {number} [options.opacity= 1.0] - the opacity - * @param {number} [options.contrast= 1.0] - the contrast + * @param {Color} [options.color= "white"] - Foreground color of the stimulus. Can be String like "red" or "#ff0000" or Number like 0xff0000. + * @param {number} [options.opacity= 1.0] - Set the opacity of the stimulus. Determines how visible the stimulus is relative to background. + * @param {number} [options.contrast= 1.0] - Set the contrast of the stimulus, i.e. scales how far the stimulus deviates from the middle grey. Ranges [-1, 1]. * @param {number} [options.depth= 0] - the depth (i.e. the z order) - * @param {boolean} [options.interpolate= false] - whether or not the image is interpolated. NOT IMPLEMENTED YET. - * @param {String} [options.blendmode= 'avg'] - blend mode of the stimulus, determines how the stimulus is blended with the background. NOT IMPLEMENTED YET. + * @param {boolean} [options.interpolate= false] - Whether to interpolate (linearly) the texture in the stimulus. Currently supports only image based gratings. + * @param {String} [options.blendmode= "avg"] - blend mode of the stimulus, determines how the stimulus is blended with the background. Supported values: "avg", "add", "mul", "screen". * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log */ -export class GratingStim extends util.mix(VisualStim).with(ColorMixin) +export class GratingStim extends VisualStim { /** * An object that keeps shaders source code and default uniform values for them. * Shader source code is later used for construction of shader programs to create respective visual stimuli. * @name module:visual.GratingStim.#SHADERS * @type {Object} + * + * @property {Object} imageShader - Renders provided image with applied effects (coloring, phase, frequency). + * @property {String} imageShader.shader - shader source code for the image based grating stimuli. + * @property {Object} imageShader.uniforms - default uniforms for the image based shader. + * @property {float} imageShader.uniforms.uFreq=1.0 - how much times image repeated within grating stimuli. + * @property {float} imageShader.uniforms.uPhase=0.0 - offset of the image along X axis. + * @property {float} imageShader.uniforms.uAlpha=1.0 - value of the alpha channel. + * * @property {Object} sin - Creates 2d sine wave image as if 1d sine graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Sine_wave} * @property {String} sin.shader - shader source code for the sine wave stimuli * @property {Object} sin.uniforms - default uniforms for sine wave shader * @property {float} sin.uniforms.uFreq=1.0 - frequency of sine wave. * @property {float} sin.uniforms.uPhase=0.0 - phase of sine wave. + * @property {float} sin.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} sqr - Creates 2d square wave image as if 1d square graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Square_wave} @@ -102,6 +111,7 @@

Source: visual/GratingStim.js

* @property {Object} sqr.uniforms - default uniforms for square wave shader * @property {float} sqr.uniforms.uFreq=1.0 - frequency of square wave. * @property {float} sqr.uniforms.uPhase=0.0 - phase of square wave. + * @property {float} sqr.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} saw - Creates 2d sawtooth wave image as if 1d sawtooth graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Sawtooth_wave} @@ -109,6 +119,7 @@

Source: visual/GratingStim.js

* @property {Object} saw.uniforms - default uniforms for sawtooth wave shader * @property {float} saw.uniforms.uFreq=1.0 - frequency of sawtooth wave. * @property {float} saw.uniforms.uPhase=0.0 - phase of sawtooth wave. + * @property {float} saw.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} tri - Creates 2d triangle wave image as if 1d triangle graph was extended across Z axis and observed from above. * {@link https://en.wikipedia.org/wiki/Triangle_wave} @@ -117,6 +128,7 @@

Source: visual/GratingStim.js

* @property {float} tri.uniforms.uFreq=1.0 - frequency of triangle wave. * @property {float} tri.uniforms.uPhase=0.0 - phase of triangle wave. * @property {float} tri.uniforms.uPeriod=1.0 - period of triangle wave. + * @property {float} tri.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} sinXsin - Creates an image of two 2d sine waves multiplied with each other. * {@link https://en.wikipedia.org/wiki/Sine_wave} @@ -124,6 +136,7 @@

Source: visual/GratingStim.js

* @property {Object} sinXsin.uniforms - default uniforms for shader * @property {float} sinXsin.uniforms.uFreq=1.0 - frequency of sine wave (both of them). * @property {float} sinXsin.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * @property {float} sinXsin.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} sqrXsqr - Creates an image of two 2d square waves multiplied with each other. * {@link https://en.wikipedia.org/wiki/Square_wave} @@ -131,60 +144,81 @@

Source: visual/GratingStim.js

* @property {Object} sqrXsqr.uniforms - default uniforms for shader * @property {float} sqrXsqr.uniforms.uFreq=1.0 - frequency of sine wave (both of them). * @property {float} sqrXsqr.uniforms.uPhase=0.0 - phase of sine wave (both of them). + * @property {float} sqrXsqr.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} circle - Creates a filled circle shape with sharp edges. - * @property {String} circle.shader - shader source code for filled circle. - * @property {Object} circle.uniforms - default uniforms for shader. - * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim - * and 1.0 is circle that spans from edge to edge of the stim. - * - * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. + * @property {String} circle.shader - shader source code for filled circle. + * @property {Object} circle.uniforms - default uniforms for shader. + * @property {float} circle.uniforms.uRadius=1.0 - Radius of the circle. Ranges [0.0, 1.0], where 0.0 is circle so tiny it results in empty stim + * and 1.0 is circle that spans from edge to edge of the stim. + * @property {float} circle.uniforms.uAlpha=1.0 - value of the alpha channel. + * + * @property {Object} gauss - Creates a 2d Gaussian image as if 1d Gaussian graph was rotated arount Y axis and observed from above. * {@link https://en.wikipedia.org/wiki/Gaussian_function} * @property {String} gauss.shader - shader source code for Gaussian shader * @property {Object} gauss.uniforms - default uniforms for shader * @property {float} gauss.uniforms.uA=1.0 - A constant for gaussian formula (see link). * @property {float} gauss.uniforms.uB=0.0 - B constant for gaussian formula (see link). * @property {float} gauss.uniforms.uC=0.16 - C constant for gaussian formula (see link). + * @property {float} gauss.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} cross - Creates a filled cross shape with sharp edges. - * @property {String} cross.shader - shader source code for cross shader - * @property {Object} cross.uniforms - default uniforms for shader - * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes - * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. - * - * @property {Object} radRamp - Creates 2d radial ramp image. + * @property {String} cross.shader - shader source code for cross shader + * @property {Object} cross.uniforms - default uniforms for shader + * @property {float} cross.uniforms.uThickness=0.2 - Thickness of the cross. Ranges [0.0, 1.0], where 0.0 thickness makes a cross so thin it becomes + * invisible and results in an empty stim and 1.0 makes it so thick it fills the entire stim. + * @property {float} cross.uniforms.uAlpha=1.0 - value of the alpha channel. + * + * @property {Object} radRamp - Creates 2d radial ramp image. * @property {String} radRamp.shader - shader source code for radial ramp shader * @property {Object} radRamp.uniforms - default uniforms for shader * @property {float} radRamp.uniforms.uSqueeze=1.0 - coefficient that helps to modify size of the ramp. Ranges [0.0, Infinity], where 0.0 results in ramp being so large * it fills the entire stim and Infinity makes it so tiny it's invisible. + * @property {float} radRamp.uniforms.uAlpha=1.0 - value of the alpha channel. * * @property {Object} raisedCos - Creates 2d raised-cosine image as if 1d raised-cosine graph was rotated around Y axis and observed from above. * {@link https://en.wikipedia.org/wiki/Raised-cosine_filter} - * @property {String} raisedCos.shader - shader source code for raised-cosine shader - * @property {Object} raisedCos.uniforms - default uniforms for shader - * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). - * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + * @property {String} raisedCos.shader - shader source code for raised-cosine shader + * @property {Object} raisedCos.uniforms - default uniforms for shader + * @property {float} raisedCos.uniforms.uBeta=0.25 - roll-off factor (see link). + * @property {float} raisedCos.uniforms.uPeriod=0.625 - reciprocal of the symbol-rate (see link). + * @property {float} raisedCos.uniforms.uAlpha=1.0 - value of the alpha channel. */ static #SHADERS = { + imageShader: { + shader: imageShader, + uniforms: { + uFreq: 1.0, + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 + } + }, sin: { shader: sinShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, sqr: { shader: sqrShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, saw: { shader: sawShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, tri: { @@ -192,27 +226,35 @@

Source: visual/GratingStim.js

uniforms: { uFreq: 1.0, uPhase: 0.0, - uPeriod: 1.0 + uPeriod: 1.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, sinXsin: { shader: sinXsinShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, sqrXsqr: { shader: sqrXsqrShader, uniforms: { uFreq: 1.0, - uPhase: 0.0 + uPhase: 0.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, circle: { shader: circleShader, uniforms: { - uRadius: 1.0 + uRadius: 1.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, gauss: { @@ -220,26 +262,34 @@

Source: visual/GratingStim.js

uniforms: { uA: 1.0, uB: 0.0, - uC: 0.16 + uC: 0.16, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, cross: { shader: crossShader, uniforms: { - uThickness: 0.2 + uThickness: 0.2, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, radRamp: { shader: radRampShader, uniforms: { - uSqueeze: 1.0 + uSqueeze: 1.0, + uColor: [1., 1., 1.], + uAlpha: 1.0 } }, raisedCos: { shader: raisedCosShader, uniforms: { uBeta: 0.25, - uPeriod: 0.625 + uPeriod: 0.625, + uColor: [1., 1., 1.], + uAlpha: 1.0 } } }; @@ -252,6 +302,13 @@

Source: visual/GratingStim.js

*/ static #DEFAULT_STIM_SIZE_PX = [256, 256]; // in pixels + static #BLEND_MODES_MAP = { + avg: PIXI.BLEND_MODES.NORMAL, + add: PIXI.BLEND_MODES.ADD, + mul: PIXI.BLEND_MODES.MULTIPLY, + screen: PIXI.BLEND_MODES.SCREEN + }; + constructor({ name, tex = "sin", @@ -266,7 +323,7 @@

Source: visual/GratingStim.js

color, colorSpace, opacity, - contrast, + contrast = 1, depth, interpolate, blendmode, @@ -277,42 +334,20 @@

Source: visual/GratingStim.js

{ super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); - this._addAttribute( - "tex", - tex, - ); - this._addAttribute( - "mask", - mask, - ); - this._addAttribute( - "SF", - sf, - GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uFreq || 1.0 : 1.0 - ); - this._addAttribute( - "phase", - phase, - GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uPhase || 0.0 : 0.0 - ); - this._addAttribute( - "color", - color, - "white", - this._onChange(true, false), - ); - this._addAttribute( - "contrast", - contrast, - 1.0, - this._onChange(true, false), - ); - this._addAttribute( - "interpolate", - interpolate, - false, - this._onChange(true, false), - ); + this._adjustmentFilter = new AdjustmentFilter({ + contrast + }); + this._addAttribute("tex", tex); + this._addAttribute("mask", mask); + this._addAttribute("SF", sf, GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uFreq || 1.0 : 1.0); + this._addAttribute("phase", phase, GratingStim.#SHADERS[tex] ? GratingStim.#SHADERS[tex].uniforms.uPhase || 0.0 : 0.0); + this._addAttribute("color", color, "white"); + this._addAttribute("colorSpace", colorSpace, "RGB"); + this._addAttribute("contrast", contrast, 1.0, () => { + this._adjustmentFilter.contrast = this._contrast; + }); + this._addAttribute("blendmode", blendmode, "avg"); + this._addAttribute("interpolate", interpolate, false); // estimate the bounding box: this._estimateBoundingBox(); @@ -502,11 +537,11 @@

Source: visual/GratingStim.js

* @name module:visual.GratingStim#_getPixiMeshFromPredefinedShaders * @function * @protected - * @param {String} funcName - name of the shader function. Must be one of the SHADERS + * @param {String} shaderName - name of the shader. Must be one of the SHADERS * @param {Object} uniforms - a set of uniforms to supply to the shader. Mixed together with default uniform values. * @return {Pixi.Mesh} Pixi.Mesh object that represents shader and later added to the scene. */ - _getPixiMeshFromPredefinedShaders (funcName = "", uniforms = {}) { + _getPixiMeshFromPredefinedShaders (shaderName = "", uniforms = {}) { const geometry = new PIXI.Geometry(); geometry.addAttribute( "aVertexPosition", @@ -525,8 +560,8 @@

Source: visual/GratingStim.js

); geometry.addIndex([0, 1, 2, 0, 2, 3]); const vertexSrc = defaultQuadVert; - const fragmentSrc = GratingStim.#SHADERS[funcName].shader; - const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[funcName].uniforms, uniforms); + const fragmentSrc = GratingStim.#SHADERS[shaderName].shader; + const uniformsFinal = Object.assign({}, GratingStim.#SHADERS[shaderName].uniforms, uniforms); const shader = PIXI.Shader.from(vertexSrc, fragmentSrc, uniformsFinal); return new PIXI.Mesh(geometry, shader); } @@ -542,9 +577,57 @@

Source: visual/GratingStim.js

setPhase (phase, log = false) { this._setAttribute("phase", phase, log); if (this._pixi instanceof PIXI.Mesh) { - this._pixi.shader.uniforms.uPhase = phase; - } else if (this._pixi instanceof PIXI.TilingSprite) { - this._pixi.tilePosition.x = -phase * (this._size_px[0] * this._pixi.tileScale.x) / (2 * Math.PI) + this._pixi.shader.uniforms.uPhase = -phase; + } + } + + /** + * Set color space value for the grating stimulus. + * + * @name module:visual.GratingStim#setColorSpace + * @public + * @param {String} colorSpaceVal - color space value + * @param {boolean} [log= false] - whether of not to log + */ + setColorSpace (colorSpaceVal = "RGB", log = false) { + let colorSpaceValU = colorSpaceVal.toUpperCase(); + if (Color.COLOR_SPACE[colorSpaceValU] === undefined) { + colorSpaceValU = "RGB"; + } + const hasChanged = this._setAttribute("colorSpace", colorSpaceValU, log); + if (hasChanged) { + this.setColor(this._color); + } + } + + /** + * Set foreground color value for the grating stimulus. + * + * @name module:visual.GratingStim#setColor + * @public + * @param {Color} colorVal - color value, can be String like "red" or "#ff0000" or Number like 0xff0000. + * @param {boolean} [log= false] - whether of not to log + */ + setColor (colorVal = "white", log = false) { + const colorObj = (colorVal instanceof Color) ? colorVal : new Color(colorVal, Color.COLOR_SPACE[this._colorSpace]) + this._setAttribute("color", colorObj, log); + if (this._pixi instanceof PIXI.Mesh) { + this._pixi.shader.uniforms.uColor = colorObj.rgbFull; + } + } + + /** + * Determines how visible the stimulus is relative to background. + * + * @name module:visual.GratingStim#setOpacity + * @public + * @param {number} [opacity=1] opacity - The value should be a single float ranging 1.0 (opaque) to 0.0 (transparent). + * @param {boolean} [log= false] - whether of not to log + */ + setOpacity (opacity = 1, log = false) { + this._setAttribute("opacity", opacity, log); + if (this._pixi instanceof PIXI.Mesh) { + this._pixi.shader.uniforms.uAlpha = opacity; } } @@ -560,13 +643,45 @@

Source: visual/GratingStim.js

this._setAttribute("SF", sf, log); if (this._pixi instanceof PIXI.Mesh) { this._pixi.shader.uniforms.uFreq = sf; - } else if (this._pixi instanceof PIXI.TilingSprite) { - // tileScale units are pixels, so converting function frequency to pixels - // and also taking into account possible size difference between used texture and requested stim size - this._pixi.tileScale.x = (1 / sf) * (this._pixi.width / this._pixi.texture.width); - // since most functions defined in SHADERS assume spatial frequency change along X axis - // we assume desired effect for image based stims to be the same so tileScale.y is not affected by spatialFrequency - this._pixi.tileScale.y = this._pixi.height / this._pixi.texture.height; + } + } + + /** + * Set blend mode of the grating stimulus. + * + * @name module:visual.GratingStim#setBlendmode + * @public + * @param {String} blendMode - blend mode, can be one of the following: ["avg", "add", "mul", "screen"]. + * @param {boolean} [log=false] - whether or not to log + */ + setBlendmode (blendMode = "avg", log = false) { + this._setAttribute("blendmode", blendMode, log); + if (this._pixi !== undefined) { + let pixiBlendMode = GratingStim.#BLEND_MODES_MAP[blendMode]; + if (pixiBlendMode === undefined) { + pixiBlendMode = PIXI.BLEND_MODES.NORMAL; + } + if (this._pixi.filters) { + this._pixi.filters[this._pixi.filters.length - 1].blendMode = pixiBlendMode; + } else { + this._pixi.blendMode = pixiBlendMode; + } + } + } + + /** + * Whether to interpolate (linearly) the texture in the stimulus. + * + * @name module:visual.GratingStim#setInterpolate + * @public + * @param {boolean} interpolate - interpolate or not. + * @param {boolean} [log=false] - whether or not to log + */ + setInterpolate (interpolate = false, log = false) { + this._setAttribute("interpolate", interpolate, log); + if (this._pixi instanceof PIXI.Mesh && this._pixi.shader.uniforms.uTex instanceof PIXI.Texture) { + this._pixi.shader.uniforms.uTex.baseTexture.scaleMode = interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST; + this._pixi.shader.uniforms.uTex.baseTexture.update(); } } @@ -588,8 +703,14 @@

Source: visual/GratingStim.js

if (this._needPixiUpdate) { this._needPixiUpdate = false; + let shaderName; + let shaderUniforms; + let currentUniforms = {}; if (typeof this._pixi !== "undefined") { + if (this._pixi instanceof PIXI.Mesh) { + Object.assign(currentUniforms, this._pixi.shader.uniforms); + } this._pixi.destroy(true); } this._pixi = undefined; @@ -602,21 +723,30 @@

Source: visual/GratingStim.js

if (this._tex instanceof HTMLImageElement) { - this._pixi = PIXI.TilingSprite.from(this._tex, { - width: this._size_px[0], - height: this._size_px[1] + shaderName = "imageShader"; + let shaderTex = PIXI.Texture.from(this._tex, { + wrapMode: PIXI.WRAP_MODES.REPEAT, + scaleMode: this._interpolate ? PIXI.SCALE_MODES.LINEAR : PIXI.SCALE_MODES.NEAREST }); - this.setPhase(this._phase); - this.setSF(this._SF); + shaderUniforms = { + uTex: shaderTex, + uFreq: this._SF, + uPhase: this._phase, + uColor: this._color.rgbFull + }; } else { - this._pixi = this._getPixiMeshFromPredefinedShaders(this._tex, { + shaderName = this._tex; + shaderUniforms = { uFreq: this._SF, - uPhase: this._phase - }); + uPhase: this._phase, + uColor: this._color.rgbFull + }; } + this._pixi = this._getPixiMeshFromPredefinedShaders(shaderName, Object.assign(shaderUniforms, currentUniforms)); this._pixi.pivot.set(this._pixi.width * 0.5, this._pixi.width * 0.5); + this._pixi.filters = [this._adjustmentFilter]; // add a mask if need be: if (typeof this._mask !== "undefined") @@ -657,7 +787,7 @@

Source: visual/GratingStim.js

} this._pixi.zIndex = this._depth; - this._pixi.alpha = this.opacity; + this.opacity = this._opacity; // set the scale: const displaySize = this._getDisplaySize(); @@ -670,7 +800,7 @@

Source: visual/GratingStim.js

// set the position, rotation, and anchor (image centered on pos): let pos = to_pixiPoint(this.pos, this.units, this.win); this._pixi.position.set(pos.x, pos.y); - this._pixi.rotation = this.ori * Math.PI / 180; + this._pixi.rotation = -this.ori * Math.PI / 180; // re-estimate the bounding box, as the texture's width may now be available: this._estimateBoundingBox(); @@ -686,13 +816,13 @@

Source: visual/GratingStim.js


- Documentation generated by JSDoc 3.6.7 on Mon Mar 21 2022 21:35:47 GMT+0300 (Moscow Standard Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_ImageStim.js.html b/docs/visual_ImageStim.js.html index 11a5937d..024dd43b 100644 --- a/docs/visual_ImageStim.js.html +++ b/docs/visual_ImageStim.js.html @@ -35,13 +35,12 @@

Source: visual/ImageStim.js

* @license Distributed under the terms of the MIT License */ - -import * as PIXI from 'pixi.js-legacy'; -import {VisualStim} from './VisualStim'; -import {Color} from '../util/Color'; -import {ColorMixin} from '../util/ColorMixin'; -import * as util from '../util/Util'; - +import * as PIXI from "pixi.js-legacy"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; /** * Image Stimulus. @@ -73,53 +72,53 @@

Source: visual/ImageStim.js

*/ export class ImageStim extends util.mix(VisualStim).with(ColorMixin) { - constructor({name, win, image, mask, pos, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog} = {}) + constructor({ name, win, image, mask, pos, units, ori, size, color, opacity, contrast, texRes, depth, interpolate, flipHoriz, flipVert, autoDraw, autoLog } = {}) { - super({name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog}); + super({ name, win, units, ori, opacity, depth, pos, size, autoDraw, autoLog }); this._addAttribute( - 'image', - image + "image", + image, ); this._addAttribute( - 'mask', - mask + "mask", + mask, ); this._addAttribute( - 'color', + "color", color, - 'white', - this._onChange(true, false) + "white", + this._onChange(true, false), ); this._addAttribute( - 'contrast', + "contrast", contrast, 1.0, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'texRes', + "texRes", texRes, 128, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'interpolate', + "interpolate", interpolate, false, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'flipHoriz', + "flipHoriz", flipHoriz, false, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'flipVert', + "flipVert", flipVert, false, - this._onChange(false, false) + this._onChange(false, false), ); // estimate the bounding box: @@ -131,8 +130,6 @@

Source: visual/ImageStim.js

} } - - /** * Setter for the image attribute. * @@ -144,22 +141,22 @@

Source: visual/ImageStim.js

setImage(image, log = false) { const response = { - origin: 'ImageStim.setImage', - context: 'when setting the image of ImageStim: ' + this._name + origin: "ImageStim.setImage", + context: "when setting the image of ImageStim: " + this._name, }; try { // image is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem - if (typeof image === 'undefined') + if (typeof image === "undefined") { - this.psychoJS.logger.warn('setting the image of ImageStim: ' + this._name + ' with argument: undefined.'); - this.psychoJS.logger.debug('set the image of ImageStim: ' + this._name + ' as: undefined'); + this.psychoJS.logger.warn("setting the image of ImageStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the image of ImageStim: " + this._name + " as: undefined"); } else { // image is a string: it should be the name of a resource, which we load - if (typeof image === 'string') + if (typeof image === "string") { image = this.psychoJS.serverManager.getResource(image); } @@ -167,16 +164,16 @@

Source: visual/ImageStim.js

// image should now be an actual HTMLImageElement: we raise an error if it is not if (!(image instanceof HTMLImageElement)) { - throw 'the argument: ' + image.toString() + ' is not an image" }'; + throw "the argument: " + image.toString() + ' is not an image" }'; } - this.psychoJS.logger.debug('set the image of ImageStim: ' + this._name + ' as: src= ' + image.src + ', size= ' + image.width + 'x' + image.height); + this.psychoJS.logger.debug("set the image of ImageStim: " + this._name + " as: src= " + image.src + ", size= " + image.width + "x" + image.height); } const existingImage = this.getImage(); const hasChanged = existingImage ? existingImage.src !== image.src : true; - this._setAttribute('image', image, log); + this._setAttribute("image", image, log); if (hasChanged) { @@ -185,12 +182,10 @@

Source: visual/ImageStim.js

} catch (error) { - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - - /** * Setter for the mask attribute. * @@ -202,22 +197,22 @@

Source: visual/ImageStim.js

setMask(mask, log = false) { const response = { - origin: 'ImageStim.setMask', - context: 'when setting the mask of ImageStim: ' + this._name + origin: "ImageStim.setMask", + context: "when setting the mask of ImageStim: " + this._name, }; try { // mask is undefined: that's fine but we raise a warning in case this is a sympton of an actual problem - if (typeof mask === 'undefined') + if (typeof mask === "undefined") { - this.psychoJS.logger.warn('setting the mask of ImageStim: ' + this._name + ' with argument: undefined.'); - this.psychoJS.logger.debug('set the mask of ImageStim: ' + this._name + ' as: undefined'); + this.psychoJS.logger.warn("setting the mask of ImageStim: " + this._name + " with argument: undefined."); + this.psychoJS.logger.debug("set the mask of ImageStim: " + this._name + " as: undefined"); } else { // mask is a string: it should be the name of a resource, which we load - if (typeof mask === 'string') + if (typeof mask === "string") { mask = this.psychoJS.serverManager.getResource(mask); } @@ -225,24 +220,22 @@

Source: visual/ImageStim.js

// mask should now be an actual HTMLImageElement: we raise an error if it is not if (!(mask instanceof HTMLImageElement)) { - throw 'the argument: ' + mask.toString() + ' is not an image" }'; + throw "the argument: " + mask.toString() + ' is not an image" }'; } - this.psychoJS.logger.debug('set the mask of ImageStim: ' + this._name + ' as: src= ' + mask.src + ', size= ' + mask.width + 'x' + mask.height); + this.psychoJS.logger.debug("set the mask of ImageStim: " + this._name + " as: src= " + mask.src + ", size= " + mask.width + "x" + mask.height); } - this._setAttribute('mask', mask, log); + this._setAttribute("mask", mask, log); this._onChange(true, false)(); } catch (error) { - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - - /** * Estimate the bounding box. * @@ -254,21 +247,19 @@

Source: visual/ImageStim.js

_estimateBoundingBox() { const size = this._getDisplaySize(); - if (typeof size !== 'undefined') + if (typeof size !== "undefined") { this._boundingBox = new PIXI.Rectangle( this._pos[0] - size[0] / 2, this._pos[1] - size[1] / 2, size[0], - size[1] + size[1], ); } // TODO take the orientation into account } - - /** * Update the stimulus, if necessary. * @@ -288,14 +279,14 @@

Source: visual/ImageStim.js

{ this._needPixiUpdate = false; - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { this._pixi.destroy(true); } this._pixi = undefined; // no image to draw: return immediately - if (typeof this._image === 'undefined') + if (typeof this._image === "undefined") { return; } @@ -306,7 +297,7 @@

Source: visual/ImageStim.js

this._pixi = PIXI.Sprite.from(this._texture); // add a mask if need be: - if (typeof this._mask !== 'undefined') + if (typeof this._mask !== "undefined") { this._pixi.mask = PIXI.Sprite.from(this._mask); @@ -348,8 +339,8 @@

Source: visual/ImageStim.js

this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; // set the position, rotation, and anchor (image centered on pos): - this._pixi.position = util.to_pixiPoint(this.pos, this.units, this.win); - this._pixi.rotation = this.ori * Math.PI / 180; + this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.rotation = -this.ori * Math.PI / 180; this._pixi.anchor.x = 0.5; this._pixi.anchor.y = 0.5; @@ -357,8 +348,6 @@

Source: visual/ImageStim.js

this._estimateBoundingBox(); } - - /** * Get the size of the display image, which is either that of the ImageStim or that of the image * it contains. @@ -371,20 +360,18 @@

Source: visual/ImageStim.js

{ let displaySize = this.size; - if (typeof displaySize === 'undefined') + if (typeof displaySize === "undefined") { // use the size of the texture, if we have access to it: - if (typeof this._texture !== 'undefined' && this._texture.width > 0) + if (typeof this._texture !== "undefined" && this._texture.width > 0) { const textureSize = [this._texture.width, this._texture.height]; - displaySize = util.to_unit(textureSize, 'pix', this.win, this.units); + displaySize = util.to_unit(textureSize, "pix", this.win, this.units); } } return displaySize; } - - } @@ -396,13 +383,13 @@

Source: visual/ImageStim.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_MovieStim.js.html b/docs/visual_MovieStim.js.html index 4de27240..0d72ee0b 100644 --- a/docs/visual_MovieStim.js.html +++ b/docs/visual_MovieStim.js.html @@ -35,13 +35,14 @@

Source: visual/MovieStim.js

* @license Distributed under the terms of the MIT License */ - -import * as PIXI from 'pixi.js-legacy'; -import {VisualStim} from './VisualStim'; -import {Color} from '../util/Color'; -import {ColorMixin} from '../util/ColorMixin'; -import * as util from '../util/Util'; -import {PsychoJS} from "../core/PsychoJS"; +import * as PIXI from "pixi.js-legacy"; +import { PsychoJS } from "../core/PsychoJS.js"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; +import {Camera} from "./Camera.js"; /** @@ -53,7 +54,8 @@

Source: visual/MovieStim.js

* @param {Object} options * @param {String} options.name - the name used when logging messages from this stimulus * @param {module:core.Window} options.win - the associated Window - * @param {string | HTMLVideoElement} options.movie - the name of the movie resource or the HTMLVideoElement corresponding to the movie + * @param {string | HTMLVideoElement | module:visual.Camera} movie - the name of a + * movie resource or of a HTMLVideoElement or of a Camera component * @param {string} [options.units= "norm"] - the units of the stimulus (e.g. for size, position, vertices) * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the stimulus * @param {string} [options.units= 'norm'] - the units of the stimulus vertices, size and position @@ -76,82 +78,81 @@

Source: visual/MovieStim.js

*/ export class MovieStim extends VisualStim { - constructor({name, win, movie, pos, units, ori, size, color, opacity, contrast, interpolate, flipHoriz, flipVert, loop, volume, noAudio, autoPlay, autoDraw, autoLog} = {}) + constructor({ name, win, movie, pos, units, ori, size, color, opacity, contrast, interpolate, flipHoriz, flipVert, loop, volume, noAudio, autoPlay, autoDraw, autoLog } = {}) { - super({name, win, units, ori, opacity, pos, size, autoDraw, autoLog}); + super({ name, win, units, ori, opacity, pos, size, autoDraw, autoLog }); - this.psychoJS.logger.debug('create a new MovieStim with name: ', name); + this.psychoJS.logger.debug("create a new MovieStim with name: ", name); // movie and movie control: this._addAttribute( - 'movie', - movie + "movie", + movie, ); this._addAttribute( - 'volume', + "volume", volume, 1.0, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'noAudio', + "noAudio", noAudio, false, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'autoPlay', + "autoPlay", autoPlay, true, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'flipHoriz', + "flipHoriz", flipHoriz, false, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'flipVert', + "flipVert", flipVert, false, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'interpolate', + "interpolate", interpolate, false, - this._onChange(true, false) + this._onChange(true, false), ); // colors: this._addAttribute( - 'color', + "color", color, - 'white', - this._onChange(true, false) + "white", + this._onChange(true, false), ); this._addAttribute( - 'contrast', + "contrast", contrast, 1.0, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'loop', + "loop", loop, false, - this._onChange(false, false) + this._onChange(false, false), ); - // estimate the bounding box: this._estimateBoundingBox(); // check whether the fastSeek method on HTMLVideoElement is implemented: - const videoElement = document.createElement('video'); - this._hasFastSeek = (typeof videoElement.fastSeek === 'function'); + const videoElement = document.createElement("video"); + this._hasFastSeek = (typeof videoElement.fastSeek === "function"); if (this._autoLog) { @@ -159,70 +160,80 @@

Source: visual/MovieStim.js

} } - - /** * Setter for the movie attribute. * * @name module:visual.MovieStim#setMovie * @public - * @param {string | HTMLVideoElement} movie - the name of the movie resource or the HTMLVideoElement corresponding to the movie + * @param {string | HTMLVideoElement | module:visual.Camera} movie - the name of a + * movie resource or of a HTMLVideoElement or of a Camera component * @param {boolean} [log= false] - whether of not to log */ setMovie(movie, log = false) { const response = { - origin: 'MovieStim.setMovie', - context: 'when setting the movie of MovieStim: ' + this._name + origin: "MovieStim.setMovie", + context: "when setting the movie of MovieStim: " + this._name, }; try { - // movie is undefined: that's fine but we raise a warning in case this is a symptom of an actual problem + // movie is undefined: that's fine but we raise a warning in case this is + // a symptom of an actual problem if (typeof movie === 'undefined') { - this.psychoJS.logger.warn('setting the movie of MovieStim: ' + this._name + ' with argument: undefined.'); - this.psychoJS.logger.debug('set the movie of MovieStim: ' + this._name + ' as: undefined'); + this.psychoJS.logger.warn( + `setting the movie of MovieStim: ${this._name} with argument: undefined.`); + this.psychoJS.logger.debug(`set the movie of MovieStim: ${this._name} as: undefined`); } + else { - // movie is a string: it should be the name of a resource, which we load - if (typeof movie === 'string') + // if movie is a string, then it should be the name of a resource, which we get: + if (typeof movie === "string") { movie = this.psychoJS.serverManager.getResource(movie); } - // movie should now be an actual HTMLVideoElement: we raise an error if it is not + // if movie is an instance of camera, get a video element from it: + else if (movie instanceof Camera) + { + const video = movie.getVideo(); + // TODO remove previous one if there is one + // document.body.appendChild(video); + movie = video; + } + + // check that movie is now an HTMLVideoElement if (!(movie instanceof HTMLVideoElement)) { - throw 'the argument: ' + movie.toString() + ' is not a video" }'; + throw movie.toString() + " is not a video"; } this.psychoJS.logger.debug(`set the movie of MovieStim: ${this._name} as: src= ${movie.src}, size= ${movie.videoWidth}x${movie.videoHeight}, duration= ${movie.duration}s`); - } - // Make sure just one listener attached across instances - // https://stackoverflow.com/questions/11455515 - if (!movie.onended) - { - movie.onended = () => + // ensure we have only one onended listener per HTMLVideoElement, since we can have several + // MovieStim with the same underlying HTMLVideoElement + // https://stackoverflow.com/questions/11455515 + if (!movie.onended) { - this.status = PsychoJS.Status.FINISHED; - }; + movie.onended = () => + { + this.status = PsychoJS.Status.FINISHED; + }; + } } - this._setAttribute('movie', movie, log); + this._setAttribute("movie", movie, log); this._needUpdate = true; this._needPixiUpdate = true; } catch (error) { - throw Object.assign(response, {error}); + throw Object.assign(response, { error }); } } - - /** * Reset the stimulus. * @@ -235,8 +246,6 @@

Source: visual/MovieStim.js

this.seek(0, log); } - - /** * Start playing the movie. * @@ -251,18 +260,17 @@

Source: visual/MovieStim.js

if (playPromise !== undefined) { - playPromise.catch((error) => { + playPromise.catch((error) => + { throw { - origin: 'MovieStim.play', + origin: "MovieStim.play", context: `when attempting to play MovieStim: ${this._name}`, - error + error, }; }); } } - - /** * Pause the movie. * @@ -274,8 +282,6 @@

Source: visual/MovieStim.js

this._movie.pause(); } - - /** * Stop the movie and reset to 0s. * @@ -288,8 +294,6 @@

Source: visual/MovieStim.js

this.seek(0, log); } - - /** * Jump to a specific timepoint * @@ -303,9 +307,9 @@

Source: visual/MovieStim.js

if (timePoint < 0 || timePoint > this._movie.duration) { throw { - origin: 'MovieStim.seek', + origin: "MovieStim.seek", context: `when seeking to timepoint: ${timePoint} of MovieStim: ${this._name}`, - error: `the timepoint does not belong to [0, ${this._movie.duration}` + error: `the timepoint does not belong to [0, ${this._movie.duration}`, }; } @@ -322,16 +326,14 @@

Source: visual/MovieStim.js

catch (error) { throw { - origin: 'MovieStim.seek', + origin: "MovieStim.seek", context: `when seeking to timepoint: ${timePoint} of MovieStim: ${this._name}`, - error + error, }; } } } - - /** * Estimate the bounding box. * @@ -343,21 +345,19 @@

Source: visual/MovieStim.js

_estimateBoundingBox() { const size = this._getDisplaySize(); - if (typeof size !== 'undefined') + if (typeof size !== "undefined") { this._boundingBox = new PIXI.Rectangle( this._pos[0] - size[0] / 2, this._pos[1] - size[1] / 2, size[0], - size[1] + size[1], ); } // TODO take the orientation into account } - - /** * Update the stimulus, if necessary. * @@ -377,20 +377,20 @@

Source: visual/MovieStim.js

{ this._needPixiUpdate = false; - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { // Leave original video in place // https://pixijs.download/dev/docs/PIXI.Sprite.html#destroy this._pixi.destroy({ children: true, texture: true, - baseTexture: false + baseTexture: false, }); } this._pixi = undefined; // no movie to draw: return immediately - if (typeof this._movie === 'undefined') + if (typeof this._movie === "undefined") { return; } @@ -428,8 +428,8 @@

Source: visual/MovieStim.js

this._pixi.scale.y = this.flipVert ? scaleY : -scaleY; // set the position, rotation, and anchor (movie centered on pos): - this._pixi.position = util.to_pixiPoint(this.pos, this.units, this.win); - this._pixi.rotation = this.ori * Math.PI / 180; + this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.rotation = -this.ori * Math.PI / 180; this._pixi.anchor.x = 0.5; this._pixi.anchor.y = 0.5; @@ -437,8 +437,6 @@

Source: visual/MovieStim.js

this._estimateBoundingBox(); } - - /** * Get the size of the display image, which is either that of the ImageStim or that of the image * it contains. @@ -451,20 +449,18 @@

Source: visual/MovieStim.js

{ let displaySize = this.size; - if (typeof displaySize === 'undefined') + if (typeof displaySize === "undefined") { // use the size of the texture, if we have access to it: - if (typeof this._texture !== 'undefined' && this._texture.width > 0) + if (typeof this._texture !== "undefined" && this._texture.width > 0) { const textureSize = [this._texture.width, this._texture.height]; - displaySize = util.to_unit(textureSize, 'pix', this.win, this.units); + displaySize = util.to_unit(textureSize, "pix", this.win, this.units); } } return displaySize; } - - } @@ -476,13 +472,13 @@

Source: visual/MovieStim.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_Polygon.js.html b/docs/visual_Polygon.js.html index e19cc5dc..34e1d470 100644 --- a/docs/visual_Polygon.js.html +++ b/docs/visual_Polygon.js.html @@ -35,10 +35,8 @@

Source: visual/Polygon.js

* @license Distributed under the terms of the MIT License */ - -import {ShapeStim} from './ShapeStim'; -import {Color} from '../util/Color'; - +import { Color } from "../util/Color.js"; +import { ShapeStim } from "./ShapeStim.js"; /** * <p>Polygonal visual stimulus.</p> @@ -67,7 +65,7 @@

Source: visual/Polygon.js

*/ export class Polygon extends ShapeStim { - constructor({name, win, lineWidth, lineColor, fillColor, opacity, edges, radius, pos, size, ori, units, contrast, depth, interpolate, autoDraw, autoLog} = {}) + constructor({ name, win, lineWidth, lineColor, fillColor, opacity, edges, radius, pos, size, ori, units, contrast, depth, interpolate, autoDraw, autoLog } = {}) { super({ name, @@ -84,20 +82,20 @@

Source: visual/Polygon.js

depth, interpolate, autoDraw, - autoLog + autoLog, }); - this._psychoJS.logger.debug('create a new Polygon with name: ', name); + this._psychoJS.logger.debug("create a new Polygon with name: ", name); this._addAttribute( - 'edges', + "edges", edges, - 3 + 3, ); this._addAttribute( - 'radius', + "radius", radius, - 0.5 + 0.5, ); this._updateVertices(); @@ -108,8 +106,6 @@

Source: visual/Polygon.js

} } - - /** * Setter for the radius attribute. * @@ -120,7 +116,7 @@

Source: visual/Polygon.js

*/ setRadius(radius, log = false) { - const hasChanged = this._setAttribute('radius', radius, log); + const hasChanged = this._setAttribute("radius", radius, log); if (hasChanged) { @@ -128,8 +124,6 @@

Source: visual/Polygon.js

} } - - /** * Setter for the edges attribute. * @@ -140,7 +134,7 @@

Source: visual/Polygon.js

*/ setEdges(edges, log = false) { - const hasChanged = this._setAttribute('edges', Math.round(edges), log); + const hasChanged = this._setAttribute("edges", Math.round(edges), log); if (hasChanged) { @@ -148,8 +142,6 @@

Source: visual/Polygon.js

} } - - /** * Update the vertices. * @@ -158,7 +150,7 @@

Source: visual/Polygon.js

*/ _updateVertices() { - this._psychoJS.logger.debug('update the vertices of Polygon: ', this.name); + this._psychoJS.logger.debug("update the vertices of Polygon: ", this.name); const angle = 2.0 * Math.PI / this._edges; const vertices = []; @@ -169,7 +161,6 @@

Source: visual/Polygon.js

this.setVertices(vertices); } - } @@ -181,13 +172,13 @@

Source: visual/Polygon.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_Rect.js.html b/docs/visual_Rect.js.html index d43efb9e..5bb9d6f0 100644 --- a/docs/visual_Rect.js.html +++ b/docs/visual_Rect.js.html @@ -35,10 +35,8 @@

Source: visual/Rect.js

* @license Distributed under the terms of the MIT License */ - -import {ShapeStim} from './ShapeStim'; -import {Color} from '../util/Color'; - +import { Color } from "../util/Color.js"; +import { ShapeStim } from "./ShapeStim.js"; /** * <p>Rectangular visual stimulus.</p> @@ -67,7 +65,7 @@

Source: visual/Rect.js

*/ export class Rect extends ShapeStim { - constructor({name, win, lineWidth, lineColor, fillColor, opacity, width, height, pos, size, ori, units, contrast, depth, interpolate, autoDraw, autoLog} = {}) + constructor({ name, win, lineWidth, lineColor, fillColor, opacity, width, height, pos, size, ori, units, contrast, depth, interpolate, autoDraw, autoLog } = {}) { super({ name, @@ -84,20 +82,20 @@

Source: visual/Rect.js

depth, interpolate, autoDraw, - autoLog + autoLog, }); - this._psychoJS.logger.debug('create a new Rect with name: ', name); + this._psychoJS.logger.debug("create a new Rect with name: ", name); this._addAttribute( - 'width', + "width", width, - 0.5 + 0.5, ); this._addAttribute( - 'height', + "height", height, - 0.5 + 0.5, ); this._updateVertices(); @@ -108,8 +106,6 @@

Source: visual/Rect.js

} } - - /** * Setter for the width attribute. * @@ -120,9 +116,9 @@

Source: visual/Rect.js

*/ setWidth(width, log = false) { - this._psychoJS.logger.debug('set the width of Rect: ', this.name, 'to: ', width); + this._psychoJS.logger.debug("set the width of Rect: ", this.name, "to: ", width); - const hasChanged = this._setAttribute('width', width, log); + const hasChanged = this._setAttribute("width", width, log); if (hasChanged) { @@ -130,8 +126,6 @@

Source: visual/Rect.js

} } - - /** * Setter for the height attribute. * @@ -142,9 +136,9 @@

Source: visual/Rect.js

*/ setHeight(height, log = false) { - this._psychoJS.logger.debug('set the height of Rect: ', this.name, 'to: ', height); + this._psychoJS.logger.debug("set the height of Rect: ", this.name, "to: ", height); - const hasChanged = this._setAttribute('height', height, log); + const hasChanged = this._setAttribute("height", height, log); if (hasChanged) { @@ -152,8 +146,6 @@

Source: visual/Rect.js

} } - - /** * Update the vertices. * @@ -162,7 +154,7 @@

Source: visual/Rect.js

*/ _updateVertices() { - this._psychoJS.logger.debug('update the vertices of Rect: ', this.name); + this._psychoJS.logger.debug("update the vertices of Rect: ", this.name); const halfWidth = this._width / 2.0; const halfHeight = this._height / 2.0; @@ -171,10 +163,9 @@

Source: visual/Rect.js

[-halfWidth, -halfHeight], [halfWidth, -halfHeight], [halfWidth, halfHeight], - [-halfWidth, halfHeight] + [-halfWidth, halfHeight], ]); } - } @@ -186,13 +177,13 @@

Source: visual/Rect.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_ShapeStim.js.html b/docs/visual_ShapeStim.js.html index d49977d7..8a289788 100644 --- a/docs/visual_ShapeStim.js.html +++ b/docs/visual_ShapeStim.js.html @@ -36,14 +36,13 @@

Source: visual/ShapeStim.js

* @license Distributed under the terms of the MIT License */ - -import * as PIXI from 'pixi.js-legacy'; -import {VisualStim} from './VisualStim'; -import {Color} from '../util/Color'; -import {ColorMixin} from '../util/ColorMixin'; -import * as util from '../util/Util'; -import {WindowMixin} from "../core/WindowMixin"; - +import * as PIXI from "pixi.js-legacy"; +import { WindowMixin } from "../core/WindowMixin.js"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; /** * <p>This class provides the basic functionality of shape stimuli.</p> @@ -72,9 +71,9 @@

Source: visual/ShapeStim.js

*/ export class ShapeStim extends util.mix(VisualStim).with(ColorMixin, WindowMixin) { - constructor({name, win, lineWidth, lineColor, fillColor, opacity, vertices, closeShape, pos, size, ori, units, contrast, depth, interpolate, autoDraw, autoLog} = {}) + constructor({ name, win, lineWidth, lineColor, fillColor, opacity, vertices, closeShape, pos, size, ori, units, contrast, depth, interpolate, autoDraw, autoLog } = {}) { - super({name, win, units, ori, opacity, pos, depth, size, autoDraw, autoLog}); + super({ name, win, units, ori, opacity, pos, depth, size, autoDraw, autoLog }); // the PIXI polygon corresponding to the vertices, in pixel units: this._pixiPolygon_px = undefined; @@ -82,58 +81,56 @@

Source: visual/ShapeStim.js

this._vertices_px = undefined; // shape: - if (typeof size === 'undefined' || size === null) + if (typeof size === "undefined" || size === null) { this.size = [1.0, 1.0]; } this._addAttribute( - 'vertices', + "vertices", vertices, - [[-0.5, 0], [0, 0.5], [0.5, 0]] + [[-0.5, 0], [0, 0.5], [0.5, 0]], ); this._addAttribute( - 'closeShape', + "closeShape", closeShape, true, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'interpolate', + "interpolate", interpolate, true, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'lineWidth', + "lineWidth", lineWidth, 1.5, - this._onChange(true, true) + this._onChange(true, true), ); // colors: this._addAttribute( - 'lineColor', + "lineColor", lineColor, - 'white', - this._onChange(true, false) + "white", + this._onChange(true, false), ); this._addAttribute( - 'fillColor', + "fillColor", fillColor, undefined, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'contrast', + "contrast", contrast, 1.0, - this._onChange(true, false) + this._onChange(true, false), ); } - - /** * Setter for the vertices attribute. * @@ -145,16 +142,16 @@

Source: visual/ShapeStim.js

setVertices(vertices, log = false) { const response = { - origin: 'ShapeStim.setVertices', - context: 'when setting the vertices of ShapeStim: ' + this._name + origin: "ShapeStim.setVertices", + context: "when setting the vertices of ShapeStim: " + this._name, }; - this._psychoJS.logger.debug('set the vertices of ShapeStim:', this.name); + this._psychoJS.logger.debug("set the vertices of ShapeStim:", this.name); try { // if vertices is a string, we check whether it is a known shape: - if (typeof vertices === 'string') + if (typeof vertices === "string") { if (vertices in ShapeStim.KnownShapes) { @@ -166,18 +163,16 @@

Source: visual/ShapeStim.js

} } - this._setAttribute('vertices', vertices, log); + this._setAttribute("vertices", vertices, log); this._onChange(true, true)(); } catch (error) { - throw Object.assign(response, {error: error}); + throw Object.assign(response, { error: error }); } } - - /** * Determine whether an object is inside the bounding box of the ShapeStim. * @@ -195,24 +190,22 @@

Source: visual/ShapeStim.js

// get the position of the object, in pixel coordinates: const objectPos_px = util.getPositionFromObject(object, units); - if (typeof objectPos_px === 'undefined') + if (typeof objectPos_px === "undefined") { throw { - origin: 'VisualStim.contains', - context: 'when determining whether VisualStim: ' + this._name + ' contains object: ' + util.toString(object), - error: 'unable to determine the position of the object' + origin: "VisualStim.contains", + context: "when determining whether VisualStim: " + this._name + " contains object: " + util.toString(object), + error: "unable to determine the position of the object", }; } // test for inclusion: const pos_px = util.to_px(this.pos, this.units, this.win); this._getVertices_px(); - const polygon_px = this._vertices_px.map(v => [v[0] + pos_px[0], v[1] + pos_px[1]]); + const polygon_px = this._vertices_px.map((v) => [v[0] + pos_px[0], v[1] + pos_px[1]]); return util.IsPointInsidePolygon(objectPos_px, polygon_px); } - - /** * Estimate the bounding box. * @@ -230,7 +223,7 @@

Source: visual/ShapeStim.js

Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, - Number.NEGATIVE_INFINITY + Number.NEGATIVE_INFINITY, ]; for (const vertex of this._vertices_px) { @@ -244,14 +237,12 @@

Source: visual/ShapeStim.js

this._pos[0] + this._getLengthUnits(limits_px[0]), this._pos[1] + this._getLengthUnits(limits_px[1]), this._getLengthUnits(limits_px[2] - limits_px[0]), - this._getLengthUnits(limits_px[3] - limits_px[1]) + this._getLengthUnits(limits_px[3] - limits_px[1]), ); // TODO take the orientation into account } - - /** * Update the stimulus, if necessary. * @@ -271,7 +262,7 @@

Source: visual/ShapeStim.js

{ this._needPixiUpdate = false; - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { this._pixi.destroy(true); } @@ -283,25 +274,23 @@

Source: visual/ShapeStim.js

// prepare the polygon in the given color and opacity: this._pixi = new PIXI.Graphics(); this._pixi.lineStyle(this._lineWidth, this._lineColor.int, this._opacity, 0.5); - if (typeof this._fillColor !== 'undefined' && this._fillColor !== null) + if (typeof this._fillColor !== "undefined" && this._fillColor !== null) { const contrastedColor = this.getContrastedColor(new Color(this._fillColor), this._contrast); this._pixi.beginFill(contrastedColor.int, this._opacity); } this._pixi.drawPolygon(this._pixiPolygon_px); - if (typeof this._fillColor !== 'undefined' && this._fillColor !== null) + if (typeof this._fillColor !== "undefined" && this._fillColor !== null) { this._pixi.endFill(); } } // set polygon position and rotation: - this._pixi.position = util.to_pixiPoint(this.pos, this.units, this.win); - this._pixi.rotation = this.ori * Math.PI / 180.0; + this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); + this._pixi.rotation = -this.ori * Math.PI / 180.0; } - - /** * Get the PIXI polygon (in pixel units) corresponding to the vertices. * @@ -339,8 +328,6 @@

Source: visual/ShapeStim.js

return this._pixiPolygon_px; } - - /** * Get the vertices in pixel units. * @@ -352,28 +339,28 @@

Source: visual/ShapeStim.js

{ // handle flipping: let flip = [1.0, 1.0]; - if ('_flipHoriz' in this && this._flipHoriz) + if ("_flipHoriz" in this && this._flipHoriz) { flip[0] = -1.0; } - if ('_flipVert' in this && this._flipVert) + if ("_flipVert" in this && this._flipVert) { flip[1] = -1.0; } // handle size, flipping, and convert to pixel units: - this._vertices_px = this._vertices.map(v => util.to_px( - [v[0] * this._size[0] * flip[0], v[1] * this._size[1] * flip[1]], - this._units, - this._win) + this._vertices_px = this._vertices.map((v) => + util.to_px( + [v[0] * this._size[0] * flip[0], v[1] * this._size[1] * flip[1]], + this._units, + this._win, + ) ); return this._vertices_px; } - } - /** * Known shapes. * @@ -385,15 +372,15 @@

Source: visual/ShapeStim.js

[-0.1, +0.5], // up [+0.1, +0.5], [+0.1, +0.1], - [+0.5, +0.1], // right + [+0.5, +0.1], // right [+0.5, -0.1], [+0.1, -0.1], - [+0.1, -0.5], // down + [+0.1, -0.5], // down [-0.1, -0.5], [-0.1, -0.1], - [-0.5, -0.1], // left + [-0.5, -0.1], // left [-0.5, +0.1], - [-0.1, +0.1] + [-0.1, +0.1], ], star7: [ @@ -410,9 +397,8 @@

Source: visual/ShapeStim.js

[-0.49, -0.11], [-0.19, 0.04], [-0.39, 0.31], - [-0.09, 0.18] - ] - + [-0.09, 0.18], + ], }; @@ -424,13 +410,13 @@

Source: visual/ShapeStim.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_Slider.js.html b/docs/visual_Slider.js.html index 78640eee..ac91c86e 100644 --- a/docs/visual_Slider.js.html +++ b/docs/visual_Slider.js.html @@ -35,16 +35,15 @@

Source: visual/Slider.js

* @license Distributed under the terms of the MIT License */ - -import * as PIXI from 'pixi.js-legacy'; -import {VisualStim} from './VisualStim'; -import {Color} from '../util/Color'; -import {ColorMixin} from '../util/ColorMixin'; -import {WindowMixin} from '../core/WindowMixin'; -import {Clock} from '../util/Clock'; -import * as util from '../util/Util'; -import {PsychoJS} from "../core/PsychoJS"; - +import * as PIXI from "pixi.js-legacy"; +import { PsychoJS } from "../core/PsychoJS.js"; +import { WindowMixin } from "../core/WindowMixin.js"; +import { Clock } from "../util/Clock.js"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; /** * Slider stimulus. @@ -97,9 +96,39 @@

Source: visual/Slider.js

*/ export class Slider extends util.mix(VisualStim).with(ColorMixin, WindowMixin) { - constructor({name, win, pos, size, ori, units, color, markerColor, lineColor, contrast, opacity, style, ticks, labels, granularity, flip, readOnly, font, bold, italic, fontSize, compact, clipMask, autoDraw, autoLog, dependentStims} = {}) + constructor( + { + name, + win, + pos, + size, + ori, + units, + color, + markerColor, + lineColor, + contrast, + opacity, + depth, + style, + ticks, + labels, + granularity, + flip, + readOnly, + font, + bold, + italic, + fontSize, + compact, + clipMask, + autoDraw, + autoLog, + dependentStims, + } = {}, + ) { - super({name, win, units, ori, opacity, pos, size, clipMask, autoDraw, autoLog}); + super({ name, win, units, ori, opacity, depth, pos, size, clipMask, autoDraw, autoLog }); this._needMarkerUpdate = false; @@ -123,119 +152,117 @@

Source: visual/Slider.js

}; this._addAttribute( - 'style', + "style", style, [Slider.Style.RATING], - onChange(true, true, true) + onChange(true, true, true), ); this._addAttribute( - 'ticks', + "ticks", ticks, [1, 2, 3, 4, 5], - onChange(true, true, true) + onChange(true, true, true), ); this._addAttribute( - 'labels', + "labels", labels, [], - onChange(true, true, true) + onChange(true, true, true), ); this._addAttribute( - 'granularity', + "granularity", granularity, 0, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'readOnly', + "readOnly", readOnly, - false + false, ); this._addAttribute( - 'compact', + "compact", compact, false, - this._onChange(true, true) + this._onChange(true, true), ); // font: this._addAttribute( - 'font', + "font", font, - 'Arial', - this._onChange(true, true) + "Arial", + this._onChange(true, true), ); this._addAttribute( - 'fontSize', + "fontSize", fontSize, - (this._units === 'pix') ? 14 : 0.03, - this._onChange(true, true) + (this._units === "pix") ? 14 : 0.03, + this._onChange(true, true), ); this._addAttribute( - 'bold', + "bold", bold, true, - this._onChange(true, true) + this._onChange(true, true), ); this._addAttribute( - 'italic', + "italic", italic, false, - this._onChange(true, true) + this._onChange(true, true), ); this._addAttribute( - 'flip', + "flip", flip, false, - this._onChange(true, true) + this._onChange(true, true), ); // color: this._addAttribute( - 'color', + "color", color, - 'lightgray', - this._onChange(true, false) + "lightgray", + this._onChange(true, false), ); this._addAttribute( - 'lineColor', + "lineColor", lineColor, - 'lightgray', - this._onChange(true, false) + "lightgray", + this._onChange(true, false), ); this._addAttribute( - 'markerColor', + "markerColor", markerColor, - 'red', - this._onChange(true, false) + "red", + this._onChange(true, false), ); this._addAttribute( - 'contrast', + "contrast", contrast, 1.0, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'dependentStims', + "dependentStims", dependentStims, [], - this._onChange(false, false) + this._onChange(false, false), ); - - // slider rating (which might be different from the visible marker rating): - this._addAttribute('rating', undefined); + this._addAttribute("rating", undefined); // visible marker rating (which might be different from the actual rating): - this._addAttribute('markerPos', undefined); + this._addAttribute("markerPos", undefined); // full history of ratings and response times: - this._addAttribute('history', []); + this._addAttribute("history", []); // various graphical components: - this._addAttribute('lineAspectRatio', 0.01); + this._addAttribute("lineAspectRatio", 0.01); // check for attribute conflicts, missing values, etc.: this._sanitizeAttributes(); @@ -252,8 +279,6 @@

Source: visual/Slider.js

} } - - /** * Force a refresh of the stimulus. * @@ -267,8 +292,6 @@

Source: visual/Slider.js

this._needMarkerUpdate = true; } - - /** * Reset the slider. * @@ -277,7 +300,7 @@

Source: visual/Slider.js

*/ reset() { - this.psychoJS.logger.debug('reset Slider: ', this._name); + this.psychoJS.logger.debug("reset Slider: ", this._name); this._markerPos = undefined; this._history = []; @@ -289,13 +312,24 @@

Source: visual/Slider.js

this._needUpdate = true; // the marker should be invisible when markerPos is undefined: - if (typeof this._marker !== 'undefined') + if (typeof this._marker !== "undefined") { this._marker.alpha = 0; } } - + /** + * Query whether or not the marker is currently being dragged. + * + * @name module:visual.Slider#isMarkerDragging + * @function + * @public + * @returns {boolean} whether or not the marker is being dragged + */ + isMarkerDragging() + { + return this._markerDragging; + } /** * Get the current value of the rating. @@ -317,8 +351,6 @@

Source: visual/Slider.js

} } - - /** * Get the response time of the most recent change to the rating. * @@ -339,8 +371,6 @@

Source: visual/Slider.js

} } - - /** * Setter for the readOnly attribute. * @@ -354,7 +384,7 @@

Source: visual/Slider.js

*/ setReadOnly(readOnly = true, log = false) { - const hasChanged = this._setAttribute('readOnly', readOnly, log); + const hasChanged = this._setAttribute("readOnly", readOnly, log); if (hasChanged) { @@ -372,8 +402,6 @@

Source: visual/Slider.js

} } - - /** * Setter for the markerPos attribute. * @@ -399,8 +427,6 @@

Source: visual/Slider.js

} } - - /** * Setter for the rating attribute. * @@ -420,60 +446,51 @@

Source: visual/Slider.js

rating = this._labels[Math.round(rating)]; } - this._setAttribute('rating', rating, log); + this._setAttribute("rating", rating, log); } - - /** Let `borderColor` alias `lineColor` to parallel PsychoPy */ - set borderColor(color) { + set borderColor(color) + { this.lineColor = color; } - - - setBorderColor(color) { + setBorderColor(color) + { this.setLineColor(color); } - - - get borderColor() { + get borderColor() + { return this.lineColor; } - - - getBorderColor() { + getBorderColor() + { return this.getLineColor(); } - /** Let `fillColor` alias `markerColor` to parallel PsychoPy */ - set fillColor(color) { + set fillColor(color) + { this.markerColor = color; } - - - setFillColor(color) { + setFillColor(color) + { this.setMarkerColor(color); } - - - get fillColor() { + get fillColor() + { return this.markerColor; } - - - getFillColor() { + getFillColor() + { return this.getMarkerColor(); } - - /** * Estimate the bounding box. * @@ -489,18 +506,18 @@

Source: visual/Slider.js

{ // setup the slider's style (taking into account the Window dimension, etc.): this._setupStyle(); - + // calculate various values in pixel units: this._tickSize_px = util.to_px(this._tickSize, this._units, this._win); this._fontSize_px = this._getLengthPix(this._fontSize); - this._barSize_px = util.to_px(this._barSize, this._units, this._win, true).map(v => Math.max(1, v)); + this._barSize_px = util.to_px(this._barSize, this._units, this._win, true).map((v) => Math.max(1, v)); this._markerSize_px = util.to_px(this._markerSize, this._units, this._win, true); const pos_px = util.to_px(this._pos, this._units, this._win); const size_px = util.to_px(this._size, this._units, this._win); // calculate the position of the ticks: const tickPositions = this._ratingToPos(this._ticks); - this._tickPositions_px = tickPositions.map(p => util.to_px(p, this._units, this._win)); + this._tickPositions_px = tickPositions.map((p) => util.to_px(p, this._units, this._win)); // left, top, right, bottom limits: const limits_px = [0, 0, size_px[0], size_px[1]]; @@ -517,7 +534,7 @@

Source: visual/Slider.js

for (let l = 0; l < this._labels.length; ++l) { - const tickPositionIndex = Math.round( l / (this._labels.length - 1) * (this._ticks.length - 1) ); + const tickPositionIndex = Math.round(l / (this._labels.length - 1) * (this._ticks.length - 1)); this._labelPositions_px[l] = this._tickPositions_px[tickPositionIndex]; const labelBounds = PIXI.TextMetrics.measureText(this._labels[l].toString(), labelTextStyle); @@ -533,7 +550,7 @@

Source: visual/Slider.js

this._labelPositions_px[l][1] += this._tickSize_px[1]; } - if (this._style.indexOf(Slider.Style.LABELS45) === -1) + if (this._style.indexOf(Slider.Style.LABELS_45) === -1) { this._labelPositions_px[l][0] -= labelBounds.width / 2; if (this._compact) @@ -542,8 +559,10 @@

Source: visual/Slider.js

} // ensure that that labels are not overlapping: - if (prevLabelBounds && - (this._labelPositions_px[l - 1][0] + prevLabelBounds.width + tolerance >= this._labelPositions_px[l][0])) + if ( + prevLabelBounds + && (this._labelPositions_px[l - 1][0] + prevLabelBounds.width + tolerance >= this._labelPositions_px[l][0]) + ) { if (prevNonOverlapOffset === 0) { @@ -603,12 +622,10 @@

Source: visual/Slider.js

this._getLengthUnits(position_px.x + limits_px[0]), this._getLengthUnits(position_px.y + limits_px[1]), this._getLengthUnits(limits_px[2] - limits_px[0]), - this._getLengthUnits(limits_px[3] - limits_px[1]) + this._getLengthUnits(limits_px[3] - limits_px[1]), ); } - - /** * Sanitize the slider attributes: check for attribute conflicts, missing values, etc. * @@ -618,29 +635,73 @@

Source: visual/Slider.js

*/ _sanitizeAttributes() { + this._isSliderStyle = false; + this._frozenMarker = false; + // convert potential string styles into Symbols: - this._style.forEach( (style, index) => + this._style.forEach((style, index) => { - if (typeof style === 'string') + if (typeof style === "string") { this._style[index] = Symbol.for(style.toUpperCase()); } }); - // TODO: only two ticks for SLIDER type, non-empty ticks, that RADIO is also categorical, etc. + // TODO: non-empty ticks, RADIO is also categorical, etc. + + // SLIDER style: two ticks, first one is zero, second one is > 1 + if (this._style.indexOf(Slider.Style.SLIDER) > -1) + { + this._isSliderStyle = true; + + // more than 2 ticks: cut to two + if (this._ticks.length > 2) + { + this.psychoJS.logger.warn(`Slider "${this._name}" has style: SLIDER and more than two ticks. We cut the ticks to 2.`); + this._ticks = this._ticks.slice(0, 2); + } + + // less than 2 ticks: error + if (this._ticks.length < 2) + { + throw { + origin: "Slider._sanitizeAttributes", + context: "when sanitizing the attributes of Slider: " + this._name, + error: "less than 2 ticks were given for a slider of type: SLIDER" + } + } + + // first tick different from zero: change it to zero + if (this._ticks[0] !== 0) + { + this.psychoJS.logger.warn(`Slider "${this._name}" has style: SLIDER but the first tick is not 0. We changed it to 0.`); + this._ticks[0] = 0; + } + + // second tick smaller than 1: change it to 1 + if (this._ticks[1] < 1) + { + this.psychoJS.logger.warn(`Slider "${this._name}" has style: SLIDER but the second tick is less than 1. We changed it to 1.`); + this._ticks[1] = 1; + } + + // second tick is 1: the marker is frozen + if (this._ticks[1] === 1) + { + this._frozenMarker = true; + } + + } // deal with categorical sliders: this._isCategorical = (this._ticks.length === 0); if (this._isCategorical) { - this._ticks = [...Array(this._labels.length)].map( (_, i) => i ); + this._ticks = [...Array(this._labels.length)].map((_, i) => i); this._granularity = 1.0; } - } - - /** * Set the current rating. * @@ -656,7 +717,7 @@

Source: visual/Slider.js

recordRating(rating, responseTime = undefined, log = false) { // get response time: - if (typeof responseTime === 'undefined') + if (typeof responseTime === "undefined") { responseTime = this._responseClock.getTime(); } @@ -667,15 +728,14 @@

Source: visual/Slider.js

this.setRating(rating, log); // add rating and response time to history: - this._history.push({rating: this._rating, responseTime}); - this.psychoJS.logger.debug('record a new rating: ', this._rating, 'with response time: ', responseTime, 'for Slider: ', this._name); + this._history.push({ rating: this._rating, responseTime }); + this.psychoJS.logger.debug("record a new rating: ", this._rating, "with response time: ", responseTime, "for Slider: ", this._name); // update slider: this._needMarkerUpdate = true; this._needUpdate = true; } - /** * Update the stimulus, if necessary. * @@ -697,10 +757,11 @@

Source: visual/Slider.js

this._pixi.scale.x = 1; this._pixi.scale.y = -1; - this._pixi.rotation = this._ori * Math.PI / 180; + this._pixi.rotation = -this._ori * Math.PI / 180; this._pixi.position = this._getPosition_px(); this._pixi.alpha = this._opacity; + this._pixi.zIndex = this._depth; // make sure that the dependent Stimuli are also updated: for (const dependentStim of this._dependentStims) @@ -709,7 +770,6 @@

Source: visual/Slider.js

} } - /** * Estimate the position of the slider, taking the compactness into account. * @@ -719,9 +779,11 @@

Source: visual/Slider.js

*/ _getPosition_px() { - const position = util.to_pixiPoint(this.pos, this.units, this.win, true); - if (this._compact && - (this._style.indexOf(Slider.Style.RADIO) > -1 || this._style.indexOf(Slider.Style.RATING) > -1)) + const position = to_pixiPoint(this.pos, this.units, this.win, true); + if ( + this._compact + && (this._style.indexOf(Slider.Style.RADIO) > -1 || this._style.indexOf(Slider.Style.RATING) > -1) + ) { if (this._isHorizontal()) { @@ -736,8 +798,6 @@

Source: visual/Slider.js

return position; } - - /** * Update the position of the marker if necessary. * @@ -752,12 +812,12 @@

Source: visual/Slider.js

} this._needMarkerUpdate = false; - if (typeof this._marker !== 'undefined') + if (typeof this._marker !== "undefined") { - if (typeof this._markerPos !== 'undefined') + if (typeof this._markerPos !== "undefined") { const visibleMarkerPos = this._ratingToPos([this._markerPos]); - this._marker.position = util.to_pixiPoint(visibleMarkerPos[0], this.units, this.win, true); + this._marker.position = to_pixiPoint(visibleMarkerPos[0], this.units, this.win, true); this._marker.alpha = 1; } else @@ -767,8 +827,6 @@

Source: visual/Slider.js

} } - - /** * Setup the PIXI components of the slider (bar, ticks, labels, marker, etc.). * @@ -786,17 +844,15 @@

Source: visual/Slider.js

this._setupStyle(); - // calculate various values in pixel units: this._tickSize_px = util.to_px(this._tickSize, this._units, this._win); this._fontSize_px = this._getLengthPix(this._fontSize); - this._barSize_px = util.to_px(this._barSize, this._units, this._win, true).map(v => Math.max(1, v)); + this._barSize_px = util.to_px(this._barSize, this._units, this._win, true).map((v) => Math.max(1, v)); this._markerSize_px = util.to_px(this._markerSize, this._units, this._win, true); const tickPositions = this._ratingToPos(this._ticks); - this._tickPositions_px = tickPositions.map(p => util.to_px(p, this._units, this._win)); - + this._tickPositions_px = tickPositions.map((p) => util.to_px(p, this._units, this._win)); - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { this._pixi.destroy(true); } @@ -809,7 +865,6 @@

Source: visual/Slider.js

this._body.interactive = true; this._pixi.addChild(this._body); - // ensure that pointer events will be captured along the slider body, even outside of // marker and labels: if (this._tickType === Slider.Shape.DISC) @@ -819,7 +874,8 @@

Source: visual/Slider.js

-this._barSize_px[0] / 2 - maxTickSize_px, -this._barSize_px[1] / 2 - maxTickSize_px, this._barSize_px[0] + maxTickSize_px * 2, - this._barSize_px[1] + maxTickSize_px * 2); + this._barSize_px[1] + maxTickSize_px * 2, + ); } else { @@ -827,7 +883,8 @@

Source: visual/Slider.js

-this._barSize_px[0] / 2 - this._tickSize_px[0] / 2, -this._barSize_px[1] / 2 - this._tickSize_px[1] / 2, this._barSize_px[0] + this._tickSize_px[0], - this._barSize_px[1] + this._tickSize_px[1]); + this._barSize_px[1] + this._tickSize_px[1], + ); } // central bar: @@ -843,8 +900,6 @@

Source: visual/Slider.js

this._setupMarker(); } - - /** * Setup the central bar. * @@ -857,7 +912,7 @@

Source: visual/Slider.js

if (this._barLineWidth_px > 0) { this._body.lineStyle(this._barLineWidth_px, this._barLineColor.int, 1, 0.5); - if (typeof this._barFillColor !== 'undefined') + if (typeof this._barFillColor !== "undefined") { this._body.beginFill(this._barFillColor.int, 1); } @@ -865,17 +920,15 @@

Source: visual/Slider.js

Math.round(-this._barSize_px[0] / 2), Math.round(-this._barSize_px[1] / 2), Math.round(this._barSize_px[0]), - Math.round(this._barSize_px[1]) + Math.round(this._barSize_px[1]), ); - if (typeof this._barFillColor !== 'undefined') + if (typeof this._barFillColor !== "undefined") { this._body.endFill(); } } } - - /** * Setup the marker, and the associated mouse events. * @@ -885,7 +938,7 @@

Source: visual/Slider.js

*/ _setupMarker() { -/* this is now deprecated and replaced by _body.hitArea + /* this is now deprecated and replaced by _body.hitArea // transparent rectangle necessary to capture pointer events outside of marker and labels: const eventCaptureRectangle = new PIXI.Graphics(); eventCaptureRectangle.beginFill(0, 0); @@ -897,7 +950,7 @@

Source: visual/Slider.js

); eventCaptureRectangle.endFill(); this._pixi.addChild(eventCaptureRectangle); -*/ + */ // marker: this._marker = new PIXI.Graphics(); @@ -948,13 +1001,13 @@

Source: visual/Slider.js

} else if (this._markerType === Slider.Shape.BOX) { - this._marker.lineStyle(1, this.getContrastedColor(this._markerColor, 0.5).int, 1, 0.5); + this._marker.lineStyle(1, this.getContrastedColor(this._markerColor, 0.5).int, 1, 0); this._marker.beginFill(this._markerColor.int, 1); this._marker.drawRect( Math.round(-this._markerSize_px[0] / 2), Math.round(-this._markerSize_px[1] / 2), this._markerSize_px[0], - this._markerSize_px[1] + this._markerSize_px[1], ); this._marker.endFill(); @@ -962,7 +1015,6 @@

Source: visual/Slider.js

// this._marker.drawCircle(0, 0, this._markerSize_px[0] / 3); } - // marker mouse events: const self = this; self._markerDragging = false; @@ -992,9 +1044,12 @@

Source: visual/Slider.js

{ self._markerDragging = false; - const mouseLocalPos_px = event.data.getLocalPosition(self._pixi); - const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); - self.recordRating(rating); + if (!this._frozenMarker) + { + const mouseLocalPos_px = event.data.getLocalPosition(self._pixi); + const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); + self.recordRating(rating); + } event.stopPropagation(); } @@ -1005,12 +1060,15 @@

Source: visual/Slider.js

{ if (self._markerDragging) { - const mouseLocalPos_px = event.data.getLocalPosition(self._pixi); - const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); - self.recordRating(rating); - self._markerDragging = false; + if (!this._frozenMarker) + { + const mouseLocalPos_px = event.data.getLocalPosition(self._pixi); + const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); + self.recordRating(rating); + } + event.stopPropagation(); } }; @@ -1020,15 +1078,17 @@

Source: visual/Slider.js

{ if (self._markerDragging) { - const mouseLocalPos_px = event.data.getLocalPosition(self._pixi); - const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); - self.setMarkerPos(rating); + if (!this._frozenMarker) + { + const mouseLocalPos_px = event.data.getLocalPosition(self._pixi); + const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); + self.setMarkerPos(rating); + } event.stopPropagation(); } }; - // (*) slider mouse events outside of marker // note: this only works thanks to eventCaptureRectangle /* not quite right just yet (as of May 2020) @@ -1054,15 +1114,38 @@

Source: visual/Slider.js

this._pixi.pointerup = this._pixi.mouseup = this._pixi.touchend = (event) => { - const mouseLocalPos_px = event.data.getLocalPosition(self._body); - const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); - self.recordRating(rating); + if (!this._frozenMarker) + { + const mouseLocalPos_px = event.data.getLocalPosition(self._body); + const rating = self._posToRating([mouseLocalPos_px.x, mouseLocalPos_px.y]); + self.recordRating(rating); + } event.stopPropagation(); }; - } + // mouse wheel over slider: + if (this._isSliderStyle) + { + self._pointerIsOver = false; + + this._pixi.pointerover = this._pixi.mouseover = (event) => + { + self._pointerIsOver = true; + event.stopPropagation(); + } + this._pixi.pointerout = this._pixi.mouseout = (event) => + { + self._pointerIsOver = false; + event.stopPropagation(); + } + + /*renderer.view.addEventListener("wheel", (event) => + { + }*/ + } + } /** * Setup the ticks. @@ -1099,8 +1182,6 @@

Source: visual/Slider.js

} } - - /** * Get the PIXI Text Style applied to the PIXI.Text labels. * @@ -1111,23 +1192,21 @@

Source: visual/Slider.js

_getTextStyle() { this._fontSize_px = this._getLengthPix(this._fontSize); - + return new PIXI.TextStyle({ fontFamily: this._font, fontSize: Math.round(this._fontSize_px), - fontWeight: (this._bold) ? 'bold' : 'normal', - fontStyle: (this._italic) ? 'italic' : 'normal', + fontWeight: (this._bold) ? "bold" : "normal", + fontStyle: (this._italic) ? "italic" : "normal", fill: this.getContrastedColor(this._labelColor, this._contrast).hex, - align: 'center', + align: "center", }); } - - /** * Setup the labels. * - * @name module:visual.Slider#_setupTicks + * @name module:visual.Slider#_setupLabels * @function * @private */ @@ -1140,7 +1219,7 @@

Source: visual/Slider.js

const labelText = new PIXI.Text(this._labels[l], labelTextStyle); labelText.position.x = this._labelPositions_px[l][0]; labelText.position.y = this._labelPositions_px[l][1]; - labelText.rotation = (this._ori + this._labelOri) * Math.PI / 180; + labelText.rotation = -(this._ori + this._labelOri) * Math.PI / 180; labelText.anchor = this._labelAnchor; labelText.alpha = 1; @@ -1148,8 +1227,6 @@

Source: visual/Slider.js

} } - - /** * Apply a particular style to the slider. * @@ -1185,7 +1262,8 @@

Source: visual/Slider.js

this._tickType = Slider.Shape.LINE; this._tickColor = (!skin.TICK_COLOR) ? new Color(this._lineColor) : skin.TICK_COLOR; - if (this.markerColor === undefined) { + if (this.markerColor === undefined) + { this.markerColor = skin.MARKER_COLOR; } @@ -1198,7 +1276,6 @@

Source: visual/Slider.js

this._labelOri = 0; - // rating: if (this._style.indexOf(Slider.Style.RATING) > -1) { @@ -1211,8 +1288,8 @@

Source: visual/Slider.js

this._markerType = Slider.Shape.TRIANGLE; if (!this._skin.MARKER_SIZE) { - this._markerSize = this._markerSize.map(s => s * 2); - } + this._markerSize = this._markerSize.map((s) => s * 2); + } } // slider: @@ -1221,9 +1298,9 @@

Source: visual/Slider.js

this._markerType = Slider.Shape.BOX; if (!this._skin.MARKER_SIZE) { - this._markerSize = (this._isHorizontal()) ? - [this._size[0] / (this._ticks[this._ticks.length - 1] - this._ticks[0]), this._size[1]] : - [this._size[0], this._size[1] / (this._ticks[this._ticks.length - 1] - this._ticks[0])]; + this._markerSize = (this._isHorizontal()) + ? [this._size[0] / (this._ticks[this._ticks.length - 1] - this._ticks[0]), this._size[1]] + : [this._size[0], this._size[1] / (this._ticks[this._ticks.length - 1] - this._ticks[0])]; } this._barSize = [this._size[0], this._size[1]]; this._barFillColor = this.getContrastedColor(new Color(this.color), 0.5); @@ -1241,7 +1318,7 @@

Source: visual/Slider.js

*/ // labels45: - if (this._style.indexOf(Slider.Style.LABELS45) > -1) + if (this._style.indexOf(Slider.Style.LABELS_45) > -1) { this._labelOri = -45; if (this._flip) @@ -1262,12 +1339,10 @@

Source: visual/Slider.js

if (!this._skin.MARKER_SIZE) { - this._markerSize = this._markerSize.map(s => s * 0.7); + this._markerSize = this._markerSize.map((s) => s * 0.7); + } } } - } - - /** * Convert an array of ratings into an array of [x,y] positions (in Slider units, with 0 at the center of the Slider) @@ -1287,20 +1362,22 @@

Source: visual/Slider.js

// in compact mode the circular markers of RADIO sliders must fit within the width: if (this._compact && this._style.indexOf(Slider.Style.RADIO) > -1) { - return ratings.map(v => [ - ((v - this._ticks[0]) / range) * (this._size[0] - this._tickSize[1]*2) - - (this._size[0] / 2) + this._tickSize[1], - 0]); + return ratings.map((v) => [ + ((v - this._ticks[0]) / range) * (this._size[0] - this._tickSize[1] * 2) + - (this._size[0] / 2) + this._tickSize[1], + 0, + ]); } else if (this._style.indexOf(Slider.Style.SLIDER) > -1) { - return ratings.map(v => [ + return ratings.map((v) => [ ((v - this._ticks[0]) / range - 0.5) * (this._size[0] - this._markerSize[0]), - 0]); + 0, + ]); } else { - return ratings.map(v => [((v - this._ticks[0]) / range - 0.5) * this._size[0], 0]); + return ratings.map((v) => [((v - this._ticks[0]) / range - 0.5) * this._size[0], 0]); } } else @@ -1308,25 +1385,26 @@

Source: visual/Slider.js

// in compact mode the circular markers of RADIO sliders must fit within the height: if (this._compact && this._style.indexOf(Slider.Style.RADIO) > -1) { - return ratings.map(v => [0, - ((v - this._ticks[0]) / range) * (this._size[1] - this._tickSize[0]*2) - - (this._size[1] / 2) + this._tickSize[0]]); + return ratings.map((v) => [ + 0, + ((v - this._ticks[0]) / range) * (this._size[1] - this._tickSize[0] * 2) + - (this._size[1] / 2) + this._tickSize[0], + ]); } else if (this._style.indexOf(Slider.Style.SLIDER) > -1) { - return ratings.map(v => [ + return ratings.map((v) => [ 0, - ((v - this._ticks[0]) / range - 0.5) * (this._size[1] - this._markerSize[1])]); + ((v - this._ticks[0]) / range - 0.5) * (this._size[1] - this._markerSize[1]), + ]); } else { - return ratings.map(v => [0, (1.0 - (v - this._ticks[0]) / range - 0.5) * this._size[1]]); + return ratings.map((v) => [0, (1.0 - (v - this._ticks[0]) / range - 0.5) * this._size[1]]); } } } - - /** * Convert a [x,y] position, in pixel units, relative to the slider, into a rating. * @@ -1357,7 +1435,14 @@

Source: visual/Slider.js

{ if (this._style.indexOf(Slider.Style.SLIDER) > -1) { - return (pos_px[1] / (size_px[1] - markerSize_px[1]) + 0.5) * range + this._ticks[0]; + if (size_px[1] === markerSize_px[1]) + { + + } + else + { + return (pos_px[1] / (size_px[1] - markerSize_px[1]) + 0.5) * range + this._ticks[0]; + } } else { @@ -1366,8 +1451,6 @@

Source: visual/Slider.js

} } - - /** * Determine whether the slider is horizontal. * @@ -1383,8 +1466,6 @@

Source: visual/Slider.js

return (this._size[0] > this._size[1]); } - - /** * Calculate the rating once granularity has been taken into account. * @@ -1396,7 +1477,7 @@

Source: visual/Slider.js

*/ _granularise(rating) { - if (typeof rating === 'undefined') + if (typeof rating === "undefined") { return undefined; } @@ -1409,10 +1490,8 @@

Source: visual/Slider.js

return rating; } - } - /** * Shape of the marker and of the ticks. * @@ -1422,13 +1501,12 @@

Source: visual/Slider.js

* @public */ Slider.Shape = { - DISC: Symbol.for('DISC'), - TRIANGLE: Symbol.for('TRIANGLE'), - LINE: Symbol.for('LINE'), - BOX: Symbol.for('BOX') + DISC: Symbol.for("DISC"), + TRIANGLE: Symbol.for("TRIANGLE"), + LINE: Symbol.for("LINE"), + BOX: Symbol.for("BOX"), }; - /** * Styles. * @@ -1438,15 +1516,14 @@

Source: visual/Slider.js

* @public */ Slider.Style = { - RATING: Symbol.for('RATING'), - TRIANGLE_MARKER: Symbol.for('TRIANGLEMARKER'), - SLIDER: Symbol.for('SLIDER'), - WHITE_ON_BLACK: Symbol.for('WHITEONBLACK'), - LABELS45: Symbol.for('LABELS45'), - RADIO: Symbol.for('RADIO') + RATING: Symbol.for("RATING"), + TRIANGLE_MARKER: Symbol.for("TRIANGLE_MARKER"), + SLIDER: Symbol.for("SLIDER"), + WHITE_ON_BLACK: Symbol.for("WHITE_ON_BLACK"), + LABELS_45: Symbol.for("LABELS_45"), + RADIO: Symbol.for("RADIO"), }; - /** * Skin. * @@ -1460,17 +1537,17 @@

Source: visual/Slider.js

Slider.Skin = { MARKER_SIZE: null, STANDARD: { - MARKER_COLOR: new Color('red'), + MARKER_COLOR: new Color("red"), BAR_LINE_COLOR: null, TICK_COLOR: null, - LABEL_COLOR: null + LABEL_COLOR: null, }, WHITE_ON_BLACK: { - MARKER_COLOR: new Color('white'), - BAR_LINE_COLOR: new Color('black'), - TICK_COLOR: new Color('black'), - LABEL_COLOR: new Color('black') - } + MARKER_COLOR: new Color("white"), + BAR_LINE_COLOR: new Color("black"), + TICK_COLOR: new Color("black"), + LABEL_COLOR: new Color("black"), + }, }; @@ -1482,13 +1559,13 @@

Source: visual/Slider.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_TextBox.js.html b/docs/visual_TextBox.js.html index 3fe12652..3225ed2c 100644 --- a/docs/visual_TextBox.js.html +++ b/docs/visual_TextBox.js.html @@ -29,20 +29,19 @@

Source: visual/TextBox.js

/**
  * Editable TextBox Stimulus.
  *
- * @author Alain Pitiot
+ * @author Alain Pitiot, Nikita Agafonov
  * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
  * @license Distributed under the terms of the MIT License
  */
 
-
-import * as PIXI from 'pixi.js-legacy';
-import {VisualStim} from './VisualStim';
-import {Color} from '../util/Color';
-import {ColorMixin} from '../util/ColorMixin';
-import {TextInput} from './TextInput';
-import {ButtonStim} from './ButtonStim.js';
-import * as util from '../util/Util';
+import * as PIXI from "pixi.js-legacy";
+import { Color } from "../util/Color.js";
+import { ColorMixin } from "../util/ColorMixin.js";
+import * as util from "../util/Util.js";
+import { ButtonStim } from "./ButtonStim.js";
+import { TextInput } from "./TextInput.js";
+import { VisualStim } from "./VisualStim.js";
 
 // TODO finish documenting all options
 /**
@@ -57,7 +56,7 @@ 

Source: visual/TextBox.js

* @param {string} [options.font= "Arial"] - the font family * @param {Array.<number>} [options.pos= [0, 0]] - the position of the center of the text * - * @param {Color} [options.color= Color('white')] the background color + * @param {Color} [options.color= Color('white')] color of the text * @param {number} [options.opacity= 1.0] - the opacity * @param {number} [options.depth= 0] - the depth (i.e. the z order) * @param {number} [options.contrast= 1.0] - the contrast @@ -68,132 +67,165 @@

Source: visual/TextBox.js

* @param {boolean} [options.italic= false] - whether or not the text is italic * @param {string} [options.anchor = 'left'] - horizontal alignment * - * @param {boolean} [options.multiline= false] - whether or not a textarea is used + * @param {boolean} [options.multiline= false] - whether or not a multiline element is used * @param {boolean} [options.autofocus= true] - whether or not the first input should receive focus by default * @param {boolean} [options.flipHoriz= false] - whether or not to flip the text horizontally * @param {boolean} [options.flipVert= false] - whether or not to flip the text vertically + * @param {Color} [options.fillColor= undefined] - fill color of the text-box + * @param {Color} [options.borderColor= undefined] - border color of the text-box * @param {PIXI.Graphics} [options.clipMask= null] - the clip mask * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip * @param {boolean} [options.autoLog= false] - whether or not to log + * @param {boolean} [options.fitToContent = false] - whether or not to resize itself automaitcally to fit to the text content */ export class TextBox extends util.mix(VisualStim).with(ColorMixin) { - constructor({name, win, pos, anchor, size, units, ori, opacity, depth, text, font, letterHeight, bold, italic, alignment, color, contrast, flipHoriz, flipVert, fillColor, borderColor, borderWidth, padding, editable, multiline, autofocus, clipMask, autoDraw, autoLog} = {}) + constructor( + { + name, + win, + pos, + anchor, + size, + units, + ori, + opacity, + depth, + text, + font, + letterHeight, + bold, + italic, + alignment, + color, + contrast, + flipHoriz, + flipVert, + fillColor, + borderColor, + borderWidth, + padding, + editable, + multiline, + autofocus, + clipMask, + autoDraw, + autoLog, + fitToContent + } = {}, + ) { - super({name, win, pos, size, units, ori, opacity, depth, clipMask, autoDraw, autoLog}); + super({ name, win, pos, size, units, ori, opacity, depth, clipMask, autoDraw, autoLog }); this._addAttribute( - 'text', + "text", text, - '', - this._onChange(true, true) + "" ); this._addAttribute( - 'placeholder', + "placeholder", text, - '', - this._onChange(true, true) - ); + "", + this._onChange(true, true), + ); this._addAttribute( - 'anchor', + "anchor", anchor, - 'center', - this._onChange(false, true) + "center" ); this._addAttribute( - 'flipHoriz', + "flipHoriz", flipHoriz, false, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'flipVert', + "flipVert", flipVert, false, - this._onChange(false, false) + this._onChange(false, false), ); // font: this._addAttribute( - 'font', + "font", font, - 'Arial', - this._onChange(true, true) + "Arial" ); this._addAttribute( - 'letterHeight', + "letterHeight", letterHeight, - this._getDefaultLetterHeight(), - this._onChange(true, true) + this._getDefaultLetterHeight() ); this._addAttribute( - 'bold', + "bold", bold, false, - this._onChange(true, true) + this._onChange(true, true), ); this._addAttribute( - 'italic', + "italic", italic, false, - this._onChange(true, true) + this._onChange(true, true), ); this._addAttribute( - 'alignment', + "alignment", alignment, - 'left', - this._onChange(true, true) + "left" ); // colors: this._addAttribute( - 'color', + "color", color, - 'white', - this._onChange(true, false) + undefined ); this._addAttribute( - 'fillColor', + "fillColor", fillColor, - 'lightgrey', - this._onChange(true, false) + undefined ); this._addAttribute( - 'borderColor', + "borderColor", borderColor, - this.fillColor, - this._onChange(true, false) + undefined ); this._addAttribute( - 'contrast', + "contrast", contrast, 1.0, - this._onChange(true, false) + this._onChange(true, false), ); // default border width: 1px this._addAttribute( - 'borderWidth', + "borderWidth", borderWidth, - util.to_unit([1, 0], 'pix', win, this._units)[0], - this._onChange(true, true) + util.to_unit([1, 0], "pix", win, this._units)[0], + this._onChange(true, true), ); // default padding: half of the letter height this._addAttribute( - 'padding', + "padding", padding, this._letterHeight / 2.0, - this._onChange(true, true) + this._onChange(true, true), ); - this._addAttribute('multiline', multiline, false, this._onChange(true, true)); - this._addAttribute('editable', editable, false, this._onChange(true, true)); - this._addAttribute('autofocus', autofocus, true, this._onChange(true, false)); - // this._setAttribute({ - // name: 'vertices', - // value: vertices, - // assert: v => (v != null) && (typeof v !== 'undefined') && Array.isArray(v) ) - // log); + this._addAttribute("multiline", multiline, false, this._onChange(true, true)); + this._addAttribute("editable", editable, false, this._onChange(true, true)); + this._addAttribute("autofocus", autofocus, true, this._onChange(true, false)); + // this._setAttribute({ + // name: 'vertices', + // value: vertices, + // assert: v => (v != null) && (typeof v !== 'undefined') && Array.isArray(v) ) + // log); + + this._addAttribute("fitToContent", fitToContent, false); + // setting size again since fitToContent field becomes available only at this point + // and setSize called from super class would not have a proper effect + this.setSize(size); // estimate the bounding box: this._estimateBoundingBox(); @@ -204,7 +236,6 @@

Source: visual/TextBox.js

} } - /** * Clears the current text value or sets it back to match the placeholder. * @@ -213,13 +244,9 @@

Source: visual/TextBox.js

*/ reset() { - const text = this.editable ? '' : this.placeholder; - this.setText(this.placeholder); } - - /** * Clears the current text value. * @@ -231,7 +258,39 @@

Source: visual/TextBox.js

this.setText(); } + /** + * Setter for the alignment attribute. + * + * @name module:visual.TextBox#setAlignment + * @public + * @param {boolean} alignment - alignment of the text + * @param {boolean} [log= false] - whether or not to log + */ + setAlignment(alignment = "left", log = false) + { + this._setAttribute("alignment", alignment, log); + if (this._pixi !== undefined) { + this._pixi.setInputStyle("textAlign", alignment); + } + } + /** + * Setter for the anchor attribute. + * + * @name module:visual.TextBox#setAnchor + * @public + * @param {boolean} anchor - anchor of the textbox + * @param {boolean} [log= false] - whether or not to log + */ + setAnchor (anchor = "center", log = false) + { + this._setAttribute("anchor", anchor, log); + if (this._pixi !== undefined) { + const anchorUnits = this._getAnchor(); + this._pixi.anchor.x = anchorUnits[0]; + this._pixi.anchor.y = anchorUnits[1]; + } + } /** * For tweaking the underlying input value. @@ -240,9 +299,9 @@

Source: visual/TextBox.js

* @public * @param {string} text */ - setText(text = '') + setText(text = "") { - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { this._pixi.text = text; } @@ -250,6 +309,38 @@

Source: visual/TextBox.js

this._text = text; } + /** + * Set the font for textbox. + * + * @name module:visual.TextBox#setFont + * @public + * @param {string} text + */ + setFont(font = "Arial", log = false) + { + this._setAttribute("font", font, log); + if (this._pixi !== undefined) + { + this._pixi.setInputStyle("fontFamily", font); + } + } + + /** + * Set letterHeight (font size) for textbox. + * + * @name module:visual.TextBox#setLetterHeight + * @public + * @param {string} text + */ + setLetterHeight(fontSize = this._getDefaultLetterHeight(), log = false) + { + this._setAttribute("letterHeight", fontSize, log); + const fontSize_px = this._getLengthPix(fontSize); + if (this._pixi !== undefined) + { + this._pixi.setInputStyle("fontSize", `${fontSize_px}px`); + } + } /** * For accessing the underlying input value. @@ -260,7 +351,7 @@

Source: visual/TextBox.js

*/ getText() { - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { return this._pixi.text; } @@ -268,6 +359,69 @@

Source: visual/TextBox.js

return this._text; } + /** + * Setter for the color attribute. + * + * @name module:visual.TextBox#setColor + * @public + * @param {boolean} color - color of the text + * @param {boolean} [log= false] - whether or not to log + */ + setColor (color, log = false) + { + this._setAttribute('color', color, log); + this._needUpdate = true; + this._needPixiUpdate = true; + } + + /** + * Setter for the fillColor attribute. + * + * @name module:visual.TextBox#setFillColor + * @public + * @param {boolean} fillColor - fill color of the text box + * @param {boolean} [log= false] - whether or not to log + */ + setFillColor (fillColor, log = false) + { + this._setAttribute('fillColor', fillColor, log); + this._needUpdate = true; + this._needPixiUpdate = true; + } + + /** + * Setter for the borderColor attribute. + * + * @name module:visual.TextBox#setBorderColor + * @public + * @param {Color} borderColor - border color of the text box + * @param {boolean} [log= false] - whether or not to log + */ + setBorderColor (borderColor, log = false) + { + this._setAttribute('borderColor', borderColor, log); + this._needUpdate = true; + this._needPixiUpdate = true; + } + + /** + * Setter for the fitToContent attribute. + * + * @name module:visual.TextBox#setFitToContent + * @public + * @param {boolean} fitToContent - whether or not to autoresize textbox to fit to text content + * @param {boolean} [log= false] - whether or not to log + */ + setFitToContent (fitToContent, log = false) + { + this._setAttribute("fitToContent", fitToContent, log); + const width_px = Math.abs(Math.round(this._getLengthPix(this._size[0]))); + const height_px = Math.abs(Math.round(this._getLengthPix(this._size[1]))); + if (this._pixi !== undefined) { + this._pixi.setInputStyle("width", fitToContent ? "auto" : `${width_px}px`); + this._pixi.setInputStyle("height", fitToContent ? "auto" : `${height_px}px`); + } + } /** * Setter for the size attribute. @@ -275,31 +429,33 @@

Source: visual/TextBox.js

* @name module:visual.TextBox#setSize * @public * @param {boolean} size - whether or not to wrap the text at the given width - * @param {boolean} [log= false] - whether of not to log + * @param {boolean} [log= false] - whether or not to log */ setSize(size, log) { // test with the size is undefined, or [undefined, undefined]: let isSizeUndefined = ( - (typeof size === 'undefined') || (size === null) || - ( Array.isArray(size) && size.every( v => typeof v === 'undefined' || v === null) ) - ); + (typeof size === "undefined") || (size === null) + || (Array.isArray(size) && size.every((v) => typeof v === "undefined" || v === null)) + ); + + this.fitToContent = isSizeUndefined; if (isSizeUndefined) { size = TextBox._defaultSizeMap.get(this._units); - if (typeof size === 'undefined') + if (typeof size === "undefined") { throw { - origin: 'TextBox.setSize', - context: 'when setting the size of TextBox: ' + this._name, - error: 'no default size for unit: ' + this._units + origin: "TextBox.setSize", + context: "when setting the size of TextBox: " + this._name, + error: "no default size for unit: " + this._units, }; } } - const hasChanged = this._setAttribute('size', size, log); + const hasChanged = this._setAttribute("size", size, log); if (hasChanged) { @@ -311,7 +467,21 @@

Source: visual/TextBox.js

} } - + /** + * Add event listeners to text-box object. Method is called internally upon object construction. + * + * @name module:visual.TextBox#_addEventListeners + * @protected + */ + _addEventListeners () + { + this._pixi.on("input", (textContent) => { + this._text = textContent; + let size = [this._pixi.width, this._pixi.height]; + size = util.to_unit(size, "pix", this._win, this._units); + this._setAttribute("size", size, false); + }); + } /** * Get the default letter height given the stimulus' units. @@ -324,20 +494,18 @@

Source: visual/TextBox.js

{ const height = TextBox._defaultLetterHeightMap.get(this._units); - if (typeof height === 'undefined') + if (typeof height === "undefined") { throw { - origin: 'TextBox._getDefaultLetterHeight', - context: 'when getting the default height of TextBox: ' + this._name, - error: 'no default letter height for unit: ' + this._units + origin: "TextBox._getDefaultLetterHeight", + context: "when getting the default height of TextBox: " + this._name, + error: "no default letter height for unit: " + this._units, }; } return height; } - - /** * Get the TextInput options applied to the PIXI.TextInput. * @@ -348,32 +516,40 @@

Source: visual/TextBox.js

{ const letterHeight_px = Math.round(this._getLengthPix(this._letterHeight)); const padding_px = Math.round(this._getLengthPix(this._padding)); - const width_px = Math.round(this._getLengthPix(this._size[0])); const borderWidth_px = Math.round(this._getLengthPix(this._borderWidth)); + const width_px = Math.round(this._getLengthPix(this._size[0])); const height_px = Math.round(this._getLengthPix(this._size[1])); - const multiline = this._multiline; return { + // input style properties eventually become CSS, so same syntax applies input: { + display: "inline-block", fontFamily: this._font, - fontSize: letterHeight_px + 'px', - color: new Color(this._color).hex, - fontWeight: (this._bold) ? 'bold' : 'normal', - fontStyle: (this._italic) ? 'italic' : 'normal', - - padding: padding_px + 'px', - multiline, + fontSize: `${letterHeight_px}px`, + color: this._color === undefined || this._color === null ? 'transparent' : new Color(this._color).hex, + fontWeight: (this._bold) ? "bold" : "normal", + fontStyle: (this._italic) ? "italic" : "normal", + textAlign: this._alignment, + padding: `${padding_px}px`, + multiline: this._multiline, text: this._text, - height: multiline ? (height_px - 2 * padding_px) + 'px' : undefined, - width: (width_px - 2 * padding_px) + 'px' + height: this._fitToContent ? "auto" : (this._multiline ? `${height_px}px` : undefined), + width: this._fitToContent ? "auto" : `${width_px}px`, + maxWidth: `${this.win.size[0]}px`, + maxHeight: `${this.win.size[1]}px`, + overflow: "hidden", + pointerEvents: "none" }, + // box style properties eventually become PIXI.Graphics settings, so same syntax applies box: { fill: new Color(this._fillColor).int, + alpha: this._fillColor === undefined || this._fillColor === null ? 0 : 1, rounded: 5, stroke: { color: new Color(this._borderColor).int, - width: borderWidth_px - } + width: borderWidth_px, + alpha: this._borderColor === undefined || this._borderColor === null ? 0 : 1 + }, /*default: { fill: new Color(this._fillColor).int, rounded: 5, @@ -398,12 +574,10 @@

Source: visual/TextBox.js

width: borderWidth_px } }*/ - } + }, }; } - - /** * Estimate the bounding box. * @@ -423,14 +597,12 @@

Source: visual/TextBox.js

this._pos[0] - anchor[0] * this._size[0], this._pos[1] - anchor[1] * boxHeight, this._size[0], - boxHeight + boxHeight, ); // TODO take the orientation into account } - - /** * Update the stimulus, if necessary. * @@ -452,24 +624,33 @@

Source: visual/TextBox.js

{ this._needPixiUpdate = false; - if (typeof this._pixi !== 'undefined') + let enteredText = ""; + // at this point this._pixi might exist but is removed from the scene, in such cases this._pixi.text + // does not retain the information about new lines etc. so we go with a local copy of entered text + if (this._pixi !== undefined && this._pixi.parent !== null) { + enteredText = this._pixi.text; + } else { + enteredText = this._text; + } + + if (typeof this._pixi !== "undefined") { this._pixi.destroy(true); } - // Get the currently entered text - let enteredText = this._pixi !== undefined? this._pixi.text: ''; - // Create new TextInput + + // Create new TextInput this._pixi = new TextInput(this._getTextInputOptions()); // listeners required for regular textboxes, but may cause problems with button stimuli if (!(this instanceof ButtonStim)) { this._pixi._addListeners(); + this._addEventListeners(); } // check if other TextBox instances are already in focus const { _drawList = [] } = this.psychoJS.window; - const otherTextBoxWithFocus = _drawList.some(item => item instanceof TextBox && item._pixi && item._pixi._hasFocus()); + const otherTextBoxWithFocus = _drawList.some((item) => item instanceof TextBox && item._pixi && item._pixi._hasFocus()); if (this._autofocus && !otherTextBoxWithFocus) { this._pixi._onSurrogateFocus(); @@ -480,7 +661,7 @@

Source: visual/TextBox.js

} if (this._editable) { - this.text = enteredText; + this.text = enteredText; this._pixi.placeholder = this._placeholder; } else @@ -491,13 +672,11 @@

Source: visual/TextBox.js

this._pixi.disabled = !this._editable; - const anchor = this._getAnchor(); - this._pixi.pivot.x = anchor[0] * this._pixi.width; - this._pixi.pivot.y = anchor[1] * this._pixi.height; - + // now when this._pixi is available, setting anchor again to trigger internal to this._pixi mechanisms + this.anchor = this._anchor; this._pixi.scale.x = this._flipHoriz ? -1 : 1; this._pixi.scale.y = this._flipVert ? 1 : -1; - this._pixi.rotation = this._ori * Math.PI / 180; + this._pixi.rotation = -this._ori * Math.PI / 180; [this._pixi.x, this._pixi.y] = util.to_px(this._pos, this._units, this._win); this._pixi.alpha = this._opacity; @@ -507,8 +686,6 @@

Source: visual/TextBox.js

this._pixi.mask = this._clipMask; } - - /** * Convert the anchor attribute into numerical values. * @@ -521,30 +698,27 @@

Source: visual/TextBox.js

{ const anchor = [0.5, 0.5]; - if (this._anchor.indexOf('left') > -1) + if (this._anchor.indexOf("left") > -1) { anchor[0] = 0; } - else if (this._anchor.indexOf('right') > -1) + else if (this._anchor.indexOf("right") > -1) { anchor[0] = 1; } - if (this._anchor.indexOf('top') > -1) + if (this._anchor.indexOf("top") > -1) { anchor[1] = 0; } - else if (this._anchor.indexOf('bottom') > -1) + else if (this._anchor.indexOf("bottom") > -1) { anchor[1] = 1; } return anchor; } - - } - /** * <p>This map associates units to default letter height.</p> * @@ -553,18 +727,17 @@

Source: visual/TextBox.js

* @private */ TextBox._defaultLetterHeightMap = new Map([ - ['cm', 1.0], - ['deg', 1.0], - ['degs', 1.0], - ['degFlatPos', 1.0], - ['degFlat', 1.0], - ['norm', 0.1], - ['height', 0.2], - ['pix', 20], - ['pixels', 20] + ["cm", 1.0], + ["deg", 1.0], + ["degs", 1.0], + ["degFlatPos", 1.0], + ["degFlat", 1.0], + ["norm", 0.1], + ["height", 0.2], + ["pix", 20], + ["pixels", 20], ]); - /** * <p>This map associates units to default sizes.</p> * @@ -573,15 +746,15 @@

Source: visual/TextBox.js

* @private */ TextBox._defaultSizeMap = new Map([ - ['cm', [15.0, -1]], - ['deg', [15.0, -1]], - ['degs', [15.0, -1]], - ['degFlatPos', [15.0, -1]], - ['degFlat', [15.0, -1]], - ['norm', [1, -1]], - ['height', [1, -1]], - ['pix', [500, -1]], - ['pixels', [500, -1]] + ["cm", [15.0, -1]], + ["deg", [15.0, -1]], + ["degs", [15.0, -1]], + ["degFlatPos", [15.0, -1]], + ["degFlat", [15.0, -1]], + ["norm", [1, -1]], + ["height", [1, -1]], + ["pix", [500, -1]], + ["pixels", [500, -1]], ]);
@@ -593,13 +766,13 @@

Source: visual/TextBox.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_TextStim.js.html b/docs/visual_TextStim.js.html index f9c5d69a..8445f10d 100644 --- a/docs/visual_TextStim.js.html +++ b/docs/visual_TextStim.js.html @@ -35,13 +35,12 @@

Source: visual/TextStim.js

* @license Distributed under the terms of the MIT License */ - -import * as PIXI from 'pixi.js-legacy'; -import {VisualStim} from './VisualStim'; -import {Color} from '../util/Color'; -import {ColorMixin} from '../util/ColorMixin'; -import * as util from '../util/Util'; - +import * as PIXI from "pixi.js-legacy"; +import { Color } from "../util/Color.js"; +import { ColorMixin } from "../util/ColorMixin.js"; +import { to_pixiPoint } from "../util/Pixi.js"; +import * as util from "../util/Util.js"; +import { VisualStim } from "./VisualStim.js"; /** * @name module:visual.TextStim @@ -76,9 +75,34 @@

Source: visual/TextStim.js

*/ export class TextStim extends util.mix(VisualStim).with(ColorMixin) { - constructor({name, win, text, font, pos, color, opacity, depth, contrast, units, ori, height, bold, italic, alignHoriz, alignVert, wrapWidth, flipHoriz, flipVert, clipMask, autoDraw, autoLog} = {}) + constructor( + { + name, + win, + text, + font, + pos, + color, + opacity, + depth, + contrast, + units, + ori, + height, + bold, + italic, + alignHoriz, + alignVert, + wrapWidth, + flipHoriz, + flipVert, + clipMask, + autoDraw, + autoLog, + } = {}, + ) { - super({name, win, units, ori, opacity, depth, pos, clipMask, autoDraw, autoLog}); + super({ name, win, units, ori, opacity, depth, pos, clipMask, autoDraw, autoLog }); // callback to deal with text metrics invalidation: const onChange = (withPixi = false, withBoundingBox = false, withMetrics = false) => @@ -96,81 +120,78 @@

Source: visual/TextStim.js

// text and font: this._addAttribute( - 'text', + "text", text, - 'Hello World', - onChange(true, true, true) + "Hello World", + onChange(true, true, true), ); this._addAttribute( - 'alignHoriz', + "alignHoriz", alignHoriz, - 'center', - onChange(true, true, true) + "center", + onChange(true, true, true), ); this._addAttribute( - 'alignVert', + "alignVert", alignVert, - 'center', - onChange(true, true, true) + "center", + onChange(true, true, true), ); this._addAttribute( - 'flipHoriz', + "flipHoriz", flipHoriz, false, - onChange(true, true, true) + onChange(true, true, true), ); this._addAttribute( - 'flipVert', + "flipVert", flipVert, false, - onChange(true, true, true) + onChange(true, true, true), ); this._addAttribute( - 'font', + "font", font, - 'Arial', - this._onChange(true, true) + "Arial", + this._onChange(true, true), ); this._addAttribute( - 'height', + "height", height, this._getDefaultLetterHeight(), - onChange(true, true, true) + onChange(true, true, true), ); this._addAttribute( - 'wrapWidth', + "wrapWidth", wrapWidth, this._getDefaultWrapWidth(), - onChange(true, true, true) + onChange(true, true, true), ); this._addAttribute( - 'bold', + "bold", bold, false, - onChange(true, true, true) + onChange(true, true, true), ); this._addAttribute( - 'italic', + "italic", italic, false, - onChange(true, true, true) + onChange(true, true, true), ); - - // color: this._addAttribute( - 'color', + "color", color, - 'white', - this._onChange(true, false) + "white" + // this._onChange(true, false) ); this._addAttribute( - 'contrast', + "contrast", contrast, 1.0, this._onChange(true, false) ); - // estimate the bounding box (using TextMetrics): this._estimateBoundingBox(); @@ -180,29 +201,38 @@

Source: visual/TextStim.js

} } - - /** * Get the metrics estimated for the text and style. * - * Note: getTextMetrics does not require the PIXI representation of the stimulus to be instantiated, - * unlike getSize(). + * Note: getTextMetrics does not require the PIXI representation of the stimulus + * to be instantiated, unlike getSize(). * * @name module:visual.TextStim#getTextMetrics * @public */ getTextMetrics() { - if (typeof this._textMetrics === 'undefined') + if (typeof this._textMetrics === "undefined") { this._textMetrics = PIXI.TextMetrics.measureText(this._text, this._getTextStyle()); + + // since PIXI.TextMetrics does not give us the actual bounding box of the text + // (e.g. the height is really just the ascent + descent of the font), we use measureText: + const textMetricsCanvas = document.createElement('canvas'); + document.body.appendChild(textMetricsCanvas); + + const ctx = textMetricsCanvas.getContext("2d"); + ctx.font = this._getTextStyle().toFontString(); + ctx.textBaseline = "alphabetic"; + ctx.textAlign = "left"; + this._textMetrics.boundingBox = ctx.measureText(this._text); + + document.body.removeChild(textMetricsCanvas); } return this._textMetrics; } - - /** * Get the default letter height given the stimulus' units. * @@ -214,20 +244,18 @@

Source: visual/TextStim.js

{ const height = TextStim._defaultLetterHeightMap.get(this._units); - if (typeof height === 'undefined') + if (typeof height === "undefined") { throw { - origin: 'TextStim._getDefaultLetterHeight', - context: 'when getting the default height of TextStim: ' + this._name, - error: 'no default letter height for unit: ' + this._units + origin: "TextStim._getDefaultLetterHeight", + context: "when getting the default height of TextStim: " + this._name, + error: "no default letter height for unit: " + this._units, }; } return height; } - - /** * Get the default wrap width given the stimulus' units. * @@ -239,19 +267,83 @@

Source: visual/TextStim.js

{ const wrapWidth = TextStim._defaultWrapWidthMap.get(this._units); - if (typeof wrapWidth === 'undefined') + if (typeof wrapWidth === "undefined") { throw { - origin: 'TextStim._getDefaultWrapWidth', - context: 'when getting the default wrap width of TextStim: ' + this._name, - error: 'no default wrap width for unit: ' + this._units + origin: "TextStim._getDefaultWrapWidth", + context: "when getting the default wrap width of TextStim: " + this._name, + error: "no default wrap width for unit: " + this._units, }; } return wrapWidth; } + /** + * Get the bounding gox. + * + * @name module:visual.TextStim#getBoundingBox + * @function + * @protected + * @param {boolean} [tight= false] - whether or not to fit as closely as possible to the text + * @return {number[]} - the bounding box, in the units of this TextStim + */ + getBoundingBox(tight = false) + { + if (tight) + { + const textMetrics_px = this.getTextMetrics(); + let left_px = this._pos[0] - textMetrics_px.boundingBox.actualBoundingBoxLeft; + let top_px = this._pos[1] + textMetrics_px.fontProperties.descent - textMetrics_px.boundingBox.actualBoundingBoxDescent; + const width_px = textMetrics_px.boundingBox.actualBoundingBoxRight + textMetrics_px.boundingBox.actualBoundingBoxLeft; + const height_px = textMetrics_px.boundingBox.actualBoundingBoxAscent + textMetrics_px.boundingBox.actualBoundingBoxDescent; + + // adjust the bounding box position by taking into account the anchoring of the text: + const boundingBox_px = this._getBoundingBox_px(); + switch (this._alignHoriz) + { + case "left": + // nothing to do + break; + case "right": + // TODO + break; + default: + case "center": + left_px -= (boundingBox_px.width - width_px) / 2; + } + switch (this._alignVert) + { + case "top": + // TODO + break; + case "bottom": + // nothing to do + break; + default: + case "center": + top_px -= (boundingBox_px.height - height_px) / 2; + } + // convert from pixel to this stimulus' units: + const leftTop = util.to_unit( + [left_px, top_px], + "pix", + this._win, + this._units); + const dimensions = util.to_unit( + [width_px, height_px], + "pix", + this._win, + this._units); + + return new PIXI.Rectangle(leftTop[0], leftTop[1], dimensions[0], dimensions[1]); + } + else + { + return this._boundingBox.clone(); + } + } /** * Estimate the bounding box. @@ -265,27 +357,25 @@

Source: visual/TextStim.js

{ // size of the text, irrespective of the orientation: const textMetrics = this.getTextMetrics(); - const textSize = util.to_unit( + const textSize = util.to_unit( [textMetrics.width, textMetrics.height], - 'pix', + "pix", this._win, - this._units + this._units, ); // take the alignment into account: const anchor = this._getAnchor(); this._boundingBox = new PIXI.Rectangle( this._pos[0] - anchor[0] * textSize[0], - this._pos[1] - anchor[1] * textSize[1], + this._pos[1] - textSize[1] - anchor[1] * textSize[1], textSize[0], - textSize[1] + textSize[1], ); // TODO take the orientation into account } - - /** * Get the PIXI Text Style applied to the PIXI.Text * @@ -297,16 +387,36 @@

Source: visual/TextStim.js

return new PIXI.TextStyle({ fontFamily: this._font, fontSize: Math.round(this._getLengthPix(this._height)), - fontWeight: (this._bold) ? 'bold' : 'normal', - fontStyle: (this._italic) ? 'italic' : 'normal', + fontWeight: (this._bold) ? "bold" : "normal", + fontStyle: (this._italic) ? "italic" : "normal", fill: this.getContrastedColor(new Color(this._color), this._contrast).hex, align: this._alignHoriz, - wordWrap: (typeof this._wrapWidth !== 'undefined'), - wordWrapWidth: (typeof this._wrapWidth !== 'undefined') ? this._getHorLengthPix(this._wrapWidth) : 0 + wordWrap: (typeof this._wrapWidth !== "undefined"), + wordWrapWidth: (typeof this._wrapWidth !== "undefined") ? this._getHorLengthPix(this._wrapWidth) : 0, }); } + /** + * Setter for the color attribute. + * + * @name module:visual.TextStim#setColor + * @public + * @param {undefined | null | number} color - the color + * @param {boolean} [log= false] - whether of not to log + */ + setColor(color, log = false) + { + const hasChanged = this._setAttribute("color", color, log); + if (hasChanged) + { + if (typeof this._pixi !== "undefined") + { + this._pixi.style = this._getTextStyle(); + this._needUpdate = true; + } + } + } /** * Update the stimulus, if necessary. @@ -328,11 +438,13 @@

Source: visual/TextStim.js

{ this._needPixiUpdate = false; - if (typeof this._pixi !== 'undefined') + if (typeof this._pixi !== "undefined") { this._pixi.destroy(true); } this._pixi = new PIXI.Text(this._text, this._getTextStyle()); + // TODO is updateText necessary? + // this._pixi.updateText(); } const anchor = this._getAnchor(); @@ -341,8 +453,8 @@

Source: visual/TextStim.js

this._pixi.scale.x = this._flipHoriz ? -1 : 1; this._pixi.scale.y = this._flipVert ? 1 : -1; - this._pixi.rotation = this._ori * Math.PI / 180; - this._pixi.position = util.to_pixiPoint(this.pos, this.units, this.win); + this._pixi.rotation = -this._ori * Math.PI / 180; + this._pixi.position = to_pixiPoint(this.pos, this.units, this.win); this._pixi.alpha = this._opacity; this._pixi.zIndex = this._depth; @@ -350,23 +462,23 @@

Source: visual/TextStim.js

// apply the clip mask: this._pixi.mask = this._clipMask; - // update the size attributes: - this._size = [ - this._getLengthUnits(Math.abs(this._pixi.width)), - this._getLengthUnits(Math.abs(this._pixi.height)) - ]; + // update the size attribute: + this._size = util.to_unit( + [Math.abs(this._pixi.width), Math.abs(this._pixi.height)], + "pix", + this._win, + this._units + ); // refine the estimate of the bounding box: this._boundingBox = new PIXI.Rectangle( this._pos[0] - anchor[0] * this._size[0], - this._pos[1] - anchor[1] * this._size[1], + this._pos[1] - this._size[1] - anchor[1] * this._size[1], this._size[0], - this._size[1] + this._size[1], ); } - - /** * Convert the alignment attributes into an anchor. * @@ -381,36 +493,33 @@

Source: visual/TextStim.js

switch (this._alignHoriz) { - case 'left': + case "left": anchor.push(0); break; - case 'right': + case "right": anchor.push(1); break; default: - case 'center': + case "center": anchor.push(0.5); } switch (this._alignVert) { - case 'top': + case "top": anchor.push(0); break; - case 'bottom': + case "bottom": anchor.push(1); break; default: - case 'center': + case "center": anchor.push(0.5); } return anchor; } - } - - /** * <p>This map associates units to default letter height.</p> * @@ -419,19 +528,17 @@

Source: visual/TextStim.js

* @private */ TextStim._defaultLetterHeightMap = new Map([ - ['cm', 1.0], - ['deg', 1.0], - ['degs', 1.0], - ['degFlatPos', 1.0], - ['degFlat', 1.0], - ['norm', 0.1], - ['height', 0.2], - ['pix', 20], - ['pixels', 20] + ["cm", 1.0], + ["deg", 1.0], + ["degs", 1.0], + ["degFlatPos", 1.0], + ["degFlat", 1.0], + ["norm", 0.1], + ["height", 0.2], + ["pix", 20], + ["pixels", 20], ]); - - /** * <p>This map associates units to default wrap width.</p> * @@ -440,15 +547,15 @@

Source: visual/TextStim.js

* @private */ TextStim._defaultWrapWidthMap = new Map([ - ['cm', 15.0], - ['deg', 15.0], - ['degs', 15.0], - ['degFlatPos', 15.0], - ['degFlat', 15.0], - ['norm', 1], - ['height', 1], - ['pix', 500], - ['pixels', 500] + ["cm", 15.0], + ["deg", 15.0], + ["degs", 15.0], + ["degFlatPos", 15.0], + ["degFlat", 15.0], + ["norm", 1], + ["height", 1], + ["pix", 500], + ["pixels", 500], ]); @@ -460,13 +567,13 @@

Source: visual/TextStim.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/docs/visual_VisualStim.js.html b/docs/visual_VisualStim.js.html index e7a8429c..51f2b340 100644 --- a/docs/visual_VisualStim.js.html +++ b/docs/visual_VisualStim.js.html @@ -35,12 +35,10 @@

Source: visual/VisualStim.js

* @license Distributed under the terms of the MIT License */ - -import * as PIXI from 'pixi.js-legacy'; -import {MinimalStim} from '../core/MinimalStim'; -import {WindowMixin} from '../core/WindowMixin'; -import * as util from '../util/Util'; - +import * as PIXI from "pixi.js-legacy"; +import { MinimalStim } from "../core/MinimalStim.js"; +import { WindowMixin } from "../core/WindowMixin.js"; +import * as util from "../util/Util.js"; /** * Base class for all visual stimuli. @@ -64,55 +62,54 @@

Source: visual/VisualStim.js

*/ export class VisualStim extends util.mix(MinimalStim).with(WindowMixin) { - constructor({name, win, units, ori, opacity, depth, pos, size, clipMask, autoDraw, autoLog} = {}) + constructor({ name, win, units, ori, opacity, depth, pos, size, clipMask, autoDraw, autoLog } = {}) { - super({win, name, autoDraw, autoLog}); + super({ win, name, autoDraw, autoLog }); this._addAttribute( - 'units', + "units", units, - (typeof win !== 'undefined' && win !== null) ? win.units : 'height', - this._onChange(true, true) + (typeof win !== "undefined" && win !== null) ? win.units : "height", + this._onChange(true, true), ); this._addAttribute( - 'pos', + "pos", pos, - [0, 0] + [0, 0], ); this._addAttribute( - 'size', + "size", size, - undefined + undefined, ); this._addAttribute( - 'ori', + "ori", ori, - 0.0 + 0.0, ); this._addAttribute( - 'opacity', + "opacity", opacity, 1.0, - this._onChange(true, false) + this._onChange(true, false), ); this._addAttribute( - 'depth', + "depth", depth, 0, - this._onChange(false, false) + this._onChange(false, false), ); this._addAttribute( - 'clipMask', + "clipMask", clipMask, null, - this._onChange(false, false) + this._onChange(false, false), ); // bounding box of the stimulus, in stimulus units // note: boundingBox does not take the orientation into account - this._addAttribute('boundingBox', PIXI.Rectangle.EMPTY); + this._addAttribute("boundingBox", PIXI.Rectangle.EMPTY); - // the stimulus need to be updated: this._needUpdate = true; @@ -120,8 +117,6 @@

Source: visual/VisualStim.js

this._needPixiUpdate = true; } - - /** * Force a refresh of the stimulus. * @@ -135,8 +130,6 @@

Source: visual/VisualStim.js

this._onChange(true, true)(); } - - /** * Setter for the size attribute. * @@ -148,7 +141,7 @@

Source: visual/VisualStim.js

setSize(size, log = false) { // size is either undefined, null, or a tuple of numbers: - if (typeof size !== 'undefined' && size !== null) + if (typeof size !== "undefined" && size !== null) { size = util.toNumerical(size); if (!Array.isArray(size)) @@ -157,7 +150,7 @@

Source: visual/VisualStim.js

} } - const hasChanged = this._setAttribute('size', size, log); + const hasChanged = this._setAttribute("size", size, log); if (hasChanged) { @@ -165,8 +158,6 @@

Source: visual/VisualStim.js

} } - - /** * Setter for the orientation attribute. * @@ -177,20 +168,24 @@

Source: visual/VisualStim.js

*/ setOri(ori, log = false) { - const hasChanged = this._setAttribute('ori', ori, log); + const hasChanged = this._setAttribute("ori", ori, log); if (hasChanged) { let radians = -ori * 0.017453292519943295; - this._rotationMatrix = [[Math.cos(radians), -Math.sin(radians)], - [Math.sin(radians), Math.cos(radians)]]; - - this._onChange(true, true)(); + this._rotationMatrix = [ + [Math.cos(radians), -Math.sin(radians)], + [Math.sin(radians), Math.cos(radians)] + ]; + + if (this._pixi instanceof PIXI.DisplayObject) { + this._pixi.rotation = -ori * Math.PI / 180; + } else { + this._onChange(true, true)(); + } } } - - /** * Setter for the position attribute. * @@ -202,20 +197,18 @@

Source: visual/VisualStim.js

setPos(pos, log = false) { const prevPos = this._pos; - const hasChanged = this._setAttribute('pos', util.toNumerical(pos), log); + const hasChanged = this._setAttribute("pos", util.toNumerical(pos), log); if (hasChanged) { this._needUpdate = true; - + // update the bounding box, without calling _estimateBoundingBox: this._boundingBox.x += this._pos[0] - prevPos[0]; this._boundingBox.y += this._pos[1] - prevPos[1]; } } - - /** * Determine whether an object is inside the bounding box of the stimulus. * @@ -230,12 +223,12 @@

Source: visual/VisualStim.js

// get the position of the object, in pixel coordinates: const objectPos_px = util.getPositionFromObject(object, units); - if (typeof objectPos_px === 'undefined') + if (typeof objectPos_px === "undefined") { throw { - origin: 'VisualStim.contains', - context: 'when determining whether VisualStim: ' + this._name + ' contains object: ' + util.toString(object), - error: 'unable to determine the position of the object' + origin: "VisualStim.contains", + context: "when determining whether VisualStim: " + this._name + " contains object: " + util.toString(object), + error: "unable to determine the position of the object", }; } @@ -243,8 +236,6 @@

Source: visual/VisualStim.js

return this._getBoundingBox_px().contains(objectPos_px[0], objectPos_px[1]); } - - /** * Estimate the bounding box. * @@ -255,14 +246,12 @@

Source: visual/VisualStim.js

_estimateBoundingBox() { throw { - origin: 'VisualStim._estimateBoundingBox', + origin: "VisualStim._estimateBoundingBox", context: `when estimating the bounding box of visual stimulus: ${this._name}`, - error: 'this method is abstract and should not be called.' + error: "this method is abstract and should not be called.", }; } - - /** * Get the bounding box in pixel coordinates * @@ -273,47 +262,48 @@

Source: visual/VisualStim.js

*/ _getBoundingBox_px() { - if (this._units === 'pix') + if (this._units === "pix") { return this._boundingBox.clone(); } - else if (this._units === 'norm') + else if (this._units === "norm") { return new PIXI.Rectangle( this._boundingBox.x * this._win.size[0] / 2, this._boundingBox.y * this._win.size[1] / 2, this._boundingBox.width * this._win.size[0] / 2, - this._boundingBox.height * this._win.size[1] / 2 + this._boundingBox.height * this._win.size[1] / 2, ); } - else if (this._units === 'height') + else if (this._units === "height") { const minSize = Math.min(this._win.size[0], this._win.size[1]); return new PIXI.Rectangle( this._boundingBox.x * minSize, this._boundingBox.y * minSize, this._boundingBox.width * minSize, - this._boundingBox.height * minSize + this._boundingBox.height * minSize, ); } else { - throw Object.assign(response, {error: `unknown units: ${this._units}`}); + throw Object.assign(response, { error: `unknown units: ${this._units}` }); } } - - /** * Generate a callback that prepares updates to the stimulus. - * This is typically called in the constructor of a stimulus, when attributes are added with _addAttribute. + * This is typically called in the constructor of a stimulus, when attributes are added + * with _addAttribute. * * @name module:visual.VisualStim#_onChange * @function - * @param {boolean} [withPixi = false] - whether or not the PIXI representation must also be updated - * @param {boolean} [withBoundingBox = false] - whether or not to immediately estimate the bounding box - * @return {Function} * @protected + * @param {boolean} [withPixi = false] - whether or not the PIXI representation must + * also be updated + * @param {boolean} [withBoundingBox = false] - whether or not to immediately estimate + * the bounding box + * @return {Function} */ _onChange(withPixi = false, withBoundingBox = false) { @@ -330,7 +320,6 @@

Source: visual/VisualStim.js

} }; } - } @@ -342,13 +331,13 @@

Source: visual/VisualStim.js


- Documentation generated by JSDoc 3.6.7 on Mon Jun 21 2021 07:34:20 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon May 23 2022 12:29:28 GMT+0200 (Central European Summer Time)
diff --git a/src/data/Shelf.js b/src/data/Shelf.js index 82dbd506..562b0e31 100644 --- a/src/data/Shelf.js +++ b/src/data/Shelf.js @@ -16,7 +16,7 @@ import { Scheduler } from "../util/Scheduler.js"; /** *

Shelf handles persistent key/value pairs, which are stored in the shelf collection on the - * server, and accesses in a safe, concurrent fashion.

+ * server, and accessed in a safe, concurrent fashion.

* * @name module:data.Shelf * @class @@ -569,7 +569,7 @@ export class Shelf extends PsychObject } /** - * Check whether it is possible to run shelf commands. + * Check whether it is possible to run a given shelf command. * * @name module:data.Shelf#_checkAvailability * @function diff --git a/src/sound/AudioClip.js b/src/sound/AudioClip.js index da15d496..f21dd0fd 100644 --- a/src/sound/AudioClip.js +++ b/src/sound/AudioClip.js @@ -187,7 +187,7 @@ export class AudioClip extends PsychObject * @param {Symbol} options.engine - the speech-to-text engine * @param {String} options.languageCode - the BCP-47 language code for the recognition, * e.g. 'en-GB' - * @return {Promise<>} a promise resolving to the transcript and associated + * @return {Promise} a promise resolving to the transcript and associated * transcription confidence */ async transcribe({ engine, languageCode } = {}) @@ -241,7 +241,7 @@ export class AudioClip extends PsychObject * * @param {String} transcriptionKey - the secret key to the Google service * @param {String} languageCode - the BCP-47 language code for the recognition, e.g. 'en-GB' - * @return {Promise<>} a promise resolving to the transcript and associated + * @return {Promise} a promise resolving to the transcript and associated * transcription confidence */ _GoogleTranscribe(transcriptionKey, languageCode) diff --git a/src/visual/Camera.js b/src/visual/Camera.js index b9d74098..b14b0448 100644 --- a/src/visual/Camera.js +++ b/src/visual/Camera.js @@ -34,10 +34,6 @@ import {ExperimentHandler} from "../data/ExperimentHandler.js"; */ export class Camera extends PsychObject { - /** - * @constructor - * @public - */ constructor({win, name, format, showDialog, dialogMsg = "Please wait a few moments while the camera initialises", clock, autoLog} = {}) { super(win._psychoJS); @@ -86,7 +82,6 @@ export class Camera extends PsychObject return (this._recorder !== null); } - /** * Get the underlying video stream. * @@ -100,7 +95,6 @@ export class Camera extends PsychObject return this._stream; } - /** * Get a video element pointing to the Camera stream. * @@ -135,7 +129,6 @@ export class Camera extends PsychObject return video; } - /** * Submit a request to start the recording. * @@ -192,7 +185,6 @@ export class Camera extends PsychObject } - /** * Submit a request to stop the recording. * @@ -237,7 +229,6 @@ export class Camera extends PsychObject } } - /** * Submit a request to pause the recording. * @@ -285,7 +276,6 @@ export class Camera extends PsychObject } } - /** * Submit a request to resume the recording. * @@ -343,7 +333,6 @@ export class Camera extends PsychObject } } - /** * Submit a request to flush the recording. * @@ -373,7 +362,6 @@ export class Camera extends PsychObject } } - /** * Offer the audio recording to the participant as a video file to download. * @@ -394,7 +382,6 @@ export class Camera extends PsychObject document.body.removeChild(anchor); } - /** * Upload the video recording to the pavlovia server. * @@ -439,7 +426,6 @@ export class Camera extends PsychObject dialogMsg}); } - /** * Get the current video recording as a VideoClip in the given format. * @@ -460,7 +446,6 @@ export class Camera extends PsychObject // TODO } - /** * Callback for changes to the recording settings. * @@ -482,7 +467,6 @@ export class Camera extends PsychObject this.start(); } - /** * Prepare the recording. * From 3b0308a77fc18d95251adf3b220e02260ab91a6c Mon Sep 17 00:00:00 2001 From: Alain Pitiot Date: Mon, 23 May 2022 12:34:15 +0200 Subject: [PATCH 41/92] DOC quick fix to index.html --- docs/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/index.html b/docs/index.html index 5a1e925a..8b419301 100644 --- a/docs/index.html +++ b/docs/index.html @@ -43,7 +43,7 @@

-

PsychoJS

+

Automated Test (short) Automated Test (full) Contributor Covenant

@@ -112,4 +112,4 @@

Home

Modules

@@ -758,19 +831,23 @@

Source: core/GUI.js

+ + - -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/core_Keyboard.js.html b/docs/core_Keyboard.js.html index 9ccb9b17..0b59d260 100644 --- a/docs/core_Keyboard.js.html +++ b/docs/core_Keyboard.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: core/Keyboard.js - - - + core/Keyboard.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + -

Source: core/Keyboard.js

+ + + + +
+ +

core/Keyboard.js

+ @@ -30,8 +54,8 @@

Source: core/Keyboard.js

* Manager handling the keyboard events. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -41,15 +65,16 @@

Source: core/Keyboard.js

import { PsychoJS } from "./PsychoJS.js"; /** - * @name module:core.KeyPress - * @class - * - * @param {string} code - W3C Key Code - * @param {number} tDown - time of key press (keydown event) relative to the global Monotonic Clock - * @param {string | undefined} name - pyglet key name + * <pKeyPress holds information about a key that has been pressed, such as the duration of the press.</p> */ export class KeyPress { + /** + * @memberof module:core + * @param {string} code - W3C Key Code + * @param {number} tDown - time of key press (keydown event) relative to the global Monotonic Clock + * @param {string | undefined} name - pyglet key name + */ constructor(code, tDown, name) { this.code = code; @@ -67,18 +92,20 @@

Source: core/Keyboard.js

/** * <p>This manager handles all keyboard events. It is a substitute for the keyboard component of EventManager. </p> * - * @name module:core.Keyboard - * @class - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {number} [options.bufferSize= 10000] - the maximum size of the circular keyboard event buffer - * @param {boolean} [options.waitForStart= false] - whether or not to wait for a call to module:core.Keyboard#start - * before recording keyboard events - * @param {Clock} [options.clock= undefined] - an optional clock - * @param {boolean} [options.autoLog= false] - whether or not to log + * @extends PsychObject */ export class Keyboard extends PsychObject { + /** + * @memberof module:core + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {number} [options.bufferSize= 10000] - the maximum size of the circular keyboard event buffer + * @param {boolean} [options.waitForStart= false] - whether or not to wait for a call to module:core.Keyboard#start + * before recording keyboard events + * @param {Clock} [options.clock= undefined] - an optional clock + * @param {boolean} [options.autoLog= false] - whether or not to log + */ constructor({ psychoJS, bufferSize = 10000, @@ -110,11 +137,6 @@

Source: core/Keyboard.js

/** * Start recording keyboard events. - * - * @name module:core.Keyboard#start - * @function - * @public - * */ start() { @@ -123,11 +145,6 @@

Source: core/Keyboard.js

/** * Stop recording keyboard events. - * - * @name module:core.Keyboard#stop - * @function - * @public - * */ stop() { @@ -147,9 +164,6 @@

Source: core/Keyboard.js

* Get the list of those keyboard events still in the buffer, i.e. those that have not been * previously cleared by calls to getKeys with clear = true. * - * @name module:core.Keyboard#getEvents - * @function - * @public * @return {Keyboard.KeyEvent[]} the list of events still in the buffer */ getEvents() @@ -179,9 +193,6 @@

Source: core/Keyboard.js

/** * Get the list of keys pressed or pushed by the participant. * - * @name module:core.Keyboard#getKeys - * @function - * @public * @param {Object} options * @param {string[]} [options.keyList= []]] - the list of keys to consider. If keyList is empty, we consider all keys. * Note that we use pyglet keys here, to make the PsychoJs code more homogeneous with PsychoPy. @@ -328,9 +339,6 @@

Source: core/Keyboard.js

/** * Clear all events and resets the circular buffers. - * - * @name module:core.Keyboard#clearEvents - * @function */ clearEvents() { @@ -347,9 +355,6 @@

Source: core/Keyboard.js

/** * Test whether a list of KeyPress's contains one with a particular name. * - * @name module:core.Keyboard#includes - * @function - * @static * @param {module:core.KeyPress[]} keypressList - list of KeyPress's * @param {string } keyName - pyglet key name, e.g. 'escape', 'left' * @return {boolean} whether or not a KeyPress with the given pyglet key name is present in the list @@ -368,9 +373,7 @@

Source: core/Keyboard.js

/** * Add key listeners to the document. * - * @name module:core.Keyboard#_addKeyListeners - * @function - * @private + * @protected */ _addKeyListeners() { @@ -483,10 +486,8 @@

Source: core/Keyboard.js

/** * Keyboard KeyStatus. * - * @name module:core.Keyboard#KeyStatus * @enum {Symbol} * @readonly - * @public */ Keyboard.KeyStatus = { KEY_DOWN: Symbol.for("KEY_DOWN"), @@ -499,19 +500,23 @@

Source: core/Keyboard.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/core_Logger.js.html b/docs/core_Logger.js.html index a0647e62..e4989fd6 100644 --- a/docs/core_Logger.js.html +++ b/docs/core_Logger.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: core/Logger.js - - - + core/Logger.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + + + + + -

Source: core/Logger.js

+
+ +

core/Logger.js

+ @@ -30,8 +54,8 @@

Source: core/Logger.js

* Logger * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -46,13 +70,14 @@

Source: core/Logger.js

* a remote one, etc.</p> * * <p>Note: we use log4javascript for the console logger, and our own for the server logger.</p> - * - * @name module:core.Logger - * @class - * @param {*} threshold - the logging threshold, e.g. log4javascript.Level.ERROR */ export class Logger { + /** + * @memberof module:core + * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance + * @param {*} threshold - the logging threshold, e.g. log4javascript.Level.ERROR + */ constructor(psychoJS, threshold) { this._psychoJS = psychoJS; @@ -97,8 +122,6 @@

Source: core/Logger.js

/** * Change the logging level. * - * @name module:core.Logger#setLevel - * @public * @param {module:core.Logger.ServerLevel} serverLevel - the new logging level */ setLevel(serverLevel) @@ -110,8 +133,6 @@

Source: core/Logger.js

/** * Log a server message at the EXP level. * - * @name module:core.Logger#exp - * @public * @param {string} msg - the message to be logged. * @param {number} [time] - the logging time * @param {object} [obj] - the associated object (e.g. a Trial) @@ -124,8 +145,6 @@

Source: core/Logger.js

/** * Log a server message at the DATA level. * - * @name module:core.Logger#data - * @public * @param {string} msg - the message to be logged. * @param {number} [time] - the logging time * @param {object} [obj] - the associated object (e.g. a Trial) @@ -138,8 +157,6 @@

Source: core/Logger.js

/** * Log a server message. * - * @name module:core.Logger#log - * @public * @param {string} msg - the message to be logged. * @param {module:core.Logger.ServerLevel} level - logging level * @param {number} [time] - the logging time @@ -178,9 +195,7 @@

Source: core/Logger.js

/** * Check whether or not a log messages must be throttled. * - * @name module:core.Logger#_throttle * @protected - * * @param {number} time - the time of the latest log message * @return {boolean} whether or not to log the message */ @@ -256,9 +271,6 @@

Source: core/Logger.js

* * <p>Note: the logs are compressed using Pako's zlib algorithm. * See https://github.com/nodeca/pako for details.</p> - * - * @name module:core.Logger#flush - * @public */ async flush() { @@ -324,8 +336,7 @@

Source: core/Logger.js

/** * Create a custom console layout. * - * @name module:core.Logger#_customConsoleLayout - * @private + * @protected * @return {*} the custom layout */ _customConsoleLayout() @@ -396,7 +407,6 @@

Source: core/Logger.js

/** * Get the integer value associated with a logging level. * - * @name module:core.Logger#_getValue * @protected * @param {module:core.Logger.ServerLevel} level - the logging level * @return {number} - the value associated with the logging level, or 30 is the logging level is unknown. @@ -411,10 +421,8 @@

Source: core/Logger.js

/** * Server logging level. * - * @name module:core.Logger#ServerLevel * @enum {Symbol} * @readonly - * @public * * @note These are similar to PsychoPy's logging levels, as defined in logging.py */ @@ -434,7 +442,6 @@

Source: core/Logger.js

* * <p>We use those values to determine whether a log is to be sent to the server or not.</p> * - * @name module:core.Logger#_ServerLevelValue * @enum {number} * @readonly * @protected @@ -456,19 +463,23 @@

Source: core/Logger.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/core_MinimalStim.js.html b/docs/core_MinimalStim.js.html index 7cacab35..cd401daf 100644 --- a/docs/core_MinimalStim.js.html +++ b/docs/core_MinimalStim.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: core/MinimalStim.js - - - + core/MinimalStim.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + -

Source: core/MinimalStim.js

+ + + + +
+ +

core/MinimalStim.js

+ @@ -30,8 +54,8 @@

Source: core/MinimalStim.js

* Base class for all stimuli. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.0 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -42,17 +66,18 @@

Source: core/MinimalStim.js

/** * <p>MinimalStim is the base class for all stimuli.</p> * - * @name module:core.MinimalStim - * @class * @extends PsychObject - * @param {Object} options - * @param {String} options.name - the name used when logging messages from this stimulus - * @param {module:core.Window} options.win - the associated Window - * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip - * @param {boolean} [options.autoLog= win.autoLog] - whether or not to log */ export class MinimalStim extends PsychObject { + /** + * @memberof module:core + * @param {Object} options + * @param {String} options.name - the name used when logging messages from this stimulus + * @param {module:core.Window} options.win - the associated Window + * @param {boolean} [options.autoDraw= false] - whether or not the stimulus should be automatically drawn on every frame flip + * @param {boolean} [options.autoLog= win.autoLog] - whether to log + */ constructor({ name, win, autoDraw, autoLog } = {}) { super(win._psychoJS, name); @@ -83,11 +108,8 @@

Source: core/MinimalStim.js

/** * Setter for the autoDraw attribute. * - * @name module:core.MinimalStim#setAutoDraw - * @function - * @public * @param {boolean} autoDraw - the new value - * @param {boolean} [log= false] - whether or not to log + * @param {boolean} [log= false] - whether to log */ setAutoDraw(autoDraw, log = false) { @@ -107,10 +129,6 @@

Source: core/MinimalStim.js

/** * Draw this stimulus on the next frame draw. - * - * @name module:core.MinimalStim#draw - * @function - * @public */ draw() { @@ -151,10 +169,6 @@

Source: core/MinimalStim.js

/** * Hide this stimulus on the next frame draw. - * - * @name module:core.MinimalStim#hide - * @function - * @public */ hide() { @@ -178,10 +192,7 @@

Source: core/MinimalStim.js

/** * Determine whether an object is inside this stimulus. * - * @name module:core.MinimalStim#contains - * @function * @abstract - * @public * @param {Object} object - the object * @param {String} units - the stimulus units */ @@ -197,11 +208,7 @@

Source: core/MinimalStim.js

/** * Release the PIXI representation, if there is one. * - * @name module:core.MinimalStim#release - * @function - * @public - * - * @param {boolean} [log= false] - whether or not to log + * @param {boolean} [log= false] - whether to log */ release(log = false) { @@ -220,10 +227,8 @@

Source: core/MinimalStim.js

* * Note: this is an abstract function, which should not be called. * - * @name module:core.MinimalStim#_updateIfNeeded - * @function * @abstract - * @private + * @protected */ _updateIfNeeded() { @@ -241,19 +246,23 @@

Source: core/MinimalStim.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/core_Mouse.js.html b/docs/core_Mouse.js.html index ae96891c..b5a83620 100644 --- a/docs/core_Mouse.js.html +++ b/docs/core_Mouse.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: core/Mouse.js - - - + core/Mouse.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + -

Source: core/Mouse.js

+ + + + +
+ +

core/Mouse.js

+ @@ -31,8 +55,8 @@

Source: core/Mouse.js

* * @author Alain Pitiot * @author Sotiri Bakagiannis - isPressedIn - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -44,18 +68,17 @@

Source: core/Mouse.js

* <p>This manager handles the interactions between the experiment's stimuli and the mouse.</p> * <p>Note: the unit of Mouse is that of its associated Window.</p> * - * @name module:core.Mouse - * @class - * @extends PsychObject - * @param {Object} options - * @param {String} options.name - the name used when logging messages from this stimulus - * @param {Window} options.win - the associated Window - * @param {boolean} [options.autoLog= true] - whether or not to log - * * @todo visible is not handled at the moment (mouse is always visible) */ export class Mouse extends PsychObject { + /** + * @memberof module:core + * @param {Object} options + * @param {String} options.name - the name used when logging messages from this stimulus + * @param {Window} options.win - the associated Window + * @param {boolean} [options.autoLog= true] - whether or not to log + */ constructor({ name, win, @@ -82,9 +105,6 @@

Source: core/Mouse.js

/** * Get the current position of the mouse in mouse/Window units. * - * @name module:core.Mouse#getPos - * @function - * @public * @return {Array.number} the position of the mouse in mouse/Window units */ getPos() @@ -107,9 +127,6 @@

Source: core/Mouse.js

* Get the position of the mouse relative to that at the last call to getRel * or getPos, in mouse/Window units. * - * @name module:core.Mouse#getRel - * @function - * @public * @return {Array.number} the relation position of the mouse in mouse/Window units. */ getRel() @@ -133,9 +150,6 @@

Source: core/Mouse.js

* <p>Note: Even though this method returns a [x, y] array, for most wheels/systems y is the only * value that varies.</p> * - * @name module:core.Mouse#getWheelRel - * @function - * @public * @return {Array.number} the mouse scroll wheel travel */ getWheelRel() @@ -155,9 +169,6 @@

Source: core/Mouse.js

* * <p>Note: clickReset is typically called at stimulus onset. When the participant presses a button, the time elapsed since the clickReset is stored internally and can be accessed any time afterwards with getPressed.</p> * - * @name module:core.Mouse#getPressed - * @function - * @public * @param {boolean} [getTime= false] whether or not to also return timestamps * @return {Array.number | Array.<Array.number>} either an array of size 3 with the status (1 for pressed, 0 for released) of each mouse button [left, center, right], or a tuple with that array and another array of size 3 with the timestamps. */ @@ -178,9 +189,6 @@

Source: core/Mouse.js

/** * Helper method for checking whether a stimulus has had any button presses within bounds. * - * @name module:core.Mouse#isPressedIn - * @function - * @public * @param {object|module:visual.VisualStim} shape A type of visual stimulus or object having a `contains()` method. * @param {object|number} [buttons] The target button index potentially tucked inside an object. * @param {object} [options] @@ -250,9 +258,6 @@

Source: core/Mouse.js

* <li>mouseMoved(distance, [x: number, y: number]: artifically set the previous mouse position to the given coordinates and determine whether the mouse moved further than the given distance</li> * </ul></p> * - * @name module:core.Mouse#mouseMoved - * @function - * @public * @param {undefined|number|Array.number} [distance] - the distance to which the mouse movement is compared (see above for a full description) * @param {boolean|String|Array.number} [reset= false] - see above for a full description * @return {boolean} see above for a full description @@ -347,9 +352,6 @@

Source: core/Mouse.js

/** * Get the amount of time elapsed since the last mouse movement. * - * @name module:core.Mouse#mouseMoveTime - * @function - * @public * @return {number} the time elapsed since the last mouse movement */ mouseMoveTime() @@ -360,9 +362,6 @@

Source: core/Mouse.js

/** * Reset the clocks associated to the given mouse buttons. * - * @name module:core.Mouse#clickReset - * @function - * @public * @param {Array.number} [buttons= [0,1,2]] the buttons to reset (0: left, 1: center, 2: right) */ clickReset(buttons = [0, 1, 2]) @@ -382,19 +381,23 @@

Source: core/Mouse.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/core_PsychoJS.js.html b/docs/core_PsychoJS.js.html index 2078f272..de12cdfd 100644 --- a/docs/core_PsychoJS.js.html +++ b/docs/core_PsychoJS.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: core/PsychoJS.js - - - + core/PsychoJS.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + -

Source: core/PsychoJS.js

+ + + + +
+ +

core/PsychoJS.js

+ @@ -31,8 +55,8 @@

Source: core/PsychoJS.js

* Main component of the PsychoJS library. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -49,18 +73,11 @@

Source: core/PsychoJS.js

import {Shelf} from "../data/Shelf"; /** - * <p>PsychoJS manages the lifecycle of an experiment. It initialises the PsychoJS library and its various components (e.g. the {@link ServerManager}, the {@link EventManager}), and is used by the experiment to schedule the various tasks.</p> - * - * @class - * @param {Object} options - * @param {boolean} [options.debug= true] whether or not to log debug information in the browser console - * @param {boolean} [options.collectIP= false] whether or not to collect the IP information of the participant + * <p>PsychoJS initialises the library and its various components (e.g. the [ServerManager]{@link module:core.ServerManager}, the [EventManager]{@link module:core.EventManager}), and manages + * the lifecycle of an experiment.</p> */ export class PsychoJS { - /** - * Properties - */ get status() { return this._status; @@ -143,8 +160,9 @@

Source: core/PsychoJS.js

} /** - * @constructor - * @public + * @param {Object} options + * @param {boolean} [options.debug= true] whether to log debug information in the browser console + * @param {boolean} [options.collectIP= false] whether to collect the IP information of the participant */ constructor({ debug = true, @@ -204,7 +222,7 @@

Source: core/PsychoJS.js

} this.logger.info("[PsychoJS] Initialised."); - this.logger.info("[PsychoJS] @version 2022.2.0"); + this.logger.info("[PsychoJS] @version 2022.2.1"); // hide the initialisation message: const root = document.getElementById("root"); @@ -240,8 +258,6 @@

Source: core/PsychoJS.js

* @param {boolean} [options.waitBlanking] whether or not to wait for all rendering operations to be done * before flipping * @throws {Object.<string, *>} exception if a window has already been opened - * - * @public */ openWindow({ name, @@ -291,9 +307,8 @@

Source: core/PsychoJS.js

/** * Schedule a task. * - * @param task - the task to be scheduled - * @param args - arguments for that task - * @public + * @param {module:util.Scheduler~Task} task - the task to be scheduled + * @param {*} args - arguments for that task */ schedule(task, args) { @@ -310,9 +325,8 @@

Source: core/PsychoJS.js

* Schedule a series of task based on a condition. * * @param {PsychoJS.condition} condition - * @param {Scheduler} thenScheduler scheduler to run if the condition is true - * @param {Scheduler} elseScheduler scheduler to run if the condition is false - * @public + * @param {Scheduler} thenScheduler - scheduler to run if the condition is true + * @param {Scheduler} elseScheduler - scheduler to run if the condition is false */ scheduleCondition(condition, thenScheduler, elseScheduler) { @@ -340,8 +354,6 @@

Source: core/PsychoJS.js

* @param {string} [options.expName=UNKNOWN] - the name of the experiment * @param {Object.<string, *>} [options.expInfo] - additional information about the experiment * @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources - * @async - * @public */ async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [], dataFileName } = {}) { @@ -452,7 +464,6 @@

Source: core/PsychoJS.js

* local to index.html unless they are prepended with a protocol.</li> * * @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources - * @public */ waitForResources(resources = []) { @@ -476,7 +487,6 @@

Source: core/PsychoJS.js

* Make the attributes of the given object those of window, such that they become global. * * @param {Object.<string, *>} obj the object whose attributes are to become global - * @public */ importAttributes(obj) { @@ -502,9 +512,7 @@

Source: core/PsychoJS.js

* * @param {Object} options * @param {string} [options.message] - optional message to be displayed in a dialog box before quitting - * @param {boolean} [options.isCompleted = false] - whether or not the participant has completed the experiment - * @async - * @public + * @param {boolean} [options.isCompleted = false] - whether the participant has completed the experiment */ async quit({ message, isCompleted = false } = {}) { @@ -512,6 +520,7 @@

Source: core/PsychoJS.js

this._experiment.experimentEnded = true; this._status = PsychoJS.Status.FINISHED; + const isServerEnv = this.getEnvironment() === ExperimentHandler.Environment.SERVER; try { @@ -519,28 +528,32 @@

Source: core/PsychoJS.js

this._scheduler.stop(); // remove the beforeunload listener: - if (this.getEnvironment() === ExperimentHandler.Environment.SERVER) + if (isServerEnv) { window.removeEventListener("beforeunload", this.beforeunloadCallback); } // save the results and the logs of the experiment: - this.gui.dialog({ - warning: "Closing the session. Please wait a few moments.", - showOK: false, + this.gui.finishDialog({ + text: "Terminating the experiment. Please wait a few moments...", + nbSteps: 2 + ((isServerEnv) ? 1 : 0) }); + if (isCompleted || this._config.experiment.saveIncompleteResults) { if (!this._serverMsg.has("__noOutput")) { + this.gui.finishDialogNextStep("saving results"); await this._experiment.save(); + this.gui.finishDialogNextStep("saving logs"); await this._logger.flush(); } } // close the session: - if (this.getEnvironment() === ExperimentHandler.Environment.SERVER) + if (isServerEnv) { + this.gui.finishDialogNextStep("closing the session"); await this._serverManager.closeSession(isCompleted); } @@ -575,6 +588,7 @@

Source: core/PsychoJS.js

} }, }); + } catch (error) { @@ -586,7 +600,6 @@

Source: core/PsychoJS.js

/** * Configure PsychoJS for the running experiment. * - * @async * @protected * @param {string} configURL - the URL of the configuration file * @param {string} name - the name of the experiment @@ -737,7 +750,6 @@

Source: core/PsychoJS.js

/** * Capture all errors and display them in a pop-up error box. - * * @protected */ _captureErrors() @@ -784,7 +796,7 @@

Source: core/PsychoJS.js

/** * Make the various Status top level, in order to accommodate PsychoPy's Code Components. - * @private + * @protected */ _makeStatusTopLevel() { @@ -800,7 +812,6 @@

Source: core/PsychoJS.js

* * @enum {Symbol} * @readonly - * @public * * @note PsychoPy is currently moving away from STOPPED and replacing STOPPED by FINISHED. * For backward compatibility reasons, we are keeping @@ -824,19 +835,23 @@

Source: core/PsychoJS.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/core_ServerManager.js.html b/docs/core_ServerManager.js.html index 4350597c..916654ea 100644 --- a/docs/core_ServerManager.js.html +++ b/docs/core_ServerManager.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: core/ServerManager.js - - - + core/ServerManager.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + + + + + -

Source: core/ServerManager.js

+
+ +

core/ServerManager.js

+ @@ -30,8 +54,8 @@

Source: core/ServerManager.js

* Manager responsible for the communication between the experiment running in the participant's browser and the pavlovia.org server. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -47,25 +71,26 @@

Source: core/ServerManager.js

* <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 * @extends PsychObject - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {boolean} [options.autoLog= false] - whether or not to log */ export class ServerManager extends PsychObject { - /**************************************************************************** + /** * Used to indicate to the ServerManager that all resources must be registered (and * subsequently downloaded) * - * @type {symbol} + * @type {Symbol} * @readonly * @public */ static ALL_RESOURCES = Symbol.for("ALL_RESOURCES"); + /** + * @memberof module:core + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {boolean} [options.autoLog= false] - whether or not to log + */ constructor({ psychoJS, autoLog = false, @@ -86,21 +111,17 @@

Source: core/ServerManager.js

this._addAttribute("status", ServerManager.Status.READY); } - /**************************************************************************** + /** * @typedef ServerManager.GetConfigurationPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.<string, *>} [config] the configuration * @property {Object.<string, *>} [error] an error message if we could not read the configuration file */ - /**************************************************************************** + /** * Read the configuration file for the experiment. * - * @name module:core.ServerManager#getConfiguration - * @function - * @public * @param {string} configURL - the URL of the configuration file - * * @returns {Promise<ServerManager.GetConfigurationPromise>} the response */ getConfiguration(configURL) @@ -147,19 +168,16 @@

Source: core/ServerManager.js

}); } - /**************************************************************************** + /** * @typedef ServerManager.OpenSessionPromise * @property {string} origin the calling method * @property {string} context the context * @property {string} [token] the session token * @property {Object.<string, *>} [error] an error message if we could not open the session */ - /**************************************************************************** + /** * Open a session for this experiment on the remote PsychoJS manager. * - * @name module:core.ServerManager#openSession - * @function - * @public * @returns {Promise<ServerManager.OpenSessionPromise>} the response */ openSession() @@ -185,24 +203,16 @@

Source: core/ServerManager.js

{ try { - const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/experiments/${this._psychoJS.config.gitlab.projectId}/sessions`; + const postResponse = await this._queryServerAPI( + "POST", + `experiments/${this._psychoJS.config.gitlab.projectId}/sessions`, + data, + "FORM" + ); - const response = await fetch(url, { - method: 'POST', - mode: 'cors', - cache: 'no-cache', - credentials: 'same-origin', - redirect: 'follow', - referrerPolicy: 'no-referrer', - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(data) - }); - - const openSessionResponse = await response.json(); + const openSessionResponse = await postResponse.json(); - if (openSessionResponse.status !== 200) + if (postResponse.status !== 200) { throw ('error' in openSessionResponse) ? openSessionResponse.error : openSessionResponse; } @@ -240,7 +250,6 @@

Source: core/ServerManager.js

self.setStatus(ServerManager.Status.READY); resolve({...response, token: openSessionResponse.token, status: openSessionResponse.status }); - } catch (error) { @@ -248,71 +257,18 @@

Source: core/ServerManager.js

self.setStatus(ServerManager.Status.ERROR); reject({...response, error}); } - - /*jQuery.post(url, data, null, "json") - .done((data, textStatus) => - { - if (!("token" in data)) - { - self.setStatus(ServerManager.Status.ERROR); - reject(Object.assign(response, { error: "unexpected answer from server: no token" })); - // reject({...response, error: 'unexpected answer from server: no token'}); - } - if (!("experiment" in data)) - { - self.setStatus(ServerManager.Status.ERROR); - // reject({...response, error: 'unexpected answer from server: no experiment'}); - reject(Object.assign(response, { error: "unexpected answer from server: no experiment" })); - } - - self._psychoJS.config.session = { - token: data.token, - status: "OPEN", - }; - self._psychoJS.config.experiment.status = data.experiment.status2; - self._psychoJS.config.experiment.saveFormat = Symbol.for(data.experiment.saveFormat); - self._psychoJS.config.experiment.saveIncompleteResults = data.experiment.saveIncompleteResults; - self._psychoJS.config.experiment.license = data.experiment.license; - self._psychoJS.config.experiment.runMode = data.experiment.runMode; - - // secret keys for various services, e.g. Google Speech API - if ("keys" in data.experiment) - { - self._psychoJS.config.experiment.keys = data.experiment.keys; - } - else - { - self._psychoJS.config.experiment.keys = []; - } - - self.setStatus(ServerManager.Status.READY); - // resolve({ ...response, token: data.token, status: data.status }); - resolve(Object.assign(response, { token: data.token, status: data.status })); - }) - .fail((jqXHR, textStatus, errorThrown) => - { - self.setStatus(ServerManager.Status.ERROR); - - const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error("error:", errorMsg); - - reject(Object.assign(response, { error: errorMsg })); - });*/ }); } - /**************************************************************************** + /** * @typedef ServerManager.CloseSessionPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.<string, *>} [error] an error message if we could not close the session (e.g. if it has not previously been opened) */ - /**************************************************************************** + /** * Close the session for this experiment on the remote PsychoJS manager. * - * @name module:core.ServerManager#closeSession - * @function - * @public * @param {boolean} [isCompleted= false] - whether or not the experiment was completed * @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner * @returns {Promise<ServerManager.CloseSessionPromise> | void} the response @@ -323,78 +279,61 @@

Source: core/ServerManager.js

origin: "ServerManager.closeSession", context: "when closing the session for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.debug("closing the session for experiment: " + this._psychoJS.config.experiment.name); this.setStatus(ServerManager.Status.BUSY); - // prepare DELETE query: - const url = this._psychoJS.config.pavlovia.URL - + "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId - + "/sessions/" + this._psychoJS.config.session.token; - - // synchronous query the pavlovia server: + // synchronously query the pavlovia server: if (sync) { - /* This is now deprecated in most browsers. - const request = new XMLHttpRequest(); - request.open("DELETE", url, false); - request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); - request.send(JSON.stringify(data)); - */ - /* This does not work in Chrome because of a CORS bug - await fetch(url, { - method: 'DELETE', - headers: { 'Content-Type': 'application/json;charset=UTF-8' }, - body: JSON.stringify(data), - // keepalive makes it possible for the request to outlive the page (e.g. when the participant closes the tab) - keepalive: true - }); - */ + const url = this._psychoJS.config.pavlovia.URL + + "/api/v2/experiments/" + this._psychoJS.config.gitlab.projectId + + "/sessions/" + this._psychoJS.config.session.token + "/delete"; const formData = new FormData(); formData.append("isCompleted", isCompleted); - navigator.sendBeacon(url + "/delete", formData); + + navigator.sendBeacon(url, formData); this._psychoJS.config.session.status = "CLOSED"; } // asynchronously query the pavlovia server: else { const self = this; - return new Promise((resolve, reject) => + return new Promise(async (resolve, reject) => { - jQuery.ajax({ - url, - type: "delete", - data: { isCompleted }, - dataType: "json", - }) - .done((data, textStatus) => - { - self.setStatus(ServerManager.Status.READY); - self._psychoJS.config.session.status = "CLOSED"; + try + { + const deleteResponse = await this._queryServerAPI( + "DELETE", + `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}`, + { isCompleted }, + "FORM" + ); - // resolve({ ...response, data }); - resolve(Object.assign(response, { data })); - }) - .fail((jqXHR, textStatus, errorThrown) => - { - self.setStatus(ServerManager.Status.ERROR); + const closeSessionResponse = await deleteResponse.json(); - const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error("error:", errorMsg); + if (deleteResponse.status !== 200) + { + throw ('error' in closeSessionResponse) ? closeSessionResponse.error : closeSessionResponse; + } - reject(Object.assign(response, { error: errorMsg })); - }); + self.setStatus(ServerManager.Status.READY); + self._psychoJS.config.session.status = "CLOSED"; + resolve({ ...response, ...closeSessionResponse }); + } + catch (error) + { + console.error(error); + self.setStatus(ServerManager.Status.ERROR); + reject({...response, error}); + } }); } } - /**************************************************************************** + /** * Get the value of a resource. * - * @name module:core.ServerManager#getResource - * @function - * @public * @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 @@ -428,7 +367,7 @@

Source: core/ServerManager.js

return pathStatusData.data; } - /**************************************************************************** + /** * Get the status of a single resource or the reduced status of an array of resources. * * <p>If an array of resources is given, getResourceStatus returns a single, reduced status @@ -443,11 +382,8 @@

Source: core/ServerManager.js

* </ul> * </p> * - * @name module:core.ServerManager#getResourceStatus - * @function - * @public * @param {string | string[]} names names of the resources whose statuses are requested - * @return {core.ServerManager.ResourceStatus} status of the resource if there is only one, or reduced status otherwise + * @return {module:core.ServerManager.ResourceStatus} status of the resource if there is only one, or reduced status otherwise * @throws {Object.<string, *>} if at least one of the names is not that of a previously * registered resource */ @@ -497,12 +433,8 @@

Source: core/ServerManager.js

return reducedStatus; } - /**************************************************************************** + /** * Set the resource manager status. - * - * @name module:core.ServerManager#setStatus - * @function - * @public */ setStatus(status) { @@ -530,12 +462,9 @@

Source: core/ServerManager.js

return this._status; } - /**************************************************************************** + /** * Reset the resource manager status to ServerManager.Status.READY. * - * @name module:core.ServerManager#resetStatus - * @function - * @public * @return {ServerManager.Status.READY} the new status */ resetStatus() @@ -543,7 +472,7 @@

Source: core/ServerManager.js

return this.setStatus(ServerManager.Status.READY); } - /**************************************************************************** + /** * Prepare resources for the experiment: register them with the server manager and possibly * start downloading them right away. * @@ -556,10 +485,7 @@

Source: core/ServerManager.js

* <li>If resources is null, then we do not download any resources</li> * </ul> * - * @name module:core.ServerManager#prepareResources * @param {String | Array.<{name: string, path: string, download: boolean} | String | Symbol>} [resources=[]] - the list of resources or a single resource - * @function - * @public */ async prepareResources(resources = []) { @@ -708,13 +634,10 @@

Source: core/ServerManager.js

} } - /**************************************************************************** + /** * 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 */ waitForResources(resources = []) { @@ -810,22 +733,18 @@

Source: core/ServerManager.js

}; } - /**************************************************************************** + /** * @typedef ServerManager.UploadDataPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.<string, *>} [error] an error message if we could not upload the data */ - /**************************************************************************** + /** * Asynchronously upload experiment data to the pavlovia server. * - * @name module:core.ServerManager#uploadData - * @function - * @public * @param {string} key - the data key (e.g. the name of .csv file) * @param {string} value - the data value (e.g. a string containing the .csv header and records) * @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner - * * @returns {Promise<ServerManager.UploadDataPromise>} the response */ uploadData(key, value, sync = false) @@ -834,59 +753,58 @@

Source: core/ServerManager.js

origin: "ServerManager.uploadData", context: "when uploading participant's results for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.debug("uploading data for experiment: " + this._psychoJS.config.experiment.fullpath); + this.setStatus(ServerManager.Status.BUSY); - const url = this._psychoJS.config.pavlovia.URL - + "/api/v2/experiments/" + encodeURIComponent(this._psychoJS.config.experiment.fullpath) - + "/sessions/" + this._psychoJS.config.session.token - + "/results"; + const path = `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}/results`; - // synchronous query the pavlovia server: + // synchronously query the pavlovia server: if (sync) { const formData = new FormData(); formData.append("key", key); formData.append("value", value); - navigator.sendBeacon(url, formData); + navigator.sendBeacon(`${this._psychoJS.config.pavlovia.URL}/api/v2/${path}`, formData); } // asynchronously query the pavlovia server: else { const self = this; - return new Promise((resolve, reject) => + return new Promise(async (resolve, reject) => { - const data = { - key, - value, - }; + try + { + const postResponse = await this._queryServerAPI( + "POST", + `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}/results`, + { key, value }, + "FORM" + ); - jQuery.post(url, data, null, "json") - .done((serverData, textStatus) => - { - self.setStatus(ServerManager.Status.READY); - resolve(Object.assign(response, { serverData })); - }) - .fail((jqXHR, textStatus, errorThrown) => - { - self.setStatus(ServerManager.Status.ERROR); + const uploadDataResponse = await postResponse.json(); - const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error("error:", errorMsg); + if (postResponse.status !== 200) + { + throw ('error' in uploadDataResponse) ? uploadDataResponse.error : uploadDataResponse; + } - reject(Object.assign(response, { error: errorMsg })); - }); + self.setStatus(ServerManager.Status.READY); + resolve({ ...response, ...uploadDataResponse }); + } + catch (error) + { + console.error(error); + self.setStatus(ServerManager.Status.ERROR); + reject({...response, error}); + } }); } } - /**************************************************************************** + /** * Asynchronously upload experiment logs to the pavlovia server. * - * @name module:core.ServerManager#uploadLog - * @function - * @public * @param {string} logs - the base64 encoded, compressed, formatted logs * @param {boolean} [compressed=false] - whether or not the logs are compressed * @returns {Promise<ServerManager.UploadDataPromise>} the response @@ -897,55 +815,56 @@

Source: core/ServerManager.js

origin: "ServerManager.uploadLog", context: "when uploading participant's log for experiment: " + this._psychoJS.config.experiment.fullpath, }; - this._psychoJS.logger.debug("uploading server log for experiment: " + this._psychoJS.config.experiment.fullpath); + this.setStatus(ServerManager.Status.BUSY); - // prepare the POST query: + // prepare a POST query: 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 + ".log"; + const filenameWithoutPath = this.psychoJS.experiment.dataFileName.split(/[\\/]/).pop(); + const filename = `${filenameWithoutPath}.log`; const data = { filename, logs, - compressed, + compressed }; // query the pavlovia server: const self = this; - return new Promise((resolve, reject) => + return new Promise(async (resolve, reject) => { - const url = self._psychoJS.config.pavlovia.URL - + "/api/v2/experiments/" + encodeURIComponent(self._psychoJS.config.experiment.fullpath) - + "/sessions/" + self._psychoJS.config.session.token - + "/logs"; + try + { + const postResponse = await this._queryServerAPI( + "POST", + `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${self._psychoJS.config.session.token}/logs`, + data, + "FORM" + ); - jQuery.post(url, data, null, "json") - .done((serverData, textStatus) => - { - self.setStatus(ServerManager.Status.READY); - resolve(Object.assign(response, { serverData })); - }) - .fail((jqXHR, textStatus, errorThrown) => + const uploadLogsResponse = await postResponse.json(); + + if (postResponse.status !== 200) { - self.setStatus(ServerManager.Status.ERROR); + throw ('error' in uploadLogsResponse) ? uploadLogsResponse.error : uploadLogsResponse; + } - const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error("error:", errorMsg); + self.setStatus(ServerManager.Status.READY); + resolve({...response, ...uploadLogsResponse }); + } + catch (error) + { + console.error(error); + self.setStatus(ServerManager.Status.ERROR); + reject({...response, error}); + } - reject(Object.assign(response, { error: errorMsg })); - }); }); } - /**************************************************************************** + /** * Synchronously or asynchronously upload audio data to the pavlovia server. * - * @name module:core.ServerManager#uploadAudioVideo - * @function - * @public * @param @param {Object} options * @param {Blob} options.mediaBlob - the audio or video blob to be uploaded * @param {string} options.tag - additional tag @@ -1075,12 +994,10 @@

Source: core/ServerManager.js

} } - /**************************************************************************** + /** * List the resources available to the experiment. * - * @name module:core.ServerManager#_listResources - * @function - * @private + * @protected */ _listResources() { @@ -1092,61 +1009,53 @@

Source: core/ServerManager.js

this.setStatus(ServerManager.Status.BUSY); - // prepare GET data: + // prepare a GET query: const data = { "token": this._psychoJS.config.session.token, }; - // query pavlovia server: + // query the server: const self = this; - return new Promise((resolve, reject) => + return new Promise(async (resolve, reject) => { - const url = this._psychoJS.config.pavlovia.URL - + "/api/v2/experiments/" + encodeURIComponent(this._psychoJS.config.experiment.fullpath) - + "/resources"; + try + { + const getResponse = await this._queryServerAPI( + "GET", + `experiments/${this._psychoJS.config.gitlab.projectId}/resources`, + data + ); - jQuery.get(url, data, null, "json") - .done((data, textStatus) => - { - if (!("resources" in data)) - { - self.setStatus(ServerManager.Status.ERROR); - // reject({ ...response, error: 'unexpected answer from server: no resources' }); - reject(Object.assign(response, { error: "unexpected answer from server: no resources" })); - } - if (!("resourceDirectory" in data)) - { - self.setStatus(ServerManager.Status.ERROR); - // reject({ ...response, error: 'unexpected answer from server: no resourceDirectory' }); - reject(Object.assign(response, { error: "unexpected answer from server: no resourceDirectory" })); - } + const getResourcesResponse = await getResponse.json(); - self.setStatus(ServerManager.Status.READY); - // resolve({ ...response, resources: data.resources, resourceDirectory: data.resourceDirectory }); - resolve(Object.assign(response, { - resources: data.resources, - resourceDirectory: data.resourceDirectory, - })); - }) - .fail((jqXHR, textStatus, errorThrown) => + if (!("resources" in getResourcesResponse)) { self.setStatus(ServerManager.Status.ERROR); + throw "unexpected answer from server: no resources"; + } + if (!("resourceDirectory" in getResourcesResponse)) + { + self.setStatus(ServerManager.Status.ERROR); + throw "unexpected answer from server: no resourceDirectory"; + } - const errorMsg = util.getRequestError(jqXHR, textStatus, errorThrown); - console.error("error:", errorMsg); - - reject(Object.assign(response, { error: errorMsg })); - }); + self.setStatus(ServerManager.Status.READY); + resolve({ ...response, resources: data.resources, resourceDirectory: data.resourceDirectory }); + } + catch (error) + { + console.error(error); + self.setStatus(ServerManager.Status.ERROR); + reject({...response, error}); + } }); } - /**************************************************************************** + /** * Download the specified resources. * * <p>Note: we use the [preloadjs library]{@link https://www.createjs.com/preloadjs}.</p> * - * @name module:core.ServerManager#_downloadResources - * @function * @protected * @param {Set} resources - a set of names of previously registered resources */ @@ -1344,11 +1253,9 @@

Source: core/ServerManager.js

} } - /**************************************************************************** + /** * Setup the preload.js queue, and the associated callbacks. * - * @name module:core.ServerManager#_setupPreloadQueue - * @function * @protected */ _setupPreloadQueue() @@ -1436,18 +1343,87 @@

Source: core/ServerManager.js

}); } + /** + * Query the pavlovia server API. + * + * @protected + * @param method the HTTP method, i.e. GET, PUT, POST, or DELETE + * @param path the resource path, without the server address + * @param data the data to be sent + * @param {string} [contentType="JSON"] the content type, either JSON or FORM + */ + _queryServerAPI(method, path, data, contentType = "JSON") + { + const fullPath = `${this._psychoJS.config.pavlovia.URL}/api/v2/${path}`; + + if (method === "PUT" || method === "POST" || method === "DELETE") + { + if (contentType === "JSON") + { + return fetch(fullPath, { + method, + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(data) + }); + } + else + { + const formData = new FormData(); + for (const attribute in data) + { + formData.append(attribute, data[attribute]); + } + + return fetch(fullPath, { + method, + mode: 'cors', + cache: 'no-cache', + credentials: 'same-origin', + redirect: 'follow', + referrerPolicy: 'no-referrer', + body: formData + }); + } + } + + if (method === "GET") + { + let url = new URL(fullPath); + url.search = new URLSearchParams(data).toString(); + + return fetch(url, { + method: "GET", + mode: "cors", + cache: "no-cache", + credentials: "same-origin", + redirect: "follow", + referrerPolicy: "no-referrer" + }); + } + + throw { + origin: "ServerManager._queryServer", + context: "when querying the server", + error: "the method should be GET, PUT, POST, or DELETE" + }; + } } -/**************************************************************************** +/** * Server event * * <p>A server event is emitted by the manager to inform its listeners of either a change of status, or of a resource related event (e.g. download started, download is completed).</p> * - * @name module:core.ServerManager#Event * @enum {Symbol} * @readonly - * @public */ ServerManager.Event = { /** @@ -1473,7 +1449,7 @@

Source: core/ServerManager.js

/** * Event: resources have all downloaded */ - DOWNLOADS_COMPLETED: Symbol.for("DOWNLOAD_COMPLETED"), + DOWNLOAD_COMPLETED: Symbol.for("DOWNLOAD_COMPLETED"), /** * Event type: status event @@ -1481,13 +1457,11 @@

Source: core/ServerManager.js

STATUS: Symbol.for("STATUS"), }; -/**************************************************************************** +/** * Server status * - * @name module:core.ServerManager#Status * @enum {Symbol} * @readonly - * @public */ ServerManager.Status = { /** @@ -1506,13 +1480,11 @@

Source: core/ServerManager.js

ERROR: Symbol.for("ERROR"), }; -/**************************************************************************** +/** * Resource status * - * @name module:core.ServerManager#ResourceStatus * @enum {Symbol} * @readonly - * @public */ ServerManager.ResourceStatus = { /** @@ -1542,19 +1514,23 @@

Source: core/ServerManager.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/core_Window.js.html b/docs/core_Window.js.html index b00a7ef8..267b4a4d 100644 --- a/docs/core_Window.js.html +++ b/docs/core_Window.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: core/Window.js - - - + core/Window.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + + + + + -

Source: core/Window.js

+
+ +

core/Window.js

+ @@ -30,8 +54,8 @@

Source: core/Window.js

* Window responsible for displaying the experiment stimuli * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -46,20 +70,7 @@

Source: core/Window.js

* <p>Window displays the various stimuli of the experiment.</p> * <p>It sets up a [PIXI]{@link http://www.pixijs.com/} renderer, which we use to render the experiment stimuli.</p> * - * @name module:core.Window - * @class * @extends PsychObject - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {string} [options.name] the name of the window - * @param {boolean} [options.fullscr= false] whether or not to go fullscreen - * @param {Color} [options.color= Color('black')] the background color of the window - * @param {number} [options.gamma= 1] sets the divisor for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) - * @param {number} [options.contrast= 1] sets the contrast value - * @param {string} [options.units= 'pix'] the units of the window - * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done - * before flipping - * @param {boolean} [options.autoLog= true] whether or not to log */ export class Window extends PsychObject { @@ -75,6 +86,20 @@

Source: core/Window.js

return 1.0 / this.getActualFrameRate(); } + /** + * @memberof module:core + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {string} [options.name] the name of the window + * @param {boolean} [options.fullscr= false] whether or not to go fullscreen + * @param {Color} [options.color= Color('black')] the background color of the window + * @param {number} [options.gamma= 1] sets the divisor for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) + * @param {number} [options.contrast= 1] sets the contrast value + * @param {string} [options.units= 'pix'] the units of the window + * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done + * before flipping + * @param {boolean} [options.autoLog= true] whether or not to log + */ constructor({ psychoJS, name, @@ -104,7 +129,7 @@

Source: core/Window.js

this._addAttribute("fullscr", fullscr); this._addAttribute("color", color, new Color("black"), () => { if (this._backgroundSprite) { - this._backgroundSprite.tint = color.int; + this._backgroundSprite.tint = this._color.int; } }); this._addAttribute("gamma", gamma, 1, () => { @@ -152,10 +177,6 @@

Source: core/Window.js

* Close the window. * * <p> Note: this actually only removes the canvas used to render the experiment stimuli.</p> - * - * @name module:core.Window#close - * @function - * @public */ close() { @@ -189,9 +210,6 @@

Source: core/Window.js

/** * Estimate the frame rate. * - * @name module:core.Window#getActualFrameRate - * @function - * @public * @return {number} rAF based delta time based approximation, 60.0 by default */ getActualFrameRate() @@ -205,10 +223,6 @@

Source: core/Window.js

/** * Take the browser full screen if possible. - * - * @name module:core.Window#adjustScreenSize - * @function - * @public */ adjustScreenSize() { @@ -250,10 +264,6 @@

Source: core/Window.js

/** * Take the browser back from full screen if needed. - * - * @name module:core.Window#closeFullScreen - * @function - * @public */ closeFullScreen() { @@ -293,9 +303,6 @@

Source: core/Window.js

* * <p> Note: the message will be time-stamped at the next call to requestAnimationFrame.</p> * - * @name module:core.Window#logOnFlip - * @function - * @public * @param {Object} options * @param {String} options.msg the message to be logged * @param {module:util.Logger.ServerLevel} [level = module:util.Logger.ServerLevel.EXP] the log level @@ -322,9 +329,6 @@

Source: core/Window.js

* * <p>This is typically used to reset a timer or clock.</p> * - * @name module:core.Window#callOnFlip - * @function - * @public * @param {module:core.Window~OnFlipCallback} flipCallback - callback function. * @param {...*} flipCallbackArgs - arguments for the callback function. */ @@ -335,10 +339,6 @@

Source: core/Window.js

/** * Add PIXI.DisplayObject to the container displayed on the scene (window) - * - * @name module:core.Window#addPixiObject - * @function - * @public */ addPixiObject(pixiObject) { @@ -347,10 +347,6 @@

Source: core/Window.js

/** * Remove PIXI.DisplayObject from the container displayed on the scene (window) - * - * @name module:core.Window#removePixiObject - * @function - * @public */ removePixiObject(pixiObject) { @@ -359,10 +355,6 @@

Source: core/Window.js

/** * Render the stimuli onto the canvas. - * - * @name module:core.Window#render - * @function - * @public */ render() { @@ -406,9 +398,7 @@

Source: core/Window.js

/** * Update this window, if need be. * - * @name module:core.Window#_updateIfNeeded - * @function - * @private + * @protected */ _updateIfNeeded() { @@ -417,6 +407,7 @@

Source: core/Window.js

if (this._renderer) { this._renderer.backgroundColor = this._color.int; + this._backgroundSprite.tint = this._color.int; } // we also change the background color of the body since @@ -430,9 +421,7 @@

Source: core/Window.js

/** * Recompute this window's draw list and _container children for the next animation frame. * - * @name module:core.Window#_refresh - * @function - * @private + * @protected */ _refresh() { @@ -454,9 +443,7 @@

Source: core/Window.js

/** * Force an update of all stimuli in this window's drawlist. * - * @name module:core.Window#_fullRefresh - * @function - * @private + * @protected */ _fullRefresh() { @@ -476,9 +463,7 @@

Source: core/Window.js

* <p>A new renderer is created and a container is added to it. The renderer's touch and mouse events * are handled by the {@link EventManager}.</p> * - * @name module:core.Window#_setupPixi - * @function - * @private + * @protected */ _setupPixi() { @@ -538,6 +523,8 @@

Source: core/Window.js

this._resizeCallback = (e) => { Window._resizePixiRenderer(this, e); + this._backgroundSprite.width = this._size[0]; + this._backgroundSprite.height = this._size[1]; this._fullRefresh(); }; window.addEventListener("resize", this._resizeCallback); @@ -548,9 +535,7 @@

Source: core/Window.js

* Adjust the size of the renderer and the position of the root container * in response to a change in the browser's size. * - * @name module:core.Window#_resizePixiRenderer - * @function - * @private + * @protected * @param {module:core.Window} pjsWindow - the PsychoJS Window * @param event */ @@ -579,9 +564,7 @@

Source: core/Window.js

/** * Send all logged messages to the {@link Logger}. * - * @name module:core.Window#_writeLogOnFlip - * @function - * @private + * @protected */ _writeLogOnFlip() { @@ -601,19 +584,23 @@

Source: core/Window.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/core_WindowMixin.js.html b/docs/core_WindowMixin.js.html index 11de007d..047687fd 100644 --- a/docs/core_WindowMixin.js.html +++ b/docs/core_WindowMixin.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: core/WindowMixin.js - - - + core/WindowMixin.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + + + + + -

Source: core/WindowMixin.js

+
+ +

core/WindowMixin.js

+ @@ -30,8 +54,8 @@

Source: core/WindowMixin.js

* Mixin implementing various unit-handling measurement methods. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -221,19 +245,23 @@

Source: core/WindowMixin.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/data_ExperimentHandler.js.html b/docs/data_ExperimentHandler.js.html index 9ee6b407..6a59258e 100644 --- a/docs/data_ExperimentHandler.js.html +++ b/docs/data_ExperimentHandler.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: data/ExperimentHandler.js - - - + data/ExperimentHandler.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + -

Source: data/ExperimentHandler.js

+ + + + +
+ +

data/ExperimentHandler.js

+ @@ -30,8 +54,8 @@

Source: data/ExperimentHandler.js

* Experiment Handler * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -45,22 +69,12 @@

Source: data/ExperimentHandler.js

* for generating a single data file from an experiment with many different loops (e.g. interleaved * staircases or loops within loops.</p> * - * @name module:data.ExperimentHandler - * @class * @extends PsychObject - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {string} options.name - name of the experiment - * @param {Object} options.extraInfo - additional information, such as session name, participant name, etc. */ export class ExperimentHandler extends PsychObject { /** * Getter for experimentEnded. - * - * @name module:data.ExperimentHandler#experimentEnded - * @function - * @public */ get experimentEnded() { @@ -69,10 +83,6 @@

Source: data/ExperimentHandler.js

/** * Setter for experimentEnded. - * - * @name module:data.ExperimentHandler#experimentEnded - * @function - * @public */ set experimentEnded(ended) { @@ -92,6 +102,13 @@

Source: data/ExperimentHandler.js

return this._trialsData; } + /** + * @memberof module:data + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {string} options.name - name of the experiment + * @param {Object} options.extraInfo - additional information, such as session name, participant name, etc. + */ constructor({ psychoJS, name, @@ -139,9 +156,6 @@

Source: data/ExperimentHandler.js

* Whether or not the current entry (i.e. trial data) is empty. * <p>Note: this is mostly useful at the end of an experiment, in order to ensure that the last entry is saved.</p> * - * @name module:data.ExperimentHandler#isEntryEmpty - * @function - * @public * @returns {boolean} whether or not the current entry is empty * @todo This really should be renamed: IsCurrentEntryNotEmpty */ @@ -156,9 +170,6 @@

Source: data/ExperimentHandler.js

* <p> The loop might be a {@link TrialHandler}, for instance.</p> * <p> Data from this loop will be included in the resulting data files.</p> * - * @name module:data.ExperimentHandler#addLoop - * @function - * @public * @param {Object} loop - the loop, e.g. an instance of TrialHandler or StairHandler */ addLoop(loop) @@ -171,9 +182,6 @@

Source: data/ExperimentHandler.js

/** * Remove the given loop from the list of unfinished loops, e.g. when it has completed. * - * @name module:data.ExperimentHandler#removeLoop - * @function - * @public * @param {Object} loop - the loop, e.g. an instance of TrialHandler or StairHandler */ removeLoop(loop) @@ -191,9 +199,6 @@

Source: data/ExperimentHandler.js

* <p> Multiple key/value pairs can be added to any given entry of the data file. There are * considered part of the same entry until a call to {@link nextEntry} is made. </p> * - * @name module:data.ExperimentHandler#addData - * @function - * @public * @param {Object} key - the key * @param {Object} value - the value */ @@ -217,9 +222,6 @@

Source: data/ExperimentHandler.js

* Inform this ExperimentHandler that the current trial has ended. Further calls to {@link addData} * will be associated with the next trial. * - * @name module:data.ExperimentHandler#nextEntry - * @function - * @public * @param {Object | Object[] | undefined} snapshots - array of loop snapshots */ nextEntry(snapshots) @@ -284,9 +286,6 @@

Source: data/ExperimentHandler.js

* </ul> * <p> * - * @name module:data.ExperimentHandler#save - * @function - * @public * @param {Object} options * @param {Array.<Object>} [options.attributes] - the attributes to be saved * @param {boolean} [options.sync=false] - whether or not to communicate with the server in a synchronous manner @@ -408,9 +407,6 @@

Source: data/ExperimentHandler.js

* Get the attribute names and values for the current trial of a given loop. * <p> Only info relating to the trial execution are returned.</p> * - * @name module:data.ExperimentHandler#_getLoopAttributes - * @function - * @static * @protected * @param {Object} loop - the loop */ @@ -470,10 +466,8 @@

Source: data/ExperimentHandler.js

/** * Experiment result format * - * @name module:core.ServerManager#SaveFormat * @enum {Symbol} * @readonly - * @public */ ExperimentHandler.SaveFormat = { /** @@ -492,7 +486,6 @@

Source: data/ExperimentHandler.js

* * @enum {Symbol} * @readonly - * @public */ ExperimentHandler.Environment = { SERVER: Symbol.for("SERVER"), @@ -505,19 +498,23 @@

Source: data/ExperimentHandler.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/data_MultiStairHandler.js.html b/docs/data_MultiStairHandler.js.html index 6d5b8d75..f77f3b8e 100644 --- a/docs/data_MultiStairHandler.js.html +++ b/docs/data_MultiStairHandler.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: data/MultiStairHandler.js - - - + data/MultiStairHandler.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + -

Source: data/MultiStairHandler.js

+ + + + +
+ +

data/MultiStairHandler.js

+ @@ -26,13 +50,12 @@

Source: data/MultiStairHandler.js

-
/** @module data */
-/**
+            
/**
  * Multiple Staircase Trial Handler
  *
  * @author Alain Pitiot
- * @version 2021.2.1
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd.
+ * @version 2021.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd.
  *   (https://opensciencetools.org)
  * @license Distributed under the terms of the MIT License
  */
@@ -50,27 +73,25 @@ 

Source: data/MultiStairHandler.js

* <p>Note that, at the moment, using the MultiStairHandler requires the jsQuest.js * library to be loaded as a resource, at the start of the experiment.</p> * - * @class module.data.MultiStairHandler * @extends TrialHandler - * @param {Object} options - the handler options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {string} options.varName - the name of the variable / intensity / contrast - * / threshold manipulated by the staircases - * @param {module:data.MultiStairHandler.StaircaseType} [options.stairType="simple"] - the - * handler type - * @param {Array.<Object> | String} [options.conditions= [undefined] ] - if it is a string, - * we treat it as the name of a conditions resource - * @param {module:data.TrialHandler.Method} options.method - the trial method - * @param {number} [options.nTrials=50] - maximum number of trials - * @param {number} options.randomSeed - seed for the random number generator - * @param {string} options.name - name of the handler - * @param {boolean} [options.autoLog= false] - whether or not to log */ export class MultiStairHandler extends TrialHandler { /** - * @constructor - * @public + * @memberof module:data + * @param {Object} options - the handler options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {string} options.varName - the name of the variable / intensity / contrast + * / threshold manipulated by the staircases + * @param {MultiStairHandler.StaircaseType} [options.stairType="simple"] - the + * handler type + * @param {Array.<Object> | String} [options.conditions= [undefined] ] - if it is a string, + * we treat it as the name of a conditions resource + * @param {module:data.TrialHandler.Method} options.method - the trial method + * @param {number} [options.nTrials=50] - maximum number of trials + * @param {number} options.randomSeed - seed for the random number generator + * @param {string} options.name - name of the handler + * @param {boolean} [options.autoLog= false] - whether or not to log */ constructor({ psychoJS, @@ -119,10 +140,7 @@

Source: data/MultiStairHandler.js

/** * Get the current staircase. * - * @name module:data.MultiStairHandler#currentStaircase - * @function - * @public - * @returns {module.data.TrialHandler} the current staircase, or undefined if the trial has ended + * @returns {TrialHandler} the current staircase, or undefined if the trial has ended */ get currentStaircase() { @@ -132,9 +150,6 @@

Source: data/MultiStairHandler.js

/** * Get the current intensity. * - * @name module:data.MultiStairHandler#intensity - * @function - * @public * @returns {number} the intensity of the current staircase, or undefined if the trial has ended */ get intensity() @@ -156,13 +171,9 @@

Source: data/MultiStairHandler.js

/** * Add a response to the current staircase. * - * @name module:data.MultiStairHandler#addResponse - * @function - * @public * @param{number} response - the response to the trial, must be either 0 (incorrect or * non-detected) or 1 (correct or detected) * @param{number | undefined} [value] - optional intensity / contrast / threshold - * @returns {void} */ addResponse(response, value) { @@ -191,10 +202,7 @@

Source: data/MultiStairHandler.js

/** * Validate the conditions. * - * @name module:data.MultiStairHandler#_validateConditions - * @function * @protected - * @returns {void} */ _validateConditions() { @@ -250,10 +258,7 @@

Source: data/MultiStairHandler.js

/** * Setup the staircases, according to the conditions. * - * @name module:data.MultiStairHandler#_prepareStaircases - * @function * @protected - * @returns {void} */ _prepareStaircases() { @@ -310,10 +315,7 @@

Source: data/MultiStairHandler.js

/** * Move onto the next trial. * - * @name module:data.MultiStairHandler#_nextTrial - * @function * @protected - * @returns {void} */ _nextTrial() { @@ -453,7 +455,6 @@

Source: data/MultiStairHandler.js

* * @enum {Symbol} * @readonly - * @public */ MultiStairHandler.StaircaseType = { /** @@ -472,7 +473,6 @@

Source: data/MultiStairHandler.js

* * @enum {Symbol} * @readonly - * @public */ MultiStairHandler.StaircaseStatus = { /** @@ -492,19 +492,23 @@

Source: data/MultiStairHandler.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/data_QuestHandler.js.html b/docs/data_QuestHandler.js.html index 878d5a91..56a736f8 100644 --- a/docs/data_QuestHandler.js.html +++ b/docs/data_QuestHandler.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: data/QuestHandler.js - - - + data/QuestHandler.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + -

Source: data/QuestHandler.js

+ + + + +
+ +

data/QuestHandler.js

+ @@ -26,13 +50,12 @@

Source: data/QuestHandler.js

-
/** @module data */
-/**
+            
/**
  * Quest Trial Handler
  *
  * @author Alain Pitiot & Thomas Pronk
- * @version 2021.2.0
- * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org)
+ * @version 2022.2.3
+ * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org)
  * @license Distributed under the terms of the MIT License
  */
 
@@ -43,31 +66,29 @@ 

Source: data/QuestHandler.js

* <p>A Trial Handler that implements the Quest algorithm for quick measurement of psychophysical thresholds. QuestHandler relies on the [jsQuest]{@link https://github.com/kurokida/jsQUEST} library, a port of Prof Dennis Pelli's QUEST algorithm by [Daiichiro Kuroki]{@link https://github.com/kurokida}.</p> * - * @class module.data.QuestHandler * @extends TrialHandler - * @param {Object} options - the handler options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {string} options.varName - the name of the variable / intensity / contrast / threshold manipulated by QUEST - * @param {number} options.startVal - initial guess for the threshold - * @param {number} options.startValSd - standard deviation of the initial guess - * @param {number} options.minVal - minimum value for the threshold - * @param {number} options.maxVal - maximum value for the threshold - * @param {number} [options.pThreshold=0.82] - threshold criterion expressed as probability of getting a correct response - * @param {number} options.nTrials - maximum number of trials - * @param {number} options.stopInterval - minimum [5%, 95%] confidence interval required for the loop to stop - * @param {module:data.QuestHandler.Method} options.method - the QUEST method - * @param {number} [options.beta=3.5] - steepness of the QUEST psychometric function - * @param {number} [options.delta=0.01] - fraction of trials with blind responses - * @param {number} [options.gamma=0.5] - fraction of trails that would generate a correct response when the threshold is infinitely small - * @param {number} [options.grain=0.01] - quantization of the internal table - * @param {string} options.name - name of the handler - * @param {boolean} [options.autoLog= false] - whether or not to log */ export class QuestHandler extends TrialHandler { /** - * @constructor - * @public + * @memberof module:data + * @param {Object} options - the handler options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {string} options.varName - the name of the variable / intensity / contrast / threshold manipulated by QUEST + * @param {number} options.startVal - initial guess for the threshold + * @param {number} options.startValSd - standard deviation of the initial guess + * @param {number} options.minVal - minimum value for the threshold + * @param {number} options.maxVal - maximum value for the threshold + * @param {number} [options.pThreshold=0.82] - threshold criterion expressed as probability of getting a correct response + * @param {number} options.nTrials - maximum number of trials + * @param {number} options.stopInterval - minimum [5%, 95%] confidence interval required for the loop to stop + * @param {QuestHandler.Method} options.method - the QUEST method + * @param {number} [options.beta=3.5] - steepness of the QUEST psychometric function + * @param {number} [options.delta=0.01] - fraction of trials with blind responses + * @param {number} [options.gamma=0.5] - fraction of trails that would generate a correct response when the threshold is infinitely small + * @param {number} [options.grain=0.01] - quantization of the internal table + * @param {string} options.name - name of the handler + * @param {boolean} [options.autoLog= false] - whether or not to log */ constructor({ psychoJS, @@ -141,15 +162,11 @@

Source: data/QuestHandler.js

/** * Add a response and update the PDF. * - * @name module:data.QuestHandler#addResponse - * @function - * @public * @param{number} response - the response to the trial, must be either 0 (incorrect or * non-detected) or 1 (correct or detected) * @param{number | undefined} value - optional intensity / contrast / threshold * @param{boolean} [doAddData = true] - whether or not to add the response as data to the * experiment - * @returns {void} */ addResponse(response, value, doAddData = true) { @@ -191,9 +208,6 @@

Source: data/QuestHandler.js

/** * Simulate a response. * - * @name module:data.QuestHandler#simulate - * @function - * @public * @param{number} trueValue - the true, known value of the threshold / contrast / intensity * @returns{number} the simulated response, 0 or 1 */ @@ -212,9 +226,6 @@

Source: data/QuestHandler.js

/** * Get the mean of the Quest posterior PDF. * - * @name module:data.QuestHandler#mean - * @function - * @public * @returns {number} the mean */ mean() @@ -225,9 +236,6 @@

Source: data/QuestHandler.js

/** * Get the standard deviation of the Quest posterior PDF. * - * @name module:data.QuestHandler#sd - * @function - * @public * @returns {number} the standard deviation */ sd() @@ -238,9 +246,6 @@

Source: data/QuestHandler.js

/** * Get the mode of the Quest posterior PDF. * - * @name module:data.QuestHandler#mode - * @function - * @public * @returns {number} the mode */ mode() @@ -252,9 +257,6 @@

Source: data/QuestHandler.js

/** * Get the standard deviation of the Quest posterior PDF. * - * @name module:data.QuestHandler#quantile - * @function - * @public * @param{number} quantileOrder the quantile order * @returns {number} the quantile */ @@ -266,9 +268,6 @@

Source: data/QuestHandler.js

/** * Get the current value of the variable / contrast / threshold. * - * @name module:data.QuestHandler#getQuestValue - * @function - * @public * @returns {number} the current QUEST value for the variable / contrast / threshold */ getQuestValue() @@ -281,9 +280,6 @@

Source: data/QuestHandler.js

* * <p>This is the getter associated to getQuestValue.</p> * - * @name module:data.MultiStairHandler#intensity - * @function - * @public * @returns {number} the intensity of the current staircase, or undefined if the trial has ended */ get intensity() @@ -294,9 +290,6 @@

Source: data/QuestHandler.js

/** * Get an estimate of the 5%-95% confidence interval (CI). * - * @name module:data.QuestHandler#confInterval - * @function - * @public * @param{boolean} [getDifference=false] - if true, return the width of the CI instead of the CI * @returns{number[] | number} the 5%-95% CI or the width of the CI */ @@ -320,10 +313,7 @@

Source: data/QuestHandler.js

/** * Setup the JS Quest object. * - * @name module:data.QuestHandler#_setupJsQuest - * @function * @protected - * @returns {void} */ _setupJsQuest() { @@ -341,10 +331,7 @@

Source: data/QuestHandler.js

* Estimate the next value of the QUEST variable, based on the current value * and on the selected QUEST method. * - * @name module:data.QuestHandler#_estimateQuestValue - * @function * @protected - * @returns {void} */ _estimateQuestValue() { @@ -415,7 +402,6 @@

Source: data/QuestHandler.js

* * @enum {Symbol} * @readonly - * @public */ QuestHandler.Method = { /** @@ -440,19 +426,23 @@

Source: data/QuestHandler.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/data_Shelf.js.html b/docs/data_Shelf.js.html index 09e6c3ee..0d58bace 100644 --- a/docs/data_Shelf.js.html +++ b/docs/data_Shelf.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: data/Shelf.js - - - + data/Shelf.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + + + + + -

Source: data/Shelf.js

+
+ +

data/Shelf.js

+ @@ -26,12 +50,12 @@

Source: data/Shelf.js

-
/** @module data */
-/**
+            
/**
  * Shelf handles persistent key/value pairs, or records, which are stored in the shelf collection on the
- * server, and be accessed and manipulated in a concurrent fashion.
+ * server, and can be accessed and manipulated in a concurrent fashion.
  *
  * @author Alain Pitiot
+ * @version 2021.2.3
  * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org)
  * @license Distributed under the terms of the MIT License
  */
@@ -44,27 +68,25 @@ 

Source: data/Shelf.js

/** * <p>Shelf handles persistent key/value pairs, or records, which are stored in the shelf collection on the - * server, and be accessed and manipulated in a concurrent fashion.</p> - * - * <p></p> + * server, and can be accessed and manipulated in a concurrent fashion.</p> * - * @name module:data.Shelf - * @class * @extends PsychObject - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS the PsychoJS instance - * @param {boolean} [options.autoLog= false] whether to log */ export class Shelf extends PsychObject { /** * Maximum number of components in a key - * @name module:data.Shelf.#MAX_KEY_LENGTH * @type {number} * @note this value should mirror that on the server, i.e. the server also checks that the key is valid */ static #MAX_KEY_LENGTH = 10; + /** + * @memberOf module:data + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS the PsychoJS instance + * @param {boolean} [options.autoLog= false] whether to log + */ constructor({psychoJS, autoLog = false } = {}) { super(psychoJS); @@ -84,9 +106,6 @@

Source: data/Shelf.js

/** * Get the value of a record of type BOOLEAN associated with the given key. * - * @name module:data.Shelf#getBooleanValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {boolean} options.defaultValue the default value returned if no record with the given key exists @@ -103,9 +122,6 @@

Source: data/Shelf.js

/** * Set the value of a record of type BOOLEAN associated with the given key. * - * @name module:data.Shelf#setBooleanValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {boolean} options.value the new value @@ -136,9 +152,6 @@

Source: data/Shelf.js

/** * Flip the value of a record of type BOOLEAN associated with the given key. * - * @name module:data.Shelf#flipBooleanValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @return {Promise<boolean>} the new, flipped, value @@ -157,9 +170,6 @@

Source: data/Shelf.js

/** * Get the value of a record of type INTEGER associated with the given key. * - * @name module:data.Shelf#getIntegerValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {number} options.defaultValue the default value returned if no record with the given key @@ -176,9 +186,6 @@

Source: data/Shelf.js

/** * Set the value of a record of type INTEGER associated with the given key. * - * @name module:data.Shelf#setIntegerValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {number} options.value the new value @@ -209,9 +216,6 @@

Source: data/Shelf.js

/** * Add a delta to the value of a record of type INTEGER associated with the given key. * - * @name module:data.Shelf#addIntegerValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {number} options.delta the delta, positive or negative, to add to the value @@ -242,9 +246,6 @@

Source: data/Shelf.js

/** * Get the value of a record of type TEXT associated with the given key. * - * @name module:data.Shelf#getTextValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string} options.defaultValue the default value returned if no record with the given key exists on @@ -261,9 +262,6 @@

Source: data/Shelf.js

/** * Set the value of a record of type TEXT associated with the given key. * - * @name module:data.Shelf#setTextValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string} options.value the new value @@ -294,9 +292,6 @@

Source: data/Shelf.js

/** * Get the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#getListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {Array.<*>} options.defaultValue the default value returned if no record with the given key exists on @@ -313,9 +308,6 @@

Source: data/Shelf.js

/** * Set the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#setListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {Array.<*>} options.value the new value @@ -346,9 +338,6 @@

Source: data/Shelf.js

/** * Append an element, or a list of elements, to the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#appendListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {*} options.elements the element or list of elements to be appended @@ -370,9 +359,6 @@

Source: data/Shelf.js

* Pop an element, at the given index, from the value of a record of type LIST associated * with the given key. * - * @name module:data.Shelf#popListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {number} [options.index = -1] the index of the element to be popped @@ -393,9 +379,6 @@

Source: data/Shelf.js

/** * Empty the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#clearListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @return {Promise<Array.<*>>} the new, empty value, i.e. [] @@ -414,9 +397,6 @@

Source: data/Shelf.js

/** * Shuffle the elements of the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#shuffleListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @return {Promise<Array.<*>>} the new, shuffled value @@ -436,9 +416,6 @@

Source: data/Shelf.js

/** * Get the names of the fields in the dictionary record associated with the given key. * - * @name module:data.Shelf#getDictionaryFieldNames - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @return {Promise<string[]>} the list of field names @@ -453,9 +430,6 @@

Source: data/Shelf.js

/** * Get the value of a given field in the dictionary record associated with the given key. * - * @name module:data.Shelf#getDictionaryFieldValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string} options.fieldName the name of the field @@ -473,9 +447,6 @@

Source: data/Shelf.js

/** * Set a field in the dictionary record associated to the given key. * - * @name module:data.Shelf#setDictionaryFieldValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string} options.fieldName the name of the field @@ -498,9 +469,6 @@

Source: data/Shelf.js

/** * Get the value of a record of type DICTIONARY associated with the given key. * - * @name module:data.Shelf#getDictionaryValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {Object.<string, *>} options.defaultValue the default value returned if no record with the given key @@ -517,9 +485,6 @@

Source: data/Shelf.js

/** * Set the value of a record of type DICTIONARY associated with the given key. * - * @name module:data.Shelf#setDictionaryValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {Object.<string, *>} options.value the new value @@ -551,9 +516,6 @@

Source: data/Shelf.js

* Schedulable component that will block the experiment until the counter associated with the given key * has been incremented by the given amount. * - * @name module:data.Shelf#incrementComponent - * @function - * @public * @param key * @param increment * @param callback @@ -604,15 +566,14 @@

Source: data/Shelf.js

/** * Get the name of a group, using a counterbalanced design. * - * @name module:data.Shelf#counterBalanceSelect - * @function - * @public - * @param {string[]} key key as an array of key components - * @param {string[]} groups the names of the groups - * @param {number[]} groupSizes the size of the groups - * @return {Promise<any>} + * @param {Object} options + * @param {string[]} options.key key as an array of key components + * @param {string[]} options.groups the names of the groups + * @param {number[]} options.groupSizes the size of the groups + * @return {Promise<{string, boolean}>} an object with the name of the selected group and whether all groups + * have been depleted */ - async counterBalanceSelect(key, groups, groupSizes) + async counterBalanceSelect({key, groups, groupSizes} = {}) { const response = { origin: 'Shelf.counterBalanceSelect', @@ -625,7 +586,6 @@

Source: data/Shelf.js

this._checkKey(key); // prepare the request: - // const componentList = key.reduce((list, component) => list + '+' + component, ''); const url = `${this._psychoJS.config.pavlovia.URL}/api/v2/shelf/${this._psychoJS.config.session.token}/counterbalance`; const data = { key, @@ -634,7 +594,7 @@

Source: data/Shelf.js

}; // query the server: - const response = await fetch(url, { + const putResponse = await fetch(url, { method: 'PUT', mode: 'cors', cache: 'no-cache', @@ -648,16 +608,19 @@

Source: data/Shelf.js

}); // convert the response to json: - const document = await response.json(); + const document = await putResponse.json(); - if (response.status !== 200) + if (putResponse.status !== 200) { throw ('error' in document) ? document.error : document; } // return the updated value: this._status = Shelf.Status.READY; - return [ document.group, document.finished ]; + return { + group: document.group, + finished: document.finished + }; } catch (error) { @@ -672,9 +635,6 @@

Source: data/Shelf.js

* * <p>This is a generic method, typically called from the Shelf helper methods, e.g. setBinaryValue.</p> * - * @name module:data.Shelf#_updateValue - * @function - * @protected * @param {string[]} key key as an array of key components * @param {Shelf.Type} type the type of the record associated with the given key * @param {*} update the desired update @@ -703,7 +663,7 @@

Source: data/Shelf.js

}; // query the server: - const response = await fetch(url, { + const postResponse = await fetch(url, { method: 'POST', mode: 'cors', cache: 'no-cache', @@ -717,9 +677,9 @@

Source: data/Shelf.js

}); // convert the response to json: - const document = await response.json(); + const document = await postResponse.json(); - if (response.status !== 200) + if (postResponse.status !== 200) { throw ('error' in document) ? document.error : document; } @@ -740,9 +700,6 @@

Source: data/Shelf.js

* * <p>This is a generic method, typically called from the Shelf helper methods, e.g. getBinaryValue.</p> * - * @name module:data.Shelf#_getValue - * @function - * @protected * @param {string[]} key key as an array of key components * @param {Shelf.Type} type the type of the record associated with the given key * @param {Object} [options] the options, e.g. the default value returned if no record with the @@ -782,7 +739,7 @@

Source: data/Shelf.js

} // query the server: - const response = await fetch(url, { + const putResponse = await fetch(url, { method: 'PUT', mode: 'cors', cache: 'no-cache', @@ -795,9 +752,9 @@

Source: data/Shelf.js

body: JSON.stringify(data) }); - const document = await response.json(); + const document = await putResponse.json(); - if (response.status !== 200) + if (putResponse.status !== 200) { throw ('error' in document) ? document.error : document; } @@ -818,11 +775,8 @@

Source: data/Shelf.js

* * <p>Since all Shelf methods call _checkAvailability, we also use it as a means to throttle those calls.</p> * - * @name module:data.Shelf#_checkAvailability - * @function - * @public - * @param {string} [methodName=""] name of the method requiring a check - * @throws {Object.<string, *>} exception if it is not possible to run the given shelf command + * @param {string} [methodName=""] - name of the method requiring a check + * @throws {Object.<string, *>} exception if it is not possible to run the given shelf command */ _checkAvailability(methodName = "") { @@ -871,9 +825,6 @@

Source: data/Shelf.js

/** * Check the validity of the key. * - * @name module:data.Shelf#_checkKey - * @function - * @public * @param {object} key key whose validity is to be checked * @throws {Object.<string, *>} exception if the key is invalid */ @@ -898,10 +849,8 @@

Source: data/Shelf.js

/** * Shelf status * - * @name module:data.Shelf#Status * @enum {Symbol} * @readonly - * @public */ Shelf.Status = { /** @@ -925,7 +874,6 @@

Source: data/Shelf.js

* * @enum {Symbol} * @readonly - * @public */ Shelf.Type = { INTEGER: Symbol.for('INTEGER'), @@ -941,19 +889,23 @@

Source: data/Shelf.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/data_TrialHandler.js.html b/docs/data_TrialHandler.js.html index 8767079f..df179b47 100644 --- a/docs/data_TrialHandler.js.html +++ b/docs/data_TrialHandler.js.html @@ -1,23 +1,47 @@ + - JSDoc: Source: data/TrialHandler.js - - - + data/TrialHandler.js - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + + + + + -

Source: data/TrialHandler.js

+
+ +

data/TrialHandler.js

+ @@ -32,8 +56,8 @@

Source: data/TrialHandler.js

* * @author Alain Pitiot * @author Hiroyuki Sogo & Sotiri Bakagiannis - better support for BOM and accented characters - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -45,25 +69,12 @@

Source: data/TrialHandler.js

/** * <p>A Trial Handler handles the importing and sequencing of conditions.</p> * - * @class * @extends PsychObject - * @param {Object} options - the handler options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {Array.<Object> | String} [options.trialList= [undefined] ] - if it is a string, we treat it as the name of a condition resource - * @param {number} options.nReps - number of repetitions - * @param {module:data.TrialHandler.Method} options.method - the trial method - * @param {Object} options.extraInfo - additional information to be stored alongside the trial data, e.g. session ID, participant ID, etc. - * @param {number} options.seed - seed for the random number generator - * @param {boolean} [options.autoLog= false] - whether or not to log */ export class TrialHandler extends PsychObject { /** * Getter for experimentHandler. - * - * @name module:core.Window#experimentHandler - * @function - * @public */ get experimentHandler() { @@ -72,10 +83,6 @@

Source: data/TrialHandler.js

/** * Setter for experimentHandler. - * - * @name module:core.Window#experimentHandler - * @function - * @public */ set experimentHandler(exp) { @@ -83,8 +90,14 @@

Source: data/TrialHandler.js

} /** - * @constructor - * @public + * @param {Object} options - the handler options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {Array.<Object> | String} [options.trialList= [undefined] ] - if it is a string, we treat it as the name of a condition resource + * @param {number} options.nReps - number of repetitions + * @param {module:data.TrialHandler.Method} options.method - the trial method + * @param {Object} options.extraInfo - additional information to be stored alongside the trial data, e.g. session ID, participant ID, etc. + * @param {number} options.seed - seed for the random number generator + * @param {boolean} [options.autoLog= false] - whether or not to log * * @todo extraInfo is not taken into account, we use the expInfo of the ExperimentHandler instead */ @@ -251,7 +264,6 @@

Source: data/TrialHandler.js

* * <p>This is typically used in the LoopBegin function, in order to capture the current state of a TrialHandler</p> * - * @public * @return {Snapshot} - a snapshot of the current internal state. */ getSnapshot() @@ -324,8 +336,6 @@

Source: data/TrialHandler.js

/** * Set the internal state of the snapshot's trial handler from the snapshot. * - * @public - * @static * @param {Snapshot} snapshot - the snapshot from which to update the current internal state of the * snapshot's trial handler */ @@ -393,7 +403,6 @@

Source: data/TrialHandler.js

/** * Get the trial index. * - * @public * @return {number} the current trial index */ getTrialIndex() @@ -417,7 +426,6 @@

Source: data/TrialHandler.js

* <p>Note: we assume that all trials in the trialList share the same attributes * and consequently consider only the attributes of the first trial.</p> * - * @public * @return {Array.string} the attributes */ getAttributes() @@ -439,7 +447,6 @@

Source: data/TrialHandler.js

/** * Get the current trial. * - * @public * @return {Object} the current trial */ getCurrentTrial() @@ -466,7 +473,6 @@

Source: data/TrialHandler.js

/** * Get the nth future or past trial, without advancing through the trial list. * - * @public * @param {number} [n = 1] - increment * @return {Object|undefined} the future trial (if n is positive) or past trial (if n is negative) * or undefined if attempting to go beyond the last trial. @@ -485,7 +491,6 @@

Source: data/TrialHandler.js

* Get the nth previous trial. * <p> Note: this is useful for comparisons in n-back tasks.</p> * - * @public * @param {number} [n = -1] - increment * @return {Object|undefined} the past trial or undefined if attempting to go prior to the first trial. */ @@ -497,7 +502,6 @@

Source: data/TrialHandler.js

/** * Add a key/value pair to data about the current trial held by the experiment handler * - * @public * @param {Object} key - the key * @param {Object} value - the value */ @@ -536,8 +540,6 @@

Source: data/TrialHandler.js

* '5:' * '-5:-2, 9, 11:5:22' * - * @public - * @static * @param {module:core.ServerManager} serverManager - the server manager * @param {String} resourceName - the name of the resource containing the list of conditions, which must have been registered with the server manager. * @param {Object} [selection = null] - the selection @@ -645,7 +647,6 @@

Source: data/TrialHandler.js

/** * Prepare the trial list. * - * @function * @protected * @returns {void} */ @@ -683,7 +684,7 @@

Source: data/TrialHandler.js

} } - /* + /** * Prepare the sequence of trials. * * <p>The returned sequence is a matrix (an array of arrays) of trial indices @@ -708,7 +709,7 @@

Source: data/TrialHandler.js

* </p> * * @protected - */ + **/ _prepareSequence() { const response = { @@ -766,7 +767,6 @@

Source: data/TrialHandler.js

* * @enum {Symbol} * @readonly - * @public */ TrialHandler.Method = { /** @@ -796,19 +796,23 @@

Source: data/TrialHandler.js

+ +
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + diff --git a/docs/fonts/Montserrat/Montserrat-Bold.eot b/docs/fonts/Montserrat/Montserrat-Bold.eot new file mode 100644 index 0000000000000000000000000000000000000000..f2970bbdc7cced41387b330775acfe5904035ada GIT binary patch literal 106135 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 $)*$PqXnI`Dx6H^>%8=WhJm03?IcDv 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 z znS!tIu8tKUn=S+ihV*Di6M4DNP8ls${Nqxt7hL6LVe_sL$|X?@>vad3lzDsmIfKnt zh@g>5as#>G!nIc=E;q6mgS~x_jSMwsCquy|>cOJ_Vu+m!27uRfe4$fu|J4W#V?>$2 zVz=-w{T?seMI6H#SKNr07|w8Tq&uR-hY~%tdWBLLgNP!4|4sN{uE2@*a%2wflLV+4 z698%`FDJ)D$zG0;Dv$<^3^NEu6==zu7AZjjvJ`f`X4X^Te;AvTM*)=y>9j{u7Fq{q z(W{PYzPyhN2U5OyHXlJJD8+erc?0o-RQ^GTgM~`+)0^jC5C((#9q6S`1l9_Mk41Tw zED?@g7J++9&O?&Xt0Lw0lmu4z(>dW^F6N+@X*mwvHVk@9aaX`-O2(fd!}2Sad{}FPA07XE$zYyi&7u4iP zV39C^z4+T@$Q1&?a>B9eAl?NTU^W)pAxF30ep1<0T3;38D~L)JxQtt@2CGY8)O;_{L zrG9>Z@#UH8IUxrRgYGA*TMEAwOkRWf9=J!AFlCV3^WdY+fCtHdQ3|Z&=B+co6!H93 z&AnlQMD1tq61xKAnMlA1$Tel6=^#Ar0I$&p)>%Mq8O7j3Q&b~1Yz#`6U!u?zrpQFI zpQC4j)GkuW^Qu$i2MrmD7r=f{_KL#gY4IXvJ#GOfJWS+(wc#TNz!bWc5O~lLF1+H{ zx+^(QX7LWrND5-ThGXa`+=32EV?J5n8!E79y;`d^Fvb{xC>#-+FJ&NNw%Cq@tvsbyC6z-#$A>2)#p;h%9O9^#M~*JWq%1FZ zn^t1raFvcfWKBsKz(U}d7+up^>8xwv%BxD02Jc=A9Bd-WzzKtv;|O#)icXu8=gwr; zeK>VHU9JKAW1mdeEpsRn1uE4CRfZd z@;||#F}z71#BmzZ8?um~ixxnW_`HF$MY20Of|y}v6If$A=LQ-54sEegXkik+Po@_# z-<0zS=2bvSrBDzS(2Z$^zLB|9c?Et35JFanW-kjN}vNS{_V zOF{U2offqXh4RiJTVe6I6@?kz!iG#IZUh*nn`7t}Y{mqsq@r1FH&(7`*fw*_1`(I6 zY9B~RBV1%S58N!>&mIQ#Y5zy~-emH{kh^mBNWt$U6Q~Srj6C^Gcvu)1KokV$hCXsF zNziy@Ly#0)13*lNk?5MStI=`^p+!q!bC>nYx2_$%V8@Hy85OpKP&5(xV5k~zQ=p$u z7uUdwIqjivqj{wK{$;^NO-qcCa)IzVbm5Cf4-_}aIoG6}grtTYP(KI<^jlO7?BmXk zdmK&@miw$xw^SajN(R59(nqxDN`9bewCQ`eHA`Vv5%z}^4P7B}<2lsMKTK;n^{Wm- zW^oL8MWxPllxg)seLSEa-gt^?!k}*uNV|}VTO!c5vn0KWfK#WsHiXxcNzq)K-|Yg1mAh1O;n}wjOe%9D>%@@2v{+c@#+l1 zDZwYy?d5^&B#Ea186@3QgM=0p9C|@fDBc2SDXPTr^k-_}$RX;>{yh;i*Gt6D1)F3* z%ig6ZkdBZ8@Jf=_QL)U;bzq^<4fxkE1N zhr%FQbD=h*!(dLAxT2?QM@%#U@>3=nYPT>$0+Z3vNWn1PegTBhNHipLy*nya&te_u zCV>IGCWnCnuB{I+aC#I)Mh!37iJ}Y2sGvBpCWTbcOr5INVenkDrRkkgwjZQ$vP#66 z)hlC7lbm9$A2$kHq)i2_6m=!g1|pt~w6G%&VUaX9AS9yz9%C^X3S9>pyk#D5j@e_b zs4clDVGGbKAf3FM87>n+F@vW!SX{6!oZVsM2qxglf@>K4dLfN;z)eDn<$|!l2HPHH z^l*W`g|vhm%eh4Z+6UBQK=w6=#AJb>;~?18O^qNJI%mP8Zm>?oY@jEK8iv7%b210$ zn&ttC#{{S`7+^^X)1~*5rS0>`B<32BZ+X|s_eb?D0&;_)Cb~hOVbGS!L8lTO!sOl7 zXzIlQx-n97gf{G)GpnZ=8U6z#Mv!a#;7G!dYlw$&T;~hlnxky*z+|Nn4KyRrPZ`4q zi2&YngOsrV(+R@&>uMIj^vD@BEGL#=*RBBQiFNppx0LL)o2}mwh=KZphP!0XJ(p@r}4wEILgJObq2nD#$z%_*tKlvxdxF9 z+BW%C&rV~HRL0pEOvkpZod+&MVIfihtg1baavLvyT7|Qzu}i7}quir%lRz}ak5xOH z6orrt&o((q5Dh1q2{%v;2bvU{NCw^S>ex@yZ$0h`OBUIK?r;jFb+dDw9d+4B;M+d9 z=VyvUSAoJ#N>LdX6l{}x~FGUohZY7mKqE0NWALn-E9$K!T)70Eu;lF|Z3MHP~wp|3eb5&lwa z`lzd^)uzjjoJ4KYhuS=^S}@E)$grTofReI+lWI+~96SgQfq(aMR>DoKfNP1O`)kS$N2mtu*ET#bs9O`*%*3!M zIGPlN5DmM;7)f*kevKEuexYppGeHpz=s_YfxLy$uek>D)@i|4}RuyGl!66C6L|($p zbzY6!&So|Bl*(`Vf06as&V+G9_9r`BAwXy@qv)GpdjWV zDc$(f>_LoYz~&FL@#RcAc-BCYb$-%KxDud!)sLzfPsRYGnPJl<3yb^6J6);lXwPxs zv`v$7*pXHIwW3jmFe|^TEeQ?ZU6u;~ftec!-QarK6kAi5f96SeF3vw9Y~VQ>z*DVs zHxA4!6pB|FcORqTQ9r@=KUoPLhk>SorWHADX#IK zql@Oo?%l*|dR?|P5jkDrx^>W+CU;eWzEN9U(h0i3jyhod&(>dt7CtD2FP7%Gy3K$R zNFY>e?U*l{4*RsvK6Ky}rA%;yL6bf=i6~pe%TpObAR)K42B>Z!4=@|{ zN|dwfAF)ALSVD+Nd&MJPU^CF%dIq~1o4)noY=tRJRv|FFbUZIjzXy&wJ$%_MpnEf z5)>~eUuPF4<U!sN48l4eJAxFn**w}J7aMj+Sl+MGj2 zK2qZmj1G3a(GZRu6`vj3pgG!*y&tn_JeDTt8ds4`9x^XV{UOjOQ=mjwO;rkq6OA_k z8w?b&TD;~sZNF2g(FaEcnxR$zG|d6f`S6XMgTy~xqj`i&5FZ8Yk;h(Iq;ck=<86`4 zmQ|zQ%977;h@~U|8Tu_s(Jys%4vvRcTmFBpb>wLhY&d=K@i$m|mXTnT(e7Mzs4Aqm zgG{wm$qC0mP$o{{=bMJZ2uM2*fX^dKFDOuDBasyP3fw%rb_rR?(6G%oaF4fc1*-uI zkQC&O7&cr>`l$lWMJq?(Hz8d}XbA80U|KSVHU|LPK$q3Wxb4pwCyhKp@Ubgrh6$in&fUj49_6l?8+Ypwa( z7fZneSNCY{9S)y}M)2VqQ>i6eOgf15p>`y^L$pdgIv!0a2R;%qV7rBa5*a(T3SDGD zRiy5DvxHD}4~d@C&c`;pd!DO72tRhroXkzn&`C)!y>%5;#Mz z0jtHNi}zgGS9qJOL-@S0Ff9~eeeEgR9750|NWmv0Kekpk=}5eeXd8E-sJdNDz;_uM zFx}Q5^HHn@8;mR%?aPMOE*FB-)X=?)%tP(rd<@Jg)9KJhh>MPO4o#jj=6$>6Mu+8z zaC$u@g?WmQyDNDfEqRtFXUe$)7# z=pGqYzsA+Xy27?%efE|su0d$}qX}BvjzoV9SxFwPkpvka z`sw*9w&xfaG(f1dluV*J!FuMFSKsHg=Eox5&IAq*3nl5B=r1i7K3fi5=V$S!h5nrnd{a^|vF+Kawv8Z~t5B zssjSO+d<>@~60mk2A&*$8ijA zKHOG#%f14*>N6-hI8AMWT{=Av|XmlLTydRu7~Pk#?!V zMO*@6lzj@Ff(sS+6h1DE}p53hEmIRKtQwLI>2a~+5eP$w4tFd#^a z$-tFeUL-6UXaOxK!TU=zs?&`7d2B_3yduJdHl6DJfioCQLN>jDUfEOH&NUmrj2?UcKy_ucc}oyj8-ZIr8BdL?-cNJV_{<4&hXUkrvM$ zU!EW#W?#v_7TO3l%0&xr#JyN6djK&3>1Nh;0cP4D>~0M}7pSm8rMGdp;vz8FT2Imi zZQw|suVbSFnJ0?+MABt$YSBRs5n_(zDj2nit(G>=XtoRh-k}uTYLZ~_Qbd$n4kSh)2qA!1jh4ho%$Df0JXXy5^G2WK1!S8I zqcXSw!lg02B=1Wy!2t^9*~P~M72d0$B5+~`8_#I08ZRB5P74ZWYQno#U6OKm6eMM# zIViF>M%j#wl%}f|t@n`@Gz(Ycepl6zX=zdAuwDz6XNuSoRJ|T5-j4u=!39W!@liPj zd<`0He;C#CfM~5tJ{8g?NiZN12Fiw~1_UU{Iz)I$;GnKvGRcAT!tCuNmjv{ag9h_C z()vcQTPi7tqJ<{q)&W!F6hs@L#~M~av?t2|jDUp0BHG9V0j0U1LRL)-gjI-bcYnT; zmPo-V2w71HsN04C_8h`6RIu>OfT8J$igL2~|%(}pGRRzFKLskW; zX!}NvSrKFzVlINB+aC91M0;91e^Ua*At|Dz;JmyQiY$UFa1iQ1j?3f3mUWbxVY(zx z;~0a5k-{odOSD=vl9am$3S|iPH?nR>8z;HQa;Rk^-VOU}w`b3i zgX!56WUC<`4*Z>T2Tp00Kp+XC5o1jA9LQ()GyoG>qW}V>=iv&%1PwYCvWayO%MwN+ zS?;NA6mLNDW&JzK59FbXF`XITaMwfTwNNkuTMdPz01hnE{RX718M(ZWEv-L6j`xnOQCg?7QZ>|qj^A|<$) zY)X#@#t6<8pupn?MN9yr9*_+kbHoc7+8^~iZXQNJBnHDHTK(12As~{RrCP+FznAaK zZ{-5t1rR2FHEx4~EWm74sH^}_agW8X(_(NeN?U-8;v|7EIN>9Ov{Q_CbfU1D(F|Et zWa>wc#b1QCePaXOQDBDesGtax7D;SA*^q_ijE7T-2gVSx8u<(^cq2Mg_@V_ia;y`F zfH_M`P8*FlTeRU)gQu!O8D7d!bi_g`lM^VU30AN87H* zBgF1d0Z0_VQAc$6d%2bR{f~edf~*uz;FS@C4?1u)W5JwesL~Tb-ThWpXU%>#tThNg z`35KNydch1qgecAwI|O+JRc&b6-?=5a1-jL3Z)TqL>r-;T*a5Qh(YqFB^P{%T`yx5 z89*l~e5!-lZ(Bi(f?;`+MLYz%aS;!W>C;iz+0F_pEQN#@8j!ub;aj<ngOK3AEs(doM zNK%#K@a0qEHi~&1+6V!6-v2`G=BXm}ib@oj7m3NNZg|^c#87>LBtjE%oBQx(#)iim zaxv(MW!2)zg?@<^nR^gcO_x2Hkfl5@R!~xwzQ>p$fh69H+G8t_rea{Oa`{TiV5uaC z^)tGTw3OHn!+ngeq#_?oxu9{0ps7(y+tHm_lxkWgby-jzDr$~=3R5fCpXq)(L0WR+ z6g;pwE6=(j3+pXDqzGS8EX?>>lI!$~uc^bMq6An8Sz`h>zeo`snQdVK^YY;mWwwZg z`7&G(EvTXp>oa7CHP4Uj>P}x)T*{Vm`n4d~#sqKTLB4{*4HZ_NOK7f_MOwnY{QegBNn9{rMKq|EL{?yMCF0XV*oU*uE3vh2H|#IvnxG3 z-!+4+F1~0{bjeCsI?{YAWDwKE>1u3uItJ`F(7LufEp}4-X(TYGN^#{bygQqUPMkVq#Us-0OtMF34$Y7c$y~oM0Y4C8e z5ok3{fU;dcD};!N3Ki_oa%YM52T}FuS*1Y8*S1Kq98P6n%_~SLGq!VXh;Av7!`jWK zL&q-36x>GPGXX78R7-`U3owu)lM>Ax6W%7EO)2(hGJ{4{Vjqf3Eq9Y>c$s{_Djq$a zcz_rV0{)l;D8{(MDO`lmCs}=HgNo=awE=HRz)Fp&MY82MK0p&dZUx|NgA#@x1;Heo z)0QU@i3T7=TYIHAg#a>vw2sYszQ=jE&!iqn73kQZt7Cw0V;~ckUI+>SF>Ni102z-= zfM{DwbOUQ;7@iC2<zh(xh7}x5s(K0<&CKw`1jr0e=9TPH#wAi5<8=fMgAphi?i?e>CYRZfAv)l8cPe z&8%WnX;0*_d?!jI68g2uY?A)~wvkyHTHJ&(GAgWiuL`6--4^15qTFsTth4f-viji; zzra9HG7`t90D+InAf)zZOL+joUb6ML%0S6$0t$bgDAN`xe#Y_2FZE4;gn=DZ%kEqX zJxw{rrw-{kbBP!=0#rV7v$9}BG%`~>^6+Kl^}ofx5D&~k1`yV^}9vUHV2#|k-A05?jc)PuFQ zP|PVWh%Ai+8yyQ_pg(*s)7!#~O;8vdP6K~Zay5hD8y%gd`JdRT0b^U6L*TOxr`8ix zrkEtHq19P4Bq%o08xmdsEkef)21yh90U>|MJ2f?~PfThR zl33Z6Z;F*kHbbtGg~lFfM!7&Sd_Zp6n+|0ZFgJOk;r3qrK7XMz<2oUA!Pwo~Ly!u` z>aX{pYlLXc@KeSe16(;y=GzQ-RcHr9F1R?X=Exk1EHGz94Mwgyqga)qLE30X@Gx1H z0GNq+gl&JgAi5d*j@Vj$fNnQirFxaN%Bmc0+t(6DV&{R`-UnLv9Zf%}Bp21ETy>T< zZZ>Kwj3ewresndL*P^@n_u`Sj=6dz6{18%;$XX`hz&@^yzVPXzl+!^{d9R>Li7_g= z5H>f|;y;wT#8(m=_!ECkfd)Wb{q=sVAeOb_x2aWq(wm^{YU9w0#yy7E_errZb- z6zjm>Aqsv@*~ziMd>t`wxdJMAoxj44-`|_(Dw#uP117eL>$zRd@DXrhu|c-%t^!JX z!mC5xo@KH;tc?Yl(+v%Y>uf!>Lq_^BG7$;o;ovMwv!+8q6O)zaYAOKe2WMXH+)Fxu zgIlcl^kG%(MGi>>s`1!ax|L`(0wp?(7&21w!Hy`%GwFiElUOZM6Csk(O{v~VwDc}x z!ca{c5{Ay`CQ6!Q&E%zZcO}%equQo88%WK;*qm2c2I=`-ifO@Yd}$}EaW&($phvr==mEv7)B`mfhfUL` z&3X7vVnaCSV=9(&3R$u8*dnmOoABTceM09+?V8Uu&s5}^@JSk6*b1jR!Ski}Ck}&@ zFjN=JaBaE?-PB$Uuu^t6Wtw-&36W1L0JL<9Y3!#hp4&6)34&rghk^-UvQYkbKxi_8 zuoD3TMAVoLfOd^ki`6H5n4}=BoX0udU@^jHyES(?2j@g|p8^dgAGHK9tX@o?=n8wbgKEy4Gx!537O%V64_@EUbGDee&mm8C-s-ns*4GM>AXBOe_! zFwa_S;bG~{ku(~ZC{#|iW2uieayx+Af+7Q!LF#Vrk6Dd%ktrXi31IDIK52v!fr3V)QS0pm{LCOByej}=8lUPy?Bse%9FgpXD6Tu?w9zY+ zbx1C9`BrkQqg;)%p-V<%w4Y5Xr-sgv?rGE3NC%W&$2w1d0Yvtb+4&(mb{1<9tw%b` zy^VC-3>BDB%>6df`Q!C!^}9rs;51Bt36^m60oiB;UFoazGUyb>IK4ep=&`DG6j4YLwwrW#aF`0H`#)k z3Xkj3yDjwqA3GI&W^x!0v5qHaKj#EYMwNm&{?OU0UfA%cnv|py!?8f`fb=C`_lW8j zk1vg_U#@WQa}pbD2$@z`0?xH12Sp zb*bOH?e3X%VGjKDaT&xS57L%2ho`kky}b!9a(MTPmT# z{ntA_b>4Y23Q70y1Wz_u6F8^PL=m<)>?>s*Q=o!acLhSlWUH+N_hdH;`zU9?YXg1| z)}uz@;nY(gi%QWj!caPFzuwv4L86RhHGm5yo4y4)0oq}fO=C61LiS8Z=n9*0!Mm9q zTd9yJ=o*2aT#lHkVP7QrA&DX-ZD0x7>t(X{htNG%Cg@C@Dm(i*3I}rd{d_3EQvw1G z1O~NbU@%QmY_Z9M^y4a1>^cOI{=QA7?c9sc7p*8~=jm&FH_+mnd1f)K+|XzsOTqqS zbH3f>wNaTg_;OJw27@j-g#0`HMGU7L6}DI=!QAQS5HS(IJ>A#<7Luvht?xXg83R

fETNMevwO+mYTFC)@^Ar{>f35uhUo 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 NbN`kq065MBBfty~TCM;9 literal 0 HcmV?d00001 diff --git a/docs/fonts/Montserrat/Montserrat-Bold.ttf b/docs/fonts/Montserrat/Montserrat-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..3bfd79b66f9dd2a59deef46385ff5d9c576d6aae GIT binary patch literal 47088 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 TV!uf}z&C0i)Vr*-XWRb)IXm>Y literal 0 HcmV?d00001 diff --git a/docs/fonts/Montserrat/Montserrat-Bold.woff b/docs/fonts/Montserrat/Montserrat-Bold.woff new file mode 100644 index 0000000000000000000000000000000000000000..92607654b7c7225082b354f7ec641d5595f3d8c1 GIT binary patch literal 112872 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�Msr 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

M3UDXbPVJmHZvev$~fdAmfKZ%lf*NUNInSXp>h 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�O;&bslFM}<`=-aHtO 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

<(a?CQuB=SbU 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 literal 0 HcmV?d00001 diff --git a/docs/fonts/Montserrat/Montserrat-Regular.eot b/docs/fonts/Montserrat/Montserrat-Regular.eot new file mode 100644 index 0000000000000000000000000000000000000000..735d12b51e3e109058fe239ec79c77cbd59f1384 GIT binary patch literal 106287 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

}Sj2B`TwI*d^qb9-QHahAo5Grcy-5U-|39@#! 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

kKuE;l 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

- +
Source:
+
@@ -297,32 +356,25 @@
Properties
-

Members

- - - -

(static, readonly) Status :Symbol

- - + -
- PsychoJS status. -
+ + + -
Type:
-
    -
  • - -Symbol + + -
  • -
+ + + + @@ -562,64 +614,43 @@
Properties:
-
- - - - - - +
+

PsychoJS status.

+
- - - +
Type:
+
    +
  • + +Symbol - - +
  • +
- - - - -
Source:
-
- - + -
- - - - - +

Methods

-

status

- - - - -
- Properties -
+ +

(protected) _captureErrors()

+ @@ -627,7 +658,10 @@

status - +
Source:
+
@@ -651,10 +685,7 @@

statusSource: -
+ @@ -668,29 +699,18 @@

status +

Capture all errors and display them in a pop-up error box.

+

- - - -

Methods

- - - - -

(protected) _captureErrors()

- - -
- Capture all errors and display them in a pop-up error box. -
@@ -704,36 +724,30 @@

(protected) - - - - - - + + - +

(async, protected) _configure(configURL, name)

- - - + +
Source:
@@ -742,42 +756,38 @@

(protected) - - - - - - - - - - - - + + + + + + + - - + -

(async, protected) _configure(configURL, name)

+ +

-
- Configure PsychoJS for the running experiment. + + +
+

Configure PsychoJS for the running experiment.

@@ -788,6 +798,8 @@

(async, protected) + +
Parameters:
@@ -829,7 +841,7 @@
Parameters:
- the URL of the configuration file +

the URL of the configuration file

@@ -852,7 +864,7 @@
Parameters:
- the name of the experiment +

the name of the experiment

@@ -864,12 +876,38 @@
Parameters:
-
+ + + + + + + + + + + + + + + + +

(async, protected) _getParticipantIPInfo()

+ + + +
+ + +
Source:
+
@@ -891,10 +929,9 @@
Parameters:
-
Source:
-
+ + + @@ -908,6 +945,10 @@
Parameters:
+
+

Get the IP information of the participant, asynchronously.

+

Note: we use http://www.geoplugin.net/json.gp.

+
@@ -923,33 +964,27 @@
Parameters:
- - - - -

(async, protected) _getParticipantIPInfo()

- - -
- Get the IP information of the participant, asynchronously. -

Note: we use http://www.geoplugin.net/json.gp.

-
+ + + +

(protected) _makeStatusTopLevel()

+ @@ -957,7 +992,10 @@

(async,
- +
Source:
+
@@ -981,10 +1019,7 @@

(async, -
Source:
-
+ @@ -998,6 +1033,9 @@

(async, +
+

Make the various Status top level, in order to accommodate PsychoPy's Code Components.

+
@@ -1013,22 +1051,12 @@

(async, - - - - -

getEnvironment() → {ExperimentHandler.Environment|undefined}

- - -
- Get the experiment's environment. -
@@ -1036,8 +1064,14 @@

getEnvi + + + + +

getEnvironment() → {ExperimentHandler.Environment|undefined}

+ @@ -1045,7 +1079,10 @@

getEnvi
- +
Source:
+
@@ -1069,10 +1106,7 @@

getEnvi -
Source:
-
+ @@ -1086,6 +1120,24 @@

getEnvi +
+

Get the experiment's environment.

+
+ + + + + + + + + + + + + + + @@ -1100,12 +1152,12 @@

Returns:
- the environment of the experiment, or undefined +

the environment of the experiment, or undefined

-
+
Type
@@ -1114,33 +1166,74 @@
Returns:
ExperimentHandler.Environment | -undefined +undefined + + + +
+ + + + + + + + + + +

importAttributes(obj)

+ + + + + + +
+ + +
Source:
+
+ + + + - -
+ + + + + - - + -

importAttributes(obj)

+ + + + + +
+ -
- Make the attributes of the given object those of window, such that they become global. + +
+

Make the attributes of the given object those of window, such that they become global.

@@ -1151,6 +1244,8 @@

impor + +

Parameters:
@@ -1192,7 +1287,7 @@
Parameters:
- the object whose attributes are to become global +

the object whose attributes are to become global

@@ -1204,81 +1299,77 @@
Parameters:
-
- - - - - - - - - - - - - -
Source:
-
- + + - +

openWindow(options)

-
- - - - +
+ +
Source:
+
+ + + + + + + + + + + - - + -

openWindow(options)

+ +
-
- Open a PsychoJS Window. + +
+

Open a PsychoJS Window.

This opens a PIXI canvas.

Note: we can only open one window.

@@ -1291,6 +1382,8 @@

openWindow< + +

Parameters:
@@ -1386,7 +1479,7 @@
Properties
- the name of the window +

the name of the window

@@ -1419,7 +1512,7 @@
Properties
- whether or not to go fullscreen +

whether or not to go fullscreen

@@ -1432,7 +1525,7 @@
Properties
-Color +Color @@ -1452,7 +1545,7 @@
Properties
- the background color of the window +

the background color of the window

@@ -1485,7 +1578,7 @@
Properties
- the units of the window +

the units of the window

@@ -1518,7 +1611,7 @@
Properties
- whether or not to log +

whether or not to log

@@ -1551,8 +1644,8 @@
Properties
- whether or not to wait for all rendering operations to be done -before flipping +

whether or not to wait for all rendering operations to be done +before flipping

@@ -1571,50 +1664,6 @@
Properties
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - @@ -1629,13 +1678,13 @@
Throws:
-
- exception if a window has already been opened +
+

exception if a window has already been opened

-
+
Type
@@ -1657,25 +1706,65 @@
Throws:
- - -

(async) quit(options)

+ + + + + + +
+ + +
Source:
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
-
- Close everything and exit nicely at the end of the experiment, -potentially redirecting to one of the URLs previously specified by setRedirectUrls. + +
+

Close everything and exit nicely at the end of the experiment, +potentially redirecting to one of the URLs previously specified by setRedirectUrls.

Note: if the resource manager is busy, we inform the participant that he or she needs to wait for a bit.

@@ -1688,6 +1777,8 @@

(async) quitParameters:

@@ -1789,7 +1880,7 @@
Properties
- optional message to be displayed in a dialog box before quitting +

optional message to be displayed in a dialog box before quitting

@@ -1823,12 +1914,12 @@
Properties
- false + false - whether or not the participant has completed the experiment +

whether the participant has completed the experiment

@@ -1847,80 +1938,77 @@
Properties
-
- - - - - - - - - - - - - -
Source:
-
- + + - +

schedule(task, args)

-
- - - - +
+ +
Source:
+
+ + + + + + + + + + + - - + -

schedule(task, args)

+ +
+ + -
- Schedule a task. +
+

Schedule a task.

@@ -1931,6 +2019,8 @@

scheduleParameters:

@@ -1961,13 +2051,18 @@
Parameters:
+ +module:util.Scheduler~Task + + + - the task to be scheduled +

the task to be scheduled

@@ -1979,20 +2074,50 @@
Parameters:
+ +* + + + - + + + + +

arguments for that task

+ + + + + + + + + + + + + + + + + + + + + + - - arguments for that task - + + - - +

scheduleCondition(condition, thenScheduler, elseScheduler)

+ @@ -2000,7 +2125,10 @@
Parameters:
- +
Source:
+
@@ -2024,10 +2152,7 @@
Parameters:
-
Source:
-
+ @@ -2041,6 +2166,9 @@
Parameters:
+
+

Schedule a series of task based on a condition.

+
@@ -2052,35 +2180,6 @@
Parameters:
- - - - - - - - - - -

scheduleCondition(condition, thenScheduler, elseScheduler)

- - - - - - -
- Schedule a series of task based on a condition. -
- - - - - - - - -
Parameters:
@@ -2135,7 +2234,7 @@
Parameters:
-Scheduler +Scheduler @@ -2145,7 +2244,7 @@
Parameters:
- scheduler to run if the condition is true +

scheduler to run if the condition is true

@@ -2158,7 +2257,7 @@
Parameters:
-Scheduler +Scheduler @@ -2168,7 +2267,7 @@
Parameters:
- scheduler to run if the condition is false +

scheduler to run if the condition is false

@@ -2180,80 +2279,77 @@
Parameters:
-
- - - - - - - - - - - - - -
Source:
-
- + + - +

setRedirectUrls(completionUrl, cancellationUrl)

-
- - - - +
+ +
Source:
+
+ + + + + + + + + + + - - + -

setRedirectUrls(completionUrl, cancellationUrl)

+ +
+ + -
- Set the completion and cancellation URL to which the participant will be redirect at the end of the experiment. +
+

Set the completion and cancellation URL to which the participant will be redirect at the end of the experiment.

@@ -2264,6 +2360,8 @@

setRed + +

Parameters:
@@ -2305,7 +2403,7 @@
Parameters:
- the completion URL +

the completion URL

@@ -2328,7 +2426,7 @@
Parameters:
- the cancellation URL +

the cancellation URL

@@ -2340,81 +2438,77 @@
Parameters:
-
- - - - - - - - - - - - - -
Source:
-
- + + - +

(async) start(options, resourcesopt)

-
- - - - +
+ +
Source:
+
+ + + + + + + + + + + - - + -

(async) start(options, resourcesopt)

+ +
+ -
- Start the experiment. +
+

Start the experiment.

The resources are specified in the following fashion:

-
+
-

This mixin implements various unit-handling measurement methods.

+ -

Note: (a) this is the equivalent of PsychoPY's WindowMixin. - (b) it will most probably be made obsolete by a fully-integrated unit approach. -

- +
- + +
Source:
+
+ + -
+ @@ -75,10 +105,21 @@

-
Source:
-
+

+ + + + + +

This mixin implements various unit-handling measurement methods.

+

Note: (a) this is the equivalent of PsychoPY's WindowMixin. + (b) it will most probably be made obsolete by a fully-integrated unit approach. +

+ + + + +
@@ -86,15 +127,37 @@

-

+ + + + + + + + + +

Methods

+ + -

+ +

(protected) _getHorLengthPix(length) → {number}

+ + + +
+ + +
Source:
+
@@ -108,23 +171,32 @@

-

Methods

- - + -

(protected) _getHorLengthPix(length) → {number}

+ + + + + + + + + +
+ -
- Convert the given length from stimulus units to pixel units + +
+

Convert the given length from stimulus units to pixel units

@@ -135,6 +207,8 @@

(protected) + +
Parameters:
@@ -176,7 +250,7 @@
Parameters:
- the length in stimulus units +

the length in stimulus units

@@ -188,102 +262,101 @@
Parameters:
-
- - - - - - - - - - +
Returns:
- + +
+
    +
  • the length in pixels
  • +
+
- - -
Source:
-
- - +
+
+ Type +
+
+ +number - - +
+ + + + +

(protected) _getLengthPix(length, integerCoordinatesopt) → {number}

+ +
+ +
Source:
+
+ + + -
Returns:
- - -
- - the length in pixels -
- - - -
-
- Type -
-
- -number + + -
-
+ + + + + - - + -

(protected) _getLengthPix(length, integerCoordinatesopt) → {number}

+ +
-
- Convert the given length from stimulus unit to pixel units. + + +
+

Convert the given length from stimulus unit to pixel units.

@@ -294,6 +367,8 @@

(protected) Parameters:

@@ -351,7 +426,7 @@
Parameters:
- the length in stimulus units +

the length in stimulus units

@@ -385,12 +460,12 @@
Parameters:
- false + false - whether or not to round the length. +

whether or not to round the length.

@@ -402,102 +477,101 @@
Parameters:
-
- - - - - - - - - - +
Returns:
- + +
+
    +
  • the length in pixel units
  • +
+
- - -
Source:
-
- - +
+
+ Type +
+
+ +number - - +
+ + + + +

(protected) _getLengthUnits(length_px) → {number}

+ +
+ +
Source:
+
+ + + -
Returns:
- - -
- - the length in pixel units -
- - - -
-
- Type -
-
- -number + + -
-
+ + + + + - - + -

(protected) _getLengthUnits(length_px) → {number}

+ +
+ + -
- Convert the given length from pixel units to the stimulus units +
+

Convert the given length from pixel units to the stimulus units

@@ -508,6 +582,8 @@

(protected) < + +
Parameters:
@@ -549,7 +625,7 @@
Parameters:
- the length in pixel units +

the length in pixel units

@@ -561,102 +637,101 @@
Parameters:
-
- - - - - - - - - - +
Returns:
- + +
+
    +
  • the length in stimulus units
  • +
+
- - -
Source:
-
- - +
+
+ Type +
+
+ +number - - +
+ + + + +

(protected) _getVerLengthPix(length) → {number}

+ +
+ +
Source:
+
+ + + -
Returns:
- - -
- - the length in stimulus units -
- - - -
-
- Type -
-
- -number + + -
-
+ + + + + - - + -

(protected) _getVerLengthPix(length) → {number}

+ +
-
- Convert the given length from pixel units to the stimulus units + + +
+

Convert the given length from pixel units to the stimulus units

@@ -667,6 +742,8 @@

(protected) + +
Parameters:
@@ -708,7 +785,7 @@
Parameters:
- the length in pixel units +

the length in pixel units

@@ -720,50 +797,6 @@
Parameters:
-
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - @@ -778,12 +811,14 @@
Returns:
- - the length in stimulus units +
    +
  • the length in stimulus units
  • +
-
+
Type
@@ -799,8 +834,6 @@
Returns:
- - @@ -814,19 +847,23 @@
Returns:
+ +

- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + \ No newline at end of file diff --git a/docs/module-core.html b/docs/module-core.html index 1474c954..8c5e0192 100644 --- a/docs/module-core.html +++ b/docs/module-core.html @@ -1,23 +1,47 @@ + - JSDoc: Module: core - - - + core - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + + + -

Module: core

+ + +
+ +

core

+ @@ -33,13 +57,15 @@

Module: core

- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
- - + + + + + + + + \ No newline at end of file diff --git a/docs/module-data.ExperimentHandler.html b/docs/module-data.ExperimentHandler.html deleted file mode 100644 index 75517d3c..00000000 --- a/docs/module-data.ExperimentHandler.html +++ /dev/null @@ -1,1675 +0,0 @@ - - - - - JSDoc: Class: ExperimentHandler - - - - - - - - - - -
- -

Class: ExperimentHandler

- - - - - - -
- -
- -

- data.ExperimentHandler(options)

- - -
- -
-
- - - - - - -

new ExperimentHandler(options)

- - - - - - -
-

An ExperimentHandler keeps track of multiple loops and handlers. It is particularly useful -for generating a single data file from an experiment with many different loops (e.g. interleaved -staircases or loops within loops.

-
- - - - - - - - - -
Parameters:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
options - - -Object - - - - -
Properties
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
psychoJS - - -module:core.PsychoJS - - - - the PsychoJS instance
name - - -string - - - - name of the experiment
extraInfo - - -Object - - - - additional information, such as session name, participant name, etc.
- -
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - -
- - -

Extends

- - - - -
    -
  • PsychObject
  • -
- - - - - - - - - - - - - - - - - -

Methods

- - - - - - - -

(protected) _getLoopAttributes(loop)

- - - - - - -
- Get the attribute names and values for the current trial of a given loop. -

Only info relating to the trial execution are returned.

-
- - - - - - - - - -
Parameters:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
loop - - -Object - - - - the loop
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

addData(key, value)

- - - - - - -
- Add the key/value pair. - -

Multiple key/value pairs can be added to any given entry of the data file. There are -considered part of the same entry until a call to nextEntry is made.

-
- - - - - - - - - -
Parameters:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
key - - -Object - - - - the key
value - - -Object - - - - the value
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

addLoop(loop)

- - - - - - -
- Add a loop. - -

The loop might be a TrialHandler, for instance.

-

Data from this loop will be included in the resulting data files.

-
- - - - - - - - - -
Parameters:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
loop - - -Object - - - - the loop, e.g. an instance of TrialHandler or StairHandler
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

experimentEnded()

- - - - - - -
- Getter for experimentEnded. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

experimentEnded()

- - - - - - -
- Setter for experimentEnded. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

isEntryEmpty() → {boolean}

- - - - - - -
- Whether or not the current entry (i.e. trial data) is empty. -

Note: this is mostly useful at the end of an experiment, in order to ensure that the last entry is saved.

-
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
To Do:
-
-
    -
  • This really should be renamed: IsCurrentEntryNotEmpty
  • -
-
- -
- - - - - - - - - - - - - - - -
Returns:
- - -
- whether or not the current entry is empty -
- - - -
-
- Type -
-
- -boolean - - -
-
- - - - - - - - - - - - - -

nextEntry(snapshots)

- - - - - - -
- Inform this ExperimentHandler that the current trial has ended. Further calls to addData -will be associated with the next trial. -
- - - - - - - - - -
Parameters:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
snapshots - - -Object -| - -Array.<Object> -| - -undefined - - - - array of loop snapshots
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

removeLoop(loop)

- - - - - - -
- Remove the given loop from the list of unfinished loops, e.g. when it has completed. -
- - - - - - - - - -
Parameters:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
loop - - -Object - - - - the loop, e.g. an instance of TrialHandler or StairHandler
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

save(options)

- - - - - - -
- Save the results of the experiment. - -
    -
  • For an experiment running locally, the results are offered for immediate download.
  • -
  • For an experiment running on the server, the results are uploaded to the server.
  • -
-

-

- - - - - - - - - -
Parameters:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
options - - -Object - - - - -
Properties
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeAttributesDefaultDescription
attributes - - -Array.<Object> - - - - - - <optional>
- - - - - -
- - the attributes to be saved
sync - - -boolean - - - - - - <optional>
- - - - - -
- - false - - whether or not to communicate with the server in a synchronous manner
tag - - -string - - - - - - <optional>
- - - - - -
- - '' - - an optional tag to add to the filename to which the data is saved (for CSV and XLSX saving options)
clear - - -boolean - - - - - - <optional>
- - - - - -
- - false - - whether or not to clear all experiment results immediately after they are saved (this is useful when saving data in separate chunks, throughout an experiment)
- -
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
- - - - - \ No newline at end of file diff --git a/docs/module-data.MultiStairHandler.html b/docs/module-data.MultiStairHandler.html deleted file mode 100644 index 3f7c35e6..00000000 --- a/docs/module-data.MultiStairHandler.html +++ /dev/null @@ -1,1225 +0,0 @@ - - - - - JSDoc: Class: MultiStairHandler - - - - - - - - - - -
- -

Class: MultiStairHandler

- - - - - - -
- -
- -

- data.MultiStairHandler()

- - -
- -
-
- - - - - - -

new MultiStairHandler()

- - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - -

Members

- - - -

(static, readonly) StaircaseStatus :Symbol

- - - - -
- Staircase status. -
- - - -
Type:
-
    -
  • - -Symbol - - -
  • -
- - - - - -
Properties:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
RUNNING - - -Symbol - - - - The staircase is currently running.
FINISHED - - -Symbol - - - - The staircase is now finished.
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - -

(static, readonly) StaircaseType :Symbol

- - - - -
- MultiStairHandler staircase type. -
- - - -
Type:
-
    -
  • - -Symbol - - -
  • -
- - - - - -
Properties:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
SIMPLE - - -Symbol - - - - Simple staircase handler.
QUEST - - -Symbol - - - - QUEST handler.
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - -

Methods

- - - - - - - -

(protected) _nextTrial() → {void}

- - - - - - -
- Move onto the next trial. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - - - -
-
- Type -
-
- -void - - -
-
- - - - - - - - - - - - - -

(protected) _prepareStaircases() → {void}

- - - - - - -
- Setup the staircases, according to the conditions. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - - - -
-
- Type -
-
- -void - - -
-
- - - - - - - - - - - - - -

(protected) _validateConditions() → {void}

- - - - - - -
- Validate the conditions. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - - - -
-
- Type -
-
- -void - - -
-
- - - - - - - - - - - - - -

addResponse() → {void}

- - - - - - -
- Add a response to the current staircase. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - - - -
-
- Type -
-
- -void - - -
-
- - - - - - - - - - - - - -

currentStaircase() → {module.data.TrialHandler}

- - - - - - -
- Get the current staircase. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - -
- the current staircase, or undefined if the trial has ended -
- - - -
-
- Type -
-
- -module.data.TrialHandler - - -
-
- - - - - - - - - - - - - -

intensity() → {number}

- - - - - - -
- Get the current intensity. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - -
- the intensity of the current staircase, or undefined if the trial has ended -
- - - -
-
- Type -
-
- -number - - -
-
- - - - - - - - - - - - - -

intensity() → {number}

- - - - - - -
- Get the current value of the variable / contrast / threshold. - -

This is the getter associated to getQuestValue.

-
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - -
- the intensity of the current staircase, or undefined if the trial has ended -
- - - -
-
- Type -
-
- -number - - -
-
- - - - - - - - - - - - - -
- -
- - - - -
- - - -
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
- - - - - \ No newline at end of file diff --git a/docs/module-data.QuestHandler.html b/docs/module-data.QuestHandler.html deleted file mode 100644 index be777ef4..00000000 --- a/docs/module-data.QuestHandler.html +++ /dev/null @@ -1,1551 +0,0 @@ - - - - - JSDoc: Class: QuestHandler - - - - - - - - - - -
- -

Class: QuestHandler

- - - - - - -
- -
- -

- data.QuestHandler()

- - -
- -
-
- - - - - - -

new QuestHandler()

- - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - -
- - - - - - - - - - - - - - -

Members

- - - -

(static, readonly) Method :Symbol

- - - - -
- QuestHandler method -
- - - -
Type:
-
    -
  • - -Symbol - - -
  • -
- - - - - -
Properties:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
QUANTILE - - -Symbol - - - - Quantile threshold estimate.
MEAN - - -Symbol - - - - Mean threshold estimate.
MODE - - -Symbol - - - - Mode threshold estimate.
- - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - -

Methods

- - - - - - - -

(protected) _estimateQuestValue() → {void}

- - - - - - -
- Estimate the next value of the QUEST variable, based on the current value -and on the selected QUEST method. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - - - -
-
- Type -
-
- -void - - -
-
- - - - - - - - - - - - - -

(protected) _setupJsQuest() → {void}

- - - - - - -
- Setup the JS Quest object. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - - - -
-
- Type -
-
- -void - - -
-
- - - - - - - - - - - - - -

addResponse() → {void}

- - - - - - -
- Add a response and update the PDF. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - - - -
-
- Type -
-
- -void - - -
-
- - - - - - - - - - - - - -

confInterval()

- - - - - - -
- Get an estimate of the 5%-95% confidence interval (CI). -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

getQuestValue() → {number}

- - - - - - -
- Get the current value of the variable / contrast / threshold. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - -
- the current QUEST value for the variable / contrast / threshold -
- - - -
-
- Type -
-
- -number - - -
-
- - - - - - - - - - - - - -

mean() → {number}

- - - - - - -
- Get the mean of the Quest posterior PDF. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - -
- the mean -
- - - -
-
- Type -
-
- -number - - -
-
- - - - - - - - - - - - - -

mode() → {number}

- - - - - - -
- Get the mode of the Quest posterior PDF. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - -
- the mode -
- - - -
-
- Type -
-
- -number - - -
-
- - - - - - - - - - - - - -

quantile() → {number}

- - - - - - -
- Get the standard deviation of the Quest posterior PDF. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - -
- the quantile -
- - - -
-
- Type -
-
- -number - - -
-
- - - - - - - - - - - - - -

sd() → {number}

- - - - - - -
- Get the standard deviation of the Quest posterior PDF. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - -
Returns:
- - -
- the standard deviation -
- - - -
-
- Type -
-
- -number - - -
-
- - - - - - - - - - - - - -

setMethod(method, log)

- - - - - - -
- Setter for the method attribute. -
- - - - - - - - - -
Parameters:
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
NameTypeDescription
method - - -mixed - - - - the method value, PsychoPy-style values ("mean", "median", -"quantile") are converted to their respective QuestHandler.Method values
log - - -boolean - - - - whether or not to log the change of seed
- - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -

simulate()

- - - - - - -
- Simulate a response. -
- - - - - - - - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
Source:
-
- - - - - - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - -
- -
- - - - -
- - - -
- -
- Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
- - - - - \ No newline at end of file diff --git a/docs/module-data.TrialHandler.html b/docs/module-data.TrialHandler.html index 7c7d3036..873316f8 100644 --- a/docs/module-data.TrialHandler.html +++ b/docs/module-data.TrialHandler.html @@ -1,23 +1,47 @@ + - JSDoc: Class: TrialHandler - - - + TrialHandler - PsychoJS API + + + + + + + + + + - - + + + + - -
+ + + + + + -

Class: TrialHandler

+
+ +

TrialHandler

+ @@ -28,28 +52,84 @@

Class: TrialHandler

-

- data.TrialHandler(options)

+

+ data. -

A Trial Handler handles the importing and sequencing of conditions.

+ TrialHandler +

+ +

A Trial Handler handles the importing and sequencing of conditions.

-
+
+

Constructor

-

new TrialHandler(options)

+ + + + + + +
+ + +
Source:
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
To Do:
+
+
    +
  • extraInfo is not taken into account, we use the expInfo of the ExperimentHandler instead
  • +
+
+ +
+ + + + @@ -102,7 +182,7 @@
Parameters:
- the handler options +

the handler options

Properties
@@ -160,7 +240,7 @@
Properties
- the PsychoJS instance +

the PsychoJS instance

@@ -197,12 +277,12 @@
Properties
- [undefined] + [undefined] - if it is a string, we treat it as the name of a condition resource +

if it is a string, we treat it as the name of a condition resource

@@ -237,7 +317,7 @@
Properties
- number of repetitions +

number of repetitions

@@ -272,7 +352,7 @@
Properties
- the trial method +

the trial method

@@ -307,7 +387,7 @@
Properties
- additional information to be stored alongside the trial data, e.g. session ID, participant ID, etc. +

additional information to be stored alongside the trial data, e.g. session ID, participant ID, etc.

@@ -342,7 +422,7 @@
Properties
- seed for the random number generator +

seed for the random number generator

@@ -376,12 +456,12 @@
Properties
- false + false - whether or not to log +

whether or not to log

@@ -400,85 +480,65 @@
Properties
-
- - - - - - - - - - - - - - - -
Source:
-
- - + +
-
To Do:
-
-
    -
  • extraInfo is not taken into account, we use the expInfo of the ExperimentHandler instead
  • -
-
- -
- - - +

Extends

+ +
    +
  • PsychObject
  • +
+ + + + + + + + +

Members

+ + +

(static, readonly) Method :Symbol

+
-

- +
Source:
+
-

Extends

- - - - -
    -
  • PsychObject
  • -
- @@ -493,32 +553,23 @@

Extends

-

Members

- - - -

(static, readonly) Method :Symbol

- - + -
- TrialHandler method -
- + + -
Type:
-
    -
  • - -Symbol + + -
  • -
+ + + +
@@ -564,7 +615,7 @@
Properties:
- Conditions are presented in the order they are given. +

Conditions are presented in the order they are given.

@@ -587,7 +638,7 @@
Properties:
- Conditions are shuffled within each repeat. +

Conditions are shuffled within each repeat.

@@ -610,7 +661,7 @@
Properties:
- Conditions are fully randomised across all repeats. +

Conditions are fully randomised across all repeats.

@@ -633,7 +684,7 @@
Properties:
- Same as above, but named to reflect PsychoPy boileplate. +

Same as above, but named to reflect PsychoPy boileplate.

@@ -643,10 +694,44 @@
Properties:
+ + +
+

TrialHandler method

+
+ + + +
Type:
+
    +
  • + +Symbol + + +
  • +
+ + + + + + + + +

experimentHandler

+ + + + +
- +
Source:
+
@@ -670,10 +755,7 @@
Properties:
-
Source:
-
+ @@ -687,19 +769,20 @@
Properties:
+
+

Getter for experimentHandler.

+
+ - - -

finished

-
- Getter for the finished attribute. -
+ + +

experimentHandler

@@ -708,7 +791,10 @@

finished - +
Source:
+
@@ -732,10 +818,7 @@

finishedSource: -
+ @@ -749,19 +832,20 @@

finished +

Setter for experimentHandler.

+

+ - - -

finished

-
- Setter for the finished attribute. -
+ + +

finished

@@ -770,7 +854,10 @@

finished - +
Source:
+
@@ -794,10 +881,7 @@

finishedSource: -
+ @@ -811,29 +895,147 @@

finished +

Getter for the finished attribute.

+

- - - -

Methods

- - - - -

(static) fromSnapshot(snapshot)

- - + + +

finished

+ + + + + +
+ + +
Source:
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+

Setter for the finished attribute.

+
+ + + + + + + + + + + + +

Methods

+ + + + + + +

(static) fromSnapshot(snapshot)

+ + + + + + +
+ + +
Source:
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + +
+

Set the internal state of the snapshot's trial handler from the snapshot.

+
+ -
- Set the internal state of the snapshot's trial handler from the snapshot. -
@@ -884,8 +1086,8 @@
Parameters:
- the snapshot from which to update the current internal state of the - snapshot's trial handler +

the snapshot from which to update the current internal state of the +snapshot's trial handler

@@ -897,84 +1099,79 @@
Parameters:
-
- - - - - - - - - - - - - -
Source:
-
- + + - +

(static) importConditions(serverManager, resourceName, selectionopt) → {Object}

-
- - - - +
+ +
Source:
+
+ + + + + + + + + + + - - + -

(static) importConditions(serverManager, resourceName, selectionopt) → {Object}

+ +
-
- Import a list of conditions from a .xls, .xlsx, .odp, or .csv resource. + +
+

Import a list of conditions from a .xls, .xlsx, .odp, or .csv resource.

The output is suitable as an input to 'TrialHandler', 'trialTypes' or 'MultiStairHandler' as a 'conditions' list.

-

The resource should contain one row per type of trial needed and one column for each parameter that defines the trial type. The first row should give parameter names, which should: @@ -983,10 +1180,7 @@

(static) begin with a letter (upper or lower case)
  • contain no spaces or other punctuation (underscores are permitted)
  • -

    Note that we only consider the first worksheet for .xls, .xlsx and .odp resource.

    - -

    'selection' is used to select a subset of condition indices to be used It can be a single integer, an array of indices, or a string to be parsed, e.g.: 5 @@ -1005,6 +1199,8 @@

    (static) Parameters:

    @@ -1040,7 +1236,7 @@
    Parameters:
    -module:core.ServerManager +module:core.ServerManager @@ -1062,7 +1258,7 @@
    Parameters:
    - the server manager +

    the server manager

    @@ -1097,7 +1293,7 @@
    Parameters:
    - the name of the resource containing the list of conditions, which must have been registered with the server manager. +

    the name of the resource containing the list of conditions, which must have been registered with the server manager.

    @@ -1131,12 +1327,12 @@
    Parameters:
    - null + null - the selection +

    the selection

    @@ -1148,50 +1344,6 @@
    Parameters:
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - @@ -1206,13 +1358,13 @@
    Throws:
    -
    - Throws an exception if importing the conditions failed. +
    +

    Throws an exception if importing the conditions failed.

    -
    +
    Type
    @@ -1235,12 +1387,12 @@
    Returns:
    - the parsed conditions as an array of 'object as map' +

    the parsed conditions as an array of 'object as map'

    -
    +
    Type
    @@ -1256,41 +1408,25 @@
    Returns:
    - - - -

    (protected) _prepareTrialList() → {void}

    - +

    (protected) _prepareSequence()

    -
    - Prepare the trial list. -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -1314,10 +1450,7 @@

    (protected) -
    Source:
    -
    + @@ -1331,6 +1464,31 @@

    (protected) +
    +

    Prepare the sequence of trials.

    +

    The returned sequence is a matrix (an array of arrays) of trial indices +with nStim columns and nReps rows. Note that this is the transpose of the +matrix return by PsychoPY. +

    Example: with 3 trial and 5 repetitions, we get:

    +
      +
    • sequential: +[[0 1 2] +[0 1 2] +[0 1 2] +[0 1 2] +[0 1 2]]
    • +
    +

    These 3*5 = 15 trials will be returned by the TrialHandler generator

    +
      +
    • with method = 'sequential' in the order: +0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2, 0, 1, 2
    • +
    • with method = 'random' in the order (amongst others): +2, 1, 0, 0, 2, 1, 0, 1, 2, 0, 1, 2, 1, 2, 0
    • +
    • with method = 'fullRandom' in the order (amongst others): +2, 0, 0, 1, 0, 2, 1, 2, 0, 1, 1, 1, 2, 0, 2
    • +
    +

    +
    @@ -1341,204 +1499,187 @@

    (protected) -
    Returns:
    - -
    -
    - Type -
    -
    - -void -
    -
    - - - - + + + + + + + + -

    addData(key, value)

    - + +

    (protected) _prepareTrialList() → {void}

    -
    - Add a key/value pair to data about the current trial held by the experiment handler -
    +
    + +
    Source:
    +
    + + + + + + + -
    Parameters:
    - - - - - - + - + - + - + - - - + - - - - - + - - - - - +
    +

    Prepare the trial list.

    +
    - - - - - - - - - - - - -
    NameTypeDescription
    key - - -Object + + - - the key
    value - - -Object - - the value
    -
    - - - - - - - - - - +
    Returns:
    - + - - -
    Source:
    -
    - +
    +
    + Type +
    +
    + +void - - +
    +
    -
    + + + +

    addData(key, value)

    + +
    + +
    Source:
    +
    + + + + + + + + + - - + -

    forEach(callback)

    + + + + + +
    + -
    - Execute the callback for each trial in the sequence. + +
    +

    Add a key/value pair to data about the current trial held by the experiment handler

    @@ -1549,6 +1690,8 @@

    forEachParameters:

    @@ -1574,18 +1717,46 @@
    Parameters:
    - callback + key + +Object + + + - +

    the key

    + + + + + + + value + + + + + +Object + + + + + + + + + +

    the value

    @@ -1597,11 +1768,39 @@
    Parameters:
    -
    + + + + + + + + + + + + + + + + +

    forEach(callback)

    + + + + + + +
    +
    Source:
    +
    + @@ -1624,10 +1823,7 @@
    Parameters:
    -
    Source:
    -
    + @@ -1641,6 +1837,9 @@
    Parameters:
    +
    +

    Execute the callback for each trial in the sequence.

    +
    @@ -1652,29 +1851,52 @@
    Parameters:
    +
    Parameters:
    + + + + + + + + + - - + + + + -

    getAttributes() → {Array.string}

    - + + + + + + + + + + + + + + + +
    NameTypeDescription
    callback + +
    -
    - Get the attributes of the trials. -

    Note: we assume that all trials in the trialList share the same attributes -and consequently consider only the attributes of the first trial.

    -
    @@ -1688,12 +1910,27 @@

    getAttri -
    + + + + + +

    getAttributes() → {Array.string}

    + + + +
    + + +
    Source:
    +
    @@ -1715,10 +1952,9 @@

    getAttri -
    Source:
    -
    + + + @@ -1732,6 +1968,26 @@

    getAttri +
    +

    Get the attributes of the trials.

    +

    Note: we assume that all trials in the trialList share the same attributes +and consequently consider only the attributes of the first trial.

    +
    + + + + + + + + + + + + + + + @@ -1746,12 +2002,12 @@

    Returns:
    - the attributes +

    the attributes

    -
    +
    Type
    @@ -1767,41 +2023,25 @@
    Returns:
    - - -

    getCurrentTrial() → {Object}

    - -
    - Get the current trial. -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -1825,10 +2065,7 @@

    getCur -
    Source:
    -
    + @@ -1842,6 +2079,24 @@

    getCur +
    +

    Get the current trial.

    +
    + + + + + + + + + + + + + + + @@ -1856,12 +2111,12 @@

    Returns:
    - the current trial +

    the current trial

    -
    +
    Type
    @@ -1877,23 +2132,64 @@
    Returns:
    - - -

    getEarlierTrial(nopt) → {Object|undefined}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + -
    - Get the nth previous trial. +
    +

    Get the nth previous trial.

    Note: this is useful for comparisons in n-back tasks.

    @@ -1905,6 +2201,8 @@

    getEar + +

    Parameters:
    @@ -1961,12 +2259,12 @@
    Parameters:
    - -1 + -1 - increment +

    increment

    @@ -1978,105 +2276,102 @@
    Parameters:
    -
    - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - +
    Returns:
    - + +
    +

    the past trial or undefined if attempting to go prior to the first trial.

    +
    - -
    +
    +
    + Type +
    +
    + +Object +| +undefined +
    +
    + + + + +

    getFutureTrial(nopt) → {Object|undefined}

    + -
    Returns:
    - -
    - the past trial or undefined if attempting to go prior to the first trial. -
    +
    + +
    Source:
    +
    + + -
    -
    - Type -
    -
    - -Object -| + -undefined + + -
    -
    + + + + + - - + -

    getFutureTrial(nopt) → {Object|undefined}

    + +
    -
    - Get the nth future or past trial, without advancing through the trial list. + + +
    +

    Get the nth future or past trial, without advancing through the trial list.

    @@ -2087,6 +2382,8 @@

    getFutu + +

    Parameters:
    @@ -2143,12 +2440,12 @@
    Parameters:
    - 1 + 1 - increment +

    increment

    @@ -2160,50 +2457,6 @@
    Parameters:
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - @@ -2218,13 +2471,13 @@
    Returns:
    - the future trial (if n is positive) or past trial (if n is negative) -or undefined if attempting to go beyond the last trial. +

    the future trial (if n is positive) or past trial (if n is negative) +or undefined if attempting to go beyond the last trial.

    -
    +
    Type
    @@ -2243,44 +2496,25 @@
    Returns:
    - - -

    getSnapshot() → {Snapshot}

    - -
    - Get a snapshot of the current internal state of the trial handler (e.g. current trial number, -number of trial remaining). - -

    This is typically used in the LoopBegin function, in order to capture the current state of a TrialHandler

    -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -2304,10 +2538,7 @@

    getSnapsho -
    Source:
    -
    + @@ -2321,6 +2552,26 @@

    getSnapsho +
    +

    Get a snapshot of the current internal state of the trial handler (e.g. current trial number, +number of trial remaining).

    +

    This is typically used in the LoopBegin function, in order to capture the current state of a TrialHandler

    +
    + + + + + + + + + + + + + + + @@ -2335,12 +2586,14 @@

    Returns:
    - - a snapshot of the current internal state. +
      +
    • a snapshot of the current internal state.
    • +
    -
    +
    Type
    @@ -2356,23 +2609,64 @@
    Returns:
    - - -

    getTrial(index) → {Object|undefined}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + -
    - Get the nth trial. +
    +

    Get the nth trial.

    @@ -2383,6 +2677,8 @@

    getTrialParameters:

    @@ -2427,12 +2723,12 @@
    Parameters:
    - 0 + 0 - the trial index +

    the trial index

    @@ -2444,10 +2740,63 @@
    Parameters:
    -
    + + + + + + + + + + +
    Returns:
    + + +
    +

    the requested trial or undefined if attempting to go beyond the last trial.

    +
    + + + +
    +
    + Type +
    +
    + +Object +| + +undefined + + +
    +
    + + + + + + + + + + +

    getTrialIndex() → {number}

    + + + +
    + + +
    Source:
    +
    @@ -2471,10 +2820,7 @@
    Parameters:
    -
    Source:
    -
    + @@ -2488,6 +2834,24 @@
    Parameters:
    +
    +

    Get the trial index.

    +
    + + + + + + + + + + + + + + + @@ -2502,21 +2866,18 @@
    Returns:
    - the requested trial or undefined if attempting to go beyond the last trial. +

    the current trial index

    -
    +
    Type
    -Object -| - -undefined +number
    @@ -2526,41 +2887,25 @@
    Returns:
    - - - -

    getTrialIndex() → {number}

    - +

    next()

    -
    - Get the trial index. -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -2584,10 +2929,7 @@

    getTrial -
    Source:
    -
    + @@ -2601,6 +2943,9 @@

    getTrial +
    +

    Helps go through each trial in the sequence one by one, mirrors PsychoPy.

    +
    @@ -2611,58 +2956,32 @@

    getTrial -

    Returns:
    - - -
    - the current trial index -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - -

    next()

    - - -
    - Helps go through each trial in the sequence one by one, mirrors PsychoPy. -
    + + + +

    setSeed(seed, log)

    + @@ -2670,7 +2989,10 @@

    next - +
    Source:
    +
    @@ -2694,10 +3016,7 @@

    nextSource:

    -
    + @@ -2711,6 +3030,9 @@

    next +

    Setter for the seed attribute.

    +

    @@ -2722,35 +3044,6 @@

    nextsetSeed(seed, log)

    - - - - - - -
    - Setter for the seed attribute. -
    - - - - - - - - -
    Parameters:
    @@ -2792,7 +3085,7 @@
    Parameters:
    - the seed value +

    the seed value

    @@ -2815,7 +3108,7 @@
    Parameters:
    - whether or not to log the change of seed +

    whether or not to log the change of seed

    @@ -2827,80 +3120,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    setTrialIndex(index)

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    setTrialIndex(index)

    + +
    + -
    - Set the trial index. + +
    +

    Set the trial index.

    @@ -2911,6 +3201,8 @@

    setTrial + +

    Parameters:
    @@ -2952,7 +3244,7 @@
    Parameters:
    - the new trial index +

    the new trial index

    @@ -2962,52 +3254,6 @@
    Parameters:
    - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - @@ -3028,36 +3274,20 @@
    Parameters:
    -

    Symbol.iterator()

    - -
    - Iterator over the trial sequence. - -

    This makes it possible to iterate over all trials.

    -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -3081,10 +3311,7 @@

    Symbol -
    Source:
    -
    + @@ -3098,6 +3325,27 @@

    Symbol +
    +

    Iterator over the trial sequence.

    +

    This makes it possible to iterate over all trials.

    +
    + + + + + + + + + +

    Example
    + +
    let handler = new TrialHandler({nReps: 5});
    +for (const thisTrial of handler) { console.log(thisTrial); }
    + + + + @@ -3112,10 +3360,6 @@

    Symbol -

    Example
    - -
    let handler = new TrialHandler({nReps: 5});
    -for (const thisTrial of handler) { console.log(thisTrial); }
    @@ -3132,19 +3376,23 @@
    Example
    + +
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
    - - + + + + + + + + \ No newline at end of file diff --git a/docs/module-data.html b/docs/module-data.html index 12dd3b36..fe7dae15 100644 --- a/docs/module-data.html +++ b/docs/module-data.html @@ -1,1351 +1,49 @@ - - JSDoc: Module: data - - - - - - - - - - -
    - -

    Module: data

    - - - - - - -
    - -
    - - - -
    - -
    -
    - - - - - -
    - - - - - - -

    Classes

    - -
    -
    ExperimentHandler
    -
    - -
    MultiStairHandler
    -
    - -
    QuestHandler
    -
    - -
    Shelf
    -
    - -
    TrialHandler
    -
    -
    - - - - - - - - - - - - - -

    Type Definitions

    - - - -

    Snapshot

    - - - - - - -
    Type:
    -
      -
    • - -Object - - -
    • -
    - - - - - -
    Properties:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    handler - - -TrialHandler - - - - the trialHandler
    name - - -string - - - - the trialHandler name
    nStim - - -number - - - - the number of stimuli
    nTotal - - -number - - - - the total number of trials that will be run
    nRemaining - - -number - - - - the total number of trial remaining
    thisRepN - - -number - - - - the current repeat
    thisTrialN - - -number - - - - the current trial number within the current repeat
    thisN - - -number - - - - the total number of trials completed so far
    thisIndex - - -number - - - - the index of the current trial in the conditions list
    ran - - -number - - - - whether or not the trial ran
    finished - - -number - - - - whether or not the trials finished
    trialAttributes - - -Object - - - - a list of trial attributes
    - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - -
    - -
    - - - - - - - -
    - -
    - - - -
    - -
    -
    - - - - - -
    - - - - - - -

    Classes

    - -
    -
    ExperimentHandler
    -
    - -
    MultiStairHandler
    -
    - -
    QuestHandler
    -
    - -
    Shelf
    -
    - -
    TrialHandler
    -
    -
    - - - - - - - - - - - - - -

    Type Definitions

    - - - -

    Snapshot

    - - - - - - -
    Type:
    -
      -
    • - -Object - - -
    • -
    - - - - - -
    Properties:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    handler - - -TrialHandler - - - - the trialHandler
    name - - -string - - - - the trialHandler name
    nStim - - -number - - - - the number of stimuli
    nTotal - - -number - - - - the total number of trials that will be run
    nRemaining - - -number - - - - the total number of trial remaining
    thisRepN - - -number - - - - the current repeat
    thisTrialN - - -number - - - - the current trial number within the current repeat
    thisN - - -number - - - - the total number of trials completed so far
    thisIndex - - -number - - - - the index of the current trial in the conditions list
    ran - - -number - - - - whether or not the trial ran
    finished - - -number - - - - whether or not the trials finished
    trialAttributes - - -Object - - - - a list of trial attributes
    - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - -
    - -
    - - - - - - - -
    - -
    - - - -
    - -
    -
    - - - - - -
    - - - - - - -

    Classes

    - -
    -
    ExperimentHandler
    -
    - -
    MultiStairHandler
    -
    - -
    QuestHandler
    -
    - -
    Shelf
    -
    - -
    TrialHandler
    -
    -
    - - - - - - - - - - - - - -

    Type Definitions

    - - - -

    Snapshot

    - - - - - - -
    Type:
    -
      -
    • - -Object - - -
    • -
    - - - - - -
    Properties:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    handler - - -TrialHandler - - - - the trialHandler
    name - - -string - - - - the trialHandler name
    nStim - - -number - - - - the number of stimuli
    nTotal - - -number - - - - the total number of trials that will be run
    nRemaining - - -number - - - - the total number of trial remaining
    thisRepN - - -number - - - - the current repeat
    thisTrialN - - -number - - - - the current trial number within the current repeat
    thisN - - -number - - - - the total number of trials completed so far
    thisIndex - - -number - - - - the index of the current trial in the conditions list
    ran - - -number - - - - whether or not the trial ran
    finished - - -number - - - - whether or not the trials finished
    trialAttributes - - -Object - - - - a list of trial attributes
    - - - - -
    - - - - - - - - - - - + + data - PsychoJS API - - - + + - - + + + + + + + + + - + + - -
    Source:
    -
    - + + +
    -
    - - - - - - - +

    data

    -
    - -
    - - - @@ -1359,13 +57,15 @@
    Properties:
    -
    +
    + + +
    -
    @@ -1375,27 +75,27 @@
    Properties:

    Classes

    -
    ExperimentHandler
    +
    ExperimentHandler
    -
    MultiStairHandler
    +
    TrialHandler
    -
    QuestHandler
    +
    MultiStairHandler
    -
    Shelf
    +
    QuestHandler
    -
    TrialHandler
    +
    Shelf
    - - + + @@ -1413,18 +113,45 @@

    Snapshot

    +
    -
    Type:
    - + + + + + + + + + + + + + + + + + + + + + + + + + +
    @@ -1470,7 +197,7 @@
    Properties:
    - the trialHandler +

    the trialHandler

    @@ -1493,7 +220,7 @@
    Properties:
    - the trialHandler name +

    the trialHandler name

    @@ -1516,7 +243,7 @@
    Properties:
    - the number of stimuli +

    the number of stimuli

    @@ -1539,7 +266,7 @@
    Properties:
    - the total number of trials that will be run +

    the total number of trials that will be run

    @@ -1562,7 +289,7 @@
    Properties:
    - the total number of trial remaining +

    the total number of trial remaining

    @@ -1585,7 +312,7 @@
    Properties:
    - the current repeat +

    the current repeat

    @@ -1608,7 +335,7 @@
    Properties:
    - the current trial number within the current repeat +

    the current trial number within the current repeat

    @@ -1631,7 +358,7 @@
    Properties:
    - the total number of trials completed so far +

    the total number of trials completed so far

    @@ -1654,7 +381,7 @@
    Properties:
    - the index of the current trial in the conditions list +

    the index of the current trial in the conditions list

    @@ -1677,7 +404,7 @@
    Properties:
    - whether or not the trial ran +

    whether or not the trial ran

    @@ -1700,7 +427,7 @@
    Properties:
    - whether or not the trials finished +

    whether or not the trials finished

    @@ -1723,7 +450,7 @@
    Properties:
    - a list of trial attributes +

    a list of trial attributes

    @@ -1733,45 +460,19 @@
    Properties:
    -
    - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - +
    Type:
    +
      +
    • + +Object - - -
    + + @@ -1789,19 +490,23 @@
    Properties:
    + +
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
    - - + + + + + + + + \ No newline at end of file diff --git a/docs/module-hardware.Camera.html b/docs/module-hardware.Camera.html new file mode 100644 index 00000000..df6db878 --- /dev/null +++ b/docs/module-hardware.Camera.html @@ -0,0 +1,2531 @@ + + + + + + Camera - PsychoJS API + + + + + + + + + + + + + + + + + + + + + + + + + +
    + +

    Camera

    + + + + + + + +
    + +
    + +

    + Camera +

    + + +
    + +
    + +
    + + + + + +

    new Camera(options)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    To Do:
    +
    +
      +
    • add video constraints as parameter
    • +
    +
    + +
    + + + + + +
    +

    This manager handles the recording of video signal.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    options + + +Object + + + + +
    Properties
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    win + + +module:core.Window + + + + + + + + + + + +

    the associated Window

    format + + +string + + + + + + <optional>
    + + + + + +
    + + 'video/webm;codecs=vp9' + +

    the video format

    clock + + +Clock + + + + + + <optional>
    + + + + + +
    + +

    an optional clock

    autoLog + + +boolean + + + + + + <optional>
    + + + + + +
    + + false + +

    whether or not to log

    + +
    + + + + + + + + + + + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + +

    Methods

    + + + + + + +

    (protected) _onChange()

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Callback for changes to the recording settings.

    +

    Changes to the settings require the recording to stop and be re-started.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    (protected) _prepareRecording()

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Prepare the recording.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    (protected) _upload(tag, waitForCompletionopt, showDialogopt, dialogMsgopt)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Upload the video recording to the pavlovia server.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    tag + + +string + + + + + + + + + + + +

    an optional tag for the video file

    waitForCompletion + + +boolean + + + + + + <optional>
    + + + + + +
    + + false + +

    whether to wait for completion +before returning

    showDialog + + +boolean + + + + + + <optional>
    + + + + + +
    + + false + +

    whether to open a dialog box to inform the participant to wait for the data to be uploaded to the server

    dialogMsg + + +string + + + + + + <optional>
    + + + + + +
    + + "" + +

    default message informing the participant to wait for the data to be uploaded to the server

    + + + + + + + + + + + + + + + + + + + + + + + + +

    authorize(showDialogopt, dialogMsgopt) → {boolean}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Prompt the user for permission to use the camera on their device.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    showDialog + + +boolean + + + + + + <optional>
    + + + + + +
    + + false + +

    whether to open a dialog box to inform the +participant to wait for the camera to be initialised

    dialogMsg + + +string + + + + + + <optional>
    + + + + + +
    + +

    the dialog message

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    whether or not the camera is ready to record

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + + + + +

    close() → {Promise.<void>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Close the camera stream.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    promise fulfilled when the stream has stopped and is now closed

    +
    + + + +
    +
    + Type +
    +
    + +Promise.<void> + + +
    +
    + + + + + + + + + + +

    flush() → {Promise}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Submit a request to flush the recording.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    promise fulfilled when the data has actually been made available

    +
    + + + +
    +
    + Type +
    +
    + +Promise + + +
    +
    + + + + + + + + + + +

    getRecording(tag, flushopt)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get the current video recording as a VideoClip in the given format.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    tag + + +string + + + + + + + + + + + +

    an optional tag for the video clip

    flush + + +boolean + + + + + + <optional>
    + + + + + +
    + + false + +

    whether or not to first flush the recording

    + + + + + + + + + + + + + + + + + + + + + + + + +

    getStream() → {MediaStream}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get the underlying video stream.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the video stream

    +
    + + + +
    +
    + Type +
    +
    + +MediaStream + + +
    +
    + + + + + + + + + + +

    getVideo() → {HTMLVideoElement}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get a video element pointing to the Camera stream.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    a video element

    +
    + + + +
    +
    + Type +
    +
    + +HTMLVideoElement + + +
    +
    + + + + + + + + + + +

    isReady() → {boolean}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Query whether the camera is ready to record.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    true if the camera is ready to record, false otherwise

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + + + + +

    open()

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Open the video stream.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    pause() → {Promise}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Submit a request to pause the recording.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    promise fulfilled when the recording actually paused

    +
    + + + +
    +
    + Type +
    +
    + +Promise + + +
    +
    + + + + + + + + + + +

    record() → {Promise}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Submit a request to start the recording.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    promise fulfilled when the recording actually starts

    +
    + + + +
    +
    + Type +
    +
    + +Promise + + +
    +
    + + + + + + + + + + +

    resume(options) → {Promise}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Submit a request to resume the recording.

    +

    resume has no effect if the recording was not previously paused.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    options + + +Object + + + + +
    Properties
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    clear + + +boolean + + + + + + <optional>
    + + + + + +
    + + false + +

    whether or not to empty the video buffer before +resuming the recording

    + +
    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    promise fulfilled when the recording actually resumed

    +
    + + + +
    +
    + Type +
    +
    + +Promise + + +
    +
    + + + + + + + + + + +

    stop(options) → {Promise}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Submit a request to stop the recording.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    options + + +Object + + + +
    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    promise fulfilled when the recording actually stopped, and the recorded +data was made available

    +
    + + + +
    +
    + Type +
    +
    + +Promise + + +
    +
    + + + + + + + + + + + +
    + +
    + + + + + + +
    + +
    + +
    + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme. +
    + + + + + + + + + + + \ No newline at end of file diff --git a/docs/module-sound.AudioClip.html b/docs/module-sound.AudioClip.html deleted file mode 100644 index bdad1d10..00000000 --- a/docs/module-sound.AudioClip.html +++ /dev/null @@ -1,1216 +0,0 @@ - - - - - JSDoc: Class: AudioClip - - - - - - - - - - -
    - -

    Class: AudioClip

    - - - - - - -
    - -
    - -

    - sound.AudioClip(options)

    - - -
    - -
    -
    - - - - - - -

    new AudioClip(options)

    - - - - - - -
    -

    AudioClip encapsulates an audio recording.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    options - - -Object - - - - -
    Properties
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    psychoJS - - -module:core.PsychoJS - - - - - - - - - - - - the PsychoJS instance
    name - - -String - - - - - - <optional>
    - - - - - -
    - - 'audioclip' - - the name used when logging messages
    format - - -string - - - - - - - - - - - - the format for the audio file
    sampleRateHz - - -number - - - - - - - - - - - - the sampling rate
    data - - -Blob - - - - - - - - - - - - the audio data, in the given format, at the given sampling rate
    autoLog - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to log
    - -
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - -

    Members

    - - - -

    (readonly) Engine :Symbol

    - - - - -
    - Recognition engines. -
    - - - -
    Type:
    -
      -
    • - -Symbol - - -
    • -
    - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - -

    Methods

    - - - - - - - -

    download()

    - - - - - - -
    - Offer the audio clip to the participant as a sound file to download. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    getDuration() → {Promise.<number>}

    - - - - - - -
    - Get the duration of the audio clip, in seconds. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the duration of the audio clip -
    - - - -
    -
    - Type -
    -
    - -Promise.<number> - - -
    -
    - - - - - - - - - - - - - -

    setVolume(volume)

    - - - - - - -
    - Set the volume of the playback. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    volume - - -number - - - - the volume of the playback (must be between 0.0 and 1.0)
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    startPlayback()

    - - - - - - -
    - Start playing the audio clip. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    startPlayback(fadeDurationopt)

    - - - - - - -
    - Stop playing the audio clip. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    fadeDuration - - -number - - - - - - <optional>
    - - - - - -
    - - 17 - - how long the fading out should last, in ms
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    upload()

    - - - - - - -
    - Upload the audio clip to the pavlovia server. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-sound.AudioClipPlayer.html b/docs/module-sound.AudioClipPlayer.html deleted file mode 100644 index 94b0141c..00000000 --- a/docs/module-sound.AudioClipPlayer.html +++ /dev/null @@ -1,1617 +0,0 @@ - - - - - JSDoc: Class: AudioClipPlayer - - - - - - - - - - -
    - -

    Class: AudioClipPlayer

    - - - - - - -
    - -
    - -

    - sound.AudioClipPlayer(options)

    - - -
    - -
    -
    - - - - - - -

    new AudioClipPlayer(options)

    - - - - - - -
    -

    This class handles the playback of an audio clip, e.g. a microphone recording.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    options - - -Object - - - - -
    Properties
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    psychoJS - - -module:core.PsychoJS - - - - - - - - - - - - the PsychoJS instance
    audioClip - - -Object - - - - - - - - - - - - the module:sound.AudioClip
    startTime - - -number - - - - - - <optional>
    - - - - - -
    - - 0 - - start of playback (in seconds)
    stopTime - - -number - - - - - - <optional>
    - - - - - -
    - - -1 - - end of playback (in seconds)
    stereo - - -boolean - - - - - - <optional>
    - - - - - -
    - - true - - whether or not to play the sound or track in stereo
    volume - - -number - - - - - - <optional>
    - - - - - -
    - - 1.0 - - volume of the sound (must be between 0 and 1.0)
    loops - - -number - - - - - - <optional>
    - - - - - -
    - - 0 - - how many times to repeat the track or tone after it has played *
    - -
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - -

    Extends

    - - - - -
      -
    • SoundPlayer
    • -
    - - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    (static) accept(sound) → {Object|undefined}

    - - - - - - -
    - Determine whether this player can play the given sound. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    sound - - -module:sound.Sound - - - - the sound object, which should be an AudioClip
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - an instance of AudioClipPlayer if sound is an AudioClip or undefined otherwise -
    - - - -
    -
    - Type -
    -
    - -Object -| - -undefined - - -
    -
    - - - - - - - - - - - - - -

    getDuration() → {number}

    - - - - - - -
    - Get the duration of the AudioClip, in seconds. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the duration of the clip, in seconds -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    play(loops, fadeDurationopt)

    - - - - - - -
    - Start playing the sound. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    loops - - -number - - - - - - - - - - - - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped.
    fadeDuration - - -number - - - - - - <optional>
    - - - - - -
    - - 17 - - how long should the fading in last in ms
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setDuration(duration_s)

    - - - - - - -
    - Set the duration of the audio clip. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    duration_s - - -number - - - - the duration of the clip in seconds
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setLoops(loops)

    - - - - - - -
    - Set the number of loops. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    loops - - -number - - - - how many times to repeat the clip after it has played once. If loops == -1, the clip will repeat indefinitely until stopped.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setVolume(volume, muteopt)

    - - - - - - -
    - Set the volume of the playback. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    volume - - -number - - - - - - - - - - - - the volume of the playback (must be between 0.0 and 1.0)
    mute - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to mute the playback
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    stop(fadeDurationopt)

    - - - - - - -
    - Stop playing the sound immediately. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    fadeDuration - - -number - - - - - - <optional>
    - - - - - -
    - - 17 - - how long the fading out should last, in ms
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-sound.Microphone.html b/docs/module-sound.Microphone.html deleted file mode 100644 index 4583f1d9..00000000 --- a/docs/module-sound.Microphone.html +++ /dev/null @@ -1,1441 +0,0 @@ - - - - - JSDoc: Class: Microphone - - - - - - - - - - -
    - -

    Class: Microphone

    - - - - - - -
    - -
    - -

    - sound.Microphone(options, @param)

    - - -
    - -
    -
    - - - - - - -

    new Microphone(options, @param)

    - - - - - - -
    -

    This manager handles the recording of audio signal.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    options - - -Object - - - - - - - - - - - - -
    Properties
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    psychoJS - - -module:core.PsychoJS - - - - the PsychoJS instance
    - -
    @param - - -module:core.Window - - - - - - - - - - - - options.win - the associated Window
    options.format - - -string - - - - - - <optional>
    - - - - - -
    - - 'audio/webm;codecs=opus' - - the format for the audio file
    options.sampleRateHz - - -number - - - - - - <optional>
    - - - - - -
    - - 48000 - - the audio sampling rate, in Hz
    options.clock - - -Clock - - - - - - <optional>
    - - - - - -
    - - an optional clock
    options.autoLog - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to log
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - -

    Members

    - - - -

    flush

    - - - - -
    - Submit a request to flush the recording. -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    pause

    - - - - -
    - Submit a request to pause the recording. -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    resume

    - - - - -
    - Submit a request to resume the recording. - -

    resume has no effect if the recording was not previously paused.

    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    start

    - - - - -
    - 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.

    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    stop

    - - - - -
    - Submit a request to stop the recording. -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - -

    Methods

    - - - - - - - -

    (protected) _onChange()

    - - - - - - -
    - Callback for changes to the recording settings. - -

    Changes to the settings require the recording to stop and be re-started.

    -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    (protected) _prepareRecording()

    - - - - - - -
    - Prepare the recording. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    download(filename)

    - - - - - - -
    - Offer the audio recording to the participant as a sound file to download. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    filename - - -string - - - - the filename
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    getRecording(tag, flushopt)

    - - - - - - -
    - Get the current audio recording as an AudioClip in the given format. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    tag - - -string - - - - - - - - - - - - an optional tag for the audio clip
    flush - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to first flush the recording
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    upload(tag)

    - - - - - - -
    - Upload the audio recording to the pavlovia server. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    tag - - -string - - - - an optional tag for the audio file
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-sound.Sound.html b/docs/module-sound.Sound.html index 645734aa..7d14cbaa 100644 --- a/docs/module-sound.Sound.html +++ b/docs/module-sound.Sound.html @@ -1,23 +1,47 @@ + - JSDoc: Class: Sound - - - + Sound - PsychoJS API + + + + + + + + + + - - + + + + - -
    + + + + + + -

    Class: Sound

    +
    + +

    Sound

    + @@ -28,16 +52,17 @@

    Class: Sound

    -

    - sound.Sound(options)

    +

    + sound. -

    This class handles sound playing (tones and tracks)

    - + Sound +

    + +

    This class handles sound playing (tones and tracks)

    • If value is a number then a tone will be generated at that frequency in Hz.
    • It value is a string, it must either be a note in the PsychoPy format (e.g 'A', 'Bfl', 'B', 'C', 'Csh'), in which case an octave must also be given, or the name of the resource track.
    -

    Note: the PsychoPy hamming parameter has not been implemented yet. It might be rather tricky to do so using Tone.js

    @@ -45,19 +70,61 @@

    -
    +
    +

    Constructor

    -

    new Sound(options)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    @@ -69,6 +136,22 @@

    new SoundExample

    + +
    [...]
    +const track = new Sound({
    +  win: psychoJS.window,
    +  value: 440,
    +  secs: 0.5
    +});
    +track.setVolume(1.0);
    +track.play(2);
    + + + +
    Parameters:
    @@ -168,7 +251,7 @@
    Properties
    - the name used when logging messages from this stimulus +

    the name used when logging messages from this stimulus

    @@ -203,7 +286,7 @@
    Properties
    - the associated Window +

    the associated Window

    @@ -240,12 +323,12 @@
    Properties
    - 'C' + 'C' - the sound value (see above for a full description) +

    the sound value (see above for a full description)

    @@ -279,12 +362,12 @@
    Properties
    - 4 + 4 - the octave corresponding to the tone (if applicable) +

    the octave corresponding to the tone (if applicable)

    @@ -318,12 +401,12 @@
    Properties
    - 0.5 + 0.5 - duration of the tone (in seconds) If secs == -1, the sound will play indefinitely. +

    duration of the tone (in seconds) If secs == -1, the sound will play indefinitely.

    @@ -357,12 +440,12 @@
    Properties
    - 0 + 0 - start of playback for tracks (in seconds) +

    start of playback for tracks (in seconds)

    @@ -396,12 +479,12 @@
    Properties
    - -1 + -1 - end of playback for tracks (in seconds) +

    end of playback for tracks (in seconds)

    @@ -435,12 +518,12 @@
    Properties
    - true + true - whether or not to play the sound or track in stereo +

    whether or not to play the sound or track in stereo

    @@ -474,12 +557,12 @@
    Properties
    - 1.0 + 1.0 - volume of the sound (must be between 0 and 1.0) +

    volume of the sound (must be between 0 and 1.0)

    @@ -513,12 +596,12 @@
    Properties
    - 0 + 0 - how many times to repeat the track or tone after it has played once. If loops == -1, the track or tone will repeat indefinitely until stopped. +

    how many times to repeat the track or tone after it has played once. If loops == -1, the track or tone will repeat indefinitely until stopped.

    @@ -552,12 +635,12 @@
    Properties
    - true + true - whether or not to log +

    whether or not to log

    @@ -576,51 +659,6 @@
    Properties
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - @@ -634,21 +672,9 @@
    Properties
    -
    Example
    - -
    [...]
    -const track = new Sound({
    -  win: psychoJS.window,
    -  value: 440,
    -  secs: 0.5
    -});
    -track.setVolume(1.0);
    -track.play(2);
    - - - + +
    -

    Extends

    @@ -666,11 +692,11 @@

    Extends

    - - + + @@ -683,34 +709,20 @@

    Methods

    - -

    (protected) _getPlayer() → {SoundPlayer}

    - +

    (protected) _getPlayer() → {SoundPlayer}

    -
    - Identify the appropriate player for the sound. -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -734,10 +746,7 @@

    (protected) Source: -
    + @@ -751,6 +760,24 @@

    (protected) +

    Identify the appropriate player for the sound.

    +

    + + + + + + + + + + + + + + + @@ -765,13 +792,13 @@
    Throws:
    -
    - exception if no appropriate SoundPlayer could be found for the sound +
    +

    exception if no appropriate SoundPlayer could be found for the sound

    -
    +
    Type
    @@ -794,18 +821,18 @@
    Returns:
    - the appropriate SoundPlayer +

    the appropriate SoundPlayer

    -
    +
    Type
    -SoundPlayer +SoundPlayer
    @@ -815,41 +842,25 @@
    Returns:
    - - -

    getDuration() → {number}

    - -
    - Get the duration of the sound, in seconds. -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -873,10 +884,7 @@

    getDuratio -
    Source:
    -
    + @@ -890,6 +898,24 @@

    getDuratio +
    +

    Get the duration of the sound, in seconds.

    +
    + + + + + + + + + + + + + + + @@ -904,12 +930,12 @@

    Returns:
    - the duration of the sound, in seconds +

    the duration of the sound, in seconds

    -
    +
    Type
    @@ -925,49 +951,91 @@
    Returns:
    - - -

    play(loops, logopt)

    - -
    - Start playing the sound. -

    Note: Sounds are played independently from the stimuli of the experiments, i.e. the experiment will not stop until the sound is finished playing. -Repeat calls to play may results in the sounds being played on top of each other.

    -
    +
    + +
    Source:
    +
    + + + + + + + + -
    Parameters:
    - - - - - - + - + - + + + + + + + + + + + + + + + +
    +

    Start playing the sound.

    +

    Note: Sounds are played independently from the stimuli of the experiments, i.e. the experiment will not stop until the sound is finished playing. +Repeat calls to play may results in the sounds being played on top of each other.

    +
    + + + + + + + + + + + +
    Parameters:
    + + +
    NameType
    + + + + + + + + + @@ -1012,7 +1080,7 @@
    Parameters:
    - + @@ -1046,12 +1114,12 @@
    Parameters:
    - + @@ -1063,80 +1131,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    setLoops(loopsopt, logopt)

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    setLoops(loopsopt, logopt)

    + +
    + + -
    - Set the number of loops. +
    +

    Set the number of loops.

    @@ -1147,6 +1212,8 @@

    setLoopsParameters:

    @@ -1203,12 +1270,12 @@
    Parameters:
    - + @@ -1242,12 +1309,12 @@
    Parameters:
    - + @@ -1259,80 +1326,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    setSecs(secsopt, logopt)

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    setSecs(secsopt, logopt)

    + +
    -
    - Set the duration (in seconds) + + +
    +

    Set the duration (in seconds)

    @@ -1343,6 +1407,8 @@

    setSecsParameters:

    @@ -1399,12 +1465,12 @@
    Parameters:
    - + @@ -1438,12 +1504,12 @@
    Parameters:
    - + @@ -1455,80 +1521,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    setSound(sound, logopt)

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    setSound(sound, logopt)

    + +
    + -
    - Set the sound value on demand past initialisation. + +
    +

    Set the sound value on demand past initialisation.

    @@ -1539,6 +1602,8 @@

    setSoundParameters:

    @@ -1596,7 +1661,7 @@
    Parameters:
    -
    + @@ -1630,12 +1695,12 @@
    Parameters:
    - + @@ -1647,80 +1712,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    setVolume(volume, muteopt, logopt)

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    setVolume(volume, muteopt, logopt)

    + +
    + -
    - Set the playing volume of the sound. + +
    +

    Set the playing volume of the sound.

    @@ -1731,6 +1793,8 @@

    setVolumeParameters:

    @@ -1788,7 +1852,7 @@
    Parameters:
    -
    + @@ -1822,12 +1886,12 @@
    Parameters:
    - + @@ -1861,12 +1925,12 @@
    Parameters:
    - + @@ -1878,80 +1942,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    stop(options)

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    stop(options)

    + +
    + -
    - Stop playing the sound immediately. + +
    +

    Stop playing the sound immediately.

    @@ -1962,6 +2023,8 @@

    stop - true + true -

    + @@ -2084,52 +2147,6 @@
    Properties
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - @@ -2156,19 +2173,23 @@
    Properties
    + + - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
    - - + + + + + + + + \ No newline at end of file diff --git a/docs/module-sound.SoundPlayer.html b/docs/module-sound.SoundPlayer.html deleted file mode 100644 index 0ec7a7de..00000000 --- a/docs/module-sound.SoundPlayer.html +++ /dev/null @@ -1,1048 +0,0 @@ - - - - - JSDoc: Interface: SoundPlayer - - - - - - - - - - -
    - -

    Interface: SoundPlayer

    - - - - - - -
    - -
    - -

    - sound.SoundPlayer

    - - -
    - -
    -
    - - -

    SoundPlayer is an interface for the sound players, who are responsible for actually playing the sounds, i.e. the tracks or the tones.

    - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - -
    - - -

    Extends

    - - - - -
      -
    • PsychObject
    • -
    - - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    (abstract, static) accept() → {Object|undefined}

    - - - - - - -
    - Determine whether this player can play the given sound. -
    - - - - - - - - - -
    Parameters:
    - - -
    NameTypeAttributes how many times to repeat the sound after it plays once. If loops == -1, the sound will repeat indefinitely until stopped.

    how many times to repeat the sound after it plays once. If loops == -1, the sound will repeat indefinitely until stopped.

    - true + true whether or not to log

    whether or not to log

    - 0 + 0 how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped.

    how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped.

    - true + true whether of not to log

    whether of not to log

    - 0.5 + 0.5 duration of the tone (in seconds) If secs == -1, the sound will play indefinitely.

    duration of the tone (in seconds) If secs == -1, the sound will play indefinitely.

    - true + true whether or not to log

    whether or not to log

    a sound instance to replace the current one

    a sound instance to replace the current one

    - true + true whether or not to log

    whether or not to log

    the volume (values should be between 0 and 1)

    the volume (values should be between 0 and 1)

    - false + false whether or not to mute the sound

    whether or not to mute the sound

    - true + true whether of not to log

    whether of not to log

    whether or not to log

    whether or not to log

    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    TypeDescription
    - - -module:sound.Sound - - - - the sound
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - an instance of the SoundPlayer that can play the sound, or undefined if none could be found -
    - - - -
    -
    - Type -
    -
    - -Object -| - -undefined - - -
    -
    - - - - - - - - - - - - - -

    (abstract) getDuration()

    - - - - - - -
    - Get the duration of the sound, in seconds. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    (abstract) play(loopsopt)

    - - - - - - -
    - Start playing the sound. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDescription
    loops - - -number - - - - - - <optional>
    - - - - - -
    how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    (abstract) setDuration()

    - - - - - - -
    - Set the duration of the sound, in seconds. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    (abstract) setLoops(loops)

    - - - - - - -
    - Set the number of loops. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    loops - - -number - - - - how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    (abstract) setVolume(volume, muteopt)

    - - - - - - -
    - Set the volume of the tone. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    volume - - -Integer - - - - - - - - - - - - the volume of the tone
    mute - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to mute the tone
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    (abstract) stop()

    - - - - - - -
    - Stop playing the sound immediately. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-sound.TonePlayer.html b/docs/module-sound.TonePlayer.html deleted file mode 100644 index dd9a0fb0..00000000 --- a/docs/module-sound.TonePlayer.html +++ /dev/null @@ -1,1528 +0,0 @@ - - - - - JSDoc: Class: TonePlayer - - - - - - - - - - -
    - -

    Class: TonePlayer

    - - - - - - -
    - -
    - -

    - sound.TonePlayer(options)

    - - -
    - -
    -
    - - - - - - -

    new TonePlayer(options)

    - - - - - - -
    -

    This class handles the playing of tones.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    options - - -Object - - - - -
    Properties
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    psychoJS - - -module:core.PsychoJS - - - - - - - - - - - - the PsychoJS instance
    duration_s - - -number - - - - - - <optional>
    - - - - - -
    - - 0.5 - - duration of the tone (in seconds). If duration_s == -1, the sound will play indefinitely.
    note - - -string -| - -number - - - - - - <optional>
    - - - - - -
    - - 'C4' - - note (if string) or frequency (if number)
    volume - - -number - - - - - - <optional>
    - - - - - -
    - - 1.0 - - volume of the tone (must be between 0 and 1.0)
    loops - - -number - - - - - - <optional>
    - - - - - -
    - - 0 - - how many times to repeat the tone after it has played once. If loops == -1, the tone will repeat indefinitely until stopped.
    - -
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - -

    Extends

    - - - - -
      -
    • SoundPlayer
    • -
    - - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    (protected, static) _initSoundLibrary()

    - - - - - - -
    - Initialise the sound library. - -

    Note: if TonePlayer accepts the sound but Tone.js is not available, e.g. if the browser is IE11, -we throw an exception.

    -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    (static) accept(sound) → {Object|undefined}

    - - - - - - -
    - Determine whether this player can play the given sound. - -

    Note: if TonePlayer accepts the sound but Tone.js is not available, e.g. if the browser is IE11, -we throw an exception.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    sound - - -module:sound.Sound - - - - the sound
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - an instance of TonePlayer that can play the given sound or undefined otherwise -
    - - - -
    -
    - Type -
    -
    - -Object -| - -undefined - - -
    -
    - - - - - - - - - - - - - -

    getDuration() → {number}

    - - - - - - -
    - Get the duration of the sound. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the duration of the sound, in seconds -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    play(loopsopt)

    - - - - - - -
    - Start playing the sound. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDescription
    loops - - -boolean - - - - - - <optional>
    - - - - - -
    how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setDuration(duration_s)

    - - - - - - -
    - Set the duration of the tone. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    duration_s - - -number - - - - the duration of the tone (in seconds) If duration_s == -1, the sound will play indefinitely.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setLoops(loops)

    - - - - - - -
    - Set the number of loops. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    loops - - -number - - - - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setVolume(volume, muteopt)

    - - - - - - -
    - Set the volume of the tone. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    volume - - -Integer - - - - - - - - - - - - the volume of the tone
    mute - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to mute the tone
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    stop()

    - - - - - - -
    - Stop playing the sound immediately. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-sound.TrackPlayer.html b/docs/module-sound.TrackPlayer.html deleted file mode 100644 index ae2bcd6a..00000000 --- a/docs/module-sound.TrackPlayer.html +++ /dev/null @@ -1,1627 +0,0 @@ - - - - - JSDoc: Class: TrackPlayer - - - - - - - - - - -
    - -

    Class: TrackPlayer

    - - - - - - -
    - -
    - -

    - sound.TrackPlayer(options)

    - - -
    - -
    -
    - - - - - - -

    new TrackPlayer(options)

    - - - - - - -
    -

    This class handles the playback of sound tracks.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    options - - -Object - - - - -
    Properties
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    psychoJS - - -module:core.PsychoJS - - - - - - - - - - - - the PsychoJS instance
    howl - - -Object - - - - - - - - - - - - the sound object (see https://howlerjs.com/)
    startTime - - -number - - - - - - <optional>
    - - - - - -
    - - 0 - - start of playback (in seconds)
    stopTime - - -number - - - - - - <optional>
    - - - - - -
    - - -1 - - end of playback (in seconds)
    stereo - - -boolean - - - - - - <optional>
    - - - - - -
    - - true - - whether or not to play the sound or track in stereo
    volume - - -number - - - - - - <optional>
    - - - - - -
    - - 1.0 - - volume of the sound (must be between 0 and 1.0)
    loops - - -number - - - - - - <optional>
    - - - - - -
    - - 0 - - how many times to repeat the track or tone after it has played *
    - -
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    To Do:
    -
    -
      -
    • stopTime is currently not implemented (tracks will play from startTime to finish)
    • - -
    • stereo is currently not implemented
    • -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - -

    Extends

    - - - - -
      -
    • SoundPlayer
    • -
    - - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    (static) accept(sound) → {Object|undefined}

    - - - - - - -
    - Determine whether this player can play the given sound. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    sound - - -module:sound.Sound - - - - the sound, which should be the name of an audio resource - file
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - an instance of TrackPlayer that can play the given track or undefined otherwise -
    - - - -
    -
    - Type -
    -
    - -Object -| - -undefined - - -
    -
    - - - - - - - - - - - - - -

    getDuration() → {number}

    - - - - - - -
    - Get the duration of the sound, in seconds. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the duration of the track, in seconds -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    play(loops, fadeDurationopt)

    - - - - - - -
    - Start playing the sound. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    loops - - -number - - - - - - - - - - - - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped.
    fadeDuration - - -number - - - - - - <optional>
    - - - - - -
    - - 17 - - how long should the fading in last in ms
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setDuration(duration_s)

    - - - - - - -
    - Set the duration of the track. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    duration_s - - -number - - - - the duration of the track in seconds
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setLoops(loops)

    - - - - - - -
    - Set the number of loops. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    loops - - -number - - - - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    setVolume(volume, muteopt)

    - - - - - - -
    - Set the volume of the tone. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    volume - - -Integer - - - - - - - - - - - - the volume of the track (must be between 0 and 1.0)
    mute - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to mute the track
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    stop(fadeDurationopt)

    - - - - - - -
    - Stop playing the sound immediately. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    fadeDuration - - -number - - - - - - <optional>
    - - - - - -
    - - 17 - - how long should the fading out last in ms
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-sound.Transcriber.html b/docs/module-sound.Transcriber.html deleted file mode 100644 index 8fd70163..00000000 --- a/docs/module-sound.Transcriber.html +++ /dev/null @@ -1,1395 +0,0 @@ - - - - - JSDoc: Class: Transcriber - - - - - - - - - - -
    - -

    Class: Transcriber

    - - - - - - -
    - -
    - -

    - sound.Transcriber(options)

    - - -
    - -
    -
    - - - - - - -

    new Transcriber(options)

    - - - - - - -
    -

    This manager handles the transcription of speech into text.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    options - - -Object - - - - -
    Properties
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    psychoJS - - -module:core.PsychoJS - - - - - - - - - - - - the PsychoJS instance
    name - - -String - - - - - - - - - - - - the name used when logging messages
    bufferSize - - -number - - - - - - <optional>
    - - - - - -
    - - 10000 - - the maximum size of the circular transcript buffer
    continuous - - -Array.<String> - - - - - - <optional>
    - - - - - -
    - - true - - whether or not to continuously recognise
    lang - - -Array.<String> - - - - - - <optional>
    - - - - - -
    - - 'en-US' - - the spoken language
    interimResults - - -Array.<String> - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to make interim results available
    maxAlternatives - - -Array.<String> - - - - - - <optional>
    - - - - - -
    - - 1 - - the maximum number of recognition alternatives
    tokens - - -Array.<String> - - - - - - <optional>
    - - - - - -
    - - [] - - the tokens to be recognised. This is experimental technology, not available in all browser.
    clock - - -Clock - - - - - - <optional>
    - - - - - -
    - - an optional clock
    autoLog - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to log
    - -
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    To Do:
    -
    -
      -
    • deal with alternatives, interim results, and recognition errors
    • -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    (protected) _onChange()

    - - - - - - -
    - Callback for changes to the recognition settings. - -

    Changes to the recognition settings require the recognition to stop and be re-started.

    -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    (protected) _prepareTranscription()

    - - - - - - -
    - Prepare the transcription. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    clearTranscripts()

    - - - - - - -
    - Clear all transcripts and resets the circular buffers. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    getTranscripts(options) → {Array.<Transcript>}

    - - - - - - -
    - Get the list of transcripts still in the buffer, i.e. those that have not been -previously cleared by calls to getTranscripts with clear = true. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    options - - -Object - - - - -
    Properties
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    transcriptList - - -Array.<string> - - - - - - <optional>
    - - - - - -
    - - [] - - the list of transcripts texts to consider. If transcriptList is empty, we consider all transcripts.
    clear - - -boolean - - - - - - <optional>
    - - - - - -
    - - false - - whether or not to keep in the buffer the transcripts for a subsequent call to getTranscripts. If a keyList has been given and clear = true, we only remove from the buffer those keys in keyList
    - -
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the list of transcripts still in the buffer -
    - - - -
    -
    - Type -
    -
    - -Array.<Transcript> - - -
    -
    - - - - - - - - - - - - - -

    start() → {Promise}

    - - - - - - -
    - Start the transcription. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - promise fulfilled when the transcription actually started -
    - - - -
    -
    - Type -
    -
    - -Promise - - -
    -
    - - - - - - - - - - - - - -

    stop() → {Promise}

    - - - - - - -
    - Stop the transcription. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - promise fulfilled when the speech recognition actually stopped -
    - - - -
    -
    - Type -
    -
    - -Promise - - -
    -
    - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-sound.Transcript.html b/docs/module-sound.Transcript.html deleted file mode 100644 index 84cefc56..00000000 --- a/docs/module-sound.Transcript.html +++ /dev/null @@ -1,171 +0,0 @@ - - - - - JSDoc: Class: Transcript - - - - - - - - - - -
    - -

    Class: Transcript

    - - - - - - -
    - -
    - -

    - sound.Transcript()

    - - -
    - -
    -
    - - - - - - -

    new Transcript()

    - - - - - - -
    - Transcript returned by the transcriber -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-sound.html b/docs/module-sound.html index 74b5e207..458c4905 100644 --- a/docs/module-sound.html +++ b/docs/module-sound.html @@ -1,23 +1,47 @@ + - JSDoc: Module: sound - - - + sound - PsychoJS API + + + + + + + + + + - - + + + + - -
    + + + + -

    Module: sound

    + + +
    + +

    sound

    + @@ -33,13 +57,15 @@

    Module: sound

    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
    - - + + + + + + + + \ No newline at end of file diff --git a/docs/module-util.Clock.html b/docs/module-util.Clock.html deleted file mode 100644 index a16d5dda..00000000 --- a/docs/module-util.Clock.html +++ /dev/null @@ -1,495 +0,0 @@ - - - - - JSDoc: Class: Clock - - - - - - - - - - -
    - -

    Class: Clock

    - - - - - - -
    - -
    - -

    - util.Clock()

    - - -
    - -
    -
    - - - - - - -

    new Clock()

    - - - - - - -
    -

    Clock is a MonotonicClock that also offers the possibility of being reset.

    -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - -

    Extends

    - - - - -
      -
    • MonotonicClock
    • -
    - - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    add(deltaTimeopt)

    - - - - - - -
    - Add more time to the clock's 'start' time (t0). - -

    Note: by adding time to t0, the current time is pushed forward (it becomes -smaller). As a consequence, getTime() may return a negative number.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDescription
    deltaTime - - -number - - - - - - <optional>
    - - - - - -
    the time to be added to the clock's start time (t0)
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    reset(newTimeopt)

    - - - - - - -
    - Reset the time on the clock. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    newTime - - -number - - - - - - <optional>
    - - - - - -
    - - 0 - - the new time on the clock.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-util.Color.html b/docs/module-util.Color.html deleted file mode 100644 index b236c556..00000000 --- a/docs/module-util.Color.html +++ /dev/null @@ -1,2076 +0,0 @@ - - - - - JSDoc: Class: Color - - - - - - - - - - -
    - -

    Class: Color

    - - - - - - -
    - -
    - -

    - util.Color(objopt, colorspaceopt)

    - - -
    - -
    -
    - - - - - - -

    new Color(objopt, colorspaceopt)

    - - - - - - -
    -

    This class handles multiple color spaces, and offers various -static methods for converting colors from one space to another.

    - -

    The constructor accepts the following color representations: -

      -
    • a named color, e.g. 'aliceblue' (the colorspace must be RGB)
    • -
    • an hexadecimal string representation, e.g. '#FF0000' (the colorspace must be RGB)
    • -
    • an hexadecimal number representation, e.g. 0xFF0000 (the colorspace must be RGB)
    • -
    • a triplet of numbers, e.g. [-1, 0, 1], [0, 128, 255] (the numbers must be within the range determined by the colorspace)
    • -
    -

    - -

    Note: internally, colors are represented as a [r,g,b] triplet with r,g,b in [0,1].

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    obj - - -string -| - -number -| - -Array.<number> -| - -undefined - - - - - - <optional>
    - - - - - -
    - - 'black' - - an object representing a color
    colorspace - - -module:util.Color#COLOR_SPACE -| - -undefined - - - - - - <optional>
    - - - - - -
    - - Color.COLOR_SPACE.RGB - - the colorspace of that color
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    To Do:
    -
    -
      -
    • implement HSV, DKL, and LMS colorspaces
    • -
    -
    - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - -

    Members

    - - - -

    (readonly) COLOR_SPACE :Symbol

    - - - - -
    - Color spaces. -
    - - - -
    Type:
    -
      -
    • - -Symbol - - -
    • -
    - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    (readonly) NAMED_COLORS :string

    - - - - -
    - Named colors. -
    - - - -
    Type:
    -
      -
    • - -string - - -
    • -
    - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - -

    Methods

    - - - - - - - -

    (static) hex() → {string}

    - - - - - - -
    - Get the hexadecimal color code equivalent of this Color. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the hexadecimal color code equivalent -
    - - - -
    -
    - Type -
    -
    - -string - - -
    -
    - - - - - - - - - - - - - -

    (static) hexToRgb(hex) → {Array.<number>}

    - - - - - - -
    - Get the [0,1] RGB triplet equivalent of the hexadecimal color code. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    hex - - -string - - - - the hexadecimal color code
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the [0,1] RGB triplet equivalent -
    - - - -
    -
    - Type -
    -
    - -Array.<number> - - -
    -
    - - - - - - - - - - - - - -

    (static) hexToRgb255(hex) → {Array.<number>}

    - - - - - - -
    - Get the [0,255] RGB triplet equivalent of the hexadecimal color code. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    hex - - -string - - - - the hexadecimal color code
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the [0,255] RGB triplet equivalent -
    - - - -
    -
    - Type -
    -
    - -Array.<number> - - -
    -
    - - - - - - - - - - - - - -

    (static) int() → {number}

    - - - - - - -
    - Get the integer code equivalent of this Color. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the integer code equivalent -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    (static) rgb() → {Array.<number>}

    - - - - - - -
    - Get the [0,1] RGB triplet equivalent of this Color. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the [0,1] RGB triplet equivalent -
    - - - -
    -
    - Type -
    -
    - -Array.<number> - - -
    -
    - - - - - - - - - - - - - -

    (static) rgb255() → {Array.<number>}

    - - - - - - -
    - Get the [0,255] RGB triplet equivalent of this Color. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the [0,255] RGB triplet equivalent -
    - - - -
    -
    - Type -
    -
    - -Array.<number> - - -
    -
    - - - - - - - - - - - - - -

    (static) rgb255ToHex(rgb255) → {string}

    - - - - - - -
    - Get the hexadecimal color code equivalent of the [0, 255] RGB triplet. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    rgb255 - - -Array.<number> - - - - the [0, 255] RGB triplet
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the hexadecimal color code equivalent -
    - - - -
    -
    - Type -
    -
    - -string - - -
    -
    - - - - - - - - - - - - - -

    (static) rgb255ToInt(rgb255) → {number}

    - - - - - - -
    - Get the integer equivalent of the [0, 255] RGB triplet. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    rgb255 - - -Array.<number> - - - - the [0, 255] RGB triplet
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the integer equivalent -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    (static) rgbFull() → {Array.<number>}

    - - - - - - -
    - Get the [-1,1] RGB triplet equivalent of this Color. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the [-1,1] RGB triplet equivalent -
    - - - -
    -
    - Type -
    -
    - -Array.<number> - - -
    -
    - - - - - - - - - - - - - -

    (static) rgbToHex(rgb) → {string}

    - - - - - - -
    - Get the hexadecimal color code equivalent of the [0, 1] RGB triplet. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    rgb - - -Array.<number> - - - - the [0, 1] RGB triplet
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the hexadecimal color code equivalent -
    - - - -
    -
    - Type -
    -
    - -string - - -
    -
    - - - - - - - - - - - - - -

    (static) rgbToInt(rgb) → {number}

    - - - - - - -
    - Get the integer equivalent of the [0, 1] RGB triplet. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    rgb - - -Array.<number> - - - - the [0, 1] RGB triplet
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the integer equivalent -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    (static) toString() → {string}

    - - - - - - -
    - String representation of the color, i.e. the hexadecimal representation. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the representation. -
    - - - -
    -
    - Type -
    -
    - -string - - -
    -
    - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-util.ColorMixin.html b/docs/module-util.ColorMixin.html index 92e5ff96..0c9fd815 100644 --- a/docs/module-util.ColorMixin.html +++ b/docs/module-util.ColorMixin.html @@ -1,23 +1,47 @@ + - JSDoc: Mixin: ColorMixin - - - + ColorMixin - PsychoJS API + + + + + + + + + + - - + + + + - -
    + + + + + + -

    Mixin: ColorMixin

    +
    + +

    ColorMixin

    + @@ -29,25 +53,27 @@

    Mixin: ColorMixin

    - util.ColorMixin

    + util. + + ColorMixin +
    -
    +
    -

    This mixin implement color and contrast changes for visual stimuli

    - - - - +
    - +
    Source:
    +
    @@ -71,10 +97,7 @@

    -
    Source:
    -
    + @@ -85,20 +108,27 @@

    + + + +

    This mixin implement color and contrast changes for visual stimuli

    + + + +
    -
    - - + + @@ -111,16 +141,59 @@

    Methods

    -

    getContrastedColor(color, contrast)

    + + + + +
    + + +
    Source:
    +
    + + -
    - Get a new contrasted Color. + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get a new contrasted Color.

    @@ -131,6 +204,8 @@

    get + +

    Parameters:
    @@ -178,7 +253,7 @@
    Parameters:
    - the color +

    the color

    @@ -201,7 +276,7 @@
    Parameters:
    - the contrast (must be between 0 and 1) +

    the contrast (must be between 0 and 1)

    @@ -213,80 +288,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    setColor(color, logopt)

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    setColor(color, logopt)

    + +
    + + -
    - Setter for Color attribute. +
    +

    Setter for Color attribute.

    @@ -297,6 +369,8 @@

    setColorParameters:

    @@ -332,7 +406,7 @@
    Parameters:
    -Color +Color @@ -354,7 +428,7 @@
    Parameters:
    - the new color +

    the new color

    @@ -388,12 +462,12 @@
    Parameters:
    - false + false - whether or not to log +

    whether or not to log

    @@ -405,80 +479,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    setContrast(contrast, logopt)

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    setContrast(contrast, logopt)

    + +
    + -
    - Setter for Contrast attribute. + +
    +

    Setter for Contrast attribute.

    @@ -489,6 +560,8 @@

    setContras + +

    Parameters:
    @@ -546,7 +619,7 @@
    Parameters:
    - the new contrast (must be between 0 and 1) +

    the new contrast (must be between 0 and 1)

    @@ -580,12 +653,12 @@
    Parameters:
    - false + false - whether or not to log +

    whether or not to log

    @@ -597,52 +670,6 @@
    Parameters:
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - @@ -669,19 +696,23 @@
    Parameters:
    + +
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
    - - + + + + + + + + \ No newline at end of file diff --git a/docs/module-util.CountdownTimer.html b/docs/module-util.CountdownTimer.html deleted file mode 100644 index fe9a6474..00000000 --- a/docs/module-util.CountdownTimer.html +++ /dev/null @@ -1,667 +0,0 @@ - - - - - JSDoc: Class: CountdownTimer - - - - - - - - - - -
    - -

    Class: CountdownTimer

    - - - - - - -
    - -
    - -

    - util.CountdownTimer(startTimeopt)

    - - -
    - -
    -
    - - - - - - -

    new CountdownTimer(startTimeopt)

    - - - - - - -
    -

    CountdownTimer is a clock counts down from the time of last reset. - - - - - - - - - -

    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    startTime - - -number - - - - - - <optional>
    - - - - - -
    - - 0 - - the start time of the countdown
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - -

    Extends

    - - - - -
      -
    • Clock
    • -
    - - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    add(deltaTimeopt)

    - - - - - - -
    - Add more time to the clock's 'start' time (t0). - -

    Note: by adding time to t0, you push the current time forward (make it -smaller). As a consequence, getTime() may return a negative number.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDescription
    deltaTime - - -number - - - - - - <optional>
    - - - - - -
    the time to be added to the clock's start time (t0)
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    getTime() → {number}

    - - - - - - -
    - Get the time currently left on the countdown. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the time left on the countdown (in seconds) -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    reset(newTimeopt)

    - - - - - - -
    - Reset the time on the countdown. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDescription
    newTime - - -number - - - - - - <optional>
    - - - - - -
    if newTime is undefined, the countdown time is reset to zero, otherwise we set it -to newTime
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-util.EventEmitter.html b/docs/module-util.EventEmitter.html deleted file mode 100644 index ce308abf..00000000 --- a/docs/module-util.EventEmitter.html +++ /dev/null @@ -1,1009 +0,0 @@ - - - - - JSDoc: Class: EventEmitter - - - - - - - - - - -
    - -

    Class: EventEmitter

    - - - - - - -
    - -
    - -

    - util.EventEmitter()

    - - -
    - -
    -
    - - - - - - -

    new EventEmitter()

    - - - - - - -
    -

    EventEmitter implements the classic observer/observable pattern.

    - -

    Note: this is heavily inspired by http://www.datchley.name/es6-eventemitter/

    -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - -
    Example
    - -
    let observable = new EventEmitter();
    -let uuid1 = observable.on('change', data => { console.log(data); });
    -observable.emit("change", { a: 1 });
    -observable.off("change", uuid1);
    -observable.emit("change", { a: 1 });
    - - - - -
    - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    emit(name, data) → {boolean}

    - - - - - - -
    - Emit an event with a given name and associated data. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    name - - -String - - - - the name of the event
    data - - -object - - - - the data of the event
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - true if at least one listener has been registered for that event, and false otherwise -
    - - - -
    -
    - Type -
    -
    - -boolean - - -
    -
    - - - - - - - - - - - - - -

    off(name, listener)

    - - - - - - -
    - Remove the listener with the given uuid associated to the given event name. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    name - - -String - - - - the name of the event
    listener - - -module:util.EventEmitter~Listener - - - - a listener called upon emission of the event
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -

    on(name, listener)

    - - - - - - -
    - Register a new listener for events with the given name emitted by this instance. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    name - - -String - - - - the name of the event
    listener - - -module:util.EventEmitter~Listener - - - - a listener called upon emission of the event
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - string - the unique identifier associated with that (event, listener) pair (useful to remove the listener) -
    - - - - - - - - - - - - - - - -

    once(name, listener)

    - - - - - - -
    - Register a new listener for the given event name, and remove it as soon as the event has been emitted. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    name - - -String - - - - the name of the event
    listener - - -module:util.EventEmitter~Listener - - - - a listener called upon emission of the event
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - string - the unique identifier associated with that (event, listener) pair (useful to remove the listener) -
    - - - - - - - - - - - - - -

    Type Definitions

    - - - - - - - -

    Listener(data)

    - - - - - - -
    - Listener called when this instance emits an event for which it is registered. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    data - - -object - - - - the data passed to the listener
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-util.MixinBuilder.html b/docs/module-util.MixinBuilder.html deleted file mode 100644 index e944956c..00000000 --- a/docs/module-util.MixinBuilder.html +++ /dev/null @@ -1,230 +0,0 @@ - - - - - JSDoc: Class: MixinBuilder - - - - - - - - - - -
    - -

    Class: MixinBuilder

    - - - - - - -
    - -
    - -

    - util.MixinBuilder(superclass)

    - - -
    - -
    -
    - - - - - - -

    new MixinBuilder(superclass)

    - - - - - - -
    - Syntactic sugar for Mixins - -

    This is heavily adapted from: http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    superclass - - -Object - - - -
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - -
    Example
    - -
    class BaseClass { ... }
    -let Mixin1 = (superclass) => class extends superclass { ... }
    -let Mixin2 = (superclass) => class extends superclass { ... }
    -class NewClass extends mix(BaseClass).with(Mixin1, Mixin2) { ... }
    - - - - -
    - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-util.MonotonicClock.html b/docs/module-util.MonotonicClock.html deleted file mode 100644 index 3e879ee2..00000000 --- a/docs/module-util.MonotonicClock.html +++ /dev/null @@ -1,873 +0,0 @@ - - - - - JSDoc: Class: MonotonicClock - - - - - - - - - - -
    - -

    Class: MonotonicClock

    - - - - - - -
    - -
    - -

    - util.MonotonicClock(startTimeopt)

    - - -
    - -
    -
    - - - - - - -

    new MonotonicClock(startTimeopt)

    - - - - - - -
    -

    MonotonicClock offers a convenient way to keep track of time during experiments. An experiment can have as many independent clocks as needed, e.g. one to time responses, another one to keep track of stimuli, etc.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDefaultDescription
    startTime - - -number - - - - - - <optional>
    - - - - - -
    - - <time elapsed since the reference point, i.e. the time when the module was loaded> - - the clock's start time (in ms)
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - -

    Methods

    - - - - - - - -

    (static) getDate(locales, options) → {string}

    - - - - - - -
    - Get the current timestamp with language-sensitive formatting rules applied. - -

    Note: This is just a convenience wrapper around `Intl.DateTimeFormat()`.

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    locales - - -string -| - -array.string - - - - A string with a BCP 47 language tag, or an array of such strings.
    options - - -object - - - - An object with detailed date and time styling information.
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - The current timestamp in the chosen format. -
    - - - -
    -
    - Type -
    -
    - -string - - -
    -
    - - - - - - - - - - - - - -

    (static) getDateStr() → {string}

    - - - - - - -
    - Get the clock's current time in the default format filtering out file name unsafe characters. - -

    Note: This is mostly used as an appendix to the name of the keys save to the server.

    -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - A string representing the current time formatted as YYYY-MM-DD_HH[h]mm.ss.sss -
    - - - -
    -
    - Type -
    -
    - -string - - -
    -
    - - - - - - - - - - - - - -

    getLastResetTime() → {number}

    - - - - - - -
    - Get the current offset being applied to the high resolution timebase used by this Clock. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the offset (in seconds) -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    getReferenceTime() → {number}

    - - - - - - -
    - Get the time elapsed since the reference point. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the time elapsed since the reference point (in seconds) -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -

    getTime() → {number}

    - - - - - - -
    - Get the current time on this clock. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - -
    - the current time (in seconds) -
    - - - -
    -
    - Type -
    -
    - -number - - -
    -
    - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-util.PsychObject.html b/docs/module-util.PsychObject.html index 2760e5f8..7254e496 100644 --- a/docs/module-util.PsychObject.html +++ b/docs/module-util.PsychObject.html @@ -1,23 +1,47 @@ + - JSDoc: Class: PsychObject - - - + PsychObject - PsychoJS API + + + + + + + + + + - - + + + + - -
    + + + + + + -

    Class: PsychObject

    +
    + +

    PsychObject

    + @@ -28,29 +52,78 @@

    Class: PsychObject

    -

    - util.PsychObject(psychoJS, name)

    +

    + util. -

    PsychoObject is the base class for all PsychoJS objects. + PsychObject +

    + +

    PsychoObject is the base class for all PsychoJS objects. It is responsible for handling attributes.

    -
    +
    +

    Constructor

    -

    new PsychObject(psychoJS, name)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + @@ -103,7 +176,7 @@
    Parameters:
    - the PsychoJS instance +

    the PsychoJS instance

    @@ -126,7 +199,7 @@
    Parameters:
    - the name of the object (mostly useful for debugging) +

    the name of the object (mostly useful for debugging)

    @@ -138,78 +211,81 @@
    Parameters:
    -
    - - - - - - - - - - - - - - - -
    Source:
    -
    - - + +
    - +

    Extends

    + + + + + + + + + + +

    Members

    + + +

    psychoJS

    +
    + +
    Source:
    +
    + + + -
    -

    Extends

    - + + -
      -
    • EventEmitter
    • -
    + + @@ -224,17 +300,14 @@

    Extends

    -

    Members

    + - - -

    psychoJS

    -
    - Get the PsychoJS instance. +
    +

    Get the PsychoJS instance.

    @@ -243,10 +316,22 @@

    psychoJSpsychoJS

    + + + + +
    - +
    Source:
    +
    @@ -270,10 +355,7 @@

    psychoJSSource: -
    + @@ -287,16 +369,8 @@

    psychoJSpsychoJS

    - - - - -
    - Setter for the PsychoJS attribute. +
    +

    Setter for the PsychoJS attribute.

    @@ -305,36 +379,31 @@

    psychoJS - - - - + +

    Methods

    - + + - +

    (protected) _addAttribute(name, value, defaultValueopt, onChangeopt)

    - - - - +
    Source:
    @@ -343,34 +412,38 @@

    psychoJS + + + + + - -

    Methods

    - - + -

    (protected) _addAttribute(name, value, defaultValueopt, onChangeopt)

    + +

    + -
    - Add an attribute to this instance (e.g. define setters and getters) and affect a value to it. + +
    +

    Add an attribute to this instance (e.g. define setters and getters) and affect a value to it.

    @@ -381,6 +454,8 @@

    (protected) Parameters:

    @@ -432,7 +507,7 @@
    Parameters:
    - the name of the attribute +

    the name of the attribute

    @@ -463,7 +538,7 @@
    Parameters:
    - the value of the attribute +

    the value of the attribute

    @@ -496,7 +571,7 @@
    Parameters:
    - the default value for the attribute +

    the default value for the attribute

    @@ -529,7 +604,7 @@
    Parameters:
    - function called upon changes to the attribute value +

    function called upon changes to the attribute value

    @@ -541,80 +616,77 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    (protected) _setAttribute(attributeName, attributeValue, logopt, operationopt, stealthopt) → {boolean}

    -
    - - - - +
    + +
    Source:
    +
    + + + + + + + + + + + - - + -

    (protected) _setAttribute(attributeName, attributeValue, logopt, operationopt, stealthopt) → {boolean}

    + +
    -
    - Set the value of an attribute. + + +
    +

    Set the value of an attribute.

    @@ -625,6 +697,8 @@

    (protected) Parameters:

    @@ -682,7 +756,7 @@
    Parameters:
    - the name of the attribute +

    the name of the attribute

    @@ -717,7 +791,7 @@
    Parameters:
    - the value of the attribute +

    the value of the attribute

    @@ -751,12 +825,12 @@
    Parameters:
    - false + false - whether of not to log +

    whether of not to log

    @@ -793,7 +867,7 @@
    Parameters:
    - the binary operation such that the new value of the attribute is the result of the application of the operation to the current value of the attribute and attributeValue +

    the binary operation such that the new value of the attribute is the result of the application of the operation to the current value of the attribute and attributeValue

    @@ -827,12 +901,12 @@
    Parameters:
    - false + false - whether or not to call the potential attribute setters when setting the value of this attribute +

    whether or not to call the potential attribute setters when setting the value of this attribute

    @@ -844,36 +918,584 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    whether or not the value of that attribute has changed (false if the attribute +was not previously set)

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + + + + +

    emit(name, data) → {boolean}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + +
    Overrides:
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Emit an event with a given name and associated data.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    name + + +String + + + +

    the name of the event

    data + + +object + + + +

    the data of the event

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    true if at least one listener has been registered for that event, and false otherwise

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + + + + +

    off(name, listener)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + +
    Overrides:
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Remove the listener with the given uuid associated to the given event name.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    name + + +String + + + +

    the name of the event

    listener + + +module:util.EventEmitter~Listener + + + +

    a listener called upon emission of the event

    + + + + + + + + + + + + + + + + + + + + + + + + +

    on(name, listener)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + +
    Overrides:
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Register a new listener for events with the given name emitted by this instance.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    name + + +String + + + +

    the name of the event

    listener + + +module:util.EventEmitter~Listener + + + +

    a listener called upon emission of the event

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    string - the unique identifier associated with that (event, listener) pair (useful to remove the listener)

    +
    + + + + + + + + + + + + +

    once(name, listener)

    + + + +
    +
    Source:
    @@ -882,77 +1504,162 @@
    Parameters:
    -
    + +
    Overrides:
    +
    + + + + + + + + + + + + +
    -
    Returns:
    - -
    - whether or not the value of that attribute has changed (false if the attribute -was not previously set) + +
    +

    Register a new listener for the given event name, and remove it as soon as the event has been emitted.

    -
    -
    - Type -
    -
    + + + + + + + + +
    Parameters:
    + + + + + -boolean + + + + + - - + + + + + + + + + + + - - + + + + -

    toString() → {string}

    - + + + + + + + + + + + + + + + +
    NameTypeDescription
    name + + +String + +

    the name of the event

    listener + + +module:util.EventEmitter~Listener + + + +

    a listener called upon emission of the event

    -
    - String representation of the PsychObject. -

    Note: attribute values are limited to 50 characters.

    + + + + + + + + + + + + +
    Returns:
    + + +
    +

    string - the unique identifier associated with that (event, listener) pair (useful to remove the listener)

    + + + + +

    toString() → {string}

    + @@ -960,7 +1667,10 @@

    toString - +
    Source:
    +
    @@ -984,10 +1694,7 @@

    toStringSource: -
    + @@ -1001,6 +1708,25 @@

    toString +

    String representation of the PsychObject.

    +

    Note: attribute values are limited to 50 characters.

    +

    + + + + + + + + + + + + + + + @@ -1015,12 +1741,12 @@
    Returns:
    - the representation +

    the representation

    -
    +
    Type
    @@ -1036,8 +1762,6 @@
    Returns:
    - - @@ -1051,19 +1775,23 @@
    Returns:
    + +
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) + Documentation generated by JSDoc 3.6.7 on Mon Aug 01 2022 09:56:49 GMT+0200 (Central European Summer Time) using the docdash theme.
    - - + + + + + + + + \ No newline at end of file diff --git a/docs/module-util.Scheduler.html b/docs/module-util.Scheduler.html deleted file mode 100644 index 30d31abe..00000000 --- a/docs/module-util.Scheduler.html +++ /dev/null @@ -1,960 +0,0 @@ - - - - - JSDoc: Class: Scheduler - - - - - - - - - - -
    - -

    Class: Scheduler

    - - - - - - -
    - -
    - -

    - util.Scheduler(psychoJS)

    - - -
    - -
    -
    - - - - - - -

    new Scheduler(psychoJS)

    - - - - - - -
    -

    A scheduler helps run the main loop by managing scheduled functions, -called tasks, after each frame is displayed.

    - -

    -Tasks are either another Scheduler, or a -JavaScript functions returning one of the following codes: -

      -
    • Scheduler.Event.NEXT: Move onto the next task *without* rendering the scene first.
    • -
    • Scheduler.Event.FLIP_REPEAT: Render the scene and repeat the task.
    • -
    • Scheduler.Event.FLIP_NEXT: Render the scene and move onto the next task.
    • -
    • Scheduler.Event.QUIT: Quit the scheduler.
    • -
    -

    - -

    It is possible to create sub-schedulers, e.g. to handle loops. -Sub-schedulers are added to a parent scheduler as a normal -task would be by calling scheduler.add(subScheduler).

    - -

    Conditional branching is also available: -scheduler.addConditionalBranches

    -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeDescription
    psychoJS - - -module:core.PsychoJS - - - - the PsychoJS instance
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - -

    Members

    - - - -

    add

    - - - - -
    - Schedule a new task. -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    addConditional

    - - - - -
    - Schedule a series of task or another, based on a condition. - -

    Note: the tasks are sub-schedulers.

    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    (readonly) Event :Symbol

    - - - - -
    - Events. -
    - - - -
    Type:
    -
      -
    • - -Symbol - - -
    • -
    - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    start

    - - - - -
    - Start this scheduler. - -

    Note: tasks are run after each animation frame.

    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    status

    - - - - -
    - Get the status of the scheduler. -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    (readonly) Status :Symbol

    - - - - -
    - Status. -
    - - - -
    Type:
    -
      -
    • - -Symbol - - -
    • -
    - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - -

    stop

    - - - - -
    - Stop this scheduler. -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - -

    Type Definitions

    - - - - - - - -

    Condition() → {boolean}

    - - - - - - -
    - Condition evaluated when the task is run. -
    - - - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - -
    Returns:
    - - - - -
    -
    - Type -
    -
    - -boolean - - -
    -
    - - - - - - - - - - - - - -

    Task(argsopt)

    - - - - - - -
    - Task to be run by the scheduler. -
    - - - - - - - - - -
    Parameters:
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    NameTypeAttributesDescription
    args - - -* - - - - - - <optional>
    - - - - - -
    optional arguments
    - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - -
    - -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - \ No newline at end of file diff --git a/docs/module-util.html b/docs/module-util.html index 6e4d3bbf..fa15676a 100644 --- a/docs/module-util.html +++ b/docs/module-util.html @@ -1,23 +1,47 @@ + - JSDoc: Module: util - - - + util - PsychoJS API + + + + + + + + + + - - + + + + - -
    + + + + + + -

    Module: util

    +
    + +

    util

    + @@ -33,13 +57,15 @@

    Module: util

    -
    +
    + + +
    -
    @@ -49,35 +75,32 @@

    Module: util

    Classes

    -
    Clock
    -
    - -
    Color
    +
    Clock
    -
    CountdownTimer
    +
    Color
    -
    EventEmitter
    +
    CountdownTimer
    -
    MixinBuilder
    +
    EventEmitter
    -
    MonotonicClock
    +
    PsychObject
    -
    PsychObject
    +
    MonotonicClock
    -
    Scheduler
    +
    Scheduler
    - - + +

    Mixins

    @@ -89,87 +112,84 @@

    Mixins

    - - -

    Methods

    +

    Members

    +

    (static) mix

    - + + + + +
    -

    (static) addInfoFromUrl(info)

    +
    Source:
    +
    + + -
    - Add info extracted from the URL to the given dictionary. - -

    We exclude all URL parameters starting with a double underscore -since those are reserved for client/server communication

    -
    + + + + + + + + + -
    Parameters:
    - - - - - - + - + + - - - - - - - - - - - +
    +

    Syntactic sugar for Mixins

    +

    This is heavily adapted from: http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/

    +
    - - - - - +
    Example
    - -
    NameTypeDescription
    info - - -Object - - the dictionary
    +
    class BaseClass { ... }
    +let Mixin1 = (superclass) => class extends superclass { ... }
    +let Mixin2 = (superclass) => class extends superclass { ... }
    +class NewClass extends mix(BaseClass).with(Mixin1, Mixin2) { ... }
    + + + +

    (static, constant) TEXT_DIRECTION

    + @@ -177,7 +197,10 @@
    Parameters:
    - +
    Source:
    +
    @@ -201,10 +224,7 @@
    Parameters:
    -
    Source:
    -
    + @@ -218,38 +238,85 @@
    Parameters:
    +
    +

    Enum that stores possible text directions. +Note that Arabic is the same as RTL but added here to support PsychoPy's +languageStyle enum. Arabic reshaping is handled by the browser automatically.

    +
    + + + + + + + +

    Methods

    + + + +

    (static) addInfoFromUrl(info)

    + +
    + +
    Source:
    +
    + - - + -

    (static) average(input) → {number}

    + + + + + + + + + + + + + + + + + + + + +
    -
    - 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. + + +
    +

    Add info extracted from the URL to the given dictionary.

    +

    We exclude all URL parameters starting with a double underscore +since those are reserved for client/server communication

    @@ -260,6 +327,8 @@

    (static) aver + +

    Parameters:
    @@ -285,13 +354,13 @@
    Parameters:
    - input + info -array +Object @@ -301,7 +370,7 @@
    Parameters:
    - an array of numbers, or of objects that can be cast into a number, e.g. ['1', 2.5, 3e1] +

    the dictionary

    @@ -313,104 +382,78 @@
    Parameters:
    -
    - - - - - - - - - - - - - -
    Source:
    -
    - + + - +

    (static) average(input) → {number}

    -
    - - - - - - - - - - - -
    Returns:
    +
    - -
    - the average of the elements in the array -
    + +
    Source:
    +
    + + + -
    -
    - Type -
    -
    - -number + + -
    -
    + + + + + - - + -

    (static) count(input, value)

    + +
    -
    - 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.

    + +
    +

    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.

    @@ -421,6 +464,8 @@

    (static) count< + +

    Parameters:
    @@ -462,54 +507,72 @@
    Parameters:
    - the input array +

    an array of numbers, or of objects that can be cast into a number, e.g. ['1', 2.5, 3e1]

    + + - - - value - - - - -Number -| -string -| -object -| -null - - - - - the matching value - - - - +
    Returns:
    -
    + +
    +

    the average of the elements in the array

    +
    + + + +
    +
    + Type +
    +
    + +number + + +
    +
    + + + + + + + +

    (static) count(input, value)

    + + + + + + +
    + + +
    Source:
    +
    @@ -533,10 +596,7 @@
    Parameters:
    -
    Source:
    -
    + @@ -550,6 +610,10 @@
    Parameters:
    +
    +

    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.

    +
    @@ -560,49 +624,123 @@
    Parameters:
    -
    Returns:
    + +
    Parameters:
    + + + + + + + + + + -
    - the number of matching elements -
    + + + + + + + + + + + + - - + + -

    (static) detectBrowser() → {string}

    - + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +array + +

    the input array

    value + + +Number +| + +string +| + +object +| + +null + + + +

    the matching value

    -
    - Detect the user's browser. -

    Note: since user agent is easily spoofed, we use a more sophisticated approach, as described here: -https://stackoverflow.com/a/9851769

    + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the number of matching elements

    + + + + +

    (static) detectBrowser() → {string}

    + @@ -610,7 +748,10 @@

    (static) - +
    Source:
    +
    @@ -634,10 +775,7 @@

    (static) Source: -
    + @@ -651,6 +789,26 @@

    (static) +

    Detect the user's browser.

    +

    Note: since user agent is easily spoofed, we use a more sophisticated approach, as described here: +https://stackoverflow.com/a/9851769

    +

    + + + + + + + + + + + + + + + @@ -665,13 +823,13 @@
    Returns:
    - the detected browser, one of 'Opera', 'Firefox', 'Safari', -'IE', 'Edge', 'EdgeChromium', 'Chrome', 'unknown' +

    the detected browser, one of 'Opera', 'Firefox', 'Safari', +'IE', 'Edge', 'EdgeChromium', 'Chrome', 'unknown'

    -
    +
    Type
    @@ -687,25 +845,66 @@
    Returns:
    - - -

    (static) extensionFromMimeType(mimeType) → {string}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    -
    - Return the file extension corresponding to an audio mime type. + + +
    +

    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. +or unknown, then '.dat' is returned, instead of throwing an exception.

    @@ -716,6 +915,8 @@

    (stati + +
    Parameters:
    @@ -757,7 +958,7 @@
    Parameters:
    - the MIME type, e.g. 'audio/webm;codecs=opus' +

    the MIME type, e.g. 'audio/webm;codecs=opus'

    @@ -769,50 +970,6 @@
    Parameters:
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - @@ -827,12 +984,12 @@
    Returns:
    - the corresponding file extension, e.g. '.webm' +

    the corresponding file extension, e.g. '.webm'

    -
    +
    Type
    @@ -848,24 +1005,67 @@
    Returns:
    - - -

    (static) flattenArray(array) → {Array.<Object>}

    - -
    - Recursively flatten an array of arrays. -
    + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Recursively flatten an array of arrays.

    +
    + + @@ -916,7 +1116,7 @@
    Parameters:
    - the input array of arrays +

    the input array of arrays

    @@ -928,103 +1128,100 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    the flatten array

    +
    - - -
    Source:
    -
    - - +
    +
    + Type +
    +
    + +Array.<Object> - - +
    + + + + +

    (static) getDownloadSpeed(psychoJS, nbDownloadsopt) → {number}

    + +
    + +
    Source:
    +
    + + + -
    Returns:
    - - -
    - the flatten array -
    - - - -
    -
    - Type -
    -
    - -Array.<Object> + + -
    -
    + + + + + - - + -

    (static) getDownloadSpeed(psychoJS, nbDownloadsopt) → {number}

    + +
    + -
    - Get an estimate of the download speed, by repeatedly downloading an image file from a distant -server. + +
    +

    Get an estimate of the download speed, by repeatedly downloading an image file from a distant +server.

    @@ -1035,6 +1232,8 @@

    (static) Parameters:

    @@ -1092,7 +1291,7 @@
    Parameters:
    - the instance of PsychoJS +

    the instance of PsychoJS

    @@ -1126,13 +1325,13 @@
    Parameters:
    - 1 + 1 - the number of image downloads over which to average - the download speed +

    the number of image downloads over which to average +the download speed

    @@ -1144,50 +1343,6 @@
    Parameters:
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - @@ -1202,12 +1357,12 @@
    Returns:
    - the download speed, in megabits per second +

    the download speed, in megabits per second

    -
    +
    Type
    @@ -1223,41 +1378,25 @@
    Returns:
    - - -

    (static) getErrorStack() → {string}

    - -
    - Get the error stack of the calling, exception-throwing function. -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -1281,10 +1420,7 @@

    (static) Source: -
    + @@ -1298,6 +1434,24 @@

    (static) +

    Get the error stack of the calling, exception-throwing function.

    +

    + + + + + + + + + + + + + + + @@ -1312,12 +1466,12 @@
    Returns:
    - the error stack as a string +

    the error stack as a string

    -
    +
    Type
    @@ -1333,65 +1487,108 @@
    Returns:
    - - -

    (static) getPositionFromObject(object, units) → {Array.<number>}

    - -
    - Get the position of the object, in pixel units -
    +
    + +
    Source:
    +
    + + + + + + + -
    Parameters:
    - - - - - - + - + - + - + - - - + - - - - - + - - + - + - + - + + +
    NameTypeDescription
    object - - -Object + + + + + + + +
    +

    Get the position of the object, in pixel units

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1424,7 +1621,7 @@
    Parameters:
    - + @@ -1436,102 +1633,99 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    the position of the object, in pixel units

    +
    - - -
    Source:
    -
    - - +
    +
    + Type +
    +
    + +Array.<number> - - +
    + + + + +

    (static) getRequestError(jqXHR, textStatus, errorThrown)

    + +
    + +
    Source:
    +
    + + + -
    Returns:
    - - -
    - the position of the object, in pixel units -
    - - - -
    -
    - Type -
    -
    - -Array.<number> + + -
    -
    + + + + + - - + -

    (static) getRequestError(jqXHR, textStatus, errorThrown)

    + +
    + + -
    - Get the most informative error from the server response from a jquery server request. +
    +

    Get the most informative error from the server response from a jquery server request.

    @@ -1542,6 +1736,8 @@

    (static) Parameters:

    @@ -1626,12 +1822,38 @@
    Parameters:
    -
    + + + + + + + + + + + + + + + + +

    (static) getUrlParameters() → {URLSearchParams}

    + + + +
    + + +
    Source:
    +
    @@ -1653,10 +1875,9 @@
    Parameters:
    -
    Source:
    -
    + + + @@ -1670,6 +1891,9 @@
    Parameters:
    +
    +

    Get the URL parameters.

    +
    @@ -1679,37 +1903,62 @@
    Parameters:
    +
    Example
    + +
    const urlParameters = util.getUrlParameters();
    +for (const [key, value] of urlParameters)
    +  console.log(key + ' = ' + value);
    - - - - -

    (static) getUrlParameters() → {URLSearchParams}

    - - -
    - Get the URL parameters. + + + + + + +
    Returns:
    + + +
    +

    the iterable URLSearchParams

    +
    +
    + Type +
    +
    + +URLSearchParams + + +
    +
    + + + + + +

    (static) index(input, value)

    + @@ -1717,7 +1966,10 @@

    (static) - +
    Source:
    +
    @@ -1741,10 +1993,7 @@

    (static) Source: -
    + @@ -1758,6 +2007,10 @@

    (static) +

    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.

    +

    @@ -1768,109 +2021,49 @@

    (static) Returns:

    - -
    - the iterable URLSearchParams -
    +
    Parameters:
    + +
    NameTypeDescription
    object + + +Object @@ -1401,7 +1598,7 @@
    Parameters:
    -
    the input object

    the input object

    the units

    the units

    + + + + + + -
    -
    - Type -
    -
    -URLSearchParams + -
    -
    + + + + + + + + + + - - - - -

    (static) index(input, value)

    - - - - - - -
    - 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.

    -
    - - - - - - - - - -
    Parameters:
    - - -
    NameTypeDescription
    input + + +array -
    Example
    - -
    const urlParameters = util.getUrlParameters();
    -for (const [key, value] of urlParameters)
    -  console.log(key + ' = ' + value);
    - + +
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + @@ -1902,7 +2095,7 @@
    Parameters:
    - + @@ -1914,102 +2107,99 @@
    Parameters:
    -
    - - - - - - - - - - +
    Throws:
    - + +
    - +

    if the input array does not contain any matching element

    +
    - -
    Source:
    -
    - - - -
    +
    Returns:
    + +
    +

    the index of the first element that matches the value

    +
    + + + + +

    (static) isEmpty(x) → {boolean}

    + -
    Throws:
    - +
    -
    - if the input array does not contain any matching element +
    Source:
    +
    -
    - + -
    Returns:
    - - -
    - the index of the first element that matches the value -
    + + + + + + + - - + -

    (static) isEmpty(x) → {boolean}

    + +
    -
    - Test if x is an 'empty' value. + + +
    +

    Test if x is an 'empty' value.

    @@ -2020,6 +2210,8 @@

    (static) isEm + +

    Parameters:
    @@ -2061,7 +2253,7 @@
    Parameters:
    -
    + @@ -2073,102 +2265,99 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    true if x is one of the following: undefined, [], [undefined]

    +
    - - -
    Source:
    -
    - - +
    +
    + Type +
    +
    + +boolean - - +
    + + + + +

    (static) isInt(obj) → {boolean}

    + +
    + +
    Source:
    +
    + + + -
    Returns:
    - - -
    - true if x is one of the following: undefined, [], [undefined] -
    - - - -
    -
    - Type -
    -
    - -boolean + + -
    -
    + + + + + - - + -

    (static) isInt(obj) → {boolean}

    + +
    + -
    - Test whether an object is either an integer or the string representation of an integer. + +
    +

    Test whether an object is either an integer or the string representation of an integer.

    This is adapted from: https://stackoverflow.com/a/14794066

    @@ -2180,6 +2369,8 @@

    (static) isInt< + +

    Parameters:
    @@ -2221,7 +2412,7 @@
    Parameters:
    -
    + @@ -2233,50 +2424,6 @@
    Parameters:
    -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - - - -
    - - - - - @@ -2291,12 +2438,12 @@
    Returns:
    - whether or not the object is an integer or the string representation of an integer +

    whether or not the object is an integer or the string representation of an integer

    -
    +
    Type
    @@ -2312,24 +2459,67 @@
    Returns:
    - - -

    (static) isNumeric(input) → {boolean}

    - -
    - Check whether a value looks like a number -
    + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Check whether a value looks like a number

    +
    + + @@ -2380,7 +2570,7 @@
    Parameters:
    -
    + @@ -2392,102 +2582,99 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    Whether or not the value can be converted into a number

    +
    - - -
    Source:
    -
    - - +
    +
    + Type +
    +
    + +boolean - - +
    + + + + +

    (static) IsPointInsidePolygon(point, vertices) → {boolean}

    + +
    + +
    Source:
    +
    + + + -
    Returns:
    - - -
    - Whether or not the value can be converted into a number -
    - - - -
    -
    - Type -
    -
    - -boolean + + -
    -
    + + + + + - - + -

    (static) IsPointInsidePolygon(point, vertices) → {boolean}

    + +
    + -
    - Check whether a point lies within a polygon + +
    +

    Check whether a point lies within a polygon

    We are using the algorithm described here: https://wrf.ecse.rpi.edu//Research/Short_Notes/pnpoly.html

    @@ -2499,6 +2686,8 @@

    (static + +
    Parameters:
    @@ -2540,7 +2729,7 @@
    Parameters:
    -

    + @@ -2563,7 +2752,7 @@
    Parameters:
    - + @@ -2575,12 +2764,60 @@
    Parameters:
    -
    + + + + + + + + + + +
    Returns:
    + + +
    +

    whether or not the point lies within the polygon

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + +

    (static) makeUuid() → {string}

    + + + + +
    + + +
    Source:
    +
    @@ -2602,10 +2839,9 @@
    Parameters:
    -
    Source:
    -
    + + + @@ -2619,6 +2855,25 @@
    Parameters:
    +
    +

    Get a Universally Unique Identifier (RFC4122 version 4)

    +

    See details here: https://www.ietf.org/rfc/rfc4122.txt

    +
    + + + + + + + + + + + + + + + @@ -2633,18 +2888,18 @@
    Returns:
    - whether or not the point lies within the polygon +

    the uuid

    -
    +
    Type
    -boolean +string
    @@ -2654,42 +2909,25 @@
    Returns:
    - - - -

    (static) makeUuid() → {string}

    - +

    (static) offerDataForDownload(filename, data, type)

    -
    - Get a Universally Unique Identifier (RFC4122 version 4) -

    See details here: https://www.ietf.org/rfc/rfc4122.txt

    -
    - - - - - - - - - - - -
    - +
    Source:
    +
    @@ -2713,10 +2951,7 @@

    (static) mak -
    Source:
    -
    + @@ -2730,58 +2965,7554 @@

    (static) mak +
    +

    Offer data as download in the browser.

    +
    + + + + + + + + + + + +

    Parameters:
    + + +
    NameTypeDescription
    input - - -array - - - - the input array

    the input array

    the matching value

    the matching value

    the value to test

    the value to test

    the input object

    the input object

    Some value

    Some value

    the point

    the point

    the vertices defining the polygon

    the vertices defining the polygon

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    filename + + +string + + + +

    the name of the file to be downloaded

    data + + +* + + + +

    the data

    type + + +string + + + +

    the MIME type of the data, e.g. 'text/csv' or 'application/json'

    + + + + + + + + + + + + + + + + + + + + + + + + +

    (static) pad(n, width) → {string}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Pad the given floating-point number with however many 0 needed at the start such that +the padded integer part of the number is of the given width.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    n + +

    the input floating-point number

    width + +

    the desired width

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +
      +
    • the padded number, whose integer part has the given width
    • +
    +
    + + + +
    +
    + Type +
    +
    + +string + + +
    +
    + + + + + + + + + + +

    (static) promiseToTupple(promise) → {Array.<Object>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert the resulting value of a promise into a tupple.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    promise + + +Promise + + + +

    the promise

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the resulting value in the format [error, return data] +where error is null if there was no error

    +
    + + + +
    +
    + Type +
    +
    + +Array.<Object> + + +
    +
    + + + + + + + + + + +

    (static) randchoice(array, randomNumberGeneratoropt) → {Array.<Object>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Pick a random value from an array, uses util.shuffle to shuffle the array and returns the last value.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDescription
    array + + +Array.<Object> + + + + + + + + + +

    the input 1-D array

    randomNumberGenerator + + +function + + + + + + <optional>
    + + + + + +

    A function used to generated random numbers in the interal [0, 1). Defaults to Math.random

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    a chosen value from the array

    +
    + + + +
    +
    + Type +
    +
    + +Array.<Object> + + +
    +
    + + + + + + + + + + +

    (static) randint(minopt, max) → {number}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Generates random integers a-la NumPy's in the "half-open" interval [min, max). In other words, from min inclusive to max exclusive. When max is undefined, as is the case by default, results are chosen from [0, min). An error is thrown if max is less than min.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    min + + +number + + + + + + <optional>
    + + + + + +
    + + 0 + +

    lowest integer to be drawn, or highest plus one if max is undefined (default)

    max + + +number + + + + + + + + + + + +

    one above the largest integer to be drawn

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    a random integer in the requested range (signed)

    +
    + + + +
    +
    + Type +
    +
    + +number + + +
    +
    + + + + + + + + + + +

    (static) range(startopt, stop, stepopt) → {Array.<Number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    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.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    start + + +Number + + + + + + <optional>
    + + + + + +
    + + 0 + +

    the value of start

    stop + + +Number + + + + + + + + + + + +

    the value of stop

    step + + +Number + + + + + + <optional>
    + + + + + +
    + + 1 + +

    the value of step

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the range as an array of numbers

    +
    + + + +
    +
    + Type +
    +
    + +Array.<Number> + + +
    +
    + + + + + + + + + + +

    (static) round(input, places) → {number}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    See:
    +
    + +
    + + + +
    + + + + + +
    +

    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.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +number + + + +

    the number to be rounded

    places + + +number + + + +

    the max number of decimals desired

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    input rounded to the specified number of decimal places at most

    +
    + + + +
    +
    + Type +
    +
    + +number + + +
    +
    + + + + + + + + + + +

    (static) selectFromArray(array, selection) → {Object|Array.<Object>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Select values from an array.

    +

    'selection' can be a single integer, an array of indices, or a string to be parsed, e.g.: +

      +
    • 5
    • +
    • [1,2,3,10]
    • +
    • '1,5,10'
    • +
    • '1:2:5'
    • +
    • '5:'
    • +
    • '-5:-2, 9, 11:5:22'
    • +

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    array + + +Array.<Object> + + + +

    the input array

    selection + + +number +| + +Array.<number> +| + +string + + + +

    the selection

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the array of selected items

    +
    + + + +
    +
    + Type +
    +
    + +Object +| + +Array.<Object> + + +
    +
    + + + + + + + + + + +

    (static) shuffle(array, randomNumberGeneratoropt) → {Array.<Object>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Shuffle an array in place using the Fisher-Yastes's modern algorithm

    +

    See details here: https://en.wikipedia.org/wiki/Fisher%E2%80%93Yates_shuffle#The_modern_algorithm

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDescription
    array + + +Array.<Object> + + + + + + + + + +

    the input 1-D array

    randomNumberGenerator + + +function + + + + + + <optional>
    + + + + + +

    A function used to generated random numbers in the interal [0, 1). Defaults to Math.random

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the shuffled array

    +
    + + + +
    +
    + Type +
    +
    + +Array.<Object> + + +
    +
    + + + + + + + + + + +

    (static) sliceArray(array, fromopt, toopt, stepopt) → {Array.<Object>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Slice an array.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    array + + +Array.<Object> + + + + + + + + + + + +

    the input array

    from + + +number + + + + + + <optional>
    + + + + + +
    + + NaN + +

    the start of the slice

    to + + +number + + + + + + <optional>
    + + + + + +
    + + NaN + +

    the end of the slice

    step + + +number + + + + + + <optional>
    + + + + + +
    + + NaN + +

    the step of the slice

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the array slice

    +
    + + + +
    +
    + Type +
    +
    + +Array.<Object> + + +
    +
    + + + + + + + + + + +

    (static) sort(input) → {array}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Sort the elements of the input array, in increasing alphabetical or numerical order.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +array + + + +

    an array of numbers or of strings

    + + + + + + + + + + + + + + +
    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

    + +
    + + + + + +
    Returns:
    + + +
    +

    the sorted array

    +
    + + + +
    +
    + Type +
    +
    + +array + + +
    +
    + + + + + + + + + + +

    (static) sum(input, start) → {number}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Calculate the sum of the elements in the input array.

    +

    If 'input' is not an array, then we return start.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +array + + + +

    an array of numbers, or of objects that can be cast into a number, e.g. ['1', 2.5, 3e1]

    start + + +number + + + +

    value added to the sum of numbers (a la Python)

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the sum of the elements in the array + start

    +
    + + + +
    +
    + Type +
    +
    + +number + + +
    +
    + + + + + + + + + + +

    (static) to_height(pos, posUnit, win) → {Array.<number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert the position to height units.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    pos + + +Array.<number> + + + +

    the input position

    posUnit + + +string + + + +

    the position units

    win + + +Window + + + +

    the associated Window

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the position in height units

    +
    + + + +
    +
    + Type +
    +
    + +Array.<number> + + +
    +
    + + + + + + + + + + +

    (static) to_norm(pos, posUnit, win) → {Array.<number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert the position to norm units.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    pos + + +Array.<number> + + + +

    the input position

    posUnit + + +string + + + +

    the position units

    win + + +Window + + + +

    the associated Window

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the position in norm units

    +
    + + + +
    +
    + Type +
    +
    + +Array.<number> + + +
    +
    + + + + + + + + + + +

    (static) to_pixiPoint(pos, posUnit, win, integerCoordinatesopt) → {Array.<number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert a position to a PIXI Point.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    pos + + +Array.<number> + + + + + + + + + + + +

    the input position

    posUnit + + +string + + + + + + + + + + + +

    the position units

    win + + +Window + + + + + + + + + + + +

    the associated Window

    integerCoordinates + + +boolean + + + + + + <optional>
    + + + + + +
    + + false + +

    whether or not to round the PIXI Point coordinates.

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the position as a PIXI Point

    +
    + + + +
    +
    + Type +
    +
    + +Array.<number> + + +
    +
    + + + + + + + + + + +

    (static) to_px(pos, posUnit, win, integerCoordinatesopt) → {Array.<number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert the position to pixel units.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    pos + + +Array.<number> + + + + + + + + + + + +

    the input position

    posUnit + + +string + + + + + + + + + + + +

    the position units

    win + + +Window + + + + + + + + + + + +

    the associated Window

    integerCoordinates + + +boolean + + + + + + <optional>
    + + + + + +
    + + false + +

    whether or not to round the position coordinates.

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the position in pixel units

    +
    + + + +
    +
    + Type +
    +
    + +Array.<number> + + +
    +
    + + + + + + + + + + +

    (static) to_unit(pos, posUnit, win, targetUnit) → {Array.<number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert the position to given units.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    pos + + +Array.<number> + + + +

    the input position

    posUnit + + +string + + + +

    the position units

    win + + +Window + + + +

    the associated Window

    targetUnit + + +string + + + +

    the target units

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the position in target units

    +
    + + + +
    +
    + Type +
    +
    + +Array.<number> + + +
    +
    + + + + + + + + + + +

    (static) to_win(pos, posUnit, win) → {Array.<number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert the position to window units.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    pos + + +Array.<number> + + + +

    the input position

    posUnit + + +string + + + +

    the position units

    win + + +Window + + + +

    the associated Window

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the position in window units

    +
    + + + +
    +
    + Type +
    +
    + +Array.<number> + + +
    +
    + + + + + + + + + + +

    (static) toNumerical(obj) → {number|Array.<number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert obj to its numerical form.

    +
      +
    • number -> number, e.g. 2 -> 2
    • +
    • [number] -> [number], e.g. [1,2,3] -> [1,2,3]
    • +
    • numeral string -> number, e.g. "8" -> 8
    • +
    • [number | numeral string] -> [number], e.g. [1, 2, "3"] -> [1,2,3]
    • +
    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    obj + + +Object + + + +

    the input object

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the numerical form of the input object

    +
    + + + +
    +
    + Type +
    +
    + +number +| + +Array.<number> + + +
    +
    + + + + + + + + + + +

    (static) toString(object) → {string}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert an object to its string representation, taking care of symbols.

    +

    Note: if the object is not already a string, we JSON stringify it and detect circularity.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    object + + +Object + + + +

    the input object

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    a string representation of the object or 'Object (circular)'

    +
    + + + +
    +
    + Type +
    +
    + +string + + +
    +
    + + + + + + + + + + +

    (static) turnSquareBracketsIntoArrays(input, max) → {array}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Convert a string representing a JSON array, e.g. "[1, 2]" into an array, e.g. ["1","2"]. +This approach overcomes the built-in JSON parsing limitations when it comes to eg. floats +missing the naught prefix, and is able to process several arrays, e.g. "[1,2][3,4]".

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +string + + + +

    string potentially containing JSON arrays

    max + + +string + + + +

    how many matches to return, unwrap resulting array if less than two

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    an array if arrays were found, undefined otherwise

    +
    + + + +
    +
    + Type +
    +
    + +array + + +
    +
    + + + + + + + + + + +

    (protected, inner) _match(value)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Create a boolean function that compares an input element to the given value.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    value + + +Number +| + +string +| + +object +| + +null + + + +

    the matching value

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    a function that compares an input element to the given value

    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    + +
    + + + +
    + +
    + +
    + + + + + +
    + + + + + + + +

    Classes

    + +
    +
    Clock
    +
    + +
    Color
    +
    + +
    CountdownTimer
    +
    + +
    EventEmitter
    +
    + +
    PsychObject
    +
    + +
    MonotonicClock
    +
    + +
    Scheduler
    +
    +
    + + + + + +

    Mixins

    + +
    +
    ColorMixin
    +
    +
    + + + + + +

    Members

    + + + +

    (static) mix

    + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Syntactic sugar for Mixins

    +

    This is heavily adapted from: http://justinfagnani.com/2015/12/21/real-mixins-with-javascript-classes/

    +
    + + + + + + + +
    Example
    + +
    class BaseClass { ... }
    +let Mixin1 = (superclass) => class extends superclass { ... }
    +let Mixin2 = (superclass) => class extends superclass { ... }
    +class NewClass extends mix(BaseClass).with(Mixin1, Mixin2) { ... }
    + + + + + +

    (static, constant) TEXT_DIRECTION

    + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Enum that stores possible text directions. +Note that Arabic is the same as RTL but added here to support PsychoPy's +languageStyle enum. Arabic reshaping is handled by the browser automatically.

    +
    + + + + + + + + + + + + +

    Methods

    + + + + + + +

    (static) addInfoFromUrl(info)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Add info extracted from the URL to the given dictionary.

    +

    We exclude all URL parameters starting with a double underscore +since those are reserved for client/server communication

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    info + + +Object + + + +

    the dictionary

    + + + + + + + + + + + + + + + + + + + + + + + + +

    (static) average(input) → {number}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    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.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +array + + + +

    an array of numbers, or of objects that can be cast into a number, e.g. ['1', 2.5, 3e1]

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the average of the elements in the array

    +
    + + + +
    +
    + Type +
    +
    + +number + + +
    +
    + + + + + + + + + + +

    (static) count(input, value)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    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.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +array + + + +

    the input array

    value + + +Number +| + +string +| + +object +| + +null + + + +

    the matching value

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the number of matching elements

    +
    + + + + + + + + + + + + +

    (static) detectBrowser() → {string}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Detect the user's browser.

    +

    Note: since user agent is easily spoofed, we use a more sophisticated approach, as described here: +https://stackoverflow.com/a/9851769

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the detected browser, one of 'Opera', 'Firefox', 'Safari', +'IE', 'Edge', 'EdgeChromium', 'Chrome', 'unknown'

    +
    + + + +
    +
    + Type +
    +
    + +string + + +
    +
    + + + + + + + + + + +

    (static) extensionFromMimeType(mimeType) → {string}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    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.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    mimeType + + +string + + + +

    the MIME type, e.g. 'audio/webm;codecs=opus'

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the corresponding file extension, e.g. '.webm'

    +
    + + + +
    +
    + Type +
    +
    + +string + + +
    +
    + + + + + + + + + + +

    (static) flattenArray(array) → {Array.<Object>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Recursively flatten an array of arrays.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    array + + +Array.<Object> + + + +

    the input array of arrays

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the flatten array

    +
    + + + +
    +
    + Type +
    +
    + +Array.<Object> + + +
    +
    + + + + + + + + + + +

    (static) getDownloadSpeed(psychoJS, nbDownloadsopt) → {number}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get an estimate of the download speed, by repeatedly downloading an image file from a distant +server.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeAttributesDefaultDescription
    psychoJS + + +PsychoJS + + + + + + + + + + + +

    the instance of PsychoJS

    nbDownloads + + +number + + + + + + <optional>
    + + + + + +
    + + 1 + +

    the number of image downloads over which to average +the download speed

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the download speed, in megabits per second

    +
    + + + +
    +
    + Type +
    +
    + +number + + +
    +
    + + + + + + + + + + +

    (static) getErrorStack() → {string}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get the error stack of the calling, exception-throwing function.

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the error stack as a string

    +
    + + + +
    +
    + Type +
    +
    + +string + + +
    +
    + + + + + + + + + + +

    (static) getPositionFromObject(object, units) → {Array.<number>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get the position of the object, in pixel units

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    object + + +Object + + + +

    the input object

    units + + +string + + + +

    the units

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the position of the object, in pixel units

    +
    + + + +
    +
    + Type +
    +
    + +Array.<number> + + +
    +
    + + + + + + + + + + +

    (static) getRequestError(jqXHR, textStatus, errorThrown)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get the most informative error from the server response from a jquery server request.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    jqXHR + +
    textStatus + +
    errorThrown + +
    + + + + + + + + + + + + + + + + + + + + + + + + +

    (static) getUrlParameters() → {URLSearchParams}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get the URL parameters.

    +
    + + + + + + + + + +
    Example
    + +
    const urlParameters = util.getUrlParameters();
    +for (const [key, value] of urlParameters)
    +  console.log(key + ' = ' + value);
    + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the iterable URLSearchParams

    +
    + + + +
    +
    + Type +
    +
    + +URLSearchParams + + +
    +
    + + + + + + + + + + +

    (static) index(input, value)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    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.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +array + + + +

    the input array

    value + + +Number +| + +string +| + +object +| + +null + + + +

    the matching value

    + + + + + + + + + + + + + + +
    Throws:
    + + + +
    + +

    if the input array does not contain any matching element

    + +
    + + + + + +
    Returns:
    + + +
    +

    the index of the first element that matches the value

    +
    + + + + + + + + + + + + +

    (static) isEmpty(x) → {boolean}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Test if x is an 'empty' value.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    x + + +Object + + + +

    the value to test

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    true if x is one of the following: undefined, [], [undefined]

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + + + + +

    (static) isInt(obj) → {boolean}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Test whether an object is either an integer or the string representation of an integer.

    +

    This is adapted from: https://stackoverflow.com/a/14794066

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    obj + + +Object + + + +

    the input object

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    whether or not the object is an integer or the string representation of an integer

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + + + + +

    (static) isNumeric(input) → {boolean}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Check whether a value looks like a number

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    input + + +* + + + +

    Some value

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    Whether or not the value can be converted into a number

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + + + + +

    (static) IsPointInsidePolygon(point, vertices) → {boolean}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Check whether a point lies within a polygon

    +

    We are using the algorithm described here: https://wrf.ecse.rpi.edu//Research/Short_Notes/pnpoly.html

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    point + + +Array.<number> + + + +

    the point

    vertices + + +Object + + + +

    the vertices defining the polygon

    + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    whether or not the point lies within the polygon

    +
    + + + +
    +
    + Type +
    +
    + +boolean + + +
    +
    + + + + + + + + + + +

    (static) makeUuid() → {string}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Get a Universally Unique Identifier (RFC4122 version 4)

    +

    See details here: https://www.ietf.org/rfc/rfc4122.txt

    +
    + + + + + + + + + + + + + + + + + + + + + + + + + +
    Returns:
    + + +
    +

    the uuid

    +
    + + + +
    +
    + Type +
    +
    + +string + + +
    +
    + + + + + + + + + + +

    (static) offerDataForDownload(filename, data, type)

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    + + + + + +
    +

    Offer data as download in the browser.

    +
    + + + + + + + + + + + +
    Parameters:
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    NameTypeDescription
    filename + + +string + + + +

    the name of the file to be downloaded

    data + + +* + + + +

    the data

    type + + +string + + + +

    the MIME type of the data, e.g. 'text/csv' or 'application/json'

    + + + + + + + + + + + + + + + + + + +

    (static) pad(n, width) → {string}

    + -
    Returns:
    - -
    - the uuid -
    +
    + +
    Source:
    +
    + + -
    -
    - Type -
    -
    - -string + + -
    -
    + + + + + - - + -

    (static) offerDataForDownload(filename, data, type)

    + + + +
    + + -
    - Offer data as download in the browser. +
    +

    Pad the given floating-point number with however many 0 needed at the start such that +the padded integer part of the number is of the given width.

    @@ -2792,6 +10523,8 @@

    (static + +
    Parameters:
    @@ -2817,110 +10550,102 @@
    Parameters:

    filenamen - -string - - - the name of the file to be downloaded

    the input floating-point number

    datawidth - -* - - - the data

    the desired width

    - - - type - - - - -string - - - - - the MIME type of the data, e.g. 'text/csv' or 'application/json' - - - - -
    - - +
    Returns:
    - + +
    +
      +
    • the padded number, whose integer part has the given width
    • +
    +
    - - - +
    +
    + Type +
    +
    + +string - - +
    +
    - + + + + +

    (static) promiseToTupple(promise) → {Array.<Object>}

    + + + + +
    +
    Source:
    @@ -2929,42 +10654,38 @@
    Parameters:
    -
    - - - - - - - - - - - - + + + + + + + - - + -

    (static) promiseToTupple(promise) → {Array.<Object>}

    + +
    -
    - Convert the resulting value of a promise into a tupple. + + +
    +

    Convert the resulting value of a promise into a tupple.

    @@ -2975,6 +10696,8 @@

    (static) Parameters:

    @@ -3016,7 +10739,7 @@
    Parameters:
    - the promise +

    the promise

    @@ -3028,103 +10751,100 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    the resulting value in the format [error, return data] +where error is null if there was no error

    +
    - - -
    Source:
    -
    - - +
    +
    + Type +
    +
    + +Array.<Object> - - +
    + + + + +

    (static) randchoice(array, randomNumberGeneratoropt) → {Array.<Object>}

    + +
    + +
    Source:
    +
    + + + -
    Returns:
    - - -
    - the resulting value in the format [error, return data] -where error is null if there was no error -
    - - - -
    -
    - Type -
    -
    - -Array.<Object> + + -
    -
    + + + + + - - + -

    (static) randchoice(array, randomNumberGeneratoropt) → {Array.<Object>}

    + +
    + -
    - Pick a random value from an array, uses `util.shuffle` to shuffle the array and returns the last value. + +
    +

    Pick a random value from an array, uses util.shuffle to shuffle the array and returns the last value.

    @@ -3135,6 +10855,8 @@

    (static) r + +

    Parameters:
    @@ -3186,7 +10908,7 @@
    Parameters:
    - the input 1-D array +

    the input 1-D array

    @@ -3219,7 +10941,7 @@
    Parameters:
    - A function used to generated random numbers in the interal [0, 1). Defaults to Math.random +

    A function used to generated random numbers in the interal [0, 1). Defaults to Math.random

    @@ -3231,102 +10953,99 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    a chosen value from the array

    +
    - - -
    Source:
    -
    - - +
    +
    + Type +
    +
    + +Array.<Object> - - +
    + + + + +

    (static) randint(minopt, max) → {number}

    + +
    + +
    Source:
    +
    + + + -
    Returns:
    - - -
    - a chosen value from the array -
    - - - -
    -
    - Type -
    -
    - -Array.<Object> + + -
    -
    + + + + + - - + -

    (static) randint(minopt, max) → {number}

    + +
    + + -
    - Generates random integers a-la NumPy's in the "half-open" interval [min, max). In other words, from min inclusive to max exclusive. When max is undefined, as is the case by default, results are chosen from [0, min). An error is thrown if max is less than min. +
    +

    Generates random integers a-la NumPy's in the "half-open" interval [min, max). In other words, from min inclusive to max exclusive. When max is undefined, as is the case by default, results are chosen from [0, min). An error is thrown if max is less than min.

    @@ -3337,6 +11056,8 @@

    (static) rand + +

    Parameters:
    @@ -3393,12 +11114,12 @@
    Parameters:
    - 0 + 0 - lowest integer to be drawn, or highest plus one if max is undefined (default) +

    lowest integer to be drawn, or highest plus one if max is undefined (default)

    @@ -3433,7 +11154,7 @@
    Parameters:
    - one above the largest integer to be drawn +

    one above the largest integer to be drawn

    @@ -3445,105 +11166,100 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    a random integer in the requested range (signed)

    +
    - - -
    Source:
    -
    - - +
    +
    + Type +
    +
    + +number - - +
    + + + + +

    (static) range(startopt, stop, stepopt) → {Array.<Number>}

    + +
    + +
    Source:
    +
    + + + -
    Returns:
    - - -
    - a random integer in the requested range (signed) -
    - - - -
    -
    - Type -
    -
    - -number + + -
    -
    + + + + + - - + -

    (static) range(startopt, stop, stepopt) → {Array.<Number>}

    + +
    -
    - 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 +
    +

    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.

    @@ -3555,6 +11271,8 @@

    (static) range< + +

    Parameters:
    @@ -3611,12 +11329,12 @@
    Parameters:
    - 0 + 0 - the value of start +

    the value of start

    @@ -3651,7 +11369,7 @@
    Parameters:
    - the value of stop +

    the value of stop

    @@ -3685,12 +11403,12 @@
    Parameters:
    - 1 + 1 - the value of step +

    the value of step

    @@ -3702,104 +11420,107 @@
    Parameters:
    -
    - - - - - - - - - - +
    Returns:
    - + +
    +

    the range as an array of numbers

    +
    - - -
    Source:
    -
    - - +
    +
    + Type +
    +
    + +Array.<Number> - - +
    + + + + +

    (static) round(input, places) → {number}

    + +
    + +
    Source:
    +
    + + + -
    Returns:
    - - -
    - the range as an array of numbers -
    - - + -
    -
    - Type -
    -
    - -Array.<Number> + + -
    -
    + + + + + - - + -

    (static) round(input, places) → {number}

    +
    See:
    +
    + +
    +
    + -
    - 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. +
    +

    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.

    @@ -3810,6 +11531,8 @@

    (static) round< + +

    Parameters:
    @@ -3851,7 +11574,7 @@
    Parameters:
    - the number to be rounded +

    the number to be rounded

    @@ -3872,66 +11595,15 @@
    Parameters:
    - - - the max number of decimals desired - - - - - - - - - - - -
    - - - - - - - - - - - - - - - - - - - - - - - - - - -
    Source:
    -
    - - - - - -
    See:
    -
    - -
    - - + + +

    the max number of decimals desired

    + + -
    + + + @@ -3951,12 +11623,12 @@
    Returns:
    - input rounded to the specified number of decimal places at most +

    input rounded to the specified number of decimal places at most

    -
    +
    Type
    @@ -3972,24 +11644,64 @@
    Returns:
    - - -

    (static) selectFromArray(array, selection) → {Object|Array.<Object>}

    + + + + + + +
    + + +
    Source:
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    -
    - Select values from an array. + +
    +

    Select values from an array.

    'selection' can be a single integer, an array of indices, or a string to be parsed, e.g.:

    * - * @name module:core.Mouse#mouseMoved - * @function - * @public * @param {undefined|number|Array.number} [distance] - the distance to which the mouse movement is compared (see above for a full description) * @param {boolean|String|Array.number} [reset= false] - see above for a full description * @return {boolean} see above for a full description @@ -319,9 +300,6 @@ export class Mouse extends PsychObject /** * Get the amount of time elapsed since the last mouse movement. * - * @name module:core.Mouse#mouseMoveTime - * @function - * @public * @return {number} the time elapsed since the last mouse movement */ mouseMoveTime() @@ -332,9 +310,6 @@ export class Mouse extends PsychObject /** * Reset the clocks associated to the given mouse buttons. * - * @name module:core.Mouse#clickReset - * @function - * @public * @param {Array.number} [buttons= [0,1,2]] the buttons to reset (0: left, 1: center, 2: right) */ clickReset(buttons = [0, 1, 2]) diff --git a/src/core/PsychoJS.js b/src/core/PsychoJS.js index 08aa9707..aabd33cf 100644 --- a/src/core/PsychoJS.js +++ b/src/core/PsychoJS.js @@ -3,8 +3,8 @@ * Main component of the PsychoJS library. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -21,18 +21,11 @@ import { Window } from "./Window.js"; import {Shelf} from "../data/Shelf"; /** - *

    PsychoJS manages the lifecycle of an experiment. It initialises the PsychoJS library and its various components (e.g. the {@link ServerManager}, the {@link EventManager}), and is used by the experiment to schedule the various tasks.

    - * - * @class - * @param {Object} options - * @param {boolean} [options.debug= true] whether or not to log debug information in the browser console - * @param {boolean} [options.collectIP= false] whether or not to collect the IP information of the participant + *

    PsychoJS initialises the library and its various components (e.g. the [ServerManager]{@link module:core.ServerManager}, the [EventManager]{@link module:core.EventManager}), and manages + * the lifecycle of an experiment.

    */ export class PsychoJS { - /** - * Properties - */ get status() { return this._status; @@ -115,8 +108,9 @@ export class PsychoJS } /** - * @constructor - * @public + * @param {Object} options + * @param {boolean} [options.debug= true] whether to log debug information in the browser console + * @param {boolean} [options.collectIP= false] whether to collect the IP information of the participant */ constructor({ debug = true, @@ -176,7 +170,7 @@ export class PsychoJS } this.logger.info("[PsychoJS] Initialised."); - this.logger.info("[PsychoJS] @version 2022.2.0"); + this.logger.info("[PsychoJS] @version 2022.2.1"); // hide the initialisation message: const root = document.getElementById("root"); @@ -212,8 +206,6 @@ export class PsychoJS * @param {boolean} [options.waitBlanking] whether or not to wait for all rendering operations to be done * before flipping * @throws {Object.} exception if a window has already been opened - * - * @public */ openWindow({ name, @@ -263,9 +255,8 @@ export class PsychoJS /** * Schedule a task. * - * @param task - the task to be scheduled - * @param args - arguments for that task - * @public + * @param {module:util.Scheduler~Task} task - the task to be scheduled + * @param {*} args - arguments for that task */ schedule(task, args) { @@ -282,9 +273,8 @@ export class PsychoJS * Schedule a series of task based on a condition. * * @param {PsychoJS.condition} condition - * @param {Scheduler} thenScheduler scheduler to run if the condition is true - * @param {Scheduler} elseScheduler scheduler to run if the condition is false - * @public + * @param {Scheduler} thenScheduler - scheduler to run if the condition is true + * @param {Scheduler} elseScheduler - scheduler to run if the condition is false */ scheduleCondition(condition, thenScheduler, elseScheduler) { @@ -312,8 +302,6 @@ export class PsychoJS * @param {string} [options.expName=UNKNOWN] - the name of the experiment * @param {Object.} [options.expInfo] - additional information about the experiment * @param {Array.<{name: string, path: string}>} [resources=[]] - the list of resources - * @async - * @public */ async start({ configURL = "config.json", expName = "UNKNOWN", expInfo = {}, resources = [], dataFileName } = {}) { @@ -424,7 +412,6 @@ 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 - * @public */ waitForResources(resources = []) { @@ -448,7 +435,6 @@ export class PsychoJS * Make the attributes of the given object those of window, such that they become global. * * @param {Object.} obj the object whose attributes are to become global - * @public */ importAttributes(obj) { @@ -474,9 +460,7 @@ export class PsychoJS * * @param {Object} options * @param {string} [options.message] - optional message to be displayed in a dialog box before quitting - * @param {boolean} [options.isCompleted = false] - whether or not the participant has completed the experiment - * @async - * @public + * @param {boolean} [options.isCompleted = false] - whether the participant has completed the experiment */ async quit({ message, isCompleted = false } = {}) { @@ -484,6 +468,7 @@ export class PsychoJS this._experiment.experimentEnded = true; this._status = PsychoJS.Status.FINISHED; + const isServerEnv = this.getEnvironment() === ExperimentHandler.Environment.SERVER; try { @@ -491,28 +476,32 @@ export class PsychoJS this._scheduler.stop(); // remove the beforeunload listener: - if (this.getEnvironment() === ExperimentHandler.Environment.SERVER) + if (isServerEnv) { window.removeEventListener("beforeunload", this.beforeunloadCallback); } // save the results and the logs of the experiment: - this.gui.dialog({ - warning: "Closing the session. Please wait a few moments.", - showOK: false, + this.gui.finishDialog({ + text: "Terminating the experiment. Please wait a few moments...", + nbSteps: 2 + ((isServerEnv) ? 1 : 0) }); + if (isCompleted || this._config.experiment.saveIncompleteResults) { if (!this._serverMsg.has("__noOutput")) { + this.gui.finishDialogNextStep("saving results"); await this._experiment.save(); + this.gui.finishDialogNextStep("saving logs"); await this._logger.flush(); } } // close the session: - if (this.getEnvironment() === ExperimentHandler.Environment.SERVER) + if (isServerEnv) { + this.gui.finishDialogNextStep("closing the session"); await this._serverManager.closeSession(isCompleted); } @@ -547,6 +536,7 @@ export class PsychoJS } }, }); + } catch (error) { @@ -558,7 +548,6 @@ export class PsychoJS /** * Configure PsychoJS for the running experiment. * - * @async * @protected * @param {string} configURL - the URL of the configuration file * @param {string} name - the name of the experiment @@ -709,7 +698,6 @@ export class PsychoJS /** * Capture all errors and display them in a pop-up error box. - * * @protected */ _captureErrors() @@ -756,7 +744,7 @@ export class PsychoJS /** * Make the various Status top level, in order to accommodate PsychoPy's Code Components. - * @private + * @protected */ _makeStatusTopLevel() { @@ -772,7 +760,6 @@ export class PsychoJS * * @enum {Symbol} * @readonly - * @public * * @note PsychoPy is currently moving away from STOPPED and replacing STOPPED by FINISHED. * For backward compatibility reasons, we are keeping diff --git a/src/core/ServerManager.js b/src/core/ServerManager.js index 66ddda1c..5fd8654c 100644 --- a/src/core/ServerManager.js +++ b/src/core/ServerManager.js @@ -2,8 +2,8 @@ * Manager responsible for the communication between the experiment running in the participant's browser and the pavlovia.org server. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -19,25 +19,26 @@ import { PsychoJS } from "./PsychoJS.js"; *

    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 * @extends PsychObject - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {boolean} [options.autoLog= false] - whether or not to log */ export class ServerManager extends PsychObject { - /**************************************************************************** + /** * Used to indicate to the ServerManager that all resources must be registered (and * subsequently downloaded) * - * @type {symbol} + * @type {Symbol} * @readonly * @public */ static ALL_RESOURCES = Symbol.for("ALL_RESOURCES"); + /** + * @memberof module:core + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {boolean} [options.autoLog= false] - whether or not to log + */ constructor({ psychoJS, autoLog = false, @@ -58,21 +59,17 @@ export class ServerManager extends PsychObject this._addAttribute("status", ServerManager.Status.READY); } - /**************************************************************************** + /** * @typedef ServerManager.GetConfigurationPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.} [config] the configuration * @property {Object.} [error] an error message if we could not read the configuration file */ - /**************************************************************************** + /** * Read the configuration file for the experiment. * - * @name module:core.ServerManager#getConfiguration - * @function - * @public * @param {string} configURL - the URL of the configuration file - * * @returns {Promise} the response */ getConfiguration(configURL) @@ -119,19 +116,16 @@ export class ServerManager extends PsychObject }); } - /**************************************************************************** + /** * @typedef ServerManager.OpenSessionPromise * @property {string} origin the calling method * @property {string} context the context * @property {string} [token] the session token * @property {Object.} [error] an error message if we could not open the session */ - /**************************************************************************** + /** * Open a session for this experiment on the remote PsychoJS manager. * - * @name module:core.ServerManager#openSession - * @function - * @public * @returns {Promise} the response */ openSession() @@ -160,7 +154,8 @@ export class ServerManager extends PsychObject const postResponse = await this._queryServerAPI( "POST", `experiments/${this._psychoJS.config.gitlab.projectId}/sessions`, - data + data, + "FORM" ); const openSessionResponse = await postResponse.json(); @@ -213,18 +208,15 @@ export class ServerManager extends PsychObject }); } - /**************************************************************************** + /** * @typedef ServerManager.CloseSessionPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.} [error] an error message if we could not close the session (e.g. if it has not previously been opened) */ - /**************************************************************************** + /** * Close the session for this experiment on the remote PsychoJS manager. * - * @name module:core.ServerManager#closeSession - * @function - * @public * @param {boolean} [isCompleted= false] - whether or not the experiment was completed * @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner * @returns {Promise | void} the response @@ -262,7 +254,8 @@ export class ServerManager extends PsychObject const deleteResponse = await this._queryServerAPI( "DELETE", `experiments/${this._psychoJS.config.gitlab.projectId}/sessions/${this._psychoJS.config.session.token}`, - { isCompleted } + { isCompleted }, + "FORM" ); const closeSessionResponse = await deleteResponse.json(); @@ -286,12 +279,9 @@ export class ServerManager extends PsychObject } } - /**************************************************************************** + /** * Get the value of a resource. * - * @name module:core.ServerManager#getResource - * @function - * @public * @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 @@ -325,7 +315,7 @@ export class ServerManager extends PsychObject return pathStatusData.data; } - /**************************************************************************** + /** * Get the status of a single resource or the reduced status of an array of resources. * *

    If an array of resources is given, getResourceStatus returns a single, reduced status @@ -340,11 +330,8 @@ export class ServerManager extends PsychObject * *

    * - * @name module:core.ServerManager#getResourceStatus - * @function - * @public * @param {string | string[]} names names of the resources whose statuses are requested - * @return {core.ServerManager.ResourceStatus} status of the resource if there is only one, or reduced status otherwise + * @return {module:core.ServerManager.ResourceStatus} status of the resource if there is only one, or reduced status otherwise * @throws {Object.} if at least one of the names is not that of a previously * registered resource */ @@ -394,12 +381,8 @@ export class ServerManager extends PsychObject return reducedStatus; } - /**************************************************************************** + /** * Set the resource manager status. - * - * @name module:core.ServerManager#setStatus - * @function - * @public */ setStatus(status) { @@ -427,12 +410,9 @@ export class ServerManager extends PsychObject return this._status; } - /**************************************************************************** + /** * Reset the resource manager status to ServerManager.Status.READY. * - * @name module:core.ServerManager#resetStatus - * @function - * @public * @return {ServerManager.Status.READY} the new status */ resetStatus() @@ -440,7 +420,7 @@ export class ServerManager extends PsychObject return this.setStatus(ServerManager.Status.READY); } - /**************************************************************************** + /** * Prepare resources for the experiment: register them with the server manager and possibly * start downloading them right away. * @@ -453,10 +433,7 @@ export class ServerManager extends PsychObject *
  • If resources is null, then we do not download any resources
  • * * - * @name module:core.ServerManager#prepareResources * @param {String | Array.<{name: string, path: string, download: boolean} | String | Symbol>} [resources=[]] - the list of resources or a single resource - * @function - * @public */ async prepareResources(resources = []) { @@ -605,13 +582,10 @@ export class ServerManager extends PsychObject } } - /**************************************************************************** + /** * 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 */ waitForResources(resources = []) { @@ -707,22 +681,18 @@ export class ServerManager extends PsychObject }; } - /**************************************************************************** + /** * @typedef ServerManager.UploadDataPromise * @property {string} origin the calling method * @property {string} context the context * @property {Object.} [error] an error message if we could not upload the data */ - /**************************************************************************** + /** * Asynchronously upload experiment data to the pavlovia server. * - * @name module:core.ServerManager#uploadData - * @function - * @public * @param {string} key - the data key (e.g. the name of .csv file) * @param {string} value - the data value (e.g. a string containing the .csv header and records) * @param {boolean} [sync= false] - whether or not to communicate with the server in a synchronous manner - * * @returns {Promise} the response */ uploadData(key, value, sync = false) @@ -780,12 +750,9 @@ export class ServerManager extends PsychObject } } - /**************************************************************************** + /** * Asynchronously upload experiment logs to the pavlovia server. * - * @name module:core.ServerManager#uploadLog - * @function - * @public * @param {string} logs - the base64 encoded, compressed, formatted logs * @param {boolean} [compressed=false] - whether or not the logs are compressed * @returns {Promise} the response @@ -843,12 +810,9 @@ export class ServerManager extends PsychObject }); } - /**************************************************************************** + /** * Synchronously or asynchronously upload audio data to the pavlovia server. * - * @name module:core.ServerManager#uploadAudioVideo - * @function - * @public * @param @param {Object} options * @param {Blob} options.mediaBlob - the audio or video blob to be uploaded * @param {string} options.tag - additional tag @@ -978,12 +942,10 @@ export class ServerManager extends PsychObject } } - /**************************************************************************** + /** * List the resources available to the experiment. * - * @name module:core.ServerManager#_listResources - * @function - * @private + * @protected */ _listResources() { @@ -1037,13 +999,11 @@ export class ServerManager extends PsychObject }); } - /**************************************************************************** + /** * Download the specified resources. * *

    Note: we use the [preloadjs library]{@link https://www.createjs.com/preloadjs}.

    * - * @name module:core.ServerManager#_downloadResources - * @function * @protected * @param {Set} resources - a set of names of previously registered resources */ @@ -1241,11 +1201,9 @@ export class ServerManager extends PsychObject } } - /**************************************************************************** + /** * Setup the preload.js queue, and the associated callbacks. * - * @name module:core.ServerManager#_setupPreloadQueue - * @function * @protected */ _setupPreloadQueue() @@ -1336,8 +1294,6 @@ export class ServerManager extends PsychObject /** * Query the pavlovia server API. * - * @name module:core.ServerManager#_queryServerAPI - * @function * @protected * @param method the HTTP method, i.e. GET, PUT, POST, or DELETE * @param path the resource path, without the server address @@ -1409,15 +1365,13 @@ export class ServerManager extends PsychObject } -/**************************************************************************** +/** * Server event * *

    A server event is emitted by the manager to inform its listeners of either a change of status, or of a resource related event (e.g. download started, download is completed).

    * - * @name module:core.ServerManager#Event * @enum {Symbol} * @readonly - * @public */ ServerManager.Event = { /** @@ -1443,7 +1397,7 @@ ServerManager.Event = { /** * Event: resources have all downloaded */ - DOWNLOADS_COMPLETED: Symbol.for("DOWNLOAD_COMPLETED"), + DOWNLOAD_COMPLETED: Symbol.for("DOWNLOAD_COMPLETED"), /** * Event type: status event @@ -1451,13 +1405,11 @@ ServerManager.Event = { STATUS: Symbol.for("STATUS"), }; -/**************************************************************************** +/** * Server status * - * @name module:core.ServerManager#Status * @enum {Symbol} * @readonly - * @public */ ServerManager.Status = { /** @@ -1476,13 +1428,11 @@ ServerManager.Status = { ERROR: Symbol.for("ERROR"), }; -/**************************************************************************** +/** * Resource status * - * @name module:core.ServerManager#ResourceStatus * @enum {Symbol} * @readonly - * @public */ ServerManager.ResourceStatus = { /** diff --git a/src/core/Window.js b/src/core/Window.js index 178a9fce..c666e8b5 100644 --- a/src/core/Window.js +++ b/src/core/Window.js @@ -2,8 +2,8 @@ * Window responsible for displaying the experiment stimuli * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -18,20 +18,7 @@ import { Logger } from "./Logger.js"; *

    Window displays the various stimuli of the experiment.

    *

    It sets up a [PIXI]{@link http://www.pixijs.com/} renderer, which we use to render the experiment stimuli.

    * - * @name module:core.Window - * @class * @extends PsychObject - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {string} [options.name] the name of the window - * @param {boolean} [options.fullscr= false] whether or not to go fullscreen - * @param {Color} [options.color= Color('black')] the background color of the window - * @param {number} [options.gamma= 1] sets the divisor for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) - * @param {number} [options.contrast= 1] sets the contrast value - * @param {string} [options.units= 'pix'] the units of the window - * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done - * before flipping - * @param {boolean} [options.autoLog= true] whether or not to log */ export class Window extends PsychObject { @@ -47,6 +34,20 @@ export class Window extends PsychObject return 1.0 / this.getActualFrameRate(); } + /** + * @memberof module:core + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {string} [options.name] the name of the window + * @param {boolean} [options.fullscr= false] whether or not to go fullscreen + * @param {Color} [options.color= Color('black')] the background color of the window + * @param {number} [options.gamma= 1] sets the divisor for gamma correction. In other words gamma correction is calculated as pow(rgb, 1/gamma) + * @param {number} [options.contrast= 1] sets the contrast value + * @param {string} [options.units= 'pix'] the units of the window + * @param {boolean} [options.waitBlanking= false] whether or not to wait for all rendering operations to be done + * before flipping + * @param {boolean} [options.autoLog= true] whether or not to log + */ constructor({ psychoJS, name, @@ -124,10 +125,6 @@ export class Window extends PsychObject * Close the window. * *

    Note: this actually only removes the canvas used to render the experiment stimuli.

    - * - * @name module:core.Window#close - * @function - * @public */ close() { @@ -161,9 +158,6 @@ export class Window extends PsychObject /** * Estimate the frame rate. * - * @name module:core.Window#getActualFrameRate - * @function - * @public * @return {number} rAF based delta time based approximation, 60.0 by default */ getActualFrameRate() @@ -177,10 +171,6 @@ export class Window extends PsychObject /** * Take the browser full screen if possible. - * - * @name module:core.Window#adjustScreenSize - * @function - * @public */ adjustScreenSize() { @@ -222,10 +212,6 @@ export class Window extends PsychObject /** * Take the browser back from full screen if needed. - * - * @name module:core.Window#closeFullScreen - * @function - * @public */ closeFullScreen() { @@ -265,9 +251,6 @@ export class Window extends PsychObject * *

    Note: the message will be time-stamped at the next call to requestAnimationFrame.

    * - * @name module:core.Window#logOnFlip - * @function - * @public * @param {Object} options * @param {String} options.msg the message to be logged * @param {module:util.Logger.ServerLevel} [level = module:util.Logger.ServerLevel.EXP] the log level @@ -294,9 +277,6 @@ export class Window extends PsychObject * *

    This is typically used to reset a timer or clock.

    * - * @name module:core.Window#callOnFlip - * @function - * @public * @param {module:core.Window~OnFlipCallback} flipCallback - callback function. * @param {...*} flipCallbackArgs - arguments for the callback function. */ @@ -307,10 +287,6 @@ export class Window extends PsychObject /** * Add PIXI.DisplayObject to the container displayed on the scene (window) - * - * @name module:core.Window#addPixiObject - * @function - * @public */ addPixiObject(pixiObject) { @@ -319,10 +295,6 @@ export class Window extends PsychObject /** * Remove PIXI.DisplayObject from the container displayed on the scene (window) - * - * @name module:core.Window#removePixiObject - * @function - * @public */ removePixiObject(pixiObject) { @@ -331,10 +303,6 @@ export class Window extends PsychObject /** * Render the stimuli onto the canvas. - * - * @name module:core.Window#render - * @function - * @public */ render() { @@ -378,9 +346,7 @@ export class Window extends PsychObject /** * Update this window, if need be. * - * @name module:core.Window#_updateIfNeeded - * @function - * @private + * @protected */ _updateIfNeeded() { @@ -403,9 +369,7 @@ export class Window extends PsychObject /** * Recompute this window's draw list and _container children for the next animation frame. * - * @name module:core.Window#_refresh - * @function - * @private + * @protected */ _refresh() { @@ -427,9 +391,7 @@ export class Window extends PsychObject /** * Force an update of all stimuli in this window's drawlist. * - * @name module:core.Window#_fullRefresh - * @function - * @private + * @protected */ _fullRefresh() { @@ -449,9 +411,7 @@ export class Window extends PsychObject *

    A new renderer is created and a container is added to it. The renderer's touch and mouse events * are handled by the {@link EventManager}.

    * - * @name module:core.Window#_setupPixi - * @function - * @private + * @protected */ _setupPixi() { @@ -523,9 +483,7 @@ export class Window extends PsychObject * Adjust the size of the renderer and the position of the root container * in response to a change in the browser's size. * - * @name module:core.Window#_resizePixiRenderer - * @function - * @private + * @protected * @param {module:core.Window} pjsWindow - the PsychoJS Window * @param event */ @@ -554,9 +512,7 @@ export class Window extends PsychObject /** * Send all logged messages to the {@link Logger}. * - * @name module:core.Window#_writeLogOnFlip - * @function - * @private + * @protected */ _writeLogOnFlip() { diff --git a/src/core/WindowMixin.js b/src/core/WindowMixin.js index e03be111..41100dfc 100644 --- a/src/core/WindowMixin.js +++ b/src/core/WindowMixin.js @@ -2,8 +2,8 @@ * Mixin implementing various unit-handling measurement methods. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ diff --git a/src/data/ExperimentHandler.js b/src/data/ExperimentHandler.js index 70ce3ea6..2c2bebc9 100644 --- a/src/data/ExperimentHandler.js +++ b/src/data/ExperimentHandler.js @@ -2,8 +2,8 @@ * Experiment Handler * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -17,22 +17,12 @@ import * as util from "../util/Util.js"; * for generating a single data file from an experiment with many different loops (e.g. interleaved * staircases or loops within loops.

    * - * @name module:data.ExperimentHandler - * @class * @extends PsychObject - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {string} options.name - name of the experiment - * @param {Object} options.extraInfo - additional information, such as session name, participant name, etc. */ export class ExperimentHandler extends PsychObject { /** * Getter for experimentEnded. - * - * @name module:data.ExperimentHandler#experimentEnded - * @function - * @public */ get experimentEnded() { @@ -41,10 +31,6 @@ export class ExperimentHandler extends PsychObject /** * Setter for experimentEnded. - * - * @name module:data.ExperimentHandler#experimentEnded - * @function - * @public */ set experimentEnded(ended) { @@ -64,6 +50,13 @@ export class ExperimentHandler extends PsychObject return this._trialsData; } + /** + * @memberof module:data + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {string} options.name - name of the experiment + * @param {Object} options.extraInfo - additional information, such as session name, participant name, etc. + */ constructor({ psychoJS, name, @@ -111,9 +104,6 @@ export class ExperimentHandler extends PsychObject * Whether or not the current entry (i.e. trial data) is empty. *

    Note: this is mostly useful at the end of an experiment, in order to ensure that the last entry is saved.

    * - * @name module:data.ExperimentHandler#isEntryEmpty - * @function - * @public * @returns {boolean} whether or not the current entry is empty * @todo This really should be renamed: IsCurrentEntryNotEmpty */ @@ -128,9 +118,6 @@ export class ExperimentHandler extends PsychObject *

    The loop might be a {@link TrialHandler}, for instance.

    *

    Data from this loop will be included in the resulting data files.

    * - * @name module:data.ExperimentHandler#addLoop - * @function - * @public * @param {Object} loop - the loop, e.g. an instance of TrialHandler or StairHandler */ addLoop(loop) @@ -143,9 +130,6 @@ export class ExperimentHandler extends PsychObject /** * Remove the given loop from the list of unfinished loops, e.g. when it has completed. * - * @name module:data.ExperimentHandler#removeLoop - * @function - * @public * @param {Object} loop - the loop, e.g. an instance of TrialHandler or StairHandler */ removeLoop(loop) @@ -163,9 +147,6 @@ export class ExperimentHandler extends PsychObject *

    Multiple key/value pairs can be added to any given entry of the data file. There are * considered part of the same entry until a call to {@link nextEntry} is made.

    * - * @name module:data.ExperimentHandler#addData - * @function - * @public * @param {Object} key - the key * @param {Object} value - the value */ @@ -189,9 +170,6 @@ export class ExperimentHandler extends PsychObject * Inform this ExperimentHandler that the current trial has ended. Further calls to {@link addData} * will be associated with the next trial. * - * @name module:data.ExperimentHandler#nextEntry - * @function - * @public * @param {Object | Object[] | undefined} snapshots - array of loop snapshots */ nextEntry(snapshots) @@ -256,9 +234,6 @@ export class ExperimentHandler extends PsychObject * *

    * - * @name module:data.ExperimentHandler#save - * @function - * @public * @param {Object} options * @param {Array.} [options.attributes] - the attributes to be saved * @param {boolean} [options.sync=false] - whether or not to communicate with the server in a synchronous manner @@ -380,9 +355,6 @@ export class ExperimentHandler extends PsychObject * Get the attribute names and values for the current trial of a given loop. *

    Only info relating to the trial execution are returned.

    * - * @name module:data.ExperimentHandler#_getLoopAttributes - * @function - * @static * @protected * @param {Object} loop - the loop */ @@ -442,10 +414,8 @@ export class ExperimentHandler extends PsychObject /** * Experiment result format * - * @name module:core.ServerManager#SaveFormat * @enum {Symbol} * @readonly - * @public */ ExperimentHandler.SaveFormat = { /** @@ -464,7 +434,6 @@ ExperimentHandler.SaveFormat = { * * @enum {Symbol} * @readonly - * @public */ ExperimentHandler.Environment = { SERVER: Symbol.for("SERVER"), diff --git a/src/data/MultiStairHandler.js b/src/data/MultiStairHandler.js index 5c71688e..a2b9b51c 100644 --- a/src/data/MultiStairHandler.js +++ b/src/data/MultiStairHandler.js @@ -1,10 +1,9 @@ -/** @module data */ /** * Multiple Staircase Trial Handler * * @author Alain Pitiot - * @version 2021.2.1 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. + * @version 2021.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. * (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -22,27 +21,25 @@ import seedrandom from "seedrandom"; *

    Note that, at the moment, using the MultiStairHandler requires the jsQuest.js * library to be loaded as a resource, at the start of the experiment.

    * - * @class module.data.MultiStairHandler * @extends TrialHandler - * @param {Object} options - the handler options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {string} options.varName - the name of the variable / intensity / contrast - * / threshold manipulated by the staircases - * @param {module:data.MultiStairHandler.StaircaseType} [options.stairType="simple"] - the - * handler type - * @param {Array. | String} [options.conditions= [undefined] ] - if it is a string, - * we treat it as the name of a conditions resource - * @param {module:data.TrialHandler.Method} options.method - the trial method - * @param {number} [options.nTrials=50] - maximum number of trials - * @param {number} options.randomSeed - seed for the random number generator - * @param {string} options.name - name of the handler - * @param {boolean} [options.autoLog= false] - whether or not to log */ export class MultiStairHandler extends TrialHandler { /** - * @constructor - * @public + * @memberof module:data + * @param {Object} options - the handler options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {string} options.varName - the name of the variable / intensity / contrast + * / threshold manipulated by the staircases + * @param {MultiStairHandler.StaircaseType} [options.stairType="simple"] - the + * handler type + * @param {Array. | String} [options.conditions= [undefined] ] - if it is a string, + * we treat it as the name of a conditions resource + * @param {module:data.TrialHandler.Method} options.method - the trial method + * @param {number} [options.nTrials=50] - maximum number of trials + * @param {number} options.randomSeed - seed for the random number generator + * @param {string} options.name - name of the handler + * @param {boolean} [options.autoLog= false] - whether or not to log */ constructor({ psychoJS, @@ -91,10 +88,7 @@ export class MultiStairHandler extends TrialHandler /** * Get the current staircase. * - * @name module:data.MultiStairHandler#currentStaircase - * @function - * @public - * @returns {module.data.TrialHandler} the current staircase, or undefined if the trial has ended + * @returns {TrialHandler} the current staircase, or undefined if the trial has ended */ get currentStaircase() { @@ -104,9 +98,6 @@ export class MultiStairHandler extends TrialHandler /** * Get the current intensity. * - * @name module:data.MultiStairHandler#intensity - * @function - * @public * @returns {number} the intensity of the current staircase, or undefined if the trial has ended */ get intensity() @@ -128,13 +119,9 @@ export class MultiStairHandler extends TrialHandler /** * Add a response to the current staircase. * - * @name module:data.MultiStairHandler#addResponse - * @function - * @public * @param{number} response - the response to the trial, must be either 0 (incorrect or * non-detected) or 1 (correct or detected) * @param{number | undefined} [value] - optional intensity / contrast / threshold - * @returns {void} */ addResponse(response, value) { @@ -163,10 +150,7 @@ export class MultiStairHandler extends TrialHandler /** * Validate the conditions. * - * @name module:data.MultiStairHandler#_validateConditions - * @function * @protected - * @returns {void} */ _validateConditions() { @@ -222,10 +206,7 @@ export class MultiStairHandler extends TrialHandler /** * Setup the staircases, according to the conditions. * - * @name module:data.MultiStairHandler#_prepareStaircases - * @function * @protected - * @returns {void} */ _prepareStaircases() { @@ -282,10 +263,7 @@ export class MultiStairHandler extends TrialHandler /** * Move onto the next trial. * - * @name module:data.MultiStairHandler#_nextTrial - * @function * @protected - * @returns {void} */ _nextTrial() { @@ -425,7 +403,6 @@ export class MultiStairHandler extends TrialHandler * * @enum {Symbol} * @readonly - * @public */ MultiStairHandler.StaircaseType = { /** @@ -444,7 +421,6 @@ MultiStairHandler.StaircaseType = { * * @enum {Symbol} * @readonly - * @public */ MultiStairHandler.StaircaseStatus = { /** diff --git a/src/data/QuestHandler.js b/src/data/QuestHandler.js index 6eb9cc8c..067e5263 100644 --- a/src/data/QuestHandler.js +++ b/src/data/QuestHandler.js @@ -1,10 +1,9 @@ -/** @module data */ /** * Quest Trial Handler * * @author Alain Pitiot & Thomas Pronk - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -15,31 +14,29 @@ import {TrialHandler} from "./TrialHandler.js"; *

    A Trial Handler that implements the Quest algorithm for quick measurement of psychophysical thresholds. QuestHandler relies on the [jsQuest]{@link https://github.com/kurokida/jsQUEST} library, a port of Prof Dennis Pelli's QUEST algorithm by [Daiichiro Kuroki]{@link https://github.com/kurokida}.

    * - * @class module.data.QuestHandler * @extends TrialHandler - * @param {Object} options - the handler options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {string} options.varName - the name of the variable / intensity / contrast / threshold manipulated by QUEST - * @param {number} options.startVal - initial guess for the threshold - * @param {number} options.startValSd - standard deviation of the initial guess - * @param {number} options.minVal - minimum value for the threshold - * @param {number} options.maxVal - maximum value for the threshold - * @param {number} [options.pThreshold=0.82] - threshold criterion expressed as probability of getting a correct response - * @param {number} options.nTrials - maximum number of trials - * @param {number} options.stopInterval - minimum [5%, 95%] confidence interval required for the loop to stop - * @param {module:data.QuestHandler.Method} options.method - the QUEST method - * @param {number} [options.beta=3.5] - steepness of the QUEST psychometric function - * @param {number} [options.delta=0.01] - fraction of trials with blind responses - * @param {number} [options.gamma=0.5] - fraction of trails that would generate a correct response when the threshold is infinitely small - * @param {number} [options.grain=0.01] - quantization of the internal table - * @param {string} options.name - name of the handler - * @param {boolean} [options.autoLog= false] - whether or not to log */ export class QuestHandler extends TrialHandler { /** - * @constructor - * @public + * @memberof module:data + * @param {Object} options - the handler options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {string} options.varName - the name of the variable / intensity / contrast / threshold manipulated by QUEST + * @param {number} options.startVal - initial guess for the threshold + * @param {number} options.startValSd - standard deviation of the initial guess + * @param {number} options.minVal - minimum value for the threshold + * @param {number} options.maxVal - maximum value for the threshold + * @param {number} [options.pThreshold=0.82] - threshold criterion expressed as probability of getting a correct response + * @param {number} options.nTrials - maximum number of trials + * @param {number} options.stopInterval - minimum [5%, 95%] confidence interval required for the loop to stop + * @param {QuestHandler.Method} options.method - the QUEST method + * @param {number} [options.beta=3.5] - steepness of the QUEST psychometric function + * @param {number} [options.delta=0.01] - fraction of trials with blind responses + * @param {number} [options.gamma=0.5] - fraction of trails that would generate a correct response when the threshold is infinitely small + * @param {number} [options.grain=0.01] - quantization of the internal table + * @param {string} options.name - name of the handler + * @param {boolean} [options.autoLog= false] - whether or not to log */ constructor({ psychoJS, @@ -113,15 +110,11 @@ export class QuestHandler extends TrialHandler /** * Add a response and update the PDF. * - * @name module:data.QuestHandler#addResponse - * @function - * @public * @param{number} response - the response to the trial, must be either 0 (incorrect or * non-detected) or 1 (correct or detected) * @param{number | undefined} value - optional intensity / contrast / threshold * @param{boolean} [doAddData = true] - whether or not to add the response as data to the * experiment - * @returns {void} */ addResponse(response, value, doAddData = true) { @@ -163,9 +156,6 @@ export class QuestHandler extends TrialHandler /** * Simulate a response. * - * @name module:data.QuestHandler#simulate - * @function - * @public * @param{number} trueValue - the true, known value of the threshold / contrast / intensity * @returns{number} the simulated response, 0 or 1 */ @@ -184,9 +174,6 @@ export class QuestHandler extends TrialHandler /** * Get the mean of the Quest posterior PDF. * - * @name module:data.QuestHandler#mean - * @function - * @public * @returns {number} the mean */ mean() @@ -197,9 +184,6 @@ export class QuestHandler extends TrialHandler /** * Get the standard deviation of the Quest posterior PDF. * - * @name module:data.QuestHandler#sd - * @function - * @public * @returns {number} the standard deviation */ sd() @@ -210,9 +194,6 @@ export class QuestHandler extends TrialHandler /** * Get the mode of the Quest posterior PDF. * - * @name module:data.QuestHandler#mode - * @function - * @public * @returns {number} the mode */ mode() @@ -224,9 +205,6 @@ export class QuestHandler extends TrialHandler /** * Get the standard deviation of the Quest posterior PDF. * - * @name module:data.QuestHandler#quantile - * @function - * @public * @param{number} quantileOrder the quantile order * @returns {number} the quantile */ @@ -238,9 +216,6 @@ export class QuestHandler extends TrialHandler /** * Get the current value of the variable / contrast / threshold. * - * @name module:data.QuestHandler#getQuestValue - * @function - * @public * @returns {number} the current QUEST value for the variable / contrast / threshold */ getQuestValue() @@ -253,9 +228,6 @@ export class QuestHandler extends TrialHandler * *

    This is the getter associated to getQuestValue.

    * - * @name module:data.MultiStairHandler#intensity - * @function - * @public * @returns {number} the intensity of the current staircase, or undefined if the trial has ended */ get intensity() @@ -266,9 +238,6 @@ export class QuestHandler extends TrialHandler /** * Get an estimate of the 5%-95% confidence interval (CI). * - * @name module:data.QuestHandler#confInterval - * @function - * @public * @param{boolean} [getDifference=false] - if true, return the width of the CI instead of the CI * @returns{number[] | number} the 5%-95% CI or the width of the CI */ @@ -292,10 +261,7 @@ export class QuestHandler extends TrialHandler /** * Setup the JS Quest object. * - * @name module:data.QuestHandler#_setupJsQuest - * @function * @protected - * @returns {void} */ _setupJsQuest() { @@ -313,10 +279,7 @@ export class QuestHandler extends TrialHandler * Estimate the next value of the QUEST variable, based on the current value * and on the selected QUEST method. * - * @name module:data.QuestHandler#_estimateQuestValue - * @function * @protected - * @returns {void} */ _estimateQuestValue() { @@ -387,7 +350,6 @@ export class QuestHandler extends TrialHandler * * @enum {Symbol} * @readonly - * @public */ QuestHandler.Method = { /** diff --git a/src/data/Shelf.js b/src/data/Shelf.js index 20e477d0..a92507cb 100644 --- a/src/data/Shelf.js +++ b/src/data/Shelf.js @@ -1,9 +1,9 @@ -/** @module data */ /** * Shelf handles persistent key/value pairs, or records, which are stored in the shelf collection on the - * server, and be accessed and manipulated in a concurrent fashion. + * server, and can be accessed and manipulated in a concurrent fashion. * * @author Alain Pitiot + * @version 2021.2.3 * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -16,27 +16,25 @@ import {Scheduler} from "../util/Scheduler.js"; /** *

    Shelf handles persistent key/value pairs, or records, which are stored in the shelf collection on the - * server, and be accessed and manipulated in a concurrent fashion.

    + * server, and can be accessed and manipulated in a concurrent fashion.

    * - *

    - * - * @name module:data.Shelf - * @class * @extends PsychObject - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS the PsychoJS instance - * @param {boolean} [options.autoLog= false] whether to log */ export class Shelf extends PsychObject { /** * Maximum number of components in a key - * @name module:data.Shelf.#MAX_KEY_LENGTH * @type {number} * @note this value should mirror that on the server, i.e. the server also checks that the key is valid */ static #MAX_KEY_LENGTH = 10; + /** + * @memberOf module:data + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS the PsychoJS instance + * @param {boolean} [options.autoLog= false] whether to log + */ constructor({psychoJS, autoLog = false } = {}) { super(psychoJS); @@ -56,9 +54,6 @@ export class Shelf extends PsychObject /** * Get the value of a record of type BOOLEAN associated with the given key. * - * @name module:data.Shelf#getBooleanValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {boolean} options.defaultValue the default value returned if no record with the given key exists @@ -75,9 +70,6 @@ export class Shelf extends PsychObject /** * Set the value of a record of type BOOLEAN associated with the given key. * - * @name module:data.Shelf#setBooleanValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {boolean} options.value the new value @@ -108,9 +100,6 @@ export class Shelf extends PsychObject /** * Flip the value of a record of type BOOLEAN associated with the given key. * - * @name module:data.Shelf#flipBooleanValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @return {Promise} the new, flipped, value @@ -129,9 +118,6 @@ export class Shelf extends PsychObject /** * Get the value of a record of type INTEGER associated with the given key. * - * @name module:data.Shelf#getIntegerValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {number} options.defaultValue the default value returned if no record with the given key @@ -148,9 +134,6 @@ export class Shelf extends PsychObject /** * Set the value of a record of type INTEGER associated with the given key. * - * @name module:data.Shelf#setIntegerValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {number} options.value the new value @@ -181,9 +164,6 @@ export class Shelf extends PsychObject /** * Add a delta to the value of a record of type INTEGER associated with the given key. * - * @name module:data.Shelf#addIntegerValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {number} options.delta the delta, positive or negative, to add to the value @@ -214,9 +194,6 @@ export class Shelf extends PsychObject /** * Get the value of a record of type TEXT associated with the given key. * - * @name module:data.Shelf#getTextValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string} options.defaultValue the default value returned if no record with the given key exists on @@ -233,9 +210,6 @@ export class Shelf extends PsychObject /** * Set the value of a record of type TEXT associated with the given key. * - * @name module:data.Shelf#setTextValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string} options.value the new value @@ -266,9 +240,6 @@ export class Shelf extends PsychObject /** * Get the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#getListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {Array.<*>} options.defaultValue the default value returned if no record with the given key exists on @@ -285,9 +256,6 @@ export class Shelf extends PsychObject /** * Set the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#setListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {Array.<*>} options.value the new value @@ -318,9 +286,6 @@ export class Shelf extends PsychObject /** * Append an element, or a list of elements, to the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#appendListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {*} options.elements the element or list of elements to be appended @@ -342,9 +307,6 @@ export class Shelf extends PsychObject * Pop an element, at the given index, from the value of a record of type LIST associated * with the given key. * - * @name module:data.Shelf#popListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {number} [options.index = -1] the index of the element to be popped @@ -365,9 +327,6 @@ export class Shelf extends PsychObject /** * Empty the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#clearListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @return {Promise>} the new, empty value, i.e. [] @@ -386,9 +345,6 @@ export class Shelf extends PsychObject /** * Shuffle the elements of the value of a record of type LIST associated with the given key. * - * @name module:data.Shelf#shuffleListValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @return {Promise>} the new, shuffled value @@ -408,9 +364,6 @@ export class Shelf extends PsychObject /** * Get the names of the fields in the dictionary record associated with the given key. * - * @name module:data.Shelf#getDictionaryFieldNames - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @return {Promise} the list of field names @@ -425,9 +378,6 @@ export class Shelf extends PsychObject /** * Get the value of a given field in the dictionary record associated with the given key. * - * @name module:data.Shelf#getDictionaryFieldValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string} options.fieldName the name of the field @@ -445,9 +395,6 @@ export class Shelf extends PsychObject /** * Set a field in the dictionary record associated to the given key. * - * @name module:data.Shelf#setDictionaryFieldValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string} options.fieldName the name of the field @@ -470,9 +417,6 @@ export class Shelf extends PsychObject /** * Get the value of a record of type DICTIONARY associated with the given key. * - * @name module:data.Shelf#getDictionaryValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {Object.} options.defaultValue the default value returned if no record with the given key @@ -489,9 +433,6 @@ export class Shelf extends PsychObject /** * Set the value of a record of type DICTIONARY associated with the given key. * - * @name module:data.Shelf#setDictionaryValue - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {Object.} options.value the new value @@ -523,9 +464,6 @@ export class Shelf extends PsychObject * Schedulable component that will block the experiment until the counter associated with the given key * has been incremented by the given amount. * - * @name module:data.Shelf#incrementComponent - * @function - * @public * @param key * @param increment * @param callback @@ -576,9 +514,6 @@ export class Shelf extends PsychObject /** * Get the name of a group, using a counterbalanced design. * - * @name module:data.Shelf#counterBalanceSelect - * @function - * @public * @param {Object} options * @param {string[]} options.key key as an array of key components * @param {string[]} options.groups the names of the groups @@ -648,9 +583,6 @@ export class Shelf extends PsychObject * *

    This is a generic method, typically called from the Shelf helper methods, e.g. setBinaryValue.

    * - * @name module:data.Shelf#_updateValue - * @function - * @protected * @param {string[]} key key as an array of key components * @param {Shelf.Type} type the type of the record associated with the given key * @param {*} update the desired update @@ -716,9 +648,6 @@ export class Shelf extends PsychObject * *

    This is a generic method, typically called from the Shelf helper methods, e.g. getBinaryValue.

    * - * @name module:data.Shelf#_getValue - * @function - * @protected * @param {string[]} key key as an array of key components * @param {Shelf.Type} type the type of the record associated with the given key * @param {Object} [options] the options, e.g. the default value returned if no record with the @@ -794,16 +723,8 @@ export class Shelf extends PsychObject * *

    Since all Shelf methods call _checkAvailability, we also use it as a means to throttle those calls.

    * - * @name module:data.Shelf#_checkAvailability - * @function - * @public -<<<<<<< HEAD - * @param {string} [methodName=""] name of the method requiring a check - * @throws {Object.} exception if it is not possible to run the given shelf command -======= - * @param {string} [methodName=""] name of the method requiring a check - * @throw {Object.} exception when it is not possible to run the given shelf command ->>>>>>> 8cc27b9cc9844d435c0177263ef7c2991463196c + * @param {string} [methodName=""] - name of the method requiring a check + * @throws {Object.} exception if it is not possible to run the given shelf command */ _checkAvailability(methodName = "") { @@ -852,9 +773,6 @@ export class Shelf extends PsychObject /** * Check the validity of the key. * - * @name module:data.Shelf#_checkKey - * @function - * @public * @param {object} key key whose validity is to be checked * @throws {Object.} exception if the key is invalid */ @@ -879,10 +797,8 @@ export class Shelf extends PsychObject /** * Shelf status * - * @name module:data.Shelf#Status * @enum {Symbol} * @readonly - * @public */ Shelf.Status = { /** @@ -906,7 +822,6 @@ Shelf.Status = { * * @enum {Symbol} * @readonly - * @public */ Shelf.Type = { INTEGER: Symbol.for('INTEGER'), diff --git a/src/data/TrialHandler.js b/src/data/TrialHandler.js index 0dd189a9..c531d7d7 100644 --- a/src/data/TrialHandler.js +++ b/src/data/TrialHandler.js @@ -4,8 +4,8 @@ * * @author Alain Pitiot * @author Hiroyuki Sogo & Sotiri Bakagiannis - better support for BOM and accented characters - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -17,25 +17,12 @@ import * as util from "../util/Util.js"; /** *

    A Trial Handler handles the importing and sequencing of conditions.

    * - * @class * @extends PsychObject - * @param {Object} options - the handler options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {Array. | String} [options.trialList= [undefined] ] - if it is a string, we treat it as the name of a condition resource - * @param {number} options.nReps - number of repetitions - * @param {module:data.TrialHandler.Method} options.method - the trial method - * @param {Object} options.extraInfo - additional information to be stored alongside the trial data, e.g. session ID, participant ID, etc. - * @param {number} options.seed - seed for the random number generator - * @param {boolean} [options.autoLog= false] - whether or not to log */ export class TrialHandler extends PsychObject { /** * Getter for experimentHandler. - * - * @name module:core.Window#experimentHandler - * @function - * @public */ get experimentHandler() { @@ -44,10 +31,6 @@ export class TrialHandler extends PsychObject /** * Setter for experimentHandler. - * - * @name module:core.Window#experimentHandler - * @function - * @public */ set experimentHandler(exp) { @@ -55,8 +38,14 @@ export class TrialHandler extends PsychObject } /** - * @constructor - * @public + * @param {Object} options - the handler options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {Array. | String} [options.trialList= [undefined] ] - if it is a string, we treat it as the name of a condition resource + * @param {number} options.nReps - number of repetitions + * @param {module:data.TrialHandler.Method} options.method - the trial method + * @param {Object} options.extraInfo - additional information to be stored alongside the trial data, e.g. session ID, participant ID, etc. + * @param {number} options.seed - seed for the random number generator + * @param {boolean} [options.autoLog= false] - whether or not to log * * @todo extraInfo is not taken into account, we use the expInfo of the ExperimentHandler instead */ @@ -223,7 +212,6 @@ export class TrialHandler extends PsychObject * *

    This is typically used in the LoopBegin function, in order to capture the current state of a TrialHandler

    * - * @public * @return {Snapshot} - a snapshot of the current internal state. */ getSnapshot() @@ -296,8 +284,6 @@ export class TrialHandler extends PsychObject /** * Set the internal state of the snapshot's trial handler from the snapshot. * - * @public - * @static * @param {Snapshot} snapshot - the snapshot from which to update the current internal state of the * snapshot's trial handler */ @@ -365,7 +351,6 @@ export class TrialHandler extends PsychObject /** * Get the trial index. * - * @public * @return {number} the current trial index */ getTrialIndex() @@ -389,7 +374,6 @@ export class TrialHandler extends PsychObject *

    Note: we assume that all trials in the trialList share the same attributes * and consequently consider only the attributes of the first trial.

    * - * @public * @return {Array.string} the attributes */ getAttributes() @@ -411,7 +395,6 @@ export class TrialHandler extends PsychObject /** * Get the current trial. * - * @public * @return {Object} the current trial */ getCurrentTrial() @@ -438,7 +421,6 @@ export class TrialHandler extends PsychObject /** * Get the nth future or past trial, without advancing through the trial list. * - * @public * @param {number} [n = 1] - increment * @return {Object|undefined} the future trial (if n is positive) or past trial (if n is negative) * or undefined if attempting to go beyond the last trial. @@ -457,7 +439,6 @@ export class TrialHandler extends PsychObject * Get the nth previous trial. *

    Note: this is useful for comparisons in n-back tasks.

    * - * @public * @param {number} [n = -1] - increment * @return {Object|undefined} the past trial or undefined if attempting to go prior to the first trial. */ @@ -469,7 +450,6 @@ export class TrialHandler extends PsychObject /** * Add a key/value pair to data about the current trial held by the experiment handler * - * @public * @param {Object} key - the key * @param {Object} value - the value */ @@ -508,8 +488,6 @@ export class TrialHandler extends PsychObject * '5:' * '-5:-2, 9, 11:5:22' * - * @public - * @static * @param {module:core.ServerManager} serverManager - the server manager * @param {String} resourceName - the name of the resource containing the list of conditions, which must have been registered with the server manager. * @param {Object} [selection = null] - the selection @@ -617,7 +595,6 @@ export class TrialHandler extends PsychObject /** * Prepare the trial list. * - * @function * @protected * @returns {void} */ @@ -655,7 +632,7 @@ export class TrialHandler extends PsychObject } } - /* + /** * Prepare the sequence of trials. * *

    The returned sequence is a matrix (an array of arrays) of trial indices @@ -680,7 +657,7 @@ export class TrialHandler extends PsychObject *

    * * @protected - */ + **/ _prepareSequence() { const response = { @@ -738,7 +715,6 @@ export class TrialHandler extends PsychObject * * @enum {Symbol} * @readonly - * @public */ TrialHandler.Method = { /** diff --git a/src/data/index.js b/src/data/index.js index bd13927b..1bffe5ef 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -2,5 +2,4 @@ export * from "./ExperimentHandler.js"; export * from "./TrialHandler.js"; export * from "./QuestHandler.js"; export * from "./MultiStairHandler.js"; -//export * from "./Shelf.js"; export * from "./Shelf.js"; diff --git a/src/hardware/Camera.js b/src/hardware/Camera.js index c790c924..8d3b1c25 100644 --- a/src/hardware/Camera.js +++ b/src/hardware/Camera.js @@ -1,9 +1,10 @@ +/** **/ /** * Manager handling the recording of video signal. * * @author Alain Pitiot * @version 2022.2.0 - * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ diff --git a/src/index.css b/src/index.css index 01475efd..00f232b8 100644 --- a/src/index.css +++ b/src/index.css @@ -95,6 +95,11 @@ a:hover { transform: translate(-50%, -50%); } +#root.is-ready::after +{ + content: "" +} + .dialog-container, .dialog-overlay { @@ -144,6 +149,14 @@ a:hover { border-radius: 2px; } +.dialog-warning { + background-color: #FF9900 !important; +} + +.dialog-error { + background-color: #FF0000 !important; +} + .dialog-title p { margin: 0; padding: 0; diff --git a/src/sound/AudioClip.js b/src/sound/AudioClip.js index f21dd0fd..d8d675b5 100644 --- a/src/sound/AudioClip.js +++ b/src/sound/AudioClip.js @@ -2,7 +2,7 @@ * AudioClip encapsulates an audio recording. * * @author Alain Pitiot and Sotiri Bakagiannis - * @version 2021.2.0 + * @version 2022.2.3 * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -15,18 +15,20 @@ import * as util from "../util/Util.js"; /** *

    AudioClip encapsulates 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 + * @extends PsychObject */ export class AudioClip extends PsychObject { + /** + * @memberOf module:sound + * @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 + */ constructor({ psychoJS, name, sampleRateHz, format, data, autoLog } = {}) { super(psychoJS); @@ -53,9 +55,6 @@ export class AudioClip extends PsychObject /** * Set the volume of the playback. * - * @name module:sound.AudioClip#setVolume - * @function - * @public * @param {number} volume - the volume of the playback (must be between 0.0 and 1.0) */ setVolume(volume) @@ -66,8 +65,6 @@ export class AudioClip extends PsychObject /** * Start playing the audio clip. * - * @name module:sound.AudioClip#startPlayback - * @function * @public */ async startPlayback() @@ -102,9 +99,6 @@ export class AudioClip extends PsychObject /** * Stop playing the audio clip. * - * @name module:sound.AudioClip#startPlayback - * @function - * @public * @param {number} [fadeDuration = 17] - how long the fading out should last, in ms */ async stopPlayback(fadeDuration = 17) @@ -118,9 +112,6 @@ export class AudioClip extends PsychObject /** * Get the duration of the audio clip, in seconds. * - * @name module:sound.AudioClip#getDuration - * @function - * @public * @returns {Promise} the duration of the audio clip */ async getDuration() @@ -134,8 +125,6 @@ export class AudioClip extends PsychObject /** * Upload the audio clip to the pavlovia server. * - * @name module:sound.AudioClip#upload - * @function * @public */ upload() @@ -165,10 +154,6 @@ export class AudioClip extends PsychObject /** * Offer the audio clip to the participant as a sound file to download. - * - * @name module:sound.AudioClip#download - * @function - * @public */ download(filename = "audio.webm") { @@ -239,6 +224,7 @@ export class AudioClip extends PsychObject * * ref: https://cloud.google.com/speech-to-text/docs/reference/rest/v1/speech/recognize * + * @protected * @param {String} transcriptionKey - the secret key to the Google service * @param {String} languageCode - the BCP-47 language code for the recognition, e.g. 'en-GB' * @return {Promise} a promise resolving to the transcript and associated @@ -306,6 +292,7 @@ export class AudioClip extends PsychObject /** * Decode the formatted audio data (e.g. webm) into a 32bit float PCM audio buffer. * + * @protected */ _decodeAudio() { @@ -386,6 +373,7 @@ export class AudioClip extends PsychObject * const dataAsString = String.fromCharCode.apply(null, new Uint8Array(buffer)); * base64Data = window.btoa(dataAsString); * + * @protected * @param arrayBuffer - the input buffer * @return {string} the base64 encoded input buffer */ @@ -453,10 +441,8 @@ export class AudioClip extends PsychObject /** * Recognition engines. * - * @name module:sound.AudioClip#Engine * @enum {Symbol} * @readonly - * @public */ AudioClip.Engine = { /** @@ -470,7 +456,6 @@ AudioClip.Engine = { * * @enum {Symbol} * @readonly - * @public */ AudioClip.Status = { CREATED: Symbol.for("CREATED"), diff --git a/src/sound/AudioClipPlayer.js b/src/sound/AudioClipPlayer.js index 082a71ad..10dd99b8 100644 --- a/src/sound/AudioClipPlayer.js +++ b/src/sound/AudioClipPlayer.js @@ -2,8 +2,8 @@ * AudioClip Player. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -13,20 +13,21 @@ import { SoundPlayer } from "./SoundPlayer.js"; /** *

    This class handles the playback of an audio clip, e.g. a microphone recording.

    * - * @name module:sound.AudioClipPlayer - * @class * @extends SoundPlayer - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {Object} options.audioClip - the module:sound.AudioClip - * @param {number} [options.startTime= 0] - start of playback (in seconds) - * @param {number} [options.stopTime= -1] - end of playback (in seconds) - * @param {boolean} [options.stereo= true] whether or not to play the sound or track in stereo - * @param {number} [options.volume= 1.0] - volume of the sound (must be between 0 and 1.0) - * @param {number} [options.loops= 0] - how many times to repeat the track or tone after it has played * */ export class AudioClipPlayer extends SoundPlayer { + /** + * @memberOf module:sound + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {Object} options.audioClip - the module:sound.AudioClip + * @param {number} [options.startTime= 0] - start of playback (in seconds) + * @param {number} [options.stopTime= -1] - end of playback (in seconds) + * @param {boolean} [options.stereo= true] whether or not to play the sound or track in stereo + * @param {number} [options.volume= 1.0] - volume of the sound (must be between 0 and 1.0) + * @param {number} [options.loops= 0] - how many times to repeat the track or tone after it has played * + */ constructor({ psychoJS, audioClip, @@ -52,10 +53,6 @@ export class AudioClipPlayer extends SoundPlayer /** * Determine whether this player can play the given sound. * - * @name module:sound.AudioClipPlayer.accept - * @function - * @static - * @public * @param {module:sound.Sound} sound - the sound object, which should be an AudioClip * @return {Object|undefined} an instance of AudioClipPlayer if sound is an AudioClip or undefined otherwise */ @@ -83,9 +80,6 @@ export class AudioClipPlayer extends SoundPlayer /** * Get the duration of the AudioClip, in seconds. * - * @name module:sound.AudioClipPlayer#getDuration - * @function - * @public * @return {number} the duration of the clip, in seconds */ getDuration() @@ -96,9 +90,6 @@ export class AudioClipPlayer extends SoundPlayer /** * Set the duration of the audio clip. * - * @name module:sound.AudioClipPlayer#setDuration - * @function - * @public * @param {number} duration_s - the duration of the clip in seconds */ setDuration(duration_s) @@ -115,9 +106,6 @@ export class AudioClipPlayer extends SoundPlayer /** * Set the volume of the playback. * - * @name module:sound.AudioClipPlayer#setVolume - * @function - * @public * @param {number} volume - the volume of the playback (must be between 0.0 and 1.0) * @param {boolean} [mute= false] - whether or not to mute the playback */ @@ -131,9 +119,6 @@ export class AudioClipPlayer extends SoundPlayer /** * Set the number of loops. * - * @name module:sound.AudioClipPlayer#setLoops - * @function - * @public * @param {number} loops - how many times to repeat the clip after it has played once. If loops == -1, the clip will repeat indefinitely until stopped. */ setLoops(loops) @@ -147,9 +132,6 @@ export class AudioClipPlayer extends SoundPlayer /** * Start playing the sound. * - * @name module:sound.AudioClipPlayer#play - * @function - * @public * @param {number} loops - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped. * @param {number} [fadeDuration = 17] - how long should the fading in last in ms */ @@ -172,9 +154,6 @@ export class AudioClipPlayer extends SoundPlayer /** * Stop playing the sound immediately. * - * @name module:sound.AudioClipPlayer#stop - * @function - * @public * @param {number} [fadeDuration = 17] - how long the fading out should last, in ms */ stop(fadeDuration = 17) diff --git a/src/sound/Microphone.js b/src/sound/Microphone.js index 2f839c6a..1d1f9d3a 100644 --- a/src/sound/Microphone.js +++ b/src/sound/Microphone.js @@ -2,8 +2,8 @@ * Manager handling the recording of audio signal. * * @author Alain Pitiot and Sotiri Bakagiannis - * @version 2021.2.0 - * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -17,18 +17,20 @@ import { AudioClip } from "./AudioClip.js"; /** *

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

    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() @@ -108,8 +108,6 @@ export class Microphone extends PsychObject /** * 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 @@ -145,8 +143,6 @@ export class Microphone extends PsychObject /** * Submit a request to pause the recording. * - * @name module:sound.Microphone#pause - * @public * @return {Promise} promise fulfilled when the recording actually paused */ pause() @@ -192,7 +188,6 @@ export class Microphone extends PsychObject * *

    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 @@ -245,8 +240,6 @@ export class Microphone extends PsychObject /** * 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() @@ -273,9 +266,6 @@ export class Microphone extends PsychObject /** * 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") @@ -293,9 +283,6 @@ export class Microphone extends PsychObject /** * 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 } = {}) @@ -331,9 +318,6 @@ export class Microphone extends PsychObject /** * 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 */ @@ -361,8 +345,6 @@ export class Microphone extends PsychObject * *

    Changes to the settings require the recording to stop and be re-started.

    * - * @name module:sound.Microphone#_onChange - * @function * @protected */ _onChange() @@ -380,8 +362,6 @@ export class Microphone extends PsychObject /** * Prepare the recording. * - * @name module:sound.Microphone#_prepareRecording - * @function * @protected */ async _prepareRecording() diff --git a/src/sound/Sound.js b/src/sound/Sound.js index 51f1b01b..cf4ef454 100644 --- a/src/sound/Sound.js +++ b/src/sound/Sound.js @@ -3,8 +3,8 @@ * Sound stimulus. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -35,23 +35,24 @@ import { TrackPlayer } from "./TrackPlayer.js"; * track.setVolume(1.0); * track.play(2); * - * @class * @extends PsychObject - * @param {Object} options - * @param {String} options.name - the name used when logging messages from this stimulus - * @param {module:core.Window} options.win - the associated Window - * @param {number|string} [options.value= 'C'] - the sound value (see above for a full description) - * @param {number} [options.octave= 4] - the octave corresponding to the tone (if applicable) - * @param {number} [options.secs= 0.5] - duration of the tone (in seconds) If secs == -1, the sound will play indefinitely. - * @param {number} [options.startTime= 0] - start of playback for tracks (in seconds) - * @param {number} [options.stopTime= -1] - end of playback for tracks (in seconds) - * @param {boolean} [options.stereo= true] whether or not to play the sound or track in stereo - * @param {number} [options.volume= 1.0] - volume of the sound (must be between 0 and 1.0) - * @param {number} [options.loops= 0] - how many times to repeat the track or tone after it has played once. If loops == -1, the track or tone will repeat indefinitely until stopped. - * @param {boolean} [options.autoLog= true] whether or not to log */ export class Sound extends PsychObject { + /** + * @param {Object} options + * @param {String} options.name - the name used when logging messages from this stimulus + * @param {module:core.Window} options.win - the associated Window + * @param {number|string} [options.value= 'C'] - the sound value (see above for a full description) + * @param {number} [options.octave= 4] - the octave corresponding to the tone (if applicable) + * @param {number} [options.secs= 0.5] - duration of the tone (in seconds) If secs == -1, the sound will play indefinitely. + * @param {number} [options.startTime= 0] - start of playback for tracks (in seconds) + * @param {number} [options.stopTime= -1] - end of playback for tracks (in seconds) + * @param {boolean} [options.stereo= true] whether or not to play the sound or track in stereo + * @param {number} [options.volume= 1.0] - volume of the sound (must be between 0 and 1.0) + * @param {number} [options.loops= 0] - how many times to repeat the track or tone after it has played once. If loops == -1, the track or tone will repeat indefinitely until stopped. + * @param {boolean} [options.autoLog= true] whether or not to log + */ constructor({ name, win, @@ -95,7 +96,6 @@ export class Sound extends PsychObject *

    Note: Sounds are played independently from the stimuli of the experiments, i.e. the experiment will not stop until the sound is finished playing. * Repeat calls to play may results in the sounds being played on top of each other.

    * - * @public * @param {number} loops how many times to repeat the sound after it plays once. If loops == -1, the sound will repeat indefinitely until stopped. * @param {boolean} [log= true] whether or not to log */ @@ -108,7 +108,6 @@ export class Sound extends PsychObject /** * Stop playing the sound immediately. * - * @public * @param {Object} options * @param {boolean} [options.log= true] - whether or not to log */ @@ -123,7 +122,6 @@ export class Sound extends PsychObject /** * Get the duration of the sound, in seconds. * - * @public * @return {number} the duration of the sound, in seconds */ getDuration() @@ -134,7 +132,6 @@ export class Sound extends PsychObject /** * Set the playing volume of the sound. * - * @public * @param {number} volume - the volume (values should be between 0 and 1) * @param {boolean} [mute= false] - whether or not to mute the sound * @param {boolean} [log= true] - whether of not to log @@ -152,7 +149,6 @@ export class Sound extends PsychObject /** * Set the sound value on demand past initialisation. * - * @public * @param {object} sound - a sound instance to replace the current one * @param {boolean} [log= true] - whether or not to log */ @@ -181,7 +177,6 @@ export class Sound extends PsychObject /** * Set the number of loops. * - * @public * @param {number} [loops=0] - how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped. * @param {boolean} [log=true] - whether of not to log */ @@ -198,7 +193,6 @@ export class Sound extends PsychObject /** * Set the duration (in seconds) * - * @public * @param {number} [secs=0.5] - duration of the tone (in seconds) If secs == -1, the sound will play indefinitely. * @param {boolean} [log=true] - whether or not to log */ diff --git a/src/sound/SoundPlayer.js b/src/sound/SoundPlayer.js index 4ba5bdb3..512c3656 100644 --- a/src/sound/SoundPlayer.js +++ b/src/sound/SoundPlayer.js @@ -2,8 +2,8 @@ * Sound player interface * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -12,13 +12,15 @@ import { PsychObject } from "../util/PsychObject.js"; /** *

    SoundPlayer is an interface for the sound players, who are responsible for actually playing the sounds, i.e. the tracks or the tones.

    * - * @name module:sound.SoundPlayer * @interface * @extends PsychObject - * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance */ export class SoundPlayer extends PsychObject { + /** + * @memberOf module:sound + * @param {module:core.PsychoJS} psychoJS - the PsychoJS instance + */ constructor(psychoJS) { super(psychoJS); @@ -27,10 +29,6 @@ export class SoundPlayer extends PsychObject /** * Determine whether this player can play the given sound. * - * @name module:sound.SoundPlayer.accept - * @function - * @static - * @public * @abstract * @param {module:sound.Sound} - the sound * @return {Object|undefined} an instance of the SoundPlayer that can play the sound, or undefined if none could be found @@ -47,9 +45,6 @@ export class SoundPlayer extends PsychObject /** * Start playing the sound. * - * @name module:sound.SoundPlayer#play - * @function - * @public * @abstract * @param {number} [loops] - how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped. */ @@ -65,9 +60,6 @@ export class SoundPlayer extends PsychObject /** * Stop playing the sound immediately. * - * @name module:sound.SoundPlayer#stop - * @function - * @public * @abstract */ stop() @@ -82,9 +74,6 @@ export class SoundPlayer extends PsychObject /** * Get the duration of the sound, in seconds. * - * @name module:sound.SoundPlayer#getDuration - * @function - * @public * @abstract */ getDuration() @@ -99,9 +88,6 @@ export class SoundPlayer extends PsychObject /** * Set the duration of the sound, in seconds. * - * @name module:sound.SoundPlayer#setDuration - * @function - * @public * @abstract */ setDuration(duration_s) @@ -116,9 +102,6 @@ export class SoundPlayer extends PsychObject /** * Set the number of loops. * - * @name module:sound.SoundPlayer#setLoops - * @function - * @public * @abstract * @param {number} loops - how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped. */ @@ -134,9 +117,6 @@ export class SoundPlayer extends PsychObject /** * Set the volume of the tone. * - * @name module:sound.SoundPlayer#setVolume - * @function - * @public * @abstract * @param {Integer} volume - the volume of the tone * @param {boolean} [mute= false] - whether or not to mute the tone diff --git a/docs/sound_Transcriber.js.html b/src/sound/SpeechRecognition.js similarity index 53% rename from docs/sound_Transcriber.js.html rename to src/sound/SpeechRecognition.js index 2457c9cd..53dfee3d 100644 --- a/docs/sound_Transcriber.js.html +++ b/src/sound/SpeechRecognition.js @@ -1,37 +1,9 @@ - - - - - JSDoc: Source: sound/Transcriber.js - - - - - - - - - - -
    - -

    Source: sound/Transcriber.js

    - - - - - - -
    -
    -
    /**
    - * Manager handling the transcription of Speech into Text.
    +/**
    + * Manager handling the live transcription of speech into text.
      *
    - * @author Sotiri Bakagiannis and Alain Pitiot
    - * @version 2021.2.0
    - * @copyright (c) 2021 Open Science Tools Ltd. (https://opensciencetools.org)
    + * @author Alain Pitiot
    + * @version 2022.2.3
    + * @copyright (c) 2022 Open Science Tools Ltd. (https://opensciencetools.org)
      * @license Distributed under the terms of the MIT License
      */
     
    @@ -41,13 +13,17 @@ 

    Source: sound/Transcriber.js

    /** - * Transcript returned by the transcriber - * - * @name module:sound.Transcript - * @class + * Transcript. */ export class Transcript { + /** + * Object holding a transcription result. + * + * @param {SpeechRecognition} transcriber - the transcriber + * @param {string} text - the transcript + * @param {number} confidence - confidence in the transcript + */ constructor(transcriber, text = '', confidence = 0.0) { // recognised text: @@ -69,32 +45,36 @@

    Source: sound/Transcriber.js

    /** - * <p>This manager handles the transcription of speech into text.</p> - * - * @name module:sound.Transcriber - * @class - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {String} options.name - the name used when logging messages - * @param {number} [options.bufferSize= 10000] - the maximum size of the circular transcript buffer - * @param {String[]} [options.continuous= true] - whether or not to continuously recognise - * @param {String[]} [options.lang= 'en-US'] - the spoken language - * @param {String[]} [options.interimResults= false] - whether or not to make interim results available - * @param {String[]} [options.maxAlternatives= 1] - the maximum number of recognition alternatives - * @param {String[]} [options.tokens= [] ] - the tokens to be recognised. This is experimental technology, not available in all browser. - * @param {Clock} [options.clock= undefined] - an optional clock - * @param {boolean} [options.autoLog= false] - whether or not to log + *

    This manager handles the live transcription of speech into text.

    * + * @extends PsychObject * @todo deal with alternatives, interim results, and recognition errors */ -export class Transcriber extends PsychObject +export class SpeechRecognition extends PsychObject { - + /** + *

    This manager handles the live transcription of speech into text.

    + * + * @memberOf module:sound + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {String} options.name - the name used when logging messages + * @param {number} [options.bufferSize= 10000] - the maximum size of the circular transcript buffer + * @param {String[]} [options.continuous= true] - whether to continuously recognise + * @param {String[]} [options.lang= 'en-US'] - the spoken language + * @param {String[]} [options.interimResults= false] - whether to make interim results available + * @param {String[]} [options.maxAlternatives= 1] - the maximum number of recognition alternatives + * @param {String[]} [options.tokens= [] ] - the tokens to be recognised. This is experimental technology, not available in all browser. + * @param {Clock} [options.clock= undefined] - an optional clock + * @param {boolean} [options.autoLog= false] - whether to log + * + * @todo deal with alternatives, interim results, and recognition errors + */ constructor({psychoJS, name, bufferSize, continuous, lang, interimResults, maxAlternatives, tokens, clock, autoLog} = {}) { super(psychoJS); - this._addAttribute('name', name, 'transcriber'); + this._addAttribute('name', name, 'speech recognition'); this._addAttribute('bufferSize', bufferSize, 10000); this._addAttribute('continuous', continuous, true, this._onChange); this._addAttribute('lang', lang, 'en-US', this._onChange); @@ -105,8 +85,7 @@

    Source: sound/Transcriber.js

    this._addAttribute('autoLog', false, autoLog); this._addAttribute('status', PsychoJS.Status.NOT_STARTED); - // prepare the transcription: - this._prepareTranscription(); + this._prepareRecognition(); if (this._autoLog) { @@ -116,18 +95,15 @@

    Source: sound/Transcriber.js

    /** - * Start the transcription. + * Start the speech recognition process. * - * @name module:sound.Transcriber#start - * @function - * @public - * @return {Promise} promise fulfilled when the transcription actually started + * @return {Promise} promise fulfilled when the process actually starts */ start() { if (this._status !== PsychoJS.Status.STARTED) { - this._psychoJS.logger.debug('request to start speech to text transcription'); + this._psychoJS.logger.debug('request to start the speech recognition process'); try { @@ -138,7 +114,7 @@

    Source: sound/Transcriber.js

    this._recognition.start(); - // return a promise, which will be satisfied when the transcription actually starts, + // return a promise, which will be satisfied when the process 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) => @@ -167,22 +143,19 @@

    Source: sound/Transcriber.js

    /** - * Stop the transcription. + * Stop the speech recognition process. * - * @name module:sound.Transcriber#stop - * @function - * @public - * @return {Promise} promise fulfilled when the speech recognition actually stopped + * @return {Promise} promise fulfilled when the process actually stops */ stop() { if (this._status === PsychoJS.Status.STARTED) { - this._psychoJS.logger.debug('request to stop speech to text transcription'); + this._psychoJS.logger.debug('request to stop the speech recognition process'); this._recognition.stop(); - // return a promise, which will be satisfied when the recognition actually stops: + // return a promise, which will be satisfied when the process actually stops: const self = this; return new Promise((resolve, reject) => { @@ -197,9 +170,6 @@

    Source: sound/Transcriber.js

    * Get the list of transcripts still in the buffer, i.e. those that have not been * previously cleared by calls to getTranscripts with clear = true. * - * @name module:sound.Transcriber#getTranscripts - * @function - * @public * @param {Object} options * @param {string[]} [options.transcriptList= []]] - the list of transcripts texts to consider. If transcriptList is empty, we consider all transcripts. * @param {boolean} [options.clear= false] - whether or not to keep in the buffer the transcripts for a subsequent call to getTranscripts. If a keyList has been given and clear = true, we only remove from the buffer those keys in keyList @@ -216,7 +186,6 @@

    Source: sound/Transcriber.js

    return []; } - // iterate over the buffer, from start to end, and discard the null transcripts (i.e. those // previously cleared): const filteredTranscripts = []; @@ -248,9 +217,6 @@

    Source: sound/Transcriber.js

    /** * Clear all transcripts and resets the circular buffers. - * - * @name module:sound.Transcriber#clearTranscripts - * @function */ clearTranscripts() { @@ -264,10 +230,9 @@

    Source: sound/Transcriber.js

    /** * Callback for changes to the recognition settings. * - * <p>Changes to the recognition settings require the recognition to stop and be re-started.</p> + *

    Changes to the recognition settings require the speech recognition process + * to be stopped and be re-started.

    * - * @name module:sound.Transcriber#_onChange - * @function * @protected */ _onChange() @@ -277,25 +242,22 @@

    Source: sound/Transcriber.js

    this.stop(); } - this._prepareTranscription(); + this._prepareRecognition(); this.start(); } /** - * Prepare the transcription. + * Prepare the speech recognition process. * - * @name module:sound.Transcriber#_prepareTranscription - * @function * @protected */ - _prepareTranscription() + _prepareRecognition() { // setup the circular buffer of transcripts: this.clearTranscripts(); - // recognition settings: const SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition; this._recognition = new SpeechRecognition(); @@ -305,20 +267,19 @@

    Source: sound/Transcriber.js

    this._recognition.maxAlternatives = this._maxAlternatives; // grammar list with tokens added: - if (Array.isArray(this._tokens) && this._tokens.length > 0) + if (Array.isArray(this._tokens) && this._tokens.length > 0) { const SpeechGrammarList = window.SpeechGrammarList || window.webkitSpeechGrammarList; // note: we accepts JSGF encoded strings, and relative weight indicator between 0.0 and 1.0 // ref: https://www.w3.org/TR/jsgf/ - const name = 'NULL'; - const grammar = `#JSGF V1.0; grammar ${name}; public <${name}> = ${this._tokens.join('|')};` + const name = "NULL"; + const grammar = `#JSGF V1.0; grammar ${name}; public <${name}> = ${this._tokens.join('|')};` const grammarList = new SpeechGrammarList(); grammarList.addFromString(grammar, 1); this._recognition.grammars = grammarList; } - // setup the callbacks: const self = this; @@ -344,7 +305,7 @@

    Source: sound/Transcriber.js

    this._status = PsychoJS.Status.STARTED; self._psychoJS.logger.debug('speech recognition started'); - // resolve the Transcriber.start promise, if need be: + // resolve the SpeechRecognition.start promise, if need be: if (self._startCallback()) { self._startCallback({ @@ -359,7 +320,7 @@

    Source: sound/Transcriber.js

    this._status = PsychoJS.Status.STOPPED; self._psychoJS.logger.debug('speech recognition ended'); - // resolve the Transcriber.stop promise, if need be: + // resolve the SpeechRecognition.stop promise, if need be: if (self._stopCallback) { self._stopCallback({ @@ -413,32 +374,8 @@

    Source: sound/Transcriber.js

    self._psychoJS.logger.error('speech recognition error: ', JSON.stringify(event)); self._status = PsychoJS.Status.ERROR; } - } } -
    -
    -
    - - - - -
    - - - -
    - -
    - Documentation generated by JSDoc 3.6.7 on Thu Jun 16 2022 12:47:14 GMT+0200 (Central European Summer Time) -
    - - - - - diff --git a/src/sound/TonePlayer.js b/src/sound/TonePlayer.js index a5521628..488016a2 100644 --- a/src/sound/TonePlayer.js +++ b/src/sound/TonePlayer.js @@ -2,8 +2,8 @@ * Tone Player. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -14,18 +14,21 @@ import { SoundPlayer } from "./SoundPlayer.js"; /** *

    This class handles the playing of tones.

    * - * @name module:sound.TonePlayer - * @class * @extends SoundPlayer - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {number} [options.duration_s= 0.5] - duration of the tone (in seconds). If duration_s == -1, the sound will play indefinitely. - * @param {string|number} [options.note= 'C4'] - note (if string) or frequency (if number) - * @param {number} [options.volume= 1.0] - volume of the tone (must be between 0 and 1.0) - * @param {number} [options.loops= 0] - how many times to repeat the tone after it has played once. If loops == -1, the tone will repeat indefinitely until stopped. */ export class TonePlayer extends SoundPlayer { + /** + *

    This class handles the playing of tones.

    + * + * @memberOf module:sound + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {number} [options.duration_s= 0.5] - duration of the tone (in seconds). If duration_s == -1, the sound will play indefinitely. + * @param {string|number} [options.note= 'C4'] - note (if string) or frequency (if number) + * @param {number} [options.volume= 1.0] - volume of the tone (must be between 0 and 1.0) + * @param {number} [options.loops= 0] - how many times to repeat the tone after it has played once. If loops == -1, the tone will repeat indefinitely until stopped. + */ constructor({ psychoJS, note = "C4", @@ -63,10 +66,6 @@ export class TonePlayer extends SoundPlayer *

    Note: if TonePlayer accepts the sound but Tone.js is not available, e.g. if the browser is IE11, * we throw an exception.

    * - * @name module:sound.TonePlayer.accept - * @function - * @static - * @public * @param {module:sound.Sound} sound - the sound * @return {Object|undefined} an instance of TonePlayer that can play the given sound or undefined otherwise */ @@ -117,9 +116,6 @@ export class TonePlayer extends SoundPlayer /** * Get the duration of the sound. * - * @name module:sound.TonePlayer#getDuration - * @function - * @public * @return {number} the duration of the sound, in seconds */ getDuration() @@ -130,9 +126,6 @@ export class TonePlayer extends SoundPlayer /** * Set the duration of the tone. * - * @name module:sound.TonePlayer#setDuration - * @function - * @public * @param {number} duration_s - the duration of the tone (in seconds) If duration_s == -1, the sound will play indefinitely. */ setDuration(duration_s) @@ -143,9 +136,6 @@ export class TonePlayer extends SoundPlayer /** * Set the number of loops. * - * @name module:sound.TonePlayer#setLoops - * @function - * @public * @param {number} loops - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped. */ setLoops(loops) @@ -156,9 +146,6 @@ export class TonePlayer extends SoundPlayer /** * Set the volume of the tone. * - * @name module:sound.TonePlayer#setVolume - * @function - * @public * @param {Integer} volume - the volume of the tone * @param {boolean} [mute= false] - whether or not to mute the tone */ @@ -188,9 +175,6 @@ export class TonePlayer extends SoundPlayer /** * Start playing the sound. * - * @name module:sound.TonePlayer#play - * @function - * @public * @param {boolean} [loops] - how many times to repeat the sound after it has played once. If loops == -1, the sound will repeat indefinitely until stopped. */ play(loops) @@ -254,10 +238,6 @@ export class TonePlayer extends SoundPlayer /** * Stop playing the sound immediately. - * - * @name module:sound.TonePlayer#stop - * @function - * @public */ stop() { @@ -285,8 +265,6 @@ export class TonePlayer extends SoundPlayer *

    Note: if TonePlayer accepts the sound but Tone.js is not available, e.g. if the browser is IE11, * we throw an exception.

    * - * @name module:sound.TonePlayer._initSoundLibrary - * @function * @protected */ _initSoundLibrary() diff --git a/src/sound/TrackPlayer.js b/src/sound/TrackPlayer.js index a5dcbdaf..7451ae6f 100644 --- a/src/sound/TrackPlayer.js +++ b/src/sound/TrackPlayer.js @@ -2,8 +2,8 @@ * Track Player. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ @@ -12,22 +12,23 @@ import { SoundPlayer } from "./SoundPlayer.js"; /** *

    This class handles the playback of sound tracks.

    * - * @name module:sound.TrackPlayer - * @class * @extends SoundPlayer - * @param {Object} options - * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance - * @param {Object} options.howl - the sound object (see {@link https://howlerjs.com/}) - * @param {number} [options.startTime= 0] - start of playback (in seconds) - * @param {number} [options.stopTime= -1] - end of playback (in seconds) - * @param {boolean} [options.stereo= true] whether or not to play the sound or track in stereo - * @param {number} [options.volume= 1.0] - volume of the sound (must be between 0 and 1.0) - * @param {number} [options.loops= 0] - how many times to repeat the track or tone after it has played * * @todo stopTime is currently not implemented (tracks will play from startTime to finish) * @todo stereo is currently not implemented */ export class TrackPlayer extends SoundPlayer { + /** + * @memberOf module:sound + * @param {Object} options + * @param {module:core.PsychoJS} options.psychoJS - the PsychoJS instance + * @param {Object} options.howl - the sound object (see {@link https://howlerjs.com/}) + * @param {number} [options.startTime= 0] - start of playback (in seconds) + * @param {number} [options.stopTime= -1] - end of playback (in seconds) + * @param {boolean} [options.stereo= true] whether or not to play the sound or track in stereo + * @param {number} [options.volume= 1.0] - volume of the sound (must be between 0 and 1.0) + * @param {number} [options.loops= 0] - how many times to repeat the track or tone after it has played + */ constructor({ psychoJS, howl, @@ -53,10 +54,6 @@ export class TrackPlayer extends SoundPlayer /** * Determine whether this player can play the given sound. * - * @name module:sound.TrackPlayer.accept - * @function - * @static - * @public * @param {module:sound.Sound} sound - the sound, which should be the name of an audio resource * file * @return {Object|undefined} an instance of TrackPlayer that can play the given track or undefined otherwise @@ -90,9 +87,6 @@ export class TrackPlayer extends SoundPlayer /** * Get the duration of the sound, in seconds. * - * @name module:sound.TrackPlayer#getDuration - * @function - * @public * @return {number} the duration of the track, in seconds */ getDuration() @@ -103,9 +97,6 @@ export class TrackPlayer extends SoundPlayer /** * Set the duration of the track. * - * @name module:sound.TrackPlayer#setDuration - * @function - * @public * @param {number} duration_s - the duration of the track in seconds */ setDuration(duration_s) @@ -120,9 +111,6 @@ export class TrackPlayer extends SoundPlayer /** * Set the volume of the tone. * - * @name module:sound.TrackPlayer#setVolume - * @function - * @public * @param {Integer} volume - the volume of the track (must be between 0 and 1.0) * @param {boolean} [mute= false] - whether or not to mute the track */ @@ -137,9 +125,6 @@ export class TrackPlayer extends SoundPlayer /** * Set the number of loops. * - * @name module:sound.TrackPlayer#setLoops - * @function - * @public * @param {number} loops - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped. */ setLoops(loops) @@ -160,9 +145,6 @@ export class TrackPlayer extends SoundPlayer /** * Start playing the sound. * - * @name module:sound.TrackPlayer#play - * @function - * @public * @param {number} loops - how many times to repeat the track after it has played once. If loops == -1, the track will repeat indefinitely until stopped. * @param {number} [fadeDuration = 17] - how long should the fading in last in ms */ @@ -201,9 +183,6 @@ export class TrackPlayer extends SoundPlayer /** * Stop playing the sound immediately. * - * @name module:sound.TrackPlayer#stop - * @function - * @public * @param {number} [fadeDuration = 17] - how long should the fading out last in ms */ stop(fadeDuration = 17) diff --git a/src/sound/index.js b/src/sound/index.js index 81637d7c..979ef53e 100644 --- a/src/sound/index.js +++ b/src/sound/index.js @@ -2,8 +2,7 @@ export * from "./Sound.js"; export * from "./SoundPlayer.js"; export * from "./TonePlayer.js"; export * from "./TrackPlayer.js"; - export * from "./AudioClip.js"; export * from "./AudioClipPlayer.js"; export * from "./Microphone.js"; -// export * from './Transcriber.js'; +export * from './SpeechRecognition.js'; diff --git a/src/util/Clock.js b/src/util/Clock.js index 5259a46b..cd898001 100644 --- a/src/util/Clock.js +++ b/src/util/Clock.js @@ -2,20 +2,20 @@ * Clock component. * * @author Alain Pitiot - * @version 2021.2.0 - * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2021 Open Science Tools Ltd. (https://opensciencetools.org) + * @version 2022.2.3 + * @copyright (c) 2017-2020 Ilixa Ltd. (http://ilixa.com) (c) 2020-2022 Open Science Tools Ltd. (https://opensciencetools.org) * @license Distributed under the terms of the MIT License */ /** *

    MonotonicClock offers a convenient way to keep track of time during experiments. An experiment can have as many independent clocks as needed, e.g. one to time responses, another one to keep track of stimuli, etc.

    - * - * @name module:util.MonotonicClock - * @class - * @param {number} [startTime=