evOo-local/packages/core/src/streaming-voice.test.ts
cynarAI ca869561ac
Voice-first streaming, non-blocking follow-ups and chat UX (#205)
* media: check ffmpeg filter support before subtitle operations

* sandbox: handle synchronous spawn errors safely in desktop sandbox

* Voice-first streaming, non-blocking follow-ups, quiet routines and chat UX

Why: questions blocked the chat, follow-up messages had no acknowledgement, message actions sat beside the bubble, and voice turn-taking added latency.

What: waiting runs accept steering messages and the bot marks each claimed follow-up as seen; unclaimed follow-ups show as queued. Reaction, copy and reply controls sit under each message. Streaming voice (VAD endpointing, barge-in that stops the run, clause-based TTS), an optimistic input queue, procedural earcons and delta-based quiet routines were added. Onboarding can be skipped straight to the main conversation. Dual-surface layout and project pickers are included. Status colors use semantic tokens.

Tested: unit suites for core, adapters, api and web pass, typecheck and Biome are clean. Browser end-to-end tests were not run locally.

* Update translation catalogs

Re-extracted catalogs for the new and moved strings; the catalog sync test passes.

* Add project plan and test readiness notes

Notes from the milestone work: feature inventory, test infrastructure and test readiness. No code changes.

* fix(ci): align scale guards, space deletion, computer toggle, and mobile expo deps

* fix(ci): restore space deletion guard, browser toggle, and run-steps contracts

* fix(ci): restore model resolution errors, group stage, and onboarding flow

* fix(ci): restore in-app-browser close button, disable stop button while sending, and sync lingui catalogs

* fix(ci): address web e2e failures for bot-crud, choice cards, message quote, and voice onboarding

* test(web): wait for avatar and rig visibility in oo-avatar motion spec

* test(web): wait for persisted message IDs in message-hover-actions pagination spec

* Onboarding: always drop the local voice when the voice step is skipped

Skipping waited only for the latest voice load and disconnected only
when the client saw the connect succeed. An earlier or client-failed
connect could still persist the local credential, so a user who
skipped voice was left with one. Wait for every load and always
disconnect.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

* Shell: hide the optimistic copy once the transcript shows the send

A message sent while the bot works streamed into the transcript before
its send request resolved, so the same text showed twice until the
optimistic bubble was removed. Drop an optimistic message as soon as a
persisted user message with the same text appears.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wy1md9QE1ayJbDCwJ4vAQs

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-15 14:08:31 +02:00

223 lines
7.8 KiB
TypeScript

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([]);
});
});