diff --git a/examples/jspsych-html-video-response.html b/examples/jspsych-html-video-response.html index 6575843d69..924c85853b 100644 --- a/examples/jspsych-html-video-response.html +++ b/examples/jspsych-html-video-response.html @@ -27,6 +27,20 @@ allow_playback: true } + // optional version that shows how to save the video both locally and via an API endpoint + // NOTE: you have to have your own API endpoint to test that feature here. + // const record_and_save_via_api = { + // type: jsPsychHtmlVideoResponse, + // stimulus: `
+ //
+ //
`, + // show_done_button: false, + // recording_duration: 2000, + // allow_playback: true, + // save_locally: true, + // // save_via_api: "https://yourserver.com/upload", + // } + jsPsych.run([init_camera, record]); diff --git a/packages/plugin-html-audio-response/README.md b/packages/plugin-html-audio-response/README.md index 4f9cc391be..e2f902352d 100644 --- a/packages/plugin-html-audio-response/README.md +++ b/packages/plugin-html-audio-response/README.md @@ -8,6 +8,12 @@ The html-audio-response plugin displays HTML content and records audio from the The audio data is recorded in base 64 format, which is a text representation of the audio that may be converted into others. Note that this plugin will _quickly_ generate large amounts of data, so if a large amount of audio needs to be recorded, consider storing the data on a server immediately and deleting it from the data object (This is shown in the documentation link below). +In addition to the default base64 saving, this plugin now supports: +- **Server upload** via the `save_via_api` parameter, which uploads the audio file to a specified API endpoint. A loading spinner and customizable `upload_wait_message` are shown during upload. +- **Local file saving** via the `save_locally` parameter, which saves the audio file directly to the participant's default Downloads folder with a random UUID filename. + +These options can be used individually or together, and if neither is enabled, the original base64 behavior is preserved. + ## Examples Several example experiments and plugin demonstrations are available in the `/examples` folder. diff --git a/packages/plugin-html-audio-response/src/index.ts b/packages/plugin-html-audio-response/src/index.ts index 5071985e83..458513f689 100644 --- a/packages/plugin-html-audio-response/src/index.ts +++ b/packages/plugin-html-audio-response/src/index.ts @@ -52,6 +52,27 @@ const info = { type: ParameterType.BOOL, default: false, }, + /** If a string is provided (e.g., "https://yourserver.com/upload"), the recorded audio will be uploaded + * directly to the specified API endpoint as an audio file (e.g., .webm, .wav, .mp3). A reference ID returned + * by the server (in a field named `ref_id` in the JSON response) will be stored under `response`. + * If `null` or empty, no upload is performed. */ + save_via_api: { + type: ParameterType.STRING, + default: null, + }, + /** If `true`, the recorded audio will be saved locally using a randomly generated filename + * (e.g., "a8sjw93kd.webm"). The browser saves to the user's default Downloads folder. */ + save_locally: { + type: ParameterType.BOOL, + default: false, + }, + /** A message shown to participants while the audio file is being uploaded to a remote server + * (i.e., when `save_via_api` is set to a valid API endpoint). During this time, a loading spinner + * will appear along with this message. */ + upload_wait_message: { + type: ParameterType.STRING, + default: "Uploading data...", + }, }, data: { /** The time, since the onset of the stimulus, for the participant to click the done button. If the button is not clicked (or not enabled), then `rt` will be `null`. */ @@ -121,6 +142,8 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin { private stop_event_handler; private data_available_handler; private recorded_data_chunks = []; + private audio_data: Blob; + private audio_extension: string; constructor(private jsPsych: JsPsych) {} @@ -151,7 +174,9 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin { } private hideStimulus(display_element: HTMLElement) { - const el: HTMLElement = display_element.querySelector("#jspsych-html-audio-response-stimulus"); + const el: HTMLElement = display_element.querySelector( + "#jspsych-html-audio-response-stimulus" + ); if (el) { el.style.visibility = "hidden"; } @@ -174,6 +199,102 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin { } } + private showLoadingStateSpinner(display_element, trial) { + const html = ` + +

${trial.upload_wait_message}

