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