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 = `
+
+
+