feat(@scripts/session-tools): ✨ add rvoice dictation tool
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
949e63f16d
commit
4a5b2f7273
6 changed files with 366 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ;;
|
||||
|
|
|
|||
142
docs/rvoice.md
Normal file
142
docs/rvoice.md
Normal file
|
|
@ -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
|
||||
`<host> · <session>`).
|
||||
|
||||
## 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 "<text>"
|
||||
```
|
||||
|
||||
## 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
|
||||
`<host> · <session>` 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 <host>` re-pushes the canonical tmux config; verify
|
||||
with `ssh <host> '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.
|
||||
84
tests/run-tests.sh
Executable file
84
tests/run-tests.sh
Executable file
|
|
@ -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 <expected> <actual> [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_<name>()`.
|
||||
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
|
||||
80
tests/test_rclaude_helpers.sh
Normal file
80
tests/test_rclaude_helpers.sh
Normal file
|
|
@ -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)"
|
||||
}
|
||||
49
tests/test_rvoice.sh
Normal file
49
tests/test_rvoice.sh
Normal file
|
|
@ -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 <sub>`; 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
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue