Skip to content

Instantly share code, notes, and snippets.

@richardkmichael
Last active August 11, 2026 05:26
Show Gist options
  • Select an option

  • Save richardkmichael/1b836724651ebb049ece76aadf4b035f to your computer and use it in GitHub Desktop.

Select an option

Save richardkmichael/1b836724651ebb049ece76aadf4b035f to your computer and use it in GitHub Desktop.
Claude Code voice dictation on macOS — Karabiner tap-to-talk, terminal Option-as-Alt, system-audio muting

Claude Code voice dictation — macOS terminal + Karabiner

Tap right Option in Alacritty or Ghostty to toggle Claude Code voice dictation; speaker output mutes while recording so the mic doesn't catch speaker audio. Escape cancels without submitting.

A bare modifier tap never reaches a terminal app, and voice:pushToTalk takes a single keystroke, so Karabiner converts a solo right-Option tap to Option+z.

Option-as-Alt terminal configuration sends that as Alt+z, which Claude Code has bound to voice:pushToTalk in tap mode — the binding is opt+z because Claude Code sees only the Alt+z escape, not which physical Option produced it. voice-mute does the muting, driven from the keys because Claude Code has no voice hook.

file location purpose
karabiner.json ~/.config/karabiner/karabiner.json tap -> Option+z + mute; Esc cancels
alacritty.toml Alacritty [window] option_as_alt = 'Both'
ghostty ~/.config/ghostty/config macos-option-as-alt = true
settings.json merge into ~/.claude/settings.json voice tap + UserPromptSubmit hook
keybindings.json ~/.claude/keybindings.json opt+z -> voice:pushToTalk
voice-mute ~/bin/voice-mute (chmod +x) mute on start, restore on stop

The UserPromptSubmit hook covers the one case the keys can't: voice can auto-submit on a silence (15s) or 2-minute timeout with no keystroke, so the hook releases the mute on submit.

{
"global": { "show_in_menu_bar": false },
"profiles": [
{
"complex_modifications": {
"rules": [
{
"description": "right_option (tap alone) -> opt+z + voice-mute toggle, only in Alacritty/Ghostty/Terminal (Claude Code voice dictation push-to-talk; voice:pushToTalk only supports a single keystroke, not a chord; voice-mute mutes the output device while listening so the mic does not pick up speaker audio, e.g. browser music, and records the prior mute level to restore on submit/exit)",
"manipulators": [
{
"conditions": [
{
"bundle_identifiers": [
"^org\\.alacritty$",
"^com\\.mitchellh\\.ghostty$",
"^com\\.apple\\.Terminal$"
],
"type": "frontmost_application_if"
}
],
"from": {
"key_code": "right_option",
"modifiers": { "optional": ["any"] }
},
"to": [{ "key_code": "right_option" }],
"to_if_alone": [
{
"key_code": "z",
"modifiers": ["right_option"]
},
{
"shell_command": "$HOME/bin/voice-mute toggle"
}
],
"type": "basic"
}
]
},
{
"description": "escape (physical key) -> escape + voice-mute exit, only in Alacritty/Ghostty/Terminal (physical ESC stops a Claude Code voice recording without submitting; exit restores the pre-voice output-mute level; the caps_lock-as-escape path is handled in the caps_lock rule)",
"manipulators": [
{
"conditions": [
{
"bundle_identifiers": [
"^org\\.alacritty$",
"^com\\.mitchellh\\.ghostty$",
"^com\\.apple\\.Terminal$"
],
"type": "frontmost_application_if"
}
],
"from": {
"key_code": "escape",
"modifiers": { "optional": ["any"] }
},
"to": [
{ "key_code": "escape" },
{ "shell_command": "$HOME/bin/voice-mute exit" }
],
"type": "basic"
}
]
}
]
},
"name": "Default profile",
"selected": true,
"virtual_hid_keyboard": { "keyboard_type_v2": "ansi" }
}
]
}
[window]
# Both Option keys send Alt (ESC-prefixed) escape sequences instead of composing macOS special
# characters (e.g. Option+Z would otherwise type a literal character determined by the keyboard
# layout). Right Option carries the Karabiner tap-to-opt+z voice dictation binding, so it must send
# Alt for that keystroke to reach terminal apps; left Option covers every other opt+<key> binding.
option_as_alt = 'Both'
# Both Option keys send Alt (ESC-prefixed) escape sequences instead of composing macOS special
# characters. Right Option carries the Karabiner tap-to-opt+z voice dictation binding, so it must
# send Alt for that keystroke to reach terminal apps; left Option covers every other opt+<key>
# binding. Matches option_as_alt = 'Both' in alacritty-base.toml.
macos-option-as-alt = true
{
"voice": { "enabled": true, "mode": "tap" },
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{ "type": "command", "command": "$HOME/bin/voice-mute exit" }
]
}
]
}
}
{
"bindings": [
{ "context": "Chat", "bindings": { "opt+z": "voice:pushToTalk" } }
]
}
#!/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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment