Desktop read aloud TTS 15s timeout bug and fixes
NousResearch/hermes-agent · cluster 39290 · apps/desktop
Data as of
Nothing open on GitHub. Fix merged in #68056. Check it covers your case.
The short answer
- Known?
- Yes. 6 related items grouped here, 4 issues already closed.
- Fix event
- A fix was merged to main in PR #68056. Check it covers your case. No release data is tracked.
- Tracker
- The maintainers' tracker marks this
unresolved. That is separate from the fix event. - Still open
- Nothing open on GitHub.
Multiple reports and fix attempts address a hardcoded 15-second client-side timeout that cuts off text-to-speech (read aloud) on long responses in the desktop app. Related PRs attempt to extend or address this timeout.
Open issues0
Open PRs0
Fixes on record
-
#68056 fix(desktop): extend read aloud + transcription timeouts (salvage of #39286)
files:
apps/desktop/src/hermes.test.ts,apps/desktop/src/hermes.tsShow the diff
-
apps/desktop/src/hermes.test.ts@@ -1,6 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, + AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, + AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, + AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, + audioSpeakRequestTimeoutMs, + audioTranscribeRequestTimeoutMs, getCronJobs, getGlobalModelInfo, getGlobalModelOptions, @@ -12,7 +18,9 @@ import { listAllProfileSessions, listSessions, listSidebarSessions, - resetSidebarBatchCapability + resetSidebarBatchCapability, + speakText, + transcribeAudio } from './hermes' import { refreshActiveProfile } from './store/profile' @@ -23,7 +31,7 @@ const emptySessionsResponse = { total: 0 } -describe('Hermes REST session helpers', () => { +describe('Hermes REST helpers', () => { let api: ReturnType<typeof vi.fn> beforeEach(() => { @@ -322,6 +330,62 @@ describe('Hermes REST session helpers', () => { }) }) + it('bounds blocking TTS synthesis timeouts by text length', () => { + expect(audioSpeakRequestTimeoutMs('short message')).toBe(AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS) + expect(audioSpeakRequestTimeoutMs('x'.repeat(8_000))).toBe(280_000) + expect(audioSpeakRequestTimeoutMs('x'.repeat(100_000))).toBe(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS) + }) + + it('uses an extended timeout for blocking TTS synthesis', async () => { + api.mockResolvedValueOnce({ + data_url: 'data:audio/mpeg;base64,AA==', + mime_type: 'audio/mpeg', + ok: true, + provider: 'openai' + }) + + await expect(speakText('Read this aloud')).resolves.toEqual({ + data_url: 'data:audio/mpeg;base64,AA==', + mime_type: 'audio/mpeg', + ok: true, + provider: 'openai' + }) + + expect(api).toHaveBeenCalledWith({ + body: { text: 'Read this aloud' }, + method: 'POST', + path: '/api/audio/speak', + timeoutMs: AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS + }) + }) + + it('bounds blocking transcription timeouts by payload length', () => { + expect(audioTranscribeRequestTimeoutMs('data:audio/webm;base64,AA==')).toBe(AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS) + expect(audioTranscribeRequestTimeoutMs('x'.repeat(3_000_000))).toBe(300_000) + expect(audioTranscribeRequestTimeoutMs('x'.repeat(9_000_000))).toBe(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS) + }) + + it('uses an extended timeout for blocking transcription', async () => { + api.mockResolvedValueOnce({ + ok: true, + provider: 'openai', + text: 'transcribed text' + }) + + await expect(transcribeAudio('data:audio/webm;base64,AA==', 'audio/webm')).resolves.toEqual({ + ok: true, + provider: 'openai', + text: 'transcribed text' + }) + + expect(api).toHaveBeenCalledWith({ + body: { data_url: 'data:audio/webm;base64,AA==', mime_type: 'audio/webm' }, + method: 'POST', + path: '/api/audio/transcribe', + timeoutMs: AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS + }) + }) + it('defaults model options to configured providers only', async () => { await getGlobalModelOptions() -
apps/desktop/src/hermes.ts@@ -84,6 +84,36 @@ const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000 // agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only // ever fires when the turn itself would have been abandoned server-side. export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000 +export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000 +export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000 +const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35 + +export function audioSpeakRequestTimeoutMs(text: string): number { + const estimated = Math.max( + AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS, + Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR) + ) + + return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated) +} + +export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000 +export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000 +// The transcribe payload is the base64 audio data URL itself, so its string +// length tracks clip size. ~0.1ms/char keeps short clips at the floor while +// letting multi-minute recordings scale toward the cap (a base64 char is +// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of +// timeout per 1s of audio before the cap clamps it). +const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1 + +export function audioTranscribeRequestTimeoutMs(dataUrl: string): number { + const estimated = Math.max( + AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS, + Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR) + ) + + return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated) +} export type { ActionResponse, @@ -1346,7 +1376,11 @@ export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<Aud body: { data_url: dataUrl, mime_type: mimeType - } + }, + // Transcription blocks until provider STT, file handling, and response + // encoding finish. Remote providers and long clips regularly exceed the + // default 15s Electron backend timeout. + timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl) }) } @@ -1354,7 +1388,11 @@ export function speakText(text: string): Promise<AudioSpeakResponse> { return window.hermesDesktop.api<AudioSpeakResponse>({ path: '/api/audio/speak', method: 'POST', - body: { text } + body: { text }, + // TTS blocks until provider synthesis, file read, and base64 encoding + // finish. Remote providers and large messages regularly exceed the + // default 15s Electron backend timeout. + timeoutMs: audioSpeakRequestTimeoutMs(text) }) }
-
1 PR closed without merging
From the close comments: superseded by another PR 1
-
#39286 fix(desktop): extend read aloud timeout · superseded by another PR →
#68056
Closed issues · 4
- Closed#39290[Bug]: Desktop Read Aloud times out after 15s while TTS continues
- Closed#39292Desktop "Read aloud" (TTS) times out intermittently (15s) + is buried in a dropdown
- Closed#53161TTS times out on long text (hardcoded 15s client timeout)
- Closed#66008Hermes Desktop "Read aloud" times out on long replies (15s fetch timeout — regression)
How this cluster was grouped
-
Link evidence (
partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text. - Members outside that structural core may belong to a different underlying problem.
-
Pairwise link density
0.25: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36.1.00means every pair is linked.
Pick up a Lab task
Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.
Open the LabAt a glance
- Subsystem
- apps/desktop
- Merged PRs
- #68056
- Closed issues
- 4
- Attempts
- 1 PR closed without merging
- Fix link checked
- 20 Jul 2026