Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

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) <noreply@anthropic.com>

+91 -4
+1 -1
slab/README.md
··· 38 38 | Subagent finishes (Task tool) | single ping | 39 39 | Claude Stop, **other active work remaining** | N ascending beeps (C6 D6 E6 G6 A6 C7 D7 E7) | 40 40 | Claude Stop, all work done, lid open | "all-done" chime | 41 - | Claude Stop, all work done, lid closed | "all-done" chime → `pmset sleepnow` | 41 + | Claude Stop, all work done, lid closed | TTS "i'm tired" with cosine fade-out tail → `pmset sleepnow` | 42 42 | User submits new prompt | touches active-prompts marker, sets `disablesleep=1` | 43 43 44 44 ## Install
+17 -3
slab/bin/claude-stop.sh
··· 4 4 # active-subagents/<timestamp>-.. — PreToolUse(Task) → SubagentStop 5 5 # This script removes its own prompt marker and counts whatever remains. 6 6 # others > 0 → N distinct ascending pentatonic beeps (capped at 8). 7 - # others = 0 → "all done" chime. 8 - # If lid is closed, stop ambient + sleep the machine immediately. 7 + # others = 0 → "all done" chime (lid open) OR TTS "i'm tired" with fade-out 8 + # tail → `pmset sleepnow` (lid closed: stops ambient first, so 9 + # the transition to sleep is a gentle dissolve instead of a cut). 9 10 set -u 10 11 SLAB_HOME=${SLAB_HOME:-$HOME/.local/share/slab} 11 12 SLAB_BIN=${SLAB_BIN:-$HOME/.local/bin} ··· 48 49 49 50 lid=$(ioreg -r -k AppleClamshellState -d 4 | awk '/AppleClamshellState/{print $NF; exit}') 50 51 52 + tired_stinger() { 53 + # Speak "i'm tired" with a cosine fade-out tail, so the transition to 54 + # sleep is a gentle dissolve rather than an abrupt cut. Falls back to 55 + # the all-done chime if the venv/helper is missing for any reason. 56 + local py="$SLAB_HOME/venv/bin/python3" 57 + local helper="$SLAB_BIN/claude-tired.py" 58 + if [[ -x "$py" && -f "$helper" ]]; then 59 + "$py" "$helper" 2>>"$LOG" || /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null 60 + else 61 + /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null 62 + fi 63 + } 64 + 51 65 if (( others == 0 )); then 52 66 if [[ "$lid" == "Yes" ]]; then 53 67 stop_ambient 54 - /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null 68 + tired_stinger 55 69 "$SLAB_BIN/claude-sleep" now 56 70 else 57 71 /usr/bin/afplay "$CH/all-done.wav" 2>/dev/null &
+73
slab/bin/claude-tired.py
··· 1 + #!/usr/bin/env python3 2 + """Sleep stinger: speak "i'm tired" with a cosine fade-out tail. 3 + 4 + Invoked from claude-stop.sh in the lid-closed "all work done" branch, right 5 + before `claude-sleep now`. The voice starts at full amplitude, then the tail 6 + of the phrase tapers smoothly to silence and a short silence pad follows, so 7 + `pmset sleepnow` fires after the audio has already faded — a gentle dissolve 8 + instead of an abrupt cut. 9 + """ 10 + 11 + import os 12 + import subprocess 13 + import sys 14 + import tempfile 15 + import wave 16 + 17 + import numpy as np 18 + import sounddevice as sd 19 + 20 + TEXT = "i'm tired" 21 + RATE = 150 # slightly slower than default for a sleepier delivery 22 + HOLD_FRAC = 0.35 # first chunk of the phrase held at full volume 23 + TAIL_SILENCE_S = 0.7 # silence pad appended after the faded phrase 24 + 25 + 26 + def synth(): 27 + with tempfile.TemporaryDirectory() as tmp: 28 + aiff = os.path.join(tmp, 'tired.aiff') 29 + wav = os.path.join(tmp, 'tired.wav') 30 + subprocess.run( 31 + ['/usr/bin/say', '-r', str(RATE), '-o', aiff, TEXT], 32 + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, 33 + ) 34 + subprocess.run( 35 + ['/usr/bin/afconvert', '-f', 'WAVE', '-d', 'LEI16@22050', aiff, wav], 36 + check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, 37 + ) 38 + with wave.open(wav, 'rb') as wf: 39 + sr = wf.getframerate() 40 + nch = wf.getnchannels() 41 + raw = wf.readframes(wf.getnframes()) 42 + audio = np.frombuffer(raw, dtype=np.int16).astype(np.float32) / 32768.0 43 + if nch > 1: 44 + audio = audio.reshape(-1, nch).mean(axis=1) 45 + return audio, sr 46 + 47 + 48 + def envelope(audio, sr): 49 + n = audio.size 50 + if n == 0: 51 + return audio 52 + hold = int(n * HOLD_FRAC) 53 + env = np.ones(n, dtype=np.float32) 54 + fade_n = n - hold 55 + if fade_n > 0: 56 + t = np.linspace(0.0, 1.0, fade_n, dtype=np.float32) 57 + env[hold:] = np.cos(t * np.pi * 0.5) ** 2 # equal-power taper 58 + tail = np.zeros(int(sr * TAIL_SILENCE_S), dtype=np.float32) 59 + return np.concatenate([audio * env, tail]) 60 + 61 + 62 + def main(): 63 + try: 64 + audio, sr = synth() 65 + except (subprocess.CalledProcessError, FileNotFoundError) as e: 66 + print(f"claude-tired: tts failed: {e}", file=sys.stderr) 67 + return 1 68 + sd.play(envelope(audio, sr), sr, blocking=True) 69 + return 0 70 + 71 + 72 + if __name__ == '__main__': 73 + sys.exit(main())