-- rvoice.lua — Right-Option push-to-talk for the rvoice helper. -- -- Install: -- 1. Hammerspoon → Preferences → enable "Launch Hammerspoon at login" -- 2. Add this line to ~/.hammerspoon/init.lua: -- require("rvoice") -- 3. Symlink this file so init.lua can find it: -- ln -sfn ~/Code/@scripts/session-tools/hammerspoon/rvoice.lua \ -- ~/.hammerspoon/rvoice.lua -- 4. Reload Hammerspoon config (menu bar → Reload Config) -- 5. Grant Accessibility + Microphone permissions when prompted. -- -- Behavior: hold Right ⌥ (Right Option) to talk — but only when the -- focused iTerm2 tab is attached to an rclaude session (i.e. its title -- matches ` · claude-…`, the format set by session-tools/tmux.conf). -- Outside that context Right ⌥ passes through unmodified, so the key still -- types its usual special characters in other apps. -- Release to transcribe + inject into the active rclaude tmux session. -- Taps shorter than 200ms are ignored (configurable via RVOICE_MIN_MS). -- -- Visual feedback (in order, from least to most intrusive): -- 1. Menu-bar dot — gray idle, red filled while recording, yellow during -- transcription (a few hundred ms). Always present so you know rvoice -- is loaded. -- 2. Floating "● REC" overlay — top-center of the focused screen, only -- visible while the key is held. Goes away on release. -- 3. Transcript toast — short-lived alert with the recognized text after -- a successful injection, or an error message on failure. local M = {} -- Resolve `rvoice` once at load. Hammerspoon's task PATH is barebones, so -- prefer an explicit symlink in ~/.local/bin or fall back to the repo path. local function resolveRvoice() local candidates = { os.getenv("HOME") .. "/.local/bin/rvoice", os.getenv("HOME") .. "/Code/@scripts/session-tools/bin/rvoice", } for _, p in ipairs(candidates) do local f = io.open(p, "r") if f then f:close(); return p end end return "rvoice" end local RVOICE = resolveRvoice() local holding = false -- rvoice can be toggled off via `rclaude voice off`, which drops a sentinel -- file. If present at load time, skip starting the eventtap entirely so the -- Right-⌥ key behaves normally. `rclaude voice on` calls reloadConfig which -- re-runs this module. local DISABLE_FLAG = (os.getenv("XDG_STATE_HOME") or (os.getenv("HOME") .. "/.local/state")) .. "/rclaude/voice-disabled" local function isDisabled() local f = io.open(DISABLE_FLAG, "r") if f then f:close(); return true end return false end -- ────────────────────────────────────────────────────────────────────────── -- Visual feedback -- ────────────────────────────────────────────────────────────────────────── -- Menu-bar status dot. Always visible while rvoice is loaded. M.menubar = hs.menubar.new() local function setMenu(state) -- state: "idle" | "recording" | "transcribing" | "disabled" local glyph, tooltip if state == "recording" then glyph = "🔴"; tooltip = "rvoice: recording" elseif state == "transcribing" then glyph = "🟡"; tooltip = "rvoice: transcribing" elseif state == "disabled" then glyph = "⚪"; tooltip = "rvoice: disabled" else glyph = "🎙️"; tooltip = "rvoice: idle (hold Right ⌥ to talk)" end if M.menubar then M.menubar:setTitle(glyph) M.menubar:setTooltip(tooltip) end end -- Click the menubar to open the action log (handy for quick debugging). if M.menubar then M.menubar:setClickCallback(function() hs.task.new("/bin/sh", nil, {"-c", RVOICE .. " log | tail -20 | pbcopy"}):start() hs.alert.show("rvoice log copied to clipboard") end) end if isDisabled() then setMenu("disabled") hs.alert.show("rvoice: disabled (rclaude voice on to re-enable)") return M end setMenu("idle") -- Floating "● REC" overlay shown while the key is held. hs.alert is built -- for short pop-ups, but a long duration + an explicit closeAll on release -- gives us the persistent-while-holding behavior we want with no per-frame -- repaint cost. local recAlertUUID = nil local function showRecording() recAlertUUID = hs.alert.show( "🔴 REC", { textSize = 36, radius = 12, fillColor = { red = 0.85, alpha = 0.85 }, strokeColor = { white = 1, alpha = 0.6 }, strokeWidth = 2 }, hs.screen.mainScreen(), 9999 -- effectively indefinite; closed on key-up ) end local function hideRecording() if recAlertUUID then hs.alert.closeSpecific(recAlertUUID); recAlertUUID = nil else hs.alert.closeAll(0) end end -- Toast helpers for the post-transcription result. local function toastOk(text) local snippet = text if #snippet > 120 then snippet = snippet:sub(1, 117) .. "…" end hs.alert.show( "✓ " .. snippet, { textSize = 16, radius = 8, fillColor = { green = 0.6, alpha = 0.85 } }, hs.screen.mainScreen(), 2.5 ) end local function toastErr(text) hs.alert.show( "✗ " .. text, { textSize = 16, radius = 8, fillColor = { red = 0.7, alpha = 0.85 } }, hs.screen.mainScreen(), 3.5 ) end -- ────────────────────────────────────────────────────────────────────────── -- Action dispatch -- ────────────────────────────────────────────────────────────────────────── -- Run rvoice asynchronously. The callback receives stdout so we can -- surface the transcript in a toast on success. rvoice writes the recognized -- text to stdout when invoked as `rvoice stop --print-text`; for `start` -- we just fire and forget. local function runAsync(cmd, onDone) local t = hs.task.new("/bin/sh", function(exit, stdout, stderr) if onDone then onDone(exit, stdout or "", stderr or "") end if exit ~= 0 then hs.printf("[rvoice] %s exited %d: %s", cmd, exit, stderr or "") end end, {"-c", RVOICE .. " " .. cmd}) t:start() end local function doStart() setMenu("recording") showRecording() runAsync("start") end local function doStop() hideRecording() setMenu("transcribing") runAsync("stop --print-text", function(exit, stdout, stderr) setMenu("idle") local text = (stdout or ""):gsub("^%s+", ""):gsub("%s+$", "") if exit == 0 and text ~= "" then toastOk(text) elseif exit == 0 then -- silent success (e.g. taps below min duration) — no toast else local err = (stderr or ""):gsub("^%s+", ""):gsub("%s+$", "") if err == "" then err = "transcription failed" end toastErr(err) end end) end -- Context gate: PTT only fires when the focused iTerm2 tab is showing an -- rclaude session. The canonical tmux config sets the title to -- " · "; for rclaude the session name always starts with -- "claude-". Anything else (browser, finder, local iTerm2 shell tab, -- a non-rclaude tmux) returns false so the key behaves normally. local function inRclaudeSession() local front = hs.application.frontmostApplication() if not front then return false end local name = front:name() if name ~= "iTerm2" and name ~= "iTerm" then return false end -- Pull the title of the active session via AppleScript. Cheap (~5ms); -- we only run this on a Right ⌥ keyDown, not on every event. local ok, title = hs.osascript.applescript( 'tell application "iTerm2" to tell current session of current window to return name') if not ok or type(title) ~= "string" then return false end -- Canonical tmux title set by session-tools/tmux.conf: -- "#H · #S" → "apricot · claude-natalie-..." -- We're permissive on whitespace around the separator but require the -- session name to start with "claude-" (rclaude's invariant). return title:match("·%s*claude%-") ~= nil end -- Right-Option push-to-talk. Hammerspoon delivers modifier transitions via -- flagsChanged; we gate on keycode 61 (Right Option) and read the alt flag -- to determine press vs release. M.tap = hs.eventtap.new({ hs.eventtap.event.types.flagsChanged }, function(e) local code = e:getKeyCode() if code ~= 61 then return false end -- 61 = Right Option local flags = e:getFlags() local pressed = flags.alt or false if pressed and not holding then -- Only arm PTT when the focused tab is an rclaude session. If we -- didn't arm on keyDown, the release branch below will also skip -- because `holding` stays false. if not inRclaudeSession() then return false end holding = true doStart() elseif (not pressed) and holding then holding = false doStop() end return false -- don't swallow; other apps may want the modifier end) M.tap:start() hs.alert.show("rvoice: hold Right ⌥ to talk", 1.5) return M