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:<messageId>` 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.
This commit is contained in:
Teknium
2026-09-10 09:25:05 -07:00
parent 62081b87b7
commit 50ea107d9d
3 changed files with 41 additions and 5 deletions
+18 -1
View File
@@ -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)
})
+18
View File
@@ -30,3 +30,21 @@ export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) {
return false
}
}
// A `speak:<messageId>` 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))
}
}
+5 -4
View File
@@ -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 })