Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion apps/desktop/src/app/chat/composer/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -157,6 +158,7 @@ const cloneAttachments = (attachments: ComposerAttachment[]) => attachments.map(
const DRAFT_PERSIST_DEBOUNCE_MS = 400

export function ChatBar({
autoTtsEnabled,
busy,
cwd,
disabled,
Expand Down Expand Up @@ -1528,6 +1530,38 @@ export function ChatBar({
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Current main has a single auto-speech controller in hooks/use-auto-speak-replies.ts, composed through useComposerVoice. Please port the session-switch guard into that controller rather than adding a second busy-edge effect here; otherwise the current playback/deduplication flow would be duplicated.

}, [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(() => {
Expand Down Expand Up @@ -1899,6 +1933,7 @@ export function ChatBar({
composerSurfaceGlass
)}
/>
<VoicePlaybackActivity />
<div
className={cn(
'relative z-1 flex min-h-0 w-full flex-col gap-(--composer-row-gap) overflow-hidden rounded-[inherit] px-(--composer-surface-pad-x) py-(--composer-surface-pad-y) transition-opacity duration-200 ease-out',
Expand All @@ -1907,7 +1942,6 @@ export function ChatBar({
data-slot="composer-fade"
>
<VoiceActivity state={voiceActivityState} />
<VoicePlaybackActivity />
{queueEdit && editingQueuedPrompt && (
<div className="flex items-center justify-between gap-2 rounded-lg border border-[color-mix(in_srgb,var(--dt-composer-ring)_32%,transparent)] bg-accent/18 px-2 py-1">
<div className="min-w-0 text-[0.7rem] text-muted-foreground/88">
Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/app/chat/composer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface ChatBarState {
}

export interface ChatBarProps {
autoTtsEnabled?: boolean
busy: boolean
disabled: boolean
focusKey?: string | null
Expand Down
3 changes: 3 additions & 0 deletions apps/desktop/src/app/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import { SessionActionsMenu } from './sidebar/session-actions-menu'
import { threadLoadingState } from './thread-loading'

interface ChatViewProps extends Omit<React.ComponentProps<'div'>, 'onSubmit'> {
autoTtsEnabled?: boolean
gateway: HermesGateway | null
onToggleSelectedPin: () => void
onDeleteSelectedSession: () => void
Expand Down Expand Up @@ -256,6 +257,7 @@ export function ChatView({
onAddUrl,
onAttachImageBlob,
onAttachDroppedItems,
autoTtsEnabled,
onBranchInNewChat,
maxVoiceRecordingSeconds,
onPasteClipboardImage,
Expand Down Expand Up @@ -432,6 +434,7 @@ export function ChatView({
{showChatBar && (
<Suspense fallback={<ChatBarFallback />}>
<ChatBar
autoTtsEnabled={autoTtsEnabled}
busy={busy}
cwd={currentCwd}
disabled={!gatewayOpen}
Expand Down
3 changes: 2 additions & 1 deletion apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -530,7 +530,7 @@ export function DesktopController() {
requestGateway
})

const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
const { autoTtsEnabled, refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
activeSessionIdRef,
refreshProjectBranch
})
Expand Down Expand Up @@ -967,6 +967,7 @@ export function DesktopController() {

const chatView = (
<ChatView
autoTtsEnabled={autoTtsEnabled}
gateway={gatewayRef.current}
maxVoiceRecordingSeconds={voiceMaxRecordingSeconds}
onAddContextRef={composer.addContextRefAttachment}
Expand Down
4 changes: 3 additions & 1 deletion apps/desktop/src/app/session/hooks/use-hermes-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ interface HermesConfigOptions {
export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: HermesConfigOptions) {
const [voiceMaxRecordingSeconds, setVoiceMaxRecordingSeconds] = useState(DEFAULT_VOICE_SECONDS)
const [sttEnabled, setSttEnabled] = useState(true)
const [autoTtsEnabled, setAutoTtsEnabled] = useState(false)

const refreshHermesConfig = useCallback(async () => {
try {
Expand Down Expand Up @@ -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 }
}
5 changes: 4 additions & 1 deletion apps/desktop/src/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -741,7 +741,10 @@ export function speakText(text: string): Promise<AudioSpeakResponse> {
return window.hermesDesktop.api<AudioSpeakResponse>({
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
})
}

Expand Down
1 change: 1 addition & 0 deletions apps/desktop/src/types/hermes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ export interface HermesConfig {
enabled?: boolean
}
voice?: {
auto_tts?: boolean
max_recording_seconds?: number
}
}
Expand Down
28 changes: 21 additions & 7 deletions tui_gateway/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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"
Expand Down