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"]
+}