diff --git a/bin/rclaude b/bin/rclaude index 2e398f8..cc31bf3 100755 --- a/bin/rclaude +++ b/bin/rclaude @@ -1020,6 +1020,12 @@ cmd_version() { fi } +# Guard: when sourced as a library (by tests/run-tests.sh), skip dispatch +# so callers can invoke individual helpers without launching anything. +if [ "${RCLAUDE_LIB_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + cmd_setup() { # Args: # (none) → install on every host in scan_hosts diff --git a/bin/rvoice b/bin/rvoice index 1065ebf..583c4f8 100755 --- a/bin/rvoice +++ b/bin/rvoice @@ -188,6 +188,11 @@ is_local_host() { return 1 } +# Guard: when sourced as a library (by tests/run-tests.sh), skip dispatch. +if [ "${RVOICE_LIB_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + case ${1:-} in start) cmd_start ;; stop) cmd_stop ;; diff --git a/docs/rvoice.md b/docs/rvoice.md new file mode 100644 index 0000000..c5ad7ad --- /dev/null +++ b/docs/rvoice.md @@ -0,0 +1,142 @@ +# rvoice — push-to-talk dictation for remote rclaude sessions + +`/voice` in Claude Code opens the mic on **whichever host the claude binary is +running on**. When you're sshed to apricot through `cc` / `rclaude resume`, +that's apricot — which has no mic. `rvoice` fills the gap. + +It records audio locally on macOS, transcribes via Groq Whisper (no local model +RAM), and injects the transcript into the active remote tmux session via +`tmux send-keys` over ssh. The target session is auto-detected from the +focused iTerm2 tab title (set by the canonical session-tools `tmux.conf` to +` · `). + +## Architecture + +``` +[ Right ⌥ down ] ──Hammerspoon──▶ rvoice start ──▶ ffmpeg → recording.wav +[ Right ⌥ up ] ──Hammerspoon──▶ rvoice stop + │ + ▼ + POST WAV → Groq /audio/transcriptions + │ + ▼ + iTerm2 active tab title → "apricot · claude-…" + │ + ▼ + ssh apricot tmux send-keys -t claude-… -l "" +``` + +## Files + +| Path | Role | +|------------------------------------------------------|---------------------------------------| +| `bin/rvoice` | CLI: `start`/`stop`/`cancel`/`target`/`log` | +| `hammerspoon/rvoice.lua` | Right-⌥ hold detector → calls `rvoice` | +| `~/.config/rvoice/config` | Sourced at startup; holds `GROQ_API_KEY` and tweaks | +| `$TMPDIR/rvoice/` | Per-recording state (pid, wav, log) | + +## Install + +Prerequisites: `ffmpeg`, `jq`, `curl` (all `brew install`able), a Groq API key +(free tier — https://console.groq.com/keys), and Hammerspoon +(`brew install --cask hammerspoon`). + +```sh +# 1. Symlink rvoice (already done if you ran install.sh) +ln -sfn ~/Code/@scripts/session-tools/bin/rvoice ~/.local/bin/rvoice + +# 2. Drop your Groq key +mkdir -p ~/.config/rvoice +cat >> ~/.config/rvoice/config <<'EOF' +export GROQ_API_KEY=gsk_...your_key... +# export RVOICE_AUTOSEND=1 # uncomment to auto-press Enter after injection +EOF + +# 3. Wire up Hammerspoon +mkdir -p ~/.hammerspoon +ln -sfn ~/Code/@scripts/session-tools/hammerspoon/rvoice.lua ~/.hammerspoon/rvoice.lua +echo 'require("rvoice")' >> ~/.hammerspoon/init.lua +open /Applications/Hammerspoon.app + +# 4. From Hammerspoon's menu bar → Reload Config. +# Grant Accessibility + Microphone permission when macOS prompts. +``` + +## Usage + +From any iTerm2 tab that's attached to a remote claude session via `cc` or +`rclaude resume`: + +1. **Hold Right ⌥** → "listening…" notification, Tink sound +2. **Speak** +3. **Release** → recording stops, transcript types into your claude prompt, + Pop sound on success / Funk sound on error +4. **Hit Enter** when you're ready (review first), or set `RVOICE_AUTOSEND=1` + to skip the manual confirmation + +## Config (`~/.config/rvoice/config`) + +Plain shell fragment sourced at startup. Defaults shown. + +```sh +export GROQ_API_KEY=... # REQUIRED +export RVOICE_MODEL=whisper-large-v3-turbo # Groq model id +export RVOICE_AUTOSEND=0 # 1 = press Enter after inject +export RVOICE_MIN_MS=200 # ignore taps shorter than this (debounce) +export RVOICE_MAX_S=60 # hard cap on a single recording +export RVOICE_HOST=apricot.lan # force target host (overrides iTerm2 detection) +export RVOICE_SESSION=claude-natalie-… # force target tmux session +``` + +Override any of these per-invocation: `RVOICE_AUTOSEND=1 rvoice stop`. + +## Subcommands + +```sh +rvoice start # begin recording (Hammerspoon calls this on key-down) +rvoice stop # stop, transcribe, inject (called on key-up) +rvoice cancel # stop without transcribing (called on quick-tap abort) +rvoice target # debug: echo the host+session rvoice WOULD inject into +rvoice log # tail -50 of the action log +``` + +## Troubleshooting + +- **"GROQ_API_KEY not set"** — Hammerspoon's shell environment doesn't inherit + from your login shell. Make sure the key is exported in + `~/.config/rvoice/config`; rvoice sources that file before each invocation. +- **"no target session resolvable"** — the focused iTerm2 tab title isn't in + ` · ` format. Either: (a) you're not in an rclaude/ssh + session, or (b) the remote tmux config didn't get the title-setting fragment. + `rclaude install --on ` re-pushes the canonical tmux config; verify + with `ssh 'tmux show-options -g | grep set-titles'`. +- **Hammerspoon doesn't see Right ⌥** — System Settings → Privacy & + Security → Accessibility → enable Hammerspoon. Also Microphone for the + recording step. Restart Hammerspoon after granting. +- **Transcription returns nonsense** — Groq's `whisper-large-v3-turbo` is + multilingual but English-biased. Set `RVOICE_MODEL=whisper-large-v3` for + the slower but more accurate variant. +- **Injection types into the wrong session** — `rvoice target` shows what it + will hit. If wrong, set `RVOICE_HOST` / `RVOICE_SESSION` in config to pin + the target. +- **Latency feels high** — Groq is fast (~500ms for short clips). Network + latency to plum + ssh round-trip to apricot adds ~200ms. Local Whisper + would be slower in practice on most laptops once you account for model + load. + +## Why this architecture (vs. /voice over ssh) + +`/voice` is a feature of the `claude` binary itself; it opens the mic via +the OS audio API on whichever host it runs on. ssh has no audio channel and +doesn't forward CoreAudio events. The only ways to make `/voice` work over a +remote rclaude session would be: + +1. **Run claude locally** (lose apricot's compute / project files / LAN + services — not viable for our workflow) +2. **Forward audio via PulseAudio** (brittle on macOS, breaks on every + claude release) +3. **Reproduce /voice's behavior with our own pieces** ← this is rvoice + +`rvoice` keeps the mic and the hotkey on the Mac, runs transcription on a +hosted endpoint (zero local RAM), and uses tmux's existing send-keys +protocol to deliver text — every layer is well-understood and stable. diff --git a/tests/run-tests.sh b/tests/run-tests.sh new file mode 100755 index 0000000..b516f9f --- /dev/null +++ b/tests/run-tests.sh @@ -0,0 +1,84 @@ +#!/bin/sh +# tests/run-tests.sh — lightweight test runner for session-tools. +# +# Each test file (tests/test_*.sh) defines one or more functions starting +# with `test_`. The runner sources every file, finds those functions, and +# invokes them with PS4 trace + per-test pass/fail tally. Exits non-zero on +# any failure. +# +# Conventions: +# - Use the `assert_eq [msg]` helper. +# - Tests should be isolated: each function builds its own fixtures. +# - Tests must not require network or sudo. + +set -u + +ROOT=$(cd "$(dirname "$0")/.." && pwd) +TESTS_DIR=$ROOT/tests + +pass=0; fail=0; failed_names="" + +# --------------------------------------------------------------------------- +# Assertion helpers (available to every test file) +# --------------------------------------------------------------------------- + +assert_eq() { + _exp=$1; _got=$2; _msg=${3:-} + if [ "$_exp" = "$_got" ]; then return 0; fi + printf ' ✗ assertion failed%s\n expected: %s\n actual: %s\n' \ + "${_msg:+: $_msg}" "$_exp" "$_got" >&2 + return 1 +} + +assert_contains() { + _hay=$1; _needle=$2; _msg=${3:-} + case $_hay in + *"$_needle"*) return 0 ;; + esac + printf ' ✗ assertion failed%s\n haystack: %s\n missing: %s\n' \ + "${_msg:+: $_msg}" "$_hay" "$_needle" >&2 + return 1 +} + +assert_exit() { + _exp=$1; shift + "$@" >/dev/null 2>&1 + _got=$? + if [ "$_exp" -eq "$_got" ]; then return 0; fi + printf ' ✗ exit assertion failed\n expected: %s\n actual: %s\n cmd: %s\n' \ + "$_exp" "$_got" "$*" >&2 + return 1 +} + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + +for tf in "$TESTS_DIR"/test_*.sh; do + [ -f "$tf" ] || continue + printf '\n── %s\n' "$(basename "$tf")" + # shellcheck disable=SC1090 + . "$tf" + # Find every `test_*` function defined by this file. POSIX has no + # introspection, but the loaded source declared them; we grep the file + # for `test_()`. + for name in $(grep -oE '^test_[A-Za-z0-9_]+\b' "$tf" | sort -u); do + # Skip if grep matched a comment. + if ! type "$name" >/dev/null 2>&1; then continue; fi + if ( set -e; "$name" ); then + printf ' ✓ %s\n' "$name" + pass=$((pass + 1)) + else + failed_names="$failed_names $name" + fail=$((fail + 1)) + fi + # Unset so the next file's same-named test (if any) reloads cleanly. + unset -f "$name" 2>/dev/null || true + done +done + +printf '\n────────────────\n %d passed, %d failed\n' "$pass" "$fail" +if [ "$fail" -gt 0 ]; then + printf ' failed:%s\n' "$failed_names" + exit 1 +fi diff --git a/tests/test_rclaude_helpers.sh b/tests/test_rclaude_helpers.sh new file mode 100644 index 0000000..151bb36 --- /dev/null +++ b/tests/test_rclaude_helpers.sh @@ -0,0 +1,80 @@ +# test_rclaude_helpers.sh — unit tests for rclaude's pure helpers. +# +# Strategy: source rclaude with a guard so the dispatch block doesn't fire, +# then call the individual helpers directly. The guard is `RCLAUDE_LIB_ONLY=1` +# — rclaude checks it at the top of its dispatch and returns early. + +# Source rclaude as a library. The dispatch block is bypassed by the guard. +RCLAUDE_LIB_ONLY=1 . "$ROOT/bin/rclaude" 2>/dev/null || true + +# --------------------------------------------------------------------------- +# claude_slug +# --------------------------------------------------------------------------- + +test_claude_slug_basic() { + assert_eq "-Users-natalie-Code--projects--lilith" \ + "$(claude_slug "/Users/natalie/Code/@projects/@lilith")" +} + +test_claude_slug_no_special() { + # Leading `/` becomes leading `-` (claude's own behavior — every + # non-alphanumeric char is replaced, including the leading slash). + assert_eq "-tmp-foo" "$(claude_slug "/tmp/foo")" +} + +test_claude_slug_empty() { + assert_eq "" "$(claude_slug "")" +} + +# --------------------------------------------------------------------------- +# is_local +# --------------------------------------------------------------------------- + +test_is_local_keywords() { + assert_exit 0 is_local "local" + assert_exit 0 is_local "localhost" + assert_exit 0 is_local "127.0.0.1" + assert_exit 0 is_local "::1" +} + +test_is_local_unknown_host() { + assert_exit 1 is_local "definitely-not-a-real-host-12345" +} + +# --------------------------------------------------------------------------- +# dedupe_sessions (keeps highest-mtime row per uuid) +# --------------------------------------------------------------------------- + +test_dedupe_sessions_keeps_newest() { + # Two rows with the same uuid (col 3), different mtimes (col 6). + # Should retain only the row with the higher mtime. + _in=$(printf 'apricot\tsession\tUUID-A\tsnip\tcwd\t100\nlocal\tsession\tUUID-A\tsnip2\tcwd\t200\n') + _out=$(printf '%s' "$_in" | dedupe_sessions) + _count=$(printf '%s\n' "$_out" | wc -l | tr -d ' ') + assert_eq "1" "$_count" "expected 1 deduped row" || return 1 + assert_contains "$_out" "200" "should keep mtime=200 row" || return 1 +} + +test_dedupe_sessions_passes_unique() { + _in=$(printf 'apricot\tsession\tA\ts\tc\t100\nlocal\tsession\tB\ts\tc\t100\n') + _out=$(printf '%s' "$_in" | dedupe_sessions) + _count=$(printf '%s\n' "$_out" | wc -l | tr -d ' ') + assert_eq "2" "$_count" +} + +# --------------------------------------------------------------------------- +# get_home — always returns 0 even on failure (regression test) +# --------------------------------------------------------------------------- + +test_get_home_unknown_returns_zero() { + # Use a clearly invalid host. The function must not abort `set -e` + # callers; previously this caused silent exits in cmd_resume. + _v=$(get_home "definitely-not-a-real-host-12345-zzz" 2>/dev/null) + _rc=$? + assert_eq "0" "$_rc" "get_home must return 0 on failure" || return 1 + assert_eq "" "$_v" "should produce empty stdout on failure" || return 1 +} + +test_get_home_local_returns_HOME() { + assert_eq "$HOME" "$(get_home local)" +} diff --git a/tests/test_rvoice.sh b/tests/test_rvoice.sh new file mode 100644 index 0000000..c2f1868 --- /dev/null +++ b/tests/test_rvoice.sh @@ -0,0 +1,49 @@ +# test_rvoice.sh — unit tests for rvoice's pure helpers. +# +# Strategy: rvoice's CLI dispatch runs at the bottom of the file; we source +# it with $1 set to a no-op subcommand so the case statement falls through +# without firing start/stop/etc. Helpers defined above the case are then +# callable. + +# Stub the case-statement input. The "noop" pattern matches nothing → no +# subcommand runs, but every function definition has been loaded. +( + set -- + # rvoice expects to be invoked with `rvoice `; with no args it + # prints usage and exits 2. We trap that exit so sourcing succeeds. + set +e + RVOICE_LIB_ONLY=1 . "$ROOT/bin/rvoice" 2>/dev/null +) >/dev/null 2>&1 || true + +# We need the helpers in the *current* shell to test them. Re-source with +# the guard expected by rvoice (added in the matching rvoice edit). +RVOICE_LIB_ONLY=1 . "$ROOT/bin/rvoice" 2>/dev/null || true + +# --------------------------------------------------------------------------- +# is_local_host +# --------------------------------------------------------------------------- + +test_rvoice_is_local_keywords() { + assert_exit 0 is_local_host "local" + assert_exit 0 is_local_host "localhost" + assert_exit 0 is_local_host "127.0.0.1" + assert_exit 0 is_local_host "::1" +} + +test_rvoice_is_local_remote() { + assert_exit 1 is_local_host "definitely-not-a-real-host-12345" +} + +# --------------------------------------------------------------------------- +# resolve_target — env override path +# --------------------------------------------------------------------------- + +test_resolve_target_env_override() { + RVOICE_HOST=apricot.lan RVOICE_SESSION=claude-test-1 \ + _out=$(resolve_target) + # tab-separated host\tsession + _host=$(printf %s "$_out" | cut -f1) + _sess=$(printf %s "$_out" | cut -f2) + assert_eq "apricot.lan" "$_host" || return 1 + assert_eq "claude-test-1" "$_sess" || return 1 +}