From 0bd6e20ce035638d50fe7c1d8fb0006a87b6753a Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 28 Apr 2026 13:30:30 -0700 Subject: [PATCH] slab/bin/claude-help.py: jeffrey elevenlabs voice with phrase variants Switches the Notification stinger from a single fixed "help me" line to a random pick from a phrase pool, fetched from the AC say endpoint with Jeffrey's ElevenLabs PVC and cached per-variant under ~/.local/share/slab/sounds/jeffrey/. Adds --prewarm to fetch every phrase upfront. Falls back to /usr/bin/say if the network fetch fails. Co-Authored-By: Claude Opus 4.7 (1M context) --- slab/bin/claude-help.py | 155 +++++++++++++++++++++++++++++++++++----- 1 file changed, 136 insertions(+), 19 deletions(-) diff --git a/slab/bin/claude-help.py b/slab/bin/claude-help.py index c21078ecd3..be3bc39cc2 100755 --- a/slab/bin/claude-help.py +++ b/slab/bin/claude-help.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Notification stinger: speak "help me" with a short cosine fade-out. +"""Notification stinger: speak a short attention cue with a cosine fade-out. Invoked from claude-notify.sh when Claude Code emits a Notification hook — i.e. the agent has paused and is waiting on user feedback (permission prompt @@ -7,36 +7,122 @@ or idle input). Unlike claude-tired.py, this is an attention cue, not a lullaby: the delivery is brisker and the tail silence is small. Ambient has already been asked to fade via SIGTERM by the caller, so this phrase plays over the dissolving bed. + +Voice: Jeffrey ElevenLabs PVC (same clone used in the LACMA pitch video), +served via the AC `say` endpoint and cached locally after the first fetch. +A phrase is chosen at random from PHRASES on each invocation; each variant +is cached as its own mp3 under ~/.local/share/slab/sounds/jeffrey/. +Falls back to macOS `/usr/bin/say` (with the same chosen phrase) if the +network or API is unreachable. + +Run with `--prewarm` to fetch every phrase upfront so the first stinger of +each variant is instant. """ +import json import os +import random +import re import subprocess import sys import tempfile +import urllib.error +import urllib.request import wave +from pathlib import Path import numpy as np import sounddevice as sd -TEXT = "help me" -RATE = 175 # brisker than the sleep stinger — it's a request, not a sigh +# Edit this list to tune the variations. Keep them short — these play over +# a fading ambient bed and need to land in well under a second. +PHRASES = [ + "help me", + "your turn", + "psst", + "ahem", + "hey jeffrey", + "knock knock", + "you there", + "i need you", +] + HOLD_FRAC = 0.55 # hold more of the phrase at full volume before tapering TAIL_SILENCE_S = 0.2 # short pad; we're not about to sleep the machine +SAY_ENDPOINT = "https://aesthetic.computer/api/say" +# Cloudflare blocks the default `Python-urllib/X.Y` UA on art.aesthetic.computer. +USER_AGENT = "slab-menubar/1.0 (claude-help)" +SLAB_HOME = Path(os.environ.get("SLAB_HOME", Path.home() / ".local/share/slab")) +CACHE_DIR = SLAB_HOME / "sounds" / "jeffrey" + +FALLBACK_RATE = 175 # macOS `say` rate when the Jeffrey voice is unreachable + + +def slug(phrase): + return re.sub(r"[^a-z0-9]+", "-", phrase.lower()).strip("-") or "phrase" + + +def cache_path(phrase): + return CACHE_DIR / f"{slug(phrase)}.mp3" + + +class _NoRedirect(urllib.request.HTTPRedirectHandler): + # The `say` endpoint 302s to a Spaces CDN URL. urllib's default handler + # would re-issue the request and may forward POST headers/body, which DO + # rejects with 403. Capture the Location and follow it ourselves with a + # clean GET. + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + +def fetch_jeffrey_mp3(phrase): + dest = cache_path(phrase) + if dest.exists() and dest.stat().st_size > 0: + return dest + dest.parent.mkdir(parents=True, exist_ok=True) + body = json.dumps({"from": phrase, "provider": "jeffrey"}).encode("utf-8") + opener = urllib.request.build_opener(_NoRedirect) + req = urllib.request.Request( + SAY_ENDPOINT, + data=body, + headers={"Content-Type": "application/json", "User-Agent": USER_AGENT}, + method="POST", + ) + cdn_url = None + data = None + try: + with opener.open(req, timeout=15) as resp: + if resp.status == 200: + data = resp.read() + else: + raise RuntimeError(f"unexpected status {resp.status}") + except urllib.error.HTTPError as e: + if e.code in (301, 302, 303, 307, 308): + cdn_url = e.headers.get("Location") + else: + raise + if cdn_url: + cdn_req = urllib.request.Request(cdn_url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(cdn_req, timeout=15) as resp2: + data = resp2.read() + if not data or (not data.startswith(b"ID3") and b"\xff\xfb" not in data[:64] and b"\xff\xf3" not in data[:64]): + raise RuntimeError("response is not an mp3") + tmp_path = dest.with_suffix(".mp3.partial") + tmp_path.write_bytes(data) + tmp_path.replace(dest) + return dest + -def synth(): +def decode_to_audio(audio_path): with tempfile.TemporaryDirectory() as tmp: - aiff = os.path.join(tmp, 'help.aiff') - wav = os.path.join(tmp, 'help.wav') - subprocess.run( - ['/usr/bin/say', '-r', str(RATE), '-o', aiff, TEXT], - check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - ) + wav = os.path.join(tmp, "out.wav") subprocess.run( - ['/usr/bin/afconvert', '-f', 'WAVE', '-d', 'LEI16@22050', aiff, wav], + ["/usr/bin/afconvert", "-f", "WAVE", "-d", "LEI16@22050", + str(audio_path), wav], check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) - with wave.open(wav, 'rb') as wf: + with wave.open(wav, "rb") as wf: sr = wf.getframerate() nch = wf.getnchannels() raw = wf.readframes(wf.getnframes()) @@ -46,6 +132,16 @@ def synth(): return audio, sr +def synth_fallback(phrase): + with tempfile.TemporaryDirectory() as tmp: + aiff = os.path.join(tmp, "out.aiff") + subprocess.run( + ["/usr/bin/say", "-r", str(FALLBACK_RATE), "-o", aiff, phrase], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + return decode_to_audio(aiff) + + def envelope(audio, sr): n = audio.size if n == 0: @@ -60,15 +156,36 @@ def envelope(audio, sr): return np.concatenate([audio * env, tail]) -def main(): +def prewarm(): + failures = [] + for phrase in PHRASES: + try: + path = fetch_jeffrey_mp3(phrase) + print(f" ✓ {phrase!r:24s} → {path}") + except Exception as e: + print(f" ✗ {phrase!r:24s} → {e}", file=sys.stderr) + failures.append(phrase) + return 1 if failures else 0 + + +def main(argv): + if "--prewarm" in argv: + return prewarm() + + phrase = random.choice(PHRASES) try: - audio, sr = synth() - except (subprocess.CalledProcessError, FileNotFoundError) as e: - print(f"claude-help: tts failed: {e}", file=sys.stderr) - return 1 + mp3 = fetch_jeffrey_mp3(phrase) + audio, sr = decode_to_audio(mp3) + except Exception as e: + print(f"claude-help: jeffrey voice unavailable ({e}); falling back to /usr/bin/say", file=sys.stderr) + try: + audio, sr = synth_fallback(phrase) + except (subprocess.CalledProcessError, FileNotFoundError) as e2: + print(f"claude-help: fallback tts failed: {e2}", file=sys.stderr) + return 1 sd.play(envelope(audio, sr), sr, blocking=True) return 0 -if __name__ == '__main__': - sys.exit(main()) +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) -- 2.51.2