NousResearch/hermes-agent

Auto-TTS toggle for reading responses aloud

Cluster 43767 · apps/desktop. Data as of

Nothing open on GitHub. Fix merged in #55154 on 29 Jun 2026. Check it covers your case.

The short answer

0open issues
0open PRs
3items grouped
Known?
Yes. 3 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #55154. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
Nothing open on GitHub.

These items all propose an automatic text-to-speech feature in the desktop composer that reads AI responses aloud, intended for hands-free use. A toggle in the composer UI is a common element across the proposals.

Open issues0

No open issues.

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #55154 feat(desktop): read replies aloud (auto-TTS) composer toggle

    merged to main

    files: apps/desktop/src/app/chat/composer/controls.tsx, apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts, apps/desktop/src/app/chat/composer/index.tsx, apps/desktop/src/app/session/hooks/use-hermes-config.ts, apps/desktop/src/i18n/en.ts, apps/desktop/src/i18n/ja.ts, apps/desktop/src/i18n/types.ts, apps/desktop/src/i18n/zh-hant.ts, apps/desktop/src/i18n/zh.ts, apps/desktop/src/store/voice-prefs.ts, apps/desktop/src/types/hermes.ts

    Show the diff
    • apps/desktop/src/app/chat/composer/controls.tsx
      @@ -4,7 +4,7 @@ import { KbdCombo } from '@/components/ui/kbd'
       import { Tip } from '@/components/ui/tooltip'
       import { useI18n } from '@/i18n'
       import { triggerHaptic } from '@/lib/haptics'
      -import { AudioLines, Layers3, Loader2, Square, SteeringWheel } from '@/lib/icons'
      +import { AudioLines, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
       import { formatCombo } from '@/lib/keybinds/combo'
       import { cn } from '@/lib/utils'
       
      @@ -39,6 +39,7 @@ interface ConversationProps {
       }
       
       export function ComposerControls({
      +  autoSpeak,
         busy,
         busyAction,
         canSteer,
      @@ -50,8 +51,10 @@ export function ComposerControls({
         state,
         voiceStatus,
         onDictate,
      -  onSteer
      +  onSteer,
      +  onToggleAutoSpeak
       }: {
      +  autoSpeak: boolean
         busy: boolean
         busyAction: 'queue' | 'stop'
         canSteer: boolean
      @@ -64,6 +67,7 @@ export function ComposerControls({
         voiceStatus: VoiceStatus
         onDictate: () => void
         onSteer: () => void
      +  onToggleAutoSpeak: () => void
       }) {
         const { t } = useI18n()
         const c = t.composer
      @@ -105,6 +109,7 @@ export function ComposerControls({
             ) : (
               <DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
             )}
      +      <AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
             {showVoicePrimary ? (
               <Tip label={c.startVoice}>
                 <Button
      @@ -254,6 +259,47 @@ function ConversationIndicator({
         )
       }
       
      +// Pure-TTS toggle: type normally, but have every assistant reply read aloud —
      +// no dictation, no full conversation loop. Filled/accent when on, mirroring the
      +// muted-mic pressed state above. Driven by (and persisted to) `voice.auto_tts`.
      +function AutoSpeakButton({
      +  active,
      +  disabled,
      +  onToggle
      +}: {
      +  active: boolean
      +  disabled: boolean
      +  onToggle: () => void
      +}) {
      +  const { t } = useI18n()
      +  const c = t.composer
      +  const label = active ? c.stopSpeakingReplies : c.speakReplies
      +
      +  return (
      +    <Tip label={label}>
      +      <Button
      +        aria-label={label}
      +        aria-pressed={active}
      +        className={cn(
      +          GHOST_ICON_BTN,
      +          'p-0',
      +          active && 'bg-primary/10 text-primary hover:bg-primary/15 hover:text-primary'
      +        )}
      +        disabled={disabled}
      +        onClick={() => {
      +          triggerHaptic(active ? 'close' : 'open')
      +          onToggle()
      +        }}
      +        size="icon"
      +        type="button"
      +        variant="ghost"
      +      >
      +        {active ? <Volume2 size={14} /> : <VolumeX size={14} />}
      +      </Button>
      +    </Tip>
      +  )
      +}
      +
       function DictationButton({
         disabled,
         state,
    • apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts
      @@ -0,0 +1,79 @@
      +import { useStore } from '@nanostores/react'
      +import { useEffect, useRef } from 'react'
      +
      +import { playSpeechText } from '@/lib/voice-playback'
      +import { notifyError } from '@/store/notifications'
      +import { $messages } from '@/store/session'
      +import { $voicePlayback } from '@/store/voice-playback'
      +import { $autoSpeakReplies } from '@/store/voice-prefs'
      +
      +interface AutoSpeakReply {
      +  id: string
      +  pending: boolean
      +  text: string
      +}
      +
      +interface UseAutoSpeakReplies {
      +  conversationActive: boolean
      +  failureLabel: string
      +  /** Mark the current last reply spoken — shared dedupe with the conversation consumer. */
      +  markSpoken: () => void
      +  /** Latest completed assistant reply, or null; `pending` true while still streaming. */
      +  pendingReply: () => AutoSpeakReply | null
      +  /** Re-arm on session switch so opening a chat never reads its existing last reply. */
      +  sessionId: string | null | undefined
      +}
      +
      +/**
      + * Pure-TTS auto-speak: when `voice.auto_tts` is on, read each completed assistant
      + * turn aloud — no dictation, no conversation loop. Stays off while a full voice
      + * conversation runs (it speaks replies itself) and never overlaps clips: a reply
      + * landing mid-playback is held and spoken on the playback-idle edge. Always reads
      + * the latest reply, so a backlog collapses to the newest.
      + */
      +export function useAutoSpeakReplies({
      +  conversationActive,
      +  failureLabel,
      +  markSpoken,
      +  pendingReply,
      +  sessionId
      +}: UseAutoSpeakReplies) {
      +  const enabled = useStore($autoSpeakReplies)
      +  const latest = useRef({ conversationActive, failureLabel, markSpoken, pendingReply })
      +  latest.current = { conversationActive, failureLabel, markSpoken, pendingReply }
      +
      +  useEffect(() => {
      +    if (!enabled) {
      +      return undefined
      +    }
      +
      +    // Don't read whatever reply already sits at the bottom when the toggle flips
      +    // on (or a chat opens) — consume it so only later replies are spoken.
      +    latest.current.markSpoken()
      +
      +    const speakLatest = () => {
      +      const { conversationActive, failureLabel, markSpoken, pendingReply } = latest.current
      +
      +      if (conversationActive || $voicePlayback.get().status !== 'idle') {
      +        return
      +      }
      +
      +      const reply = pendingReply()
      +
      +      if (!reply || reply.pending) {
      +        return
      +      }
      +
      +      markSpoken()
      +      void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error =>
      +        notifyError(error, failureLabel)
      +      )
      +    }
      +
      +    // Re-check on a reply completing ($messages) and on the prior clip ending
      +    // ($voicePlayback → idle), which frees us to read the next held reply.
      +    const stops = [$messages.subscribe(speakLatest), $voicePlayback.listen(speakLatest)]
      +
      +    return () => stops.forEach(f => f())
      +  }, [enabled, sessionId])
      +}
    • apps/desktop/src/app/chat/composer/index.tsx
      @@ -60,13 +60,14 @@ import {
         updateQueuedPrompt
       } from '@/store/composer-queue'
       import { $statusItemsBySession } from '@/store/composer-status'
      -import { notify } from '@/store/notifications'
      +import { notify, notifyError } from '@/store/notifications'
       import { $previewStatusBySession } from '@/store/preview-status'
       import { listRepoBranches, requestStartWorkSession, startWorkInRepo, switchBranchInRepo } from '@/store/projects'
       import { $activeSessionAwaitingInput } from '@/store/prompts'
       import { toggleReview } from '@/store/review'
       import { $gatewayState, $messages, setSessionPickerOpen } from '@/store/session'
       import { $threadScrolledUp } from '@/store/thread-scroll'
      +import { $autoSpeakReplies, setAutoSpeakReplies } from '@/store/voice-prefs'
       import { isSecondaryWindow } from '@/store/windows'
       import { useTheme } from '@/themes'
       
      @@ -88,6 +89,7 @@ import {
       } from './focus'
       import { HelpHint } from './help-hint'
       import { useAtCompletions } from './hooks/use-at-completions'
      +import { useAutoSpeakReplies } from './hooks/use-auto-speak-replies'
       import { useComposerPopoutGestures } from './hooks/use-popout-drag'
       import { useSlashCompletions } from './hooks/use-slash-completions'
       import { useVoiceConversation } from './hooks/use-voice-conversation'
      @@ -230,6 +232,7 @@ export function ChatBar({
         const statusItemsBySession = useStore($statusItemsBySession)
         const previewStatusBySession = useStore($previewStatusBySession)
         const scrolledUp = useStore($threadScrolledUp)
      +  const autoSpeak = useStore($autoSpeakReplies)
         // The turn is parked on the user (clarify / approval / sudo / secret). Esc must
         // not interrupt it — there's nothing actively running to stop, and stopping
         // would discard a question the user may want to come back to. The blocking
      @@ -2021,6 +2024,20 @@ export function ChatBar({
       
         useEffect(() => onComposerVoiceToggleRequest(toggleVoiceConversation), [toggleVoiceConversation])
       
      +  const handleToggleAutoSpeak = useCallback(() => {
      +    void setAutoSpeakReplies(!$autoSpeakReplies.get()).catch(error =>
      +      notifyError(error, t.settings.config.autosaveFailed)
      +    )
      +  }, [t])
      +
      +  useAutoSpeakReplies({
      +    conversationActive: voiceConversationActive,
      +    failureLabel: t.assistant.thread.readAloudFailed,
      +    markSpoken: consumePendingResponse,
      +    pendingReply: pendingResponse,
      +    sessionId
      +  })
      +
         const contextMenu = (
           <ContextMenu
             onInsertText={insertText}
      @@ -2038,6 +2055,7 @@ export function ChatBar({
       
         const controls = (
           <ComposerControls
      +      autoSpeak={autoSpeak}
             busy={busy}
             busyAction={busyAction}
             canSteer={canSteer}
      @@ -2060,6 +2078,7 @@ export function ChatBar({
             hasComposerPayload={hasComposerPayload}
             onDictate={dictate}
             onSteer={steerDraft}
      +      onToggleAutoSpeak={handleToggleAutoSpeak}
             state={state}
             voiceStatus={voiceStatus}
           />
    • apps/desktop/src/app/session/hooks/use-hermes-config.ts
      @@ -12,6 +12,7 @@ import {
         setCurrentServiceTier,
         setIntroPersonality
       } from '@/store/session'
      +import { applyAutoSpeakFromConfig } from '@/store/voice-prefs'
       
       const DEFAULT_VOICE_SECONDS = 120
       const FAST_TIERS = new Set(['fast', 'priority', 'on'])
      @@ -65,6 +66,7 @@ export function useHermesConfig({ activeSessionIdRef, refreshProjectBranch }: He
       
             setVoiceMaxRecordingSeconds(recordingLimit(config.voice?.max_recording_seconds))
             setSttEnabled(config.stt?.enabled !== false)
      +      applyAutoSpeakFromConfig(config)
           } catch {
             // Config is nice-to-have; chat still works without it.
           }
    • apps/desktop/src/i18n/en.ts
      @@ -1434,6 +1434,8 @@ export const en: Translations = {
           stopDictation: 'Stop dictation',
           transcribingDictation: 'Transcribing dictation',
           voiceDictation: 'Voice dictation',
      +    speakReplies: 'Read replies aloud',
      +    stopSpeakingReplies: 'Stop reading replies aloud',
           lookupLoading: 'Looking up…',
           lookupNoMatches: 'No matches.',
           lookupTry: 'Try',
    • apps/desktop/src/i18n/ja.ts
      @@ -1551,6 +1551,8 @@ export const ja = defineLocale({
           stopDictation: '口述を停止',
           transcribingDictation: '口述を文字起こし中',
           voiceDictation: '音声口述',
      +    speakReplies: '返信を読み上げる',
      +    stopSpeakingReplies: '返信の読み上げを停止',
           lookupLoading: '検索中…',
           lookupNoMatches: '一致なし。',
           lookupTry: '試す',
    • apps/desktop/src/i18n/types.ts
      @@ -1162,6 +1162,8 @@ export interface Translations {
           stopDictation: string
           transcribingDictation: string
           voiceDictation: string
      +    speakReplies: string
      +    stopSpeakingReplies: string
           lookupLoading: string
           lookupNoMatches: string
           lookupTry: string
    • apps/desktop/src/i18n/zh-hant.ts
      @@ -1502,6 +1502,8 @@ export const zhHant = defineLocale({
           stopDictation: '停止聽寫',
           transcribingDictation: '正在轉寫聽寫',
           voiceDictation: '語音聽寫',
      +    speakReplies: '朗讀回覆',
      +    stopSpeakingReplies: '停止朗讀回覆',
           lookupLoading: '查詢中…',
           lookupNoMatches: '沒有相符項目。',
           lookupTry: '試試',
    • apps/desktop/src/i18n/zh.ts
      @@ -1613,6 +1613,8 @@ export const zh: Translations = {
           stopDictation: '停止听写',
           transcribingDictation: '正在转写听写',
           voiceDictation: '语音听写',
      +    speakReplies: '朗读回复',
      +    stopSpeakingReplies: '停止朗读回复',
           lookupLoading: '查找中…',
           lookupNoMatches: '没有匹配项。',
           lookupTry: '试试',
    • apps/desktop/src/store/voice-prefs.ts
      @@ -0,0 +1,38 @@
      +import { atom } from 'nanostores'
      +
      +import { getHermesConfigRecord, saveHermesConfig } from '@/hermes'
      +
      +// "Read replies aloud" — mirrors the canonical `voice.auto_tts` config key (also
      +// in Settings → Voice, honored by the messaging gateway) so the composer toggle
      +// and the Settings switch are one source of truth, not two that can disagree.
      +export const $autoSpeakReplies = atom<boolean>(false)
      +
      +/** Seed the atom from a loaded config payload (mount / refresh). */
      +export function applyAutoSpeakFromConfig(config: { voice?: { auto_tts?: unknown } | null } | null | undefined) {
      +  $autoSpeakReplies.set(Boolean(config?.voice?.auto_tts))
      +}
      +
      +/**
      + * Flip the preference and persist it. Optimistic — the atom updates instantly and
      + * reverts if the config write fails. Read-modify-writes the whole record (the
      + * same path the Settings page uses; there's no partial-update endpoint).
      + */
      +export async function setAutoSpeakReplies(enabled: boolean): Promise<void> {
      +  const previous = $autoSpeakReplies.get()
      +
      +  if (previous === enabled) {
      +    return
      +  }
      +
      +  $autoSpeakReplies.set(enabled)
      +
      +  try {
      +    const record = await getHermesConfigRecord()
      +    const voice = record.voice && typeof record.voice === 'object' ? (record.voice as Record<string, unknown>) : {}
      +
      +    await saveHermesConfig({ ...record, voice: { ...voice, auto_tts: enabled } })
      +  } catch (error) {
      +    $autoSpeakReplies.set(previous)
      +    throw error
      +  }
      +}
    • apps/desktop/src/types/hermes.ts
      @@ -224,6 +224,7 @@ export interface HermesConfig {
         }
         voice?: {
           max_recording_seconds?: number
      +    auto_tts?: boolean
         }
       }
       

1 PR closed without merging

From the close comments: done elsewhere 1

  • #43845 feat(desktop): auto-TTS toggle in composer for hands-free response readback · done elsewhere → 596b813, #55154
Closed issues · 1
  • Closed#43767feat(desktop): auto-TTS / read-aloud on every response (keyboard input + spoken output)
How this cluster was grouped
  • Link evidence (structural links throughout): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
  • Pairwise link density 0.67: the share of item 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.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
apps/desktop
Merged PRs
#55154
Closed issues
1
Attempts
1 PR closed without merging
Fix link checked
29 Jun 2026

For your agent

The same facts, typed.