+
+ `; + display_element.innerHTML = html; + } + + private async saveAudioData( + display_element: HTMLElement, + trial: TrialType + ): Promise { + const isApi = + typeof trial.save_via_api === "string" && + trial.save_via_api.trim() !== ""; + const wantsLocalSave = trial.save_locally === true; + + // UUID-style filename + const randomId = Math.random().toString(36).slice(2, 11); + const filename = `${randomId}.${this.audio_extension}`; + + // API upload if requested + if (isApi) { + const formData = new FormData(); + // Server should accept the field name "audio" (adjust if your server expects a different name) + formData.append("audio", this.audio_data, filename); + + try { + this.showLoadingStateSpinner(display_element, trial); + const response = await fetch(trial.save_via_api, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Upload failed (${response.status}): ${text.slice(0, 300)}` + ); + } + + let result: any; + try { + result = await response.json(); + } catch { + const text = await response.text(); + throw new Error(`Expected JSON but got: ${text.slice(0, 300)}`); + } + + this.response = result.ref_id ?? "Uploaded with no ref_id"; + } catch (error) { + console.error("Error uploading audio:", error); + this.response = "Error uploading audio: " + error; + } + } + + // Local download if requested + if (wantsLocalSave) { + const a = document.createElement("a"); + a.style.display = "none"; + a.href = this.audio_url; + a.download = filename; // browser saves to Downloads + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + if (!isApi) { + this.response = filename; + } + } + + // Fallback: base64-encoded into trial data + if (!isApi && !wantsLocalSave) { + this.response = await new Promise((resolve) => { + const reader = new FileReader(); + reader.addEventListener("load", () => { + const base64 = (reader.result as string).split(",")[1]; + resolve(base64); + }); + reader.readAsDataURL(this.audio_data); + }); + } + } + private setupRecordingEvents(display_element, trial) { this.data_available_handler = (e) => { if (e.data.size > 0) { @@ -182,15 +303,38 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin { }; this.stop_event_handler = () => { - const data = new Blob(this.recorded_data_chunks, { type: this.recorded_data_chunks[0].type }); - this.audio_url = URL.createObjectURL(data); - const reader = new FileReader(); - reader.addEventListener("load", () => { - const base64 = (reader.result as string).split(",")[1]; - this.response = base64; - this.load_resolver(); + this.audio_data = new Blob(this.recorded_data_chunks, { + type: this.recorded_data_chunks[0].type, }); - reader.readAsDataURL(data); + + this.audio_url = URL.createObjectURL(this.audio_data); + + // extension from MIME type (e.g., "audio/webm;codecs=opus" → "webm") + const mimeType = this.audio_data.type.split("/"); + if (mimeType.length > 1) { + const raw = mimeType[1].split(";")[0]; + switch (raw) { + case "webm": + this.audio_extension = "webm"; + break; + case "x-wav": + case "wav": + this.audio_extension = "wav"; + break; + case "mpeg": + this.audio_extension = "mp3"; + break; + case "ogg": + this.audio_extension = "ogg"; + break; + default: + this.audio_extension = raw; + } + } else { + this.audio_extension = "webm"; + } + + this.load_resolver(); }; this.start_event_handler = (e) => { @@ -226,7 +370,10 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin { } }; - this.recorder.addEventListener("dataavailable", this.data_available_handler); + this.recorder.addEventListener( + "dataavailable", + this.data_available_handler + ); this.recorder.addEventListener("stop", this.stop_event_handler); @@ -251,11 +398,13 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin { `; - display_element.querySelector("#record-again").addEventListener("click", () => { - // release object url to save memory - URL.revokeObjectURL(this.audio_url); - this.startRecording(); - }); + display_element + .querySelector("#record-again") + .addEventListener("click", () => { + // release object url to save memory + URL.revokeObjectURL(this.audio_url); + this.startRecording(); + }); display_element.querySelector("#continue").addEventListener("click", () => { this.endTrial(display_element, trial); }); @@ -264,10 +413,14 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin { // audio.src = } - private endTrial(display_element, trial) { + private async endTrial(display_element, trial) { + await this.saveAudioData(display_element, trial); // clear recordering event handler - this.recorder.removeEventListener("dataavailable", this.data_available_handler); + this.recorder.removeEventListener( + "dataavailable", + this.data_available_handler + ); this.recorder.removeEventListener("start", this.start_event_handler); this.recorder.removeEventListener("stop", this.stop_event_handler); @@ -276,7 +429,9 @@ class HtmlAudioResponsePlugin implements JsPsychPlugin { rt: this.rt, stimulus: trial.stimulus, response: this.response, - estimated_stimulus_onset: Math.round(this.stimulus_start_time - this.recorder_start_time), + estimated_stimulus_onset: Math.round( + this.stimulus_start_time - this.recorder_start_time + ), }; if (trial.save_audio_url) { diff --git a/packages/plugin-html-video-response/README.md b/packages/plugin-html-video-response/README.md index 6cd1b0c59b..3d4b064831 100644 --- a/packages/plugin-html-video-response/README.md +++ b/packages/plugin-html-video-response/README.md @@ -6,11 +6,21 @@ jsPsych is a JavaScript framework for creating behavioral experiments that run i The html-video-response plugin displays HTML content and records video from the participant via a webcam. In order to get access to the webcam, use the [initialize-camera plugin](https://www.jspsych.org/7.3/plugins/initialize-camera/) before this plugin. -The video data is recorded in base 64 format, which is a text representation of the video that may be converted into others. Note that this plugin will _quickly_ generate large amounts of data, so if a large amount of video needs to be recorded, consider storing the data on a server immediately and deleting it from the data object (This is shown in the documentation link below). +The video data is by default recorded in base 64 format, which is a text representation of the video that may be converted into others. Note that this plugin will _quickly_ generate large amounts of data, so if a large amount of video needs to be recorded, consider storing the data on a server immediately and deleting it from the data object (This is shown in the documentation link below). + +Optionally, in addition to saving video data in the trial’s `response` field in base 64 format, this plugin can: +- **Upload recordings directly to a server API endpoint** via the `save_via_api` parameter. +- **Save recordings locally** to the participant’s default Downloads folder via the `save_locally` parameter. + +Both options automatically generate a unique filename for each recording (e.g., `a7fj2sd9.webm`). If `save_via_api` is used, the plugin expects the server to return a JSON object containing a `ref_id` field, which will be stored in the trial data for linking the trial to the saved file. +If neither option is enabled, the plugin behaves as before, storing the base 64 string in the trial data. + +Note that this plugin will _quickly_ generate large amounts of data, so if a large amount of video needs to be recorded, consider storing the data on a server immediately and deleting it from the data object (This is shown in the documentation link below). ## Examples Several example experiments and plugin demonstrations are available in the `/examples` folder. + After you've downloaded the [latest release](https://github.com/jspsych/jsPsych/releases), double-click on an example HTML file to run it in your web browser, and open it with a programming-friendly text editor to see how it works. ## Documentation diff --git a/packages/plugin-html-video-response/src/index.ts b/packages/plugin-html-video-response/src/index.ts index 5764eaad5a..f0f56b5146 100644 --- a/packages/plugin-html-video-response/src/index.ts +++ b/packages/plugin-html-video-response/src/index.ts @@ -60,6 +60,29 @@ const info = { type: ParameterType.BOOL, default: false, }, + /** A message shown to participants while the video file is being uploaded to a remote server + * (i.e., when `save_via_api` is set to a valid API endpoint). During this time, a loading spinner + * will appear along with this message. */ + upload_wait_message: { + type: ParameterType.STRING, + default: "Uploading data...", + }, + + /** If a string is provided (e.g., "https://yourserver.com/upload"), the recorded video will be uploaded + * directly to the specified API endpoint as a video file (e.g., .webm, .mp4). A reference ID returned + * by the server (in a field named `ref_id` in the JSON response) will be stored under `response`. + * If `null` or empty, no upload is performed. */ + save_via_api: { + type: ParameterType.STRING, + default: null, + }, + + /** If `true`, the recorded video will be saved locally using a randomly generated filename + * (e.g., "a8sjw93kd.webm"). The browser saves to the user's default Downloads folder. */ + save_locally: { + type: ParameterType.BOOL, + default: false, + }, }, data: { /** The time, since the onset of the stimulus, for the participant to click the done button. If the button is not clicked (or not enabled), then `rt` will be `null`. */ @@ -130,6 +153,8 @@ class HtmlVideoResponsePlugin implements JsPsychPlugin { private stop_event_handler; private data_available_handler; private recorded_data_chunks = []; + private video_data: Blob; + private video_extension: string; constructor(private jsPsych: JsPsych) {} @@ -152,7 +177,9 @@ class HtmlVideoResponsePlugin implements JsPsychPlugin { } private hideStimulus(display_element: HTMLElement) { - const el: HTMLElement = display_element.querySelector("#jspsych-html-video-response-stimulus"); + const el: HTMLElement = display_element.querySelector( + "#jspsych-html-video-response-stimulus" + ); if (el) { el.style.visibility = "hidden"; } @@ -175,6 +202,102 @@ class HtmlVideoResponsePlugin implements JsPsychPlugin { } } + private showLoadingStateSpinner(display_element, trial) { + const html = ` + + +

