import { describe, expect, it } from "vitest"; import type { StreamingVoiceFrame } from "./streaming-voice.js"; import { ClauseSegmenter, calculateRms, DEFAULT_ENDPOINT_SILENCE_MS, DEFAULT_MIN_SPEECH_FRAMES, DEFAULT_SAMPLE_RATE, DEFAULT_SILENCE_RMS, DEFAULT_VAD_TICK_MS, float32ToInt16, int16ToFloat32, StreamingVad, } from "./streaming-voice.js"; describe("streaming-voice audio conversions and math", () => { it("converts Float32 to Int16 and back accurately", () => { const input = new Float32Array([-1.0, -0.5, 0, 0.5, 1.0]); const int16 = float32ToInt16(input); expect(int16[0]).toBe(-32768); expect(int16[2]).toBe(0); expect(int16[4]).toBe(32767); const backToFloat = int16ToFloat32(int16); expect(backToFloat[0]).toBeCloseTo(-1.0, 3); expect(backToFloat[1]).toBeCloseTo(-0.5, 3); expect(backToFloat[2]).toBeCloseTo(0.0, 3); expect(backToFloat[3]).toBeCloseTo(0.5, 3); expect(backToFloat[4]).toBeCloseTo(1.0, 3); }); it("clamps Float32 outside [-1, 1]", () => { const input = new Float32Array([-2.5, 2.5]); const int16 = float32ToInt16(input); expect(int16[0]).toBe(-32768); expect(int16[1]).toBe(32767); }); it("calculates RMS correctly", () => { expect(calculateRms(new Int16Array([]))).toBe(0); const silence = new Int16Array(160).fill(0); expect(calculateRms(silence)).toBe(0); const loud = new Int16Array(100).fill(16384); // ~0.5 amplitude expect(calculateRms(loud)).toBeCloseTo(0.5, 2); }); }); describe("StreamingVad", () => { function makeFrame(amplitude: number, durationMs = DEFAULT_VAD_TICK_MS): StreamingVoiceFrame { const samples = Math.floor((DEFAULT_SAMPLE_RATE * durationMs) / 1000); const pcm16 = new Int16Array(samples).fill(Math.round(amplitude * 32767)); return { pcm16, sampleRate: DEFAULT_SAMPLE_RATE, timestamp: Date.now(), }; } it("exports correct default constants", () => { expect(DEFAULT_ENDPOINT_SILENCE_MS).toBe(120); expect(DEFAULT_VAD_TICK_MS).toBe(20); expect(DEFAULT_SILENCE_RMS).toBe(0.035); expect(DEFAULT_SAMPLE_RATE).toBe(16000); expect(DEFAULT_MIN_SPEECH_FRAMES).toBe(3); }); it("suppresses single-frame clicks/pops without triggering speech or endpointing", () => { const vad = new StreamingVad(); // 1 loud frame (click / acoustic pop) const popDecision = vad.processFrame(makeFrame(0.2)); expect(popDecision.isSpeech).toBe(false); expect(popDecision.endpointReached).toBe(false); // Followed by silence frames for (let i = 0; i < 10; i += 1) { const silenceDecision = vad.processFrame(makeFrame(0.001)); expect(silenceDecision.isSpeech).toBe(false); // Because speech was never confirmed (needed minSpeechFrames = 3), endpoint never fires expect(silenceDecision.endpointReached).toBe(false); } }); it("detects sustained speech and triggers endpoint precisely at 120ms (6 ticks)", () => { const vad = new StreamingVad(); // Frame 1: speech energy, but not yet 3 frames const f1 = vad.processFrame(makeFrame(0.1)); expect(f1.isSpeech).toBe(false); expect(f1.endpointReached).toBe(false); // Frame 2: speech energy const f2 = vad.processFrame(makeFrame(0.1)); expect(f2.isSpeech).toBe(false); expect(f2.endpointReached).toBe(false); // Frame 3: confirmed speech! const f3 = vad.processFrame(makeFrame(0.1)); expect(f3.isSpeech).toBe(true); expect(f3.endpointReached).toBe(false); // Frame 4: continued speech const f4 = vad.processFrame(makeFrame(0.12)); expect(f4.isSpeech).toBe(true); expect(f4.endpointReached).toBe(false); // Now silence begins (each frame is 20ms) // Frame 1 of silence: 20ms expect(vad.processFrame(makeFrame(0.005)).endpointReached).toBe(false); // Frame 2 of silence: 40ms expect(vad.processFrame(makeFrame(0.005)).endpointReached).toBe(false); // Frame 3 of silence: 60ms expect(vad.processFrame(makeFrame(0.005)).endpointReached).toBe(false); // Frame 4 of silence: 80ms expect(vad.processFrame(makeFrame(0.005)).endpointReached).toBe(false); // Frame 5 of silence: 100ms expect(vad.processFrame(makeFrame(0.005)).endpointReached).toBe(false); // Frame 6 of silence: 120ms -> Endpoint reaches exactly 120ms! const endpointDecision = vad.processFrame(makeFrame(0.005)); expect(endpointDecision.endpointReached).toBe(true); expect(endpointDecision.consecutiveSilenceMs).toBe(120); // Subsequent silence does not re-fire endpoint const postEndpoint = vad.processFrame(makeFrame(0.005)); expect(postEndpoint.endpointReached).toBe(false); }); it("resets state completely on reset()", () => { const vad = new StreamingVad(); for (let i = 0; i < 5; i += 1) { vad.processFrame(makeFrame(0.1)); } vad.reset(); // After reset, single silence frame has 0 silence duration const decision = vad.processFrame(makeFrame(0.001)); expect(decision.consecutiveSilenceMs).toBe(0); expect(decision.endpointReached).toBe(false); }); }); describe("ClauseSegmenter", () => { it("splits natural clauses on punctuation with minWords threshold", () => { const segmenter = new ClauseSegmenter({ minWords: 4, maxWords: 8 }); // Stream tokens const c1 = segmenter.push("Hello there, "); // "Hello there," has only 2 words, which is < minWords (4), so no split yet expect(c1).toEqual([]); const c2 = segmenter.push("I have checked your project, "); // Now we have "Hello there, I have checked your project," (7 words) ending in comma! expect(c2.length).toBeGreaterThanOrEqual(1); expect(c2[0]).toContain("Hello there"); const c3 = segmenter.push("and everything looks ready to go. "); expect(c3.length).toBeGreaterThanOrEqual(1); expect(c3[0]).toContain("everything looks ready"); }); it("protects abbreviations like Dr. and e.g. from premature splitting", () => { const segmenter = new ClauseSegmenter({ minWords: 3 }); const c1 = segmenter.push("Dr. "); expect(c1).toEqual([]); const c2 = segmenter.push("Smith arrived at the lab. "); expect(c2).toEqual(["Dr. Smith arrived at the lab."]); }); it("protects decimal numbers from premature splitting", () => { const segmenter = new ClauseSegmenter({ minWords: 3 }); const c1 = segmenter.push("The version is 3.14 and "); expect(c1).toEqual([]); const c2 = segmenter.push("it is stable now. "); expect(c2).toEqual(["The version is 3.14 and it is stable now."]); }); it("splits long sentences exceeding maxWords even without punctuation", () => { const segmenter = new ClauseSegmenter({ minWords: 3, maxWords: 6 }); // 8 words without punctuation const clauses = segmenter.push("one two three four five six seven eight "); expect(clauses.length).toBeGreaterThanOrEqual(1); const remaining = segmenter.flush(); expect(remaining.length).toBeGreaterThanOrEqual(1); }); it("strips markdown code fences using speakable", () => { const segmenter = new ClauseSegmenter(); const clauses = [ ...segmenter.push("Here is the solution: ```ts\nconsole.log(42);\n```\n It is tested. "), ...segmenter.flush(), ]; expect(clauses.some((c) => c.includes("code block") || c.includes("solution"))).toBe(true); expect(clauses.some((c) => c.includes("console.log"))).toBe(false); }); it("flushes remaining buffered text on stream completion", () => { const segmenter = new ClauseSegmenter({ minWords: 4 }); const c1 = segmenter.push("Just a short phrase"); expect(c1).toEqual([]); const flushed = segmenter.flush(); expect(flushed).toEqual(["Just a short phrase"]); }); it("handles reset properly", () => { const segmenter = new ClauseSegmenter(); segmenter.push("Unfinished text here"); segmenter.reset(); expect(segmenter.flush()).toEqual([]); }); });