Skip to content

Claude Code TTS Hooks Setup Guide for macOS

Source: Notion | Last edited: 2025-07-22 | ID: 2372d2dc-3ef...


This guide shows you how to set up audio feedback for Claude Code that plays a sound and reads responses aloud when conversations end.

  • 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
  • macOS with Claude Code installed
  • Basic terminal familiarity

First, create the directory and main TTS script:

Terminal window
# Create Claude config directory if it doesn't exist
mkdir -p ~/.claude
# Create the main TTS script
cat > ~/.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.old
fi
echo "$(date): Enhanced TTS hook triggered" >> /tmp/claude_tts_debug.log
# Read JSON input from stdin
input=$(cat)
echo "Input received: $input" >> /tmp/claude_tts_debug.log
# Extract session_id and transcript_path from JSON input
session_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.log
echo "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
fi
else
# 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.log
fi
EOF
# Make it executable
chmod +x ~/.claude/claude_response_speaker.sh

Create the hook entry script:

cat > ~/.claude/tts_hook_entry.sh << 'EOF'
#!/bin/bash
echo "$(date): Simple TTS hook triggered" >> /tmp/claude_tts_debug.log
# Pass the JSON input to the Claude response speaker script
cat | /Users/$USER/.claude/claude_response_speaker.sh
EOF
# Make it executable
chmod +x ~/.claude/tts_hook_entry.sh

Create or update your Claude Code settings file:

Terminal window
# Create settings.json if it doesn't exist
cat > ~/.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 username
sed -i '' "s/REPLACE_USERNAME/$USER/g" ~/.claude/settings.json
  1. Test the sound: Run afplay /System/Library/Sounds/Glass.aiff to verify sound works
  2. Test TTS manually: Run say "Hello, this is a test" to verify text-to-speech works
  3. Start a new Claude Code session and ask a question - you should hear both the notification sound and TTS when Claude responds

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

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 220 entirely

Add voice selection to the say command:

Terminal window
say -v "Alex" -r 220 "$final_sentence"

Available voices: say -v "?" to list all voices

  • 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 hooks section from settings.json
Terminal window
tail -f /tmp/claude_tts_debug.log
  1. No sound: Check system volume and test with afplay
  2. No TTS: Verify say command works manually
  3. Permission errors: Ensure scripts are executable with ls -la ~/.claude/*.sh
  4. Settings not loading: Restart Claude Code after changing settings.json
Terminal window
# Check recent log entries
tail -20 /tmp/claude_tts_debug.log
# Clear logs to start fresh
rm /tmp/claude_tts_debug.log*

⚠️ These hooks execute shell commands automatically. Review all scripts before installation and only use trusted code.


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