${trial.upload_wait_message}

+
+ `; + display_element.innerHTML = html; + } + + private async saveVideoData( + display_element: HTMLElement, + trial: TrialType + ): Promise { + const isApi = + typeof trial.save_via_api === "string" && + trial.save_via_api.trim() !== ""; + const wantsLocalSave = trial.save_locally === true; + + // UUID-style filename + const randomId = Math.random().toString(36).slice(2, 11); + const filename = `${randomId}.${this.video_extension}`; + + if (isApi) { + const formData = new FormData(); + formData.append("video", this.video_data, filename); + + try { + this.showLoadingStateSpinner(display_element, trial); + const response = await fetch(trial.save_via_api, { + method: "POST", + body: formData, + }); + + if (!response.ok) { + const text = await response.text(); + throw new Error( + `Upload failed (${response.status}): ${text.slice(0, 300)}` + ); + } + + let result: any; + try { + result = await response.json(); + } catch { + const text = await response.text(); + throw new Error(`Expected JSON but got: ${text.slice(0, 300)}`); + } + + this.response = result.ref_id ?? "Uploaded with no ref_id"; + } catch (error) { + console.error("Error uploading video:", error); + this.response = "Error uploading video: " + error; + } + } + + if (wantsLocalSave) { + const a = document.createElement("a"); + a.style.display = "none"; + a.href = this.video_url; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + + if (!isApi) { + this.response = filename; + } + } + + if (!isApi && !wantsLocalSave) { + this.response = await new Promise((resolve) => { + const reader = new FileReader(); + reader.addEventListener("load", () => { + const base64 = (reader.result as string).split(",")[1]; + resolve(base64); + }); + reader.readAsDataURL(this.video_data); + }); + } + } + private setupRecordingEvents(display_element, trial) { this.data_available_handler = (e) => { if (e.data.size > 0) { @@ -183,15 +306,34 @@ class HtmlVideoResponsePlugin implements JsPsychPlugin { }; this.stop_event_handler = () => { - const data = new Blob(this.recorded_data_chunks, { type: this.recorder.mimeType }); - this.video_url = URL.createObjectURL(data); - const reader = new FileReader(); - reader.addEventListener("load", () => { - const base64 = (reader.result as string).split(",")[1]; - this.response = base64; - this.load_resolver(); + this.video_data = new Blob(this.recorded_data_chunks, { + type: this.recorder.mimeType, }); - reader.readAsDataURL(data); + + this.video_url = URL.createObjectURL(this.video_data); + + // derive extension from MIME type (e.g., "video/webm;codecs=vp8" → "webm") + const mimeType = this.video_data.type.split("/"); + if (mimeType.length > 1) { + const raw = mimeType[1].split(";")[0]; + switch (raw) { + case "mp4": + this.video_extension = "mp4"; + break; + case "webm": + this.video_extension = "webm"; + break; + case "x-matroska": + this.video_extension = "mkv"; + break; + default: + this.video_extension = raw; + } + } else { + this.video_extension = "webm"; // safe default + } + + this.load_resolver(); }; this.start_event_handler = (e) => { @@ -227,7 +369,10 @@ class HtmlVideoResponsePlugin implements JsPsychPlugin { } }; - this.recorder.addEventListener("dataavailable", this.data_available_handler); + this.recorder.addEventListener( + "dataavailable", + this.data_available_handler + ); this.recorder.addEventListener("stop", this.stop_event_handler); @@ -252,11 +397,13 @@ class HtmlVideoResponsePlugin implements JsPsychPlugin { `; - display_element.querySelector("#record-again").addEventListener("click", () => { - // release object url to save memory - URL.revokeObjectURL(this.video_url); - this.startRecording(); - }); + display_element + .querySelector("#record-again") + .addEventListener("click", () => { + // release object url to save memory + URL.revokeObjectURL(this.video_url); + this.startRecording(); + }); display_element.querySelector("#continue").addEventListener("click", () => { this.endTrial(display_element, trial); }); @@ -265,10 +412,14 @@ class HtmlVideoResponsePlugin implements JsPsychPlugin { // video.src = } - private endTrial(display_element, trial) { + private async endTrial(display_element, trial) { + await this.saveVideoData(display_element, trial); // clear recordering event handler - this.recorder.removeEventListener("dataavailable", this.data_available_handler); + this.recorder.removeEventListener( + "dataavailable", + this.data_available_handler + ); this.recorder.removeEventListener("start", this.start_event_handler); this.recorder.removeEventListener("stop", this.stop_event_handler);