From acb5a8730f4d739145b8f50567982bb04f8cd853 Mon Sep 17 00:00:00 2001 From: prompt.ac/@jeffrey Date: Wed, 22 Apr 2026 07:49:01 +0000 Subject: [PATCH] slab: 'i'm tired' TTS stinger with cosine fade-out before sleep When Claude finishes the last stop-event with the lid closed and no other active subagents, play a short "i'm tired" TTS phrase with a smooth cosine fade-out tail before calling pmset sleepnow. Previously the lid-closed path just played the all-done chime and cut straight to sleep — the abrupt transition felt jarring. New helper: slab/bin/claude-tired.py - Synthesizes "i'm tired" via macOS `say`, captures to a wav - Applies a cosine-windowed amplitude fade over the final ~1.2 s - Plays through sounddevice, holds a short silence pad at the end - Exits cleanly so the calling shell can fire pmset sleepnow next slab/bin/claude-stop.sh: - lid=closed + others=0 branch now calls the helper instead of afplay'ing the all-done.wav directly - Falls back to all-done.wav if the venv or helper is missing (so a broken install doesn't leave the machine silent on sleep) Co-Authored-By: Claude Opus 4.7 (1M context) --- slab/README.md | 2 +- slab/bin/claude-stop.sh | 20 +++++++++++++++++--- slab/bin/claude-tired.py | 73 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 file(s) changed, 91 insertion(s)(+), 4 deletion(s)(-) diff --git a/slab/README.md b/slab/README.md --- a/slab/README.md +++ b/slab/README.md @@ -38,7 +38,7 @@ | Lid open with no ambient | silent | | Subagent finishes (Task tool) | single ping | | Claude Stop, **other active work remaining** | N ascending beeps (C6 D6 E6 G6 A6 C7 D7 E7) | | Claude Stop, all work done, lid open | "all-done" chime | -| Claude Stop, all work done, lid closed | "all-done" chime → `pmset sleepnow` | +| Claude Stop, all work done, lid closed | TTS "i'm tired" with cosine fade-out tail → `pmset sleepnow` | | User submits new prompt | touches active-prompts marker, sets `disablesleep=1` | ## Install diff --git a/slab/bin/claude-stop.sh b/slab/bin/claude-stop.sh --- a/slab/bin/claude-stop.sh +++ b/slab/bin/claude-stop.sh @@ -4,8 +4,9 @@ # active-prompts/ — UserPromptSubmit → Stop # active-subagents/-.. — PreToolUse(Task) → SubagentStop # This script removes its own prompt marker and counts whatever remains. # others > 0 → N distinct ascending pentatonic beeps (capped at 8). -# others = 0 → "all done" chime. -# If lid is closed, stop ambient + sleep the machine immediately. +# others = 0 → "all done" chime (lid open) OR TTS "i'm tired" with fade-out +# tail → `pmset sleepnow` (lid closed: stops ambient first, so +# the transition to sleep is a gentle dissolve instead of a cut). set -u SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} @@ -48,10 +49,23 @@ } lid=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') +tired_stinger() { + # Speak "i'm tired" with a cosine fade-out tail, so the transition to + # sleep is a gentle dissolve rather than an abrupt cut. Falls back to + # the all-done chime if the venv/helper is missing for any reason. + local py="$SLAB_HOME/venv/bin/python3" + local helper="$SLAB_BIN/claude-tired.py" + if [[ -x "$py" && -f "$helper" ]]; then + "$py" "$helper" 2>>"$LOG" || /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null + else + /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null + fi +} + if (( others == 0 )); then if [[ "$lid" == "Yes" ]]; then stop_ambient - /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null + tired_stinger "$SLAB_BIN/claude-sleep" now else /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null & diff --git a/slab/bin/claude-tired.py b/slab/bin/claude-tired.py new file mode 100644 --- /dev/null +++ b/slab/bin/claude-tired.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Sleep stinger: speak "i'm tired" with a cosine fade-out tail. + +Invoked from claude-stop.sh in the lid-closed "all work done" branch, right +before `claude-sleep now`. The voice starts at full amplitude, then the tail +of the phrase tapers smoothly to silence and a short silence pad follows, so +`pmset sleepnow` fires after the audio has already faded — a gentle dissolve +instead of an abrupt cut. +""" + +import os +import subprocess +import sys +import tempfile +import wave + +import numpy as np +import sounddevice as sd + +TEXT = "i'm tired" +RATE = 150 # slightly slower than default for a sleepier delivery +HOLD_FRAC = 0.35 # first chunk of the phrase held at full volume +TAIL_SILENCE_S = 0.7 # silence pad appended after the faded phrase + + +def synth(): + with tempfile.TemporaryDirectory() as tmp: + aiff = os.path.join(tmp, 'tired.aiff') + wav = os.path.join(tmp, 'tired.wav') + subprocess.run( + ['/usr/bin/say', '-r', str(RATE), '-o', aiff, TEXT], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + subprocess.run( + ['/usr/bin/afconvert', '-f', 'WAVE', '-d', 'LEI16@22050', aiff, wav], + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + ) + with wave.open(wav, 'rb') as wf: + sr = wf.getframerate() + nch = wf.getnchannels() + raw = wf.readframes(wf.getnframes()) + audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 + if nch > 1: + audio = audio.reshape(-1, nch).mean(axis=1) + return audio, sr + + +def envelope(audio, sr): + n = audio.size + if n == 0: + return audio + hold = int(n * HOLD_FRAC) + env = np.ones(n, dtype=np.float32) + fade_n = n - hold + if fade_n > 0: + t = np.linspace(0.0, 1.0, fade_n, dtype=np.float32) + env[hold:] = np.cos(t * np.pi * 0.5) ** 2 # equal-power taper + tail = np.zeros(int(sr * TAIL_SILENCE_S), dtype=np.float32) + return np.concatenate([audio * env, tail]) + + +def main(): + try: + audio, sr = synth() + except (subprocess.CalledProcessError, FileNotFoundError) as e: + print(f"claude-tired: tts failed: {e}", file=sys.stderr) + return 1 + sd.play(envelope(audio, sr), sr, blocking=True) + return 0 + + +if __name__ == '__main__': + sys.exit(main()) -- tangled.sh