@jspsych/plugin-audio-button-response 1.2.0 → 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,312 +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
  },
71
- /** The delay of enabling button */
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. */
72
97
  enable_button_after: {
73
98
  type: ParameterType.INT,
74
- pretty_name: "Enable button after",
75
99
  default: 0,
76
100
  },
77
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
+ },
112
+ },
78
113
  };
79
114
 
80
115
  type Info = typeof info;
81
116
 
82
117
  /**
83
- * **audio-button-response**
84
- *
85
- * jsPsych plugin for playing an audio file and getting a button response
86
- *
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
+ *
87
131
  * @author Kristin Diep
88
- * @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}
89
133
  */
90
134
  class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
91
135
  static info = info;
92
- private audio;
93
-
94
- 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
+ }
95
148
 
96
- trial(display_element: HTMLElement, trial: TrialType<Info>, on_load: () => void) {
149
+ async trial(display_element: HTMLElement, trial: TrialType<Info>, on_load: () => void) {
97
150
  // hold the .resolve() function from the Promise that ends the trial
98
- let trial_complete;
99
-
151
+ this.trial_complete;
152
+ this.params = trial;
153
+ this.display = display_element;
100
154
  // setup stimulus
101
- var context = this.jsPsych.pluginAPI.audioContext();
102
-
103
- // store response
104
- var response = {
105
- rt: null,
106
- button: null,
107
- };
108
-
109
- // record webaudio context start time
110
- var startTime;
155
+ this.context = this.jsPsych.pluginAPI.audioContext();
111
156
 
112
157
  // load audio file
113
- this.jsPsych.pluginAPI
114
- .getAudioBuffer(trial.stimulus)
115
- .then((buffer) => {
116
- if (context !== null) {
117
- this.audio = context.createBufferSource();
118
- this.audio.buffer = buffer;
119
- this.audio.connect(context.destination);
120
- } else {
121
- this.audio = buffer;
122
- this.audio.currentTime = 0;
123
- }
124
- setupTrial();
125
- })
126
- .catch((err) => {
127
- console.error(
128
- `Failed to load audio file "${trial.stimulus}". Try checking the file path. We recommend using the preload plugin to load audio files.`
129
- );
130
- console.error(err);
131
- });
132
-
133
- const setupTrial = () => {
134
- // set up end event if trial needs it
135
- if (trial.trial_ends_after_audio) {
136
- this.audio.addEventListener("ended", end_trial);
137
- }
138
-
139
- // enable buttons after audio ends if necessary
140
- if (!trial.response_allowed_while_playing && !trial.trial_ends_after_audio) {
141
- this.audio.addEventListener("ended", enable_buttons);
142
- }
143
-
144
- //display buttons
145
- var buttons = [];
146
- if (Array.isArray(trial.button_html)) {
147
- if (trial.button_html.length == trial.choices.length) {
148
- buttons = trial.button_html;
149
- } else {
150
- console.error(
151
- "Error in audio-button-response plugin. The length of the button_html array does not equal the length of the choices array"
152
- );
153
- }
154
- } else {
155
- for (var i = 0; i < trial.choices.length; i++) {
156
- buttons.push(trial.button_html);
157
- }
158
- }
159
-
160
- var html = '<div id="jspsych-audio-button-response-btngroup">';
161
- for (var i = 0; i < trial.choices.length; i++) {
162
- var str = buttons[i].replace(/%choice%/g, trial.choices[i]);
163
- html +=
164
- '<div class="jspsych-audio-button-response-button" style="cursor: pointer; display: inline-block; margin:' +
165
- trial.margin_vertical +
166
- " " +
167
- trial.margin_horizontal +
168
- '" id="jspsych-audio-button-response-button-' +
169
- i +
170
- '" data-choice="' +
171
- i +
172
- '">' +
173
- str +
174
- "</div>";
175
- }
176
- html += "</div>";
177
-
178
- //show prompt if there is one
179
- if (trial.prompt !== null) {
180
- html += trial.prompt;
181
- }
158
+ this.audio = await this.jsPsych.pluginAPI.getAudioPlayer(trial.stimulus);
182
159
 
183
- display_element.innerHTML = html;
184
-
185
- if (trial.response_allowed_while_playing) {
186
- disable_buttons();
187
- enable_buttons();
188
- } else {
189
- disable_buttons();
190
- }
191
-
192
- // start time
193
- startTime = performance.now();
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
+ }
194
164
 
195
- // start audio
196
- if (context !== null) {
197
- startTime = context.currentTime;
198
- this.audio.start(startTime);
199
- } else {
200
- this.audio.play();
201
- }
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
+ }
202
169
 
203
- // end trial if time limit is set
204
- if (trial.trial_duration !== null) {
205
- this.jsPsych.pluginAPI.setTimeout(() => {
206
- end_trial();
207
- }, trial.trial_duration);
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
+ );
208
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
+ }
209
193
 
210
- on_load();
211
- };
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
+ }
212
203
 
