Audio notifications for Claude Code on macOS using text-to-speech and system sounds.
-
-
Save furey/05c56c1183efe6ffafd8e2b114f2331a to your computer and use it in GitHub Desktop.
| #!/bin/bash | |
| HOOKS_DIR="$(dirname "$0")" | |
| CONFIG_DIR="${HOOKS_DIR}/macos-audio-notify" | |
| [[ -z "$MODE" ]] && source "${CONFIG_DIR}/config.sh" | |
| [[ "$MODE" == "off" ]] && exit 0 | |
| INPUT=$(cat) | |
| RAW=$(echo "$INPUT" | jq -r '.message // empty') | |
| SPEAK=$(echo "${RAW:-Attention needed}" | python3 "${CONFIG_DIR}/sanitize.py" | python3 "${CONFIG_DIR}/extract.py" "Attention needed") | |
| killall say 2>/dev/null | |
| killall afplay 2>/dev/null | |
| use_voice() { | |
| [[ "$MODE" == "voice" ]] && return 0 | |
| [[ "$MODE" == "sound" ]] && return 1 | |
| FRONT=$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null) | |
| [[ "$FRONT" != "Code" && "$FRONT" != "Terminal" && "$FRONT" != "iTerm2" ]] | |
| } | |
| if use_voice; then | |
| say -v "$VOICE" -r "$RATE" "$SPEAK" & | |
| else | |
| afplay "$SOUND_ATTENTION" & | |
| fi |
| #!/bin/bash | |
| HOOKS_DIR="$(dirname "$0")" | |
| CONFIG_DIR="${HOOKS_DIR}/macos-audio-notify" | |
| [[ -z "$MODE" ]] && source "${CONFIG_DIR}/config.sh" | |
| [[ "$MODE" == "off" ]] && exit 0 | |
| INPUT=$(cat) | |
| ACTIVE=$(echo "$INPUT" | jq -r '.stop_hook_active // false') | |
| if [ "$ACTIVE" = "true" ]; then | |
| exit 0 | |
| fi | |
| RAW=$(echo "$INPUT" | jq -r '.last_assistant_message // empty') | |
| SPEAK=$(echo "${RAW:-Done}" | tr '\n' ' ' | sed -E 's/^[[:space:]]+//' | python3 "${CONFIG_DIR}/sanitize.py" | python3 "${CONFIG_DIR}/extract.py" "Done") | |
| killall say 2>/dev/null | |
| killall afplay 2>/dev/null | |
| use_voice() { | |
| [[ "$MODE" == "voice" ]] && return 0 | |
| [[ "$MODE" == "sound" ]] && return 1 | |
| FRONT=$(osascript -e 'tell application "System Events" to get name of first application process whose frontmost is true' 2>/dev/null) | |
| [[ "$FRONT" != "Code" && "$FRONT" != "Terminal" && "$FRONT" != "iTerm2" ]] | |
| } | |
| if use_voice; then | |
| say -v "$VOICE" -r "$RATE" "$SPEAK" & | |
| else | |
| afplay "$SOUND_DONE" & | |
| fi |
| #!/bin/bash | |
| HOOKS_DIR="$(dirname "$0")" | |
| GLOBAL_SETTINGS="$HOME/.claude/settings.json" | |
| PROJECT_SETTINGS="$PWD/.claude/settings.local.json" | |
| INPUT=$(cat) | |
| TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // empty') | |
| tool_input() { | |
| case "$TOOL_NAME" in | |
| Bash) echo "$INPUT" | jq -r '.tool_input.command // empty' ;; | |
| Edit|Write|Read) echo "$INPUT" | jq -r '.tool_input.file_path // empty' ;; | |
| *) echo "$INPUT" | jq -r '.tool_input | tostring' ;; | |
| esac | |
| } | |
| TOOL_INPUT=$(tool_input) | |
| matches_rule() { | |
| local rule="$1" | |
| [[ "$rule" == "$TOOL_NAME" ]] && return 0 | |
| if [[ "$rule" == "$TOOL_NAME("*")" ]]; then | |
| local inner="${rule#*\(}" | |
| inner="${inner%\)}" | |
| if [[ "$inner" == *":*" ]]; then | |
| local prefix="${inner%%:*}" | |
| local first_word | |
| first_word=$(echo "$TOOL_INPUT" | awk '{print $1}') | |
| [[ "$first_word" == "$prefix" ]] && return 0 | |
| elif [[ "$inner" == *"**"* || "$inner" == *"*" ]]; then | |
| local glob_pattern="${inner}" | |
| [[ "$TOOL_INPUT" == $glob_pattern ]] && return 0 | |
| else | |
| [[ "$TOOL_INPUT" == "$inner" ]] && return 0 | |
| fi | |
| fi | |
| return 1 | |
| } | |
| auto_allowed() { | |
| local rules | |
| rules=$( | |
| jq -r '.permissions.allow[]' "$GLOBAL_SETTINGS" 2>/dev/null | |
| jq -r '.permissions.allow[]' "$PROJECT_SETTINGS" 2>/dev/null | |
| ) | |
| while IFS= read -r rule; do | |
| [[ -z "$rule" ]] && continue | |
| matches_rule "$rule" && return 0 | |
| done <<< "$rules" | |
| return 1 | |
| } | |
| if auto_allowed; then | |
| exit 0 | |
| fi | |
| CONFIG_DIR="${HOOKS_DIR}/macos-audio-notify" | |
| [[ -z "${PRETOOL_MODE:-}" ]] && source "${CONFIG_DIR}/config.sh" | |
| [[ "$PRETOOL_MODE" == "off" ]] && exit 0 | |
| killall say 2>/dev/null | |
| killall afplay 2>/dev/null | |
| if [[ "$PRETOOL_MODE" == "voice" ]]; then | |
| describe_tool() { | |
| case "$TOOL_NAME" in | |
| Bash) echo "Claude needs command approval" ;; | |
| Edit) echo "Claude needs edit approval" ;; | |
| Write) echo "Claude needs file write approval" ;; | |
| AskUserQuestion) echo "Claude has a question for you" ;; | |
| *) echo "Claude needs your approval" ;; | |
| esac | |
| } | |
| MESSAGE=$(describe_tool) | |
| echo "$INPUT" | jq --arg msg "$MESSAGE" '. + {message: $msg}' \ | |
| | bash "${HOOKS_DIR}/macos-audio-notify-attention.sh" | |
| else | |
| afplay "$SOUND_PRETOOL" & | |
| fi |
| # Claude Code macOS audio notification config | |
| # | |
| # Mode options: auto | voice | sound | off | |
| # auto = system sounds when focused, voice when not | |
| # voice = always TTS voice | |
| # sound = always system sounds | |
| # off = silent | |
| MODE=voice | |
| # PreToolUse hook mode options: sound | voice | off | |
| # sound = play system sound (recommended, less intrusive for false positives) | |
| # voice = speak contextual message via TTS | |
| # off = silent | |
| PRETOOL_MODE=sound | |
| # TTS voice (run `say -v '?'` to list all available voices) | |
| # Popular English options: | |
| # "Daniel (Enhanced)" en_GB | |
| # "Karen (Enhanced)" en_AU | |
| # "Samantha" en_US | |
| # "Moira" en_IE | |
| # "Rishi" en_IN | |
| VOICE="Daniel (Enhanced)" | |
| # TTS speed in words per minute (default macOS is ~175) | |
| RATE=220 | |
| # TTS max characters spoken per notification | |
| export MAX_LENGTH=80 | |
| # TTS pause between sentences in milliseconds (uses macOS `say` [[slnc N]]) | |
| export PAUSE=400 | |
| # System sounds (browse /System/Library/Sounds/ for options) | |
| # Available: Basso, Blow, Bottle, Frog, Funk, Glass, Hero, | |
| # Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink | |
| SOUND_ATTENTION=/System/Library/Sounds/Ping.aiff | |
| SOUND_PRETOOL=/System/Library/Sounds/Tink.aiff | |
| SOUND_DONE=/System/Library/Sounds/Pop.aiff |
| import re, sys, os | |
| text = sys.stdin.read().strip() | |
| fallback = sys.argv[1] if len(sys.argv) > 1 else "Done" | |
| max_len = int(os.environ.get("MAX_LENGTH", 120)) | |
| parts = re.split(r'\s+-\s+|\s+(?=\d+\.\s)|(?<=[^0-9]\.)(?:\s+)|(?<=[!?])\s+', text) | |
| if not parts or not parts[0]: | |
| print(fallback) | |
| else: | |
| pause = os.environ.get("PAUSE", "400") | |
| marker = f" [[slnc {pause}]] " | |
| result = parts[0] | |
| spoken_len = len(parts[0]) | |
| for part in parts[1:]: | |
| if spoken_len + 1 + len(part) > max_len: | |
| break | |
| result = result + marker + part | |
| spoken_len += 1 + len(part) | |
| print(result) |
- macOS
- Claude Code (CLI or VS Code extension)
jq(brew install jq)- Python 3
One-liner:
curl -sL "https://gist.githubusercontent.com/furey/05c56c1183efe6ffafd8e2b114f2331a/raw/claude-code.hooks.macos-audio-notify.installer.sh" | bashOr manually:
mkdir -p ~/.claude/hooks/macos-audio-notify
GIST_BASE="https://gist.githubusercontent.com/furey/05c56c1183efe6ffafd8e2b114f2331a/raw"
curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify.config.sh" \
-o ~/.claude/hooks/macos-audio-notify/config.sh
curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify.sanitize.py" \
-o ~/.claude/hooks/macos-audio-notify/sanitize.py
curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify.extract.py" \
-o ~/.claude/hooks/macos-audio-notify/extract.py
curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify-attention.sh" \
-o ~/.claude/hooks/macos-audio-notify-attention.sh
curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify-done.sh" \
-o ~/.claude/hooks/macos-audio-notify-done.sh
curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify-pretool.sh" \
-o ~/.claude/hooks/macos-audio-notify-pretool.sh
chmod +x ~/.claude/hooks/macos-audio-notify-attention.sh \
~/.claude/hooks/macos-audio-notify-done.sh \
~/.claude/hooks/macos-audio-notify-pretool.shThen add the hooks to your ~/.claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/macos-audio-notify-pretool.sh"
}
]
}
],
"Notification": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/macos-audio-notify-attention.sh"
}
]
}
],
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "bash ~/.claude/hooks/macos-audio-notify-done.sh"
}
]
}
]
}
}~/.claude/hooks/
├── macos-audio-notify-attention.sh # Notification hook entry point
├── macos-audio-notify-done.sh # Stop hook entry point
├── macos-audio-notify-pretool.sh # PreToolUse hook
└── macos-audio-notify/ # Shared dependencies
├── config.sh # Voice, speed, mode, max length, sounds
├── extract.py # Multi-sentence extraction (up to MAX_LENGTH)
├── kill-claude-tts.sh # Raycast script command to stop TTS
└── sanitize.py # Strips URLs, paths, hashes, markdown
Edit ~/.claude/hooks/macos-audio-notify/config.sh:
| Variable | Description | Default |
|---|---|---|
MODE |
Notification mode: auto, voice, sound, off |
auto |
PRETOOL_MODE |
PreToolUse notification mode: sound, voice, off |
sound |
VOICE |
macOS TTS voice name (run say -v '?' to list all) |
Daniel (Enhanced) |
RATE |
Speech speed in words per minute (default macOS is ~175) | 220 |
MAX_LENGTH |
Max characters spoken per notification | 80 |
SOUND_ATTENTION |
System sound for attention notifications (browse /System/Library/Sounds/) |
Ping.aiff |
SOUND_PRETOOL |
System sound for tool approval notifications (browse /System/Library/Sounds/) |
Funk.aiff |
SOUND_DONE |
System sound for response-complete notifications (browse /System/Library/Sounds/) |
Pop.aiff |
Example config with defaults:
# Mode options: auto | voice | sound | off
# auto = system sounds when focused, voice when not
# voice = always TTS voice
# sound = always system sounds
# off = silent
MODE=auto
# PreToolUse hook mode options: sound | voice | off
# sound = play system sound (recommended, less intrusive for false positives)
# voice = speak contextual message via TTS
# off = silent
PRETOOL_MODE=sound
# TTS voice (run `say -v '?'` to list all available voices)
# Popular English options:
# "Daniel (Enhanced)" en_GB
# "Karen (Enhanced)" en_AU
# "Samantha" en_US
# "Moira" en_IE
# "Rishi" en_IN
VOICE="Daniel (Enhanced)"
# TTS speed in words per minute (default macOS is ~175)
RATE=220
# TTS max characters spoken per notification
MAX_LENGTH=80
# System sounds (browse /System/Library/Sounds/ for options)
# Available: Basso, Blow, Bottle, Frog, Funk, Glass, Hero,
# Morse, Ping, Pop, Purr, Sosumi, Submarine, Tink
SOUND_ATTENTION=/System/Library/Sounds/Ping.aiff
SOUND_PRETOOL=/System/Library/Sounds/Tink.aiff
SOUND_DONE=/System/Library/Sounds/Pop.aiffControls the Notification and Stop hooks via MODE:
| Mode | Behaviour |
|---|---|
auto |
System sounds when your terminal/editor is focused, voice when you're in another app |
voice |
Always speaks via macOS TTS |
sound |
Always plays system sounds (Ping for attention, Pop for done) |
off |
Silent |
In auto mode, the scripts detect whether your terminal or editor is focused by checking the frontmost app. By default, Code, Terminal, and iTerm2 are treated as "focused" — edit the use_voice() function in either script to add other apps.
Controls the PreToolUse hook via PRETOOL_MODE. This hook fires before tool calls that require user approval (e.g. Bash commands, file edits). It checks your global and project-level permission allow lists to avoid false positives.
| Mode | Behaviour |
|---|---|
sound |
Plays a system sound (default: Funk) — recommended, less intrusive for occasional false positives |
voice |
Speaks a contextual message via TTS (e.g. "Claude needs command approval") |
off |
Silent |
Note: Session-only approvals (clicking "Allow" once without "Always allow") are stored in memory only and cannot be detected by the hook. This may cause occasional false positive sounds for tools you've already approved in the current session.
If a TTS notification is too long, you can kill it mid-speak with:
killall sayA Raycast script command (kill-claude-tts.sh) is included for binding this to a hotkey:
- Open Raycast Settings → Extensions → Script Commands → Add Script Directory
- Select
~/.claude/hooks/macos-audio-notify - Search "Kill Claude TTS" in Raycast and assign a hotkey (e.g.
⌃X)
Now press your hotkey anytime to silence Claude mid-sentence.
Re-run the one-liner or manual curl commands to pull the latest versions.
rm ~/.claude/hooks/macos-audio-notify-attention.sh \
~/.claude/hooks/macos-audio-notify-done.sh \
~/.claude/hooks/macos-audio-notify-pretool.sh
rm -rf ~/.claude/hooks/macos-audio-notifyThen remove the PreToolUse, Notification, and Stop hooks from ~/.claude/settings.json.
| #!/usr/bin/env bash | |
| set -euo pipefail | |
| HOOKS_DIR="${HOME}/.claude/hooks" | |
| CONFIG_DIR="${HOOKS_DIR}/macos-audio-notify" | |
| SETTINGS="${HOME}/.claude/settings.json" | |
| GIST_BASE="https://gist.githubusercontent.com/furey/05c56c1183efe6ffafd8e2b114f2331a/raw" | |
| HOOK_MARKER="macos-audio-notify" | |
| if ! command -v jq &>/dev/null; then | |
| echo "Error: jq is required. Install via: brew install jq" | |
| exit 1 | |
| fi | |
| if ! command -v python3 &>/dev/null; then | |
| echo "Error: python3 is required." | |
| exit 1 | |
| fi | |
| mkdir -p "${CONFIG_DIR}" | |
| if [ -f "${CONFIG_DIR}/config.sh" ]; then | |
| SAVED_CONFIG=$(grep -E '^[[:space:]]*(export[[:space:]]+)?[A-Z_]+=.' "${CONFIG_DIR}/config.sh") | |
| fi | |
| curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify.config.sh" \ | |
| -o "${CONFIG_DIR}/config.sh" | |
| if [ -n "${SAVED_CONFIG:-}" ]; then | |
| while IFS= read -r line; do | |
| key=$(echo "$line" | sed -E 's/^[[:space:]]*(export[[:space:]]+)?([A-Z_]+)=.*/\2/') | |
| value=$(echo "$line" | sed -E 's/^[[:space:]]*(export[[:space:]]+)?[A-Z_]+=(.*)/\2/') | |
| sed -i '' -E "s|^(export )?${key}=.*|\\1${key}=${value}|" "${CONFIG_DIR}/config.sh" | |
| done <<< "$SAVED_CONFIG" | |
| fi | |
| curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify.sanitize.py" \ | |
| -o "${CONFIG_DIR}/sanitize.py" | |
| curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify.extract.py" \ | |
| -o "${CONFIG_DIR}/extract.py" | |
| curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify-attention.sh" \ | |
| -o "${HOOKS_DIR}/macos-audio-notify-attention.sh" | |
| curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify-done.sh" \ | |
| -o "${HOOKS_DIR}/macos-audio-notify-done.sh" | |
| curl -sL "${GIST_BASE}/claude-code.hooks.macos-audio-notify-pretool.sh" \ | |
| -o "${HOOKS_DIR}/macos-audio-notify-pretool.sh" | |
| chmod +x "${HOOKS_DIR}/macos-audio-notify-attention.sh" \ | |
| "${HOOKS_DIR}/macos-audio-notify-done.sh" \ | |
| "${HOOKS_DIR}/macos-audio-notify-pretool.sh" | |
| PRETOOL_HOOK='{"hooks":[{"type":"command","command":"bash ~/.claude/hooks/macos-audio-notify-pretool.sh"}]}' | |
| NOTIFICATION_HOOK='{"hooks":[{"type":"command","command":"bash ~/.claude/hooks/macos-audio-notify-attention.sh"}]}' | |
| STOP_HOOK='{"hooks":[{"type":"command","command":"bash ~/.claude/hooks/macos-audio-notify-done.sh"}]}' | |
| if [ -f "${SETTINGS}" ]; then | |
| ALREADY_INSTALLED=$(jq -r "[.hooks.Notification // [], .hooks.Stop // [], .hooks.PreToolUse // [] | .[].hooks[]?.command // empty] | any(contains(\"${HOOK_MARKER}\"))" "${SETTINGS}" 2>/dev/null || echo "false") | |
| if [ "$ALREADY_INSTALLED" = "true" ]; then | |
| echo "" | |
| echo "Hooks already configured in ${SETTINGS} — skipping settings update." | |
| echo "" | |
| echo "Scripts updated to latest version." | |
| else | |
| jq --argjson p "${PRETOOL_HOOK}" --argjson n "${NOTIFICATION_HOOK}" --argjson s "${STOP_HOOK}" \ | |
| '.hooks = (.hooks // {}) + {"PreToolUse": [(.hooks.PreToolUse // [] | .[]), $p], "Notification": [(.hooks.Notification // [] | .[]), $n], "Stop": [(.hooks.Stop // [] | .[]), $s]}' \ | |
| "${SETTINGS}" > "${SETTINGS}.tmp" && mv "${SETTINGS}.tmp" "${SETTINGS}" | |
| fi | |
| else | |
| mkdir -p "$(dirname "${SETTINGS}")" | |
| cat > "${SETTINGS}" <<EOF | |
| { | |
| "hooks": { | |
| "PreToolUse": [${PRETOOL_HOOK}], | |
| "Notification": [${NOTIFICATION_HOOK}], | |
| "Stop": [${STOP_HOOK}] | |
| } | |
| } | |
| EOF | |
| fi | |
| echo "" | |
| echo "Installed Claude Code macOS audio notifications." | |
| echo "" | |
| echo "Config: ${CONFIG_DIR}/config.sh" | |
| echo "" | |
| echo " MODE=auto System sounds when focused, voice when away" | |
| echo " MODE=voice Always speak via TTS" | |
| echo " MODE=sound Always play system sounds" | |
| echo " MODE=off Silent" | |
| echo "" | |
| echo "Done." | |
| say -v "Daniel (Enhanced)" -r 220 "Claude Code audio notifications installed! [[slnc 400]] Customise your voice, speed, and notifications mode in the newly installed hooks configuration file." & |
| #!/bin/bash | |
| # Required parameters: | |
| # @raycast.schemaVersion 1 | |
| # @raycast.title Kill Claude TTS | |
| # @raycast.mode silent | |
| # Optional parameters: | |
| # @raycast.icon 🔇 | |
| # @raycast.packageName Claude Code | |
| killall say 2>/dev/null |
| import re, sys | |
| text = sys.stdin.read().strip() | |
| text = re.sub(r'\x1b\[[0-9;]*m', '', text) | |
| text = re.sub(r'https?://\S+', '(url)', text) | |
| text = re.sub(r'(?<!\w)[~/][\w./-]{10,}', '(file path)', text) | |
| text = re.sub(r'[\w.-]+/[\w./-]*\.[\w]+', '(file path)', text) | |
| text = re.sub(r'\b[0-9a-f]{8,}\b', '', text) | |
| text = re.sub(r'\b[0-9a-f-]{20,}\b', '', text) | |
| text = re.sub(r'```[\s\S]*?```', '(code block)', text) | |
| text = re.sub(r'`([^`]+)`', r'\1', text) | |
| text = re.sub(r'[*_#>\[\]]', '', text) | |
| text = re.sub(r'\s{2,}', ' ', text) | |
| print(text.strip()) |
Comments are disabled for this gist.