UNPKG

jspsych

Version:

Behavioral experiments in a browser

1,654 lines (1,631 loc) 90 kB
'use strict'; var autoBind = require('auto-bind'); var rw = require('random-words'); var seedrandom = require('seedrandom/lib/alea.js'); var version = "8.2.2"; class ExtensionManager { constructor(dependencies, extensionsConfiguration) { this.dependencies = dependencies; this.extensionsConfiguration = extensionsConfiguration; this.extensions = Object.fromEntries( extensionsConfiguration.map((extension) => [ ExtensionManager.getExtensionNameByClass(extension.type), this.dependencies.instantiateExtension(extension.type) ]) ); } static getExtensionNameByClass(extensionClass) { return extensionClass["info"].name; } getExtensionInstanceByClass(extensionClass) { return this.extensions[ExtensionManager.getExtensionNameByClass(extensionClass)]; } async initializeExtensions() { await Promise.all( this.extensionsConfiguration.map(({ type, params = {} }) => { this.getExtensionInstanceByClass(type).initialize(params); const extensionInfo = type["info"]; if (!("version" in extensionInfo) && !("data" in extensionInfo)) { console.warn( extensionInfo["name"], "is missing the 'version' and 'data' fields. Please update extension as 'version' and 'data' will be required in v9. See https://www.jspsych.org/latest/developers/extension-development/ for more details." ); } else if (!("version" in extensionInfo)) { console.warn( extensionInfo["name"], "is missing the 'version' field. Please update extension as 'version' will be required in v9. See https://www.jspsych.org/latest/developers/extension-development/ for more details." ); } else if (!("data" in extensionInfo)) { console.warn( extensionInfo["name"], "is missing the 'data' field. Please update extension as 'data' will be required in v9. See https://www.jspsych.org/latest/developers/extension-development/ for more details." ); } }) ); } onStart(trialExtensionsConfiguration = []) { for (const { type, params } of trialExtensionsConfiguration) { this.getExtensionInstanceByClass(type)?.on_start(params); } } onLoad(trialExtensionsConfiguration = []) { for (const { type, params } of trialExtensionsConfiguration) { this.getExtensionInstanceByClass(type)?.on_load(params); } } async onFinish(trialExtensionsConfiguration = []) { const results = await Promise.all( trialExtensionsConfiguration.map( ({ type, params }) => Promise.resolve(this.getExtensionInstanceByClass(type)?.on_finish(params)) ) ); const extensionInfos = trialExtensionsConfiguration.length ? { extension_type: trialExtensionsConfiguration.map(({ type }) => type["info"].name), extension_version: trialExtensionsConfiguration.map(({ type }) => type["info"].version) } : {}; results.unshift(extensionInfos); return Object.assign({}, ...results); } } function unique(arr) { return [...new Set(arr)]; } function deepCopy(obj) { if (!obj) return obj; let out; if (Array.isArray(obj)) { out = []; for (const x of obj) { out.push(deepCopy(x)); } return out; } else if (typeof obj === "object" && obj !== null) { out = {}; for (const key in obj) { if (obj.hasOwnProperty(key)) { out[key] = deepCopy(obj[key]); } } return out; } else { return obj; } } function deepMerge(obj1, obj2) { let merged = {}; for (const key in obj1) { if (obj1.hasOwnProperty(key)) { if (typeof obj1[key] === "object" && obj2.hasOwnProperty(key)) { merged[key] = deepMerge(obj1[key], obj2[key]); } else { merged[key] = obj1[key]; } } } for (const key in obj2) { if (obj2.hasOwnProperty(key)) { if (!merged.hasOwnProperty(key)) { merged[key] = obj2[key]; } else if (typeof obj2[key] === "object") { merged[key] = deepMerge(merged[key], obj2[key]); } else { merged[key] = obj2[key]; } } } return merged; } var utils = /*#__PURE__*/Object.freeze({ __proto__: null, deepCopy: deepCopy, deepMerge: deepMerge, unique: unique }); class DataColumn { constructor(values = []) { this.values = values; } sum() { let s = 0; for (const v of this.values) { s += v; } return s; } mean() { let sum = 0; let count = 0; for (const value of this.values) { if (typeof value !== "undefined" && value !== null) { sum += value; count++; } } if (count === 0) { return void 0; } return sum / count; } median() { if (this.values.length === 0) { return void 0; } const numbers = this.values.slice(0).sort(function(a, b) { return a - b; }); const middle = Math.floor(numbers.length / 2); const isEven = numbers.length % 2 === 0; return isEven ? (numbers[middle] + numbers[middle - 1]) / 2 : numbers[middle]; } min() { return Math.min.apply(null, this.values); } max() { return Math.max.apply(null, this.values); } count() { return this.values.length; } variance() { const mean = this.mean(); let sum_square_error = 0; for (const x of this.values) { sum_square_error += Math.pow(x - mean, 2); } const mse = sum_square_error / (this.values.length - 1); return mse; } sd() { const mse = this.variance(); const rmse = Math.sqrt(mse); return rmse; } frequencies() { const unique = {}; for (const x of this.values) { if (typeof unique[x] === "undefined") { unique[x] = 1; } else { unique[x]++; } } return unique; } all(eval_fn) { for (const x of this.values) { if (!eval_fn(x)) { return false; } } return true; } subset(eval_fn) { const out = []; for (const x of this.values) { if (eval_fn(x)) { out.push(x); } } return new DataColumn(out); } } function saveTextToFile(textstr, filename) { const blobToSave = new Blob([textstr], { type: "text/plain" }); let blobURL = ""; if (typeof window.webkitURL !== "undefined") { blobURL = window.webkitURL.createObjectURL(blobToSave); } else { blobURL = window.URL.createObjectURL(blobToSave); } const link = document.createElement("a"); link.id = "jspsych-download-as-text-link"; link.style.display = "none"; link.download = filename; link.href = blobURL; link.click(); } function JSON2CSV(objArray) { const array = typeof objArray != "object" ? JSON.parse(objArray) : objArray; let line = ""; let result = ""; const columns = []; for (const row of array) { for (const key in row) { let keyString = key + ""; keyString = '"' + keyString.replace(/"/g, '""') + '",'; if (!columns.includes(key)) { columns.push(key); line += keyString; } } } line = line.slice(0, -1); result += line + "\r\n"; for (const row of array) { line = ""; for (const col of columns) { let value = typeof row[col] === "undefined" ? "" : row[col]; if (typeof value == "object") { value = JSON.stringify(value); } const valueString = value + ""; line += '"' + valueString.replace(/"/g, '""') + '",'; } line = line.slice(0, -1); result += line + "\r\n"; } return result; } function getQueryString() { const a = window.location.search.substr(1).split("&"); const b = {}; for (let i = 0; i < a.length; ++i) { const p = a[i].split("=", 2); if (p.length == 1) b[p[0]] = ""; else b[p[0]] = decodeURIComponent(p[1].replace(/\+/g, " ")); } return b; } class DataCollection { constructor(data = []) { this.trials = data; } push(new_data) { this.trials.push(new_data); return this; } join(other_data_collection) { this.trials = this.trials.concat(other_data_collection.values()); return this; } top() { if (this.trials.length <= 1) { return this; } else { return new DataCollection([this.trials[this.trials.length - 1]]); } } /** * Queries the first n elements in a collection of trials. * * @param n A positive integer of elements to return. A value of * n that is less than 1 will throw an error. * * @return First n objects of a collection of trials. If fewer than * n trials are available, the trials.length elements will * be returned. * */ first(n = 1) { if (n < 1) { throw `You must query with a positive nonzero integer. Please use a different value for n.`; } if (this.trials.length === 0) return new DataCollection(); if (n > this.trials.length) n = this.trials.length; return new DataCollection(this.trials.slice(0, n)); } /** * Queries the last n elements in a collection of trials. * * @param n A positive integer of elements to return. A value of * n that is less than 1 will throw an error. * * @return Last n objects of a collection of trials. If fewer than * n trials are available, the trials.length elements will * be returned. * */ last(n = 1) { if (n < 1) { throw `You must query with a positive nonzero integer. Please use a different value for n.`; } if (this.trials.length === 0) return new DataCollection(); if (n > this.trials.length) n = this.trials.length; return new DataCollection(this.trials.slice(this.trials.length - n, this.trials.length)); } values() { return this.trials; } count() { return this.trials.length; } readOnly() { return new DataCollection(deepCopy(this.trials)); } addToAll(properties) { for (const trial of this.trials) { Object.assign(trial, properties); } return this; } addToLast(properties) { if (this.trials.length > 0) { Object.assign(this.trials[this.trials.length - 1], properties); } return this; } filter(filters) { let f; if (!Array.isArray(filters)) { f = deepCopy([filters]); } else { f = deepCopy(filters); } const filtered_data = []; for (const trial of this.trials) { let keep = false; for (const filter of f) { let match = true; for (const key of Object.keys(filter)) { if (typeof trial[key] !== "undefined" && trial[key] === filter[key]) ; else { match = false; } } if (match) { keep = true; break; } } if (keep) { filtered_data.push(trial); } } return new DataCollection(filtered_data); } filterCustom(fn) { return new DataCollection(this.trials.filter(fn)); } filterColumns(columns) { return new DataCollection( this.trials.map( (trial) => Object.fromEntries(columns.filter((key) => key in trial).map((key) => [key, trial[key]])) ) ); } select(column) { const values = []; for (const trial of this.trials) { if (typeof trial[column] !== "undefined") { values.push(trial[column]); } } return new DataColumn(values); } ignore(columns) { if (!Array.isArray(columns)) { columns = [columns]; } const o = deepCopy(this.trials); for (const trial of o) { for (const delete_key of columns) { delete trial[delete_key]; } } return new DataCollection(o); } uniqueNames() { const names = []; for (const trial of this.trials) { for (const key of Object.keys(trial)) { if (!names.includes(key)) { names.push(key); } } } return names; } csv() { return JSON2CSV(this.trials); } json(pretty = false) { if (pretty) { return JSON.stringify(this.trials, null, " "); } return JSON.stringify(this.trials); } localSave(format, filename) { format = format.toLowerCase(); let data_string; if (format === "json") { data_string = this.json(); } else if (format === "csv") { data_string = this.csv(); } else { throw new Error('Invalid format specified for localSave. Must be "json" or "csv".'); } saveTextToFile(data_string, filename); } } class JsPsychData { constructor(dependencies) { this.dependencies = dependencies; /** Data properties for all trials */ this.dataProperties = {}; this.interactionListeners = { blur: () => { this.addInteractionRecord("blur"); }, focus: () => { this.addInteractionRecord("focus"); }, fullscreenchange: () => { this.addInteractionRecord( // @ts-expect-error document.isFullScreen || // @ts-expect-error document.webkitIsFullScreen || // @ts-expect-error document.mozIsFullScreen || document.fullscreenElement ? "fullscreenenter" : "fullscreenexit" ); } }; this.reset(); } reset() { this.results = new DataCollection(); this.resultToTrialMap = /* @__PURE__ */ new WeakMap(); this.interactionRecords = new DataCollection(); } get() { return this.results; } getInteractionData() { return this.interactionRecords; } write(trial) { const result = trial.getResult(); Object.assign(result, this.dataProperties); this.results.push(result); this.resultToTrialMap.set(result, trial); } addProperties(properties) { this.results.addToAll(properties); this.dataProperties = Object.assign({}, this.dataProperties, properties); } addDataToLastTrial(data) { this.results.addToLast(data); } getLastTrialData() { return this.results.top(); } getLastTimelineData() { const lastResult = this.getLastTrialData().values()[0]; return new DataCollection( lastResult ? this.resultToTrialMap.get(lastResult).parent.getResults() : [] ); } displayData(format = "json") { format = format.toLowerCase(); if (format !== "json" && format !== "csv") { console.log("Invalid format declared for displayData function. Using json as default."); format = "json"; } const dataContainer = document.createElement("pre"); dataContainer.id = "jspsych-data-display"; dataContainer.textContent = format === "json" ? this.results.json(true) : this.results.csv(); this.dependencies.getDisplayElement().replaceChildren(dataContainer); } urlVariables() { if (typeof this.query_string == "undefined") { this.query_string = getQueryString(); } return this.query_string; } getURLVariable(whichvar) { return this.urlVariables()[whichvar]; } addInteractionRecord(event) { const record = { event, ...this.dependencies.getProgress() }; this.interactionRecords.push(record); this.dependencies.onInteractionRecordAdded(record); } createInteractionListeners() { window.addEventListener("blur", this.interactionListeners.blur); window.addEventListener("focus", this.interactionListeners.focus); document.addEventListener("fullscreenchange", this.interactionListeners.fullscreenchange); document.addEventListener("mozfullscreenchange", this.interactionListeners.fullscreenchange); document.addEventListener("webkitfullscreenchange", this.interactionListeners.fullscreenchange); } removeInteractionListeners() { window.removeEventListener("blur", this.interactionListeners.blur); window.removeEventListener("focus", this.interactionListeners.focus); document.removeEventListener("fullscreenchange", this.interactionListeners.fullscreenchange); document.removeEventListener("mozfullscreenchange", this.interactionListeners.fullscreenchange); document.removeEventListener( "webkitfullscreenchange", this.interactionListeners.fullscreenchange ); } } class KeyboardListenerAPI { constructor(getRootElement, areResponsesCaseSensitive = false, minimumValidRt = 0) { this.getRootElement = getRootElement; this.areResponsesCaseSensitive = areResponsesCaseSensitive; this.minimumValidRt = minimumValidRt; this.listeners = /* @__PURE__ */ new Set(); this.heldKeys = /* @__PURE__ */ new Set(); this.areRootListenersRegistered = false; autoBind(this); this.registerRootListeners(); } /** * If not previously done and `this.getRootElement()` returns an element, adds the root key * listeners to that element. */ registerRootListeners() { if (!this.areRootListenersRegistered) { const rootElement = this.getRootElement(); if (rootElement) { rootElement.addEventListener("keydown", this.rootKeydownListener); rootElement.addEventListener("keyup", this.rootKeyupListener); this.areRootListenersRegistered = true; } } } rootKeydownListener(e) { for (const listener of [...this.listeners]) { listener(e); } this.heldKeys.add(this.toLowerCaseIfInsensitive(e.key)); } toLowerCaseIfInsensitive(string) { return this.areResponsesCaseSensitive ? string : string.toLowerCase(); } rootKeyupListener(e) { this.heldKeys.delete(this.toLowerCaseIfInsensitive(e.key)); } isResponseValid(validResponses, allowHeldKey, key) { if (!allowHeldKey && this.heldKeys.has(key)) { return false; } if (validResponses === "ALL_KEYS") { return true; } if (validResponses === "NO_KEYS") { return false; } return validResponses.includes(key); } getKeyboardResponse({ callback_function, valid_responses = "ALL_KEYS", rt_method = "performance", persist, audio_context, audio_context_start_time, allow_held_key = false, minimum_valid_rt = this.minimumValidRt }) { if (rt_method !== "performance" && rt_method !== "audio") { console.log( 'Invalid RT method specified in getKeyboardResponse. Defaulting to "performance" method.' ); rt_method = "performance"; } const usePerformanceRt = rt_method === "performance"; const startTime = usePerformanceRt ? performance.now() : audio_context_start_time * 1e3; this.registerRootListeners(); if (!this.areResponsesCaseSensitive && typeof valid_responses !== "string") { valid_responses = valid_responses.map((r) => r.toLowerCase()); } const listener = (e) => { const rt = Math.round( (rt_method == "performance" ? performance.now() : audio_context.currentTime * 1e3) - startTime ); if (rt < minimum_valid_rt) { return; } const key = this.toLowerCaseIfInsensitive(e.key); if (this.isResponseValid(valid_responses, allow_held_key, key)) { e.preventDefault(); if (!persist) { this.cancelKeyboardResponse(listener); } callback_function({ key: e.key, rt }); } }; this.listeners.add(listener); return listener; } cancelKeyboardResponse(listener) { this.listeners.delete(listener); } cancelAllKeyboardResponses() { this.listeners.clear(); } compareKeys(key1, key2) { if (typeof key1 !== "string" && key1 !== null || typeof key2 !== "string" && key2 !== null) { console.error( "Error in jsPsych.pluginAPI.compareKeys: arguments must be key strings or null." ); return void 0; } if (typeof key1 === "string" && typeof key2 === "string") { return this.areResponsesCaseSensitive ? key1 === key2 : key1.toLowerCase() === key2.toLowerCase(); } return key1 === null && key2 === null; } } var ParameterType = /* @__PURE__ */ ((ParameterType2) => { ParameterType2[ParameterType2["BOOL"] = 0] = "BOOL"; ParameterType2[ParameterType2["STRING"] = 1] = "STRING"; ParameterType2[ParameterType2["INT"] = 2] = "INT"; ParameterType2[ParameterType2["FLOAT"] = 3] = "FLOAT"; ParameterType2[ParameterType2["FUNCTION"] = 4] = "FUNCTION"; ParameterType2[ParameterType2["KEY"] = 5] = "KEY"; ParameterType2[ParameterType2["KEYS"] = 6] = "KEYS"; ParameterType2[ParameterType2["SELECT"] = 7] = "SELECT"; ParameterType2[ParameterType2["HTML_STRING"] = 8] = "HTML_STRING"; ParameterType2[ParameterType2["IMAGE"] = 9] = "IMAGE"; ParameterType2[ParameterType2["AUDIO"] = 10] = "AUDIO"; ParameterType2[ParameterType2["VIDEO"] = 11] = "VIDEO"; ParameterType2[ParameterType2["OBJECT"] = 12] = "OBJECT"; ParameterType2[ParameterType2["COMPLEX"] = 13] = "COMPLEX"; ParameterType2[ParameterType2["TIMELINE"] = 14] = "TIMELINE"; return ParameterType2; })(ParameterType || {}); class AudioPlayer { constructor(src, options = { useWebAudio: false }) { this.src = src; this.useWebAudio = options.useWebAudio; this.audioContext = options.audioContext || null; } async load() { if (this.useWebAudio) { this.webAudioBuffer = await this.preloadWebAudio(this.src); } else { this.audio = await this.preloadHTMLAudio(this.src); } } play() { if (this.audio instanceof HTMLAudioElement) { this.audio.play(); } else { if (!this.audio) this.audio = this.getAudioSourceNode(this.webAudioBuffer); this.audio.start(); } } stop() { if (this.audio instanceof HTMLAudioElement) { this.audio.pause(); this.audio.currentTime = 0; } else { this.audio.stop(); this.audio = this.getAudioSourceNode(this.webAudioBuffer); } } addEventListener(eventName, callback) { if (!this.audio && this.webAudioBuffer) this.audio = this.getAudioSourceNode(this.webAudioBuffer); this.audio.addEventListener(eventName, callback); } removeEventListener(eventName, callback) { if (!this.audio && this.webAudioBuffer) this.audio = this.getAudioSourceNode(this.webAudioBuffer); this.audio.removeEventListener(eventName, callback); } getAudioSourceNode(audioBuffer) { const source = this.audioContext.createBufferSource(); source.buffer = audioBuffer; source.connect(this.audioContext.destination); return source; } async preloadWebAudio(src) { const buffer = await fetch(src); const arrayBuffer = await buffer.arrayBuffer(); const audioBuffer = await this.audioContext.decodeAudioData(arrayBuffer); const source = this.audioContext.createBufferSource(); source.buffer = audioBuffer; source.connect(this.audioContext.destination); return audioBuffer; } async preloadHTMLAudio(src) { return new Promise((resolve, reject) => { const audio = new Audio(src); audio.addEventListener("canplaythrough", () => { resolve(audio); }); audio.addEventListener("error", (err) => { reject(err); }); audio.addEventListener("abort", (err) => { reject(err); }); }); } } const preloadParameterTypes = [ ParameterType.AUDIO, ParameterType.IMAGE, ParameterType.VIDEO ]; class MediaAPI { constructor(useWebaudio) { this.useWebaudio = useWebaudio; // video // this.video_buffers = {}; // audio // this.context = null; this.audio_buffers = []; // preloading stimuli // this.preload_requests = []; this.img_cache = {}; this.preloadMap = /* @__PURE__ */ new Map(); this.microphone_recorder = null; this.camera_stream = null; this.camera_recorder = null; if (this.useWebaudio && typeof window !== "undefined" && typeof window.AudioContext !== "undefined") { this.context = new AudioContext(); } } getVideoBuffer(videoID) { if (videoID.startsWith("blob:")) { this.video_buffers[videoID] = videoID; } return this.video_buffers[videoID]; } audioContext() { if (this.context && this.context.state !== "running") { this.context.resume(); } return this.context; } async getAudioPlayer(audioID) { if (this.audio_buffers[audioID] instanceof AudioPlayer) { return this.audio_buffers[audioID]; } else { this.audio_buffers[audioID] = new AudioPlayer(audioID, { useWebAudio: this.useWebaudio, audioContext: this.context }); await this.audio_buffers[audioID].load(); return this.audio_buffers[audioID]; } } preloadAudio(files, callback_complete = () => { }, callback_load = (filepath) => { }, callback_error = (error) => { }) { files = unique(files.flat()); let n_loaded = 0; if (files.length == 0) { callback_complete(); return; } for (const file of files) { if (this.audio_buffers[file] instanceof AudioPlayer) { n_loaded++; callback_load(file); if (n_loaded == files.length) { callback_complete(); } } else { this.audio_buffers[file] = new AudioPlayer(file, { useWebAudio: this.useWebaudio, audioContext: this.context }); this.audio_buffers[file].load().then(() => { n_loaded++; callback_load(file); if (n_loaded == files.length) { callback_complete(); } }).catch((e) => { callback_error(e); }); } } } preloadImages(images, callback_complete = () => { }, callback_load = (filepath) => { }, callback_error = (error_msg) => { }) { images = unique(images.flat()); var n_loaded = 0; if (images.length === 0) { callback_complete(); return; } for (let i = 0; i < images.length; i++) { const img = new Image(); const src = images[i]; img.onload = () => { n_loaded++; callback_load(src); if (n_loaded === images.length) { callback_complete(); } }; img.onerror = (e) => { callback_error({ source: src, error: e }); }; img.src = src; this.img_cache[src] = img; this.preload_requests.push(img); } } preloadVideo(videos, callback_complete = () => { }, callback_load = (filepath) => { }, callback_error = (error_msg) => { }) { videos = unique(videos.flat()); let n_loaded = 0; if (videos.length === 0) { callback_complete(); return; } for (const video of videos) { const video_buffers = this.video_buffers; const request = new XMLHttpRequest(); request.open("GET", video, true); request.responseType = "blob"; request.onload = () => { if (request.status === 200 || request.status === 0) { const videoBlob = request.response; video_buffers[video] = URL.createObjectURL(videoBlob); n_loaded++; callback_load(video); if (n_loaded === videos.length) { callback_complete(); } } }; request.onerror = (e) => { let err = e; if (request.status == 404) { err = "404"; } callback_error({ source: video, error: err }); }; request.onloadend = (e) => { if (request.status == 404) { callback_error({ source: video, error: "404" }); } }; request.send(); this.preload_requests.push(request); } } getAutoPreloadList(timeline_description) { const preloadPaths = Object.fromEntries( preloadParameterTypes.map((type) => [type, /* @__PURE__ */ new Set()]) ); const traverseTimeline = (node, inheritedTrialType) => { const isTimeline = typeof node.timeline !== "undefined"; if (isTimeline) { for (const childNode of node.timeline) { traverseTimeline(childNode, node.type ?? inheritedTrialType); } } else if ((node.type ?? inheritedTrialType)?.info) { const { name: pluginName, parameters } = (node.type ?? inheritedTrialType).info; if (!this.preloadMap.has(pluginName)) { this.preloadMap.set( pluginName, Object.fromEntries( Object.entries(parameters).filter( ([_name, { type, preload }]) => preloadParameterTypes.includes(type) && (preload ?? true) ).map(([name, { type }]) => [name, type]) ) ); } for (const [parameterName, parameterType] of Object.entries( this.preloadMap.get(pluginName) )) { const parameterValue = node[parameterName]; const elements = preloadPaths[parameterType]; if (typeof parameterValue === "string") { elements.add(parameterValue); } else if (Array.isArray(parameterValue)) { for (const element of parameterValue.flat()) { if (typeof element === "string") { elements.add(element); } } } } } }; traverseTimeline({ timeline: timeline_description }); return { images: [...preloadPaths[ParameterType.IMAGE]], audio: [...preloadPaths[ParameterType.AUDIO]], video: [...preloadPaths[ParameterType.VIDEO]] }; } cancelPreloads() { for (const request of this.preload_requests) { request.onload = () => { }; request.onerror = () => { }; request.oncanplaythrough = () => { }; request.onabort = () => { }; } this.preload_requests = []; } initializeMicrophoneRecorder(stream) { const recorder = new MediaRecorder(stream); this.microphone_recorder = recorder; } getMicrophoneRecorder() { return this.microphone_recorder; } initializeCameraRecorder(stream, opts) { let mimeType = this.getCompatibleMimeType() || "video/webm"; const recorderOptions = { ...opts, mimeType }; this.camera_stream = stream; const recorder = new MediaRecorder(stream, recorderOptions); this.camera_recorder = recorder; } // mimetype checking code adapted from https://github.com/lookit/lookit-jspsych/blob/develop/packages/record/src/videoConfig.ts#L673-L699 /** returns a compatible mimetype string, or null if none from the array are supported. */ getCompatibleMimeType() { const types = [ // chrome firefox edge "video/webm;codecs=vp9,opus", "video/webm;codecs=vp8,opus", // general "video/mp4;codecs=avc1.42E01E,mp4a.40.2", // safari "video/mp4;codecs=h264,aac", "video/mp4;codecs=hevc,aac" ]; for (const mimeType of types) { if (MediaRecorder.isTypeSupported(mimeType)) { return mimeType; } } return null; } getCameraStream() { return this.camera_stream; } getCameraRecorder() { return this.camera_recorder; } } class SimulationAPI { constructor(getDisplayContainerElement, setJsPsychTimeout) { this.getDisplayContainerElement = getDisplayContainerElement; this.setJsPsychTimeout = setJsPsychTimeout; } dispatchEvent(event) { this.getDisplayContainerElement().dispatchEvent(event); } /** * Dispatches a `keydown` event for the specified key * @param key Character code (`.key` property) for the key to press. */ keyDown(key) { this.dispatchEvent(new KeyboardEvent("keydown", { key })); } /** * Dispatches a `keyup` event for the specified key * @param key Character code (`.key` property) for the key to press. */ keyUp(key) { this.dispatchEvent(new KeyboardEvent("keyup", { key })); } /** * Dispatches a `keydown` and `keyup` event in sequence to simulate pressing a key. * @param key Character code (`.key` property) for the key to press. * @param delay Length of time to wait (ms) before executing action */ pressKey(key, delay = 0) { if (delay > 0) { this.setJsPsychTimeout(() => { this.keyDown(key); this.keyUp(key); }, delay); } else { this.keyDown(key); this.keyUp(key); } } /** * Dispatches `mousedown`, `mouseup`, and `click` events on the target element * @param target The element to click * @param delay Length of time to wait (ms) before executing action */ clickTarget(target, delay = 0) { if (delay > 0) { this.setJsPsychTimeout(() => { target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); target.dispatchEvent(new MouseEvent("click", { bubbles: true })); }, delay); } else { target.dispatchEvent(new MouseEvent("mousedown", { bubbles: true })); target.dispatchEvent(new MouseEvent("mouseup", { bubbles: true })); target.dispatchEvent(new MouseEvent("click", { bubbles: true })); } } /** * Sets the value of a target text input * @param target A text input element to fill in * @param text Text to input * @param delay Length of time to wait (ms) before executing action */ fillTextInput(target, text, delay = 0) { if (delay > 0) { this.setJsPsychTimeout(() => { target.value = text; }, delay); } else { target.value = text; } } /** * Picks a valid key from `choices`, taking into account jsPsych-specific * identifiers like "NO_KEYS" and "ALL_KEYS". * @param choices Which keys are valid. * @returns A key selected at random from the valid keys. */ getValidKey(choices) { const possible_keys = [ "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", " " ]; let key; if (choices == "NO_KEYS") { key = null; } else if (choices == "ALL_KEYS") { key = possible_keys[Math.floor(Math.random() * possible_keys.length)]; } else { const flat_choices = choices.flat(); key = flat_choices[Math.floor(Math.random() * flat_choices.length)]; } return key; } mergeSimulationData(default_data, simulation_options) { return { ...default_data, ...simulation_options?.data }; } ensureSimulationDataConsistency(trial, data) { if (data.rt) { data.rt = Math.round(data.rt); } if (trial.trial_duration && data.rt && data.rt > trial.trial_duration) { data.rt = null; if (data.response) { data.response = null; } if (data.correct) { data.correct = false; } } if (trial.choices && trial.choices == "NO_KEYS") { if (data.rt) { data.rt = null; } if (data.response) { data.response = null; } } if (trial.allow_response_before_complete) { if (trial.sequence_reps && trial.frame_time) { const min_time = trial.sequence_reps * trial.frame_time * trial.stimuli.length; if (data.rt < min_time) { data.rt = null; data.response = null; } } } } } class TimeoutAPI { constructor() { this.timeout_handlers = []; } /** * Calls a function after a specified delay, in milliseconds. * @param callback The function to call after the delay. * @param delay The number of milliseconds to wait before calling the function. * @returns A handle that can be used to clear the timeout with clearTimeout. */ setTimeout(callback, delay) { const handle = window.setTimeout(callback, delay); this.timeout_handlers.push(handle); return handle; } /** * Clears all timeouts that have been created with setTimeout. */ clearAllTimeouts() { for (const handler of this.timeout_handlers) { clearTimeout(handler); } this.timeout_handlers = []; } } function createJointPluginAPIObject(jsPsych) { const settings = jsPsych.getInitSettings(); const keyboardListenerAPI = new KeyboardListenerAPI( jsPsych.getDisplayContainerElement, settings.case_sensitive_responses, settings.minimum_valid_rt ); const timeoutAPI = new TimeoutAPI(); const mediaAPI = new MediaAPI(settings.use_webaudio); const simulationAPI = new SimulationAPI( jsPsych.getDisplayContainerElement, timeoutAPI.setTimeout.bind(timeoutAPI) ); return Object.assign( {}, ...[keyboardListenerAPI, timeoutAPI, mediaAPI, simulationAPI].map((object) => autoBind(object)) ); } function setSeed(seed = Math.random().toString()) { Math.random = seedrandom(seed); return seed; } function repeat(array, repetitions, unpack = false) { const arr_isArray = Array.isArray(array); const rep_isArray = Array.isArray(repetitions); if (!arr_isArray) { if (!rep_isArray) { array = [array]; repetitions = [repetitions]; } else { repetitions = [repetitions[0]]; console.log( "Unclear parameters given to randomization.repeat. Multiple set sizes specified, but only one item exists to sample. Proceeding using the first set size." ); } } else { if (!rep_isArray) { let reps = []; for (let i = 0; i < array.length; i++) { reps.push(repetitions); } repetitions = reps; } else { if (array.length != repetitions.length) { console.warn( "Unclear parameters given to randomization.repeat. Items and repetitions are unequal lengths. Behavior may not be as expected." ); if (repetitions.length < array.length) { let reps = []; for (let i = 0; i < array.length; i++) { reps.push(repetitions); } repetitions = reps; } else { repetitions = repetitions.slice(0, array.length); } } } } let allsamples = []; for (let i = 0; i < array.length; i++) { for (let j = 0; j < repetitions[i]; j++) { if (array[i] == null || typeof array[i] != "object") { allsamples.push(array[i]); } else { allsamples.push(Object.assign({}, array[i])); } } } let out = shuffle(allsamples); if (unpack) { out = unpackArray(out); } return out; } function shuffle(array) { if (!Array.isArray(array)) { console.error("Argument to shuffle() must be an array."); } const copy_array = array.slice(0); let m = copy_array.length, t, i; while (m) { i = Math.floor(Math.random() * m--); t = copy_array[m]; copy_array[m] = copy_array[i]; copy_array[i] = t; } return copy_array; } function shuffleNoRepeats(arr, equalityTest) { if (!Array.isArray(arr)) { console.error("First argument to shuffleNoRepeats() must be an array."); } if (typeof equalityTest !== "undefined" && typeof equalityTest !== "function") { console.error("Second argument to shuffleNoRepeats() must be a function."); } if (typeof equalityTest == "undefined") { equalityTest = function(a, b) { if (a === b) { return true; } else { return false; } }; } const random_shuffle = shuffle(arr); for (let i = 0; i < random_shuffle.length - 1; i++) { if (equalityTest(random_shuffle[i], random_shuffle[i + 1])) { let random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1; while (equalityTest(random_shuffle[i + 1], random_shuffle[random_pick]) || equalityTest(random_shuffle[i + 1], random_shuffle[random_pick + 1]) || equalityTest(random_shuffle[i + 1], random_shuffle[random_pick - 1]) || equalityTest(random_shuffle[i], random_shuffle[random_pick])) { random_pick = Math.floor(Math.random() * (random_shuffle.length - 2)) + 1; } const new_neighbor = random_shuffle[random_pick]; random_shuffle[random_pick] = random_shuffle[i + 1]; random_shuffle[i + 1] = new_neighbor; } } return random_shuffle; } function shuffleAlternateGroups(arr_groups, random_group_order = false) { const n_groups = arr_groups.length; if (n_groups == 1) { console.warn( "shuffleAlternateGroups() was called with only one group. Defaulting to simple shuffle." ); return shuffle(arr_groups[0]); } let group_order = []; for (let i = 0; i < n_groups; i++) { group_order.push(i); } if (random_group_order) { group_order = shuffle(group_order); } const randomized_groups = []; let min_length = null; for (let i = 0; i < n_groups; i++) { min_length = min_length === null ? arr_groups[i].length : Math.min(min_length, arr_groups[i].length); randomized_groups.push(shuffle(arr_groups[i])); } const out = []; for (let i = 0; i < min_length; i++) { for (let j = 0; j < group_order.length; j++) { out.push(randomized_groups[group_order[j]][i]); } } return out; } function sampleWithoutReplacement(arr, size) { if (!Array.isArray(arr)) { console.error("First argument to sampleWithoutReplacement() must be an array"); } if (size > arr.length) { console.error("Cannot take a sample larger than the size of the set of items to sample."); } return shuffle(arr).slice(0, size); } function sampleWithReplacement(arr, size, weights) { if (!Array.isArray(arr)) { console.error("First argument to sampleWithReplacement() must be an array"); } const normalized_weights = []; if (typeof weights !== "undefined") { if (weights.length !== arr.length) { console.error( "The length of the weights array must equal the length of the array to be sampled from." ); } let weight_sum = 0; for (const weight of weights) { weight_sum += weight; } for (const weight of weights) { normalized_weights.push(weight / weight_sum); } } else { for (let i = 0; i < arr.length; i++) { normalized_weights.push(1 / arr.length); } } const cumulative_weights = [normalized_weights[0]]; for (let i = 1; i < normalized_weights.length; i++) { cumulative_weights.push(normalized_weights[i] + cumulative_weights[i - 1]); } const samp = []; for (let i = 0; i < size; i++) { const rnd = Math.random(); let index = 0; while (rnd > cumulative_weights[index]) { index++; } samp.push(arr[index]); } return samp; } function factorial(factors, repetitions = 1, unpack = false) { let design = [{}]; for (const [factorName, factor] of Object.entries(factors)) { const new_design = []; for (const level of factor) { for (const cell of design) { new_design.push({ ...cell, [factorName]: level }); } } design = new_design; } return repeat(design, repetitions, unpack); } function randomID(length = 32) { let result = ""; const chars = "0123456789abcdefghjklmnopqrstuvwxyz"; for (let i = 0; i < length; i++) { result += chars[Math.floor(Math.random() * chars.length)]; } return result; } function randomInt(lower, upper) { if (upper < lower) { throw new Error("Upper boundary must be greater than or equal to lower boundary"); } return lower + Math.floor(Math.random() * (upper - lower + 1)); } function sampleBernoulli(p) { return Math.random() <= p ? 1 : 0; } function sampleNormal(mean, standard_deviation) { return randn_bm() * standard_deviation + mean; } function sampleExponential(rate) { return -Math.log(Math.random()) / rate; } function sampleExGaussian(mean, standard_deviation, rate, positive = false) { let s = sampleNormal(mean, standard_deviation) + sampleExponential(rate); if (positive) { while (s <= 0) { s = sampleNormal(mean, standard_deviation) + sampleExponential(rate); } } return s; } function randomWords(opts) { return rw(opts); } function randn_bm() { var u = 0, v = 0; while (u === 0) u = Math.random(); while (v === 0) v = Math.random(); return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v); } function unpackArray(array) { const out = {}; for (const x of array) { for (const key of Object.keys(x)) { if (typeof out[key] === "undefined") { out[key] = []; } out[key].push(x[key]); } } return out; } var randomization = /*#__PURE__*/Object.freeze({ __proto__: null, factorial: factorial, randomID: randomID, randomInt: randomInt, randomWords: randomWords, repeat: repeat, sampleBernoulli: sampleBernoulli, sampleExGaussian: sampleExGaussian, sampleExponential: sampleExponential, sampleNormal: sampleNormal, sampleWithReplacement: sampleWithReplacement, sampleWithoutReplacement: sampleWithoutReplacement, setSeed: setSeed, shuffle: shuffle, shuffleAlternateGroups: shuffleAlternateGroups, shuffleNoRepeats: shuffleNoRepeats }); function turkInfo() { const turk = { previewMode: false, outsideTurk: false, hitId: "INVALID_URL_PARAMETER", assignmentId: "INVALID_URL_PARAMETER", workerId: "INVALID_URL_PARAMETER", turkSubmitTo: "INVALID_URL_PARAMETER" }; const param = function(url, name) { name = name.replace(/[\[]/, "\\[").replace(/[\]]/, "\\]"); const regexS = "[\\?&]" + name + "=([^&#]*)"; const regex = new RegExp(regexS); const results = regex.exec(url); return results == null ? "" : results[1]; }; const src = param(window.location.href, "assignmentId") ? window.location.href : document.referrer; const keys = ["assignmentId", "hitId", "workerId", "turkSubmitTo"]; keys.map(function(key) { turk[key] = unescape(param(src, key)); }); turk.previewMode = turk.assignmentId == "ASSIGNMENT_ID_NOT_AVAILABLE"; turk.outsideTurk = !turk.previewMode && turk.hitId === "" && turk.assignmentId == "" && turk.workerId == ""; return turk; } function submitToTurk(data) { const turk = turkInfo(); const assignmentId = turk.assignmentId; const turkSubmitTo = turk.turkSubmitTo; if (!assignmentId || !turkSubmitTo) return; const form = document.createElement("form"); form.method = "POST"; form.action = turkSubmitTo + "/mturk/externalSubmit?assignmentId=" + assignmentId; for (const key in data) { if (data.hasOwnProperty(key)) { const hiddenField = document.createElement("input"); hiddenField.type = "hidden"; hiddenField.name = key; hiddenField.id = key; hiddenField.value = data[key]; form.appendChild(hiddenField); } } document.body.appendChild(form); form.submit(); } var turk = /*#__PURE__*/Object.freeze({ __proto__: null, submitToTurk: submitToTurk, turkInfo: turkInfo }); class ProgressBar { constructor(containerElement, message) { this.containerElement = containerElement; this.message = message; this._progress = 0; this.setupElements(); } /** Adds the progress bar HTML code into `this.containerElement` */ setupElements() { this.messageSpan = document.createElement("span"); this.innerDiv = document.createElement("div"); this.innerDiv.id = "jspsych-progressbar-inner"; this.update(); const outerDiv = document.createElement("div"); outerDiv.id = "jspsych-progressbar-outer"; outerDiv.appendChild(this.innerDiv); this.containerElement.appendChild(this.messageSpan); this.containerElement.appendChild(outerDiv); } /** Updates the progress bar according to `this.progress` */ update() { this.innerDiv.style.width = this._progress * 100 + "%"; if (typeof this.message === "function") { this.messageSpan.innerHTML = this.message(this._progress); } else { this.messageSpan.innerHTML = this.message; } } /** * The bar's current position as a number in the closed interval [0, 1]. Set this to update the * progress bar accordingly. */ set progress(progress) { if (typeof progress !== "number" || progress < 0 || progress > 1) { throw new Error("jsPsych.progressBar.progress must be a number between 0 and 1"); } this._progress = progress; this.update(); } get progress() { return this._progress; } } class TimelineVariable { constructor(name) { this.name = name; } } const timelineDescriptionKeys = [ "timeline", "timeline_variables", "name", "repetitions", "loop_function", "conditional_function", "randomize_order", "sample", "on_timeline_start", "on_timeline_finish" ]; function isTrialDescription(description) { return !isTimelineDescription(description); } function isTimelineDescription(description) { return Boolean(description.timeline) || Array.isArray(description); } var TimelineNodeStatus = /* @__PURE__ */ ((TimelineNodeStatus2) => { TimelineNodeStatus2[TimelineNodeStatus2["PENDING"] = 0] = "PENDING"; TimelineNodeStatus2[TimelineNodeStatus2["RUNNING"] = 1] = "RUNNING"; TimelineNodeStatus2[TimelineNodeStatus2["PAUSED"] = 2] = "PAUSED"; TimelineNodeStatus2[TimelineNodeStatus2["COMPLETED"] = 3] = "COMPLETED"; TimelineNodeStatus2[TimelineNodeStatus2["ABORTED"] = 4] = "ABORTED"; return TimelineNodeStatus2; })(TimelineNodeStatus || {}); class PromiseWrapper { constructor() { this.reset(); } reset() { this.promise = new Promise((resolve) => { this.resolvePromise = resolve; }); } get() { return this.promise; } resolve(value) { this.resolvePromise(value); this.reset(); } } function isPromise(value) { return value && typeof value["then"] === "function"; } function delay(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function parameterPathArrayToString([firstPathElement, ...remainingPathElements]) { let pathString = firstPathElement ?? ""; for (const pathElement of remainingPathElements) { pathString += Number.isNaN(Number.parseInt(pathElement)) ? `.${pathElement}` : `[${pathElement}]`; } return pathString; } function isObjectOrArray(value) { return typeof value === "object" && value !== null; } class ParameterObjectPathCache { constructor() { this.cache = /* @__PURE__ */ new Map(); } static lookupChild(objectOrArray, childName) { let doesPathExist = false; let childValue; if (Number.isNaN(Number.parseInt(childName))) { if (Object.hasOwn(objectOrArray, ch