213
- // function to handle responses by the subject
214
- function after_response(choice) {
215
- // measure rt
216
- var endTime = performance.now();
217
- var rt = Math.round(endTime - startTime);
218
- if (context !== null) {
219
- endTime = context.currentTime;
220
- rt = Math.round((endTime - startTime) * 1000);
221
- }
222
- response.button = parseInt(choice);
223
- response.rt = rt;
204
+ display_element.appendChild(buttonGroupElement);
224
205
 
225
- // disable all the buttons after a response
226
- disable_buttons();
206
+ // Show prompt if there is one
207
+ if (trial.prompt !== null) {
208
+ display_element.insertAdjacentHTML("beforeend", trial.prompt);
209
+ }
227
210
 
228
- if (trial.response_ends_trial) {
229
- end_trial();
211
+ if (trial.response_allowed_while_playing) {
212
+ if (trial.enable_button_after > 0) {
213
+ this.disable_buttons();
214
+ this.enable_buttons();
230
215
  }
216
+ } else {
217
+ this.disable_buttons();
231
218
  }
232
219
 
233
- // function to end trial when it is time
234
- const end_trial = () => {
235
- // kill any remaining setTimeout handlers
236
- this.jsPsych.pluginAPI.clearAllTimeouts();
220
+ // start time
221
+ this.startTime = performance.now();
237
222
 
238
- // stop the audio file if it is playing
239
- // remove end event listeners if they exist
240
- if (context !== null) {
241
- this.audio.stop();
242
- } else {
243
- this.audio.pause();
244
- }
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);
228
+ }
245
229
 
246
- this.audio.removeEventListener("ended", end_trial);
247
- this.audio.removeEventListener("ended", enable_buttons);
230
+ on_load();
248
231
 
249
- // gather the data to store for the trial
250
- var trial_data = {
251
- rt: response.rt,
252
- stimulus: trial.stimulus,
253
- response: response.button,
254
- };
232
+ this.audio.play();
255
233
 
256
- // clear the display
257
- display_element.innerHTML = "";
234
+ return new Promise((resolve) => {
235
+ this.trial_complete = resolve;
236
+ });
237
+ }
258
238
 
259
- // move on to the next trial
260
- this.jsPsych.finishTrial(trial_data);
239
+ private disable_buttons = () => {
240
+ for (const button of this.buttonElements) {
241
+ button.setAttribute("disabled", "disabled");
242
+ }
243
+ };
261
244
 
262
- trial_complete();
263
- };
245
+ private enable_buttons_without_delay = () => {
246
+ for (const button of this.buttonElements) {
247
+ button.removeAttribute("disabled");
248
+ }
249
+ };
264
250
 
265
- const enable_buttons_with_delay = (delay: number) => {
266
- this.jsPsych.pluginAPI.setTimeout(enable_buttons_without_delay, delay);
267
- };
251
+ private enable_buttons_with_delay = (delay: number) => {
252
+ this.jsPsych.pluginAPI.setTimeout(this.enable_buttons_without_delay, delay);
253
+ };
268
254
 
269
- function button_response(e) {
270
- var choice = e.currentTarget.getAttribute("data-choice"); // don't use dataset for jsdom compatibility
271
- 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();
272
260
  }
261
+ }
273
262
 
274
- function disable_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 = true;
280
- }
281
- btns[i].removeEventListener("click", button_response);
282
- }
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);
283
271
  }
272
+ this.response.button = parseInt(choice);
273
+ this.response.rt = rt;
284
274
 
285
- function enable_buttons_without_delay() {
286
- var btns = document.querySelectorAll(".jspsych-audio-button-response-button");
287
- for (var i = 0; i < btns.length; i++) {
288
- var btn_el = btns[i].querySelector("button");
289
- if (btn_el) {
290
- btn_el.disabled = false;
291
- }
292
- btns[i].addEventListener("click", button_response);
293
- }
294
- }
275
+ // disable all the buttons after a response
276
+ this.disable_buttons();
295
277
 
296
- function enable_buttons() {
297
- if (trial.enable_button_after > 0) {
298
- enable_buttons_with_delay(trial.enable_button_after);
299
- } else {
300
- enable_buttons_without_delay();
301
- }
278
+ if (this.params.response_ends_trial) {
279
+ this.end_trial();
302
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
+ };
303
298
 
304
- return new Promise((resolve) => {
305
- trial_complete = resolve;
306
- });
307
- }
299
+ // move on to the next trial
300
+ this.trial_complete(trial_data);
301
+ };
308
302
 
309
- simulate(
303
+ async simulate(
310
304
  trial: TrialType<Info>,
311
305
  simulation_mode,
312
306
  simulation_options: any,
@@ -351,7 +345,9 @@ class AudioButtonResponsePlugin implements JsPsychPlugin<Info> {
351
345
  const respond = () => {
352
346
  if (data.rt !== null) {
353
347
  this.jsPsych.pluginAPI.clickTarget(
354
- display_element.querySelector(`div[data-choice="${data.response}"] button`),
348
+ display_element.querySelector(
349
+ `#jspsych-audio-button-response-btngroup [data-choice="${data.response}"]`
350
+ ),
355
351
  data.rt
356
352
  );
357
353
  }