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>
This commit is contained in:
disqualifier 2026-06-27 17:13:43 -04:00
parent 2fa3abab63
commit 9ed0e5fabd
16 changed files with 452 additions and 3 deletions

View File

@ -84,6 +84,7 @@ claudedo set <name> # set the sticky target -> claude-<name> (alias: switc
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 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
@ -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 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,27 @@ 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 = " — "
[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.1"
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

@ -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.1"

View File

@ -97,6 +97,36 @@ 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_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,6 +264,8 @@ 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)

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,13 @@ class Config:
print_heard: bool print_heard: bool
context_multiline: bool context_multiline: bool
context_separator: str 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) 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)), 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,), "")),
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__)
@ -130,6 +131,7 @@ class Daemon:
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 +204,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 +276,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 +295,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 +355,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 +394,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."""

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.