|
#!/usr/bin/env bash |
|
# |
|
# Claude Code voice-mode mute helper. |
|
# |
|
# Claude Code exposes no hook for voice dictation start/stop, so the terminal key |
|
# bindings (via Karabiner) drive this script to mute the macOS output device while |
|
# a voice memo records -- otherwise the microphone picks up speaker audio such as |
|
# browser music. A state file records the current phase and the output-mute level |
|
# that was in effect BEFORE recording, so leaving voice restores what it found and |
|
# never clobbers a mute set for another reason (provenance). |
|
# |
|
# voice-mute toggle opt+z: idle -> listening (mute), or listening -> idle (restore) |
|
# voice-mute exit ESC: listening -> idle (restore); no-op while idle |
|
# voice-mute status print the current phase: "listening" or "idle" |
|
# |
|
# Karabiner runs this with a very limited environment (no shell profile, minimal |
|
# PATH, only $HOME/$USER/$TMPDIR-class variables), so osascript is called by an |
|
# absolute path. The state file lives at a fixed $HOME-relative path -- NOT under |
|
# CLAUDE_CONFIG_DIR or any XDG_* dir, because those are shell-level variables the |
|
# Karabiner runner never sees, so honoring them would split this writer from a |
|
# shell-run reader. This is our own runtime state, not Claude's config, so it |
|
# lives on its own under ~/.local/state. |
|
|
|
set -euo pipefail |
|
|
|
readonly OSASCRIPT="/usr/bin/osascript" |
|
readonly STATE_DIR="${HOME}/.local/state/claude-voice-mute" |
|
readonly STATE_FILE="${STATE_DIR}/state" |
|
|
|
# Mute the output device and return the level that was in effect beforehand, as |
|
# a word ("muted"/"unmuted"). Reading the prior level and muting share a single |
|
# osascript invocation: on voice enter this runs before dictation starts, so its |
|
# latency is dead time in which the mic is live but the output isn't muted yet. |
|
# One osascript (~70ms) instead of two (~140ms) roughly halves that window. |
|
__mute_capturing_prior() { |
|
if [ "$("${OSASCRIPT}" \ |
|
-e 'set was_muted to output muted of (get volume settings)' \ |
|
-e 'set volume output muted true' \ |
|
-e 'return was_muted')" = "true" ]; then |
|
printf 'muted' |
|
else |
|
printf 'unmuted' |
|
fi |
|
} |
|
|
|
# Set the output-mute level from a word: "muted" or "unmuted". |
|
__set_output_mute_level() { |
|
case "${1}" in |
|
muted) "${OSASCRIPT}" -e 'set volume output muted true' ;; |
|
unmuted) "${OSASCRIPT}" -e 'set volume output muted false' ;; |
|
esac |
|
} |
|
|
|
# Print the recorded phase ("listening"/"idle"), defaulting to "idle". |
|
__phase() { |
|
local phase='' _rest='' |
|
[ -r "${STATE_FILE}" ] && read -r phase _rest < "${STATE_FILE}" || true |
|
printf '%s' "${phase:-idle}" |
|
} |
|
|
|
# idle -> listening: remember the pre-voice mute level, then mute. |
|
__enter() { |
|
local prior |
|
prior="$(__mute_capturing_prior)" |
|
mkdir -p "${STATE_DIR}" |
|
printf 'listening %s\n' "${prior}" > "${STATE_FILE}" |
|
} |
|
|
|
# listening -> idle: restore the remembered pre-voice mute level. |
|
__leave() { |
|
local _phase='' prior='' |
|
[ -r "${STATE_FILE}" ] && read -r _phase prior < "${STATE_FILE}" || true |
|
__set_output_mute_level "${prior:-unmuted}" |
|
mkdir -p "${STATE_DIR}" |
|
printf 'idle\n' > "${STATE_FILE}" |
|
} |
|
|
|
case "${1:-}" in |
|
toggle) |
|
if [ "$(__phase)" = "listening" ]; then __leave; else __enter; fi |
|
;; |
|
exit) |
|
if [ "$(__phase)" = "listening" ]; then __leave; fi |
|
;; |
|
status) |
|
printf '%s\n' "$(__phase)" |
|
;; |
|
*) |
|
printf 'usage: %s {toggle|exit|status}\n' "${0##*/}" >&2 |
|
exit 64 |
|
;; |
|
esac |