2 Commits
Author SHA1 Message Date
dsql 509d3ad3b3 v0.2.2: detached-session cleanup (shell ccclean + voice/CLI cleanup)
add a detached-only session cleanup in BOTH surfaces — the cc shell kit and the
claudedo daemon — so stale detached claude-* sessions can be cleared from either.

- cc.sh: ccclean kills DETACHED claude-* sessions only (tmux #{session_attached}==0),
  never attached; reports 'killed X, Y (2 detached); kept Z (attached)' or 'nothing to
  clean'. complements cckl (kill ALL incl attached), which stays the deliberate typed
  nuke. header updated; sources clean under bash + zsh.
- target.py: cleanup_detached() kills detached claude-* and returns (killed, kept)
  lists. it and list_sessions() now share ONE tmux query, _claude_sessions(), which
  returns (name, attached) pairs — single source for session enumeration.
- grammar: cleanup command (aliases detached/detach) routes to Action('system',
  'cleanup') — daemon-control, never injects. bare 'cleanup' and 'system cleanup' both
  accepted. 'clean'/'wipe' deliberately NOT used as aliases — they fuzzy-collide with
  erase's 'clear'/'wipe' (0.8 ratio); 'detached' is distinct. confirm command added for
  the opt-in confirm flow.
- daemon: system 'cleanup' -> _do_cleanup -> target.cleanup_detached, reports
  '[SYSTEM] cleanup: killed ...; kept ... (attached)'. behavior.cleanup_confirm
  (default false) announces and waits for a following 'confirm' before killing.
- CLI: 'claudedo cleanup' (self-contained tmux op, no running daemon needed).

safety model: detached-only means a misheard voice cleanup can NEVER kill the active
(attached) session. the only kill-attached path remains the shell cckl.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-27 20:01:17 -04:00
dsql 1a593b95fa v0.2.1: earcons — audio feedback tones (eyes-free confirmation)
short confirmation tones on daemon events so the user gets eyes-free "did it hear me?"
feedback without watching the terminal. NOT TTS — short pre-generated .wav beeps.

- audio_out.py — reusable audio-OUT module (the reverse of audio.py's capture, the
  less-tested WSLg direction). three-tier player: paplay-first (a SEPARATE process, so
  it doesn't contend with the sounddevice mic stream on the duplex-flaky WSLg bridge),
  then in-process sounddevice, then powershell.exe SoundPlayer. best-effort per-backend
  volume. plays a wav path and knows nothing about events — v0.3 TTS reuses it.
- sound.py — Earcons: the single event->tone map (wake/accept/no_match/submit) gated by
  [sound] config (master enabled + per-event flags). daemon._handle wiring: an injected
  command plays accept (submit plays submit); no-match / target-missing / unknown-context
  plays no_match; pure daemon-control commands (list/version/…) play nothing.
- sounds/ — committed earcon wavs + generate.py (regen-only). committed (not generated
  at install) so the package is self-contained and a missing tone can never appear.
  packaged via pyproject [tool.setuptools.package-data].
- [sound] config: enabled (master, on), on_wake (OFF by default — bleed/chatty),
  on_accept/on_no_match/on_submit (on), volume (0-1 best-effort), [sound.files] overrides.
- claudedo test-tone — plays each tone, the audio-OUT gate (mirrors test-audio).
- install.sh now also checks RDPSink (audio-out) alongside RDPSource.

INVARIANT: earcons are fire-and-forget on a worker thread and NEVER block or break the
inject path. a missing tone file or dead speaker logs once and is swallowed, never
raised — a broken speaker must never stop "claudedo yes" from injecting.

de-risks the WSLg audio-OUT path that v0.3 TTS-readback will reuse.

Signed-off-by: disqualifier <dev@disqualifier.me>
2026-06-27 18:32:34 -04:00
19 changed files with 602 additions and 10 deletions
+35 -1
View File
@@ -83,7 +83,9 @@ claudedo reload # reload config.toml + contexts.toml in a running daem
claudedo set <name> # set the sticky target -> claude-<name> (alias: switch) claudedo set <name> # set the sticky target -> claude-<name> (alias: switch)
claudedo unset # clear the sticky target claudedo unset # clear the sticky target
claudedo list # list running claude-* sessions claudedo list # list running claude-* sessions
claudedo cleanup # kill DETACHED claude-* sessions (never attached)
claudedo test-audio # verify the mic capture path claudedo test-audio # verify the mic capture path
claudedo test-tone # play each earcon (verify the audio-OUT path)
``` ```
### Modes ### Modes
@@ -132,6 +134,7 @@ said "okay clouds"), the heard line notes which phrase it assumed —
| `reload` | re-read `config.toml` + `contexts.toml` live (no daemon restart, model stays loaded) | | `reload` | re-read `config.toml` + `contexts.toml` live (no daemon restart, model stays loaded) |
| `system status` | print mode / target / model / context count to the console (daemon-control; never injects) | | `system status` | print mode / target / model / context count to the console (daemon-control; never injects) |
| `system reload [config\|contexts]` | reload one or both config files | | `system reload [config\|contexts]` | reload one or both config files |
| `cleanup` (alias `detached`/`detach`, also `system cleanup`) | kill **detached** `claude-*` sessions only — never an attached one |
| `commands` (alias `help`/`menu`) | print the voice-command menu to the console | | `commands` (alias `help`/`menu`) | print the voice-command menu to the console |
| `customs` (alias `custom`) | list the loaded context names | | `customs` (alias `custom`) | list the loaded context names |
| `version` | print the claudedo version to the console | | `version` | print the claudedo version to the console |
@@ -174,7 +177,8 @@ cc <name> # attach/create claude-<name>; writes ~/.claude-active
ccr <name> # re-attach an existing claude-<name> only ccr <name> # re-attach an existing claude-<name> only
ccl # list claude-* sessions ccl # list claude-* sessions
cck <name> # kill claude-<name> cck <name> # kill claude-<name>
cckl # kill all claude-* sessions ccclean # kill DETACHED claude-* sessions only (never attached) — safe cleanup
cckl # kill ALL claude-* sessions (including attached)
``` ```
## Contexts (named reference blurbs) ## Contexts (named reference blurbs)
@@ -211,6 +215,36 @@ Whisper model. The **`system`** namespace gives daemon-control by voice without
Claude: `system status` (mode / target / model / context count) and `system reload Claude: `system status` (mode / target / model / context count) and `system reload
[config|contexts]`. [config|contexts]`.
## Earcons (audio feedback tones)
Short confirmation tones play on key events so you get **eyes-free feedback** — "did it
hear me?" — without watching the terminal. They're tones, not speech (not TTS): a bright
blip when a command is accepted/injected, a low buzz when nothing matched, a rising chime
on submit, and an optional blip on wake. Tones are short (<300ms) and quiet, and they're
**additive** to the console feed — mute them and read at the desk, or hear them eyes-free.
Verify the audio-OUT path (the reverse of `test-audio`, and the less-tested direction on
WSLg) with:
```bash
claudedo test-tone # plays each tone through WSLg — the audio-out gate
```
Tones play through WSLg's PulseAudio sink, **paplay-first** (a separate process, so it
doesn't contend with the sounddevice mic stream), falling back to in-process sounddevice,
then `powershell.exe` on the Windows host. Playback is **fire-and-forget**: a dead speaker
or a missing tone file logs once and is ignored — audio-out can never block or break a
command (`claudedo yes` injects whether or not the speaker works).
Configure under `[sound]`: `enabled` (master, default on), per-event `on_wake` (default
**off** — a blip right before you speak can bleed into the command capture, and it's
chatty), `on_accept` / `on_no_match` / `on_submit` (default on), and `volume` (0.01.0,
best-effort — scaled for sounddevice, `--volume` for paplay, ignored by the PowerShell
fallback). A `[sound.files]` table can point any event at your own `.wav`. The shipped
tones live in the package (`claudedo/sounds/*.wav`); `claudedo/sounds/generate.py` is a
synthetic-beep fallback that can regenerate a placeholder set (it does **not** reproduce
the shipped tones — running it overwrites them with plain beeps).
## The confirmed Claude Code keymap ## The confirmed Claude Code keymap
The keystrokes in [`keys.py`](src/claudedo/keys.py) were confirmed **empirically** The keystrokes in [`keys.py`](src/claudedo/keys.py) were confirmed **empirically**
+29
View File
@@ -88,3 +88,32 @@ print_heard = false
context_multiline = true context_multiline = true
# separator inserted between blurb and instruction when context_multiline = false. # separator inserted between blurb and instruction when context_multiline = false.
context_separator = " — " context_separator = " — "
# the `cleanup` / `detached` command kills DETACHED claude-* sessions only (never an
# attached one — a misheard cleanup can't nuke the active session). default false:
# kill immediately (it's detached-only, so it's safe). set true to announce the
# detached set and wait for a following `confirm` before killing.
cleanup_confirm = false
[sound]
# earcons — short confirmation tones on daemon events so you get eyes-free feedback
# ("did it hear me?") without watching the terminal. tones are SHORT (<300ms) and quiet;
# they play OUT through WSLg's PulseAudio sink (paplay-first, sounddevice fallback, then
# powershell.exe). additive to the console feed — mute these and read at the desk, or
# hear them eyes-free. a dead speaker never blocks/breaks a command (fire-and-forget).
enabled = true
# blip when a wake phrase is recognized. OFF by default: a blip right before you speak
# the command can bleed into its capture, and it's chatty. turn on only if you want it.
on_wake = false
# positive blip when a command is recognized/injected.
on_accept = true
# distinct lower buzz when nothing matched or the target was missing (did nothing).
on_no_match = true
# rising chime when a send/submit is injected.
on_submit = true
# best-effort 0.0-1.0 (scaled for sounddevice, --volume for paplay; ignored by the
# powershell fallback, which has no volume control).
volume = 0.5
# optional per-event overrides to swap in your own .wav files, e.g.:
# [sound.files]
# accept = "~/sounds/my_accept.wav"
[sound.files]
+5
View File
@@ -57,9 +57,14 @@ say "verifying audio path"
if pactl info >/dev/null 2>&1; then if pactl info >/dev/null 2>&1; then
DEFAULT_SRC="$(pactl info | sed -n 's/^Default Source: //p')" DEFAULT_SRC="$(pactl info | sed -n 's/^Default Source: //p')"
echo " Default Source: ${DEFAULT_SRC:-<none>}" echo " Default Source: ${DEFAULT_SRC:-<none>}"
DEFAULT_SINK="$(pactl info | sed -n 's/^Default Sink: //p')"
echo " Default Sink: ${DEFAULT_SINK:-<none>}"
if ! pactl list sources short 2>/dev/null | grep -q RDPSource; then 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." warn "RDPSource not listed by pactl — mic may not be bridged. check Windows mic permission."
fi fi
if ! pactl list sinks short 2>/dev/null | grep -q RDPSink; then
warn "RDPSink not listed by pactl — earcon/TTS audio-OUT may not play. run 'claudedo test-tone' to check."
fi
else else
warn "pactl info failed — pulseaudio-utils installed but no server reachable yet." warn "pactl info failed — pulseaudio-utils installed but no server reachable yet."
fi fi
+4 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "claudedo" name = "claudedo"
version = "0.2.0" version = "0.2.2"
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"
@@ -23,6 +23,9 @@ claudedo = "claudedo.__main__:main"
[tool.setuptools] [tool.setuptools]
package-dir = { "" = "src" } package-dir = { "" = "src" }
[tool.setuptools.package-data]
"claudedo.sounds" = ["*.wav"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["src"] where = ["src"]
+30 -1
View File
@@ -11,7 +11,8 @@
# ccr <name> reattach only (error if it doesn't exist); writes ~/.claude-active # ccr <name> reattach only (error if it doesn't exist); writes ~/.claude-active
# ccl list running claude- sessions # ccl list running claude- sessions
# cck <name> kill claude-<name> # cck <name> kill claude-<name>
# cckl kill ALL claude- sessions # ccclean kill DETACHED claude- sessions only (never attached) — safe cleanup
# cckl kill ALL claude- sessions (including attached)
cc() { cc() {
if [ -z "$1" ]; then if [ -z "$1" ]; then
@@ -60,6 +61,34 @@ cck() {
fi fi
} }
ccclean() {
killed=""
kept=""
while read -r name attached; do
case "$name" in
claude-*) ;;
*) continue ;;
esac
if [ "$attached" = "0" ]; then
if tmux kill-session -t "$name" 2>/dev/null; then
killed="${killed:+$killed, }$name"
fi
else
kept="${kept:+$kept, }$name"
fi
done <<EOF
$(tmux list-sessions -F '#{session_name} #{session_attached}' 2>/dev/null)
EOF
if [ -z "$killed" ]; then
echo "nothing to clean (no detached sessions)"
else
n=$(printf '%s' "$killed" | awk -F', ' '{print NF}')
msg="killed $killed ($n detached)"
[ -n "$kept" ] && msg="$msg; kept $kept (attached)"
echo "$msg"
fi
}
cckl() { cckl() {
tmux ls 2>/dev/null | grep '^claude-' | cut -d: -f1 | while read -r s; do tmux ls 2>/dev/null | grep '^claude-' | cut -d: -f1 | while read -r s; do
tmux kill-session -t "$s" && echo "killed $s" tmux kill-session -t "$s" && echo "killed $s"
+1 -1
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.2.0" __version__ = "0.2.2"
+46
View File
@@ -97,6 +97,48 @@ def cmd_stop(_args: argparse.Namespace) -> int:
return 1 return 1
def cmd_test_tone(args: argparse.Namespace) -> int:
config = _load_or_die(args.config)
from . import audio_out, sound
print("== claudedo test-tone ==")
if not audio_out.available():
print("no audio-out backend found (paplay / powershell.exe).", file=sys.stderr)
print("install pulseaudio-utils (run install.sh) for paplay.", file=sys.stderr)
return 1
earcons = sound.Earcons(config)
print(f"playing each tone via WSLg audio-out (volume {config.sound_volume}) — listen ...")
ok = True
for event in sound.event_names():
path = earcons.tone_path(event)
if path is None or not Path(path).is_file():
print(f" {event:9} MISSING ({path})")
ok = False
continue
print(f" {event:9} {path.name} ...", flush=True)
played = audio_out.play_blocking(path, volume=config.sound_volume)
if not played:
print(f" {event:9} FAILED to play", file=sys.stderr)
ok = False
if not ok:
print("some tones did not play — audio-out may be unavailable.", file=sys.stderr)
return 1
print("audio-out OK (all tones played).")
return 0
def cmd_cleanup(_args: argparse.Namespace) -> int:
killed, kept = target.cleanup_detached()
if not killed:
print("nothing to clean (no detached sessions)")
return 0
msg = f"killed {', '.join(killed)}"
if kept:
msg += f"; kept {', '.join(kept)} (attached)"
print(msg)
return 0
def cmd_reload(_args: argparse.Namespace) -> int: def cmd_reload(_args: argparse.Namespace) -> int:
if daemon.reload_running(): if daemon.reload_running():
print("signalled claudedo to reload config + contexts") print("signalled claudedo to reload config + contexts")
@@ -234,9 +276,13 @@ def build_parser() -> argparse.ArgumentParser:
).set_defaults(func=cmd_reload) ).set_defaults(func=cmd_reload)
sub.add_parser("status", help="show daemon status").set_defaults(func=cmd_status) 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("test-audio", help="verify the mic capture path").set_defaults(func=cmd_test_audio)
sub.add_parser("test-tone", help="play each earcon (verify the audio-out path)"
).set_defaults(func=cmd_test_tone)
sub.add_parser("install", help="re-run the bootstrap (install.sh)").set_defaults(func=cmd_install) 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("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) sub.add_parser("list", help="list running claude-* sessions").set_defaults(func=cmd_list)
sub.add_parser("cleanup", help="kill detached claude-* sessions (never attached)"
).set_defaults(func=cmd_cleanup)
for verb in ("set", "switch"): for verb in ("set", "switch"):
sp_set = sub.add_parser(verb, help="set the sticky target session") sp_set = sub.add_parser(verb, help="set the sticky target session")
+158
View File
@@ -0,0 +1,158 @@
"""audio output — play short .wav files through the WSLg/PulseAudio sink (RDPSink).
the reverse direction of audio.py's mic capture, and the less-tested path on WSLg. a
three-tier player picks the first backend that works and remembers it:
1. paplay (pulseaudio-utils) — a SEPARATE process hitting PulseAudio directly. this
is the primary on purpose: the daemon captures with sounddevice (an open input
stream in listen mode), so keeping OUTPUT in a separate process avoids stacking
input+output in one lib on a bridge known to be duplex-flaky.
2. sounddevice sd.play() — in-process fallback if paplay is absent.
3. powershell.exe SoundPlayer — last resort via the Windows host (no volume control).
both earcons (sound.py) and future v0.3 TTS readback play through this module — keep it
generic (it plays a wav path, it knows nothing about events). playback is fire-and-
forget on a worker thread: a missing file or a dead speaker logs once and is swallowed,
never raised, so audio-out can NEVER block or break the inject path.
"""
from __future__ import annotations
import logging
import shutil
import subprocess
import threading
import wave
from pathlib import Path
log = logging.getLogger(__name__)
_PAPLAY = "paplay"
_POWERSHELL = "powershell.exe"
_backend_lock = threading.Lock()
_chosen_backend: str | None = None
_warned = False
def _have(cmd: str) -> bool:
return shutil.which(cmd) is not None
def _clamp_volume(volume: float) -> float:
return max(0.0, min(1.0, float(volume)))
def _play_paplay(path: Path, volume: float) -> bool:
"""play via paplay; volume scaled through --volume (0-65536 linear)"""
vol = int(_clamp_volume(volume) * 65536)
proc = subprocess.run(
[_PAPLAY, f"--volume={vol}", str(path)],
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
)
if proc.returncode != 0:
log.debug("paplay failed: %s", proc.stderr.decode("utf-8", "replace").strip())
return False
return True
def _play_sounddevice(path: Path, volume: float) -> bool:
"""play via sounddevice (in-process fallback); volume scales the samples"""
try:
import numpy as np
import sounddevice as sd
except Exception as exc:
log.debug("sounddevice unavailable: %s", exc)
return False
try:
with wave.open(str(path), "rb") as wf:
sr = wf.getframerate()
frames = wf.readframes(wf.getnframes())
data = np.frombuffer(frames, dtype="<i2").astype(np.float32) / 32768.0
data = data * _clamp_volume(volume)
sd.play(data, sr)
sd.wait()
return True
except Exception as exc:
log.debug("sounddevice playback failed: %s", exc)
return False
def _play_powershell(path: Path, _volume: float) -> bool:
"""play via the Windows host (last resort). SoundPlayer has no volume control,
so volume is ignored on this backend (documented best-effort)."""
if not _have(_POWERSHELL):
return False
try:
win = subprocess.run(["wslpath", "-w", str(path)], stdout=subprocess.PIPE,
stderr=subprocess.DEVNULL)
winpath = win.stdout.decode("utf-8", "replace").strip() if win.returncode == 0 else str(path)
script = f"(New-Object Media.SoundPlayer '{winpath}').PlaySync()"
proc = subprocess.run([_POWERSHELL, "-NoProfile", "-Command", script],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return proc.returncode == 0
except Exception as exc:
log.debug("powershell playback failed: %s", exc)
return False
_BACKENDS = {
"paplay": _play_paplay,
"sounddevice": _play_sounddevice,
"powershell": _play_powershell,
}
_ORDER = ("paplay", "sounddevice", "powershell")
def _play_sync(path: Path, volume: float) -> bool:
"""play a wav synchronously, choosing/remembering a working backend. returns
whether playback succeeded; never raises."""
global _chosen_backend, _warned
if not path.is_file():
log.debug("tone file missing: %s", path)
return False
with _backend_lock:
order = (_chosen_backend,) + _ORDER if _chosen_backend else _ORDER
tried = []
for name in order:
if name in tried:
continue
tried.append(name)
if name == "paplay" and not _have(_PAPLAY):
continue
if _BACKENDS[name](path, volume):
with _backend_lock:
_chosen_backend = name
return True
with _backend_lock:
if not _warned:
_warned = True
log.warning("audio-out unavailable (tried %s) — continuing silently; "
"tones disabled for this run", ", ".join(tried))
return False
def play(path: str | Path, volume: float = 1.0, blocking: bool = False) -> None:
"""play a wav file. fire-and-forget by default (a worker thread), so a slow or
dead speaker never delays the caller. set blocking=True only for test-tone, where
we want to play tones in sequence and report the result.
failures are swallowed (logged once) — audio-out must never break a command.
"""
p = Path(path)
if blocking:
_play_sync(p, volume)
return
threading.Thread(target=_play_sync, args=(p, volume), daemon=True).start()
def play_blocking(path: str | Path, volume: float = 1.0) -> bool:
"""synchronous play that returns success — for test-tone's audio-out gate"""
return _play_sync(Path(path), volume)
def available() -> bool:
"""true if any audio-out backend is present (best-effort, paplay/powershell)"""
return _have(_PAPLAY) or _have(_POWERSHELL)
+18
View File
@@ -58,6 +58,14 @@ class Config:
print_heard: bool print_heard: bool
context_multiline: bool context_multiline: bool
context_separator: str context_separator: str
cleanup_confirm: bool
sound_enabled: bool
sound_on_wake: bool
sound_on_accept: bool
sound_on_no_match: bool
sound_on_submit: bool
sound_volume: float
sound_files: dict[str, str]
source_path: Path | None = field(default=None) source_path: Path | None = field(default=None)
@@ -132,12 +140,22 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config:
print_heard=bool(_require(raw, "behavior", "print_heard", (bool,), False)), print_heard=bool(_require(raw, "behavior", "print_heard", (bool,), False)),
context_multiline=bool(_require(raw, "behavior", "context_multiline", (bool,), True)), context_multiline=bool(_require(raw, "behavior", "context_multiline", (bool,), True)),
context_separator=str(_require(raw, "behavior", "context_separator", (str,), "")), context_separator=str(_require(raw, "behavior", "context_separator", (str,), "")),
cleanup_confirm=bool(_require(raw, "behavior", "cleanup_confirm", (bool,), False)),
sound_enabled=bool(_require(raw, "sound", "enabled", (bool,), True)),
sound_on_wake=bool(_require(raw, "sound", "on_wake", (bool,), False)),
sound_on_accept=bool(_require(raw, "sound", "on_accept", (bool,), True)),
sound_on_no_match=bool(_require(raw, "sound", "on_no_match", (bool,), True)),
sound_on_submit=bool(_require(raw, "sound", "on_submit", (bool,), True)),
sound_volume=float(_require(raw, "sound", "volume", (int, float), 0.5)),
sound_files=dict(_require(raw, "sound", "files", (dict,), {})),
source_path=path, source_path=path,
) )
for label, val in (("wake_fuzzy_threshold", cfg.wake_fuzzy_threshold), for label, val in (("wake_fuzzy_threshold", cfg.wake_fuzzy_threshold),
("command_fuzzy_threshold", cfg.command_fuzzy_threshold)): ("command_fuzzy_threshold", cfg.command_fuzzy_threshold)):
if not 0.0 < val <= 1.0: if not 0.0 < val <= 1.0:
raise ConfigError(f"[behavior].{label} must be in (0, 1]") raise ConfigError(f"[behavior].{label} must be in (0, 1]")
if not 0.0 <= cfg.sound_volume <= 1.0:
raise ConfigError("[sound].volume must be in [0, 1]")
if cfg.vad_silence_ms <= 0 or cfg.vad_max_seconds <= 0: if cfg.vad_silence_ms <= 0 or cfg.vad_max_seconds <= 0:
raise ConfigError("[vad].silence_ms and max_seconds must be positive") 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:
+55 -1
View File
@@ -20,6 +20,7 @@ from . import __version__, audio, grammar, inject, keys, target
from .config import Config, ConfigError, load_config from .config import Config, ConfigError, load_config
from .console import HELP, SYSTEM, VOICE, Console from .console import HELP, SYSTEM, VOICE, Console
from .contexts import Contexts, ContextsError, load_contexts from .contexts import Contexts, ContextsError, load_contexts
from .sound import Earcons
from .stt import Transcriber from .stt import Transcriber
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -124,12 +125,14 @@ class Daemon:
self.mode = config.mode self.mode = config.mode
self._stop = False self._stop = False
self._reload_pending = False self._reload_pending = False
self._cleanup_pending = False
self._transcriber: Transcriber | None = None self._transcriber: Transcriber | None = None
self._device: int | None = None self._device: int | None = None
self._ptt = _PTTKey() self._ptt = _PTTKey()
self._pending: dict[str, int] = {} self._pending: dict[str, int] = {}
self._console = Console() self._console = Console()
self._contexts = Contexts() self._contexts = Contexts()
self._earcons = Earcons(config)
self._last_stt_ms = 0.0 self._last_stt_ms = 0.0
self._last_audio_s = 0.0 self._last_audio_s = 0.0
@@ -202,10 +205,14 @@ class Daemon:
parsed = grammar.parse(transcript, cfg.wake_phrases, cfg.wake_fuzzy_threshold, parsed = grammar.parse(transcript, cfg.wake_phrases, cfg.wake_fuzzy_threshold,
cfg.command_fuzzy_threshold, require_wake, filler=cfg.filler_words) cfg.command_fuzzy_threshold, require_wake, filler=cfg.filler_words)
if parsed is None or parsed.action is None: if parsed is None or parsed.action is None:
if parsed is not None:
self._earcons.play("wake")
self._console.emit(VOICE, f'heard "{transcript}" -> no command matched {self._timing()}', self._console.emit(VOICE, f'heard "{transcript}" -> no command matched {self._timing()}',
"yellow") "yellow")
self._earcons.play("no_match")
return return
action = parsed.action action = parsed.action
self._earcons.play("wake")
# a command was recognized — echo what we heard (green) before acting. note the # 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 # matched wake phrase (magenta) when the transcript didn't literally contain it
@@ -270,12 +277,14 @@ class Daemon:
name = str(action.arg[0]) name = str(action.arg[0])
if self._contexts.get(name) is None: if self._contexts.get(name) is None:
self._console.emit(VOICE, f"no context named '{name}' -> did nothing", "red") self._console.emit(VOICE, f"no context named '{name}' -> did nothing", "red")
self._earcons.play("no_match")
return return
session, reason = target.resolve(parsed.one_shot, auto_target=cfg.auto_target) session, reason = target.resolve(parsed.one_shot, auto_target=cfg.auto_target)
if session is None: if session is None:
self._console.emit(VOICE, f'heard "{transcript}" -> {reason} -> ' self._console.emit(VOICE, f'heard "{transcript}" -> {reason} -> '
f'{self._describe(action)} did nothing', "red") f'{self._describe(action)} did nothing', "red")
self._earcons.play("no_match")
return return
if action.name == "context": if action.name == "context":
self._inject_context(session, action) self._inject_context(session, action)
@@ -287,9 +296,12 @@ class Daemon:
buffer so backspace/erase delete only back to the last submit boundary. buffer so backspace/erase delete only back to the last submit boundary.
the 'heard ...' echo is already printed by _handle and the [session] prefix the 'heard ...' echo is already printed by _handle and the [session] prefix
names the target, so these lines just report the keystrokes injected. names the target, so these lines just report the keystrokes injected. the
earcon fires here (a real injection): submit chimes the submit tone, every
other injected command the accept tone.
""" """
name = action.name name = action.name
self._earcons.play("submit" if name == "submit" else "accept")
if name == "type": if name == "type":
text = str(action.arg) text = str(action.arg)
@@ -344,6 +356,7 @@ class Daemon:
name, dictation = str(action.arg[0]), str(action.arg[1]) name, dictation = str(action.arg[0]), str(action.arg[1])
blurb = self._contexts.get(name) or "" blurb = self._contexts.get(name) or ""
self._earcons.play("accept")
inject.send_literal(session, blurb) inject.send_literal(session, blurb)
chars = len(blurb) chars = len(blurb)
if dictation: if dictation:
@@ -382,6 +395,7 @@ class Daemon:
toggled by voice and leaving the already-loaded transcriber untouched.""" toggled by voice and leaving the already-loaded transcriber untouched."""
new_cfg.mode = self.mode new_cfg.mode = self.mode
self.config = new_cfg self.config = new_cfg
self._earcons.update(new_cfg)
def _do_system(self, arg) -> None: def _do_system(self, arg) -> None:
"""daemon-control namespace (never injects to claude): status / reload.""" """daemon-control namespace (never injects to claude): status / reload."""
@@ -398,8 +412,48 @@ class Daemon:
self._console.emit(SYSTEM, f"{blue}: mode {self.mode}, sticky {sticky}, " self._console.emit(SYSTEM, f"{blue}: mode {self.mode}, sticky {sticky}, "
f"model {cfg.stt_model}, {len(self._contexts)} contexts") f"model {cfg.stt_model}, {len(self._contexts)} contexts")
return return
if arg == "cleanup":
self._do_cleanup()
return
if arg == "confirm":
blue = self._console.paint("cleanup", "brightblue")
if self._cleanup_pending:
self._run_cleanup(blue)
else:
self._console.emit(SYSTEM, f"{blue}: nothing pending to confirm")
return
self._console.emit(SYSTEM, f"unknown system command {arg!r}", "red") self._console.emit(SYSTEM, f"unknown system command {arg!r}", "red")
def _do_cleanup(self) -> None:
"""kill detached claude-* sessions (never attached), report killed + kept.
detached-only is the safety model: a misheard voice cleanup cannot nuke the
active (attached) session. with behavior.cleanup_confirm the daemon announces
the detached set and waits for a following ``confirm`` instead of killing now.
"""
blue = self._console.paint("cleanup", "brightblue")
if self.config.cleanup_confirm:
pending = [n for n, attached in target._claude_sessions() if not attached]
if not pending:
self._console.emit(SYSTEM, f"{blue}: nothing to clean (no detached sessions)")
return
self._cleanup_pending = True
self._console.emit(SYSTEM, f"{blue}: would kill {', '.join(sorted(pending))} "
f"— say 'confirm' to proceed")
return
self._run_cleanup(blue)
def _run_cleanup(self, blue: str) -> None:
killed, kept = target.cleanup_detached()
self._cleanup_pending = False
if not killed:
self._console.emit(SYSTEM, f"{blue}: nothing to clean (no detached sessions)")
return
msg = f"{blue}: killed {', '.join(killed)}"
if kept:
msg += f"; kept {', '.join(kept)} (attached)"
self._console.emit(SYSTEM, msg)
def _timing(self) -> str: def _timing(self) -> str:
"""compact STT latency suffix for heard lines (transcribe ms on audio secs)""" """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)" return f"({self._last_stt_ms:.0f}ms/{self._last_audio_s:.1f}s)"
+11 -2
View File
@@ -58,6 +58,8 @@ _CONTEXT_VERBS = ("context", "prepare")
_RELOAD_VERBS = ("reload",) _RELOAD_VERBS = ("reload",)
_SYSTEM_VERBS = ("system",) _SYSTEM_VERBS = ("system",)
_RELOAD_SCOPES = ("config", "contexts") _RELOAD_SCOPES = ("config", "contexts")
_CLEANUP_VERBS = ("detached", "detach", "cleanup")
_CONFIRM_VERBS = ("confirm",)
# every command/synonym word, for biasing the STT toward the vocabulary we expect. # every command/synonym word, for biasing the STT toward the vocabulary we expect.
_COMMAND_WORDS = ( _COMMAND_WORDS = (
@@ -65,8 +67,8 @@ _COMMAND_WORDS = (
+ _CANCEL_VERBS + _TYPE_VERBS + _BACKSPACE_VERBS + _SPACE_VERBS + _ADD_VERBS + _CANCEL_VERBS + _TYPE_VERBS + _BACKSPACE_VERBS + _SPACE_VERBS + _ADD_VERBS
+ _ERASE_VERBS + _DEBUG_VERBS + _MODE_VERBS + _STICKY_VERBS + _ONESHOT_VERBS + _UNSET_VERBS + _ERASE_VERBS + _DEBUG_VERBS + _MODE_VERBS + _STICKY_VERBS + _ONESHOT_VERBS + _UNSET_VERBS
+ _LIST_VERBS + _COMMANDS_VERBS + _CUSTOMS_VERBS + _VERSION_VERBS + _LIST_VERBS + _COMMANDS_VERBS + _CUSTOMS_VERBS + _VERSION_VERBS
+ _CONTEXT_VERBS + _RELOAD_VERBS + _SYSTEM_VERBS + _RELOAD_SCOPES + _CONTEXT_VERBS + _RELOAD_VERBS + _SYSTEM_VERBS + _RELOAD_SCOPES + _CLEANUP_VERBS
+ _SELECT_VERBS + ("ptt", "listen") + _CONFIRM_VERBS + _SELECT_VERBS + ("ptt", "listen")
+ ("one", "two", "three", "four") + ("one", "two", "three", "four")
) )
DEFAULT_FILLER = ("select", "use", "choose") DEFAULT_FILLER = ("select", "use", "choose")
@@ -161,6 +163,7 @@ def command_menu() -> list[tuple[str, str]]:
("reload", "re-read config.toml + contexts.toml live"), ("reload", "re-read config.toml + contexts.toml live"),
("system status", "print mode/target/model/contexts to the console"), ("system status", "print mode/target/model/contexts to the console"),
("system reload [config|contexts]", "reload one or both config files"), ("system reload [config|contexts]", "reload one or both config files"),
("cleanup / detached", "kill detached claude-* sessions (never attached)"),
("commands / customs", "this menu / list loaded contexts"), ("commands / customs", "this menu / list loaded contexts"),
("version", "print the claudedo version"), ("version", "print the claudedo version"),
] ]
@@ -274,6 +277,8 @@ def _match_system(rest: list[str], threshold: float) -> Action | None:
return Action("system", ("reload", inner.arg)) return Action("system", ("reload", inner.arg))
if _fuzzy_in(head, ("status", "state"), threshold): if _fuzzy_in(head, ("status", "state"), threshold):
return Action("system", "status") return Action("system", "status")
if _fuzzy_in(head, _CLEANUP_VERBS, threshold):
return Action("system", "cleanup")
return Action("system", ("unknown", head)) return Action("system", ("unknown", head))
@@ -293,6 +298,10 @@ def match_command(remainder: str, threshold: float) -> Action | None:
if _fuzzy_in(head, _SYSTEM_VERBS, threshold): if _fuzzy_in(head, _SYSTEM_VERBS, threshold):
return _match_system(rest, threshold) return _match_system(rest, threshold)
if _fuzzy_in(head, _CLEANUP_VERBS, threshold):
return Action("system", "cleanup")
if _fuzzy_in(head, _CONFIRM_VERBS, threshold):
return Action("system", "confirm")
if _fuzzy_in(head, _RELOAD_VERBS, threshold): if _fuzzy_in(head, _RELOAD_VERBS, threshold):
return _match_reload(rest, threshold, bare_default="all") return _match_reload(rest, threshold, bare_default="all")
if _fuzzy_in(head, _CONTEXT_VERBS, threshold) and rest: if _fuzzy_in(head, _CONTEXT_VERBS, threshold) and rest:
+91
View File
@@ -0,0 +1,91 @@
"""earcons — short confirmation tones on daemon events, the eyes-free feedback layer.
the single place that maps an event name to its tone file and the per-event enable
flag. additive to the console feed (it does not replace the printed lines): at the desk
mute tones and read; eyes-free, hear them. playback goes through audio_out (paplay-first,
fire-and-forget) so a dead speaker never blocks or breaks a command.
events:
wake — a wake phrase was recognized (off by default — a blip right before you
speak the command can bleed into its capture; keep it off unless wanted)
accept — a command was recognized/injected
no_match — nothing matched, or the target was missing (did nothing)
submit — a send/submit was injected
tone files live in the packaged sounds/ dir; a per-event config override may point at a
user file instead. a missing file is swallowed by audio_out (logged once), never raised.
"""
from __future__ import annotations
import logging
from pathlib import Path
from . import audio_out
from .config import Config
log = logging.getLogger(__name__)
_SOUNDS_DIR = Path(__file__).resolve().parent / "sounds"
_EVENT_FILES = {
"wake": "wake.wav",
"accept": "accepted.wav",
"no_match": "no_match.wav",
"submit": "sent.wav",
}
_EVENT_FLAGS = {
"wake": "on_wake",
"accept": "on_accept",
"no_match": "on_no_match",
"submit": "on_submit",
}
class Earcons:
"""resolves daemon events to tones and plays them per the [sound] config"""
def __init__(self, config: Config) -> None:
self._apply(config)
def update(self, config: Config) -> None:
"""re-read the [sound] config after a live reload"""
self._apply(config)
def _apply(self, config: Config) -> None:
self.enabled = config.sound_enabled
self.volume = config.sound_volume
self._flags = {
"wake": config.sound_on_wake,
"accept": config.sound_on_accept,
"no_match": config.sound_on_no_match,
"submit": config.sound_on_submit,
}
self._overrides = dict(config.sound_files)
def _resolve(self, event: str) -> Path | None:
override = self._overrides.get(event) or self._overrides.get(_EVENT_FLAGS[event])
if override:
return Path(override).expanduser()
name = _EVENT_FILES.get(event)
return _SOUNDS_DIR / name if name else None
def play(self, event: str) -> None:
"""play the tone for an event if enabled (master + per-event). fire-and-forget;
unknown/disabled events and missing files are silently no-ops."""
if not self.enabled or not self._flags.get(event, False):
return
path = self._resolve(event)
if path is None:
return
audio_out.play(path, volume=self.volume, blocking=False)
def tone_path(self, event: str) -> Path | None:
"""the resolved tone path for an event (for test-tone), ignoring enable flags"""
return self._resolve(event)
def event_names() -> list[str]:
"""the earcon event names in a stable order (for test-tone iteration)"""
return ["wake", "accept", "no_match", "submit"]
+1
View File
@@ -0,0 +1 @@
"""earcon tone assets (committed .wav files) + their generator (generate.py)"""
Binary file not shown.
+75
View File
@@ -0,0 +1,75 @@
"""synthetic-beep FALLBACK generator for the earcon .wav tones.
WARNING: the shipped tones in this directory are now CUSTOM CURATED recordings
(edge-trimmed + loudness-normalized to ~-16 dB RMS with a -1 dBTP ceiling), NOT this
script's output. running this script OVERWRITES those real tones with plain synthetic
beeps — only do so if you deliberately want to fall back to generated placeholders. it
is kept as a bootstrap fallback so the package can always self-generate a tone set (the
"a missing tone must never break a command" guarantee), not as the source of the
committed wavs.
run ``python -m claudedo.sounds.generate`` (or ``python generate.py`` from this dir) to
write placeholder beeps. each is a short, quiet, fade-enveloped sine/triangle at a
distinct pitch so the four events are ear-distinguishable:
wake — soft single mid blip (off by default; least intrusive)
accepted — bright single high note (heard you, sent it)
no_match — low two-note falling buzz (heard you, but nothing matched / error)
sent — two-note rising chime (submitted to claude)
kept SHORT (<300ms) and quiet (amplitude 0.4) — confirmations, not alarms.
"""
from __future__ import annotations
import struct
import wave
from pathlib import Path
SAMPLE_RATE = 44100
AMPLITUDE = 0.4
HERE = Path(__file__).resolve().parent
def _tone(freq: float, dur: float) -> list[float]:
import math
n = int(SAMPLE_RATE * dur)
fade = max(1, int(SAMPLE_RATE * 0.01))
out = []
for i in range(n):
env = min(1.0, i / fade, (n - i) / fade)
out.append(math.sin(2.0 * math.pi * freq * (i / SAMPLE_RATE)) * env * AMPLITUDE)
return out
def _silence(dur: float) -> list[float]:
return [0.0] * int(SAMPLE_RATE * dur)
def _write(name: str, samples: list[float]) -> Path:
path = HERE / name
with wave.open(str(path), "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
clipped = (max(-1.0, min(1.0, s)) for s in samples)
wf.writeframes(b"".join(struct.pack("<h", int(s * 32767)) for s in clipped))
return path
def generate() -> list[Path]:
"""(re)write all earcon wavs; return the written paths"""
tones = {
"wake.wav": _tone(660.0, 0.12),
"accepted.wav": _tone(988.0, 0.14),
"no_match.wav": _tone(330.0, 0.10) + _silence(0.03) + _tone(247.0, 0.12),
"sent.wav": _tone(784.0, 0.10) + _silence(0.02) + _tone(1175.0, 0.12),
}
return [_write(name, samples) for name, samples in tones.items()]
if __name__ == "__main__":
for p in generate():
print(f"wrote {p}")
Binary file not shown.
Binary file not shown.
Binary file not shown.
+43 -3
View File
@@ -75,14 +75,54 @@ def session_exists(name: str) -> bool:
def list_sessions() -> list[str]: def list_sessions() -> list[str]:
"""return the names of all running claude-* tmux sessions (sorted)""" """return the names of all running claude-* tmux sessions (sorted)"""
return sorted(name for name, _attached in _claude_sessions())
def _claude_sessions() -> list[tuple[str, bool]]:
"""the single tmux query for claude-* sessions: (name, attached) pairs.
one source of truth for session enumeration — list_sessions() and the detached
cleanup both build on this. attached is True when at least one client is attached
(tmux #{session_attached} > 0). returns [] if tmux isn't reachable.
"""
result = subprocess.run( result = subprocess.run(
["tmux", "list-sessions", "-F", "#{session_name}"], ["tmux", "list-sessions", "-F", "#{session_name} #{session_attached}"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
) )
if result.returncode != 0: if result.returncode != 0:
return [] return []
names = result.stdout.decode("utf-8", "replace").splitlines() out: list[tuple[str, bool]] = []
return sorted(n for n in names if n.startswith(SESSION_PREFIX)) for line in result.stdout.decode("utf-8", "replace").splitlines():
parts = line.rsplit(" ", 1)
if len(parts) != 2:
continue
name, attached = parts
if name.startswith(SESSION_PREFIX):
out.append((name, attached.strip() != "0"))
return out
def cleanup_detached() -> tuple[list[str], list[str]]:
"""kill every DETACHED claude-* session, never an attached one. returns the
(killed, kept_attached) name lists (both sorted) for reporting.
detached-only is the safety model: a misheard voice ``cleanup`` cannot nuke the
active session, which is attached. the kill-including-attached path stays the shell
``cckl`` (deliberate, typed).
"""
killed: list[str] = []
kept: list[str] = []
for name, attached in _claude_sessions():
if attached:
kept.append(name)
continue
result = subprocess.run(
["tmux", "kill-session", "-t", name],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
)
if result.returncode == 0:
killed.append(name)
return sorted(killed), sorted(kept)
def resolve(one_shot: str | None = None, auto_target: bool = False) -> tuple[str | None, str]: def resolve(one_shot: str | None = None, auto_target: bool = False) -> tuple[str | None, str]: