From 6d61f826c0a28c733a1fec36bda7d42e3e3043d1 Mon Sep 17 00:00:00 2001 From: sajjad Mazaheri Date: Mon, 7 Sep 2026 16:11:00 +0200 Subject: [PATCH] feat(rsvpSpeech): Implement RSVP speech response mode validation and adjust silence thresholds --- components/rsvpSpeech/rsvpSpeechController.ts | 13 +- components/speech/audioCapture.ts | 21 ++- preprocess/validateExperimentTable.ts | 39 +++++ .../validationGreenSnapshot.test.ts.snap | 17 ++ tests/audioCapture.test.ts | 62 +++++++ tests/rsvpSpeechCompiler.test.ts | 164 ++++++++++++++++++ tests/rsvpSpeechController.test.ts | 134 ++++++++++++++ tests/speechSession.test.ts | 40 +++++ tests/validationGreenSnapshot.test.ts | 11 ++ 9 files changed, 494 insertions(+), 7 deletions(-) create mode 100644 tests/rsvpSpeechCompiler.test.ts diff --git a/components/rsvpSpeech/rsvpSpeechController.ts b/components/rsvpSpeech/rsvpSpeechController.ts index 4d4d3676..d842ce6a 100644 --- a/components/rsvpSpeech/rsvpSpeechController.ts +++ b/components/rsvpSpeech/rsvpSpeechController.ts @@ -71,6 +71,7 @@ export interface RsvpSpeechTrialConfiguration { export interface RsvpSpeechCapturePort { initialize(): Promise; + startInput(): void; start(): void; stop(): void; subscribe(listener: (chunk: PcmAudioChunk) => void): () => void; @@ -101,7 +102,7 @@ export interface RsvpSpeechControllerDependencies { ) => RsvpSpeechSessionPort; } -const DEFAULT_DEEPGRAM_ENDPOINTING_MS = 500; +const RSVP_ENDPOINTING_SILENCE_MS = 1_500; const DEFAULT_MICROPHONE_PERMISSION_TIMEOUT_MS = 8_000; const positiveDuration = (value: number, name: string): void => { @@ -215,15 +216,16 @@ const createDefaultTranscriber = ( }; if (configuration.provider === "elevenlabs") { - return new ElevenLabsRealtimeTranscriber( - shared satisfies ElevenLabsRealtimeTranscriberOptions, - ); + return new ElevenLabsRealtimeTranscriber({ + ...shared, + vad: { silenceThresholdSecs: RSVP_ENDPOINTING_SILENCE_MS / 1000 }, + } satisfies ElevenLabsRealtimeTranscriberOptions); } return new DeepgramRealtimeTranscriber({ ...shared, endpointingMs: - configuration.deepgramEndpointingMs ?? DEFAULT_DEEPGRAM_ENDPOINTING_MS, + configuration.deepgramEndpointingMs ?? RSVP_ENDPOINTING_SILENCE_MS, } satisfies DeepgramRealtimeTranscriberOptions); }; @@ -400,6 +402,7 @@ export class RsvpSpeechController { } this.capture = this.dependencies.createCapture(this.microphone); await this.capture.initialize(); + if (!this.closeRequested) this.capture.startInput(); }; const [microphoneResult, connectionResult] = await Promise.allSettled([ prepareMicrophone(), diff --git a/components/speech/audioCapture.ts b/components/speech/audioCapture.ts index 76312115..22191467 100644 --- a/components/speech/audioCapture.ts +++ b/components/speech/audioCapture.ts @@ -99,6 +99,7 @@ export class PcmMicrophoneCapture { private pendingSamples: number[] = []; private frameSource?: MicrophoneAudioFrameSource; private initializePromise?: Promise; + private inputStarted = false; private active = false; private closed = false; @@ -138,6 +139,19 @@ export class PcmMicrophoneCapture { return this.initializePromise; } + /** Starts local audio input without emitting PCM chunks until start(). */ + startInput(): void { + if (this.closed || !this.frameSource) { + throw new MicrophoneError( + this.closed ? "sessionClosed" : "invalidConfiguration", + "PCM microphone capture must be initialized before input starts.", + ); + } + if (this.inputStarted) return; + this.frameSource.start(); + this.inputStarted = true; + } + start(): void { if (this.closed || !this.frameSource) { throw new MicrophoneError( @@ -148,12 +162,15 @@ export class PcmMicrophoneCapture { this.pendingSamples = []; this.resampler?.reset(); this.active = true; - this.frameSource.start(); + this.startInput(); } stop(): void { + if (this.inputStarted) { + this.frameSource?.stop(); + this.inputStarted = false; + } if (!this.active) return; - this.frameSource?.stop(); this.active = false; this.emitPending(true); } diff --git a/preprocess/validateExperimentTable.ts b/preprocess/validateExperimentTable.ts index 813a6a59..76588fe0 100644 --- a/preprocess/validateExperimentTable.ts +++ b/preprocess/validateExperimentTable.ts @@ -1110,6 +1110,44 @@ const checkMutuallyExclusiveParameters = ( ]; }; +const checkRsvpSpeechResponseModes = (t: ExperimentTable): EasyEyesError[] => { + const off: number[] = []; + for (let ci = 0; ci < t.conditionCount; ci++) { + if (t.effectiveValue("targetKind", ci) !== "rsvpReading") continue; + if ( + t.params.includes("conditionEnabledBool") && + t.effectiveValue("conditionEnabledBool", ci).toLowerCase() !== "true" + ) + continue; + if ( + t.effectiveValue("responseSpokenBool", ci).toLowerCase() === "true" && + t.effectiveValue("responseSpokenToExperimenterBool", ci).toLowerCase() === + "true" + ) + off.push(ci); + } + return off.length + ? [ + makeError({ + name: "Incompatible RSVP speech response modes", + message: `When ${param("targetKind")} is "rsvpReading", ${param( + "responseSpokenBool", + )} (automatic speech recognition) and ${param( + "responseSpokenToExperimenterBool", + )} (human experimenter scoring) cannot both be TRUE in the same condition.`, + hint: `${columnsHint( + off, + )}. Set one of the two response parameters to FALSE.`, + parameters: [ + "responseSpokenBool", + "responseSpokenToExperimenterBool", + "targetKind", + ], + }), + ] + : []; +}; + const checkCrosshairTrackingValues = (t: ExperimentTable): EasyEyesError[] => { const e: EasyEyesError[] = []; const neg: [string, number][] = []; @@ -2212,6 +2250,7 @@ export const TABLE_CHECKS: ReadonlyArray = [ checkShuffleGroupsContiguous, checkShuffleGroupsSubsets, checkMutuallyExclusiveParameters, + checkRsvpSpeechResponseModes, checkCrosshairTrackingValues, checkFixationLocation, checkThresholdParameterForRsvpReading, diff --git a/tests/__snapshots__/validationGreenSnapshot.test.ts.snap b/tests/__snapshots__/validationGreenSnapshot.test.ts.snap index 3d93c91c..9832335c 100644 --- a/tests/__snapshots__/validationGreenSnapshot.test.ts.snap +++ b/tests/__snapshots__/validationGreenSnapshot.test.ts.snap @@ -1041,6 +1041,23 @@ exports[`Part B: per-check trigger scenarios rsvpMultiple — fires its check, o ] `; +exports[`Part B: per-check trigger scenarios rsvpSpeechResponseModes — fires its check, output unchanged 1`] = ` +[ + { + "context": "preprocessor", + "hint": "Check column C. Set one of the two response parameters to FALSE.", + "kind": "error", + "message": "When targetKind is "rsvpReading", responseSpokenBool (automatic speech recognition) and responseSpokenToExperimenterBool (human experimenter scoring) cannot both be TRUE in the same condition.", + "name": "Incompatible RSVP speech response modes", + "parameters": [ + "responseSpokenBool", + "responseSpokenToExperimenterBool", + "targetKind", + ], + }, +] +`; + exports[`Part B: per-check trigger scenarios rsvpThreshold — fires its check, output unchanged 1`] = ` [ { diff --git a/tests/audioCapture.test.ts b/tests/audioCapture.test.ts index 8bfa8d93..e211a0d3 100644 --- a/tests/audioCapture.test.ts +++ b/tests/audioCapture.test.ts @@ -86,6 +86,7 @@ describe("generic microphone PCM capture", () => { const chunks: Int16Array[] = []; capture.subscribe((chunk) => chunks.push(chunk.samples)); await capture.initialize(); + expect(microphone.startFrameSource).not.toHaveBeenCalled(); microphone.emit(new Float32Array(48).fill(0.5)); expect(chunks).toHaveLength(0); @@ -103,4 +104,65 @@ describe("generic microphone PCM capture", () => { await capture.close(); expect(microphone.closeFrameSource).toHaveBeenCalledTimes(1); }); + + it("starts local input before onset without buffering or emitting pre-onset frames", async () => { + const microphone = new FakeMicrophoneSession(); + const capture = new PcmMicrophoneCapture(microphone, { + outputChunkDurationMs: 1, + }); + const chunks: Int16Array[] = []; + capture.subscribe((chunk) => chunks.push(chunk.samples)); + await capture.initialize(); + + capture.startInput(); + capture.startInput(); + expect(microphone.startFrameSource).toHaveBeenCalledTimes(1); + microphone.emit(new Float32Array(48).fill(0.75)); + expect(chunks).toHaveLength(0); + + capture.start(); + expect(microphone.startFrameSource).toHaveBeenCalledTimes(1); + microphone.emit(new Float32Array(48).fill(0.25)); + expect(chunks).toHaveLength(1); + expect(Array.from(chunks[0])).toEqual(Array(16).fill(8192)); + await capture.close(); + }); + + it("closes input when preparation is cancelled before the task gate opens", async () => { + const microphone = new FakeMicrophoneSession(); + const capture = new PcmMicrophoneCapture(microphone); + const listener = jest.fn(); + capture.subscribe(listener); + await capture.initialize(); + capture.startInput(); + + await capture.close(); + await capture.close(); + microphone.emit(new Float32Array(48)); + + expect(microphone.stopFrameSource).toHaveBeenCalledTimes(1); + expect(microphone.closeFrameSource).toHaveBeenCalledTimes(1); + expect(listener).not.toHaveBeenCalled(); + expect(() => capture.startInput()).toThrow(); + }); + + it("can stop prepared input and restart it for a later capture", async () => { + const microphone = new FakeMicrophoneSession(); + const capture = new PcmMicrophoneCapture(microphone); + await capture.initialize(); + capture.startInput(); + capture.stop(); + capture.stop(); + expect(microphone.stopFrameSource).toHaveBeenCalledTimes(1); + + capture.start(); + expect(microphone.startFrameSource).toHaveBeenCalledTimes(2); + await capture.close(); + expect(microphone.stopFrameSource).toHaveBeenCalledTimes(2); + }); + + it("requires initialization before starting local input", () => { + const capture = new PcmMicrophoneCapture(new FakeMicrophoneSession()); + expect(() => capture.startInput()).toThrow(); + }); }); diff --git a/tests/rsvpSpeechCompiler.test.ts b/tests/rsvpSpeechCompiler.test.ts new file mode 100644 index 00000000..09373005 --- /dev/null +++ b/tests/rsvpSpeechCompiler.test.ts @@ -0,0 +1,164 @@ +import { ExperimentTable } from "../preprocess/experimentTable"; +import { validateExperimentTable } from "../preprocess/validateExperimentTable"; +import { loadGlossaryForTests } from "./helpers/glossary"; + +const ERROR_NAME = "Incompatible RSVP speech response modes"; + +const errorsFor = (parameters: Record) => { + const conditionCount = parameters.block?.length ?? 1; + const rows: Record = { + block: Array(conditionCount).fill("1"), + conditionName: Array.from({ length: conditionCount }, (_, i) => `C${i}`), + readingCorpus: Array(conditionCount).fill("words.txt"), + targetKind: Array(conditionCount).fill("rsvpReading"), + targetTask: Array(conditionCount).fill("identify"), + thresholdParameter: Array(conditionCount).fill("targetDurationSec"), + ...parameters, + }; + const table = new ExperimentTable([ + ["_about", "RSVP speech compiler test"], + ...Object.keys(rows) + .sort() + .map((name) => [name, "", ...rows[name]]), + ]); + return validateExperimentTable(table); +}; + +beforeAll(async () => { + await loadGlossaryForTests(); +}); + +describe("RSVP speech compiler validation", () => { + it("reports a blocking compiler error for both speech modes in one condition", () => { + expect( + errorsFor({ + responseSpokenBool: ["TRUE"], + responseSpokenToExperimenterBool: ["TRUE"], + }), + ).toEqual([ + { + name: ERROR_NAME, + kind: "error", + context: "preprocessor", + parameters: [ + "responseSpokenBool", + "responseSpokenToExperimenterBool", + "targetKind", + ], + message: expect.stringContaining("cannot both be TRUE"), + hint: "Check column C. Set one of the two response parameters to FALSE.", + }, + ]); + }); + + it.each([ + ["matrix", "FALSE", "FALSE"], + ["human experimenter", "FALSE", "TRUE"], + ["automatic speech", "TRUE", "FALSE"], + ])("preserves the %s configuration", (_, automatic, experimenter) => { + expect( + errorsFor({ + responseSpokenBool: [automatic], + responseSpokenToExperimenterBool: [experimenter], + }), + ).toEqual([]); + }); + + it.each([ + {}, + { responseSpokenBool: ["TRUE"] }, + { responseSpokenToExperimenterBool: ["TRUE"] }, + { responseSpokenBool: [""], responseSpokenToExperimenterBool: [""] }, + ])("uses existing defaults for omitted or empty cells: %j", (parameters) => { + expect(errorsFor(parameters)).toEqual([]); + }); + + it("does not combine speech flags from different conditions or blocks", () => { + expect( + errorsFor({ + block: ["1", "1", "2", "2"], + responseSpokenBool: ["TRUE", "FALSE", "FALSE", "TRUE"], + responseSpokenToExperimenterBool: ["FALSE", "TRUE", "FALSE", "FALSE"], + }), + ).toEqual([]); + }); + + it("reports only enabled RSVP conflicts in a mixed-task table", () => { + const errors = errorsFor({ + block: ["1", "2", "3", "3", "4"], + conditionEnabledBool: ["TRUE", "TRUE", "TRUE", "FALSE", "TRUE"], + responseSpokenBool: ["TRUE", "TRUE", "TRUE", "TRUE", "TRUE"], + responseSpokenToExperimenterBool: [ + "TRUE", + "TRUE", + "TRUE", + "TRUE", + "TRUE", + ], + targetKind: [ + "letter", + "reading", + "rsvpReading", + "rsvpReading", + "rsvpReading", + ], + }).filter((error) => error.name === ERROR_NAME); + expect(errors).toHaveLength(1); + expect(errors[0].hint).toBe( + "Check columns E and G. Set one of the two response parameters to FALSE.", + ); + }); + + it.each(["letter", "reading", "sound", "vernier"])( + "does not impose RSVP response restrictions on %s", + (targetKind) => { + const withBothModes = errorsFor({ + targetKind: [targetKind], + responseSpokenBool: ["TRUE"], + responseSpokenToExperimenterBool: ["TRUE"], + }); + const withOneMode = errorsFor({ + targetKind: [targetKind], + responseSpokenBool: ["TRUE"], + responseSpokenToExperimenterBool: ["FALSE"], + }); + expect(withBothModes).toEqual(withOneMode); + expect(withBothModes.some((error) => error.name === ERROR_NAME)).toBe( + false, + ); + }, + ); + + it("uses the existing disabled-condition exemption", () => { + expect( + errorsFor({ + conditionEnabledBool: ["FALSE"], + responseSpokenBool: ["TRUE"], + responseSpokenToExperimenterBool: ["TRUE"], + }), + ).toEqual([]); + }); + + it.each(["true", "TrUe", " TRUE "])( + "checks Boolean values accepted by the compiler: %s", + (value) => { + expect( + errorsFor({ + responseSpokenBool: [value], + responseSpokenToExperimenterBool: [value], + }).map((error) => error.name), + ).toEqual([ERROR_NAME]); + }, + ); + + it("leaves invalid Boolean values to the existing type check", () => { + const errors = errorsFor({ + responseSpokenBool: ["yes"], + responseSpokenToExperimenterBool: ["TRUE"], + }); + expect(errors.some((error) => error.name === ERROR_NAME)).toBe(false); + expect( + errors.some((error) => error.parameters.includes("responseSpokenBool")), + ).toBe(true); + }); +}); diff --git a/tests/rsvpSpeechController.test.ts b/tests/rsvpSpeechController.test.ts index 6b052bf3..d6b70f43 100644 --- a/tests/rsvpSpeechController.test.ts +++ b/tests/rsvpSpeechController.test.ts @@ -1,4 +1,6 @@ import type { MicrophoneSession } from "../components/speech/microphone"; +import * as elevenLabsAdapter from "../components/speech/elevenLabsRealtimeTranscriber"; +import * as deepgramAdapter from "../components/speech/deepgramRealtimeTranscriber"; import { DeepgramRealtimeTranscriber } from "../components/speech/deepgramRealtimeTranscriber"; import { ElevenLabsRealtimeTranscriber } from "../components/speech/elevenLabsRealtimeTranscriber"; import type { @@ -57,6 +59,7 @@ const makeConfiguration = ( class FakeCapture implements RsvpSpeechCapturePort { readonly initialize = jest.fn(async () => undefined); + readonly startInput = jest.fn(() => undefined); readonly start = jest.fn(() => undefined); readonly stop = jest.fn(() => undefined); readonly close = jest.fn(async () => undefined); @@ -170,7 +173,9 @@ describe("RsvpSpeechController", () => { expect(controller.state).toBe("ready"); expect(harness.capture.initialize).toHaveBeenCalledTimes(1); + expect(harness.capture.startInput).toHaveBeenCalledTimes(1); expect(harness.capture.start).not.toHaveBeenCalled(); + expect(harness.session.events).not.toContain("session.begin:1_1-1"); expect(harness.session.connect).toHaveBeenCalledTimes(1); }); @@ -189,6 +194,7 @@ describe("RsvpSpeechController", () => { await Promise.resolve(); expect(harness.capture.initialize).toHaveBeenCalledTimes(1); + expect(harness.capture.startInput).toHaveBeenCalledTimes(1); expect(controller.state).toBe("preparing"); connection.resolve({}); await preparation; @@ -347,6 +353,134 @@ describe("RsvpSpeechController", () => { expect(harness.session.cancelUtterance).toHaveBeenCalledTimes(1); }); + it("configures 1.5 seconds of silence for the RSVP ElevenLabs connection", async () => { + const Adapter = ElevenLabsRealtimeTranscriber; + const constructor = jest + .spyOn(elevenLabsAdapter, "ElevenLabsRealtimeTranscriber") + .mockImplementation((options) => new Adapter(options)); + const harness = makeHarness(); + const controller = new RsvpSpeechController(makeConfiguration(), { + ...harness.dependencies, + createTranscriber: undefined, + }); + try { + await controller.prepare(); + const options = constructor.mock.calls[0][0]; + const url = new URL( + elevenLabsAdapter.buildElevenLabsRealtimeUrl("test-token", options), + ); + expect(url.searchParams.get("commit_strategy")).toBe("vad"); + expect(url.searchParams.get("vad_silence_threshold_secs")).toBe("1.5"); + expect( + elevenLabsAdapter.DEFAULT_ELEVENLABS_VAD_CONFIG.silenceThresholdSecs, + ).toBe(0.5); + } finally { + constructor.mockRestore(); + await controller.close(); + } + }); + + it.each([ + [undefined, "1500"], + [700, "700"], + ])( + "configures the RSVP Deepgram silence threshold (%s)", + async (override, expected) => { + const Adapter = DeepgramRealtimeTranscriber; + const constructor = jest + .spyOn(deepgramAdapter, "DeepgramRealtimeTranscriber") + .mockImplementation((options) => new Adapter(options)); + const harness = makeHarness(); + const controller = new RsvpSpeechController( + makeConfiguration({ + provider: "deepgram", + deepgramEndpointingMs: override, + }), + { ...harness.dependencies, createTranscriber: undefined }, + ); + try { + await controller.prepare(); + const url = new URL( + deepgramAdapter.buildDeepgramRealtimeUrl( + constructor.mock.calls[0][0], + ), + ); + expect(url.searchParams.get("endpointing")).toBe(expected); + } finally { + constructor.mockRestore(); + await controller.close(); + } + }, + ); + + it("does not start the response during preparation or while input is already running", async () => { + jest.useFakeTimers(); + const harness = makeHarness(); + const controller = new RsvpSpeechController( + makeConfiguration({ maximumResponseDurationMs: 2000 }), + harness.dependencies, + ); + try { + await controller.prepare(); + jest.advanceTimersByTime(10_000); + expect(controller.state).toBe("ready"); + expect(harness.capture.startInput).toHaveBeenCalledTimes(1); + expect(harness.capture.start).not.toHaveBeenCalled(); + expect(harness.session.events).not.toContain("session.begin:1_1-1"); + expect(harness.session.pushAudio).not.toHaveBeenCalled(); + + controller.startCapture(); + expect(harness.session.events).toContain("session.begin:1_1-1"); + expect(harness.capture.startInput).toHaveBeenCalledTimes(1); + harness.session.complete(); + await controller.waitForResult(); + } finally { + await controller.close(); + jest.useRealTimers(); + } + }); + + it("handles an input-start failure before exposing a stimulus", async () => { + const capture = new FakeCapture(); + capture.startInput.mockImplementationOnce(() => { + throw new Error("input failed"); + }); + const harness = makeHarness({ capture }); + const controller = new RsvpSpeechController( + makeConfiguration(), + harness.dependencies, + ); + + await expect(controller.prepare()).rejects.toMatchObject({ + code: "preparationFailed", + }); + expect(capture.start).not.toHaveBeenCalled(); + expect(capture.close).toHaveBeenCalledTimes(1); + expect(harness.microphone.close).toHaveBeenCalledTimes(1); + expect(harness.session.events).not.toContain("session.begin:1_1-1"); + }); + + it("does not start input if cancelled while capture initialization is pending", async () => { + const initialization = deferred(); + const capture = new FakeCapture(); + capture.initialize.mockImplementationOnce(() => initialization.promise); + const harness = makeHarness({ capture }); + const controller = new RsvpSpeechController( + makeConfiguration(), + harness.dependencies, + ); + const preparation = controller.prepare(); + await Promise.resolve(); + expect(capture.initialize).toHaveBeenCalledTimes(1); + const closing = controller.close(); + initialization.resolve(); + + await expect(preparation).rejects.toMatchObject({ code: "closed" }); + await closing; + expect(capture.startInput).not.toHaveBeenCalled(); + expect(capture.close).toHaveBeenCalledTimes(1); + }); + it("reports provider failure separately from a participant response", async () => { const harness = makeHarness(); const controller = new RsvpSpeechController( diff --git a/tests/speechSession.test.ts b/tests/speechSession.test.ts index c0685184..493b2cff 100644 --- a/tests/speechSession.test.ts +++ b/tests/speechSession.test.ts @@ -182,6 +182,46 @@ describe("SpeechSession", () => { expect(transcriber.endUtterance).toHaveBeenCalledWith("trial-1"); }); + it("does not stop input or start a final-result timeout when presentation ends", async () => { + jest.useFakeTimers(); + const transcriber = new FakeTranscriber(); + const session = new SpeechSession(transcriber, { + maximumUtteranceDurationMs: 12_000, + finalizationTimeoutMs: 5_000, + }); + await session.connect(); + const resultPromise = session.beginUtterance("trial-1"); + + jest.advanceTimersByTime(1500); + transcriber.emit({ + type: "commit", + utteranceId: "trial-1", + text: "cat", + receivedAtMs: 1500, + }); + expect(session.state).toBe("listening"); + expect(transcriber.endUtterance).not.toHaveBeenCalled(); + + session.allowProviderFinalization(); + jest.advanceTimersByTime(5000); + expect(session.state).toBe("listening"); + expect(session.pushAudio(audioChunk)).toBe(true); + expect(transcriber.requestCommit).not.toHaveBeenCalled(); + + transcriber.emit({ + type: "commit", + utteranceId: "trial-1", + text: "dog fish", + receivedAtMs: 6500, + }); + await expect(resultPromise).resolves.toMatchObject({ + text: "cat dog fish", + finalizationTrigger: "providerVad", + }); + expect(session.pushAudio(audioChunk)).toBe(false); + await session.close(); + }); + it("starts each sequential utterance with an empty transcript buffer", async () => { const transcriber = new FakeTranscriber(); const session = new SpeechSession(transcriber, { diff --git a/tests/validationGreenSnapshot.test.ts b/tests/validationGreenSnapshot.test.ts index 98549d21..47183d4c 100644 --- a/tests/validationGreenSnapshot.test.ts +++ b/tests/validationGreenSnapshot.test.ts @@ -186,6 +186,17 @@ responseMustClickCrosshairBool,,TRUE responseMustTrackContinuouslyBool,,TRUE`, expectName: /mutually exclusive/i, }, + { + label: "rsvpSpeechResponseModes", + csv: `${BASE} +readingCorpus,,words.txt +responseSpokenBool,,TRUE +responseSpokenToExperimenterBool,,TRUE +targetKind,,rsvpReading +targetTask,,identify +thresholdParameter,,targetDurationSec`, + expectName: /Incompatible RSVP speech response modes/i, + }, { label: "crosshair: negative stroke thickening", csv: `${BASE}