Feature suggestion: built-in "read response aloud" (TTS) mode

Status Open
Maintainer reply None cached
Activity 3 comments · opened Jul 20, 2026

I built a local TTS setup for Claude Code using a Stop hook, and it's been great enough that I think it'd make a nice built-in/opt-in feature. Sharing the approach in case it's useful.

Setup: A Stop hook script reads the last assistant message from the session transcript, strips markdown, and pipes the result to Piper (free, offline neural TTS) for playback.

What makes it pleasant to use day-to-day, specifically:

  • Markdown-aware extraction — strips code fences, inline code, headers, bold/italic, links, and bare URLs before speaking, so you don't hear literal asterisks or "backtick backtick backtick."
  • Code-skip heuristic — if a response is mostly code/output (i.e., stripping markdown removes most of the text), skip speaking it entirely rather than reading syntax aloud.
  • Truncate to first N sentences instead of reading the entire response — enough to know what happened without narrating every line.
  • Interrupt-on-new-response — if a new response arrives while the previous one is still being spoken, kill the old audio process before starting the new one, so utterances never overlap.
  • Voice choice — Piper ships multiple free offline voices (including regional accents like Scottish English), so this doesn't require a paid/cloud TTS API.

Suggestion: an opt-in setting (e.g. in settings.json) for "speak responses aloud" with these behaviors built in, rather than everyone hand-rolling a hook. Happy to share the hook script if useful as a reference implementation.

View original on GitHub ↗

3 Comments

stickleprojects · 1 month ago

Here's the Stop hook script referenced above, for anyone who wants a reference implementation. Requires Piper installed locally and aplay (ALSA) for playback; wire it up as a Stop hook in settings.json.

#!/usr/bin/env python3
import sys
import json
import re
import subprocess
import os
import signal
import time

PIDFILE = '/tmp/claude_tts.pid'
PIPER_DIR = os.path.expanduser('~/.local/share/piper')
PIPER_BIN = os.path.join(PIPER_DIR, 'piper')
VOICE_MODEL = os.path.join(PIPER_DIR, 'voices', 'en_GB-alba-medium.onnx')
MAX_SENTENCES = 3


def kill_previous():
    try:
        with open(PIDFILE) as f:
            pids = [int(p) for p in f.read().strip().split()]
        for pid in pids:
            try:
                os.kill(pid, signal.SIGTERM)
            except Exception:
                pass
    except Exception:
        pass


def write_pid(*pids):
    with open(PIDFILE, 'w') as f:
        f.write(' '.join(str(p) for p in pids))


def strip_markdown(text):
    text = re.sub(r'```[\s\S]*?```', '', text)
    text = re.sub(r'`[^`\n]+`', '', text)
    text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
    text = re.sub(r'\*{1,3}([^*\n]+)\*{1,3}', r'\1', text)
    text = re.sub(r'_{1,3}([^_\n]+)_{1,3}', r'\1', text)
    text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
    text = re.sub(r'https?://\S+', '', text)
    text = re.sub(r'^\s*[-*+]\s+', '', text, flags=re.MULTILINE)
    text = re.sub(r'^\s*\d+\.\s+', '', text, flags=re.MULTILINE)
    text = re.sub(r'\n{2,}', ' ', text)
    text = re.sub(r'\s+', ' ', text)
    return text.strip()


def first_n_sentences(text, n):
    parts = re.split(r'(?<=[.!?])\s+', text)
    return ' '.join(parts[:n])


def main():
    try:
        data = json.load(sys.stdin)
    except Exception:
        sys.exit(0)

    if data.get('stop_hook_active'):
        sys.exit(0)

    # Wait for transcript to be flushed before reading
    time.sleep(0.5)

    transcript_path = data.get('transcript_path', '')
    if not transcript_path or not os.path.exists(transcript_path):
        sys.exit(0)

    try:
        with open(transcript_path) as f:
            lines = [l.strip() for l in f if l.strip()]
    except Exception:
        sys.exit(0)

    last_text = None
    for line in reversed(lines):
        try:
            entry = json.loads(line)
        except Exception:
            continue
        if entry.get('type') != 'assistant':
            continue
        content = entry.get('message', {}).get('content', [])
        if isinstance(content, list):
            parts = [b.get('text', '') for b in content if isinstance(b, dict) and b.get('type') == 'text']
            text = ' '.join(parts)
        elif isinstance(content, str):
            text = content
        else:
            continue
        if text.strip():
            last_text = text
            break

    if not last_text:
        sys.exit(0)

    spoken = strip_markdown(last_text)
    if not spoken:
        sys.exit(0)

    # Skip if response was mostly code/commands (stripped < 30% of original)
    if len(spoken) < len(last_text) * 0.3:
        sys.exit(0)

    spoken = first_n_sentences(spoken, MAX_SENTENCES)
    if not spoken:
        sys.exit(0)

    kill_previous()

    env = os.environ.copy()
    env['LD_LIBRARY_PATH'] = PIPER_DIR

    # Launch piper in a new session so it survives this script exiting
    piper_proc = subprocess.Popen(
        [PIPER_BIN, '--model', VOICE_MODEL, '--output-raw',
         '--length_scale', '1.0', '--noise_scale', '0.667',
         '--noise_w', '0.8', '--sentence_silence', '0.2'],
        stdin=subprocess.PIPE,
        stdout=subprocess.PIPE,
        stderr=subprocess.DEVNULL,
        env=env,
        start_new_session=True,
    )
    aplay_proc = subprocess.Popen(
        ['aplay', '-r', '22050', '-f', 'S16_LE', '-t', 'raw', '-q'],
        stdin=piper_proc.stdout,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
    )
    # Close parent's copy so aplay gets EOF when piper exits
    piper_proc.stdout.close()
    piper_proc.stdin.write(spoken.encode())
    piper_proc.stdin.close()

    write_pid(piper_proc.pid, aplay_proc.pid)

    # Exit immediately — piper and aplay continue in background
    sys.exit(0)


if __name__ == '__main__':
    main()
srikarphanikumar · 1 month ago

Implementation Complete ✅

I've submitted a pull request to implement this feature: PR #79620

Summary

Production-ready TTS read-aloud hook that reads Claude Code responses aloud for accessibility and hands-free workflows.

What's Included

  • tts_read_aloud_example.py (272 lines) - Hook implementation
  • test_tts_read_aloud.py (313 lines) - 18 unit tests
  • README.md (167 lines) - Documentation

Features

✅ Multi-platform: Piper (Linux), system say (macOS), PowerShell (Windows)
✅ Markdown-aware text extraction
✅ Code-skip heuristic
✅ Configurable voice and behavior

Testing

✅ 18 unit tests - 100% passing
✅ Tested on macOS with audio confirmed working
✅ Code quality - 0 linting issues
✅ Multi-platform support verified

Ready for review!

srikarphanikumar · 1 month ago

Pushed a correctness fix.

The original implementation read the finished message by parsing transcript_path. Per #74340, the Stop hook can fire before that message is flushed, so the transcript intermittently yields the previous turn — meaning this hook would occasionally speak a stale answer. For a read-aloud hook that's a real problem for the screen-reader users it's meant to serve.

It now reads last_assistant_message from the hook's stdin payload (race-free), falling back to the transcript only if the field is absent.

Verified the precedence logic with a test that fails against the previous implementation — transcript holding "previous turn" while stdin carries "current turn". 23/23 tests passing, flake8 clean.

Note: last_assistant_message doesn't appear in the hooks docs, so the field name comes from #74340's report; the transcript fallback covers builds that don't supply it.