# NousResearch/hermes-agent · Auto-TTS toggle for reading responses aloud

## Open now

- 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.

## TL;DR

- 3 member(s): 0 open issue(s), 1 closed issue(s), 0 open PR(s), 1 merged PR(s), 1 PR(s) closed without merging.
- A fix was merged to main in PR #55154. Verify it addresses your case.
- **Recommended:** read PR #55154 (and any competing PRs below) before filing. Confirm the merged change covers your symptom.

## Maintainer resolution verdict

- Internal-tracker verdict over this cluster's issue members: `unresolved`. This is distinct from the fix status above. Weigh it yourself.

## How this cluster was grouped

- Link evidence (`STRUCTURAL_SPANNING`): every member is connected by structural evidence: shared changed files, or a closing/superseded reference.
- Pairwise link density `0.67`: the share of member 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.

## Closed / duplicate issues

- #43767: feat(desktop): auto-TTS / read-aloud on every response (keyboard input + spoken output)

## Prior & competing PRs

- **#55154: feat(desktop): read replies aloud (auto-TTS) composer toggle**. merged to main · fix tier `verified`
  - maintainer direction: category `merged`
  - 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`
  - fix hunks:
  - `apps/desktop/src/app/chat/composer/controls.tsx`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -39,6 +39,7 @@ interface ConversationProps {
     }
     
     export function ComposerControls({
    +  autoSpeak,
       busy,
       busyAction,
       canSteer,
    ```

    ```diff
    @@ -50,8 +51,10 @@ export function ComposerControls({
       state,
       voiceStatus,
       onDictate,
    -  onSteer
    +  onSteer,
    +  onToggleAutoSpeak
     }: {
    +  autoSpeak: boolean
       busy: boolean
       busyAction: 'queue' | 'stop'
       canSteer: boolean
    ```

    ```diff
    @@ -64,6 +67,7 @@ export function ComposerControls({
       voiceStatus: VoiceStatus
       onDictate: () => void
       onSteer: () => void
    +  onToggleAutoSpeak: () => void
     }) {
       const { t } = useI18n()
       const c = t.composer
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -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'
     
    ```

    ```diff
    @@ -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'
    ```

    ```diff
    @@ -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
    ```

    ```diff
    @@ -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}
    ```

    ```diff
    @@ -2038,6 +2055,7 @@ export function ChatBar({
     
       const controls = (
         <ComposerControls
    +      autoSpeak={autoSpeak}
           busy={busy}
           busyAction={busyAction}
           canSteer={canSteer}
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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'])
    ```

    ```diff
    @@ -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`

    ```diff
    @@ -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`

    ```diff
    @@ -1551,6 +1551,8 @@ export const ja = defineLocale({
         stopDictation: '口述を停止',
         transcribingDictation: '口述を文字起こし中',
         voiceDictation: '音声口述',
    +    speakReplies: '返信を読み上げる',
    +    stopSpeakingReplies: '返信の読み上げを停止',
         lookupLoading: '検索中…',
         lookupNoMatches: '一致なし。',
         lookupTry: '試す',
    ```
  - `apps/desktop/src/i18n/types.ts`

    ```diff
    @@ -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`

    ```diff
    @@ -1502,6 +1502,8 @@ export const zhHant = defineLocale({
         stopDictation: '停止聽寫',
         transcribingDictation: '正在轉寫聽寫',
         voiceDictation: '語音聽寫',
    +    speakReplies: '朗讀回覆',
    +    stopSpeakingReplies: '停止朗讀回覆',
         lookupLoading: '查詢中…',
         lookupNoMatches: '沒有相符項目。',
         lookupTry: '試試',
    ```
  - `apps/desktop/src/i18n/zh.ts`

    ```diff
    @@ -1613,6 +1613,8 @@ export const zh: Translations = {
         stopDictation: '停止听写',
         transcribingDictation: '正在转写听写',
         voiceDictation: '语音听写',
    +    speakReplies: '朗读回复',
    +    stopSpeakingReplies: '停止朗读回复',
         lookupLoading: '查找中…',
         lookupNoMatches: '没有匹配项。',
         lookupTry: '试试',
    ```
  - `apps/desktop/src/store/voice-prefs.ts`

    ```diff
    @@ -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`

    ```diff
    @@ -224,6 +224,7 @@ export interface HermesConfig {
       }
       voice?: {
         max_recording_seconds?: number
    +    auto_tts?: boolean
       }
     }
     
    ```
- **#43845: feat(desktop): auto-TTS toggle in composer for hands-free response readback**. closed without merge
  - maintainer direction: category `implemented_elsewhere`; implication `superseded`; refs `596b813c9b03ecdc46b8c6a75aa1cb42e79eb7d2`, `#55154`

## Fix status

- Merged to main in PR #55154 (cited above). No release data is tracked. Verify downstream.
- Fix last verified (merged) `2026-06-29`.
- Subsystem: `apps/desktop`
