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