Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions components/rsvpSpeech/rsvpSpeechController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ export interface RsvpSpeechTrialConfiguration {

export interface RsvpSpeechCapturePort {
initialize(): Promise<void>;
startInput(): void;
start(): void;
stop(): void;
subscribe(listener: (chunk: PcmAudioChunk) => void): () => void;
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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);
};

Expand Down Expand Up @@ -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(),
Expand Down
21 changes: 19 additions & 2 deletions components/speech/audioCapture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export class PcmMicrophoneCapture {
private pendingSamples: number[] = [];
private frameSource?: MicrophoneAudioFrameSource;
private initializePromise?: Promise<void>;
private inputStarted = false;
private active = false;
private closed = false;

Expand Down Expand Up @@ -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(
Expand All @@ -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);
}
Expand Down
39 changes: 39 additions & 0 deletions preprocess/validateExperimentTable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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][] = [];
Expand Down Expand Up @@ -2212,6 +2250,7 @@ export const TABLE_CHECKS: ReadonlyArray<TableCheck> = [
checkShuffleGroupsContiguous,
checkShuffleGroupsSubsets,
checkMutuallyExclusiveParameters,
checkRsvpSpeechResponseModes,
checkCrosshairTrackingValues,
checkFixationLocation,
checkThresholdParameterForRsvpReading,
Expand Down
17 changes: 17 additions & 0 deletions tests/__snapshots__/validationGreenSnapshot.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -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 <span class="error-parameter">targetKind</span> is "rsvpReading", <span class="error-parameter">responseSpokenBool</span> (automatic speech recognition) and <span class="error-parameter">responseSpokenToExperimenterBool</span> (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`] = `
[
{
Expand Down
62 changes: 62 additions & 0 deletions tests/audioCapture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
});
});
Loading