@lookit/record 0.0.3
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/README.md +376 -0
- package/dist/consentVideo.d.ts +353 -0
- package/dist/errors.d.ts +158 -0
- package/dist/index.browser.js +26321 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.browser.min.js +4 -0
- package/dist/index.browser.min.js.map +1 -0
- package/dist/index.cjs +26321 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +26319 -0
- package/dist/index.js.map +1 -0
- package/dist/mic_check.d.ts +33 -0
- package/dist/recorder.d.ts +146 -0
- package/dist/start.d.ts +24 -0
- package/dist/stop.d.ts +30 -0
- package/dist/trial.d.ts +28 -0
- package/dist/types.d.ts +5 -0
- package/dist/utils.d.ts +21 -0
- package/dist/video_config.d.ts +296 -0
- package/package.json +52 -0
- package/src/consentVideo.spec.ts +274 -0
- package/src/consentVideo.ts +284 -0
- package/src/environment.d.ts +10 -0
- package/src/errors.ts +243 -0
- package/src/img-import.d.ts +1 -0
- package/src/index.spec.ts +104 -0
- package/src/index.ts +13 -0
- package/src/mic_check.d.ts +1 -0
- package/src/mic_check.js +74 -0
- package/src/mic_check.ts +77 -0
- package/src/recorder.spec.ts +446 -0
- package/src/recorder.ts +353 -0
- package/src/start.ts +36 -0
- package/src/stop.ts +46 -0
- package/src/string-import.d.ts +14 -0
- package/src/trial.ts +47 -0
- package/src/types.ts +8 -0
- package/src/utils.spec.ts +55 -0
- package/src/utils.ts +119 -0
- package/src/video_config.spec.ts +1113 -0
- package/src/video_config.ts +665 -0
- package/src/video_config_mic_check.spec.ts +268 -0
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
import Handlebars from "handlebars";
|
|
2
|
+
import { JsPsych, JsPsychPlugin, ParameterType, TrialType } from "jspsych";
|
|
3
|
+
import chromeInitialPrompt from "../img/chrome_initialprompt.png";
|
|
4
|
+
import chromeAlwaysAllow from "../img/chrome_step1_alwaysallow.png";
|
|
5
|
+
import chromePermissions from "../img/chrome_step1_permissions.png";
|
|
6
|
+
import firefoxInitialPrompt from "../img/firefox_initialprompt.png";
|
|
7
|
+
import firefoxChooseDevice from "../img/firefox_prompt_choose_device.png";
|
|
8
|
+
import firefoxDevicesBlocked from "../img/firefox_prompt_devices_blocked.png";
|
|
9
|
+
import { version } from "../package.json";
|
|
10
|
+
import video_config from "../templates/video-config.hbs";
|
|
11
|
+
import { MicCheckError, NoStreamError } from "./errors";
|
|
12
|
+
import Recorder from "./recorder";
|
|
13
|
+
// import MicCheckProcessor from './mic_check'; // TO DO: fix or remove this. See: https://github.com/lookit/lookit-jspsych/issues/44
|
|
14
|
+
|
|
15
|
+
const info = <const>{
|
|
16
|
+
name: "video-config-plugin",
|
|
17
|
+
version: version,
|
|
18
|
+
parameters: {
|
|
19
|
+
troubleshooting_intro: {
|
|
20
|
+
/**
|
|
21
|
+
* Optional string to appear at the start of the "Setup tips and
|
|
22
|
+
* troubleshooting" section.
|
|
23
|
+
*/
|
|
24
|
+
type: ParameterType.HTML_STRING,
|
|
25
|
+
default: "",
|
|
26
|
+
pretty_name: "Troubleshooting Intro",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
data: {
|
|
30
|
+
rt: {
|
|
31
|
+
type: ParameterType.INT,
|
|
32
|
+
},
|
|
33
|
+
camId: {
|
|
34
|
+
type: ParameterType.STRING,
|
|
35
|
+
},
|
|
36
|
+
micId: {
|
|
37
|
+
type: ParameterType.STRING,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type Info = typeof info;
|
|
43
|
+
|
|
44
|
+
export type VideoConsentTrialType = TrialType<Info>;
|
|
45
|
+
|
|
46
|
+
interface MediaDeviceInfo {
|
|
47
|
+
label: string;
|
|
48
|
+
deviceId: string;
|
|
49
|
+
kind: string;
|
|
50
|
+
groupId: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* **Video Config**.
|
|
55
|
+
*
|
|
56
|
+
* CHS jsPsych plugin for presenting a video recording configuration plugin, to
|
|
57
|
+
* help participants set up their webcam and microphone before beginning a study
|
|
58
|
+
* that includes video/audio recording. This plugin must be used before any
|
|
59
|
+
* other trials in the experiment can access the camera/mic.
|
|
60
|
+
*
|
|
61
|
+
* @author Becky Gilbert
|
|
62
|
+
* @see {@link https://github.com/lookit/lookit-jspsych/blob/main/packages/video-config/README.md video-config plugin documentation on Github}
|
|
63
|
+
*/
|
|
64
|
+
export default class VideoConfigPlugin implements JsPsychPlugin<Info> {
|
|
65
|
+
public static info = info;
|
|
66
|
+
private display_el: HTMLElement | null = null;
|
|
67
|
+
private start_time: number | null = null;
|
|
68
|
+
private recorder: Recorder | null = null;
|
|
69
|
+
private hasReloaded: boolean = false;
|
|
70
|
+
private response: Record<"rt", null | number> = { rt: null };
|
|
71
|
+
private camId: string = "";
|
|
72
|
+
private micId: string = "";
|
|
73
|
+
private minVolume: number = 0.1;
|
|
74
|
+
private micChecked: boolean = false;
|
|
75
|
+
private processorNode: AudioWorkletNode | null = null;
|
|
76
|
+
// HTML IDs and classes
|
|
77
|
+
private webcam_container_id: string = "lookit-jspsych-webcam-container";
|
|
78
|
+
private reload_button_id_text: string = "lookit-jspsych-reload-webcam";
|
|
79
|
+
private reload_button_id_cam: string = "lookit-jspsych-reload-cam-mic";
|
|
80
|
+
private camera_selection_id: string = "lookit-jspsych-which-webcam";
|
|
81
|
+
private mic_selection_id: string = "lookit-jspsych-which-mic";
|
|
82
|
+
private next_button_id: string = "lookit-jspsych-next";
|
|
83
|
+
private error_msg_div_id: string = "lookit-jspsych-video-config-errors";
|
|
84
|
+
private step1_id: string = "lookit-jspsych-step1";
|
|
85
|
+
private step2_id: string = "lookit-jspsych-step2";
|
|
86
|
+
private step3_id: string = "lookit-jspsych-step3";
|
|
87
|
+
private step_complete_class: string = "lookit-jspsych-step-complete";
|
|
88
|
+
// info/error messages
|
|
89
|
+
private step_complete_text: string = "Done!";
|
|
90
|
+
private waiting_for_access_msg: string = "Waiting for camera/mic access...";
|
|
91
|
+
private checking_mic_msg: string = "Checking mic input...";
|
|
92
|
+
private access_problem_msg: string =
|
|
93
|
+
"There was a problem accessing your media devices.";
|
|
94
|
+
private setup_problem_msg: string =
|
|
95
|
+
"There was a problem setting up your camera and mic.";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Constructor for video config plugin.
|
|
99
|
+
*
|
|
100
|
+
* @param jsPsych - JsPsych object automatically passed into the constructor.
|
|
101
|
+
*/
|
|
102
|
+
public constructor(private jsPsych: JsPsych) {}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Trial method called by jsPsych.
|
|
106
|
+
*
|
|
107
|
+
* @param display_element - Element where the jsPsych trial will be displayed.
|
|
108
|
+
* @param trial - Trial object with parameters/values.
|
|
109
|
+
*/
|
|
110
|
+
public trial(display_element: HTMLElement, trial: VideoConsentTrialType) {
|
|
111
|
+
this.display_el = display_element;
|
|
112
|
+
// Set up the event listener for device changes.
|
|
113
|
+
navigator.mediaDevices.ondevicechange = this.onDeviceChange;
|
|
114
|
+
// Add page content.
|
|
115
|
+
this.addHtmlContent(trial.troubleshooting_intro as string);
|
|
116
|
+
// Add event listeners after elements have been added to the page.
|
|
117
|
+
this.addEventListeners();
|
|
118
|
+
// Begin the initial recorder setup steps.
|
|
119
|
+
this.setupRecorder();
|
|
120
|
+
// Record start time.
|
|
121
|
+
this.start_time = performance.now();
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Add HTML content to the page.
|
|
126
|
+
*
|
|
127
|
+
* @param troubleshooting_intro - Troubleshooting intro parameter from the
|
|
128
|
+
* Trial object.
|
|
129
|
+
*/
|
|
130
|
+
private addHtmlContent = (troubleshooting_intro: string) => {
|
|
131
|
+
const html_params = {
|
|
132
|
+
webcam_container_id: this.webcam_container_id,
|
|
133
|
+
reload_button_id_cam: this.reload_button_id_cam,
|
|
134
|
+
camera_selection_id: this.camera_selection_id,
|
|
135
|
+
mic_selection_id: this.mic_selection_id,
|
|
136
|
+
step1_id: this.step1_id,
|
|
137
|
+
step2_id: this.step2_id,
|
|
138
|
+
step3_id: this.step3_id,
|
|
139
|
+
step_complete_class: this.step_complete_class,
|
|
140
|
+
step_complete_text: this.step_complete_text,
|
|
141
|
+
reload_button_id_text: this.reload_button_id_text,
|
|
142
|
+
next_button_id: this.next_button_id,
|
|
143
|
+
chromeInitialPrompt,
|
|
144
|
+
chromeAlwaysAllow,
|
|
145
|
+
chromePermissions,
|
|
146
|
+
firefoxInitialPrompt,
|
|
147
|
+
firefoxChooseDevice,
|
|
148
|
+
firefoxDevicesBlocked,
|
|
149
|
+
troubleshooting_intro,
|
|
150
|
+
};
|
|
151
|
+
this.display_el!.innerHTML = Handlebars.compile(video_config)(html_params);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Add event listeners to elements after they've been added to the page. */
|
|
155
|
+
private addEventListeners = () => {
|
|
156
|
+
// Next button.
|
|
157
|
+
const next_button_el = this.display_el?.querySelector(
|
|
158
|
+
`#${this.next_button_id}`,
|
|
159
|
+
) as HTMLButtonElement;
|
|
160
|
+
next_button_el.addEventListener("click", this.nextButtonClick);
|
|
161
|
+
// Reload buttons.
|
|
162
|
+
(
|
|
163
|
+
this.display_el?.querySelectorAll(
|
|
164
|
+
`#${this.reload_button_id_cam}, #${this.reload_button_id_text}`,
|
|
165
|
+
) as NodeListOf<HTMLButtonElement>
|
|
166
|
+
).forEach((el) => el.addEventListener("click", this.reloadButtonClick));
|
|
167
|
+
// Camera/mic selection elements.
|
|
168
|
+
// If either value changes, then set the current values as the devices and re-run the checks.
|
|
169
|
+
(
|
|
170
|
+
this.display_el?.querySelectorAll(
|
|
171
|
+
".lookit-jspsych-device-selection",
|
|
172
|
+
) as NodeListOf<HTMLSelectElement>
|
|
173
|
+
).forEach((el) => {
|
|
174
|
+
el.addEventListener("change", async () => {
|
|
175
|
+
await this.setDevices();
|
|
176
|
+
this.runStreamChecks();
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
// Accordion section (Setup tips and troubleshooting)
|
|
180
|
+
const accButtons = document.getElementsByClassName(
|
|
181
|
+
"lookit-jspsych-accordion",
|
|
182
|
+
) as HTMLCollectionOf<HTMLButtonElement>;
|
|
183
|
+
for (let i = 0; i < accButtons.length; i++) {
|
|
184
|
+
accButtons[i].addEventListener("click", function () {
|
|
185
|
+
this.classList.toggle("active");
|
|
186
|
+
const panel = this.nextElementSibling as HTMLDivElement;
|
|
187
|
+
if (panel.style.display === "block") {
|
|
188
|
+
panel.style.display = "none";
|
|
189
|
+
} else {
|
|
190
|
+
panel.style.display = "block";
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Access media devices, populate device lists, setup the jsPsych
|
|
198
|
+
* camera/stream and Recorder instance, and run the permissions/mic checks.
|
|
199
|
+
* This is run when the trial first loads and anytime the user clicks the
|
|
200
|
+
* reload recorder button.
|
|
201
|
+
*
|
|
202
|
+
* 1. Request permissions.
|
|
203
|
+
* 2. Enumerate devices and populate the device selection elements with options.
|
|
204
|
+
* 3. Initialize the jsPsych recorder with the current/selected devices, and
|
|
205
|
+
* create a new Recorder.
|
|
206
|
+
* 4. Run the stream checks. (If completed successfully, this will clear any
|
|
207
|
+
* error messages and enable the next button).
|
|
208
|
+
*/
|
|
209
|
+
private setupRecorder = async () => {
|
|
210
|
+
// Reset step completion states for step 1 (stream access) and 3 (mic check).
|
|
211
|
+
// Don't reset step 2 (reload) because that should persist after being checked once with any Recorder/devices.
|
|
212
|
+
this.updateInstructions(1, false);
|
|
213
|
+
this.updateInstructions(3, false);
|
|
214
|
+
this.updateErrors(this.waiting_for_access_msg);
|
|
215
|
+
await this.requestPermission({ video: true, audio: true });
|
|
216
|
+
this.updateErrors("");
|
|
217
|
+
await this.onDeviceChange();
|
|
218
|
+
await this.setDevices();
|
|
219
|
+
await this.runStreamChecks();
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Request permission to use the webcam and/or microphone. This can be used
|
|
224
|
+
* with and without specific device selection (and other constraints).
|
|
225
|
+
*
|
|
226
|
+
* @param constraints - Media stream constraints object with 'video' and
|
|
227
|
+
* 'audio' properties, whose values can be boolean or a
|
|
228
|
+
* MediaTrackConstraints object or undefined.
|
|
229
|
+
* @param constraints.video - If false, do not include video. If true, use the
|
|
230
|
+
* default webcam device. If a media track constraints object is passed,
|
|
231
|
+
* then it can contain the properties of all media tracks and video tracks:
|
|
232
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints.
|
|
233
|
+
* @param constraints.audio - If false, do not include audio. If true, use the
|
|
234
|
+
* default mic device. If a media track constraints object is passed, then
|
|
235
|
+
* it can contain the properties of all media tracks and audio tracks:
|
|
236
|
+
* https://developer.mozilla.org/en-US/docs/Web/API/MediaTrackConstraints.
|
|
237
|
+
* @returns Camera/microphone stream.
|
|
238
|
+
*/
|
|
239
|
+
private async requestPermission(constraints: MediaStreamConstraints) {
|
|
240
|
+
const stream = await navigator.mediaDevices.getUserMedia(constraints);
|
|
241
|
+
return stream;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Receives device lists and populates the device selection HTML elements.
|
|
246
|
+
* This is run when the trial loads, and in response to changes to the
|
|
247
|
+
* available devices.
|
|
248
|
+
*
|
|
249
|
+
* @param devices - Object with properties 'cameras' and 'mics', which are
|
|
250
|
+
* arrays containing lists of available devices.
|
|
251
|
+
* @param devices.cameras - Array of MediaDeviceInfo objects for webcam/video
|
|
252
|
+
* input devices.
|
|
253
|
+
* @param devices.mics - Array of MediaDeviceInfo objects for mic/audio input
|
|
254
|
+
* devices.
|
|
255
|
+
*/
|
|
256
|
+
private updateDeviceSelection = (devices: {
|
|
257
|
+
cameras: MediaDeviceInfo[];
|
|
258
|
+
mics: MediaDeviceInfo[];
|
|
259
|
+
}) => {
|
|
260
|
+
// Clear any existing options in select elements
|
|
261
|
+
const cam_selection_el = this.display_el?.querySelector(
|
|
262
|
+
`#${this.camera_selection_id}`,
|
|
263
|
+
) as HTMLSelectElement;
|
|
264
|
+
cam_selection_el.innerHTML = "";
|
|
265
|
+
const mic_selection_el = this.display_el?.querySelector(
|
|
266
|
+
`#${this.mic_selection_id}`,
|
|
267
|
+
) as HTMLSelectElement;
|
|
268
|
+
mic_selection_el.innerHTML = "";
|
|
269
|
+
// Populate select elements with current device options.
|
|
270
|
+
// If any of the devices were previously selected, then select them again.
|
|
271
|
+
devices.cameras.forEach((d: MediaDeviceInfo) => {
|
|
272
|
+
const el = document.createElement("option");
|
|
273
|
+
el.value = d.deviceId;
|
|
274
|
+
el.innerHTML = d.label;
|
|
275
|
+
cam_selection_el.appendChild(el);
|
|
276
|
+
if (this.camId == el.value) {
|
|
277
|
+
cam_selection_el.value = el.value;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
devices.mics.forEach((d: MediaDeviceInfo) => {
|
|
281
|
+
const el = document.createElement("option");
|
|
282
|
+
el.value = d.deviceId;
|
|
283
|
+
el.innerHTML = d.label;
|
|
284
|
+
mic_selection_el.appendChild(el);
|
|
285
|
+
if (this.micId == el.value) {
|
|
286
|
+
mic_selection_el.value = el.value;
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Takes the selected device IDs from the camera/mic selection elements, gets
|
|
293
|
+
* the streams for these devices, sets these as the input devices via
|
|
294
|
+
* jsPsych.pluginAPI.initializeCameraRecorder, and creates a new Recorder.
|
|
295
|
+
*/
|
|
296
|
+
private setDevices = async () => {
|
|
297
|
+
this.enableNext(false);
|
|
298
|
+
this.updateInstructions(3, false);
|
|
299
|
+
// Get the devices selected from the drop-down element.
|
|
300
|
+
const selected_cam: string = (
|
|
301
|
+
this.display_el?.querySelector(
|
|
302
|
+
`#${this.camera_selection_id}`,
|
|
303
|
+
) as HTMLSelectElement
|
|
304
|
+
).value;
|
|
305
|
+
this.camId = selected_cam;
|
|
306
|
+
const selected_mic: string = (
|
|
307
|
+
this.display_el?.querySelector(
|
|
308
|
+
`#${this.mic_selection_id}`,
|
|
309
|
+
) as HTMLSelectElement
|
|
310
|
+
).value;
|
|
311
|
+
this.micId = selected_mic;
|
|
312
|
+
// Request permission for those specific devices, and initialize the jsPsych recorder.
|
|
313
|
+
const stream = await this.requestPermission({
|
|
314
|
+
video: { deviceId: selected_cam },
|
|
315
|
+
audio: { deviceId: selected_mic },
|
|
316
|
+
});
|
|
317
|
+
this.initializeAndCreateRecorder(stream);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Run the stream checks after permissions have been granted and devices have
|
|
322
|
+
* been selected. This is run when the trial first loads, after permissions
|
|
323
|
+
* are granted, and in response to changes to the device selection. It checks
|
|
324
|
+
* for (1) the existence of the required streams, (2) minimum microphone input
|
|
325
|
+
* level (when audio is included), and (3) media permissions (via recorder
|
|
326
|
+
* destroy/reload). This runs the stream checks, updates the info/error
|
|
327
|
+
* messages, enables the next button, and handles errors. This is factored out
|
|
328
|
+
* of the set up process (setupRecorder) because it is also triggered by a
|
|
329
|
+
* change in the cam/mic device selection.
|
|
330
|
+
*/
|
|
331
|
+
private runStreamChecks = async () => {
|
|
332
|
+
if (this.jsPsych.pluginAPI.getCameraRecorder() && this.recorder) {
|
|
333
|
+
this.recorder.insertWebcamFeed(
|
|
334
|
+
this.display_el?.querySelector(
|
|
335
|
+
`#${this.webcam_container_id}`,
|
|
336
|
+
) as HTMLDivElement,
|
|
337
|
+
);
|
|
338
|
+
this.updateInstructions(1, true);
|
|
339
|
+
this.updateErrors(this.checking_mic_msg);
|
|
340
|
+
try {
|
|
341
|
+
await this.checkMic();
|
|
342
|
+
this.updateErrors("");
|
|
343
|
+
this.updateInstructions(3, true);
|
|
344
|
+
// Allow user to continue (end trial) when all checks have passed.
|
|
345
|
+
if (this.hasReloaded) {
|
|
346
|
+
this.enableNext(true);
|
|
347
|
+
}
|
|
348
|
+
} catch (e) {
|
|
349
|
+
console.warn(`${e}`);
|
|
350
|
+
this.updateErrors(this.setup_problem_msg);
|
|
351
|
+
throw new Error(`${e}`);
|
|
352
|
+
}
|
|
353
|
+
} else {
|
|
354
|
+
this.updateErrors(this.access_problem_msg);
|
|
355
|
+
throw new NoStreamError();
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* If there is a change to the available devices, then we need to: (1) get the
|
|
361
|
+
* updated device lists, and (2) update the select elements with these list
|
|
362
|
+
* elements.
|
|
363
|
+
*/
|
|
364
|
+
private onDeviceChange = async () => {
|
|
365
|
+
const devices = await this.getDeviceLists();
|
|
366
|
+
this.updateDeviceSelection(devices);
|
|
367
|
+
};
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Gets the lists of available cameras and mics (via Media Devices
|
|
371
|
+
* 'enumerateDevices'). These lists can be used to populate camera/mic
|
|
372
|
+
* selection elements.
|
|
373
|
+
*
|
|
374
|
+
* @param include_audio - Whether or not to include audio capture (mic)
|
|
375
|
+
* devices. Optional, default is true.
|
|
376
|
+
* @param include_camera - Whether or not to include the webcam (video)
|
|
377
|
+
* devices. Optional, default is true.
|
|
378
|
+
* @returns Object with properties 'cameras' and 'mics', containing lists of
|
|
379
|
+
* available devices.
|
|
380
|
+
*/
|
|
381
|
+
private getDeviceLists = async (
|
|
382
|
+
include_audio: boolean = true,
|
|
383
|
+
include_camera: boolean = true,
|
|
384
|
+
): Promise<{ cameras: MediaDeviceInfo[]; mics: MediaDeviceInfo[] }> => {
|
|
385
|
+
const devices = await navigator.mediaDevices.enumerateDevices();
|
|
386
|
+
let unique_cameras: Array<MediaDeviceInfo> = [];
|
|
387
|
+
let unique_mics: Array<MediaDeviceInfo> = [];
|
|
388
|
+
if (include_camera) {
|
|
389
|
+
const cams = devices.filter(
|
|
390
|
+
(d) =>
|
|
391
|
+
d.kind === "videoinput" &&
|
|
392
|
+
d.deviceId !== "default" &&
|
|
393
|
+
d.deviceId !== "communications",
|
|
394
|
+
);
|
|
395
|
+
unique_cameras = cams.filter(
|
|
396
|
+
(cam, index, arr) =>
|
|
397
|
+
arr.findIndex((v) => v.groupId == cam.groupId) == index,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
if (include_audio) {
|
|
401
|
+
const mics = devices.filter(
|
|
402
|
+
(d_1) =>
|
|
403
|
+
d_1.kind === "audioinput" &&
|
|
404
|
+
d_1.deviceId !== "default" &&
|
|
405
|
+
d_1.deviceId !== "communications",
|
|
406
|
+
);
|
|
407
|
+
unique_mics = mics.filter(
|
|
408
|
+
(mic, index_1, arr_1) =>
|
|
409
|
+
arr_1.findIndex((v_1) => v_1.groupId == mic.groupId) == index_1,
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
return { cameras: unique_cameras, mics: unique_mics };
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Initialize recorder using the jsPsych plugin API. This must be called
|
|
417
|
+
* before running the stream checks.
|
|
418
|
+
*
|
|
419
|
+
* @param stream - Media stream returned from getUserMedia that should be used
|
|
420
|
+
* to set up the jsPsych recorder.
|
|
421
|
+
* @param opts - Media recorder options to use when setting up the recorder.
|
|
422
|
+
*/
|
|
423
|
+
public initializeAndCreateRecorder = (
|
|
424
|
+
stream: MediaStream,
|
|
425
|
+
opts?: MediaRecorderOptions,
|
|
426
|
+
) => {
|
|
427
|
+
this.jsPsych.pluginAPI.initializeCameraRecorder(stream, opts);
|
|
428
|
+
this.recorder = new Recorder(this.jsPsych);
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Perform a sound check on the audio input (microphone).
|
|
433
|
+
*
|
|
434
|
+
* @param minVol - Minimum mic activity needed to reach the mic check
|
|
435
|
+
* threshold (optional). Default is `this.minVolume`
|
|
436
|
+
* @returns Promise that resolves when the mic check is complete because the
|
|
437
|
+
* audio stream has reached the required minimum level.
|
|
438
|
+
*/
|
|
439
|
+
private checkMic = async (minVol: number = this.minVolume) => {
|
|
440
|
+
if (this.jsPsych.pluginAPI.getCameraStream()) {
|
|
441
|
+
const audioContext = new AudioContext();
|
|
442
|
+
const microphone = audioContext.createMediaStreamSource(
|
|
443
|
+
this.jsPsych.pluginAPI.getCameraStream(),
|
|
444
|
+
);
|
|
445
|
+
try {
|
|
446
|
+
// This currently loads from lookit-api static files.
|
|
447
|
+
// TO DO: load mic_check.js from dist or a URL? See https://github.com/lookit/lookit-jspsych/issues/44
|
|
448
|
+
await audioContext.audioWorklet.addModule("/static/js/mic_check.js");
|
|
449
|
+
await this.createConnectProcessor(audioContext, microphone);
|
|
450
|
+
await this.setupPortOnMessage(minVol);
|
|
451
|
+
} catch (err) {
|
|
452
|
+
throw new MicCheckError(err);
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
throw new NoStreamError();
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Handle the mic level messages that are sent via an AudioWorkletProcessor.
|
|
461
|
+
* This checks the current level against the minimum threshold, and if the
|
|
462
|
+
* threshold is met, sets the micChecked property to true and resolves the
|
|
463
|
+
* checkMic promise.
|
|
464
|
+
*
|
|
465
|
+
* @param currentActivityLevel - Microphone activity level calculated by the
|
|
466
|
+
* processor node.
|
|
467
|
+
* @param minVolume - Minimum microphone activity level needed to pass the
|
|
468
|
+
* microphone check.
|
|
469
|
+
* @param resolve - Resolve callback function for Promise returned by the
|
|
470
|
+
* checkMic method.
|
|
471
|
+
*/
|
|
472
|
+
private onMicActivityLevel(
|
|
473
|
+
currentActivityLevel: number,
|
|
474
|
+
minVolume: number,
|
|
475
|
+
resolve: () => void,
|
|
476
|
+
) {
|
|
477
|
+
if (currentActivityLevel > minVolume) {
|
|
478
|
+
this.micChecked = true;
|
|
479
|
+
this.processorNode?.port.postMessage({ micChecked: true });
|
|
480
|
+
this.processorNode = null;
|
|
481
|
+
resolve();
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Creates the processor node for the mic check input level processing, and
|
|
487
|
+
* connects the microphone to the processor node.
|
|
488
|
+
*
|
|
489
|
+
* @param audioContext - Audio context that was created in checkMic. This is
|
|
490
|
+
* used to create the processor node.
|
|
491
|
+
* @param microphone - Microphone audio stream source, created in checkMic.
|
|
492
|
+
* The processor node will be connected to this source.
|
|
493
|
+
* @returns Promise that resolves after the processor node has been created,
|
|
494
|
+
* and the microphone audio stream source is connected to the processor node
|
|
495
|
+
* and audio context destination.
|
|
496
|
+
*/
|
|
497
|
+
private createConnectProcessor(
|
|
498
|
+
audioContext: AudioContext,
|
|
499
|
+
microphone: MediaStreamAudioSourceNode,
|
|
500
|
+
) {
|
|
501
|
+
return new Promise<void>((resolve) => {
|
|
502
|
+
this.processorNode = new AudioWorkletNode(
|
|
503
|
+
audioContext,
|
|
504
|
+
"mic-check-processor",
|
|
505
|
+
);
|
|
506
|
+
microphone.connect(this.processorNode).connect(audioContext.destination);
|
|
507
|
+
resolve();
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Set up the port's on message event handler for the mic check processor
|
|
513
|
+
* node. This adds the event related callback, which calls onMicActivityLevel
|
|
514
|
+
* with the event data.
|
|
515
|
+
*
|
|
516
|
+
* @param minVol - Minimum volume level (RMS amplitude) passed from checkMic.
|
|
517
|
+
* @returns Promise that resolves from inside the onMicActivityLevel callback,
|
|
518
|
+
* when the mic stream input level has reached the threshold.
|
|
519
|
+
*/
|
|
520
|
+
private setupPortOnMessage(minVol: number) {
|
|
521
|
+
return new Promise<void>((resolve) => {
|
|
522
|
+
/**
|
|
523
|
+
* Callback on the microphone's AudioWorkletNode that fires in response to
|
|
524
|
+
* a message event containing the current mic level. When the mic level
|
|
525
|
+
* reaches the threshold, this callback sets the micChecked property to
|
|
526
|
+
* true and resolves this Promise (via onMicActivityLevel).
|
|
527
|
+
*
|
|
528
|
+
* @param event - The message event that was sent from the processor on
|
|
529
|
+
* the audio worklet node. Contains a 'data' property (object) which
|
|
530
|
+
* contains a 'volume' property (number).
|
|
531
|
+
*/
|
|
532
|
+
this.processorNode!.port.onmessage = (event: MessageEvent) => {
|
|
533
|
+
// handle message from the processor: event.data
|
|
534
|
+
if (this.onMicActivityLevel) {
|
|
535
|
+
if ("data" in event && "volume" in event.data) {
|
|
536
|
+
this.onMicActivityLevel(event.data.volume, minVol, resolve);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Update the instructions for a given step, based on whether or not the check
|
|
545
|
+
* has passed for that step.
|
|
546
|
+
*
|
|
547
|
+
* @param step - Which instructions step to update. 1 = stream access, 2 =
|
|
548
|
+
* reload complete, 3 = mic level check.
|
|
549
|
+
* @param checkPassed - Whether or not the mic check has passed.
|
|
550
|
+
*/
|
|
551
|
+
private updateInstructions = (step: number, checkPassed: boolean) => {
|
|
552
|
+
// Get the IDs for the elements we need to update.
|
|
553
|
+
let step_id = null;
|
|
554
|
+
switch (step) {
|
|
555
|
+
case 1: {
|
|
556
|
+
step_id = this.step1_id;
|
|
557
|
+
break;
|
|
558
|
+
}
|
|
559
|
+
case 2: {
|
|
560
|
+
step_id = this.step2_id;
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
case 3: {
|
|
564
|
+
step_id = this.step3_id;
|
|
565
|
+
break;
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
const step_complete_span = this.display_el?.querySelector(
|
|
569
|
+
`#${step_id}-span`,
|
|
570
|
+
) as HTMLSpanElement;
|
|
571
|
+
const step_paragraph = this.display_el?.querySelector(
|
|
572
|
+
`#${step_id}-paragraph`,
|
|
573
|
+
) as HTMLParagraphElement;
|
|
574
|
+
if (step_complete_span && step_paragraph) {
|
|
575
|
+
if (checkPassed) {
|
|
576
|
+
step_complete_span.style.visibility = "visible";
|
|
577
|
+
step_paragraph.style.color = "gray";
|
|
578
|
+
} else {
|
|
579
|
+
step_complete_span.style.visibility = "hidden";
|
|
580
|
+
step_paragraph.style.color = "black";
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
};
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Update the errors/messages div with information for the user about the
|
|
587
|
+
* camera/mic checks.
|
|
588
|
+
*
|
|
589
|
+
* @param errorMsg - Message to display in the error message div. Pass an
|
|
590
|
+
* empty string to clear any existing errors/messages.
|
|
591
|
+
*/
|
|
592
|
+
private updateErrors = (errorMsg: string) => {
|
|
593
|
+
const error_msg_div = this.display_el?.querySelector(
|
|
594
|
+
`#${this.error_msg_div_id}`,
|
|
595
|
+
) as HTMLDivElement;
|
|
596
|
+
error_msg_div.innerHTML = errorMsg;
|
|
597
|
+
};
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Collect trial data and end the trial. JsPsych.finishTrial takes the
|
|
601
|
+
* plugin's trial data as an argument, ends the trial, and moves on to the
|
|
602
|
+
* next trial in the experiment timeline.
|
|
603
|
+
*/
|
|
604
|
+
private endTrial = () => {
|
|
605
|
+
const trial_data = {
|
|
606
|
+
rt: this.response.rt,
|
|
607
|
+
camId: this.camId,
|
|
608
|
+
micId: this.micId,
|
|
609
|
+
};
|
|
610
|
+
this.jsPsych.finishTrial(trial_data);
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* Destroy the recorder. This is used to stop the streams at the end of the
|
|
615
|
+
* trial, and as part of the reload check to test the user's browser
|
|
616
|
+
* permissions by mimicking the start of a new trial (i.e. stop streams and
|
|
617
|
+
* then create a new Recorder instance).
|
|
618
|
+
*/
|
|
619
|
+
private destroyRecorder = () => {
|
|
620
|
+
if (this.recorder) {
|
|
621
|
+
this.recorder.stopTracks();
|
|
622
|
+
this.recorder.reset();
|
|
623
|
+
this.recorder = null;
|
|
624
|
+
}
|
|
625
|
+
this.enableNext(false);
|
|
626
|
+
this.updateInstructions(3, false);
|
|
627
|
+
this.updateInstructions(1, false);
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
/** Handle the next button click event. */
|
|
631
|
+
private nextButtonClick = () => {
|
|
632
|
+
const end_time = performance.now();
|
|
633
|
+
const rt = this.start_time ? Math.round(end_time - this.start_time) : null;
|
|
634
|
+
this.response.rt = rt;
|
|
635
|
+
this.destroyRecorder();
|
|
636
|
+
this.endTrial();
|
|
637
|
+
};
|
|
638
|
+
|
|
639
|
+
/** Handle the reload recorder button click event. */
|
|
640
|
+
private reloadButtonClick = () => {
|
|
641
|
+
this.hasReloaded = true;
|
|
642
|
+
this.updateInstructions(2, true);
|
|
643
|
+
this.destroyRecorder();
|
|
644
|
+
this.setupRecorder();
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Toggle the next button disable property.
|
|
649
|
+
*
|
|
650
|
+
* @param enable - Whether to enable (true) or disable (false) the next
|
|
651
|
+
* button.
|
|
652
|
+
*/
|
|
653
|
+
private enableNext = (enable: boolean) => {
|
|
654
|
+
const next_button_el = this.display_el?.querySelector(
|
|
655
|
+
`#${this.next_button_id}`,
|
|
656
|
+
) as HTMLButtonElement;
|
|
657
|
+
if (enable) {
|
|
658
|
+
next_button_el.disabled = false;
|
|
659
|
+
next_button_el.classList.add(`${this.step_complete_class}`);
|
|
660
|
+
} else {
|
|
661
|
+
next_button_el.disabled = true;
|
|
662
|
+
next_button_el.classList.remove(`${this.step_complete_class}`);
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|