273 lines
8.7 KiB
TypeScript
273 lines
8.7 KiB
TypeScript
import { describe, expect, it, vi } from "vitest";
|
|
|
|
import { SpeechSegmenter } from "./speech-segmenter";
|
|
|
|
const mkPcmBytes = (length: number): Uint8Array => {
|
|
const bytes = new Uint8Array(length);
|
|
for (let i = 0; i < length; i++) bytes[i] = i % 255;
|
|
return bytes;
|
|
};
|
|
|
|
describe("SpeechSegmenter", () => {
|
|
it("streams continuously and flushes at stop", () => {
|
|
const onAudioSegment = vi.fn();
|
|
const segmenter = new SpeechSegmenter(
|
|
{
|
|
enableContinuousStreaming: true,
|
|
volumeThreshold: 0.3,
|
|
silenceDurationMs: 2000,
|
|
speechConfirmationMs: 300,
|
|
detectionGracePeriodMs: 200,
|
|
minChunkDurationMs: 100, // ensure we only flush on stop
|
|
pcmSampleRate: 1000,
|
|
},
|
|
{ onAudioSegment }
|
|
);
|
|
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
expect(onAudioSegment).not.toHaveBeenCalled();
|
|
|
|
segmenter.stop(Date.now());
|
|
|
|
expect(onAudioSegment).toHaveBeenCalledTimes(1);
|
|
const lastCall = onAudioSegment.mock.calls.at(-1)![0];
|
|
expect(lastCall.isLast).toBe(true);
|
|
expect(lastCall.audioData.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("detects speech, calls onSpeechStart, and flushes on speech end", () => {
|
|
const onAudioSegment = vi.fn();
|
|
const onSpeechStart = vi.fn();
|
|
const onSpeechEnd = vi.fn();
|
|
const detectingChanges: boolean[] = [];
|
|
const speakingChanges: boolean[] = [];
|
|
|
|
const segmenter = new SpeechSegmenter(
|
|
{
|
|
enableContinuousStreaming: false,
|
|
volumeThreshold: 0.3,
|
|
silenceDurationMs: 2000,
|
|
speechConfirmationMs: 300,
|
|
detectionGracePeriodMs: 200,
|
|
minChunkDurationMs: 100,
|
|
pcmSampleRate: 1000,
|
|
},
|
|
{
|
|
onAudioSegment,
|
|
onSpeechStart,
|
|
onSpeechEnd,
|
|
onDetectingChange: (v) => detectingChanges.push(v),
|
|
onSpeakingChange: (v) => speakingChanges.push(v),
|
|
}
|
|
);
|
|
|
|
const t0 = 10_000;
|
|
|
|
// Start detection.
|
|
segmenter.pushVolumeLevel(0.5, t0);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
expect(detectingChanges).toEqual([true]);
|
|
expect(speakingChanges).toEqual([true]);
|
|
expect(onSpeechStart).not.toHaveBeenCalled();
|
|
|
|
// Confirm speech.
|
|
segmenter.pushVolumeLevel(0.5, t0 + 300);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
|
expect(detectingChanges).toEqual([true, false]);
|
|
expect(speakingChanges).toEqual([true]);
|
|
|
|
// End speech after silence.
|
|
segmenter.pushVolumeLevel(0.0, t0 + 301);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
segmenter.pushVolumeLevel(0.0, t0 + 301 + 2000);
|
|
segmenter.pushVolumeLevel(0.0, t0 + 301 + 2000 + 2000);
|
|
|
|
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
|
expect(detectingChanges).toEqual([true, false, true, false]);
|
|
expect(speakingChanges).toEqual([true, false]);
|
|
expect(onAudioSegment).toHaveBeenCalled();
|
|
const lastCall = onAudioSegment.mock.calls.at(-1)![0];
|
|
expect(lastCall.isLast).toBe(true);
|
|
expect(lastCall.audioData.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("keeps detection alive across a brief pause and confirms short utterances", () => {
|
|
const onSpeechStart = vi.fn();
|
|
const detectingChanges: boolean[] = [];
|
|
|
|
const segmenter = new SpeechSegmenter(
|
|
{
|
|
enableContinuousStreaming: false,
|
|
volumeThreshold: 0.18,
|
|
silenceDurationMs: 1400,
|
|
speechConfirmationMs: 120,
|
|
detectionGracePeriodMs: 700,
|
|
minChunkDurationMs: 100,
|
|
pcmSampleRate: 1000,
|
|
},
|
|
{
|
|
onSpeechStart,
|
|
onDetectingChange: (v) => detectingChanges.push(v),
|
|
}
|
|
);
|
|
|
|
const t0 = 20_000;
|
|
|
|
segmenter.pushVolumeLevel(0.4, t0);
|
|
segmenter.pushPcmChunk(mkPcmBytes(20));
|
|
segmenter.pushVolumeLevel(0.0, t0 + 80);
|
|
segmenter.pushPcmChunk(mkPcmBytes(20));
|
|
segmenter.pushVolumeLevel(0.4, t0 + 140);
|
|
segmenter.pushPcmChunk(mkPcmBytes(20));
|
|
|
|
expect(detectingChanges).toContain(true);
|
|
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps utterance open after fade-out and resumes speaking during silence grace", () => {
|
|
const onAudioSegment = vi.fn();
|
|
const onSpeechStart = vi.fn();
|
|
const onSpeechEnd = vi.fn();
|
|
const speakingChanges: boolean[] = [];
|
|
|
|
const segmenter = new SpeechSegmenter(
|
|
{
|
|
enableContinuousStreaming: false,
|
|
volumeThreshold: 0.2,
|
|
silenceDurationMs: 1500,
|
|
speechConfirmationMs: 120,
|
|
detectionGracePeriodMs: 400,
|
|
minChunkDurationMs: 1000, // keep everything buffered until final flush
|
|
pcmSampleRate: 1000,
|
|
},
|
|
{
|
|
onAudioSegment,
|
|
onSpeechStart,
|
|
onSpeechEnd,
|
|
onSpeakingChange: (v) => speakingChanges.push(v),
|
|
}
|
|
);
|
|
|
|
const t0 = 30_000;
|
|
|
|
// Start + confirm speaking.
|
|
segmenter.pushVolumeLevel(0.6, t0);
|
|
segmenter.pushPcmChunk(mkPcmBytes(200));
|
|
segmenter.pushVolumeLevel(0.6, t0 + 130);
|
|
segmenter.pushPcmChunk(mkPcmBytes(200));
|
|
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
|
|
|
// Drop below threshold: meter should fade, but turn remains open.
|
|
segmenter.pushVolumeLevel(0.0, t0 + 200);
|
|
segmenter.pushPcmChunk(mkPcmBytes(200));
|
|
segmenter.pushVolumeLevel(0.0, t0 + 500);
|
|
segmenter.pushPcmChunk(mkPcmBytes(200));
|
|
|
|
// Speak again before silence timeout expires: continue same utterance.
|
|
segmenter.pushVolumeLevel(0.6, t0 + 900);
|
|
segmenter.pushPcmChunk(mkPcmBytes(200));
|
|
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
|
expect(onSpeechEnd).not.toHaveBeenCalled();
|
|
|
|
// End for real.
|
|
segmenter.pushVolumeLevel(0.0, t0 + 1000);
|
|
segmenter.pushPcmChunk(mkPcmBytes(200));
|
|
segmenter.pushVolumeLevel(0.0, t0 + 2600);
|
|
segmenter.pushVolumeLevel(0.0, t0 + 4200);
|
|
|
|
expect(speakingChanges).toEqual([true, false, true, false]);
|
|
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
|
const finalCalls = onAudioSegment.mock.calls.filter((call) => call[0].isLast);
|
|
expect(finalCalls).toHaveLength(1);
|
|
});
|
|
|
|
it("keeps confirmed speech active above release threshold even when below start threshold", () => {
|
|
const onSpeechStart = vi.fn();
|
|
const onSpeechEnd = vi.fn();
|
|
const speakingChanges: boolean[] = [];
|
|
|
|
const segmenter = new SpeechSegmenter(
|
|
{
|
|
enableContinuousStreaming: false,
|
|
volumeThreshold: 0.3,
|
|
volumeReleaseThreshold: 0.1,
|
|
silenceDurationMs: 1200,
|
|
speechConfirmationMs: 120,
|
|
detectionGracePeriodMs: 200,
|
|
minChunkDurationMs: 1000,
|
|
pcmSampleRate: 1000,
|
|
},
|
|
{
|
|
onSpeechStart,
|
|
onSpeechEnd,
|
|
onSpeakingChange: (v) => speakingChanges.push(v),
|
|
}
|
|
);
|
|
|
|
const t0 = 40_000;
|
|
|
|
// Confirm speech.
|
|
segmenter.pushVolumeLevel(0.4, t0);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
segmenter.pushVolumeLevel(0.4, t0 + 130);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
expect(onSpeechStart).toHaveBeenCalledTimes(1);
|
|
|
|
// Stay between start and release thresholds for longer than silence timeout:
|
|
// should still be considered speaking.
|
|
segmenter.pushVolumeLevel(0.15, t0 + 400);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
segmenter.pushVolumeLevel(0.15, t0 + 1000);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
segmenter.pushVolumeLevel(0.15, t0 + 2000);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
|
|
expect(onSpeechEnd).not.toHaveBeenCalled();
|
|
expect(speakingChanges.at(-1)).toBe(true);
|
|
|
|
// Now actually drop below release threshold and allow silence timeout.
|
|
segmenter.pushVolumeLevel(0.0, t0 + 2001);
|
|
segmenter.pushPcmChunk(mkPcmBytes(50));
|
|
segmenter.pushVolumeLevel(0.0, t0 + 3405);
|
|
segmenter.pushVolumeLevel(0.0, t0 + 4706);
|
|
|
|
expect(onSpeechEnd).toHaveBeenCalledTimes(1);
|
|
expect(speakingChanges.at(-1)).toBe(false);
|
|
});
|
|
|
|
it("ignores brief confirmed dips below release threshold without flip-flopping speaking", () => {
|
|
const speakingChanges: boolean[] = [];
|
|
|
|
const segmenter = new SpeechSegmenter(
|
|
{
|
|
enableContinuousStreaming: false,
|
|
volumeThreshold: 0.2,
|
|
volumeReleaseThreshold: 0.08,
|
|
confirmedDropGracePeriodMs: 250,
|
|
silenceDurationMs: 1500,
|
|
speechConfirmationMs: 120,
|
|
detectionGracePeriodMs: 200,
|
|
minChunkDurationMs: 1000,
|
|
pcmSampleRate: 1000,
|
|
},
|
|
{
|
|
onSpeakingChange: (v) => speakingChanges.push(v),
|
|
}
|
|
);
|
|
|
|
const t0 = 50_000;
|
|
segmenter.pushVolumeLevel(0.4, t0);
|
|
segmenter.pushVolumeLevel(0.4, t0 + 130); // confirm speaking
|
|
|
|
// Two short dips below release threshold; both shorter than debounce.
|
|
segmenter.pushVolumeLevel(0.05, t0 + 300);
|
|
segmenter.pushVolumeLevel(0.09, t0 + 500);
|
|
segmenter.pushVolumeLevel(0.05, t0 + 620);
|
|
segmenter.pushVolumeLevel(0.1, t0 + 760);
|
|
|
|
expect(speakingChanges).toEqual([true]);
|
|
});
|
|
});
|