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"