diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx
index dc3f0a490cbc..d508cbb7be4d 100644
--- a/apps/desktop/src/app/chat/composer/index.tsx
+++ b/apps/desktop/src/app/chat/composer/index.tsx
@@ -25,6 +25,7 @@ import { desktopSlashCommandTakesArgs } from '@/lib/desktop-slash-commands'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
+import { isVoicePlaybackActive, playSpeechText } from '@/lib/voice-playback'
import {
$composerAttachments,
clearComposerAttachments,
@@ -157,6 +158,7 @@ const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(
const DRAFT_PERSIST_DEBOUNCE_MS = 400
export function ChatBar({
+ autoTtsEnabled,
busy,
cwd,
disabled,
@@ -1528,6 +1530,38 @@ export function ChatBar({
}
}, [autoDrainNext, busy, queuedPrompts.length])
+ // Auto-TTS: speak last assistant message on busy → false, when voice.auto_tts is on.
+ const wasBusyRef = useRef(false)
+ const autoTtsSessionRef = useRef(sessionId)
+
+ // Reset on session switch — ChatBar is persistent, refs survive across sessions.
+ if (autoTtsSessionRef.current !== sessionId) {
+ autoTtsSessionRef.current = sessionId
+ wasBusyRef.current = false
+ }
+
+ useEffect(() => {
+ if (busy) {
+ wasBusyRef.current = true
+
+ return
+ }
+
+ if (!wasBusyRef.current || !autoTtsEnabled || voiceConversationActive || isVoicePlaybackActive()) {
+ return
+ }
+
+ const response = pendingResponse()
+
+ if (!response || response.pending) {
+ return
+ }
+
+ // Consume before async call to prevent double-play on a second effect fire.
+ consumePendingResponse()
+ void playSpeechText(response.text, { messageId: response.id, source: 'read-aloud' })
+ }, [busy]) // eslint-disable-line react-hooks/exhaustive-deps
+
// Queue-edit cleanup: on session swap the scope effect already stashed the
// edit snapshot; only restore into the composer when still on the same scope.
useEffect(() => {
@@ -1899,6 +1933,7 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
+
-
{queueEdit && editingQueuedPrompt && (
diff --git a/apps/desktop/src/app/chat/composer/types.ts b/apps/desktop/src/app/chat/composer/types.ts
index 36b3b8e6d3d8..a7318ad06d8b 100644
--- a/apps/desktop/src/app/chat/composer/types.ts
+++ b/apps/desktop/src/app/chat/composer/types.ts
@@ -28,6 +28,7 @@ export interface ChatBarState {
}
export interface ChatBarProps {
+ autoTtsEnabled?: boolean
busy: boolean
disabled: boolean
focusKey?: string | null
diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx
index f890a5bfe6c3..928ce2a15be9 100644
--- a/apps/desktop/src/app/chat/index.tsx
+++ b/apps/desktop/src/app/chat/index.tsx
@@ -60,6 +60,7 @@ import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { threadLoadingState } from './thread-loading'
interface ChatViewProps extends Omit
, 'onSubmit'> {
+ autoTtsEnabled?: boolean
gateway: HermesGateway | null
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
@@ -256,6 +257,7 @@ export function ChatView({
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
+ autoTtsEnabled,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
@@ -432,6 +434,7 @@ export function ChatView({
{showChatBar && (
}>
{
try {
@@ -65,10 +66,11 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
setSttEnabled(config.stt?.enabled !== false)
+ setAutoTtsEnabled(config.voice?.auto_tts === true)
} catch {
// Config is nice-to-have; chat still works without it.
}
}, [activeSessionIdRef, refreshProjectBranch])
- return { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
+ return { autoTtsEnabled, refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds }
}
diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts
index 5d8d70b38a89..93f795f147e2 100644
--- a/apps/desktop/src/hermes.ts
+++ b/apps/desktop/src/hermes.ts
@@ -741,7 +741,10 @@ export function speakText(text: string): Promise {
return window.hermesDesktop.api({
path: '/api/audio/speak',
method: 'POST',
- body: { text }
+ body: { text },
+ // Long-response synthesis exceeds the app-wide 15s default; the backend
+ // caps edge-tts at 60s (tools/tts_tool.py), so give the HTTP layer headroom
+ timeoutMs: 75_000
})
}
diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts
index 627fe5e53e1f..c8c204296822 100644
--- a/apps/desktop/src/types/hermes.ts
+++ b/apps/desktop/src/types/hermes.ts
@@ -182,6 +182,7 @@ export interface HermesConfig {
enabled?: boolean
}
voice?: {
+ auto_tts?: boolean
max_recording_seconds?: number
}
}
diff --git a/tui_gateway/server.py b/tui_gateway/server.py
index d34f558f6cfd..9e23f92a78db 100644
--- a/tui_gateway/server.py
+++ b/tui_gateway/server.py
@@ -6032,14 +6032,18 @@ def _stream(delta):
pass
# CLI parity: when voice-mode TTS is on, speak the agent reply
- # (cli.py:_voice_speak_response). Only the final text — tool
- # calls / reasoning already stream separately and would be
- # noisy to read aloud.
+ # WS clients (desktop, dashboard) do their own client-side playback,
+ # so speaking here too would double the audio — restrict server-side
+ # speech to non-WS transports (TUI / stdio CLI). Class-name check
+ # avoids a circular import of tui_gateway.ws.
+ _transport = session.get("transport")
+ _is_ws_client = type(_transport).__name__ == "WSTransport"
if (
status == "complete"
and isinstance(raw, str)
and raw.strip()
and _voice_tts_enabled()
+ and not _is_ws_client
):
try:
from hermes_cli.voice import speak_text
@@ -9230,8 +9234,20 @@ def _voice_mode_enabled() -> bool:
def _voice_tts_enabled() -> bool:
- """Whether agent replies should be spoken back via TTS (runtime only)."""
- return os.environ.get("HERMES_VOICE_TTS", "").strip() == "1"
+ """Whether agent replies should be spoken back via TTS.
+
+ Runtime flag (``HERMES_VOICE_TTS`` env var) takes precedence so an
+ in-session toggle survives config reloads. When the flag has never been
+ set this session, fall back to ``voice.auto_tts`` in config.yaml so the
+ setting takes effect immediately on first launch without a manual toggle.
+ """
+ raw = os.environ.get("HERMES_VOICE_TTS", "")
+ if raw.strip():
+ return raw.strip() == "1"
+ # Seed from config on first access (flag not yet set this session).
+ enabled = bool(_voice_cfg_dict().get("auto_tts", False))
+ os.environ["HERMES_VOICE_TTS"] = "1" if enabled else "0"
+ return enabled
def _voice_cfg_dict() -> dict:
@@ -9334,8 +9350,6 @@ def _(rid, params: dict) -> dict:
)
if action == "tts":
- if not _voice_mode_enabled():
- return _err(rid, 4014, "enable voice mode first: /voice on")
new_value = not _voice_tts_enabled()
# Runtime-only flag (CLI parity) — see voice.toggle on/off above.
os.environ["HERMES_VOICE_TTS"] = "1" if new_value else "0"