28 Commits
Author SHA1 Message Date
dsql f177b46a4b docs: fix stale README config defaults (wake 0.65, vad 700/15)
the lower [vad]/threshold bullets still said 0.6 / 800ms / max 10; sync to the real
defaults (wake_fuzzy_threshold 0.65, silence_ms 700, max_seconds 15). CLAUDE.md and
COMPACT.md (git-ignored) corrected on disk too (model small.en, same numbers).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 04:07:27 -04:00
dsql 252385fb67 feat: highlight wake phrases in magenta (startup banner + wake note)
add a magenta color; paint wake phrases magenta in the startup 'wake:' list and in
the loose-match '(wake: <phrase>)' note (the rest of that green heard line stays
green around the magenta phrase). makes the wake vocabulary visually distinct from
green heard-text and brightblue command words.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 04:02:15 -04:00
dsql 97591eb24d feat: version voice command + matched-wake note on loose matches
add 'version' (prints claudedo <ver> to console; in vocab + menu). when a command's
wake phrase matched loosely (the transcript didn't contain it literally), the green
heard line appends '(wake: <phrase>)' so e.g. 'okay clouds' -> 'okay claude' is
visible. grammar.parse() now returns the matched phrase on ParsedCommand.wake (via a
new strip_wake_match; strip_wake kept as a thin wrapper).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 03:59:52 -04:00
dsql 5f05a01423 feat: v0.1.4 — HELP menu, 15s cap, wake 0.65, small.en default + docs sync
commands menu now prints under a single [HELP] header with bare indented rows
(brightblue usage) instead of 15 repeated [SYSTEM] tags. raise [vad].max_seconds
10 -> 15 for long dictation. wake_fuzzy_threshold 0.6 -> 0.65 (slightly fewer false
wakes; note short spellings 'ok/okay claude' still admit some). carries the prior
small.en default, [vad].silence_ms 700, lighter (brightblue) command color, lean
injection lines, .en model variants in the validator. README/CLAUDE.md synced.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 03:52:19 -04:00
dsql e84ef91e7b tune: small.en default, vad 700ms, lighter command color, lean inject lines
default model -> small.en (english-only small; better english accuracy, same ~1s
latency; .en variants added to the validator). raise [vad].silence_ms 500 -> 700
(500 cut off too early). command words now brightblue (lighter/cyan-ish) instead of
dark blue. drop the redundant target from injection lines — the [session] prefix
already names it, so e.g. '[claude-testing] typed ...' not '... sticky claude-testing
-> typed ...'.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 03:41:46 -04:00
dsql 2cbbabfaa1 feat: unbounded backspace + blue command words in console
backspace now sends exactly n BSpace with no boundary cap (buffer floored at 0 so a
later erase stays correct); erase remains bound to the uncommitted-input buffer. add
a blue color and Console.paint(); paint the command word blue on SYSTEM lines
(list/set/unset/mode -> ...) so the action stands out.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 03:11:42 -04:00
dsql 4357b14fad perf: default back to small model; show per-command STT latency
medium added ~3s/command lag (measured ~1.2s small vs ~3s medium on a 7950X3D), so
default model -> small; lean on initial_prompt + lenient wake for the coined word.
every heard line now shows STT latency as (<ms>/<audio>s) — always on, not just
print_heard — so a model change's cost is visible. snappier vad (silence_ms 500)
from the prior commit stands.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 02:57:52 -04:00
dsql 8e20b7eb0b feat: commands/customs menu, green heard-echo, snappier VAD
add voice 'commands' (alias help/menu) printing the command menu and 'customs'
(alias custom) stubbed for v0.2.0. echo every recognized command as a green
'heard "..." -> ACTION' line before acting, so you see what landed; the result line
then reports target + keystrokes. lower [vad].silence_ms default 800 -> 500 for a
snappier endpoint after you stop talking.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 02:32:28 -04:00
dsql 4abdfd56bc feat: start skips the mic check by default; --check to opt in
invert the pre-listen mic check — default is no check (just start listening); pass
'claudedo start --check' to run it. replaces the old --skip-audio-check flag.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 02:25:20 -04:00
dsql e6dadab143 feat: debug/echo command — print the spoken phrase to the console
'<wake> debug <text>' (alias echo) echoes what you said to the console as
[VOICE] debug: "..." and injects nothing — a no-target test command for checking
wake + STT transcription. added to the STT vocab so it's biased for.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 02:22:37 -04:00
dsql 5064f912a4 fix: install.sh installs config.toml to ~/.config/claudedo
the daemon's config lookup falls through to ./config.toml only, so without a copy in
the standard dir it was repo-cwd-only. install config.toml to ~/.config/claudedo/ —
copy if absent, else write config.toml.new beside the user's edited copy (never
clobber). also gitignore COMPACT.md (handoff doc kept on disk, untracked).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:53:25 -04:00
dsql a51c2fbdd4 feat: v0.1.3 STT tuning — medium model, initial_prompt bias, split thresholds, VAD config
default stt.model -> medium (biggest accuracy gain for the coined wake word;
small/large-v3 documented alternatives). seed faster-whisper with an initial_prompt
derived from the configured wake phrases + command vocabulary (grammar.vocabulary /
initial_prompt, one source — command synonyms now live in named _*_VERBS tuples).

