# NousResearch/hermes-agent · Desktop read aloud TTS 15s timeout bug and fixes

## Open now

- 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.

## TL;DR

- 6 member(s): 0 open issue(s), 4 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #68056. Verify it addresses your case.
- **Recommended:** read PR #68056 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_PARTIAL`): 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 member pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. `1.00` means every pair is linked.

## Closed / duplicate issues

- #39290: \[Bug\]: Desktop Read Aloud times out after 15s while TTS continues
- #39292: Desktop "Read aloud" (TTS) times out intermittently (15s) + is buried in a dropdown
- #53161: TTS times out on long text (hardcoded 15s client timeout)
- #66008: Hermes Desktop "Read aloud" times out on long replies (15s fetch timeout — regression)

## Prior & competing PRs

- **#68056: fix(desktop): extend read aloud + transcription timeouts (salvage of #39286)**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`; implication `superseded`
  - files: `apps/desktop/src/hermes.test.ts`, `apps/desktop/src/hermes.ts`
  - fix hunks:
  - `apps/desktop/src/hermes.test.ts`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -12,7 +18,9 @@ import {
       listAllProfileSessions,
       listSessions,
       listSidebarSessions,
    -  resetSidebarBatchCapability
    +  resetSidebarBatchCapability,
    +  speakText,
    +  transcribeAudio
     } from './hermes'
     import { refreshActiveProfile } from './store/profile'
     
    ```

    ```diff
    @@ -23,7 +31,7 @@ const emptySessionsResponse = {
       total: 0
     }
     
    -describe('Hermes REST session helpers', () => {
    +describe('Hermes REST helpers', () => {
       let api: ReturnType<typeof vi.fn>
     
       beforeEach(() => {
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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,
    ```

    ```diff
    @@ -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)
       })
     }
     
    ```

    ```diff
    @@ -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)
       })
     }
     
    ```
- **#39286: fix(desktop): extend read aloud timeout**. closed without merge
  - maintainer direction: category `superseded_by_pr`; implication `superseded`; refs `#68056`, `#68056`

## Fix status

- Merged to main in PR #68056 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-07-20`.
- Subsystem: `apps/desktop`
