From 50ea107d9d1ba3ef57720362ce86bdb97a021b6c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Thu, 10 Sep 2026 09:25:05 -0700 Subject: [PATCH] fix(desktop): read an auto-TTS reply aloud once when the HUD is open With `voice.auto_tts` on and HUD mode active, a reply could be spoken twice: the HUD renderer and the hidden app window both claim `speak:` through `hermes:ambient:claim`, and main collapsed the two claims with the same 1 s deduper it uses for the turn-end beep. The app window under the HUD is throttled by Chromium, so its transcript subscription fires well past 1 s, finds the key pruned, and is told it owns the cue. Fixes #99717. The unit was wrong, not the window: a spoken reply is minutes of audio keyed by a durable message id. `createAmbientClaimArbiter` keeps the tick-sized window for `sound:*` and holds `speak:*` claims for a 10-minute TTL. No HUD special-casing: the same race exists between any two windows showing the same chat, and the arbiter fixes them all. Diagnosis credit: #99810 (@liuhao1024) and #100289 (@shivamjg101), whose TTL framing this follows. --- apps/desktop/electron/event-dedupe.test.ts | 19 ++++++++++++++++++- apps/desktop/electron/event-dedupe.ts | 18 ++++++++++++++++++ apps/desktop/electron/main.ts | 9 +++++---- 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/apps/desktop/electron/event-dedupe.test.ts b/apps/desktop/electron/event-dedupe.test.ts index 3c0f905587..c27096796b 100644 --- a/apps/desktop/electron/event-dedupe.test.ts +++ b/apps/desktop/electron/event-dedupe.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { createEventDeduper } from './event-dedupe' +import { createAmbientClaimArbiter, createEventDeduper } from './event-dedupe' test('collapses the same key inside the window (two windows, one event)', () => { const isDup = createEventDeduper(1000) @@ -35,3 +35,20 @@ test('prunes stale keys so the map cannot grow unbounded', () => { assert.equal(isDup(`turnDone:s${i}`, i * 2000), false) } }) + +// #99717: the hidden app window under an open HUD claims the same reply late. +test('a spoken reply stays claimed long after the tick window, a beep does not', () => { + const owns = createAmbientClaimArbiter(1000) + + assert.equal(owns('speak:m1', 0), true, 'HUD renderer claims the reply') + assert.equal(owns('speak:m1', 5_000), false, 'app window claiming 5 s later stays quiet') + assert.equal(owns('sound:turnDone:s1', 0), true) + assert.equal(owns('sound:turnDone:s1', 5_000), true, 'beep keys still re-fire after the tick window') +}) + +test('a spoken reply can be claimed again once the speech TTL elapses', () => { + const owns = createAmbientClaimArbiter(1000, 60_000) + + assert.equal(owns('speak:m1', 0), true) + assert.equal(owns('speak:m1', 60_000), true) +}) diff --git a/apps/desktop/electron/event-dedupe.ts b/apps/desktop/electron/event-dedupe.ts index a2d12a8f2c..c8f553ed02 100644 --- a/apps/desktop/electron/event-dedupe.ts +++ b/apps/desktop/electron/event-dedupe.ts @@ -30,3 +30,21 @@ export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) { return false } } + +// A `speak:` cue is seconds of audio keyed by a durable backend +// message id, not an instant beep: the peer's claim can arrive well past the +// 1 s window (the app window hidden under an open HUD is throttled by Chromium +// and its transcript subscription fires late), and the reply was read twice +// (#99717). One reply is one claim for as long as a reply can plausibly play. +export const SPEECH_CLAIM_TTL_MS = 10 * 60_000 + +// Cross-window arbiter for every ambient cue: `speak:*` keys hold for the +// speech TTL, everything else keeps the tick-sized window. +export function createAmbientClaimArbiter(intervalMs = DEDUPE_INTERVAL_MS, speechTtlMs = SPEECH_CLAIM_TTL_MS) { + const cues = createEventDeduper(intervalMs) + const speech = createEventDeduper(speechTtlMs) + + return function owns(key: string, now = Date.now()): boolean { + return !(key.startsWith('speak:') ? speech(key, now) : cues(key, now)) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4fb88c2d4d..d0c2e18513 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -169,7 +169,7 @@ import { } from './desktop-uninstall' import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp' import { installEmbedReferer } from './embed-referer' -import { createEventDeduper } from './event-dedupe' +import { createAmbientClaimArbiter } from './event-dedupe' import { buildTerminalScript, resolveTerminalLaunch, @@ -16799,9 +16799,10 @@ ipcMain.handle('hermes:api', async (_event, request) => { return handleHermesApiRequest(request).finally(releaseProfileDeletion) }) -// Main serializes cross-window ambient claims. -const claimedAmbientCue = createEventDeduper() -ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? ''))) +// Main serializes cross-window ambient claims (see event-dedupe.ts for why a +// spoken reply holds its claim far longer than a beep). +const ownsAmbientCue = createAmbientClaimArbiter() +ipcMain.handle('hermes:ambient:claim', (_event, key) => ownsAmbientCue(String(key ?? ''))) registerNativeNotifications({ getMainWindow: () => mainWindow, focusWindow })