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:
parent
2fa3abab63
commit
9ed0e5fabd
31
README.md
31
README.md
@ -84,6 +84,7 @@ claudedo set <name> # set the sticky target -> claude-<name> (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**
|
||||
|
||||
24
config.toml
24
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]
|
||||
|
||||
@ -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:-<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
|
||||
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
|
||||
|
||||
@ -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"]
|
||||
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
"""claudedo — voice-control daemon for claude code (local STT -> tmux send-keys)"""
|
||||
|
||||
__version__ = "0.2.0"
|
||||
__version__ = "0.2.1"
|
||||
|
||||
@ -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)
|
||||
|
||||
158
src/claudedo/audio_out.py
Normal file
158
src/claudedo/audio_out.py
Normal 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)
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
91
src/claudedo/sound.py
Normal file
91
src/claudedo/sound.py
Normal file
@ -0,0 +1,91 @@
|
||||
"""earcons — short confirmation tones on daemon events, the eyes-free feedback layer.
|
||||
|
||||
the single place that maps an event name to its tone file and the per-event enable
|
||||
flag. additive to the console feed (it does not replace the printed lines): at the desk
|
||||
mute tones and read; eyes-free, hear them. playback goes through audio_out (paplay-first,
|
||||
fire-and-forget) so a dead speaker never blocks or breaks a command.
|
||||
|
||||
events:
|
||||
wake — a wake phrase was recognized (off by default — a blip right before you
|
||||
speak the command can bleed into its capture; keep it off unless wanted)
|
||||
accept — a command was recognized/injected
|
||||
no_match — nothing matched, or the target was missing (did nothing)
|
||||
submit — a send/submit was injected
|
||||
|
||||
tone files live in the packaged sounds/ dir; a per-event config override may point at a
|
||||
user file instead. a missing file is swallowed by audio_out (logged once), never raised.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from . import audio_out
|
||||
from .config import Config
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_SOUNDS_DIR = Path(__file__).resolve().parent / "sounds"
|
||||
|
||||
_EVENT_FILES = {
|
||||
"wake": "wake.wav",
|
||||
"accept": "accepted.wav",
|
||||
"no_match": "no_match.wav",
|
||||
"submit": "sent.wav",
|
||||
}
|
||||
|
||||
_EVENT_FLAGS = {
|
||||
"wake": "on_wake",
|
||||
"accept": "on_accept",
|
||||
"no_match": "on_no_match",
|
||||
"submit": "on_submit",
|
||||
}
|
||||
|
||||
|
||||
class Earcons:
|
||||
"""resolves daemon events to tones and plays them per the [sound] config"""
|
||||
|
||||
def __init__(self, config: Config) -> None:
|
||||
self._apply(config)
|
||||
|
||||
def update(self, config: Config) -> None:
|
||||
"""re-read the [sound] config after a live reload"""
|
||||
self._apply(config)
|
||||
|
||||
def _apply(self, config: Config) -> None:
|
||||
self.enabled = config.sound_enabled
|
||||
self.volume = config.sound_volume
|
||||
self._flags = {
|
||||
"wake": config.sound_on_wake,
|
||||
"accept": config.sound_on_accept,
|
||||
"no_match": config.sound_on_no_match,
|
||||
"submit": config.sound_on_submit,
|
||||
}
|
||||
self._overrides = dict(config.sound_files)
|
||||
|
||||
def _resolve(self, event: str) -> Path | None:
|
||||
override = self._overrides.get(event) or self._overrides.get(_EVENT_FLAGS[event])
|
||||
if override:
|
||||
return Path(override).expanduser()
|
||||
name = _EVENT_FILES.get(event)
|
||||
return _SOUNDS_DIR / name if name else None
|
||||
|
||||
def play(self, event: str) -> None:
|
||||
"""play the tone for an event if enabled (master + per-event). fire-and-forget;
|
||||
unknown/disabled events and missing files are silently no-ops."""
|
||||
if not self.enabled or not self._flags.get(event, False):
|
||||
return
|
||||
path = self._resolve(event)
|
||||
if path is None:
|
||||
return
|
||||
audio_out.play(path, volume=self.volume, blocking=False)
|
||||
|
||||
def tone_path(self, event: str) -> Path | None:
|
||||
"""the resolved tone path for an event (for test-tone), ignoring enable flags"""
|
||||
return self._resolve(event)
|
||||
|
||||
|
||||
def event_names() -> list[str]:
|
||||
"""the earcon event names in a stable order (for test-tone iteration)"""
|
||||
return ["wake", "accept", "no_match", "submit"]
|
||||
1
src/claudedo/sounds/__init__.py
Normal file
1
src/claudedo/sounds/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
"""earcon tone assets (committed .wav files) + their generator (generate.py)"""
|
||||
BIN
src/claudedo/sounds/accepted.wav
Normal file
BIN
src/claudedo/sounds/accepted.wav
Normal file
Binary file not shown.
75
src/claudedo/sounds/generate.py
Normal file
75
src/claudedo/sounds/generate.py
Normal 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}")
|
||||
BIN
src/claudedo/sounds/no_match.wav
Normal file
BIN
src/claudedo/sounds/no_match.wav
Normal file
Binary file not shown.
BIN
src/claudedo/sounds/sent.wav
Normal file
BIN
src/claudedo/sounds/sent.wav
Normal file
Binary file not shown.
BIN
src/claudedo/sounds/wake.wav
Normal file
BIN
src/claudedo/sounds/wake.wav
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user