diff --git a/.changeset/popular-rivers-reply.md b/.changeset/popular-rivers-reply.md new file mode 100644 index 0000000000..4420a7d52c --- /dev/null +++ b/.changeset/popular-rivers-reply.md @@ -0,0 +1,5 @@ +--- +"@jspsych/plugin-mts": patch +--- + +feat: add plugin-mts for Match-to-Sample procedures diff --git a/package-lock.json b/package-lock.json index 498e2df6a8..7523f0f0d5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2458,6 +2458,10 @@ "resolved": "packages/plugin-mirror-camera", "link": true }, + "node_modules/@jspsych/plugin-mts": { + "resolved": "packages/plugin-mts", + "link": true + }, "node_modules/@jspsych/plugin-preload": { "resolved": "packages/plugin-preload", "link": true @@ -14656,6 +14660,18 @@ "jspsych": ">=7.2.0" } }, + "packages/plugin-mts": { + "name": "@jspsych/plugin-mts", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@jspsych/config": "^3.2.0", + "@jspsych/test-utils": "^1.2.0" + }, + "peerDependencies": { + "jspsych": ">=7.1.0" + } + }, "packages/plugin-preload": { "name": "@jspsych/plugin-preload", "version": "2.1.0", diff --git a/packages/plugin-mts/CHANGELOG.md b/packages/plugin-mts/CHANGELOG.md new file mode 100644 index 0000000000..f1615ffbd4 --- /dev/null +++ b/packages/plugin-mts/CHANGELOG.md @@ -0,0 +1,16 @@ +# @jspsych/plugin-mts + +## 1.0.0 + +### Major Changes + +- Initial release of the MTS (Match-to-Sample) plugin +- Support for SMTS (Simultaneous Matching-to-Sample) procedures +- Support for DMTS (Delayed Matching-to-Sample) procedures +- Visual and auditory sample stimuli +- Multiple sample stimuli support (contextual stimuli) +- Customizable consequence feedback (visual and auditory) +- Comprehensive data collection (RT measurements, accuracy, trial info) +- Randomization of comparison positions +- Flexible stimulus positioning and sizing +- Based on PyMTS by Carvalho, F. C., Regaço, A., & de Rose, J. C. (2023) diff --git a/packages/plugin-mts/README.md b/packages/plugin-mts/README.md new file mode 100644 index 0000000000..285c7d8cfb --- /dev/null +++ b/packages/plugin-mts/README.md @@ -0,0 +1,281 @@ +# plugin-mts + +Current version: 1.0.0. [See the version history](https://github.com/jspsych/jsPsych/blob/main/packages/plugin-mts/CHANGELOG.md). + +This plugin implements Match-to-Sample (MTS) procedures for equivalence class formation and behavioral research. It supports simultaneous (SMTS) and delayed (DMTS) matching, visual and auditory stimuli, customizable feedback, and comprehensive data collection. + +MTS procedures are widely used in behavioral psychology to study stimulus equivalence, concept learning, and relational learning. This plugin is based on PyMTS (Carvalho, F. C., Regaço, A., & de Rose, J. C., 2023), a Python Match-to-Sample program. + +## Install + +Using the CDN-hosted JavaScript file: + +```js + +``` + +Using the JavaScript file downloaded from a GitHub release dist archive: + +```js + +``` + +Using NPM: + +``` +npm install @jspsych/plugin-mts +``` + +```js +import mts from '@jspsych/plugin-mts'; +``` + +## Parameters + +In addition to the [parameters available in all plugins](https://jspsych.org/latest/overview/plugins.md#parameters-available-in-all-plugins), this plugin accepts the following parameters. Parameters with a default value of undefined must be specified. Other parameters can be left unspecified if the default value is acceptable. + +### Sample Stimuli + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| sample_stimuli | array | undefined | Array of image paths to display as sample stimuli. Multiple samples can be displayed simultaneously (e.g., for contextual stimuli). | +| sample_audio | string | null | Audio file path to play as sample stimulus. If provided, participant must click sample to play audio before comparisons appear (for SMTS). | +| sample_positions | array | [[0, 200]] | Positions for sample stimuli in pixels [x, y] relative to center. If only one position is provided, it applies to the first sample. | + +### Comparison Stimuli + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| comparison_stimuli | array | undefined | Array of image paths to display as comparison stimuli. | +| correct_comparison | string or array | undefined | The correct comparison stimulus (or array of correct stimuli for multiple correct responses). | +| comparison_positions | array | [[-350, -200], [350, -200]] | Positions for comparison stimuli in pixels [x, y] relative to center. | +| randomize_comparison_positions | boolean | true | Whether to randomize the positions of comparison stimuli. | + +### Protocol & Timing + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| protocol | string | "SMTS" | Protocol type: 'SMTS' (simultaneous matching) or 'DMTS' (delayed matching). | +| comparison_delay | integer | 0 | Delay in milliseconds before comparison stimuli appear (if protocol is DMTS). | +| require_sample_click | boolean | true | Whether the sample must be clicked before comparisons appear. | +| allow_sample_audio_replay | boolean | true | Whether to allow clicking on sample during comparison phase to replay audio (SMTS only). | + +### Stimuli Appearance + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| stimulus_size | array | [200, 200] | Size of stimuli in pixels [width, height]. | +| background_color | string | "#000000" | Background color of the display element. | + +### Consequences (Feedback) + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| correct_consequence_image | string | null | Image path to display as consequence for correct response. | +| correct_consequence_audio | string | null | Audio path to play as consequence for correct response. | +| correct_consequence_duration | integer | 1000 | Duration in milliseconds to display correct consequence. | +| incorrect_consequence_image | string | null | Image path to display as consequence for incorrect response. | +| incorrect_consequence_audio | string | null | Audio path to play as consequence for incorrect response. | +| incorrect_consequence_duration | integer | 1000 | Duration in milliseconds to display incorrect consequence. | +| consequence_size | array | [600, 400] | Size of consequence images in pixels [width, height]. | +| iti_duration | integer | 0 | Inter-trial interval in milliseconds after consequence. | + +### Audio + +| Parameter | Type | Default Value | Description | +| --------- | ---- | ------------- | ----------- | +| audio_volume | float | 0.5 | Volume for audio playback (0.0 to 1.0). | + +## Data Generated + +In addition to the [default data collected by all plugins](https://jspsych.org/latest/overview/plugins.md#data-collected-by-all-plugins), this plugin collects the following data for each trial. + +| Name | Type | Value | +| ---- | ---- | ----- | +| sample_stimuli | array | The sample stimuli that were displayed. | +| sample_audio | string | The sample audio that was played (if any). | +| comparison_stimuli | array | The comparison stimuli that were displayed. | +| correct_comparison | string or array | The correct comparison stimulus. | +| selected_comparison | string | The comparison stimulus that was selected. | +| selected_comparison_index | integer | Index of the selected comparison in the comparison_stimuli array. | +| correct | boolean | Whether the response was correct. | +| rt_sample | integer | Time from trial start to sample click in milliseconds (null if no click required). | +| rt_comparison | integer | Time from sample click to comparison selection in milliseconds. | +| rt_total | integer | Total trial duration in milliseconds. | +| sample_audio_plays | integer | Number of times the sample audio was played. | +| protocol | string | Protocol used for this trial (SMTS or DMTS). | + +## Examples + +### Basic SMTS (Simultaneous Matching-to-Sample) + +```javascript +const trial = { + type: jsPsychMts, + sample_stimuli: ['stimuli/A1.png'], + comparison_stimuli: ['stimuli/B1.png', 'stimuli/B2.png'], + correct_comparison: 'stimuli/B1.png', + require_sample_click: false, + correct_consequence_image: 'stimuli/correct.png', + incorrect_consequence_image: 'stimuli/incorrect.png' +}; +``` + +### SMTS with Auditory Sample (AB Training) + +```javascript +const trial = { + type: jsPsychMts, + sample_stimuli: ['stimuli/sound_icon.png'], + sample_audio: 'audio/A1.wav', + comparison_stimuli: ['stimuli/B1.png', 'stimuli/B2.png'], + correct_comparison: 'stimuli/B1.png', + require_sample_click: true, + allow_sample_audio_replay: true, + correct_consequence_image: 'stimuli/correct.png', + correct_consequence_audio: 'audio/beep.wav', + incorrect_consequence_image: 'stimuli/incorrect.png', + audio_volume: 0.7 +}; +``` + +### SMTS with Contextual Stimulus (AC Training) + +```javascript +const trial = { + type: jsPsychMts, + sample_stimuli: ['stimuli/A1.png', 'stimuli/sound_icon.png'], + sample_positions: [[0, 200], [350, 250]], + comparison_stimuli: ['stimuli/C1.png', 'stimuli/C2.png'], + correct_comparison: 'stimuli/C1.png', + require_sample_click: false, + correct_consequence_image: 'stimuli/correct.png', + incorrect_consequence_image: 'stimuli/incorrect.png' +}; +``` + +### DMTS (Delayed Matching-to-Sample) + +```javascript +const trial = { + type: jsPsychMts, + sample_stimuli: ['stimuli/A1.png'], + comparison_stimuli: ['stimuli/B1.png', 'stimuli/B2.png'], + correct_comparison: 'stimuli/B1.png', + protocol: 'DMTS', + comparison_delay: 2000, // 2 second delay + correct_consequence_image: 'stimuli/correct.png', + incorrect_consequence_image: 'stimuli/incorrect.png' +}; +``` + +### Equivalence Testing (Multiple Correct Responses) + +```javascript +const trial = { + type: jsPsychMts, + sample_stimuli: ['stimuli/B1.png'], + comparison_stimuli: ['stimuli/C1.png', 'stimuli/C2.png'], + correct_comparison: ['stimuli/C1.png'], // Can specify multiple correct options + require_sample_click: false, + // No consequences for testing phase + iti_duration: 500 +}; +``` + +### Complete Equivalence Training Procedure + +```javascript +// AB Training Block +const ab_training = { + timeline: [ + { + type: jsPsychMts, + sample_stimuli: ['stimuli/sound_icon.png'], + sample_audio: jsPsych.timelineVariable('sample_audio'), + comparison_stimuli: ['stimuli/B1.png', 'stimuli/B2.png'], + correct_comparison: jsPsych.timelineVariable('correct'), + require_sample_click: true, + correct_consequence_image: 'stimuli/correct.png', + correct_consequence_duration: 1000, + incorrect_consequence_image: 'stimuli/incorrect.png', + incorrect_consequence_duration: 1000, + iti_duration: 500 + } + ], + timeline_variables: [ + { sample_audio: 'audio/A1.wav', correct: 'stimuli/B1.png' }, + { sample_audio: 'audio/A2.wav', correct: 'stimuli/B2.png' } + ], + repetitions: 6, + randomize_order: true +}; + +// AC Training Block +const ac_training = { + timeline: [ + { + type: jsPsychMts, + sample_stimuli: jsPsych.timelineVariable('sample'), + comparison_stimuli: ['stimuli/C1.png', 'stimuli/C2.png'], + correct_comparison: jsPsych.timelineVariable('correct'), + require_sample_click: false, + correct_consequence_image: 'stimuli/correct.png', + incorrect_consequence_image: 'stimuli/incorrect.png', + iti_duration: 500 + } + ], + timeline_variables: [ + { sample: ['stimuli/A1.png'], correct: 'stimuli/C1.png' }, + { sample: ['stimuli/A2.png'], correct: 'stimuli/C2.png' } + ], + repetitions: 6, + randomize_order: true +}; + +// Equivalence Test (BC/CB) +const equivalence_test = { + timeline: [ + { + type: jsPsychMts, + sample_stimuli: jsPsych.timelineVariable('sample'), + comparison_stimuli: jsPsych.timelineVariable('comparisons'), + correct_comparison: jsPsych.timelineVariable('correct'), + require_sample_click: false, + // No consequences in testing + iti_duration: 500 + } + ], + timeline_variables: [ + { sample: ['stimuli/B1.png'], comparisons: ['stimuli/C1.png', 'stimuli/C2.png'], correct: 'stimuli/C1.png' }, + { sample: ['stimuli/B2.png'], comparisons: ['stimuli/C1.png', 'stimuli/C2.png'], correct: 'stimuli/C2.png' }, + { sample: ['stimuli/C1.png'], comparisons: ['stimuli/B1.png', 'stimuli/B2.png'], correct: 'stimuli/B1.png' }, + { sample: ['stimuli/C2.png'], comparisons: ['stimuli/B1.png', 'stimuli/B2.png'], correct: 'stimuli/B2.png' } + ], + randomize_order: true +}; +``` + +## Similarity to PyMTS + +This plugin implements the core functionality of PyMTS, a Python-based Match-to-Sample program: + +- **Sample presentation**: Visual and/or auditory sample stimuli +- **Comparison presentation**: SMTS and DMTS protocols +- **Multiple samples**: Support for contextual stimuli +- **Consequences**: Visual and auditory feedback for correct/incorrect responses +- **Comprehensive data**: RT measurements, accuracy, trial information +- **Flexible positioning**: Custom positioning for all stimuli + +### Differences from PyMTS + +- **Block management**: jsPsych handles block logic through timeline variables and loops +- **Criteria**: Use conditional functions in jsPsych timelines for advancement criteria +- **Instructions**: Use jsPsych's `html-keyboard-response` or `instructions` plugin +- **Configuration**: Parameters are specified per trial rather than in separate config files + +## Reference + +If you use this plugin in your research, please cite: + +- Original PyMTS: Carvalho, F. C., Regaço, A., & de Rose, J. C. (2023). PyMTS [Computer Software]. Universidade Federal de São Carlos. diff --git a/packages/plugin-mts/jest.config.cjs b/packages/plugin-mts/jest.config.cjs new file mode 100644 index 0000000000..6ac19d5cf3 --- /dev/null +++ b/packages/plugin-mts/jest.config.cjs @@ -0,0 +1 @@ +module.exports = require("@jspsych/config/jest").makePackageConfig(__dirname); diff --git a/packages/plugin-mts/package.json b/packages/plugin-mts/package.json new file mode 100644 index 0000000000..6ac0316f69 --- /dev/null +++ b/packages/plugin-mts/package.json @@ -0,0 +1,37 @@ +{ + "name": "@jspsych/plugin-mts", + "version": "1.0.0", + "description": "jsPsych plugin for Match-to-Sample (MTS) procedures", + "type": "module", + "main": "dist/index.cjs", + "exports": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + }, + "typings": "dist/index.d.ts", + "unpkg": "dist/index.browser.min.js", + "source": "src/index.ts", + "scripts": { + "test": "jest", + "build": "rollup --config", + "build:watch": "npm run build -- --watch" + }, + "repository": { + "type": "git", + "url": "https://github.com/jspsych/jsPsych.git", + "directory": "packages/plugin-mts" + }, + "author": "jsPsych contributors", + "license": "MIT", + "bugs": { + "url": "https://github.com/jspsych/jsPsych/issues" + }, + "homepage": "https://www.jspsych.org/latest/plugins/mts", + "peerDependencies": { + "jspsych": ">=7.1.0" + }, + "devDependencies": { + "@jspsych/config": "^3.2.0", + "@jspsych/test-utils": "^1.2.0" + } +} diff --git a/packages/plugin-mts/rollup.config.mjs b/packages/plugin-mts/rollup.config.mjs new file mode 100644 index 0000000000..ac3a1a51ee --- /dev/null +++ b/packages/plugin-mts/rollup.config.mjs @@ -0,0 +1,3 @@ +import { makeRollupConfig } from "@jspsych/config/rollup"; + +export default makeRollupConfig("jsPsychMts"); diff --git a/packages/plugin-mts/src/index.spec.ts b/packages/plugin-mts/src/index.spec.ts new file mode 100644 index 0000000000..d0888ed8b6 --- /dev/null +++ b/packages/plugin-mts/src/index.spec.ts @@ -0,0 +1,254 @@ +import { clickTarget, simulateTimeline, startTimeline } from "@jspsych/test-utils"; + +import mts from "."; + +jest.useFakeTimers(); + +describe("plugin-mts", () => { + it("should load", () => { + expect(mts).toBeDefined(); + }); + + describe("SMTS (Simultaneous Matching-to-Sample)", () => { + it("should display sample and comparison stimuli simultaneously", async () => { + const { getHTML, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: false, + protocol: "SMTS", + }, + ]); + + expect(getHTML()).toContain("sample1.png"); + expect(getHTML()).toContain("comp1.png"); + expect(getHTML()).toContain("comp2.png"); + + clickTarget(document.querySelector('img[src="img/comp1.png"]')); + await expectFinished(); + }); + + it("should record correct response when correct comparison is selected", async () => { + const { getData, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: false, + }, + ]); + + clickTarget(document.querySelector('img[src="img/comp1.png"]')); + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(true); + expect(data.selected_comparison).toBe("img/comp1.png"); + }); + + it("should record incorrect response when wrong comparison is selected", async () => { + const { getData, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: false, + }, + ]); + + clickTarget(document.querySelector('img[src="img/comp2.png"]')); + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(false); + expect(data.selected_comparison).toBe("img/comp2.png"); + }); + }); + + describe("Sample click requirement", () => { + it("should require sample click before displaying comparisons when require_sample_click is true", async () => { + const { getHTML } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: true, + protocol: "SMTS", + }, + ]); + + expect(getHTML()).toContain("sample1.png"); + + // Click sample + clickTarget(document.querySelector('img[src="img/sample1.png"]')); + jest.runAllTimers(); + + // Now comparisons should be visible + expect(getHTML()).toContain("comp1.png"); + expect(getHTML()).toContain("comp2.png"); + }); + }); + + describe("Multiple samples (contextual stimuli)", () => { + it("should display multiple sample stimuli", async () => { + const { getHTML, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png", "img/context.png"], + sample_positions: [ + [0, 200], + [350, 250], + ], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: false, + }, + ]); + + expect(getHTML()).toContain("sample1.png"); + expect(getHTML()).toContain("context.png"); + expect(getHTML()).toContain("comp1.png"); + + clickTarget(document.querySelector('img[src="img/comp1.png"]')); + await expectFinished(); + }); + }); + + describe("Consequence display", () => { + it("should display correct consequence image after correct response", async () => { + const { getHTML, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: false, + correct_consequence_image: "img/correct.png", + correct_consequence_duration: 1000, + }, + ]); + + clickTarget(document.querySelector('img[src="img/comp1.png"]')); + jest.advanceTimersByTime(100); + + expect(getHTML()).toContain("correct.png"); + + jest.advanceTimersByTime(1000); + await expectFinished(); + }); + + it("should display incorrect consequence image after incorrect response", async () => { + const { getHTML, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: false, + incorrect_consequence_image: "img/incorrect.png", + incorrect_consequence_duration: 1000, + }, + ]); + + clickTarget(document.querySelector('img[src="img/comp2.png"]')); + jest.advanceTimersByTime(100); + + expect(getHTML()).toContain("incorrect.png"); + + jest.advanceTimersByTime(1000); + await expectFinished(); + }); + }); + + describe("Reaction time recording", () => { + it("should record rt_sample, rt_comparison, and rt_total", async () => { + const { getData, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: true, + protocol: "SMTS", + }, + ]); + + jest.advanceTimersByTime(500); + clickTarget(document.querySelector('img[src="img/sample1.png"]')); + + jest.advanceTimersByTime(1000); + clickTarget(document.querySelector('img[src="img/comp1.png"]')); + + await expectFinished(); + + const data = getData().values()[0]; + expect(data.rt_sample).toBeGreaterThan(0); + expect(data.rt_comparison).toBeGreaterThan(0); + expect(data.rt_total).toBeGreaterThan(0); + }); + }); + + describe("Multiple correct comparisons", () => { + it("should accept any of multiple correct comparisons", async () => { + const { getData, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png", "img/comp3.png"], + correct_comparison: ["img/comp1.png", "img/comp2.png"], + require_sample_click: false, + }, + ]); + + clickTarget(document.querySelector('img[src="img/comp2.png"]')); + await expectFinished(); + + const data = getData().values()[0]; + expect(data.correct).toBe(true); + expect(data.selected_comparison).toBe("img/comp2.png"); + }); + }); + + describe("Randomization", () => { + it("should randomize comparison positions when randomize_comparison_positions is true", async () => { + const { getData, expectFinished } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: false, + randomize_comparison_positions: true, + }, + ]); + + clickTarget(document.querySelectorAll("img")[1]); // Click first comparison + await expectFinished(); + + const data = getData().values()[0]; + expect(data.selected_comparison).toBeDefined(); + }); + }); + + describe("Background color", () => { + it("should set background color", async () => { + const { displayElement } = await startTimeline([ + { + type: mts, + sample_stimuli: ["img/sample1.png"], + comparison_stimuli: ["img/comp1.png", "img/comp2.png"], + correct_comparison: "img/comp1.png", + require_sample_click: false, + background_color: "#FF0000", + }, + ]); + + expect(displayElement.style.backgroundColor).toBe("rgb(255, 0, 0)"); + }); + }); +}); diff --git a/packages/plugin-mts/src/index.ts b/packages/plugin-mts/src/index.ts new file mode 100644 index 0000000000..56226e172d --- /dev/null +++ b/packages/plugin-mts/src/index.ts @@ -0,0 +1,642 @@ +import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych"; +import { AudioPlayerInterface } from "../../jspsych/src/modules/plugin-api/AudioPlayer"; + +const info = { + name: "mts", + version: "1.0.0", + parameters: { + /** + * Array of sample stimuli to display. Can be image paths or HTML strings. + * Multiple samples can be displayed simultaneously (e.g., for contextual stimuli). + */ + sample_stimuli: { + type: ParameterType.IMAGE, + default: undefined, + array: true, + }, + /** + * Audio file to play as sample stimulus. If provided, participant must click + * sample to play audio before comparisons appear (for SMTS) or audio plays automatically. + */ + sample_audio: { + type: ParameterType.AUDIO, + default: null, + }, + /** + * Array of comparison stimuli to display. Can be image paths or HTML strings. + */ + comparison_stimuli: { + type: ParameterType.IMAGE, + default: undefined, + array: true, + }, + /** + * The correct comparison stimulus (or array of correct stimuli for multiple correct responses). + */ + correct_comparison: { + type: ParameterType.STRING, + default: undefined, + array: true, + }, + /** + * Positions for sample stimuli in pixels [x, y] relative to center. + * If only one position provided, it applies to the first sample. + */ + sample_positions: { + type: ParameterType.INT, + default: [[0, 200]], + array: true, + nested: { + x: { type: ParameterType.INT }, + y: { type: ParameterType.INT }, + }, + }, + /** + * Positions for comparison stimuli in pixels [x, y] relative to center. + */ + comparison_positions: { + type: ParameterType.INT, + default: [ + [-350, -200], + [350, -200], + ], + array: true, + nested: { + x: { type: ParameterType.INT }, + y: { type: ParameterType.INT }, + }, + }, + /** + * Size of stimuli in pixels [width, height]. + */ + stimulus_size: { + type: ParameterType.INT, + default: [200, 200], + array: true, + }, + /** + * Protocol type: 'SMTS' (simultaneous), 'DMTS' (delay in seconds), or number for delay duration. + */ + protocol: { + type: ParameterType.STRING, + default: "SMTS", + }, + /** + * Delay in milliseconds before comparison stimuli appear (if protocol is DMTS or number). + */ + comparison_delay: { + type: ParameterType.INT, + default: 0, + }, + /** + * Image to display as consequence for correct response. + */ + correct_consequence_image: { + type: ParameterType.IMAGE, + default: null, + }, + /** + * Audio to play as consequence for correct response. + */ + correct_consequence_audio: { + type: ParameterType.AUDIO, + default: null, + }, + /** + * Duration in milliseconds to display correct consequence. + */ + correct_consequence_duration: { + type: ParameterType.INT, + default: 1000, + }, + /** + * Image to display as consequence for incorrect response. + */ + incorrect_consequence_image: { + type: ParameterType.IMAGE, + default: null, + }, + /** + * Audio to play as consequence for incorrect response. + */ + incorrect_consequence_audio: { + type: ParameterType.AUDIO, + default: null, + }, + /** + * Duration in milliseconds to display incorrect consequence. + */ + incorrect_consequence_duration: { + type: ParameterType.INT, + default: 1000, + }, + /** + * Inter-trial interval in milliseconds after consequence. + */ + iti_duration: { + type: ParameterType.INT, + default: 0, + }, + /** + * Background color of the display element. + */ + background_color: { + type: ParameterType.STRING, + default: "#000000", + }, + /** + * Size of consequence images in pixels [width, height]. + */ + consequence_size: { + type: ParameterType.INT, + default: [600, 400], + array: true, + }, + /** + * Whether to randomize the positions of comparison stimuli. + */ + randomize_comparison_positions: { + type: ParameterType.BOOL, + default: true, + }, + /** + * Whether the sample must be clicked before comparisons appear (SMTS with audio). + */ + require_sample_click: { + type: ParameterType.BOOL, + default: true, + }, + /** + * Volume for audio playback (0.0 to 1.0). + */ + audio_volume: { + type: ParameterType.FLOAT, + default: 0.5, + }, + /** + * Whether to allow clicking on sample during comparison phase to replay audio. + */ + allow_sample_audio_replay: { + type: ParameterType.BOOL, + default: true, + }, + }, + data: { + /** + * The sample stimuli that were displayed. + */ + sample_stimuli: { + type: ParameterType.STRING, + array: true, + }, + /** + * The sample audio that was played (if any). + */ + sample_audio: { + type: ParameterType.STRING, + }, + /** + * The comparison stimuli that were displayed. + */ + comparison_stimuli: { + type: ParameterType.STRING, + array: true, + }, + /** + * The correct comparison stimulus. + */ + correct_comparison: { + type: ParameterType.STRING, + array: true, + }, + /** + * The comparison stimulus that was selected. + */ + selected_comparison: { + type: ParameterType.STRING, + }, + /** + * Index of the selected comparison in the comparison_stimuli array. + */ + selected_comparison_index: { + type: ParameterType.INT, + }, + /** + * Whether the response was correct. + */ + correct: { + type: ParameterType.BOOL, + }, + /** + * Reaction time from sample click to comparison selection in milliseconds. + */ + rt_comparison: { + type: ParameterType.INT, + }, + /** + * Time from trial start to sample click in milliseconds. + */ + rt_sample: { + type: ParameterType.INT, + }, + /** + * Total trial duration in milliseconds. + */ + rt_total: { + type: ParameterType.INT, + }, + /** + * Number of times the sample audio was played. + */ + sample_audio_plays: { + type: ParameterType.INT, + }, + /** + * Protocol used for this trial. + */ + protocol: { + type: ParameterType.STRING, + }, + }, +}; + +type Info = typeof info; + +/** + * This plugin implements Match-to-Sample (MTS) procedures for equivalence class formation + * and behavioral research. It supports simultaneous (SMTS) and delayed (DMTS) matching, + * visual and auditory stimuli, customizable feedback, and comprehensive data collection. + * + * Based on PyMTS - A Python Match-to-Sample program by Carvalho, F. C., Regaço, A., & de Rose, J. C. (2023) + * + * @author Your Name + * @see {@link https://www.jspsych.org/latest/plugins/mts/ mts plugin documentation on jspsych.org} + */ +class MtsPlugin implements JsPsychPlugin { + static info = info; + + constructor(private jsPsych: JsPsych) {} + + async trial(display_element: HTMLElement, trial: TrialType) { + // Set background color + display_element.style.backgroundColor = trial.background_color; + display_element.style.position = "relative"; + display_element.style.width = "100%"; + display_element.style.height = "100vh"; + + // Trial timing variables + const trial_start_time = performance.now(); + let sample_click_time: number | null = null; + let comparison_click_time: number | null = null; + let sample_audio_plays = 0; + let selected_comparison: string | null = null; + let selected_comparison_index: number | null = null; + let is_correct: boolean | null = null; + + // Preload audio if needed + let sample_audio_player: AudioPlayerInterface | null = null; + if (trial.sample_audio) { + try { + sample_audio_player = await this.jsPsych.pluginAPI.getAudioPlayer(trial.sample_audio); + } catch (error) { + console.error("Error loading sample audio:", error); + } + } + + // Shuffle comparison positions if requested + let comparison_order = trial.comparison_stimuli.map((_, i) => i); + if (trial.randomize_comparison_positions) { + comparison_order = this.jsPsych.randomization.shuffle(comparison_order); + } + + // Phase 1: Display sample stimuli + await this.displaySample( + display_element, + trial, + sample_audio_player, + (time, plays) => { + sample_click_time = time; + sample_audio_plays = plays; + } + ); + + // Phase 2: Display comparison stimuli (with optional delay) + if (trial.protocol !== "SMTS" && trial.comparison_delay > 0) { + // Clear sample for DMTS + display_element.innerHTML = ""; + await this.jsPsych.pluginAPI.setTimeout(async () => { + await this.displayComparison( + display_element, + trial, + comparison_order, + sample_audio_player, + sample_audio_plays, + (comp, idx, time) => { + selected_comparison = comp; + selected_comparison_index = idx; + comparison_click_time = time; + } + ); + }, trial.comparison_delay); + } else { + // SMTS - comparisons appear immediately or after sample click + await this.displayComparison( + display_element, + trial, + comparison_order, + sample_audio_player, + sample_audio_plays, + (comp, idx, time) => { + selected_comparison = comp; + selected_comparison_index = idx; + comparison_click_time = time; + } + ); + } + + // Wait for comparison selection + await new Promise((resolve) => { + const checkSelection = () => { + if (selected_comparison !== null) { + resolve(); + } else { + requestAnimationFrame(checkSelection); + } + }; + checkSelection(); + }); + + // Check if response is correct + const correct_comparisons = Array.isArray(trial.correct_comparison) + ? trial.correct_comparison + : [trial.correct_comparison]; + is_correct = correct_comparisons.includes(selected_comparison!); + + // Phase 3: Display consequence + const consequence_duration = is_correct + ? trial.correct_consequence_duration + : trial.incorrect_consequence_duration; + const consequence_image = is_correct + ? trial.correct_consequence_image + : trial.incorrect_consequence_image; + const consequence_audio = is_correct + ? trial.correct_consequence_audio + : trial.incorrect_consequence_audio; + + if (consequence_image || consequence_audio) { + display_element.innerHTML = ""; + await this.displayConsequence( + display_element, + trial, + consequence_image, + consequence_audio, + consequence_duration + ); + } + + // Phase 4: ITI + if (trial.iti_duration > 0) { + display_element.innerHTML = ""; + await this.jsPsych.pluginAPI.setTimeout(() => {}, trial.iti_duration); + } + + // Calculate reaction times + const rt_sample = sample_click_time !== null ? sample_click_time - trial_start_time : null; + const rt_comparison = + comparison_click_time !== null && sample_click_time !== null + ? comparison_click_time - sample_click_time + : null; + const rt_total = performance.now() - trial_start_time; + + // Gather trial data + const trial_data = { + sample_stimuli: trial.sample_stimuli, + sample_audio: trial.sample_audio, + comparison_stimuli: trial.comparison_stimuli, + correct_comparison: trial.correct_comparison, + selected_comparison: selected_comparison, + selected_comparison_index: selected_comparison_index, + correct: is_correct, + rt_sample: rt_sample !== null ? Math.round(rt_sample) : null, + rt_comparison: rt_comparison !== null ? Math.round(rt_comparison) : null, + rt_total: Math.round(rt_total), + sample_audio_plays: sample_audio_plays, + protocol: trial.protocol, + }; + + // Clear display + display_element.innerHTML = ""; + display_element.style.backgroundColor = ""; + + // End trial + this.jsPsych.finishTrial(trial_data); + } + + private async displaySample( + display_element: HTMLElement, + trial: TrialType, + audio_player: AudioPlayerInterface | null, + onSampleClick: (time: number, plays: number) => void + ): Promise { + return new Promise((resolve) => { + display_element.innerHTML = ""; + let audio_plays = 0; + let has_clicked_sample = false; + + // Create sample stimuli + trial.sample_stimuli.forEach((stimulus, idx) => { + const pos = trial.sample_positions[idx] || trial.sample_positions[0]; + const img = document.createElement("img"); + img.src = stimulus; + img.style.position = "absolute"; + img.style.width = `${trial.stimulus_size[0]}px`; + img.style.height = `${trial.stimulus_size[1]}px`; + img.style.left = `calc(50% + ${pos[0]}px - ${trial.stimulus_size[0] / 2}px)`; + img.style.top = `calc(50% - ${pos[1]}px - ${trial.stimulus_size[1] / 2}px)`; + img.style.cursor = idx === 0 && trial.require_sample_click ? "pointer" : "default"; + + // Only first sample is clickable + if (idx === 0 && trial.require_sample_click) { + img.addEventListener("click", () => { + const click_time = performance.now(); + + if (!has_clicked_sample) { + has_clicked_sample = true; + onSampleClick(click_time, audio_plays); + + // Play audio if present + if (audio_player) { + audio_player.play(); + audio_plays++; + } + + // Resolve to show comparisons (if SMTS) + if (trial.protocol === "SMTS") { + resolve(); + } + } + }); + } + + display_element.appendChild(img); + }); + + // If no sample click required or no audio, proceed immediately + if (!trial.require_sample_click || !trial.sample_audio) { + onSampleClick(performance.now(), 0); + resolve(); + } + }); + } + + private async displayComparison( + display_element: HTMLElement, + trial: TrialType, + comparison_order: number[], + audio_player: AudioPlayerInterface | null, + initial_audio_plays: number, + onComparisonClick: (comp: string, idx: number, time: number) => void + ): Promise { + return new Promise((resolve) => { + let audio_plays = initial_audio_plays; + + // Keep sample visible for SMTS + if (trial.protocol === "SMTS") { + // Sample is already displayed, just add comparisons + } else { + // For DMTS, clear and redisplay or just show comparisons + display_element.innerHTML = ""; + } + + // Create comparison stimuli + comparison_order.forEach((original_idx, position_idx) => { + const stimulus = trial.comparison_stimuli[original_idx]; + const pos = trial.comparison_positions[position_idx]; + const img = document.createElement("img"); + img.src = stimulus; + img.style.position = "absolute"; + img.style.width = `${trial.stimulus_size[0]}px`; + img.style.height = `${trial.stimulus_size[1]}px`; + img.style.left = `calc(50% + ${pos[0]}px - ${trial.stimulus_size[0] / 2}px)`; + img.style.top = `calc(50% - ${pos[1]}px - ${trial.stimulus_size[1] / 2}px)`; + img.style.cursor = "pointer"; + + img.addEventListener("click", () => { + const click_time = performance.now(); + onComparisonClick(stimulus, original_idx, click_time); + resolve(); + }); + + display_element.appendChild(img); + }); + + // If SMTS with audio replay allowed, keep sample clickable + if ( + trial.protocol === "SMTS" && + trial.allow_sample_audio_replay && + audio_player && + trial.sample_stimuli.length > 0 + ) { + const sample_images = display_element.querySelectorAll("img"); + if (sample_images.length > 0) { + const first_sample = sample_images[0]; + const new_sample = first_sample.cloneNode(true) as HTMLImageElement; + new_sample.style.cursor = "pointer"; + new_sample.addEventListener("click", () => { + audio_player.play(); + audio_plays++; + }); + first_sample.replaceWith(new_sample); + } + } + }); + } + + private async displayConsequence( + display_element: HTMLElement, + trial: TrialType, + image: string | null, + audio: string | null, + duration: number + ): Promise { + if (image) { + const img = document.createElement("img"); + img.src = image; + img.style.position = "absolute"; + img.style.width = `${trial.consequence_size[0]}px`; + img.style.height = `${trial.consequence_size[1]}px`; + img.style.left = `calc(50% - ${trial.consequence_size[0] / 2}px)`; + img.style.top = `calc(50% - ${trial.consequence_size[1] / 2}px)`; + display_element.appendChild(img); + } + + if (audio) { + try { + const audio_player = await this.jsPsych.pluginAPI.getAudioPlayer(audio); + audio_player.play(); + } catch (error) { + console.error("Error loading consequence audio:", error); + } + } + + await this.jsPsych.pluginAPI.setTimeout(() => {}, duration); + } + + async simulate( + trial: TrialType, + simulation_mode, + simulation_options: any, + load_callback: () => void + ) { + if (simulation_mode == "data-only") { + load_callback(); + this.simulate_data_only(trial, simulation_options); + } + if (simulation_mode == "visual") { + this.simulate_visual(trial, simulation_options, load_callback); + } + } + + private create_simulation_data(trial: TrialType, data) { + const correct_comparisons = Array.isArray(trial.correct_comparison) + ? trial.correct_comparison + : [trial.correct_comparison]; + + const default_data = { + sample_stimuli: trial.sample_stimuli, + sample_audio: trial.sample_audio, + comparison_stimuli: trial.comparison_stimuli, + correct_comparison: trial.correct_comparison, + selected_comparison: this.jsPsych.randomization.sampleWithoutReplacement( + trial.comparison_stimuli, + 1 + )[0], + selected_comparison_index: 0, + correct: false, + rt_sample: this.jsPsych.randomization.sampleExGaussian(500, 100, 1 / 200, true), + rt_comparison: this.jsPsych.randomization.sampleExGaussian(1000, 200, 1 / 200, true), + rt_total: 2000, + sample_audio_plays: trial.sample_audio ? 1 : 0, + protocol: trial.protocol, + }; + + const selected_idx = trial.comparison_stimuli.indexOf(default_data.selected_comparison); + default_data.selected_comparison_index = selected_idx; + default_data.correct = correct_comparisons.includes(default_data.selected_comparison); + default_data.rt_total = + default_data.rt_sample + default_data.rt_comparison + (trial.iti_duration || 0); + + const trial_data = Object.assign({}, default_data, data); + this.jsPsych.finishTrial(trial_data); + } + + private simulate_data_only(trial: TrialType, simulation_options) { + const data = this.create_simulation_data(trial, {}); + } + + private simulate_visual(trial: TrialType, simulation_options, load_callback: () => void) { + const data = this.create_simulation_data(trial, {}); + } +} + +export default MtsPlugin; diff --git a/packages/plugin-mts/tsconfig.json b/packages/plugin-mts/tsconfig.json new file mode 100644 index 0000000000..588f044808 --- /dev/null +++ b/packages/plugin-mts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "@jspsych/config/tsconfig.core.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +}