diff --git a/apps/desktop/src/hermes.test.ts b/apps/desktop/src/hermes.test.ts index 46a7cef5f928..f829471c5a33 100644 --- a/apps/desktop/src/hermes.test.ts +++ b/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 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() diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index d84f85815d85..a3d4199fa789 100644 --- a/apps/desktop/src/hermes.ts +++ b/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 { return window.hermesDesktop.api({ 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) }) }