Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save furey/05c56c1183efe6ffafd8e2b114f2331a to your computer and use it in GitHub Desktop.

Select an option

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-audio-notify Installation & Usage Guide

Prerequisites

  • macOS
  • Claude Code (CLI or VS Code extension)
  • jq (brew install jq)
  • Python 3

Installation

One-liner:

curl -sL "https://gist.githubusercontent.com/furey/05c56c1183efe6ffafd8e2b114f2331a/raw/claude-code.hooks.macos-audio-notify.installer.sh" | bash
Or 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.sh

Then 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"
          }
        ]
      }
    ]
  }
}

File Structure

~/.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

Configuration

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

Modes

General Mode

Controls 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

Focus detection

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.

PreToolUse Mode

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.

Stopping Speech Mid-Notification

If a TTS notification is too long, you can kill it mid-speak with:

killall say

Raycast Shortcut

A Raycast script command (kill-claude-tts.sh) is included for binding this to a hotkey:

  1. Open Raycast Settings → Extensions → Script Commands → Add Script Directory
  2. Select ~/.claude/hooks/macos-audio-notify
  3. Search "Kill Claude TTS" in Raycast and assign a hotkey (e.g. ⌃X)

Now press your hotkey anytime to silence Claude mid-sentence.

Updating

Re-run the one-liner or manual curl commands to pull the latest versions.

Uninstalling

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-notify

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