split the single fuzzy threshold into wake_fuzzy_threshold (0.6, lenient — a false
wake is cheap) and command_fuzzy_threshold (0.8, tight — a false command fires the
wrong action); grammar.parse() takes both. add a [vad] config section (silence_ms,
max_seconds) for the existing Alexa-style record-until-pause endpointing, which
captures a command whole and lets the trailing pause separate it from following
chatter (that chatter is a separate capture the wake gate discards). bump to 0.1.3.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:41:48 -04:00
dsql bd6597352a feat: 'add [a] space' / 'insert <n> spaces' phrasing; drop 'claude due' wake
map 'add a space'/'add space'/'insert two spaces' to the space command (count read
from either side of the noun). remove 'claude due' from the default wake list (it
double-rendered with 'claude do' and wasn't wanted). docs synced.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:27:16 -04:00
dsql 08bbe3ce58 docs: sync README with v0.1.2 (wake list, editing cmds, auto_target, console)
reconcile README with the shipped code and with CLAUDE.md: full 6-phrase wake list
(claudedo/claude do/claude due/hey claude/ok claude/okay claude) with the Whisper
rationale; space/backspace/erase in the grammar + flow; colored prefixed console
output description; fix the auto_target contradiction (default false = require
set/target, not auto-pick); drop the stale 'backgroundable'.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:22:00 -04:00
dsql d96dc3898f feat: backspace/space/erase editing commands + colored prefixed console
voice editing: 'space [<n>]' inserts spaces, 'backspace [<n>]' (alias delete)
deletes chars, 'erase' (alias clear/wipe) wipes the current input. the daemon
tracks a per-session uncommitted-input char count so backspace is capped at the
last submit boundary and erase clears exactly back to it; submit/set reset it.
keys.py gains BSpace/space; grammar gains a count parser (digits + number words).

new console.py renders every daemon line as 'HH:MM:SS [prefix] message' with
color: [<session>] for injected lines (green), [SYSTEM] for state, [VOICE] for
recognition/drops (red/dim). bump to 0.1.2.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:17:22 -04:00
dsql d734161c97 feat: auto_target toggle, print_heard debug, more wake spellings
add behavior.auto_target (default false): with no sticky target and exactly one
session running, false requires an explicit set/target rather than guessing; true
auto-uses it. target.resolve() takes the flag. add behavior.print_heard (default
false, debug): opt-in console echo of non-wake transcripts to see how Whisper
renders the wake word. add behavior.filler_words. expand the wake list with the
spellings Whisper actually emits for the coined word ('claude do', 'claude due',
'ok claude', 'okay claude').

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:17:08 -04:00
dsql b05f6256c1 fix: quiet onnxruntime GPU-discovery warning and faster_whisper INFO
faster-whisper's VAD loads an onnx model that prints a 'GPU device discovery
failed' warning on headless/WSL hosts and chatty INFO per transcribe. raise onnx
log severity, drop the faster_whisper logger to WARNING, and filter the C++-level
discovery line out of stderr during model load + a one-shot warm transcribe (so it
fires once at startup, not in the hot loop). real errors still pass through.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-26 01:16:47 -04:00
dsql 43b36d2a0b feat: v0.1.1 sticky vs one-shot targeting, filler words, auto-single
redefine targeting: 'set' (aliases sticky/switch) is the persistent sticky
default (~/.claude-active); 'target <name> <command>' is a one-shot override that
routes a single command without changing the sticky default. add 'unset' and
'list'. resolution moves to a single target.resolve(one_shot) implementing the
order: one-shot -> sticky-if-exists -> only-session auto -> ambiguous/none do
nothing (never falls through, never injects into a missing session).

grammar.parse now returns ParsedCommand(one_shot, action) and skips optional
leading filler words (config behavior.filler_words: select/use/choose), with a
filler-before-digit still meaning the select command. CLI gains set/unset/list
(switch kept as a set alias). daemon console shows the targeting reason per line.
docs updated; no stale 'target = sticky' wording remains. bump to 0.1.1.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 20:16:29 -04:00
dsql 17db65858e feat: terminal-run only — drop systemd/autostart, start does mic-check + visible loop
terminal-run is the product, so remove all backgrounding: delete the
claudedo.service unit and autostart.sh, strip the systemd step and the
autostart source-line from install.sh (rc block now sources cc.sh only).

claudedo start now runs a mic check first (warm-up + brief capture, aborts with
guidance if silent; --skip-audio-check to bypass) then drops into a visible
listen loop printing the recognition/action log: a startup banner, then
heard -> matched -> target / injected per utterance, target/mode state changes,
and (listen mode) non-wake speech dropped WITHOUT the transcript per the privacy
invariant.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 19:30:36 -04:00
dsql eb587692e1 fix: prime mic to skip RDPSource resume gap
WSLg's RDPSource suspends when idle and emits ~1-2s of silence while it resumes
on the first read, so a short timed capture (test-audio) or the first utterance
after daemon start could be lost. add audio.warm_up() that opens a stream and
reads until a non-silent block arrives (or times out); call it at daemon startup
and before test-audio's capture. test-audio now primes then captures 3s.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 19:09:08 -04:00
dsql 84c74603e5 feat: output-handler seam with tmux and stdout handlers
extract an OutputHandler abstract base; TmuxOutputHandler is production
(send-keys, PTY-only), StdoutOutputHandler prints what would be injected so
grammar+keymap run end-to-end without a live claude session (the deterministic
test path). module-level shims default to tmux so the daemon is unchanged.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 18:42:34 -04:00
dsql d43004e4b9 feat: tmux send-keys settings in install.sh bootstrap
append escape-time 0, large history-limit, allow-passthrough, and extended-keys
to ~/.tmux.conf under an idempotent marker block (no clobber). required for
reliable keystroke injection and for notifications/modified-keys to reach the
claude pane.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 18:42:26 -04:00
dsql 66b08d290c docs: lead how-to-run with the terminal-run model
state terminal-run as the product (the claudedo start terminal is the
recognition/action console) and frame backgrounding/autostart/systemd as
optional extras, not the default.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 18:42:22 -04:00
dsql 7f4a6f6699 style: drop inline comments, trim docstring periods
remove inline comments (CLAUDE.md: docstrings only), strip trailing periods
from single-line docstrings, and fix a PulseArmy->PulseAudio typo. no behavior
change.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 18:42:17 -04:00
dsql bf516143b5 install: shell cc kit, opt-in autostart, bootstrap
cc kit as a sourced ~/.config/claudedo/cc.sh (bash+zsh, forced explicit names).
opt-in rc autostart guarded by CLAUDEDO_AUTOSTART + an optional systemd user
unit. install.sh is idempotent: WSL audio deps, ~/.asoundrc pulse shim, audio
verify, model prime, and source-line rc wiring with backups.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 17:55:30 -04:00
dsql 7780a8d47c daemon: capture->stt->match->inject loop and CLI
daemon.py runs the loop with pidfile/state, runtime mode switching, and the
privacy invariant: in listen mode any non-wake utterance is dropped the instant
grammar.parse() returns None. __main__.py exposes start|stop|status|test-audio|
install|switch.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 17:55:25 -04:00
dsql 947b30c22e grammar: fuzzy wake gate and command matching
word-boundary wake stripping that's lenient on the coined word 'claudedo'
(despaced-prefix match) without swallowing the command's spaces. data-driven
phrase->action map; number words normalized to digits; 'target' aliases
'switch'.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 17:55:21 -04:00
dsql da7c39c4f2 audio: local STT and mic capture
stt.py wraps faster-whisper for fully on-device transcription. audio.py
captures via sounddevice with two paths: silence-segmented for listen mode
and held-key for ptt. resolves the input device from config (auto/index/name).

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-25 17:55:17 -04:00
17 changed files with 1927 additions and 160 deletions
+1
View File
@@ -1,4 +1,5 @@
CLAUDE.md CLAUDE.md
COMPACT.md
__pycache__/ __pycache__/
*.pyc *.pyc
+86 -42
View File
@@ -11,7 +11,7 @@ hands-free while another window (a game) is focused.
It exists because Claude Code's native `/voice` is hardcoded-blocked in WSL (it It exists because Claude Code's native `/voice` is hardcoded-blocked in WSL (it
assumes WSL has no audio). Modern WSL2 + WSLg *does* have working mic input via assumes WSL has no audio). Modern WSL2 + WSLg *does* have working mic input via
PulseAudio/RDP. `claudedo` captures the mic itself, transcribes on-device, and drives PulseAudio/RDP. `claudedo` captures the mic itself, transcribes on-device, and drives
Claude Code over tmux — fully local, private, backgroundable. Claude Code over tmux — fully local and private. You run it in a terminal you watch.
## How it works ## How it works
@@ -20,9 +20,11 @@ mic (WSLg/PulseAudio RDPSource)
-> sounddevice capture -> sounddevice capture
-> faster-whisper (local STT, on-device) -> faster-whisper (local STT, on-device)
-> wake gate: utterance must start with a wake phrase, else DISCARD locally -> wake gate: utterance must start with a wake phrase, else DISCARD locally
-> grammar match (yes/no/one..four/approve/deny/send/type/mode/switch/cancel) -> grammar match (yes/no/one..four/approve/deny/send/type/space/backspace/erase/
-> resolve target session (~/.claude-active) mode/set/target/unset/list/cancel)
-> resolve target session (one-shot > sticky ~/.claude-active > auto/none)
-> tmux send-keys -t <session> "<keys>" -> tmux send-keys -t <session> "<keys>"
-> log the action to the watched terminal ([session]/[SYSTEM]/[VOICE], colored)
``` ```
**Privacy by construction.** STT runs on-device. In listen mode, any speech that **Privacy by construction.** STT runs on-device. In listen mode, any speech that
@@ -61,37 +63,28 @@ claudedo test-audio
## Usage ## Usage
**Run it in a terminal you watch — that's the product.** You launch `claudedo
start` and it drops into a visible listen loop (pass `--check` to run a mic check
first). Each utterance prints a timestamped, colored line — `HH:MM:SS [claude-libs]
heard "…" →
typed 'fix'` (green for injected, red for drops, `[SYSTEM]`/`[VOICE]` for state and
recognition). That terminal is your recognition/action console; you attach to the
`claude-<name>` session in another pane to watch the keystrokes land. It runs in the
foreground by design — the console is the point — though `claudedo stop` can signal a
stray instance.
```bash ```bash
claudedo start # run the daemon (foreground; listen mode by default) claudedo start # the visible listen loop (listen mode default; no mic check)
claudedo start --check # run a mic check before listening
claudedo start --mode ptt # push-to-talk instead (desk-only — see Modes) claudedo start --mode ptt # push-to-talk instead (desk-only — see Modes)
claudedo status # running? mode? target session? claudedo status # running? mode? target session?
claudedo stop # stop a running daemon claudedo stop # stop a running daemon
claudedo switch <name> # retarget to claude-<name> claudedo set <name> # set the sticky target -> claude-<name> (alias: switch)
claudedo unset # clear the sticky target
claudedo list # list running claude-* sessions
claudedo test-audio # verify the mic capture path claudedo test-audio # verify the mic capture path
``` ```
Background it in its own tmux session:
```bash
tmux new-session -d -s claudedo 'claudedo start'
```
### Autostart
WSL has no real boot, so autostart is rc-based and **opt-in**. `install.sh` ships
`~/.config/claudedo/autostart.sh`, which starts the daemon in a `claudedo-daemon`
tmux session once per WSL session — but only when `CLAUDEDO_AUTOSTART=1` is set.
Enable it by uncommenting the `export CLAUDEDO_AUTOSTART=1` line in the cc-kit marker
block of your rc; disable it by re-commenting (or deleting the file). Watch its logs
with `tmux attach -t claudedo-daemon`.
If your WSL runs systemd (`systemd=true` in `/etc/wsl.conf`), `install.sh` also
installs an optional user unit — enable it instead with:
```bash
systemctl --user enable --now claudedo
```
### Modes ### Modes
- **listen (default)** — continuous capture; only acts on utterances that **start - **listen (default)** — continuous capture; only acts on utterances that **start
@@ -109,9 +102,14 @@ Switch at runtime by voice: "claudedo mode listen" / "claudedo mode ptt".
## Command grammar ## Command grammar
Wake phrases (listen mode), fuzzy-matched: **"claudedo"**, **"hey claude"**. Wake phrases (listen mode), fuzzy-matched. The default list is **"claudedo"**,
"claudedo" is a coined word, so the matcher is lenient (accepts "claude do", **"claude do"**, **"hey claude"**, **"ok claude"**, **"okay claude"** — Whisper has
"clauddo", "cloud do", …). In PTT mode the wake phrase is optional. no token for the coined word "claudedo" and renders it as real words ("claude do"),
so that spelling is listed explicitly. Matching is lenient (case/space-insensitive).
Add the spellings you actually see (turn on `print_heard` to find them). In PTT mode
the wake phrase is optional. When a command's wake phrase matched loosely (e.g. you
said "okay clouds"), the heard line notes which phrase it assumed —
`heard "okay clouds list" -> LIST (wake: okay claude)`.
| Say | Does | | Say | Does |
|---|---| |---|---|
@@ -120,22 +118,45 @@ Wake phrases (listen mode), fuzzy-matched: **"claudedo"**, **"hey claude"**.
| `approve` / `deny` | allow / deny a permission prompt | | `approve` / `deny` | allow / deny a permission prompt |
| `send` / `enter` | submit (Enter) | | `send` / `enter` | submit (Enter) |
| `type <phrase>` | insert literal text, **no** submit (read-before-send; say "send") | | `type <phrase>` | insert literal text, **no** submit (read-before-send; say "send") |
| `space [<n>]` (also `add [a] space`, `insert <n> spaces`) | insert n spaces (default 1) |
| `backspace [<n>]` (alias `delete`) | delete n chars (default 1), capped at the last submit boundary |
| `erase` (alias `clear`/`wipe`) | delete everything typed since the last submit/boundary |
| `debug <text>` (alias `echo`) | just print what you said to the console (test wake/STT; injects nothing) |
| `mode ptt` / `mode listen` | switch input mode | | `mode ptt` / `mode listen` | switch input mode |
| `switch <name>` / `target <name>` | retarget to `claude-<name>` | | `set <name>` (alias `sticky`/`switch`) | set the **sticky** target `claude-<name>` (persists) |
| `target <name> <command>` | **one-shot** override: run that command on `claude-<name>` for this utterance only; sticky default unchanged |
| `unset` (alias `unsticky`) | clear the sticky target |
| `list` | list running `claude-*` sessions to the daemon console |
| `commands` (alias `help`/`menu`) | print the voice-command menu to the console |
| `customs` (alias `custom`) | custom commands — arriving in v0.2.0 (stub for now) |
| `version` | print the claudedo version to the console |
| `cancel` / `escape` | back out of a prompt | | `cancel` / `escape` | back out of a prompt |
Optional filler (`select` / `use` / `choose`) may precede any command and is ignored:
`select yes` and `use yes` behave like `yes`. (`select 1` is still the select command.)
When no sticky target is set, a bare command does nothing and asks you to `set` one
(the default). Set `auto_target = true` to instead auto-use the single running
`claude-*` session when there's exactly one; with several running it always does
nothing and asks you to `set` one.
Number words are normalized to digits before matching ("one"/"won" → 1). Number words are normalized to digits before matching ("one"/"won" → 1).
## Targeting ## Targeting
`~/.claude-active` holds the target session name (e.g. `claude-rethink-public`). The `~/.claude-active` holds the **sticky** target session name (e.g.
**cc kit** writes this file when you attach, so the target is "the project you most `claude-rethink-public`). The **cc kit** writes this file when you attach, and
recently attached to". `claudedo switch <name>` / `target <name>` overwrites it. If `claudedo set <name>` (alias `sticky`/`switch`) overwrites it; `unset` clears it.
the file is missing or the session no longer exists, `claudedo` injects nothing and A `target <name>` voice command is a **one-shot** that does NOT touch the sticky
logs a warning (it never guesses a target). default — it routes a single command and the next bare command reverts to sticky.
Resolution order (one place — `target.resolve()`): one-shot if present →
sticky if set and the session exists → else, only if `auto_target = true`, the single
running `claude-*` session → else (default, or zero/several sessions) do nothing and
say so. It never guesses, and never injects into a nonexistent session.
Every name maps to `claude-<name>` through one helper (`target.session_name()`), and Every name maps to `claude-<name>` through one helper (`target.session_name()`), and
the cc kit mirrors it exactly — so `cc libs` (shell) and `target libs` (voice) refer the cc kit mirrors it exactly — so `cc libs` (shell) and `set libs` (voice) refer
to the same session `claude-libs`. The name is your **stable, speakable handle**: to the same session `claude-libs`. The name is your **stable, speakable handle**:
because the kit forces an explicit name (no basename guessing), you always know the because the kit forces an explicit name (no basename guessing), you always know the
exact word to say. exact word to say.
@@ -169,11 +190,34 @@ If Claude Code changes its prompt UI, re-confirm against a live session and upda
## Config ## Config
Everything tunable lives in [`config.toml`](config.toml): wake phrases, mode + PTT Everything tunable lives in [`config.toml`](config.toml): wake phrases, mode + PTT
key, Whisper model/language/device, audio segmentation thresholds, and key, Whisper model/language/device, `[vad]` endpointing, and `[behavior]`
`type_autosend = false`. The default model is `small`; bump to `medium` if the coined (`type_autosend`, fuzzy thresholds, `filler_words`, `auto_target`, `print_heard`).
wake word is recognized poorly. `claudedo -c <path> ...` points at a specific config; The default model is **`small.en`** (the English-only small model — ~1s/command on a
otherwise it searches `$CLAUDEDO_CONFIG`, `~/.config/claudedo/config.toml`, then strong CPU, more accurate on English than multilingual `small` at the same speed);
`./config.toml`. `medium`/`medium.en` are more accurate but ~3× slower (noticeable lag), `base.en` is
snappier/less accurate, `large-v3` most accurate/slowest. Every `heard` line shows the
STT latency as `(<ms>/<audio>s)` so you can see what a model change costs. VAD
endpointing ends a capture after `[vad].silence_ms` (700) of trailing silence, capped
at `max_seconds` (15). `claudedo -c <path> ...` points at a specific config; otherwise
it searches
`$CLAUDEDO_CONFIG`, `~/.config/claudedo/config.toml`, then `./config.toml`.
- **STT biasing.** The transcriber is seeded with an `initial_prompt` built from the
configured wake phrases + command vocabulary (one source — `grammar.vocabulary()`),
so Whisper is conditioned to expect "claudedo" and the command words.
- **Split fuzzy thresholds.** `wake_fuzzy_threshold` (default `0.65`, lenient) vs
`command_fuzzy_threshold` (default `0.8`, tight). The asymmetry is deliberate: a
false *wake* is cheap (it wakes, finds no command, does nothing), but a false
*command* fires the wrong action. Prefer expanding command synonyms over loosening
the command threshold.
- **`[vad]` endpointing.** Capture starts on speech and ends after `silence_ms`
(default 700) of trailing silence — Alexa-style record-until-pause — capped at
`max_seconds` (default 15). The pause both ends a command and separates it from
following chatter (the chatter is a separate capture the wake gate discards).
- **`auto_target`** (default `false`): with no sticky target and one session running,
`false` does nothing and asks you to `set`; `true` auto-uses that session.
- **`print_heard`** (default `false`, debug): prints non-wake transcripts so you can
see how Whisper renders your wake word, then tune the wake list/threshold.
## Requirements ## Requirements
+39 -12
View File
@@ -5,7 +5,7 @@
# wake phrases for listen mode. fuzzy-matched: case/space-insensitive, lenient on # wake phrases for listen mode. fuzzy-matched: case/space-insensitive, lenient on
# the coined word "claudedo" (whisper renders it inconsistently). number words are # the coined word "claudedo" (whisper renders it inconsistently). number words are
# normalized to digits before command matching. # normalized to digits before command matching.
phrases = ["claudedo", "hey claude"] phrases = ["claudedo", "claude do", "hey claude", "ok claude", "okay claude"]
[input] [input]
# "listen" (default): continuous capture; only acts on utterances that start with a # "listen" (default): continuous capture; only acts on utterances that start with a
@@ -21,10 +21,12 @@ mode = "listen"
ptt_key = "space" ptt_key = "space"
[stt] [stt]
# faster-whisper model size. "small" is a good accuracy/latency balance for the # faster-whisper model size. "small.en" is the default — the English-only small model
# short command grammar (~sub-second per chunk on a strong cpu). if the coined wake # (~1s/command on a strong cpu, more accurate on english than multilingual "small" at
# word "claudedo" is recognized poorly, bump to "medium" (slower per chunk). # the same speed). "medium"/"medium.en" are more accurate but ~3x slower (noticeable
model = "small" # lag); "large-v3" is most accurate and slowest. drop to "base.en" for max snappiness
# (less accurate). bump only if recognition is poor.
model = "small.en"
language = "en" language = "en"
# mic device: "auto", or a sounddevice device index (integer) / substring of a # mic device: "auto", or a sounddevice device index (integer) / substring of a
# device name. run `claudedo test-audio` to list devices. # device name. run `claudedo test-audio` to list devices.
@@ -36,18 +38,43 @@ compute = "auto"
# capture parameters. 16 kHz mono is what whisper expects. # capture parameters. 16 kHz mono is what whisper expects.
samplerate = 16000 samplerate = 16000
channels = 1 channels = 1
# listen-mode silence segmentation: an utterance ends after this many seconds below # rms energy below this counts as silence (the VAD onset/endpoint floor).
# the rms threshold. keeps latency low without streaming.
silence_threshold = 0.012 silence_threshold = 0.012
silence_duration = 0.8
# ignore utterances shorter than this (clicks, coughs). # ignore utterances shorter than this (clicks, coughs).
min_utterance = 0.3 min_utterance = 0.3
# hard cap on a single utterance so a stuck stream can't grow unbounded.
max_utterance = 15.0 [vad]
# Alexa-style record-until-pause endpointing (listen mode). capture starts on speech
# onset and ends after this much trailing silence — the natural end of an utterance.
# a real pause both ends the command AND separates it from following chatter (the
# chatter becomes a separate capture that the wake gate then discards).
silence_ms = 700
# hard cap so continuous noise can't record forever (also the ceiling for a long
# dictated `type` phrase).
max_seconds = 15.0
[behavior] [behavior]
# dictation never auto-submits: "type <phrase>" inserts literal text only; you say # dictation never auto-submits: "type <phrase>" inserts literal text only; you say
# "send" separately to submit (read-before-send). # "send" separately to submit (read-before-send).
type_autosend = false type_autosend = false
# fuzzy match ratio (0..1) required to accept a wake phrase / command token. # fuzzy match ratios (0..1). the asymmetry is deliberate: a false WAKE is cheap (it
match_threshold = 0.8 # wakes, finds no command, does nothing), so wake is lenient; a false COMMAND fires
# the WRONG action, so commands stay tight. lower = more lenient = more matches.
# prefer expanding command synonyms over loosening command_fuzzy_threshold.
wake_fuzzy_threshold = 0.65
command_fuzzy_threshold = 0.8
# optional filler words that may precede a command and are ignored for matching:
# "select yes" / "use yes" behave like "yes". (a filler word followed by a digit is
# the select command, e.g. "select 1", and is not dropped.)
filler_words = ["select", "use", "choose"]
# when no sticky target is set and exactly ONE claude-* session is running:
# false (default) -> require an explicit `set <name>` or one-shot `target <name>`;
# a bare command does nothing and tells you to set one.
# true -> auto-target that single session (convenience).
auto_target = false
# DEBUG ONLY — relaxes the privacy invariant. when true, the daemon console prints
# the raw transcript of EVERY utterance, including non-wake speech it would otherwise
# drop silently (shown as `heard (dropped): "<transcript>"`). use it to see exactly
# how Whisper renders your wake word, then turn it OFF. default false: non-wake speech
# is discarded without ever printing the transcript.
print_heard = false
Executable
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env bash
# claudedo bootstrap — does the system setup pip can't. idempotent: re-running is
# safe and won't duplicate the shell-rc cc kit. run from the repo root.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ASOUNDRC="$HOME/.asoundrc"
MARKER_BEGIN="# >>> claudedo cc kit >>>"
MARKER_END="# <<< claudedo cc kit <<<"
say() { printf '\n\033[1;36m==> %s\033[0m\n' "$*"; }
warn() { printf '\033[1;33m!! %s\033[0m\n' "$*" >&2; }
die() { printf '\033[1;31mxx %s\033[0m\n' "$*" >&2; exit 1; }
# 1. windows-side checks (cannot automate — check and instruct) -----------------
say "checking WSLg audio bridge"
if [ ! -e /mnt/wslg/PulseServer ]; then
die "WSLg PulseServer missing (/mnt/wslg/PulseServer). claudedo needs WSLg audio.
update WSL ('wsl --update' in Windows) or install WSL from the Microsoft Store,
then restart WSL ('wsl --shutdown') and re-run this script."
fi
echo " /mnt/wslg/PulseServer present"
cat <<'EOF'
MANUAL WINDOWS STEP (this script cannot do it for you):
Windows Settings -> Privacy & security -> Microphone ->
enable "Let desktop apps access your microphone".
Without this, the mic is silent inside WSL. Do it now if you haven't.
EOF
# 2. WSL audio deps (apt) -------------------------------------------------------
say "installing WSL audio dependencies (apt)"
sudo apt-get update
sudo apt-get install -y libportaudio2 libasound2t64 libasound2-plugins \
alsa-utils pulseaudio-utils
# 3. ALSA -> Pulse routing ------------------------------------------------------
say "configuring ALSA -> Pulse routing (~/.asoundrc)"
if [ -f "$ASOUNDRC" ] && grep -q "type pulse" "$ASOUNDRC"; then
echo " ~/.asoundrc already routes to pulse"
else
{
echo "pcm.!default { type pulse }"
echo "ctl.!default { type pulse }"
} >> "$ASOUNDRC"
echo " wrote pulse default to ~/.asoundrc"
fi
if [ -z "${PULSE_SERVER:-}" ] && [ -e /mnt/wslg/PulseServer ]; then
export PULSE_SERVER="unix:/mnt/wslg/PulseServer"
echo " exported PULSE_SERVER=$PULSE_SERVER (WSLg usually sets this already)"
fi
# 4. verify audio (fail loudly with guidance) -----------------------------------
say "verifying audio path"
if pactl info >/dev/null 2>&1; then
DEFAULT_SRC="$(pactl info | sed -n 's/^Default Source: //p')"
echo " Default Source: ${DEFAULT_SRC:-<none>}"
if ! pactl list sources short 2>/dev/null | grep -q RDPSource; then
warn "RDPSource not listed by pactl — mic may not be bridged. check Windows mic permission."
fi
else
warn "pactl info failed — pulseaudio-utils installed but no server reachable yet."
fi
TESTWAV="/tmp/claudedo_test.wav"
if arecord -D default -f S16_LE -c 1 -r 16000 -d 2 "$TESTWAV" >/dev/null 2>&1 && [ -s "$TESTWAV" ]; then
echo " arecord captured 2s -> $TESTWAV ($(stat -c%s "$TESTWAV") bytes)"
else
warn "arecord could not capture. fix-chain: apt deps above + ~/.asoundrc + Windows mic permission.
debug anytime with: claudedo test-audio"
fi
# 5. python install + model prime -----------------------------------------------
say "installing the claudedo python package"
PIP="${PIP:-pip3}"
"$PIP" install -e "$REPO_DIR"
say "priming the faster-whisper model (so first run isn't slow)"
MODEL="$(sed -n 's/^model *= *"\(.*\)".*/\1/p' "$REPO_DIR/config.toml" | head -1)"
MODEL="${MODEL:-small}"
python3 - "$MODEL" <<'PY' || warn "model prime failed — first run will download it"
import sys
from faster_whisper import WhisperModel
WhisperModel(sys.argv[1], device="cpu", compute_type="int8")
print(" primed faster-whisper model:", sys.argv[1])
PY
# 6. cc kit as a sourced file + rc wiring (idempotent) --------------------------
say "installing the cc kit (~/.config/claudedo/cc.sh)"
CONF_DIR="$HOME/.config/claudedo"
mkdir -p "$CONF_DIR"
install -m 0644 "$REPO_DIR/shell/cc.sh" "$CONF_DIR/cc.sh"
echo " wrote $CONF_DIR/cc.sh"
# install config.toml to the standard location so the daemon finds it from any dir.
# never clobber an edited user config: copy only if absent, else drop a .new to diff.
if [ ! -f "$CONF_DIR/config.toml" ]; then
install -m 0644 "$REPO_DIR/config.toml" "$CONF_DIR/config.toml"
echo " wrote $CONF_DIR/config.toml"
elif ! cmp -s "$REPO_DIR/config.toml" "$CONF_DIR/config.toml"; then
install -m 0644 "$REPO_DIR/config.toml" "$CONF_DIR/config.toml.new"
echo " kept your $CONF_DIR/config.toml; new default written to config.toml.new (diff to merge)"
else
echo " $CONF_DIR/config.toml already current"
fi
# wire EVERY rc that exists (the user may have both zsh and bash).
wired_any=0
for RC in "$HOME/.zshrc" "$HOME/.bashrc"; do
[ -f "$RC" ] || continue
wired_any=1
if grep -qF "$MARKER_BEGIN" "$RC"; then
echo " cc kit marker already in $RC (not duplicating)"
continue
fi
cp "$RC" "$RC.claudedo.bak"
echo " backed up $RC -> $RC.claudedo.bak"
cat >> "$RC" <<'CCKIT'
# >>> claudedo cc kit >>>
[ -f ~/.config/claudedo/cc.sh ] && source ~/.config/claudedo/cc.sh
# <<< claudedo cc kit <<<
CCKIT
echo " wired source-line block into $RC (open a new shell or 'source $RC')"
done
[ "$wired_any" = 1 ] || warn "no ~/.zshrc or ~/.bashrc found — add the marker block from README.md manually."
# warn about any OLD loose cc defs outside our markers (do not auto-delete).
for RC in "$HOME/.zshrc" "$HOME/.bashrc"; do
[ -f "$RC" ] || continue
loose="$(grep -nE '^[[:space:]]*(cc|ccr|ccl|cck|cckl|_cc_name)[[:space:]]*\(\)' "$RC" \
| grep -v 'claudedo' || true)"
if [ -n "$loose" ]; then
warn "old cc-function defs found in $RC (outside the claudedo markers):"
echo "$loose" | sed 's/^/ /'
echo " review and remove them by hand — the new sourced kit overrides them, but"
echo " they are dead code. a backup is at $RC.claudedo.bak"
fi
done
# 7. tmux settings for reliable send-keys (idempotent ~/.tmux.conf append) -------
say "configuring tmux for reliable send-keys (~/.tmux.conf)"
TMUX_CONF="$HOME/.tmux.conf"
TMUX_MARKER="# >>> claudedo tmux >>>"
touch "$TMUX_CONF"
if grep -qF "$TMUX_MARKER" "$TMUX_CONF"; then
echo " claudedo tmux block already present (not duplicating)"
else
cat >> "$TMUX_CONF" <<'TMUXCONF'
# >>> claudedo tmux >>>
# settings for reliable keystroke injection + notifications (do not edit inside the
# markers; re-run install.sh to refresh). escape-time 0 stops injected Escape from
# being misread; allow-passthrough + extended-keys let notifications and modified
# keys (Shift+Enter) reach the claude pane; the larger history-limit keeps scrollback.
set -g escape-time 0
set -g history-limit 50000
set -g allow-passthrough on
set -s extended-keys on
set -as terminal-features 'xterm*:extkeys'
# <<< claudedo tmux <<<
TMUXCONF
echo " appended claudedo tmux settings to $TMUX_CONF (reload: tmux source-file ~/.tmux.conf)"
fi
say "done. next: 'claudedo test-audio' then 'claudedo start'"
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "claudedo" name = "claudedo"
version = "0.1.0" version = "0.1.4"
description = "voice-control daemon for claude code (local STT -> tmux send-keys)" description = "voice-control daemon for claude code (local STT -> tmux send-keys)"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"
+67
View File
@@ -0,0 +1,67 @@
# claudedo cc kit — claude-code-in-tmux session helpers.
# POSIX sh; sources cleanly under bash and zsh. side-effect-free on source
# (function definitions only — nothing runs at source time).
#
# every command REQUIRES an explicit project name. the session is always
# "claude-<name>", a stable speakable handle: "cc libs" -> claude-libs, which the
# voice daemon targets with "claudedo target libs" / "switch libs". the name->session
# mapping here MUST match target.py's session_name() in the daemon.
#
# cc <name> start or reattach to claude-<name>; writes ~/.claude-active
# ccr <name> reattach only (error if it doesn't exist); writes ~/.claude-active
# ccl list running claude- sessions
# cck <name> kill claude-<name>
# cckl kill ALL claude- sessions
cc() {
if [ -z "$1" ]; then
echo "usage: cc <project-name>" >&2
return 1
fi
session="claude-$1"
echo "$session" > "$HOME/.claude-active"
if tmux has-session -t "$session" 2>/dev/null; then
tmux attach -t "$session"
else
tmux new-session -s "$session" "claude"
fi
}
ccr() {
if [ -z "$1" ]; then
echo "usage: ccr <project-name>" >&2
return 1
fi
session="claude-$1"
if tmux has-session -t "$session" 2>/dev/null; then
echo "$session" > "$HOME/.claude-active"
tmux attach -t "$session"
else
echo "no session '$session' — run 'cc $1' to start one" >&2
return 1
fi
}
ccl() {
tmux ls 2>/dev/null | grep '^claude-' || echo "no claude sessions running"
}
cck() {
if [ -z "$1" ]; then
echo "usage: cck <project-name>" >&2
return 1
fi
session="claude-$1"
if tmux kill-session -t "$session" 2>/dev/null; then
echo "killed $session"
else
echo "no session '$session'" >&2
return 1
fi
}
cckl() {
tmux ls 2>/dev/null | grep '^claude-' | cut -d: -f1 | while read -r s; do
tmux kill-session -t "$s" && echo "killed $s"
done
}
+2 -2
View File
@@ -1,3 +1,3 @@
"""claudedo — voice-control daemon for claude code (local STT -> tmux send-keys).""" """claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)"""
__version__ = "0.1.0" __version__ = "0.1.4"
+246
View File
@@ -0,0 +1,246 @@
"""claudedo CLI: start | stop | status | test-audio | install"""
from __future__ import annotations
import argparse
import logging
import subprocess
import sys
import wave
from pathlib import Path
from . import __version__, daemon, target
from .config import Config, ConfigError, load_config
def _setup_logging(verbose: bool) -> None:
logging.basicConfig(
level=logging.DEBUG if verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
datefmt="%H:%M:%S",
)
def _load_or_die(path: str | None) -> Config:
try:
return load_config(path)
except ConfigError as exc:
print(f"config error: {exc}", file=sys.stderr)
raise SystemExit(2)
def cmd_start(args: argparse.Namespace) -> int:
config = _load_or_die(args.config)
if args.mode:
config.mode = args.mode
if args.check:
print("checking mic before listening (speak briefly) ...")
peak = _probe_mic(config, seconds=2.0, verbose=False)
if peak is None or peak < 0.02:
print("mic check failed — no usable input.", file=sys.stderr)
print("run `claudedo test-audio` to debug, or `claudedo start` to skip the check",
file=sys.stderr)
return 1
print(f"mic OK (peak {peak:.3f}).")
try:
daemon.run_daemon(config)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 1
return 0
def _probe_mic(config: Config, seconds: float, verbose: bool):
"""warm up the mic then capture for `seconds`; return peak amplitude or None.
None signals a hard capture failure (no PortAudio / device error) with guidance
already printed; a float (possibly ~0) is a successful capture whose level the
caller judges. shared by `start`'s precheck and `test-audio`.
"""
from . import audio as audio_mod
try:
device = audio_mod.resolve_device(config.stt_device)
if verbose:
print("priming mic (RDPSource resumes from suspend) ...")
audio_mod.warm_up(config.samplerate, config.channels, device)
if verbose:
print(f"capturing {seconds:.0f}s from "
f"device={device if device is not None else 'default'} — speak now ...")
chunk = audio_mod.record_while(
config.samplerate, config.channels, device,
held=_timed_hold(seconds), max_utterance=seconds + 1.0, min_utterance=0.0,
)
except Exception as exc:
print(f"audio capture FAILED: {exc}", file=sys.stderr)
print("fix-chain: install.sh apt deps + ~/.asoundrc pulse shim + Windows mic permission",
file=sys.stderr)
return None
if chunk is None or chunk.size == 0:
print("captured no audio — check mic permission + RDPSource", file=sys.stderr)
return None
peak = float(abs(chunk).max())
if verbose:
out = Path("/tmp/claudedo_test.wav")
_write_wav(out, chunk, config.samplerate)
print(f"captured {chunk.size / config.samplerate:.1f}s, peak amplitude {peak:.3f} -> {out}")
return peak
def cmd_stop(_args: argparse.Namespace) -> int:
if daemon.stop_running():
print("sent stop signal to claudedo")
return 0
print("claudedo is not running")
return 1
def cmd_status(_args: argparse.Namespace) -> int:
pid = daemon.read_pid()
if pid is None:
print("claudedo: not running")
return 1
state = daemon.read_state() or {}
print(f"claudedo: running (pid {pid})")
print(f" mode: {state.get('mode', '?')}")
print(f" target: {state.get('target') or '(none — run cc to attach)'}")
return 0
def _check_audio_tools() -> None:
for tool in ("pactl", "arecord"):
path = subprocess.run(["which", tool], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
mark = "ok" if path.returncode == 0 else "MISSING (run install.sh)"
print(f" {tool}: {mark}")
def cmd_test_audio(args: argparse.Namespace) -> int:
config = _load_or_die(args.config)
print("== claudedo test-audio ==")
print("WSLg PulseServer:", "present" if Path("/mnt/wslg/PulseServer").exists() else "MISSING")
_check_audio_tools()
try:
pactl = subprocess.run(["pactl", "info"], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
if pactl.returncode == 0:
for line in pactl.stdout.decode("utf-8", "replace").splitlines():
if line.startswith("Default Source"):
print(" ", line.strip())
except FileNotFoundError:
pass
from . import audio as audio_mod
print("\nsounddevice input devices:")
try:
for idx, dev in enumerate(audio_mod.list_devices()):
if dev.get("max_input_channels", 0) > 0:
print(f" [{idx}] {dev['name']} ({dev['max_input_channels']}ch)")
except Exception as exc:
print(f" could not list devices: {exc}", file=sys.stderr)
peak = _probe_mic(config, seconds=3.0, verbose=True)
if peak is None:
return 1
if peak < 0.02:
print("WARNING: near-silent capture — is the mic muted / permission denied?")
print("fix-chain: Windows mic permission for desktop apps + a non-Krisp default input;")
print(" if still silent, `wsl --shutdown` then reopen to re-attach RDPSource.")
return 1
print("mic OK.")
return 0
def _timed_hold(seconds: float):
import time
end = [None]
def held() -> bool:
now = time.monotonic()
if end[0] is None:
end[0] = now + seconds
return now < end[0]
return held
def _write_wav(path: Path, chunk, samplerate: int) -> None:
import numpy as np
pcm = (np.clip(chunk, -1.0, 1.0) * 32767).astype("<i2")
with wave.open(str(path), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(samplerate)
wf.writeframes(pcm.tobytes())
def cmd_install(_args: argparse.Namespace) -> int:
script = Path(__file__).resolve().parents[2] / "install.sh"
if not script.is_file():
print(f"install.sh not found at {script}", file=sys.stderr)
return 1
return subprocess.call(["bash", str(script)])
def cmd_set(args: argparse.Namespace) -> int:
session = target.set_target(args.name)
print(f"sticky target -> {session}")
return 0
def cmd_unset(_args: argparse.Namespace) -> int:
target.unset_target()
print("sticky target cleared")
return 0
def cmd_list(_args: argparse.Namespace) -> int:
sessions = target.list_sessions()
if not sessions:
print("no claude sessions running")
return 1
active = target.read_active()
for s in sessions:
print(f"{'* ' if s == active else ' '}{s}")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="claudedo", description="voice control for claude code")
p.add_argument("--version", action="version", version=f"claudedo {__version__}")
p.add_argument("-v", "--verbose", action="store_true", help="debug logging")
p.add_argument("-c", "--config", help="path to config.toml")
sub = p.add_subparsers(dest="command", required=True)
sp = sub.add_parser("start", help="run the daemon (foreground)")
sp.add_argument("--mode", choices=("listen", "ptt"), help="override input mode")
sp.add_argument("--check", action="store_true",
help="run a mic check before listening (off by default)")
sp.set_defaults(func=cmd_start)
sub.add_parser("stop", help="stop a running daemon").set_defaults(func=cmd_stop)
sub.add_parser("status", help="show daemon status").set_defaults(func=cmd_status)
sub.add_parser("test-audio", help="verify the mic capture path").set_defaults(func=cmd_test_audio)
sub.add_parser("install", help="re-run the bootstrap (install.sh)").set_defaults(func=cmd_install)
sub.add_parser("unset", help="clear the sticky target session").set_defaults(func=cmd_unset)
sub.add_parser("list", help="list running claude-* sessions").set_defaults(func=cmd_list)
for verb in ("set", "switch"):
sp_set = sub.add_parser(verb, help="set the sticky target session")
sp_set.add_argument("name", help="project short-name (claude- prefix optional)")
sp_set.set_defaults(func=cmd_set)
return p
def main(argv: list[str] | None = None) -> int:
parser = build_parser()
args = parser.parse_args(argv)
_setup_logging(getattr(args, "verbose", False))
return args.func(args)
if __name__ == "__main__":
sys.exit(main())
+179
View File
@@ -0,0 +1,179 @@
"""mic capture via sounddevice — the WSL-hard part.
device selection resolves config's stt.device ("auto" | index | name substring) to
a concrete sounddevice input device. two capture paths:
- record_until_silence(): listen mode — stream until trailing silence segments the
utterance (no streaming STT; chunk-on-silence is enough for commands).
- record_while(predicate): ptt mode — capture while predicate() is true (key held).
the WSLg/PulseAudio path is verified separately by `claudedo test-audio`; if capture
fails here the fix-chain is the apt deps + ~/.asoundrc + Windows mic permission.
"""
from __future__ import annotations
import logging
import queue
import time
from typing import Callable
import numpy as np
log = logging.getLogger(__name__)
class AudioError(Exception):
"""raised when no usable input device is found or capture fails"""
def list_devices() -> list[dict]:
"""return sounddevice's device table (for test-audio / debugging)"""
import sounddevice as sd
return list(sd.query_devices())
def resolve_device(spec: str) -> int | None:
"""resolve a device spec to a sounddevice input index, or None for default.
spec: "auto" -> default input; a digit string -> that index; otherwise a
case-insensitive substring of a device name with input channels.
"""
import sounddevice as sd
if spec in ("", "auto", "default"):
return None
if spec.isdigit():
return int(spec)
spec_low = spec.lower()
for idx, dev in enumerate(sd.query_devices()):
if dev.get("max_input_channels", 0) > 0 and spec_low in dev["name"].lower():
return idx
raise AudioError(f"no input device matching {spec!r}")
def _rms(block: np.ndarray) -> float:
if block.size == 0:
return 0.0
return float(np.sqrt(np.mean(np.square(block, dtype=np.float64))))
def warm_up(samplerate: int, channels: int, device: int | None,
timeout: float = 3.0) -> bool:
"""open a short stream and read until the source produces audio.
WSLg's RDPSource suspends when idle and emits ~1-2s of silence while it resumes
on the next read. priming here means the first real capture isn't lost to that
warm-up gap. returns whether any non-silent block arrived before timeout (still
safe to proceed either way — a truly silent mic just returns False).
"""
import sounddevice as sd
block_dur = 0.05
blocksize = int(samplerate * block_dur)
deadline = time.monotonic() + timeout
with sd.InputStream(samplerate=samplerate, channels=channels, device=device,
dtype="float32", blocksize=blocksize) as stream:
while time.monotonic() < deadline:
block, _overflowed = stream.read(blocksize)
mono = block.reshape(-1) if channels == 1 else block.mean(axis=1)
if _rms(mono) > 0.0:
return True
return False
def record_until_silence(samplerate: int, channels: int, device: int | None,
silence_threshold: float, silence_duration: float,
min_utterance: float, max_utterance: float,
stop: Callable[[], bool] | None = None) -> np.ndarray | None:
"""capture one utterance, ending after trailing silence. returns mono float32.
blocks until speech is detected and then trailing silence segments it, or until
stop() returns true (clean shutdown). returns None if stopped before any speech
or if the captured utterance is shorter than min_utterance.
"""
import sounddevice as sd
block_dur = 0.05
blocksize = int(samplerate * block_dur)
q: "queue.Queue[np.ndarray]" = queue.Queue()
def _cb(indata, _frames, _time, status):
if status:
log.debug("audio status: %s", status)
q.put(indata.copy())
collected: list[np.ndarray] = []
speaking = False
silence_run = 0.0
started_at = time.monotonic()
with sd.InputStream(samplerate=samplerate, channels=channels, device=device,
dtype="float32", blocksize=blocksize, callback=_cb):
while True:
if stop is not None and stop():
break
try:
block = q.get(timeout=0.2)
except queue.Empty:
if not speaking and time.monotonic() - started_at > 600:
started_at = time.monotonic()
continue
mono = block.reshape(-1) if channels == 1 else block.mean(axis=1)
level = _rms(mono)
if level >= silence_threshold:
speaking = True
silence_run = 0.0
collected.append(mono)
elif speaking:
silence_run += block_dur
collected.append(mono)
if silence_run >= silence_duration:
break
if speaking and (time.monotonic() - started_at) > max_utterance:
log.debug("utterance hit max_utterance cap")
break
if not collected:
return None
audio = np.concatenate(collected).astype(np.float32)
if audio.size / samplerate < min_utterance:
return None
return audio
def record_while(samplerate: int, channels: int, device: int | None,
held: Callable[[], bool], max_utterance: float,
min_utterance: float) -> np.ndarray | None:
"""capture while held() is true (push-to-talk). returns mono float32 or None"""
import sounddevice as sd
block_dur = 0.05
blocksize = int(samplerate * block_dur)
q: "queue.Queue[np.ndarray]" = queue.Queue()
def _cb(indata, _frames, _time, status):
if status:
log.debug("audio status: %s", status)
q.put(indata.copy())
collected: list[np.ndarray] = []
started_at = time.monotonic()
with sd.InputStream(samplerate=samplerate, channels=channels, device=device,
dtype="float32", blocksize=blocksize, callback=_cb):
while held():
try:
block = q.get(timeout=0.1)
except queue.Empty:
continue
mono = block.reshape(-1) if channels == 1 else block.mean(axis=1)
collected.append(mono)
if (time.monotonic() - started_at) > max_utterance:
break
if not collected:
return None
audio = np.concatenate(collected).astype(np.float32)
if audio.size / samplerate < min_utterance:
return None
return audio
+33 -16
View File
@@ -1,4 +1,4 @@
"""load and validate config.toml into a typed Config object with clear errors.""" """load and validate config.toml into a typed Config object with clear errors"""
from __future__ import annotations from __future__ import annotations
@@ -10,14 +10,17 @@ from pathlib import Path
try: try:
import tomllib as _toml import tomllib as _toml
_TOML_BINARY = True _TOML_BINARY = True
except ModuleNotFoundError: # python < 3.11 except ModuleNotFoundError:
import tomli as _toml import tomli as _toml
_TOML_BINARY = True _TOML_BINARY = True
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
_VALID_MODES = ("listen", "ptt") _VALID_MODES = ("listen", "ptt")
_VALID_MODELS = ("tiny", "base", "small", "medium", "large-v2", "large-v3") _VALID_MODELS = (
"tiny", "base", "small", "medium", "large-v1", "large-v2", "large-v3",
"tiny.en", "base.en", "small.en", "medium.en",
)
DEFAULT_CONFIG_PATHS = ( DEFAULT_CONFIG_PATHS = (
Path(os.environ.get("CLAUDEDO_CONFIG", "")) if os.environ.get("CLAUDEDO_CONFIG") else None, Path(os.environ.get("CLAUDEDO_CONFIG", "")) if os.environ.get("CLAUDEDO_CONFIG") else None,
@@ -27,12 +30,12 @@ DEFAULT_CONFIG_PATHS = (
class ConfigError(Exception): class ConfigError(Exception):
"""raised on a missing or invalid configuration value.""" """raised on a missing or invalid configuration value"""
@dataclass @dataclass
class Config: class Config:
"""validated claudedo configuration.""" """validated claudedo configuration"""
wake_phrases: list[str] wake_phrases: list[str]
mode: str mode: str
@@ -44,16 +47,20 @@ class Config:
samplerate: int samplerate: int
channels: int channels: int
silence_threshold: float silence_threshold: float
silence_duration: float vad_silence_ms: int
vad_max_seconds: float
min_utterance: float min_utterance: float
max_utterance: float
type_autosend: bool type_autosend: bool
match_threshold: float wake_fuzzy_threshold: float
command_fuzzy_threshold: float
filler_words: tuple[str, ...]
auto_target: bool
print_heard: bool
source_path: Path | None = field(default=None) source_path: Path | None = field(default=None)
def find_config_path(explicit: str | os.PathLike | None = None) -> Path: def find_config_path(explicit: str | os.PathLike | None = None) -> Path:
"""resolve the config file path, raising ConfigError if none is found.""" """resolve the config file path, raising ConfigError if none is found"""
candidates: list[Path] = [] candidates: list[Path] = []
if explicit: if explicit:
candidates.append(Path(explicit)) candidates.append(Path(explicit))
@@ -79,7 +86,7 @@ def _require(table: dict, section: str, key: str, types: tuple, default=None):
def load_config(explicit: str | os.PathLike | None = None) -> Config: def load_config(explicit: str | os.PathLike | None = None) -> Config:
"""load config.toml from the first existing default path (or an explicit one).""" """load config.toml from the first existing default path (or an explicit one)"""
path = find_config_path(explicit) path = find_config_path(explicit)
try: try:
with open(path, "rb") as fh: with open(path, "rb") as fh:
@@ -95,7 +102,7 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
if mode not in _VALID_MODES: if mode not in _VALID_MODES:
raise ConfigError(f"[input].mode must be one of {_VALID_MODES}, got {mode!r}") raise ConfigError(f"[input].mode must be one of {_VALID_MODES}, got {mode!r}")
model = _require(raw, "stt", "model", (str,), "small") model = _require(raw, "stt", "model", (str,), "small.en")
if model not in _VALID_MODELS: if model not in _VALID_MODELS:
log.warning("unknown stt model %r — passing through to faster-whisper", model) log.warning("unknown stt model %r — passing through to faster-whisper", model)
@@ -110,15 +117,25 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
samplerate=int(_require(raw, "audio", "samplerate", (int,), 16000)), samplerate=int(_require(raw, "audio", "samplerate", (int,), 16000)),
channels=int(_require(raw, "audio", "channels", (int,), 1)), channels=int(_require(raw, "audio", "channels", (int,), 1)),
silence_threshold=float(_require(raw, "audio", "silence_threshold", (int, float), 0.012)), silence_threshold=float(_require(raw, "audio", "silence_threshold", (int, float), 0.012)),
silence_duration=float(_require(raw, "audio", "silence_duration", (int, float), 0.8)), vad_silence_ms=int(_require(raw, "vad", "silence_ms", (int,), 700)),
vad_max_seconds=float(_require(raw, "vad", "max_seconds", (int, float), 15.0)),
min_utterance=float(_require(raw, "audio", "min_utterance", (int, float), 0.3)), min_utterance=float(_require(raw, "audio", "min_utterance", (int, float), 0.3)),
max_utterance=float(_require(raw, "audio", "max_utterance", (int, float), 15.0)),
type_autosend=bool(_require(raw, "behavior", "type_autosend", (bool,), False)), type_autosend=bool(_require(raw, "behavior", "type_autosend", (bool,), False)),
match_threshold=float(_require(raw, "behavior", "match_threshold", (int, float), 0.8)), wake_fuzzy_threshold=float(_require(raw, "behavior", "wake_fuzzy_threshold", (int, float), 0.65)),
command_fuzzy_threshold=float(_require(raw, "behavior", "command_fuzzy_threshold",
(int, float), 0.8)),
filler_words=tuple(_require(raw, "behavior", "filler_words", (list,),
["select", "use", "choose"])),
auto_target=bool(_require(raw, "behavior", "auto_target", (bool,), False)),
print_heard=bool(_require(raw, "behavior", "print_heard", (bool,), False)),
source_path=path, source_path=path,
) )
if not 0.0 < cfg.match_threshold <= 1.0: for label, val in (("wake_fuzzy_threshold", cfg.wake_fuzzy_threshold),
raise ConfigError("[behavior].match_threshold must be in (0, 1]") ("command_fuzzy_threshold", cfg.command_fuzzy_threshold)):
if not 0.0 < val <= 1.0:
raise ConfigError(f"[behavior].{label} must be in (0, 1]")
if cfg.vad_silence_ms <= 0 or cfg.vad_max_seconds <= 0:
raise ConfigError("[vad].silence_ms and max_seconds must be positive")
if cfg.samplerate <= 0 or cfg.channels <= 0: if cfg.samplerate <= 0 or cfg.channels <= 0:
raise ConfigError("[audio].samplerate and channels must be positive") raise ConfigError("[audio].samplerate and channels must be positive")
return cfg return cfg
+65
View File
@@ -0,0 +1,65 @@
"""colored, prefixed console output for the daemon's recognition/action feed.
every line is ``HH:MM:SS [PREFIX] message``. prefixes group the source: a session
name (e.g. ``[claude-libs]``) for anything injected into a tmux session, ``[SYSTEM]``
for daemon-control/state lines, and ``[VOICE]`` for STT/recognition lines. color is
opt-in via tty detection (or forced): green for successful injections, red for
drops/errors, dim for routine. falls back to plain text when stdout is not a tty.
"""
from __future__ import annotations
import sys
import time
RESET = "\033[0m"
_COLORS = {
"green": "\033[32m",
"red": "\033[31m",
"yellow": "\033[33m",
"cyan": "\033[36m",
"blue": "\033[34m",
"brightblue": "\033[94m",
"magenta": "\033[35m",
"dim": "\033[2m",
"bold": "\033[1m",
}
SYSTEM = "SYSTEM"
VOICE = "VOICE"
HELP = "HELP"
class Console:
"""formats and prints daemon log lines with timestamp, prefix, and color"""
def __init__(self, color: bool | None = None, stream=None, clock=None) -> None:
self.stream = stream if stream is not None else sys.stdout
self._clock = clock or time.localtime
if color is None:
color = hasattr(self.stream, "isatty") and self.stream.isatty()
self.color = bool(color)
def _stamp(self) -> str:
t = self._clock()
return f"{t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}"
def _paint(self, text: str, color: str | None) -> str:
if not self.color or not color or color not in _COLORS:
return text
return f"{_COLORS[color]}{text}{RESET}"
def paint(self, text: str, color: str | None) -> str:
"""public colorizer for pre-coloring a fragment of a message (e.g. a command
word) before passing it to emit() with color=None"""
return self._paint(text, color)
def emit(self, prefix: str, message: str, color: str | None = None) -> None:
"""print one line: ``HH:MM:SS [prefix] message`` (message optionally colored)"""
line = f"{self._stamp()} {self._paint(f'[{prefix}]', 'dim')} {self._paint(message, color)}"
print(line, file=self.stream, flush=True)
def line(self, message: str, color: str | None = None) -> None:
"""print a bare continuation line (no timestamp/prefix) — for multi-row blocks
like the help menu, indented under a preceding header"""
print(self._paint(message, color), file=self.stream, flush=True)
+352
View File
@@ -0,0 +1,352 @@
"""the capture -> stt -> match -> inject loop.
privacy invariant: in listen mode, any utterance that does not start with a wake
phrase is discarded the instant grammar.parse() returns None — the transcript text
is dropped and never stored or transmitted. nothing about non-command speech is
persisted.
"""
from __future__ import annotations
import json
import logging
import os
import signal
import sys
import time
from pathlib import Path
from . import __version__, audio, grammar, inject, target
from .config import Config
from .console import HELP, SYSTEM, VOICE, Console
from .stt import Transcriber
log = logging.getLogger(__name__)
STATE_DIR = Path(os.environ.get("XDG_CACHE_HOME", str(Path.home() / ".cache"))) / "claudedo"
PIDFILE = STATE_DIR / "claudedo.pid"
STATEFILE = STATE_DIR / "state.json"
def _ensure_state_dir() -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
def write_state(pid: int, mode: str, target_session: str | None) -> None:
"""write the running daemon's status for `claudedo status` to read"""
_ensure_state_dir()
STATEFILE.write_text(json.dumps({
"pid": pid,
"mode": mode,
"target": target_session,
"since": time.time(),
}), encoding="utf-8")
def read_state() -> dict | None:
"""read the daemon status file, or None if absent/unreadable"""
try:
return json.loads(STATEFILE.read_text(encoding="utf-8"))
except (FileNotFoundError, json.JSONDecodeError, OSError):
return None
def read_pid() -> int | None:
"""return the pid of a running daemon, or None (also clears stale pidfiles)"""
try:
pid = int(PIDFILE.read_text(encoding="utf-8").strip())
except (FileNotFoundError, ValueError, OSError):
return None
try:
os.kill(pid, 0)
except ProcessLookupError:
PIDFILE.unlink(missing_ok=True)
return None
except PermissionError:
return pid
return pid
def stop_running() -> bool:
"""signal a running daemon to stop. returns whether one was found"""
pid = read_pid()
if pid is None:
return False
os.kill(pid, signal.SIGTERM)
return True
class _PTTKey:
"""desk-only push-to-talk: 'held' while the configured key is down in the
daemon's own terminal. there is deliberately NO global hotkey — a system-wide
keyboard hook is the keylogger/cheat silhouette claudedo refuses to install. for
hands-free-while-gaming use listen mode (voice trigger over the mic bridge).
implementation reads stdin in raw mode: press the key to start capture, press it
again (or Enter) to stop. (terminals don't deliver key-up events, so true
hold-to-talk isn't possible from a tty — this is press-toggle, documented.)
"""
def __init__(self) -> None:
self._tty = sys.stdin.isatty()
def wait_press(self, stop) -> bool:
import select
if not self._tty:
log.warning("ptt mode needs a tty; falling back to a 3s timed capture")
time.sleep(0.2)
return not stop()
while not stop():
r, _, _ = select.select([sys.stdin], [], [], 0.2)
if r:
sys.stdin.read(1)
return True
return False
class Daemon:
"""owns the capture/transcribe/inject loop and runtime mode switching"""
def __init__(self, config: Config) -> None:
self.config = config
self.mode = config.mode
self._stop = False
self._transcriber: Transcriber | None = None
self._device: int | None = None
self._ptt = _PTTKey()
self._pending: dict[str, int] = {}
self._console = Console()
self._last_stt_ms = 0.0
self._last_audio_s = 0.0
def _install_signals(self) -> None:
signal.signal(signal.SIGTERM, self._on_signal)
signal.signal(signal.SIGINT, self._on_signal)
def _on_signal(self, _signum, _frame) -> None:
log.info("stop requested")
self._stop = True
def stopped(self) -> bool:
return self._stop
def _load(self) -> None:
cfg = self.config
self._device = audio.resolve_device(cfg.stt_device)
self._transcriber = Transcriber(
model=cfg.stt_model, language=cfg.stt_language,
device=cfg.stt_compute if cfg.stt_compute in ("cpu", "cuda") else "auto",
compute_type="auto",
initial_prompt=grammar.initial_prompt(cfg.wake_phrases),
)
if audio.warm_up(cfg.samplerate, cfg.channels, self._device):
log.info("mic warmed up (source live)")
else:
log.warning("mic warm-up saw only silence — check mic permission / RDPSource")
def _capture(self):
cfg = self.config
if self.mode == "ptt":
print("[ptt] press the capture key in this terminal, speak, then press again to stop")
if not self._ptt.wait_press(self.stopped):
return None
return audio.record_while(
cfg.samplerate, cfg.channels, self._device,
held=lambda: not self._ptt.wait_press(self.stopped),
max_utterance=cfg.vad_max_seconds, min_utterance=cfg.min_utterance,
)
return audio.record_until_silence(
cfg.samplerate, cfg.channels, self._device,
silence_threshold=cfg.silence_threshold, silence_duration=cfg.vad_silence_ms / 1000.0,
min_utterance=cfg.min_utterance, max_utterance=cfg.vad_max_seconds,
stop=self.stopped,
)
def _handle(self, transcript: str) -> None:
cfg = self.config
require_wake = self.mode == "listen"
parsed = grammar.parse(transcript, cfg.wake_phrases, cfg.wake_fuzzy_threshold,
cfg.command_fuzzy_threshold, require_wake, filler=cfg.filler_words)
if parsed is None or parsed.action is None:
self._console.emit(VOICE, f'heard "{transcript}" -> no command matched {self._timing()}',
"yellow")
return
action = parsed.action
# a command was recognized — echo what we heard (green) before acting. note the
# matched wake phrase (magenta) when the transcript didn't literally contain it
# (so a loose match like "okay clouds" -> "okay claude" is visible).
head = self._console.paint(f'heard "{transcript}" -> {self._describe(action)}', "green")
note = ""
if parsed.wake and parsed.wake.replace(" ", "") not in transcript.lower().replace(" ", ""):
note = (self._console.paint(" (wake: ", "green")
+ self._console.paint(parsed.wake, "magenta")
+ self._console.paint(")", "green"))
tail = self._console.paint(f" {self._timing()}", "green")
self._console.emit(VOICE, f"{head}{note}{tail}")
def blue(s):
return self._console.paint(s, "brightblue")
if action.name == "mode":
new_mode = str(action.arg)
if new_mode != self.mode:
self.mode = new_mode
self._console.emit(SYSTEM, f"{blue('mode')} -> {new_mode}")
self._refresh_state()
return
if action.name == "set":
session = target.set_target(str(action.arg))
self._pending.pop(session, None)
self._console.emit(SYSTEM, f"{blue('set sticky')} -> {session}")
self._refresh_state()
return
if action.name == "unset":
target.unset_target()
self._console.emit(SYSTEM, f"{blue('unset')} (cleared)")
self._refresh_state()
return
if action.name == "list":
sessions = target.list_sessions()
self._console.emit(SYSTEM, f"{blue('list')} -> "
+ (", ".join(sessions) if sessions else "(none running)"))
return
if action.name == "commands":
self._console.emit(HELP, "voice commands:")
for usage, desc in grammar.command_menu():
self._console.line(f" {self._console.paint(f'{usage:<26}', 'brightblue')} {desc}")
return
if action.name == "customs":
self._console.emit(SYSTEM, "custom commands arrive in v0.2.0 (contexts.toml)")
return
if action.name == "version":
self._console.emit(SYSTEM, f"claudedo {__version__}")
return
if action.name == "debug":
self._console.emit(VOICE, f'debug: "{action.arg}"', "yellow")
return
session, reason = target.resolve(parsed.one_shot, auto_target=cfg.auto_target)
if session is None:
self._console.emit(VOICE, f'heard "{transcript}" -> {reason} -> '
f'{self._describe(action)} did nothing', "red")
return
self._inject(session, action)
def _inject(self, session: str, action) -> None:
"""run a resolved command against `session`, tracking the uncommitted-input
buffer so backspace/erase delete only back to the last submit boundary.
the 'heard ...' echo is already printed by _handle and the [session] prefix
names the target, so these lines just report the keystrokes injected.
"""
name = action.name
if name == "type":
text = str(action.arg)
inject.send_literal(session, text)
self._pending[session] = self._pending.get(session, 0) + len(text)
if self.config.type_autosend:
inject.send_named(session, inject.keys.SUBMIT)
self._pending[session] = 0
self._console.emit(session, f"typed {text!r}"
+ (" + send" if self.config.type_autosend else ""), "green")
return
if name == "space":
n = int(action.arg)
inject.perform(session, action)
self._pending[session] = self._pending.get(session, 0) + n
self._console.emit(session, f"space x{n}", "green")
return
if name == "backspace":
n = int(action.arg)
if n:
inject.perform(session, action)
self._pending[session] = max(0, self._pending.get(session, 0) - n)
self._console.emit(session, f"backspace x{n}", "green")
return
if name == "erase":
n = self._pending.get(session, 0)
if n:
inject.perform(session, grammar.Action("erase", n))
self._pending[session] = 0
self._console.emit(session, f"erase x{n} (to last boundary)", "green")
return
inject.perform(session, action)
if name == "submit":
self._pending[session] = 0
self._console.emit(session, f"injected {self._describe(action)}", "green")
def _timing(self) -> str:
"""compact STT latency suffix for heard lines (transcribe ms on audio secs)"""
return f"({self._last_stt_ms:.0f}ms/{self._last_audio_s:.1f}s)"
@staticmethod
def _describe(action) -> str:
if action.arg is None:
return action.name.upper()
return f"{action.name.upper()}({action.arg})"
def _has_wake(self, transcript: str) -> bool:
"""true if the utterance starts with a wake phrase (listen-mode gate).
non-wake speech is dropped without ever printing the transcript — the privacy
invariant: non-command speech is discarded, never recorded.
"""
cfg = self.config
return grammar.strip_wake(transcript, cfg.wake_phrases,
cfg.wake_fuzzy_threshold, True) is not None
def _print_startup(self) -> None:
cfg = self.config
dev = cfg.stt_device if cfg.stt_device != "auto" else "default"
target_now = target.read_active() or "(none — run cc / set <name>)"
self._console.emit(SYSTEM, f"claudedo {self.mode} mode — Ctrl-C to stop", "bold")
self._console.emit(SYSTEM, f"model {cfg.stt_model} ({cfg.stt_language}) · mic {dev} · "
f"target {target_now}")
wakes = ", ".join(self._console.paint(p, "magenta") for p in cfg.wake_phrases)
self._console.emit(SYSTEM, f"wake: {wakes}")
def _refresh_state(self) -> None:
write_state(os.getpid(), self.mode, target.read_active())
def run(self) -> None:
"""run the daemon loop until a stop signal arrives"""
_ensure_state_dir()
PIDFILE.write_text(str(os.getpid()), encoding="utf-8")
self._install_signals()
try:
self._load()
self._refresh_state()
self._print_startup()
while not self._stop:
audio_chunk = self._capture()
if self._stop:
break
if audio_chunk is None:
continue
t0 = time.monotonic()
transcript = self._transcriber.transcribe(audio_chunk, self.config.samplerate)
self._last_stt_ms = (time.monotonic() - t0) * 1000.0
self._last_audio_s = audio_chunk.size / self.config.samplerate
if not transcript:
continue
if self.mode == "listen" and not self._has_wake(transcript):
if self.config.print_heard:
self._console.emit(VOICE, f'heard (dropped) "{transcript}" {self._timing()}', "red")
else:
self._console.emit(VOICE, "dropped: non-wake speech (not recorded)", "dim")
continue
self._handle(transcript)
finally:
PIDFILE.unlink(missing_ok=True)
STATEFILE.unlink(missing_ok=True)
log.info("claudedo stopped")
def run_daemon(config: Config) -> None:
"""entry point used by the CLI ``start`` command"""
if read_pid() is not None:
raise RuntimeError("claudedo is already running (see `claudedo status`)")
Daemon(config).run()
+348
View File
@@ -0,0 +1,348 @@
"""wake-phrase gate + command grammar matching (fuzzy, data-driven).
the matcher is lenient by design: whisper renders the coined word "claudedo"
inconsistently, so wake-phrase detection normalizes case, strips spaces/punctuation,
and accepts close variants. number words are normalized to digits before matching.
flow: transcript -> strip_wake() returns the command remainder (or None if no wake
phrase in listen mode) -> match_command() maps the remainder to an Action.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from difflib import SequenceMatcher
_PUNCT = re.compile(r"[^a-z0-9 ]+")
_WS = re.compile(r"\s+")
_NUMBER_WORDS = {
"zero": "0", "oh": "0",
"one": "1", "won": "1",
"two": "2", "to": "2", "too": "2",
"three": "3", "tree": "3",
"four": "4", "for": "4", "fore": "4",
}
_INDEX_WORDS = {"1": 1, "2": 2, "3": 3, "4": 4}
_COUNT_WORDS = {
"five": 5, "six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"eleven": 11, "twelve": 12, "thirteen": 13, "fourteen": 14, "fifteen": 15,
"sixteen": 16, "seventeen": 17, "eighteen": 18, "nineteen": 19, "twenty": 20,
}
_YES_VERBS = ("yes", "yeah", "yep", "yup")
_NO_VERBS = ("no", "nope", "nah")
_APPROVE_VERBS = ("approve", "allow")
_DENY_VERBS = ("deny", "reject")
_SUBMIT_VERBS = ("send", "enter", "submit")
_CANCEL_VERBS = ("cancel", "escape")
_TYPE_VERBS = ("type", "dictate", "write")
_BACKSPACE_VERBS = ("backspace", "delete")
_SPACE_VERBS = ("space", "spacebar")
_ADD_VERBS = ("add", "insert")
_ERASE_VERBS = ("erase", "clear", "wipe")
_DEBUG_VERBS = ("debug", "echo")
_MODE_VERBS = ("mode",)
_STICKY_VERBS = ("set", "sticky", "switch")
_ONESHOT_VERBS = ("target",)
_UNSET_VERBS = ("unset", "unsticky")
_LIST_VERBS = ("list", "sessions")
_COMMANDS_VERBS = ("commands", "help", "menu")
_CUSTOMS_VERBS = ("customs", "custom")
_VERSION_VERBS = ("version",)
_SELECT_VERBS = ("select", "option", "choose", "number")
# every command/synonym word, for biasing the STT toward the vocabulary we expect.
_COMMAND_WORDS = (
_YES_VERBS + _NO_VERBS + _APPROVE_VERBS + _DENY_VERBS + _SUBMIT_VERBS
+ _CANCEL_VERBS + _TYPE_VERBS + _BACKSPACE_VERBS + _SPACE_VERBS + _ADD_VERBS
+ _ERASE_VERBS + _DEBUG_VERBS + _MODE_VERBS + _STICKY_VERBS + _ONESHOT_VERBS + _UNSET_VERBS
+ _LIST_VERBS + _COMMANDS_VERBS + _CUSTOMS_VERBS + _VERSION_VERBS
+ _SELECT_VERBS + ("ptt", "listen")
+ ("one", "two", "three", "four")
)
DEFAULT_FILLER = ("select", "use", "choose")
@dataclass(frozen=True)
class Action:
"""a matched command: a name plus an optional argument.
names: yes, no, select, approve, deny, submit, type, space, backspace, erase,
cancel, mode, set, unset, list. arg carries the select index (int), the literal
text for ``type``, the count for ``space``/``backspace`` (int), the mode for
``mode``, or the session short-name for ``set``.
"""
name: str
arg: object = None
@dataclass(frozen=True)
class ParsedCommand:
"""a fully parsed utterance: an optional one-shot target plus the command action.
one_shot is the session short-name from a leading ``target <name>`` (this command
only; does not change the sticky default), or None. action is the command to run,
or None if nothing matched after the wake phrase / one-shot / filler. wake is the
configured wake phrase that matched (e.g. "okay claude" for a heard "okay clouds"),
or None.
"""
one_shot: str | None
action: Action | None
wake: str | None = None
def normalize(text: str) -> str:
"""lowercase, strip punctuation, collapse whitespace, map number words to digits"""
text = text.lower().strip()
text = _PUNCT.sub(" ", text)
text = _WS.sub(" ", text).strip()
if not text:
return ""
tokens = [_NUMBER_WORDS.get(tok, tok) for tok in text.split(" ")]
return " ".join(tokens)
def vocabulary(wake_phrases: list[str]) -> list[str]:
"""the wake + command vocabulary, deduped in first-seen order.
single source for biasing the STT: the same wake phrases the matcher uses plus
every command/synonym word in _COMMAND_WORDS. no separate hardcoded copy.
"""
seen: dict[str, None] = {}
for word in list(wake_phrases) + list(_COMMAND_WORDS):
key = word.strip()
if key and key not in seen:
seen[key] = None
return list(seen)
def initial_prompt(wake_phrases: list[str]) -> str:
"""a comma-joined vocabulary string to pass faster-whisper as initial_prompt,
conditioning transcription toward the words we expect (esp. the coined wake)"""
return ", ".join(vocabulary(wake_phrases))
def command_menu() -> list[tuple[str, str]]:
"""the voice command menu as (usage, description) rows, for the `commands` cmd.
a small curated list keyed off the verb groups — the speakable command surface,
NOT the cc shell kit.
"""
return [
("yes / no", "answer a yes/no prompt"),
("one..four", "pick numbered option 1-4"),
("approve / deny", "allow / deny a permission prompt"),
("send", "submit (Enter)"),
("cancel", "back out (Escape)"),
("type <text>", "insert literal text (no submit)"),
("space [n] / add a space", "insert n spaces"),
("backspace [n]", "delete n chars (to last submit)"),
("erase", "wipe the current input"),
("debug <text>", "echo to console (no inject)"),
("set <name>", "sticky target -> claude-<name>"),
("target <name> <cmd>", "one-shot to another session"),
("unset / list", "clear sticky / list sessions"),
("mode ptt|listen", "switch input mode"),
("commands / customs", "this menu / custom commands (v0.2.0)"),
("version", "print the claudedo version"),
]
def _ratio(a: str, b: str) -> float:
return SequenceMatcher(None, a, b).ratio()
def _wake_variants(phrase: str) -> set[str]:
"""spaced and despaced forms of a wake phrase for lenient matching"""
norm = normalize(phrase)
return {norm, norm.replace(" ", "")}
def strip_wake_match(transcript: str, wake_phrases: list[str], threshold: float,
require_wake: bool) -> tuple[str | None, str | None]:
"""return (command remainder, matched wake phrase).
if ``require_wake`` (listen mode) and no wake phrase is found at the start, the
remainder is None so the daemon discards the utterance. if not required (ptt
mode), a leading wake phrase is stripped when present but its absence is fine.
the matched phrase is the configured wake phrase that best matched (e.g. "okay
claude" for a heard "okay clouds"), or None when none matched.
matches leniently on a despaced prefix (whisper splits/joins the coined word
inconsistently) but always slices the remainder on a WORD boundary of the
spaced, normalized transcript — so the command portion keeps its spaces.
"""
norm = normalize(transcript)
if not norm:
return (None, None) if require_wake else ("", None)
words = norm.split(" ")
best_remainder: str | None = None
best_phrase: str | None = None
best_score = 0.0
for phrase in wake_phrases:
variants = _wake_variants(phrase)
max_words = phrase.count(" ") + 2
for take in range(1, min(max_words, len(words)) + 1):
head_despaced = "".join(words[:take])
for variant in variants:
if not variant:
continue
score = _ratio(head_despaced, variant)
if score >= threshold and score > best_score:
best_score = score
best_remainder = " ".join(words[take:]).strip()
best_phrase = phrase
if best_remainder is not None:
return best_remainder, best_phrase
return (None, None) if require_wake else (norm, None)
def strip_wake(transcript: str, wake_phrases: list[str], threshold: float,
require_wake: bool) -> str | None:
"""return the command remainder after the wake phrase (None if no wake in listen
mode). thin wrapper over strip_wake_match for callers that don't need the phrase"""
return strip_wake_match(transcript, wake_phrases, threshold, require_wake)[0]
def _fuzzy_in(token: str, options: tuple[str, ...], threshold: float) -> bool:
return any(_ratio(token, opt) >= threshold for opt in options)
def _leading_count(rest: list[str], default: int = 1) -> int:
"""read a count from the first token (digit or number word), else the default.
'backspace 3' -> 3, 'backspace ten' -> 10 (normalize maps small words to digits;
larger words come from _COUNT_WORDS), 'backspace' -> default.
"""
if not rest:
return default
tok = rest[0]
if tok.isdigit():
return max(0, int(tok))
if tok in _COUNT_WORDS:
return _COUNT_WORDS[tok]
return default
def match_command(remainder: str, threshold: float) -> Action | None:
"""map a normalized command remainder to an Action, or None if unrecognized.
expects the one-shot target and any leading filler to have been stripped already
(see parse). a leading ``select``/``option``/etc. is only treated as the select
command when followed by a digit; otherwise it is filler handled upstream.
"""
remainder = remainder.strip()
if not remainder:
return None
tokens = remainder.split(" ")
head = tokens[0]
rest = tokens[1:]
if head in _INDEX_WORDS:
return Action("select", _INDEX_WORDS[head])
if _fuzzy_in(head, _YES_VERBS, threshold):
return Action("yes")
if _fuzzy_in(head, _NO_VERBS, threshold):
return Action("no")
if _fuzzy_in(head, _APPROVE_VERBS, threshold):
return Action("approve")
if _fuzzy_in(head, _DENY_VERBS, threshold):
return Action("deny")
if _fuzzy_in(head, _SUBMIT_VERBS, threshold):
return Action("submit")
if _fuzzy_in(head, _CANCEL_VERBS, threshold):
return Action("cancel")
if _fuzzy_in(head, _SELECT_VERBS, threshold) and rest and rest[0] in _INDEX_WORDS:
return Action("select", _INDEX_WORDS[rest[0]])
if _fuzzy_in(head, _TYPE_VERBS, threshold):
text = " ".join(rest).strip()
return Action("type", text) if text else None
if _fuzzy_in(head, _BACKSPACE_VERBS, threshold):
return Action("backspace", _leading_count(rest, default=1))
if _fuzzy_in(head, _SPACE_VERBS, threshold):
return Action("space", _leading_count(rest, default=1))
if _fuzzy_in(head, _ADD_VERBS, threshold) and rest:
tail = [t for t in rest if t not in ("a", "an")]
if any(_fuzzy_in(t, ("space", "spaces"), threshold) for t in tail):
count = next((int(t) for t in tail if t.isdigit()),
next((_COUNT_WORDS[t] for t in tail if t in _COUNT_WORDS), 1))
return Action("space", count)
if _fuzzy_in(head, _ERASE_VERBS, threshold):
return Action("erase")
if _fuzzy_in(head, _DEBUG_VERBS, threshold):
return Action("debug", " ".join(rest).strip())
if _fuzzy_in(head, _MODE_VERBS, threshold) and rest:
if _fuzzy_in(rest[0], ("ptt",), threshold) or "push" in rest[0]:
return Action("mode", "ptt")
if _fuzzy_in(rest[0], ("listen",), threshold):
return Action("mode", "listen")
return None
if _fuzzy_in(head, _STICKY_VERBS, threshold) and rest:
name = "".join(rest)
return Action("set", name) if name else None
if _fuzzy_in(head, _UNSET_VERBS, threshold) and not rest:
return Action("unset")
if _fuzzy_in(head, _CUSTOMS_VERBS, threshold):
return Action("customs")
if _fuzzy_in(head, _COMMANDS_VERBS, threshold):
return Action("commands")
if _fuzzy_in(head, _LIST_VERBS, threshold):
return Action("list")
if _fuzzy_in(head, _VERSION_VERBS, threshold):
return Action("version")
return None
def _strip_filler(tokens: list[str], filler: tuple[str, ...], threshold: float) -> list[str]:
"""drop leading optional filler words (e.g. select/use/choose) before a command.
a filler word that is followed by a digit is NOT dropped — that is the select
command (``select 1``), handled by match_command.
"""
while tokens and _fuzzy_in(tokens[0], filler, threshold):
if len(tokens) > 1 and tokens[1] in _INDEX_WORDS:
break
tokens = tokens[1:]
return tokens
def parse(transcript: str, wake_phrases: list[str], wake_threshold: float,
command_threshold: float, require_wake: bool,
filler: tuple[str, ...] = DEFAULT_FILLER) -> ParsedCommand | None:
"""full parse: wake gate -> optional one-shot target -> filler -> command.
wake_threshold gates the wake phrase (lenient — a false wake is cheap, it just
finds no command); command_threshold gates the command words (stricter — a false
command fires the wrong action). returns a ParsedCommand (one_shot, action), or
None if the wake gate dropped the utterance (listen mode, no wake phrase). a
ParsedCommand with action=None means a wake phrase was present but no command
matched.
"""
remainder, wake = strip_wake_match(transcript, wake_phrases, wake_threshold, require_wake)
if remainder is None:
return None
tokens = remainder.split(" ") if remainder else []
one_shot: str | None = None
if tokens and _fuzzy_in(tokens[0], _ONESHOT_VERBS, command_threshold) and len(tokens) >= 2:
one_shot = tokens[1]
tokens = tokens[2:]
tokens = _strip_filler(tokens, filler, command_threshold)
action = match_command(" ".join(tokens), command_threshold)
return ParsedCommand(one_shot=one_shot, action=action, wake=wake)
+127 -42
View File
@@ -1,15 +1,24 @@
"""inject keystrokes into a tmux session via ``tmux send-keys``. """output handlers: resolve a grammar.Action to keystrokes and emit them.
this is the ONLY mechanism by which claudedo affects claude code — PTY injection, the production handler (TmuxOutputHandler) injects via ``tmux send-keys`` — the ONLY
never OS-level keyboard input. it works regardless of which window is focused and mechanism by which claudedo affects claude code. PTY injection, never OS-level
never touches Windows input or a game/anticheat's view (it is text into a linux keyboard input: it works regardless of which window is focused and never touches
pseudo-terminal). do not replace this with OS keystroke injection. Windows input or a game/anticheat's view (it is text into a linux pseudo-terminal).
do not replace this with OS keystroke injection. this is also why claudedo is a
standalone daemon and not an MCP server — MCP tools can only return content to claude,
not inject into its input stream.
StdoutOutputHandler prints what WOULD be injected instead of touching tmux, so the
grammar + keymap can be exercised end-to-end without a live claude session — the
deterministic test path. both implement the same OutputHandler seam and are
interchangeable.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import subprocess import subprocess
from abc import ABC, abstractmethod
from . import keys, target from . import keys, target
@@ -17,9 +26,72 @@ log = logging.getLogger(__name__)
class InjectError(Exception): class InjectError(Exception):
"""raised when a tmux send-keys call fails.""" """raised when a tmux send-keys call fails"""
class OutputHandler(ABC):
"""abstract sink for resolved keystrokes.
concretes implement send_named (a sequence of named tmux keys) and send_literal
(literal text, no submit). perform() maps a grammar.Action onto these and is shared
by all handlers.
"""
@abstractmethod
def send_named(self, session: str, key_tokens: list[str]) -> None:
"""emit a sequence of named keys (e.g. ['1'] or ['Down', 'Enter'])"""
@abstractmethod
def send_literal(self, session: str, text: str) -> None:
"""emit literal text into the input box without submitting (``type``)"""
def send_repeat(self, session: str, token: str, count: int) -> None:
"""emit a named key `count` times (e.g. BSpace x n). default impl loops."""
if count <= 0:
return
self.send_named(session, [token] * count)
def perform(self, session: str, action) -> bool:
"""resolve a grammar.Action to keystrokes and emit them. returns acted?.
``switch``/``set``/``mode`` etc. are handled by the daemon (they change daemon
state, not the claude session), so they are ignored here. ``erase`` arrives
with action.arg already set to the count the daemon wants backspaced.
"""
name = action.name
if name == "yes":
self.send_named(session, keys.YES)
elif name == "no":
self.send_named(session, keys.NO)
elif name == "approve":
self.send_named(session, keys.APPROVE)
elif name == "deny":
self.send_named(session, keys.DENY)
elif name == "submit":
self.send_named(session, keys.SUBMIT)
elif name == "cancel":
self.send_named(session, keys.CANCEL)
elif name == "select":
seq = keys.SELECT_BY_INDEX.get(int(action.arg))
if seq is None:
log.warning("no keymap for select index %r", action.arg)
return False
self.send_named(session, seq)
elif name == "type":
self.send_literal(session, str(action.arg))
elif name == "space":
self.send_literal(session, " " * int(action.arg))
elif name in ("backspace", "erase"):
self.send_repeat(session, keys.BACKSPACE[0], int(action.arg))
else:
return False
return True
class TmuxOutputHandler(OutputHandler):
"""production handler — injects keystrokes into a tmux session via send-keys"""
@staticmethod
def _send_keys(session: str, args: list[str], literal: bool) -> None: def _send_keys(session: str, args: list[str], literal: bool) -> None:
cmd = ["tmux", "send-keys", "-t", session] cmd = ["tmux", "send-keys", "-t", session]
if literal: if literal:
@@ -30,55 +102,68 @@ def _send_keys(session: str, args: list[str], literal: bool) -> None:
err = result.stderr.decode("utf-8", "replace").strip() err = result.stderr.decode("utf-8", "replace").strip()
raise InjectError(f"tmux send-keys failed: {err}") raise InjectError(f"tmux send-keys failed: {err}")
def send_named(self, session: str, key_tokens: list[str]) -> None:
def send_named(session: str, key_tokens: list[str]) -> None:
"""send a sequence of named tmux keys (e.g. ['1'] or ['Down', 'Enter'])."""
if not target.session_exists(session): if not target.session_exists(session):
log.warning("refusing to inject — session %r does not exist", session) log.warning("refusing to inject — session %r does not exist", session)
return return
for token in key_tokens: for token in key_tokens:
_send_keys(session, [token], literal=False) self._send_keys(session, [token], literal=False)
log.info("injected keys %s -> %s", key_tokens, session) log.info("injected keys %s -> %s", key_tokens, session)
def send_literal(self, session: str, text: str) -> None:
def send_literal(session: str, text: str) -> None:
"""insert literal text into the input box without submitting (``type``)."""
if not text: if not text:
return return
if not target.session_exists(session): if not target.session_exists(session):
log.warning("refusing to inject — session %r does not exist", session) log.warning("refusing to inject — session %r does not exist", session)
return return
_send_keys(session, [text], literal=True) self._send_keys(session, [text], literal=True)
log.info("injected literal text (%d chars) -> %s", len(text), session) log.info("injected literal text (%d chars) -> %s", len(text), session)
def perform(session: str, action) -> bool: class StdoutOutputHandler(OutputHandler):
"""resolve a grammar.Action to keystrokes and inject them. returns acted?. """test handler — prints what would be injected instead of touching tmux.
``switch`` and ``mode`` are handled by the daemon (they change daemon state, not no session existence check (there is no real session); lets grammar + keymap be
the claude session), so they are ignored here. exercised end-to-end without a live claude session. records the last emission on
``self.last`` for assertions.
""" """
name = action.name
if name == "yes": def __init__(self, stream=None) -> None:
send_named(session, keys.YES) import sys
elif name == "no":
send_named(session, keys.NO) self.stream = stream if stream is not None else sys.stdout
elif name == "approve": self.last: tuple[str, object] | None = None
send_named(session, keys.APPROVE)
elif name == "deny": def send_named(self, session: str, key_tokens: list[str]) -> None:
send_named(session, keys.DENY) self.last = ("named", list(key_tokens))
elif name == "submit": print(f"[stdout] keys {key_tokens} -> {session}", file=self.stream)
send_named(session, keys.SUBMIT)
elif name == "cancel": def send_literal(self, session: str, text: str) -> None:
send_named(session, keys.CANCEL) if not text:
elif name == "select": return
seq = keys.SELECT_BY_INDEX.get(int(action.arg)) self.last = ("literal", text)
if seq is None: print(f"[stdout] literal {text!r} -> {session}", file=self.stream)
log.warning("no keymap for select index %r", action.arg)
return False
send_named(session, seq) _default_handler: OutputHandler = TmuxOutputHandler()
elif name == "type":
send_literal(session, str(action.arg))
else: def set_default_handler(handler: OutputHandler) -> None:
return False """swap the module-level handler the daemon drives (tmux in prod, stdout in tests)"""
return True global _default_handler
_default_handler = handler
def send_named(session: str, key_tokens: list[str]) -> None:
"""module-level shim delegating to the default handler"""
_default_handler.send_named(session, key_tokens)
def send_literal(session: str, text: str) -> None:
"""module-level shim delegating to the default handler"""
_default_handler.send_literal(session, text)
def perform(session: str, action) -> bool:
"""module-level shim delegating to the default handler"""
return _default_handler.perform(session, action)
+6
View File
@@ -37,6 +37,12 @@ DENY = ["3"]
SUBMIT = ["Enter"] SUBMIT = ["Enter"]
CANCEL = ["Escape"] CANCEL = ["Escape"]
# BACKSPACE deletes one char left; SPACE inserts one literal space. both are emitted
# repeatedly for `backspace <n>` / `space <n>` and for `erase` (n = the daemon's
# tracked uncommitted-input count). BSpace is tmux's name for the backspace key.
BACKSPACE = ["BSpace"]
SPACE = [" "]
SELECT_BY_INDEX = { SELECT_BY_INDEX = {
1: SELECT_1, 1: SELECT_1,
2: SELECT_2, 2: SELECT_2,
+127
View File
@@ -0,0 +1,127 @@
"""faster-whisper wrapper: load a model once, transcribe audio chunks locally.
privacy invariant: transcription runs entirely on-device. audio handed here is a
short in-memory chunk; nothing is written to disk or sent anywhere.
"""
from __future__ import annotations
import contextlib
import logging
import os
import re
import sys
import numpy as np
log = logging.getLogger(__name__)
_NOISE = re.compile(r"GPU device discovery failed|device_discovery\.cc|DiscoverDevicesForPlatform")
def _quiet_backends() -> None:
"""quiet onnxruntime/ctranslate2 chatter and the faster_whisper INFO log.
faster-whisper's VAD loads an onnx model whose device discovery prints a noisy
'GPU device discovery failed' warning on headless/WSL hosts with no GPU sysfs.
the env var + logger severity stop most onnx logging; the warning itself is
emitted at C++ init and is filtered out of stderr by _filter_stderr().
"""
os.environ.setdefault("ORT_LOGGING_LEVEL", "3")
os.environ.setdefault("OMP_NUM_THREADS", os.environ.get("OMP_NUM_THREADS", "4"))
logging.getLogger("faster_whisper").setLevel(logging.WARNING)
try:
import onnxruntime
onnxruntime.set_default_logger_severity(3)
except Exception:
pass
@contextlib.contextmanager
def _filter_stderr():
"""drop onnxruntime's GPU-discovery warning lines from stderr for this block.
a pipe temporarily replaces fd 2; a pump thread forwards every line to the real
stderr EXCEPT the known GPU-discovery noise, so real errors still surface. the
original fd is always restored on exit.
"""
import threading
try:
stderr_fd = sys.stderr.fileno()
except (AttributeError, OSError):
yield
return
saved_fd = os.dup(stderr_fd)
read_fd, write_fd = os.pipe()
os.dup2(write_fd, stderr_fd)
os.close(write_fd)
def pump():
with os.fdopen(read_fd, "rb") as reader, os.fdopen(saved_fd, "wb", closefd=False) as out:
for line in reader:
if not _NOISE.search(line.decode("utf-8", "replace")):
out.write(line)
out.flush()
thread = threading.Thread(target=pump, daemon=True)
thread.start()
try:
yield
finally:
import time
time.sleep(0.05)
os.dup2(saved_fd, stderr_fd)
os.close(saved_fd)
thread.join(timeout=1.0)
class Transcriber:
"""a loaded faster-whisper model that transcribes float32 mono audio chunks"""
def __init__(self, model: str = "small", language: str = "en", device: str = "auto",
compute_type: str = "auto", initial_prompt: str | None = None) -> None:
self.language = language
self.initial_prompt = initial_prompt
self._model = self._load(model, device, compute_type)
self._warm()
@staticmethod
def _load(model: str, device: str, compute_type: str):
if device == "auto":
device = "cpu"
if compute_type == "auto":
compute_type = "int8" if device == "cpu" else "float16"
log.info("loading faster-whisper model=%s device=%s compute=%s", model, device, compute_type)
with _filter_stderr():
_quiet_backends()
from faster_whisper import WhisperModel
return WhisperModel(model, device=device, compute_type=compute_type)
def _warm(self) -> None:
"""run one throwaway transcribe so the VAD onnx session inits now, under the
stderr filter — the GPU-discovery warning fires here once, not in the loop"""
with _filter_stderr():
list(self._model.transcribe(np.zeros(1600, dtype=np.float32), vad_filter=True)[0])
def transcribe(self, audio: np.ndarray, samplerate: int = 16000) -> str:
"""transcribe a mono float32 numpy array to a stripped text string.
the audio must be 16 kHz mono float32 in [-1, 1]; resample upstream if not.
"""
if audio.dtype != np.float32:
audio = audio.astype(np.float32)
if audio.ndim > 1:
audio = audio.reshape(-1)
segments, _info = self._model.transcribe(
audio,
language=self.language,
beam_size=1,
vad_filter=True,
condition_on_previous_text=False,
initial_prompt=self.initial_prompt,
)
text = " ".join(seg.text for seg in segments).strip()
return text
+63 -28
View File
@@ -1,4 +1,4 @@
"""resolve the active claude code tmux session from ~/.claude-active.""" """resolve the active claude code tmux session from ~/.claude-active"""
from __future__ import annotations from __future__ import annotations
@@ -18,15 +18,16 @@ def session_name(name: str) -> str:
single source of truth for the name->session mapping. the shell cc kit single source of truth for the name->session mapping. the shell cc kit
(~/.config/claudedo/cc.sh) mirrors this exactly, so ``cc libs`` and the voice (~/.config/claudedo/cc.sh) mirrors this exactly, so ``cc libs`` and the voice
commands ``switch libs`` / ``target libs`` all resolve to ``claude-libs``. an commands ``set libs`` (sticky) / ``target libs`` (one-shot) all resolve to
already-prefixed name is returned unchanged so callers can pass either form. ``claude-libs``. an already-prefixed name is returned unchanged so callers can
pass either form.
""" """
name = name.strip() name = name.strip()
return name if name.startswith(SESSION_PREFIX) else f"{SESSION_PREFIX}{name}" return name if name.startswith(SESSION_PREFIX) else f"{SESSION_PREFIX}{name}"
def read_active() -> str | None: def read_active() -> str | None:
"""return the target session name from ~/.claude-active, or None if unset.""" """return the target session name from ~/.claude-active, or None if unset"""
try: try:
name = ACTIVE_FILE.read_text(encoding="utf-8").strip() name = ACTIVE_FILE.read_text(encoding="utf-8").strip()
except FileNotFoundError: except FileNotFoundError:
@@ -38,20 +39,30 @@ def read_active() -> str | None:
def write_active(name: str) -> None: def write_active(name: str) -> None:
"""overwrite ~/.claude-active with a session name (used by ``switch``).""" """overwrite ~/.claude-active with a session name (the sticky default)"""
ACTIVE_FILE.write_text(name + "\n", encoding="utf-8") ACTIVE_FILE.write_text(name + "\n", encoding="utf-8")
def set_target(name: str) -> str: def set_target(name: str) -> str:
"""map a project short-name via session_name() and persist it. returns the """map a project short-name via session_name() and persist it as the sticky
resolved session name.""" default. returns the resolved session name."""
session = session_name(name) session = session_name(name)
write_active(session) write_active(session)
return session return session
def unset_target() -> None:
"""clear the sticky default (empty/remove ~/.claude-active)"""
try:
ACTIVE_FILE.unlink()
except FileNotFoundError:
pass
except OSError as exc:
log.warning("could not clear %s: %s", ACTIVE_FILE, exc)
def session_exists(name: str) -> bool: def session_exists(name: str) -> bool:
"""true if a tmux session with this name currently exists.""" """true if a tmux session with this name currently exists"""
if not name: if not name:
return False return False
result = subprocess.run( result = subprocess.run(
@@ -62,27 +73,51 @@ def session_exists(name: str) -> bool:
return result.returncode == 0 return result.returncode == 0
def resolve_target() -> str | None: def list_sessions() -> list[str]:
"""return the active session name only if it exists; else log and return None. """return the names of all running claude-* tmux sessions (sorted)"""
result = subprocess.run(
["tmux", "list-sessions", "-F", "#{session_name}"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
)
if result.returncode != 0:
return []
names = result.stdout.decode("utf-8", "replace").splitlines()
return sorted(n for n in names if n.startswith(SESSION_PREFIX))
never guesses a target: on a missing/empty ~/.claude-active or a stale session
name, this logs a clear warning and returns None so the caller injects nothing. def resolve(one_shot: str | None = None, auto_target: bool = False) -> tuple[str | None, str]:
"""resolve the destination session and a short reason describing the choice.
single source of truth for targeting, used by both the voice and CLI paths.
returns (session_or_None, reason). a None session means inject nothing; the
reason explains why (for the daemon console / CLI message). resolution order:
1. one-shot present -> claude-<name> for THIS command only; never falls through
to a different session if it doesn't exist (explicit beats convenience).
2. sticky set + exists -> use it.
3. nothing sticky, exactly one claude-* session:
auto_target=True -> auto-use it;
auto_target=False -> require an explicit set/target, do nothing.
4. nothing sticky, multiple sessions -> ambiguous, do nothing.
5. nothing sticky, zero sessions -> do nothing.
""" """
name = read_active() if one_shot is not None:
if not name: session = session_name(one_shot)
log.warning("no active session set (%s missing/empty) — run `cc` to attach", ACTIVE_FILE) if session_exists(session):
return None return session, f"one-shot {session}"
if not session_exists(name): return None, f"one-shot {session} does not exist (did nothing)"
log.warning("target session %r no longer exists — skipping injection", name)
return None
return name
sticky = read_active()
if sticky:
if session_exists(sticky):
return sticky, f"sticky {sticky}"
return None, f"sticky {sticky} no longer exists (set one)"
# TODO: most-recently-active targeting (preferred over attached). today the target sessions = list_sessions()
# is "the project most recently ATTACHED to" (the cc kit writes ~/.claude-active on if len(sessions) == 1:
# attach). upgrade to "the session claude most recently asked a question / produced if auto_target:
# output in" via tmux session_activity timestamps: return sessions[0], f"auto-target {sessions[0]} (only session)"
# tmux list-sessions -F '#{session_name} #{session_activity}' return None, f"no target set ({sessions[0]} running — set one)"
# pick the highest-activity claude-* session; or scrape panes if len(sessions) > 1:
# (tmux capture-pane -p -t <s>) for a waiting-prompt UI and target the session whose return None, f"no target set, {len(sessions)} sessions (set one)"
# pane currently shows one. return None, "no claude sessions"