From 7af71bc391af12c044902c13de19fa490baf1dbb Mon Sep 17 00:00:00 2001 From: thedancingdeveloper <306930456+thedancingdeveloper@users.noreply.github.com> Date: Thu, 10 Sep 2026 01:47:52 +0000 Subject: [PATCH] feat(assistant): hands-free barge-in (WI-174 v2, opt-in) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets the speaker talk over a playing reply: with interrupt_response on, the device stops speaking and listens the moment you start. - voiceTurn: `speechDetected()` — halts the reply and re-opens the mic, gated on interrupt_response and an actually-playing reply, so a stray onset outside playback cannot disturb the loop; the halted reply's own speechFinished is then ignored. Unit tests for off (default), on, and outside-playback. - voiceVad: a pure `OnsetDetector` (leaky accumulator over frame RMS — a sustained ~500 ms onset fires once, a click or the AEC residual does not) + `frameRms`, unit-tested off frame energies; a thin `startBargeInDetection` Web Audio shell (echo-cancelled getUserMedia → AnalyserNode, 50 ms frames) that a WASM Silero VAD can replace behind the same seam later. - Assistant.tsx: runs the echo-cancelled capture only during `speaking` (a createEffect on voiceState), torn down the moment playback ends — the second mic is never held longer than the reply it listens over. Off by default (imperfect WebView AEC can false-trigger); config vogt.assistant.voice. {interrupt_response,vad_threshold,vad_onset_ms}. Tests: full web unit suite green (931); a real-browser Playwright round trip proves the halt + reopen. Docs: ENGINE.md §6 + USER_GUIDE §2.5. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01BojRRh8yXBBqwemk6WkqVo --- docs/ENGINE.md | 22 ++-- docs/USER_GUIDE.md | 10 +- web/src/Assistant.tsx | 21 +++- web/src/__tests__/voiceTurn.test.ts | 40 +++++++ web/src/__tests__/voiceVad.test.ts | 97 ++++++++++++++++ web/src/voiceTurn.ts | 14 +++ web/src/voiceVad.ts | 174 ++++++++++++++++++++++++++++ web/tests/browser/gui.spec.ts | 110 ++++++++++++++++++ 8 files changed, 477 insertions(+), 11 deletions(-) create mode 100644 web/src/__tests__/voiceVad.test.ts create mode 100644 web/src/voiceVad.ts diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 382e8de3..854381c0 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -1802,16 +1802,24 @@ become instructions. `idle → arming → listening → endpointing → sending → speaking → listening`, plus `paused_for_approval` (a pending write is announced, the mic closed until the on-screen approve/deny — voice still never approves) and a `muted` - flag that keeps the session alive. v1 is half-duplex (mic closed while a - reply plays; barge-in is v2). Turn detection is client-owned on every - backend, tuned by localStorage in the OpenAI Realtime vocabulary so a - Realtime-shaped backend adopts it unrenamed: - `vogt.assistant.voice.silence_duration_ms` (1000), + flag that keeps the session alive. Default is half-duplex (mic closed while a + reply plays). Turn detection is client-owned on every backend, tuned by + localStorage in the OpenAI Realtime vocabulary so a Realtime-shaped backend + adopts it unrenamed: `vogt.assistant.voice.silence_duration_ms` (1000), `final_result_grace_ms` (300), `max_turn_ms` (30000), `idle_timeout_ms` (60000), `max_empty_turns` (3), - `interrupt_response` (false in v1). Requires an event-driven recognizer + `interrupt_response` (false). Requires an event-driven recognizer (native plugin or Web Speech) and a TTS path; the server-STT path is excluded - in v1 and the control is disabled with its reason. + and the control is disabled with its reason. +- **Barge-in** (opt-in, `interrupt_response=1`) — the speaker can talk over a + playing reply: while a reply plays, an echo-cancelled `getUserMedia` capture + runs a leaky-accumulator onset detector (`voiceVad.ts`, `vad_threshold` 0.045, + `vad_onset_ms` 500), and a sustained onset halts the reply (`stopSpeaking`) and + re-opens the mic to catch the interruption. The onset logic is a pure module, + unit-tested off frame energies; the capture is a thin Web Audio shell that a + stronger model (e.g. a WASM Silero VAD) can replace behind the same seam. Off + by default because the WebView's AEC residual is imperfect and a false trigger + cuts a reply short; half-duplex is the safe default. There is no setting that lets the assistant type without asking. The convenience it would buy a trusted single-user setup is outweighed by what it diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3d2a6682..d38ef15b 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -471,9 +471,13 @@ for approval / Muted / Ended; in a conversation the microphone button mutes and unmutes with a tap (or **M** on the desktop), and a pending write still waits for an on-screen approval — the announcement says so and never offers a spoken yes. The conversation ends when you turn it off, leave the surface, mute-and-forget it -past the idle timeout, or hear nothing for a few turns. Turn timing is tunable -per device through `vogt.assistant.voice.*` browser settings (silence window, -idle timeout); the defaults suit a phone. +past the idle timeout, or hear nothing for a few turns. By default the microphone +is closed while a reply is spoken and re-opens when it finishes; setting +`vogt.assistant.voice.interrupt_response` on lets you **talk over a reply** — the +device stops speaking and listens the moment you start (off by default, because +imperfect echo cancellation can cut a reply short by mistake). Turn timing is +tunable per device through the `vogt.assistant.voice.*` browser settings (silence +window, idle timeout, barge-in); the defaults suit a phone. An approved write is audited to **your** actor, using the core token paired with the token that pressed approve. There is no shared "assistant" actor to fall diff --git a/web/src/Assistant.tsx b/web/src/Assistant.tsx index 61d8c1b5..527ac926 100644 --- a/web/src/Assistant.tsx +++ b/web/src/Assistant.tsx @@ -56,6 +56,7 @@ import { type VoicePorts, type VoiceState, } from "./voiceTurn"; +import { startBargeInDetection } from "./voiceVad"; const TTS_PREF_KEY = "vogt.assistant.tts"; @@ -383,6 +384,11 @@ export default function Assistant(props: AssistantProps) { // The hands-free loop, created lazily when Conversation is turned on. Declared // here because `applyReply` and `send` feed it their outcomes. let conversation: VoiceConversation | null = null; + // Whether barge-in (v2) is enabled for this session — read once when the + // conversation begins. Gates the echo-cancelled detection capture below; the + // machine ignores `speechDetected` when it is off, so this is only to avoid + // opening a second mic during playback for nothing. + let bargeInEnabled = false; const haltSpeech = () => { speechController?.abort(); @@ -1293,11 +1299,24 @@ export default function Assistant(props: AssistantProps) { } // Prime the synth inside the user gesture — the Android WebView requires it. window.speechSynthesis?.speak(new SpeechSynthesisUtterance("")); - conversation = new VoiceConversation(conversationPorts, readVoiceConfig()); + const cfg = readVoiceConfig(); + bargeInEnabled = cfg.interrupt_response; + conversation = new VoiceConversation(conversationPorts, cfg); setConversationOn(true); conversation.begin(); }; + // Barge-in (v2): while a reply is playing and interrupt_response is on, run an + // echo-cancelled capture that lets the speaker cut in. An onset halts the + // reply and re-opens the mic (the machine's `speechDetected`). The capture is + // open only during `speaking`, and torn down the moment that ends — so the + // second microphone is never held longer than the reply it listens over. + createEffect(() => { + if (!conversationOn() || !bargeInEnabled || voiceState() !== "speaking") return; + const stop = startBargeInDetection(() => conversation?.speechDetected()); + onCleanup(stop); + }); + // Desktop shortcut: `M` mutes/unmutes an active conversation, unless the // caret is in a text field (where `m` is just a letter). createEffect(() => { diff --git a/web/src/__tests__/voiceTurn.test.ts b/web/src/__tests__/voiceTurn.test.ts index bf1f45dc..1ddfc290 100644 --- a/web/src/__tests__/voiceTurn.test.ts +++ b/web/src/__tests__/voiceTurn.test.ts @@ -172,6 +172,46 @@ describe("the hands-free conversation loop", () => { expect(vc.getState()).toBe("arming"); }); + it("ignores barge-in while a reply plays when interrupt_response is off (the default)", () => { + const { vc, clock, count } = live({ silence_duration_ms: 100 }); + vc.partial("hello"); + clock.advance(100); + vc.replied({ text: "a long spoken reply", hasPendingAction: false }); + expect(vc.getState()).toBe("speaking"); + const opensBefore = count("openMic"); + vc.speechDetected(); + expect(vc.getState()).toBe("speaking"); // half-duplex: no interruption + expect(count("openMic")).toBe(opensBefore); + }); + + it("barges in when interrupt_response is on: halts the reply and re-opens the mic", () => { + const { vc, clock, events, count } = live({ + silence_duration_ms: 100, + interrupt_response: true, + }); + vc.partial("hello"); + clock.advance(100); + vc.replied({ text: "a long spoken reply", hasPendingAction: false }); + expect(vc.getState()).toBe("speaking"); + const opensBefore = count("openMic"); + vc.speechDetected(); + expect(events).toContain("stopSpeaking"); + expect(vc.getState()).toBe("arming"); // re-opened to capture the interruption + expect(count("openMic")).toBe(opensBefore + 1); + // The halted reply's own speechFinished now arrives, and is ignored — we + // have already left `speaking`. + vc.speechFinished(); + expect(vc.getState()).toBe("arming"); + }); + + it("does not barge in outside playback, even with interrupt_response on", () => { + const { vc } = live({ interrupt_response: true }); + // In `listening`, a stray VAD onset must not disturb the turn. + expect(vc.getState()).toBe("listening"); + vc.speechDetected(); + expect(vc.getState()).toBe("listening"); + }); + it("keeps the session alive when muted, and captures nothing until unmuted", () => { const { clock, vc, events, count } = live({ silence_duration_ms: 1000 }); vc.toggleMute(); diff --git a/web/src/__tests__/voiceVad.test.ts b/web/src/__tests__/voiceVad.test.ts new file mode 100644 index 00000000..79b8c211 --- /dev/null +++ b/web/src/__tests__/voiceVad.test.ts @@ -0,0 +1,97 @@ +// The barge-in onset decision, proven off a list of frame energies. +// +// The capture shell (getUserMedia + Web Audio) is a thin, jsdom-hostile wrapper +// and is left to the browser round trip; what is worth pinning here is the +// decision it feeds — that a sustained onset fires once, a click does not, and +// natural dips are tolerated — because that is what makes barge-in usable +// rather than trigger-happy. + +import { describe, expect, it } from "vitest"; + +import { + ONSET_DEFAULTS, + OnsetDetector, + frameRms, + readOnsetConfig, + type OnsetConfig, +} from "../voiceVad"; + +const cfg: OnsetConfig = { vad_threshold: 0.1, vad_onset_ms: 500 }; + +/** Push `n` frames of a constant level, 50 ms each. */ +function push(det: OnsetDetector, level: number, n: number, dt = 50): void { + for (let i = 0; i < n; i += 1) det.push(level, dt); +} + +describe("frameRms", () => { + it("is zero for silence and the amplitude for a constant frame", () => { + expect(frameRms(new Float32Array([0, 0, 0]))).toBe(0); + expect(frameRms(new Float32Array([0.5, 0.5, 0.5]))).toBeCloseTo(0.5, 6); + expect(frameRms(new Float32Array(0))).toBe(0); + }); +}); + +describe("OnsetDetector", () => { + it("fires once after a sustained loud stretch", () => { + let onsets = 0; + const det = new OnsetDetector(cfg, () => (onsets += 1)); + push(det, 0.3, 9); // 450 ms — not yet + expect(onsets).toBe(0); + push(det, 0.3, 1); // crosses 500 ms + expect(onsets).toBe(1); + push(det, 0.3, 20); // stays loud — but it only fires once + expect(onsets).toBe(1); + }); + + it("does not fire on a quiet signal or a brief click", () => { + let onsets = 0; + const det = new OnsetDetector(cfg, () => (onsets += 1)); + push(det, 0.02, 40); // well below threshold for 2 s + push(det, 0.3, 2); // a 100 ms click + push(det, 0.02, 40); + expect(onsets).toBe(0); + }); + + it("tolerates the natural dips in speech but resets on real silence", () => { + let onsets = 0; + const det = new OnsetDetector(cfg, () => (onsets += 1)); + // Loud with a one-frame dip every third frame: the accumulator still climbs. + for (let i = 0; i < 30 && onsets === 0; i += 1) { + det.push(i % 3 === 2 ? 0.02 : 0.3, 50); + } + expect(onsets).toBe(1); + + // A fresh detector that goes quiet before reaching the threshold never fires. + const det2 = new OnsetDetector(cfg, () => (onsets += 1)); + push(det2, 0.3, 8); // 400 ms up + push(det2, 0.02, 8); // 400 ms down — back to zero + push(det2, 0.3, 8); // 400 ms up again, still short of 500 + expect(onsets).toBe(1); // unchanged + }); + + it("re-arms after reset", () => { + let onsets = 0; + const det = new OnsetDetector(cfg, () => (onsets += 1)); + push(det, 0.3, 10); + expect(onsets).toBe(1); + det.reset(); + push(det, 0.3, 10); + expect(onsets).toBe(2); + }); +}); + +describe("readOnsetConfig", () => { + it("is the defaults when nothing is stored", () => { + expect(readOnsetConfig(() => null)).toEqual(ONSET_DEFAULTS); + }); + + it("takes overrides and ignores malformed values", () => { + const store: Record = { + "vogt.assistant.voice.vad_threshold": "0.08", + "vogt.assistant.voice.vad_onset_ms": "nope", + }; + const c = readOnsetConfig((k) => store[k] ?? null); + expect(c.vad_threshold).toBe(0.08); + expect(c.vad_onset_ms).toBe(ONSET_DEFAULTS.vad_onset_ms); + }); +}); diff --git a/web/src/voiceTurn.ts b/web/src/voiceTurn.ts index fecaeab8..0039906d 100644 --- a/web/src/voiceTurn.ts +++ b/web/src/voiceTurn.ts @@ -290,6 +290,20 @@ export class VoiceConversation { this.resumeListening(); } + /** Barge-in (v2): the speaker started talking over a playing reply. Only + * honoured when `interrupt_response` is on and a reply is actually playing — + * the host runs an echo-cancelled VAD during `speaking` and calls this on a + * confident onset. Halts the reply and re-opens the mic at once, so the + * interrupting words are captured as the next turn. A no-op otherwise, so a + * false onset outside playback cannot disturb the loop. The reply's own + * `speechFinished` that follows the halt is ignored (we have left `speaking`). */ + speechDetected(): void { + if (!this.cfg.interrupt_response) return; + if (this.state !== "speaking") return; + this.ports.stopSpeaking(); + this.resumeListening(); + } + /** The turn's send failed. The mode stays (the surface shows the failed * bubble + Retry); the loop re-opens the mic for another turn. */ sendFailed(): void { diff --git a/web/src/voiceVad.ts b/web/src/voiceVad.ts new file mode 100644 index 00000000..6fba6d3c --- /dev/null +++ b/web/src/voiceVad.ts @@ -0,0 +1,174 @@ +// Barge-in voice-activity detection (WI-174 v2). +// +// v1 hands-free is half-duplex: the mic is closed while a reply plays. v2 lets +// the speaker cut in — start talking over the reply and the loop stops speaking +// and listens. That needs a capture running *during* playback that can tell the +// speaker's voice from the reply's own audio. This module is that capture, and +// only the onset decision it makes; the loop (`voiceTurn.ts`) owns what to do +// with an onset (`speechDetected()` → halt + listen, gated on +// `interrupt_response`). +// +// The onset decision is split out as a pure `OnsetDetector` so it is unit- +// tested off a list of frame energies rather than a microphone. The capture +// wrapper below is a thin Web Audio shell around it — echo cancellation on, so +// the reply the device is playing is largely absent from what the mic hears, +// and a leaky accumulator so a confident, sustained onset (not a transient +// click or a dip in the reply's AEC residual) is what fires. A stronger model +// (Silero via a WASM VAD) can replace the shell later behind the same +// `startBargeInDetection` seam without touching the loop. +// +// Everything degrades to a no-op: no `getUserMedia`, no `AudioContext`, or a +// capture that throws, simply means no barge-in — v1 half-duplex, which is a +// working state. + +/** Onset tuning, read from the shared `vogt.assistant.voice.*` namespace. */ +export interface OnsetConfig { + /** RMS (0–1) a frame must exceed to count as loud. Over the echo-cancelled + * signal, the reply's residual sits below this and the speaker's voice above. */ + vad_threshold: number; + /** Sustained loud time that fires an onset. The design's "≥ 500 ms". */ + vad_onset_ms: number; +} + +export const ONSET_DEFAULTS: OnsetConfig = { + vad_threshold: 0.045, + vad_onset_ms: 500, +}; + +const VOICE_CFG_PREFIX = "vogt.assistant.voice."; + +export function readOnsetConfig( + read: (key: string) => string | null = (key) => { + try { + return localStorage.getItem(key); + } catch { + return null; + } + }, +): OnsetConfig { + const num = (key: keyof OnsetConfig, fallback: number): number => { + const raw = read(VOICE_CFG_PREFIX + key); + if (raw === null) return fallback; + const n = Number(raw); + return Number.isFinite(n) && n >= 0 ? n : fallback; + }; + return { + vad_threshold: num("vad_threshold", ONSET_DEFAULTS.vad_threshold), + vad_onset_ms: num("vad_onset_ms", ONSET_DEFAULTS.vad_onset_ms), + }; +} + +/** + * A leaky-accumulator onset detector. Each loud frame adds its duration to an + * accumulator; each quiet frame subtracts it (floored at zero). An onset fires + * — once — when the accumulator crosses `vad_onset_ms`, so a brief click or a + * momentary AEC residual does not trigger, but a half-second of real speech + * does, even through the small dips of natural speech. + */ +export class OnsetDetector { + private above = 0; + private fired = false; + + constructor( + private readonly cfg: OnsetConfig, + private readonly onOnset: () => void, + ) {} + + /** Feed one analysis frame: `rms` in [0, 1], `dtMs` its duration. */ + push(rms: number, dtMs: number): void { + if (rms >= this.cfg.vad_threshold) this.above += dtMs; + else this.above = Math.max(0, this.above - dtMs); + if (!this.fired && this.above >= this.cfg.vad_onset_ms) { + this.fired = true; + this.onOnset(); + } + } + + /** Reset for a fresh listening window. */ + reset(): void { + this.above = 0; + this.fired = false; + } +} + +/** Root-mean-square of a time-domain frame, in [0, 1]. */ +export function frameRms(samples: Float32Array): number { + if (samples.length === 0) return 0; + let sum = 0; + for (let i = 0; i < samples.length; i += 1) { + const s = samples[i] ?? 0; + sum += s * s; + } + return Math.sqrt(sum / samples.length); +} + +/** A handle that stops the capture and frees the mic. Idempotent. */ +export type StopBargeIn = () => void; + +interface WindowAudio { + AudioContext?: typeof AudioContext; + webkitAudioContext?: typeof AudioContext; +} + +/** + * Open an echo-cancelled capture and fire `onOnset` on a sustained speaker + * onset. Returns a stop handle; a no-op stop when the platform cannot capture + * (older WebView, denied permission, no Web Audio), which leaves the loop + * half-duplex. `now`/`schedule` are injected only so this is drivable in a + * test; production uses the real clock and `setInterval`. + */ +export function startBargeInDetection( + onOnset: () => void, + cfg: OnsetConfig = readOnsetConfig(), +): StopBargeIn { + const w = window as unknown as WindowAudio; + const Ctx = w.AudioContext ?? w.webkitAudioContext; + const media = navigator.mediaDevices; + if (!Ctx || !media?.getUserMedia) return () => {}; + + const detector = new OnsetDetector(cfg, onOnset); + let stopped = false; + let audioCtx: AudioContext | null = null; + let stream: MediaStream | null = null; + let timer: ReturnType | null = null; + + const stop: StopBargeIn = () => { + if (stopped) return; + stopped = true; + if (timer !== null) clearInterval(timer); + timer = null; + stream?.getTracks().forEach((track) => track.stop()); + stream = null; + void audioCtx?.close().catch(() => {}); + audioCtx = null; + }; + + void media + .getUserMedia({ + audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true }, + }) + .then((granted) => { + if (stopped) { + granted.getTracks().forEach((track) => track.stop()); + return; + } + stream = granted; + audioCtx = new Ctx(); + const source = audioCtx.createMediaStreamSource(granted); + const analyser = audioCtx.createAnalyser(); + analyser.fftSize = 1024; + source.connect(analyser); + const buffer = new Float32Array(analyser.fftSize); + const frameMs = 50; + timer = setInterval(() => { + analyser.getFloatTimeDomainData(buffer); + detector.push(frameRms(buffer), frameMs); + }, frameMs); + }) + .catch(() => { + // Denied or unavailable: no barge-in, half-duplex stands. + stop(); + }); + + return stop; +} diff --git a/web/tests/browser/gui.spec.ts b/web/tests/browser/gui.spec.ts index bf285732..950e8591 100644 --- a/web/tests/browser/gui.spec.ts +++ b/web/tests/browser/gui.spec.ts @@ -5516,6 +5516,116 @@ test("Assistant hands-free conversation: sends on silence, speaks the reply, and .toBeGreaterThanOrEqual(2); }); +/** + * Barge-in (v2): `interrupt_response` on, a reply that keeps playing (the fake + * synth does not auto-end), and a fake echo-cancelled capture that always reads + * loud — so the onset detector fires ~500 ms into the reply. The reply is then + * halted (synth `cancel`) and the mic re-opens to catch the interruption, with + * no `speechFinished` needed. + */ +async function primeBargeInEnv(page: Page): Promise { + await page.addInitScript(() => { + localStorage.setItem("vogt.assistant.tts", "1"); + localStorage.setItem("vogt.assistant.voice.silence_duration_ms", "80"); + localStorage.setItem("vogt.assistant.voice.interrupt_response", "1"); + localStorage.setItem("vogt.assistant.voice.vad_onset_ms", "400"); + const state = { starts: 0, cancels: 0, current: null as unknown }; + class FakeRecognition { + continuous = false; + interimResults = false; + lang = ""; + onresult: ((e: unknown) => void) | null = null; + onend: (() => void) | null = null; + onerror: ((e: unknown) => void) | null = null; + start() { + state.starts += 1; + state.current = this; + } + stop() { + this.onend?.(); + } + abort() { + this.onend?.(); + } + } + const w = window as unknown as Record; + w.webkitSpeechRecognition = FakeRecognition; + delete w.SpeechRecognition; + class FakeUtterance { + text: string; + onend: (() => void) | null = null; + constructor(text: string) { + this.text = text; + } + } + w.SpeechSynthesisUtterance = FakeUtterance; + Object.defineProperty(window, "speechSynthesis", { + configurable: true, + // The reply keeps "playing": no onend, so only barge-in can end it. + value: { speak: () => {}, cancel: () => (state.cancels += 1) }, + }); + // A fake echo-cancelled capture that always reads loud, so the onset + // detector crosses its threshold and fires. + Object.defineProperty(navigator, "mediaDevices", { + configurable: true, + value: { getUserMedia: async () => ({ getTracks: () => [{ stop() {} }] }) }, + }); + class FakeAnalyser { + fftSize = 1024; + getFloatTimeDomainData(buf: Float32Array) { + buf.fill(0.3); // well above the VAD threshold + } + } + class FakeAudioContext { + createMediaStreamSource() { + return { connect() {} }; + } + createAnalyser() { + return new FakeAnalyser(); + } + close() { + return Promise.resolve(); + } + } + w.AudioContext = FakeAudioContext; + w.__vogtVoice = { state }; + }); +} + +test("Assistant hands-free barge-in: the speaker cuts in, the reply is halted, and the mic re-opens", async ({ page }) => { + await primeBargeInEnv(page); + await installFixtures(page, { assistant_enabled: true }); + await page.route("**/api/assistant/message", async (route) => + route.fulfill({ + json: { reply: "This is a long spoken reply you can talk over.", pending_action: null, tool_trace: [] }, + }), + ); + + await openVoiceAssistant(page); + await page.getByTestId("assistant-conversation").click(); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __vogtVoice: { state: { starts: number } } }).__vogtVoice.state.starts)) + .toBeGreaterThanOrEqual(1); + + // One turn, so a reply starts playing (and keeps playing — the fake synth + // never ends on its own). + await page.evaluate(() => { + const r = (window as unknown as { __vogtVoice: { state: { current: { onresult?: (e: unknown) => void } } } }).__vogtVoice.state.current; + r.onresult?.({ results: { length: 1, 0: { length: 1, 0: { transcript: "tell me a long story" } } } }); + }); + await expect(page.getByText("This is a long spoken reply you can talk over.")).toBeVisible(); + + // ~400 ms of "loud" capture during playback fires the onset: the reply is + // cancelled and the mic re-opens (a second recognizer start) with no + // speechFinished — the speaker talked over it. + await expect + .poll(() => page.evaluate(() => (window as unknown as { __vogtVoice: { state: { cancels: number } } }).__vogtVoice.state.cancels)) + .toBeGreaterThanOrEqual(1); + await expect + .poll(() => page.evaluate(() => (window as unknown as { __vogtVoice: { state: { starts: number } } }).__vogtVoice.state.starts)) + .toBeGreaterThanOrEqual(2); +}); + for (const mouseTracking of [false, true]) { test(`terminal swipe owns ${mouseTracking ? "reports wheels to a normal-buffer application" : "moves saved rows once"}`, async ({ page, context }) => { test.skip(test.info().project.name !== "phone", "Touch input needs the phone context");