225 lines
9.3 KiB
Lua
225 lines
9.3 KiB
Lua
-- 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 `<host> · 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 <cmd> 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
|
|
-- "<host> · <session>"; 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
|