From 9e6dca4302d22f5f39d205cc9387cc3f902eb2cb Mon Sep 17 00:00:00 2001 From: dillon ofili Date: Sun, 23 Aug 2026 13:23:09 +0100 Subject: [PATCH] fix(voice): stop a tap meant to wake Aide from closing its microphone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Waking was silent. The only sign a tap had landed was micStatus reading "waking up…", which is on screen, which is the one place these users cannot look. So the natural response to tapping and hearing nothing is to tap again — and the third tap completed a mute run. The user asked Aide to start listening and heard "That's three taps, I'll stop listening now." The mic then stayed shut against every path that would have reopened it, which is exactly what the hold is built to do. Two changes, because either alone leaves the trap open. Taps that arrive while Aide is asleep no longer count toward a run at all: while dormant, a tap means wake and nothing else. And waking now answers out loud, so there is a reason to stop tapping rather than a silence that invites more. Kept to two words — this fires on an ordinary tap, and a sentence would wear thin inside one session. Separately, start() now republishes muted and dormant rather than only active. One VoiceEngine is shared across remounts while the provider's state is rebuilt from defaults each time it mounts, so a held microphone came back reporting itself as listening. A wrong status is worse here than no status: someone who cannot see the screen has no second source to check it against, and would go on talking to an Aide that had stopped listening. --- app/aide/voice-engine.ts | 34 ++++++++++++++++++++++++++++-- tests/unit/tap-hold.test.ts | 42 +++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/app/aide/voice-engine.ts b/app/aide/voice-engine.ts index 0a80756..fd6109a 100644 --- a/app/aide/voice-engine.ts +++ b/app/aide/voice-engine.ts @@ -114,6 +114,12 @@ const TOGGLE_CLICK_GRACE_MS = 400; // is nothing on screen to remind them. export const MUTE_NOTICE = "That's three taps. I'll stop listening now. Tap three times again whenever you want me back."; export const UNMUTE_NOTICE = "I'm listening again. What can I help you with?"; +// Waking used to be silent, which is the whole reason a wake could turn into a +// hold: a user who cannot see "waking up…" has no way to know the tap landed, +// so they tap again, and a third tap used to complete a mute run. Answering the +// first tap out loud is what stops the tapping. Kept to two words, because this +// fires on an ordinary tap and a sentence here would wear thin fast. +export const WAKE_NOTICE = "I'm listening."; // The tap-run bookkeeping, kept free of the DOM so it can be tested directly. export class TapRun { @@ -274,7 +280,18 @@ export class VoiceEngine { start(): void { this.active = true; this.canHear = true; - this.handlers.onState({ active: true }); + // Republish what the engine actually is, not just that it is running. This + // engine outlives the React tree that renders it — one instance is shared + // across remounts — while the provider's state is rebuilt from defaults + // every time it mounts. Sending only `active` left a held microphone + // reporting itself as listening, which is the one thing a user who cannot + // see the screen has no way to catch. + this.handlers.onState({ + active: true, + muted: this.muted, + dormant: this.dormant, + micStatus: this.muted ? "not listening — tap three times to resume" : "starting…", + }); document.addEventListener("visibilitychange", this.onVisibility); window.addEventListener("pointerdown", this.unlock); window.addEventListener("keydown", this.unlock); @@ -492,7 +509,12 @@ export class VoiceEngine { // and must not ALSO be read as "wake up" or "shut up". // // Pointers only. A triple keypress is just typing. - if (this.canHear && e.type === "pointerdown" && this.countsAsTap(e) && this.taps.register(Date.now())) { + // + // A tap that arrives while Aide is asleep means "wake up" and nothing else, + // so it must not count toward a run. Otherwise the three taps someone makes + // to rouse a dormant Aide complete a hold instead, and they hear that Aide + // has stopped listening at the exact moment they were asking it to start. + if (this.canHear && e.type === "pointerdown" && !this.dormant && this.countsAsTap(e) && this.taps.register(Date.now())) { this.toggleMuted(); return; } @@ -600,7 +622,15 @@ export class VoiceEngine { private wake(): void { if (!this.dormant || this.muted) return; this.dormant = false; + // The tap that woke Aide is spent. Anything already counted belongs to a + // run the user began while Aide was asleep, and carrying it forward would + // let two more taps close the mic they just reopened. + this.taps.reset(); this.handlers.onState({ dormant: false, micStatus: "waking up…" }); + // Say so. speakNow() closes the mic while it talks and finishOrNext() + // reopens it after, so this does not race the recognizer being started + // below — but it does mean the user gets an answer to their tap. + this.speakNow(WAKE_NOTICE); if (this.active) this.startRecognition(); this.armIdleTimer(); } diff --git a/tests/unit/tap-hold.test.ts b/tests/unit/tap-hold.test.ts index 5aea27f..2c07a41 100644 --- a/tests/unit/tap-hold.test.ts +++ b/tests/unit/tap-hold.test.ts @@ -5,6 +5,7 @@ import { TRIPLE_TAP_GAP_MS, TapRun, UNMUTE_NOTICE, + WAKE_NOTICE, } from "../../app/aide/voice-engine"; import { SYSTEM_PROMPT } from "../../lib/agent/system"; @@ -75,6 +76,47 @@ describe("counting a run of taps", () => { }); }); +describe("waking up must not be mistaken for a hold", () => { + // Aide sleeps after a quiet spell and any tap wakes it. Waking used to say + // nothing, so a user who cannot see "waking up…" had no reason to believe + // the tap had landed — and tapping again, twice, closed the microphone they + // were trying to reopen. Two things prevent that now: taps that arrive while + // Aide is asleep do not count toward a run, and waking answers out loud. + + it("answers the tap, so there is a reason to stop tapping", () => { + expect(WAKE_NOTICE.trim()).not.toBe(""); + }); + + it("says it is listening, which is the thing the user just asked for", () => { + expect(WAKE_NOTICE).toMatch(/listening/i); + }); + + it("stays short enough to sit in front of an ordinary tap", () => { + // This fires on a plain tap, not a state change the user asked for. A + // sentence here would wear thin within one session. + expect(WAKE_NOTICE.split(/\s+/).length).toBeLessThanOrEqual(4); + }); + + it("never asks the user to look at anything", () => { + expect(WAKE_NOTICE).not.toMatch(/\b(see|look|watch|screen|display|shown?)\b/i); + }); + + it("cannot be confused with the notice for closing the mic", () => { + expect(WAKE_NOTICE).not.toBe(MUTE_NOTICE); + expect(WAKE_NOTICE).not.toBe(UNMUTE_NOTICE); + }); + + it("leaves no part-run behind for two more taps to finish", () => { + // What wake() now does: the tap that woke Aide is spent, so the two taps + // that follow it cannot complete a hold on their own. + const run = new TapRun(); + run.register(0); // the tap that woke Aide + run.reset(); // …consumed by the wake + expect(run.register(100)).toBe(false); + expect(run.register(200)).toBe(false); + }); +}); + describe("what Aide says when the microphone closes and opens", () => { it("carries the way back inside the closing notice", () => { // This is the LAST thing the user hears before Aide goes quiet. If the