diff --git a/README.md b/README.md index f86a61d..2f0fc41 100644 --- a/README.md +++ b/README.md @@ -84,6 +84,7 @@ claudedo set # set the sticky target -> claude- (alias: switc claudedo unset # clear the sticky target claudedo list # list running claude-* sessions claudedo test-audio # verify the mic capture path +claudedo test-tone # play each earcon (verify the audio-OUT path) ``` ### Modes @@ -211,6 +212,36 @@ Whisper model. The **`system`** namespace gives daemon-control by voice without Claude: `system status` (mode / target / model / context count) and `system reload [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.0–1.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 keystrokes in [`keys.py`](src/claudedo/keys.py) were confirmed **empirically** diff --git a/config.toml b/config.toml index 3aa9e02..b4dbeff 100644 --- a/config.toml +++ b/config.toml @@ -88,3 +88,27 @@ print_heard = false context_multiline = true # separator inserted between blurb and instruction when context_multiline = false. context_separator = " — " + +[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] diff --git a/install.sh b/install.sh index 26b5163..19b8e11 100755 --- a/install.sh +++ b/install.sh @@ -57,9 +57,14 @@ 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:-}" + DEFAULT_SINK="$(pactl info | sed -n 's/^Default Sink: //p')" + echo " Default Sink: ${DEFAULT_SINK:-}" 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 + 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 warn "pactl info failed — pulseaudio-utils installed but no server reachable yet." fi diff --git a/pyproject.toml b/pyproject.toml index f9ffbf2..6439486 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "claudedo" -version = "0.2.0" +version = "0.2.1" description = "voice-control daemon for claude code (local STT -> tmux send-keys)" readme = "README.md" requires-python = ">=3.10" @@ -23,6 +23,9 @@ claudedo = "claudedo.__main__:main" [tool.setuptools] package-dir = { "" = "src" } +[tool.setuptools.package-data] +"claudedo.sounds" = ["*.wav"] + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/claudedo/__init__.py b/src/claudedo/__init__.py index 20b3941..d08b05d 100644 --- a/src/claudedo/__init__.py +++ b/src/claudedo/__init__.py @@ -1,3 +1,3 @@ """claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)""" -__version__ = "0.2.0" +__version__ = "0.2.1" diff --git a/src/claudedo/__main__.py b/src/claudedo/__main__.py index fae3d17..41167e7 100644 --- a/src/claudedo/__main__.py +++ b/src/claudedo/__main__.py @@ -97,6 +97,36 @@ def cmd_stop(_args: argparse.Namespace) -> int: 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_reload(_args: argparse.Namespace) -> int: if daemon.reload_running(): print("signalled claudedo to reload config + contexts") @@ -234,6 +264,8 @@ def build_parser() -> argparse.ArgumentParser: ).set_defaults(func=cmd_reload) 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-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("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) diff --git a/src/claudedo/audio_out.py b/src/claudedo/audio_out.py new file mode 100644 index 0000000..bdb00aa --- /dev/null +++ b/src/claudedo/audio_out.py @@ -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=" 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) diff --git a/src/claudedo/config.py b/src/claudedo/config.py index 662dea4..04f0b6f 100644 --- a/src/claudedo/config.py +++ b/src/claudedo/config.py @@ -58,6 +58,13 @@ class Config: print_heard: bool context_multiline: bool context_separator: str + 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) @@ -132,12 +139,21 @@ def load_config(explicit: str | os.PathLike | None = None) -> Config: print_heard=bool(_require(raw, "behavior", "print_heard", (bool,), False)), context_multiline=bool(_require(raw, "behavior", "context_multiline", (bool,), True)), context_separator=str(_require(raw, "behavior", "context_separator", (str,), " — ")), + 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, ) for label, val in (("wake_fuzzy_threshold", cfg.wake_fuzzy_threshold), ("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 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: raise ConfigError("[vad].silence_ms and max_seconds must be positive") if cfg.samplerate <= 0 or cfg.channels <= 0: diff --git a/src/claudedo/daemon.py b/src/claudedo/daemon.py index 6442916..ed07b03 100644 --- a/src/claudedo/daemon.py +++ b/src/claudedo/daemon.py @@ -20,6 +20,7 @@ from . import __version__, audio, grammar, inject, keys, target from .config import Config, ConfigError, load_config from .console import HELP, SYSTEM, VOICE, Console from .contexts import Contexts, ContextsError, load_contexts +from .sound import Earcons from .stt import Transcriber log = logging.getLogger(__name__) @@ -130,6 +131,7 @@ class Daemon: self._pending: dict[str, int] = {} self._console = Console() self._contexts = Contexts() + self._earcons = Earcons(config) self._last_stt_ms = 0.0 self._last_audio_s = 0.0 @@ -202,10 +204,14 @@ class Daemon: 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: + if parsed is not None: + self._earcons.play("wake") self._console.emit(VOICE, f'heard "{transcript}" -> no command matched {self._timing()}', "yellow") + self._earcons.play("no_match") return action = parsed.action + self._earcons.play("wake") # 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 @@ -270,12 +276,14 @@ class Daemon: name = str(action.arg[0]) if self._contexts.get(name) is None: self._console.emit(VOICE, f"no context named '{name}' -> did nothing", "red") + self._earcons.play("no_match") 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") + self._earcons.play("no_match") return if action.name == "context": self._inject_context(session, action) @@ -287,9 +295,12 @@ class Daemon: 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. + 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 + self._earcons.play("submit" if name == "submit" else "accept") if name == "type": text = str(action.arg) @@ -344,6 +355,7 @@ class Daemon: name, dictation = str(action.arg[0]), str(action.arg[1]) blurb = self._contexts.get(name) or "" + self._earcons.play("accept") inject.send_literal(session, blurb) chars = len(blurb) if dictation: @@ -382,6 +394,7 @@ class Daemon: toggled by voice and leaving the already-loaded transcriber untouched.""" new_cfg.mode = self.mode self.config = new_cfg + self._earcons.update(new_cfg) def _do_system(self, arg) -> None: """daemon-control namespace (never injects to claude): status / reload.""" diff --git a/src/claudedo/sound.py b/src/claudedo/sound.py new file mode 100644 index 0000000..99896c3 --- /dev/null +++ b/src/claudedo/sound.py @@ -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"] diff --git a/src/claudedo/sounds/__init__.py b/src/claudedo/sounds/__init__.py new file mode 100644 index 0000000..41f56ae --- /dev/null +++ b/src/claudedo/sounds/__init__.py @@ -0,0 +1 @@ +"""earcon tone assets (committed .wav files) + their generator (generate.py)""" diff --git a/src/claudedo/sounds/accepted.wav b/src/claudedo/sounds/accepted.wav new file mode 100644 index 0000000..af78cba Binary files /dev/null and b/src/claudedo/sounds/accepted.wav differ diff --git a/src/claudedo/sounds/generate.py b/src/claudedo/sounds/generate.py new file mode 100644 index 0000000..9c7f658 --- /dev/null +++ b/src/claudedo/sounds/generate.py @@ -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(" 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}") diff --git a/src/claudedo/sounds/no_match.wav b/src/claudedo/sounds/no_match.wav new file mode 100644 index 0000000..be7bcfe Binary files /dev/null and b/src/claudedo/sounds/no_match.wav differ diff --git a/src/claudedo/sounds/sent.wav b/src/claudedo/sounds/sent.wav new file mode 100644 index 0000000..7258494 Binary files /dev/null and b/src/claudedo/sounds/sent.wav differ diff --git a/src/claudedo/sounds/wake.wav b/src/claudedo/sounds/wake.wav new file mode 100644 index 0000000..8b3a992 Binary files /dev/null and b/src/claudedo/sounds/wake.wav differ