@jspsych/plugin-audio-button-response 1.1.3 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -1,293 +1,306 @@
1
+ import autoBind from "auto-bind";
1
2
  import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
2
3
 
4
+ import { AudioPlayerInterface } from "../../jspsych/src/modules/plugin-api/AudioPlayer";
5
+ import { version } from "../package.json";
6
+
3
7
  const info = <const>{
4
8
  name: "audio-button-response",
9
+ version: version,
5
10
  parameters: {
6
- /** The audio to be played. */
11
+ /** Path to audio file to be played. */
7
12
  stimulus: {
8
13
  type: ParameterType.AUDIO,
9
- pretty_name: "Stimulus",
10
14
  default: undefined,
11
15
  },
12
- /** Array containing the label(s) for the button(s). */
16
+ /** Labels for the buttons. Each different string in the array will generate a different button. */
13
17
  choices: {
14
18
  type: ParameterType.STRING,
15
- pretty_name: "Choices",
16
19
  default: undefined,
17
20
  array: true,
18
21
  },
19
- /** The HTML for creating button. Can create own style. Use the "%choice%" string to indicate where the label from the choices parameter should be inserted. */
22
+ /**
23
+ * A function that generates the HTML for each button in the `choices` array. The function gets the string
24
+ * and index of the item in the `choices` array and should return valid HTML. If you want to use different
25
+ * markup for each button, you can do that by using a conditional on either parameter. The default parameter
26
+ * returns a button element with the text label of the choice.
27
+ */
20
28
  button_html: {
21
- type: ParameterType.HTML_STRING,
22
- pretty_name: "Button HTML",
23
- default: '<button class="jspsych-btn">%choice%</button>',
24
- array: true,
29
+ type: ParameterType.FUNCTION,
30
+ default: function (choice: string, choice_index: number) {
31
+ return `<button class="jspsych-btn">${choice}</button>`;
32
+ },
25
33
  },
26
- /** Any content here will be displayed below the stimulus. */
34
+ /** This string can contain HTML markup. Any content here will be displayed below the stimulus. The intention
35
+ * is that it can be used to provide a reminder about the action the participant is supposed to take
36
+ * (e.g., which key to press). */
27
37
  prompt: {
28
38
  type: ParameterType.HTML_STRING,
29
- pretty_name: "Prompt",
30
39
  default: null,
31
40
  },
32
- /** The maximum duration to wait for a response. */
41
+ /** How long to wait for the participant to make a response before ending the trial in milliseconds. If the
42
+ * participant fails to make a response before this timer is reached, the participant's response will be
43
+ * recorded as null for the trial and the trial will end. If the value of this parameter is null, the trial
44
+ * will wait for a response indefinitely */
33
45
  trial_duration: {
34
46
  type: ParameterType.INT,
35
- pretty_name: "Trial duration",
36
47
  default: null,
37
48
  },
38
- /** Vertical margin of button. */
39
- margin_vertical: {
49
+ /** Setting to `'grid'` will make the container element have the CSS property `display: grid` and enable the
50
+ * use of `grid_rows` and `grid_columns`. Setting to `'flex'` will make the container element have the CSS
51
+ * property `display: flex`. You can customize how the buttons are laid out by adding inline CSS in the `button_html` parameter.
52
+ */
53
+ button_layout: {
40
54
  type: ParameterType.STRING,
41
- pretty_name: "Margin vertical",
42
- default: "0px",
55
+ default: "grid",
43
56
  },
44
- /** Horizontal margin of button. */
45
- margin_horizontal: {
46
- type: ParameterType.STRING,
47
- pretty_name: "Margin horizontal",
48
- default: "8px",
57
+ /** The number of rows in the button grid. Only applicable when `button_layout` is set to `'grid'`. If null, the
58
+ * number of rows will be determined automatically based on the number of buttons and the number of columns.
59
+ */
60
+ grid_rows: {
61
+ type: ParameterType.INT,
62
+ default: 1,
49
63
  },
50
- /** If true, the trial will end when user makes a response. */
64
+ /** The number of columns in the button grid. Only applicable when `button_layout` is set to `'grid'`.
65
+ * If null, the number of columns will be determined automatically based on the number of buttons and the
66
+ * number of rows.
67
+ */
68
+ grid_columns: {
69
+ type: ParameterType.INT,
70
+ default: null,
71
+ },
72
+ /** If true, then the trial will end whenever the participant makes a response (assuming they make their
73
+ * response before the cutoff specified by the `trial_duration` parameter). If false, then the trial will
74
+ * continue until the value for `trial_duration` is reached. You can set this parameter to `false` to force
75
+ * the participant to listen to the stimulus for a fixed amount of time, even if they respond before the time is complete. */
51
76
  response_ends_trial: {
52
77
  type: ParameterType.BOOL,
53
- pretty_name: "Response ends trial",
54
78
  default: true,
55
79
  },
56
- /** If true, then the trial will end as soon as the audio file finishes playing. */
80
+ /** If true, then the trial will end as soon as the audio file finishes playing. */
57
81
  trial_ends_after_audio: {
58
82
  type: ParameterType.BOOL,
59
- pretty_name: "Trial ends after audio",
60
83
  default: false,
61
84
  },
62
85
  /**
63
- * If true, then responses are allowed while the audio is playing.
64
- * If false, then the audio must finish playing before a response is accepted.
86
+ * If true, then responses are allowed while the audio is playing. If false, then the audio must finish
87
+ * playing before the button choices are enabled and a response is accepted. Once the audio has played
88
+ * all the way through, the buttons are enabled and a response is allowed (including while the audio is
89
+ * being re-played via on-screen playback controls).
65
90
  */
66
91
  response_allowed_while_playing: {
67
92
  type: ParameterType.BOOL,
68
- pretty_name: "Response allowed while playing",
69
93
  default: true,
70
94
  },
95
+ /** How long the button will delay enabling in milliseconds. If `response_allowed_while_playing` is `true`,
96
+ * the timer will start immediately. If it is `false`, the timer will start at the end of the audio. */
97
+ enable_button_after: {
98
+ type: ParameterType.INT,
99
+ default: 0,
100
+ },
101
+ },
102
+ data: {
103
+ /** The response time in milliseconds for the participant to make a response. The time is measured from
104
+ * when the stimulus first began playing until the participant's response.*/
105
+ rt: {
106
+ type: ParameterType.INT,
107
+ },
108
+ /** Indicates which button the participant pressed. The first button in the `choices` array is 0, the second is 1, and so on. */
109
+ response: {
110
+ type: ParameterType.INT,
111
+ },
71
112
  },
72
113
  };
73
114
 
74
115
  type Info = typeof info;
75
116
 
76
117
  /**
77
- * **audio-button-response**
78
- *
79
- * jsPsych plugin for playing an audio file and getting a button response
80
- *
118
+ * If the browser supports it, audio files are played using the WebAudio API. This allows for reasonably precise
119
+ * timing of the playback. The timing of responses generated is measured against the WebAudio specific clock,
120
+ * improving the measurement of response times. If the browser does not support the WebAudio API, then the audio file is
121
+ * played with HTML5 audio.
122
+
123
+ * Audio files can be automatically preloaded by jsPsych using the [`preload` plugin](preload.md). However, if
124
+ * you are using timeline variables or another dynamic method to specify the audio stimulus, you will need
125
+ * to [manually preload](../overview/media-preloading.md#manual-preloading) the audio.
126
+
127
+ * The trial can end when the participant responds, when the audio file has finished playing, or if the participant
128
+ * has failed to respond within a fixed length of time. You can also prevent a button response from being made before the
129
+ * audio has finished playing.
130
+ *
81
131
  * @author Kristin Diep
82
- * @see {@link https://www.jspsych.org/plugins/jspsych-audio-button-response/ audio-button-response plugin documentation on jspsych.org}
132
+ * @see {@link https://www.jspsych.org/latest/plugins/audio-button-response/ audio-button-response plugin documentation on jspsych.org}
83
133
  */
84
134
  class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
85
135
  static info = info;
86
- private audio;
87
-
88
- constructor(private jsPsych: JsPsych) {}
136
+ private audio: AudioPlayerInterface;
137
+ private params: TrialType<Info>;
138
+ private buttonElements: HTMLElement[] = [];
139
+ private display: HTMLElement;
140
+ private response: { rt: number; button: number } = { rt: null, button: null };
141
+ private context: AudioContext;
142
+ private startTime: number;
143
+ private trial_complete: (trial_data: { rt: number; stimulus: string; response: number }) => void;
144
+
145
+ constructor(private jsPsych: JsPsych) {
146
+ autoBind(this);
147
+ }
89
148
 
90
- trial(display_element: HTMLElement, trial: TrialType<Info>, on_load: () => void) {
149
+ async trial(display_element: HTMLElement, trial: TrialType<Info>, on_load: () => void) {
91
150
  // hold the .resolve() function from the Promise that ends the trial
92
- let trial_complete;
93
-
151
+ this.trial_complete;
152
+ this.params = trial;
153
+ this.display = display_element;
94
154
  // setup stimulus
95
- var context = this.jsPsych.pluginAPI.audioContext();
96
-
97
- // store response
98
- var response = {
99
- rt: null,
100
- button: null,
101
- };
102
-
103
- // record webaudio context start time
104
- var startTime;
155
+ this.context = this.jsPsych.pluginAPI.audioContext();
105
156
 
106
157
  // load audio file
107
- this.jsPsych.pluginAPI
108
- .getAudioBuffer(trial.stimulus)
109
- .then((buffer) => {
110
- if (context !== null) {
111
- this.audio = context.createBufferSource();
112
- this.audio.buffer = buffer;
113
- this.audio.connect(context.destination);
114
- } else {
115
- this.audio = buffer;
116
- this.audio.currentTime = 0;
117
- }
118
- setupTrial();
119
- })
120
- .catch((err) => {
121
- console.error(
122
- `Failed to load audio file "${trial.stimulus}". Try checking the file path. We recommend using the preload plugin to load audio files.`
123
- );
124
- console.error(err);
125
- });
126
-
127
- const setupTrial = () => {
128
- // set up end event if trial needs it
129
- if (trial.trial_ends_after_audio) {
130
- this.audio.addEventListener("ended", end_trial);
131
- }
132
-
133
- // enable buttons after audio ends if necessary
134
- if (!trial.response_allowed_while_playing && !trial.trial_ends_after_audio) {
135
- this.audio.addEventListener("ended", enable_buttons);
136
- }
137
-
138
- //display buttons
139
- var buttons = [];
140
- if (Array.isArray(trial.button_html)) {
141
- if (trial.button_html.length == trial.choices.length) {
142
- buttons = trial.button_html;
143
- } else {
144
- console.error(
145
- "Error in audio-button-response plugin. The length of the button_html array does not equal the length of the choices array"
146
- );
147
- }
148
- } else {
149
- for (var i = 0; i < trial.choices.length; i++) {
150
- buttons.push(trial.button_html);
151
- }
152
- }
158
+ this.audio = await this.jsPsych.pluginAPI.getAudioPlayer(trial.stimulus);
153
159
 
154
- var html = '<div id="jspsych-audio-button-response-btngroup">';
155
- for (var i = 0; i < trial.choices.length; i++) {
156
- var str = buttons[i].replace(/%choice%/g, trial.choices[i]);
157
- html +=
158
- '<div class="jspsych-audio-button-response-button" style="cursor: pointer; display: inline-block; margin:' +
159
- trial.margin_vertical +
160
- " " +
161
- trial.margin_horizontal +
162
- '" id="jspsych-audio-button-response-button-' +
163
- i +
164
- '" data-choice="' +
165
- i +
166
- '">' +
167
- str +
168
- "</div>";
169
- }
170
- html += "</div>";
171
-
172
- //show prompt if there is one
173
- if (trial.prompt !== null) {
174
- html += trial.prompt;
175
- }
160
+ // set up end event if trial needs it
161
+ if (trial.trial_ends_after_audio) {
162
+ this.audio.addEventListener("ended", this.end_trial);
163
+ }
176
164
 
177
- display_element.innerHTML = html;
165
+ // enable buttons after audio ends if necessary
166
+ if (!trial.response_allowed_while_playing && !trial.trial_ends_after_audio) {
167
+ this.audio.addEventListener("ended", this.enable_buttons);
168
+ }
178
169
 
179
- if (trial.response_allowed_while_playing) {
180
- enable_buttons();
181
- } else {
182
- disable_buttons();
170
+ // Display buttons
171
+ const buttonGroupElement = document.createElement("div");
172
+ buttonGroupElement.id = "jspsych-audio-button-response-btngroup";
173
+ if (trial.button_layout === "grid") {
174
+ buttonGroupElement.classList.add("jspsych-btn-group-grid");
175
+ if (trial.grid_rows === null && trial.grid_columns === null) {
176
+ throw new Error(
177
+ "You cannot set `grid_rows` to `null` without providing a value for `grid_columns`."
178
+ );
183
179
  }
180
+ const n_cols =
181
+ trial.grid_columns === null
182
+ ? Math.ceil(trial.choices.length / trial.grid_rows)
183
+ : trial.grid_columns;
184
+ const n_rows =
185
+ trial.grid_rows === null
186
+ ? Math.ceil(trial.choices.length / trial.grid_columns)
187
+ : trial.grid_rows;
188
+ buttonGroupElement.style.gridTemplateColumns = `repeat(${n_cols}, 1fr)`;
189
+ buttonGroupElement.style.gridTemplateRows = `repeat(${n_rows}, 1fr)`;
190
+ } else if (trial.button_layout === "flex") {
191
+ buttonGroupElement.classList.add("jspsych-btn-group-flex");
192
+ }
184
193
 
185
- // start time
186
- startTime = performance.now();
187
-
188
- // start audio
189
- if (context !== null) {
190
- startTime = context.currentTime;
191
- this.audio.start(startTime);
192
- } else {
193
- this.audio.play();
194
- }
194
+ for (const [choiceIndex, choice] of trial.choices.entries()) {
195
+ buttonGroupElement.insertAdjacentHTML("beforeend", trial.button_html(choice, choiceIndex));
196
+ const buttonElement = buttonGroupElement.lastChild as HTMLElement;
197
+ buttonElement.dataset.choice = choiceIndex.toString();
198
+ buttonElement.addEventListener("click", () => {
199
+ this.after_response(choiceIndex);
200
+ });
201
+ this.buttonElements.push(buttonElement);
202
+ }
195
203
 
196
- // end trial if time limit is set
197
- if (trial.trial_duration !== null) {
198
- this.jsPsych.pluginAPI.setTimeout(() => {
199
- end_trial();
200
- }, trial.trial_duration);
201
- }
204
+ display_element.appendChild(buttonGroupElement);
202
205
 
203
- on_load();
204
- };
206
+ // Show prompt if there is one
207
+ if (trial.prompt !== null) {
208
+ display_element.insertAdjacentHTML("beforeend", trial.prompt);
209
+ }
205
210
 
206
- // function to handle responses by the subject
207
- function after_response(choice) {
208
- // measure rt
209
- var endTime = performance.now();
210
- var rt = Math.round(endTime - startTime);
211
- if (context !== null) {
212
- endTime = context.currentTime;
213
- rt = Math.round((endTime - startTime) * 1000);
211
+ if (trial.response_allowed_while_playing) {
212
+ if (trial.enable_button_after > 0) {
213
+ this.disable_buttons();
214
+ this.enable_buttons();
214
215
  }
215
- response.button = parseInt(choice);
216
- response.rt = rt;
216
+ } else {
217
+ this.disable_buttons();
218
+ }
217
219
 
218
- // disable all the buttons after a response
219
- disable_buttons();
220
+ // start time
221
+ this.startTime = performance.now();
220
222
 
221
- if (trial.response_ends_trial) {
222
- end_trial();
223
- }
223
+ // end trial if time limit is set
224
+ if (trial.trial_duration !== null) {
225
+ this.jsPsych.pluginAPI.setTimeout(() => {
226
+ this.end_trial();
227
+ }, trial.trial_duration);
224
228
  }
225
229
 
226
- // function to end trial when it is time
227
- const end_trial = () => {
228
- // kill any remaining setTimeout handlers
229
- this.jsPsych.pluginAPI.clearAllTimeouts();
230
+ on_load();
230
231
 
231
- // stop the audio file if it is playing
232
- // remove end event listeners if they exist
233
- if (context !== null) {
234
- this.audio.stop();
235
- } else {
236
- this.audio.pause();
237
- }
238
-
239
- this.audio.removeEventListener("ended", end_trial);
240
- this.audio.removeEventListener("ended", enable_buttons);
232
+ this.audio.play();
241
233
 
242
- // gather the data to store for the trial
243
- var trial_data = {
244
- rt: response.rt,
245
- stimulus: trial.stimulus,
246
- response: response.button,
247
- };
234
+ return new Promise((resolve) => {
235
+ this.trial_complete = resolve;
236
+ });
237
+ }
248
238
 
249
- // clear the display
250
- display_element.innerHTML = "";
239
+ private disable_buttons = () => {
240
+ for (const button of this.buttonElements) {
241
+ button.setAttribute("disabled", "disabled");
242
+ }
243
+ };
251
244
 
252
- // move on to the next trial
253
- this.jsPsych.finishTrial(trial_data);
245
+ private enable_buttons_without_delay = () => {
246
+ for (const button of this.buttonElements) {
247
+ button.removeAttribute("disabled");
248
+ }
249
+ };
254
250
 
255
- trial_complete();
256
- };
251
+ private enable_buttons_with_delay = (delay: number) => {
252
+ this.jsPsych.pluginAPI.setTimeout(this.enable_buttons_without_delay, delay);
253
+ };
257
254
 
258
- function button_response(e) {
259
- var choice = e.currentTarget.getAttribute("data-choice"); // don't use dataset for jsdom compatibility
260
- after_response(choice);
255
+ private enable_buttons() {
256
+ if (this.params.enable_button_after > 0) {
257
+ this.enable_buttons_with_delay(this.params.enable_button_after);
258
+ } else {
259
+ this.enable_buttons_without_delay();
261
260
  }
261
+ }
262
262
 
263
- function disable_buttons() {
264
- var btns = document.querySelectorAll(".jspsych-audio-button-response-button");
265
- for (var i = 0; i < btns.length; i++) {
266
- var btn_el = btns[i].querySelector("button");
267
- if (btn_el) {
268
- btn_el.disabled = true;
269
- }
270
- btns[i].removeEventListener("click", button_response);
271
- }
263
+ // function to handle responses by the subject
264
+ private after_response = (choice) => {
265
+ // measure rt
266
+ var endTime = performance.now();
267
+ var rt = Math.round(endTime - this.startTime);
268
+ if (this.context !== null) {
269
+ endTime = this.context.currentTime;
270
+ rt = Math.round((endTime - this.startTime) * 1000);
272
271
  }
272
+ this.response.button = parseInt(choice);
273
+ this.response.rt = rt;
273
274
 
274
- function enable_buttons() {
275
- var btns = document.querySelectorAll(".jspsych-audio-button-response-button");
276
- for (var i = 0; i < btns.length; i++) {
277
- var btn_el = btns[i].querySelector("button");
278
- if (btn_el) {
279
- btn_el.disabled = false;
280
- }
281
- btns[i].addEventListener("click", button_response);
282
- }
275
+ // disable all the buttons after a response
276
+ this.disable_buttons();
277
+
278
+ if (this.params.response_ends_trial) {
279
+ this.end_trial();
283
280
  }
281
+ };
282
+
283
+ // method to end trial when it is time
284
+ private end_trial = () => {
285
+ // stop the audio file if it is playing
286
+ this.audio.stop();
287
+
288
+ // remove end event listeners if they exist
289
+ this.audio.removeEventListener("ended", this.end_trial);
290
+ this.audio.removeEventListener("ended", this.enable_buttons);
291
+
292
+ // gather the data to store for the trial
293
+ var trial_data = {
294
+ rt: this.response.rt,
295
+ stimulus: this.params.stimulus,
296
+ response: this.response.button,
297
+ };
284
298
 
285
- return new Promise((resolve) => {
286
- trial_complete = resolve;
287
- });
288
- }
299
+ // move on to the next trial
300
+ this.trial_complete(trial_data);
301
+ };
289
302
 
290
- simulate(
303
+ async simulate(
291
304
  trial: TrialType<Info>,
292
305
  simulation_mode,
293
306
  simulation_options: any,
@@ -305,7 +318,9 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
305
318
  private create_simulation_data(trial: TrialType<Info>, simulation_options) {
306
319
  const default_data = {
307
320
  stimulus: trial.stimulus,
308
- rt: this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true),
321
+ rt:
322
+ this.jsPsych.randomization.sampleExGaussian(500, 50, 1 / 150, true) +
323
+ trial.enable_button_after,
309
324
  response: this.jsPsych.randomization.randomInt(0, trial.choices.length - 1),
310
325
  };
311
326
 
@@ -330,7 +345,9 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
330
345
  const respond = () => {
331
346
  if (data.rt !== null) {
332
347
  this.jsPsych.pluginAPI.clickTarget(
333
- display_element.querySelector(`div[data-choice="${data.response}"] button`),
348
+ display_element.querySelector(
349
+ `#jspsych-audio-button-response-btngroup [data-choice="${data.response}"]`
350
+ ),
334
351
  data.rt
335
352
  );
336
353
  }