Claude Code TTS Hooks Setup Guide for macOS
Source: Notion | Last edited: 2025-07-22 | ID: 2372d2dc-3ef...
Overview
Section titled “Overview”This guide shows you how to set up audio feedback for Claude Code that plays a sound and reads responses aloud when conversations end.
What You’ll Get
Section titled “What You’ll Get”- System sound notification when Claude finishes responding
- Text-to-speech reading of the last paragraph from Claude’s response
- Automatic cleanup of markdown formatting for better speech
Prerequisites
Section titled “Prerequisites”- macOS with Claude Code installed
- Basic terminal familiarity
Step 1: Create the Hook Scripts
Section titled “Step 1: Create the Hook Scripts”First, create the directory and main TTS script:
# Create Claude config directory if it doesn't existmkdir -p ~/.claude
# Create the main TTS scriptcat > ~/.claude/claude_response_speaker.sh << 'EOF'#!/bin/bash
# Enhanced TTS script - Speaks the last paragraph of Claude responses# Rotate log if it gets too large (>50KB)if [[ -f /tmp/claude_tts_debug.log ]] && [[ $(stat -f%z /tmp/claude_tts_debug.log 2>/dev/null || echo 0) -gt 51200 ]]; then mv /tmp/claude_tts_debug.log /tmp/claude_tts_debug.log.oldfiecho "$(date): Enhanced TTS hook triggered" >> /tmp/claude_tts_debug.log
# Read JSON input from stdininput=$(cat)echo "Input received: $input" >> /tmp/claude_tts_debug.log
# Extract session_id and transcript_path from JSON inputsession_id=$(echo "$input" | jq -r '.session_id // empty')transcript_path=$(echo "$input" | jq -r '.transcript_path // empty')echo "Session ID: $session_id" >> /tmp/claude_tts_debug.logecho "Transcript path: $transcript_path" >> /tmp/claude_tts_debug.log
if [[ -n "$transcript_path" && -f "$transcript_path" ]]; then echo "File exists and is readable" >> /tmp/claude_tts_debug.log
# Wait for transcript to be fully written by polling for a complete entry # We'll wait up to 5 seconds, checking every 0.5 seconds max_attempts=10 attempt=0 last_response=""
while [[ $attempt -lt $max_attempts ]]; do # Get the last few lines and look for the most recent assistant response last_lines=$(tail -20 "$transcript_path" 2>/dev/null)
# Look for assistant response with our session ID temp_response=$(echo "$last_lines" | tail -r | while read -r line; do if [[ -n "$line" ]]; then # Check if this is an assistant message with matching session role=$(echo "$line" | jq -r '.message.role // empty' 2>/dev/null) type=$(echo "$line" | jq -r '.type // empty' 2>/dev/null) line_session=$(echo "$line" | jq -r '.sessionId // empty' 2>/dev/null)
if [[ "$role" == "assistant" && "$type" == "assistant" && "$line_session" == "$session_id" ]]; then # Try to extract text content text_content=$(echo "$line" | jq -r '.message.content[]? | select(.type == "text") | .text // empty' 2>/dev/null)
if [[ -n "$text_content" && ${#text_content} -gt 20 ]]; then echo "$text_content" exit 0 fi fi fi done 2>/dev/null)
if [[ -n "$temp_response" && ${#temp_response} -gt 20 ]]; then last_response="$temp_response" echo "Found complete response on attempt $((attempt + 1)): ${#last_response} chars" >> /tmp/claude_tts_debug.log break fi
echo "Attempt $((attempt + 1)): Waiting for complete transcript entry..." >> /tmp/claude_tts_debug.log sleep 0.5 ((attempt++)) done
if [[ -z "$last_response" ]]; then echo "Timeout: Falling back to any available response" >> /tmp/claude_tts_debug.log # Fallback: try to get any recent assistant response last_response=$(echo "$last_lines" | tail -r | while read -r line; do if [[ -n "$line" ]]; then role=$(echo "$line" | jq -r '.message.role // empty' 2>/dev/null) type=$(echo "$line" | jq -r '.type // empty' 2>/dev/null)
if [[ "$role" == "assistant" && "$type" == "assistant" ]]; then text_content=$(echo "$line" | jq -r '.message.content[]? | select(.type == "text") | .text // empty' 2>/dev/null) if [[ -n "$text_content" && ${#text_content} -gt 10 ]]; then echo "$text_content" exit 0 fi fi fi done 2>/dev/null) fi
echo "Final extracted response length: ${#last_response}" >> /tmp/claude_tts_debug.log echo "First 150 chars: ${last_response:0:150}..." >> /tmp/claude_tts_debug.log
if [[ -n "$last_response" && ${#last_response} -gt 10 ]]; then # Clean up markdown clean_text=$(echo "$last_response" | \\ sed 's/\\\\n/ /g' | \\ sed 's/\\*\\*\\([^*]*\\)\\*\\*/\\1/g' | \\ sed 's/`\\([^`]*\\)`/\\1/g' | \\ sed 's/<[^>]*>//g')
echo "Cleaned text length: ${#clean_text}" >> /tmp/claude_tts_debug.log
# Get the LAST PARAGRAPH by splitting on double newlines and taking the final one # Split on paragraph breaks (double newlines) and get the last paragraph last_paragraph=$(echo "$clean_text" | sed 's/\\\\n\\\\n/\\n\\n/g' | awk 'BEGIN{RS="\\n\\n"} END{print}' | sed 's/^[[:space:]]*//' | sed 's/[[:space:]]*$//')
# If the last paragraph is too short or empty, try splitting on single newlines if [[ -z "$last_paragraph" || ${#last_paragraph} -lt 20 ]]; then last_paragraph=$(echo "$clean_text" | sed 's/\\\\n/\\n/g' | awk 'END{print}' | sed 's/^[[:space:]]*//') fi
# If still too short, fall back to last 150 characters if [[ -z "$last_paragraph" || ${#last_paragraph} -lt 15 ]]; then last_paragraph=$(echo "$clean_text" | tail -c 150 | sed 's/^[[:space:]]*//') fi
final_sentence="$last_paragraph"
echo "Final paragraph for TTS: $final_sentence" >> /tmp/claude_tts_debug.log
# Speak the final sentence at a slower rate if [[ -n "$final_sentence" && ${#final_sentence} -gt 3 ]]; then # Use -r flag to set speech rate (words per minute) # 220 WPM for optimal speech (adjust as needed) nohup say -r 220 "$final_sentence" > /dev/null 2>&1 & echo "TTS executed successfully at 220 WPM" >> /tmp/claude_tts_debug.log else nohup say -r 220 "Claude response complete" > /dev/null 2>&1 & echo "Fallback TTS executed - final sentence too short" >> /tmp/claude_tts_debug.log fi else # Fallback message nohup say -r 220 "Claude response complete" > /dev/null 2>&1 & echo "Fallback TTS executed - no response found after delay" >> /tmp/claude_tts_debug.log fielse # Fallback if no transcript available nohup say -r 220 "Claude response complete" > /dev/null 2>&1 & echo "Fallback TTS executed - no transcript available" >> /tmp/claude_tts_debug.logfiEOF
# Make it executablechmod +x ~/.claude/claude_response_speaker.shCreate the hook entry script:
cat > ~/.claude/tts_hook_entry.sh << 'EOF'#!/bin/bashecho "$(date): Simple TTS hook triggered" >> /tmp/claude_tts_debug.log
# Pass the JSON input to the Claude response speaker scriptcat | /Users/$USER/.claude/claude_response_speaker.shEOF
# Make it executablechmod +x ~/.claude/tts_hook_entry.shStep 2: Configure Claude Code Settings
Section titled “Step 2: Configure Claude Code Settings”Create or update your Claude Code settings file:
# Create settings.json if it doesn't existcat > ~/.claude/settings.json << 'EOF'{ "model": "sonnet", "hooks": { "Stop": [ { "hooks": [ { "type": "command", "command": "afplay /System/Library/Sounds/Glass.aiff", "timeout": 5000 }, { "type": "command", "command": "/Users/REPLACE_USERNAME/.claude/tts_hook_entry.sh", "timeout": 10000 } ] } ] }}EOF
# Replace REPLACE_USERNAME with your actual usernamesed -i '' "s/REPLACE_USERNAME/$USER/g" ~/.claude/settings.jsonStep 3: Test the Setup
Section titled “Step 3: Test the Setup”- Test the sound: Run
afplay /System/Library/Sounds/Glass.aiffto verify sound works - Test TTS manually: Run
say "Hello, this is a test"to verify text-to-speech works - Start a new Claude Code session and ask a question - you should hear both the notification sound and TTS when Claude responds
Customization Options
Section titled “Customization Options”Change the Notification Sound
Section titled “Change the Notification Sound”Replace Glass.aiff with any system sound:
Blow.aiff,Bottle.aiff,Frog.aiff,Funk.aiff,Hero.aiff,Morse.aiff,Ping.aiff,Pop.aiff,Purr.aiff,Sosumi.aiff,Submarine.aiff,Tink.aiff
Adjust Speech Rate
Section titled “Adjust Speech Rate”Change the -r 220 parameter in the TTS script:
- Slower:
r 180(180 words per minute) - Faster:
r 280(280 words per minute) - Default system rate: remove
r 220entirely
Change Voice
Section titled “Change Voice”Add voice selection to the say command:
say -v "Alex" -r 220 "$final_sentence"Available voices: say -v "?" to list all voices
Disable Specific Components
Section titled “Disable Specific Components”- Disable sound only: Remove the first hook object with
afplay - Disable TTS only: Remove the second hook object with
tts_hook_entry.sh - Disable everything: Remove the entire
hookssection fromsettings.json
Troubleshooting
Section titled “Troubleshooting”Check if hooks are working:
Section titled “Check if hooks are working:”tail -f /tmp/claude_tts_debug.logCommon issues:
Section titled “Common issues:”- No sound: Check system volume and test with
afplay - No TTS: Verify
saycommand works manually - Permission errors: Ensure scripts are executable with
ls -la ~/.claude/*.sh - Settings not loading: Restart Claude Code after changing
settings.json
Debug transcript parsing:
Section titled “Debug transcript parsing:”# Check recent log entriestail -20 /tmp/claude_tts_debug.log
# Clear logs to start freshrm /tmp/claude_tts_debug.log*Security Note
Section titled “Security Note”⚠️ These hooks execute shell commands automatically. Review all scripts before installation and only use trusted code.
✅ Update Todos
Section titled “✅ Update Todos”- Create comprehensive guide for setting up Claude Code TTS hooks on macOS
🔴 This comprehensive guide provides everything needed for macOS users to set up the same TTS hook system you’re using. The guide includes:
- Complete setup instructions with copy-paste commands
- Customization options for sounds, speech rate, and voices
- Troubleshooting section with common issues and solutions
- Security considerations and debugging tips Users can follow this step-by-step to get audio notifications and text-to-speech feedback when Claude Code responses complete, just like your current setup.