{"repo":"NousResearch/hermes-agent","public_id":"39290","display_label":"Desktop read aloud TTS 15s timeout bug and fixes","summary":"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.","subsystem":"apps/desktop","counts":{"members":6,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":4,"merged_prs":1,"attempt_prs":1},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":68056},"resolution":{"state":"unresolved","by":null},"freshness":{"state":"verified","last_verified_at":"2026-07-20T13:51:32+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_PARTIAL","link_density":0.2533333333333333},"fix_edges":[{"issue_number":39290,"pr_number":68056,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":46573,"pr_number":68056,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false},{"issue_number":53130,"pr_number":68056,"tier":"verified","basis":"superseded_ref","confidence":0.9,"recoverable_from_timeline":false}],"members":[{"kind":"issue","number":39290,"title":"[Bug]: Desktop Read Aloud times out after 15s while TTS continues","url":null,"state":"closed"},{"kind":"issue","number":39292,"title":"Desktop \"Read aloud\" (TTS) times out intermittently (15s) + is buried in a dropdown","url":null,"state":"closed"},{"kind":"issue","number":53161,"title":"TTS times out on long text (hardcoded 15s client timeout)","url":null,"state":"closed"},{"kind":"issue","number":66008,"title":"Hermes Desktop \"Read aloud\" times out on long replies (15s fetch timeout — regression)","url":null,"state":"closed"},{"kind":"pr","number":39286,"title":"fix(desktop): extend read aloud timeout","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#68056","number":68056},{"kind":"pr","value":"#68056","number":68056}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":68056,"title":"fix(desktop): extend read aloud + transcription timeouts (salvage of #39286)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["apps/desktop/src/hermes.test.ts","apps/desktop/src/hermes.ts"],"fix_hunks":[{"file":"apps/desktop/src/hermes.test.ts","hunks":["@@ -1,6 +1,12 @@\n import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'\n \n import {\n+  AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS,\n+  AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,\n+  AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS,\n+  AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,\n+  audioSpeakRequestTimeoutMs,\n+  audioTranscribeRequestTimeoutMs,\n   getCronJobs,\n   getGlobalModelInfo,\n   getGlobalModelOptions,","@@ -12,7 +18,9 @@ import {\n   listAllProfileSessions,\n   listSessions,\n   listSidebarSessions,\n-  resetSidebarBatchCapability\n+  resetSidebarBatchCapability,\n+  speakText,\n+  transcribeAudio\n } from './hermes'\n import { refreshActiveProfile } from './store/profile'\n ","@@ -23,7 +31,7 @@ const emptySessionsResponse = {\n   total: 0\n }\n \n-describe('Hermes REST session helpers', () => {\n+describe('Hermes REST helpers', () => {\n   let api: ReturnType<typeof vi.fn>\n \n   beforeEach(() => {","@@ -322,6 +330,62 @@ describe('Hermes REST session helpers', () => {\n     })\n   })\n \n+  it('bounds blocking TTS synthesis timeouts by text length', () => {\n+    expect(audioSpeakRequestTimeoutMs('short message')).toBe(AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS)\n+    expect(audioSpeakRequestTimeoutMs('x'.repeat(8_000))).toBe(280_000)\n+    expect(audioSpeakRequestTimeoutMs('x'.repeat(100_000))).toBe(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS)\n+  })\n+\n+  it('uses an extended timeout for blocking TTS synthesis', async () => {\n+    api.mockResolvedValueOnce({\n+      data_url: 'data:audio/mpeg;base64,AA==',\n+      mime_type: 'audio/mpeg',\n+      ok: true,\n+      provider: 'openai'\n+    })\n+\n+    await expect(speakText('Read this aloud')).resolves.toEqual({\n+      data_url: 'data:audio/mpeg;base64,AA==',\n+      mime_type: 'audio/mpeg',\n+      ok: true,\n+      provider: 'openai'\n+    })\n+\n+    expect(api).toHaveBeenCalledWith({\n+      body: { text: 'Read this aloud' },\n+      method: 'POST',\n+      path: '/api/audio/speak',\n+      timeoutMs: AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS\n+    })\n+  })\n+\n+  it('bounds blocking transcription timeouts by payload length', () => {\n+    expect(audioTranscribeRequestTimeoutMs('data:audio/webm;base64,AA==')).toBe(AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS)\n+    expect(audioTranscribeRequestTimeoutMs('x'.repeat(3_000_000))).toBe(300_000)\n+    expect(audioTranscribeRequestTimeoutMs('x'.repeat(9_000_000))).toBe(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS)\n+  })\n+\n+  it('uses an extended timeout for blocking transcription', async () => {\n+    api.mockResolvedValueOnce({\n+      ok: true,\n+      provider: 'openai',\n+      text: 'transcribed text'\n+    })\n+\n+    await expect(transcribeAudio('data:audio/webm;base64,AA==', 'audio/webm')).resolves.toEqual({\n+      ok: true,\n+      provider: 'openai',\n+      text: 'transcribed text'\n+    })\n+\n+    expect(api).toHaveBeenCalledWith({\n+      body: { data_url: 'data:audio/webm;base64,AA==', mime_type: 'audio/webm' },\n+      method: 'POST',\n+      path: '/api/audio/transcribe',\n+      timeoutMs: AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS\n+    })\n+  })\n+\n   it('defaults model options to configured providers only', async () => {\n     await getGlobalModelOptions()\n "]},{"file":"apps/desktop/src/hermes.ts","hunks":["@@ -84,6 +84,36 @@ const SESSION_LIST_REQUEST_TIMEOUT_MS = 60_000\n // agent-turn ceiling (agent.gateway_timeout = 1800s) so the ack timeout only\n // ever fires when the turn itself would have been abandoned server-side.\n export const PROMPT_SUBMIT_REQUEST_TIMEOUT_MS = 1_800_000\n+export const AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS = 180_000\n+export const AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS = 600_000\n+const AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR = 35\n+\n+export function audioSpeakRequestTimeoutMs(text: string): number {\n+  const estimated = Math.max(\n+    AUDIO_SPEAK_MIN_REQUEST_TIMEOUT_MS,\n+    Math.ceil(String(text || '').length * AUDIO_SPEAK_TIMEOUT_MS_PER_CHAR)\n+  )\n+\n+  return Math.min(AUDIO_SPEAK_MAX_REQUEST_TIMEOUT_MS, estimated)\n+}\n+\n+export const AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS = 180_000\n+export const AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS = 600_000\n+// The transcribe payload is the base64 audio data URL itself, so its string\n+// length tracks clip size. ~0.1ms/char keeps short clips at the floor while\n+// letting multi-minute recordings scale toward the cap (a base64 char is\n+// ~0.75 bytes, so at 128kbps ≈ 21k chars/s of audio this budgets ~2s of\n+// timeout per 1s of audio before the cap clamps it).\n+const AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR = 0.1\n+\n+export function audioTranscribeRequestTimeoutMs(dataUrl: string): number {\n+  const estimated = Math.max(\n+    AUDIO_TRANSCRIBE_MIN_REQUEST_TIMEOUT_MS,\n+    Math.ceil(String(dataUrl || '').length * AUDIO_TRANSCRIBE_TIMEOUT_MS_PER_CHAR)\n+  )\n+\n+  return Math.min(AUDIO_TRANSCRIBE_MAX_REQUEST_TIMEOUT_MS, estimated)\n+}\n \n export type {\n   ActionResponse,","@@ -1346,7 +1376,11 @@ export function transcribeAudio(dataUrl: string, mimeType?: string): Promise<Aud\n     body: {\n       data_url: dataUrl,\n       mime_type: mimeType\n-    }\n+    },\n+    // Transcription blocks until provider STT, file handling, and response\n+    // encoding finish. Remote providers and long clips regularly exceed the\n+    // default 15s Electron backend timeout.\n+    timeoutMs: audioTranscribeRequestTimeoutMs(dataUrl)\n   })\n }\n ","@@ -1354,7 +1388,11 @@ export function speakText(text: string): Promise<AudioSpeakResponse> {\n   return window.hermesDesktop.api<AudioSpeakResponse>({\n     path: '/api/audio/speak',\n     method: 'POST',\n-    body: { text }\n+    body: { text },\n+    // TTS blocks until provider synthesis, file read, and base64 encoding\n+    // finish. Remote providers and large messages regularly exceed the\n+    // default 15s Electron backend timeout.\n+    timeoutMs: audioSpeakRequestTimeoutMs(text)\n   })\n }\n \n"]}]}],"trust":{"default_tier":"verified","includes_suggested":false}}