Compare commits

..

2 Commits
v0.2.0 ... main

Author SHA1 Message Date
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
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

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**

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]

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

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"]

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"

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"

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
src/claudedo/audio_out.py Normal file
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)

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:

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)"

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
src/claudedo/sound.py Normal file
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"]

View File

@ -0,0 +1 @@
"""earcon tone assets (committed .wav files) + their generator (generate.py)"""

Binary file not shown.

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.

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]: