The problem

Here is a silly problem that was costing me real time. I run Claude Code all day. When I hand it something big, a refactor across a few services, a long build, a terraform plan against an environment with a few hundred resources, it goes quiet for a while. So I do what anybody does and switch over to email, or Teams, or a second terminal, and I get absorbed. Then I come back ten minutes later and find that it finished nine minutes ago and has been sitting there patiently waiting for me to approve a command. Multiply that by a dozen times a day and it adds up to a lot of dead air.

It is a little worse than that for me, because I run two laptops. A MacBook and a Windows machine, side by side, each with its own Claude account, each usually in the middle of something. So the question is never just “is it done?” It is “which one is done?”

What I wanted was a doorbell. Something that tells me “it’s done” or “it needs you” without me having to keep glancing at two screens, and something that tells me which machine is asking without me having to look at either. And since I was going to be hearing this thing many times a day, it had to be something I would not get sick of. I knew the answer before I started typing. This was my definition of done: the Mac plays the first four notes of Ludwig van’s Fifth, and the Windows box plays Shostakovich’s DSCH. Fate knocking at the door for one, and a man signing his name under a dictator’s nose for the other. 😊

The approach

So I asked Claude Code to build it. All of it, including the sounds. I did not want to go hunting for a wav file of the Fifth, worry about where it came from, and then trim it down, and I certainly did not want to do that twice. I just asked it to synthesize each motif as a piano sound and wire it up, once on each laptop. Here is what came out of that conversation, the prompts I used, and the few things that bit me along the way.

The hooks

Claude Code has a hooks system. You tell it, in your settings file, to run a shell command when certain things happen. The three that matter here are:

  • UserPromptSubmit, which fires when you hit enter on a prompt.
  • Stop, which fires when Claude finishes its turn and hands control back to you.
  • Notification, which fires for things like permission prompts, where it wants to run a command and needs you to say yes.

That last one is important. “Waiting on me” is really two different situations. Either it is done, or it is stuck asking for permission in the middle of something. Both of them mean I need to come back, and I wanted both covered.

Here is the hooks section of ~/.claude/settings.json on the Mac:

"hooks": {
  "UserPromptSubmit": [
    {
      "hooks": [
        { "type": "command", "command": "/Users/markti/.claude/hooks/chime.sh start" }
      ]
    }
  ],
  "Stop": [
    {
      "hooks": [
        { "type": "command", "command": "/Users/markti/.claude/hooks/chime.sh check", "async": true }
      ]
    }
  ],
  "Notification": [
    {
      "matcher": "permission_prompt",
      "hooks": [
        { "type": "command", "command": "/Users/markti/.claude/hooks/chime.sh check", "async": true }
      ]
    }
  ]
}

Both check hooks call the same script, and the script knows which sound this machine plays. Notice the async flag on the two check hooks. Without it, Claude Code waits for your hook to exit before it moves on, and a three second sound clip is three seconds you do not need to add to every turn.

The script

The whole thing is one bash script with two modes.

#!/bin/bash
# Claude Code turn chime with a minimum-duration gate.
#   chime.sh start  -> called on UserPromptSubmit; records turn start time for this session
#   chime.sh check  -> called on Stop / Notification; chimes only if the turn has run >= MIN_SECONDS
MIN_SECONDS=30
VOLUME=0.025
SOUND="$HOME/.claude/sounds/beethoven5.wav"

STATE_DIR="$HOME/.claude/state/chime"
mkdir -p "$STATE_DIR"
SID=$(jq -r '.session_id // "default"' 2>/dev/null)
[ -n "$SID" ] || SID=default
STAMP="$STATE_DIR/$SID"

case "$1" in
  start)
    date +%s > "$STAMP"
    # prune stamps from sessions older than a day
    find "$STATE_DIR" -type f -mtime +1 -delete 2>/dev/null
    ;;
  check)
    [ -f "$STAMP" ] || exit 0
    elapsed=$(( $(date +%s) - $(cat "$STAMP") ))
    if [ "$elapsed" -ge "$MIN_SECONDS" ]; then
      afplay -v "$VOLUME" "$SOUND" 2>/dev/null
    fi
    ;;
esac
exit 0

start writes the current time to a stamp file. check reads it back, works out how long the turn has been running, and if it has been at least 30 seconds, plays the clip. That is really all there is to it.

The Windows laptop runs the same idea in PowerShell. Same two modes, same stamp file, same gate, different composer:

# Claude Code turn chime for Windows. Same shape as chime.sh on the Mac.
#   chime.ps1 start -> called on UserPromptSubmit; records turn start time for this session
#   chime.ps1 check -> called on Stop / Notification; chimes only if the turn has run >= MinSeconds
param([string]$Mode)
$MinSeconds = 30
$Sound = "$HOME\.claude\sounds\dsch.wav"

$StateDir = "$HOME\.claude\state\chime"
New-Item -ItemType Directory -Force -Path $StateDir | Out-Null
$sid = "default"
try { $sid = ([Console]::In.ReadToEnd() | ConvertFrom-Json).session_id } catch {}
if (-not $sid) { $sid = "default" }
$stamp = Join-Path $StateDir $sid

switch ($Mode) {
  "start" {
    [int](Get-Date -UFormat %s) | Set-Content $stamp
    # prune stamps from sessions older than a day
    Get-ChildItem $StateDir | Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } | Remove-Item -Force
  }
  "check" {
    if (-not (Test-Path $stamp)) { exit 0 }
    $elapsed = [int](Get-Date -UFormat %s) - [int](Get-Content $stamp)
    if ($elapsed -ge $MinSeconds) {
      (New-Object System.Media.SoundPlayer $Sound).PlaySync()
    }
  }
}
exit 0

and the hook commands in that machine’s settings.json point at it:

{ "type": "command", "command": "powershell -NoProfile -ExecutionPolicy Bypass -File C:\\Users\\markti\\.claude\\hooks\\chime.ps1 check", "async": true }

One difference worth knowing: System.Media.SoundPlayer has no volume argument the way afplay -v does. On Windows the volume knob is the amp value in the synthesizer script below. Generate the clip quiet and it plays quiet.

The 30 second gate was not in the first version, and it is the single most important line in the script. The first version chimed on every turn. Every single one. Ask a quick question, da-da-da-DUM. Say “yes, do that”, da-da-da-DUM. Approve a trivial command, da-da-da-DUM. It was charming for about ten minutes. I work around other people, and I could see exactly where this was headed: a week of Beethoven leaking out of my desk forty times an hour, and colleagues who were going to be very polite about it right up until they were not. Oh the insanity!

The fix is obvious in hindsight. If I ask a quick question and get a quick answer, I am still sitting there looking at the window. I do not need a doorbell for that. The only turns that need one are the ones long enough for me to have wandered off, and 30 seconds turned out to be a decent line. Since adding it, the chime fires a handful of times a day instead of a handful of times a minute, and every time it does, I actually needed it. That one number is the difference between a tool and a prank.

There is one detail I would not have thought of on my own. Claude Code passes the hook a JSON payload on stdin, and one of the fields is the session id. The script pulls that out with jq and uses it to name the stamp file. That means I can have three or four Claude sessions open in different terminals, which I usually do, and each one keeps its own clock. Without that, a quick turn in one window would reset the timer for a long one in another. The find ... -mtime +1 -delete line is just housekeeping so that folder does not fill up with stamps from sessions that ended weeks ago.

The sounds

This is the part that people seem to find most surprising. There is no audio file downloaded from anywhere. I asked for the motif to be synthesized, and Claude wrote a Python script that does it with nothing but the standard library. A piano note is, roughly, a fundamental frequency plus a stack of harmonics at whole-number multiples of it, where each harmonic is quieter than the last and the high ones die away faster than the low ones. Give that a five millisecond attack and an exponential decay and you have something that reads as a struck string rather than a microwave beep. It is not going to fool a pianist. It does not have to. It has to be recognizable from across the room at low volume, and it is.

#!/usr/bin/env python3
"""Synthesize short piano-style motifs as 16-bit mono WAV files.

    python3 synth.py [output_dir]

Writes beethoven5.wav (G G G Eb, "fate knocking at the door") and
dsch.wav (D Eb C B, Shostakovich's musical signature). Standard library only.
"""
import math, struct, sys, wave, os

RATE = 44100

# Equal-tempered pitch from a note name like "Eb4".
NAMES = {"C": 0, "C#": 1, "Db": 1, "D": 2, "D#": 3, "Eb": 3, "E": 4, "F": 5,
         "F#": 6, "Gb": 6, "G": 7, "G#": 8, "Ab": 8, "A": 9, "A#": 10, "Bb": 10, "B": 11}

def freq(name):
    letter, octave = name[:-1], int(name[-1])
    semis = NAMES[letter] + 12 * (octave + 1) - 69      # A4 = midi 69
    return 440.0 * 2 ** (semis / 12)

def piano(f, seconds, amp=0.6):
    """A plucked/struck tone: a stack of decaying harmonics with a 5 ms attack.
    Higher partials decay faster, which is most of what makes it read as a piano
    rather than an organ."""
    n = int(RATE * seconds)
    partials = [(1, 1.00), (2, 0.50), (3, 0.25), (4, 0.12), (5, 0.06), (6, 0.03)]
    out = []
    for i in range(n):
        t = i / RATE
        env = min(1.0, t / 0.005) * math.exp(-3.0 * t)
        s = 0.0
        for k, a in partials:
            s += a * math.exp(-0.4 * (k - 1) * t) * math.sin(2 * math.pi * f * k * t)
        out.append(amp * env * s)
    return out

def render(notes, gap=0.02):
    """notes: list of (name, seconds). Each note rings into the next (no hard cut),
    which is what a sustain pedal does."""
    buf = []
    pos = 0
    for name, dur in notes:
        tone = piano(freq(name), dur + 1.5)          # let every note ring 1.5 s past its slot
        need = pos + len(tone)
        if len(buf) < need:
            buf.extend([0.0] * (need - len(buf)))
        for i, v in enumerate(tone):
            buf[pos + i] += v
        pos += int(RATE * (dur + gap))
    peak = max(abs(v) for v in buf) or 1.0
    return [v / peak * 0.9 for v in buf]

def write(path, samples):
    with wave.open(path, "wb") as w:
        w.setnchannels(1); w.setsampwidth(2); w.setframerate(RATE)
        w.writeframes(struct.pack("<%dh" % len(samples), *(int(v * 32767) for v in samples)))
    print(f"wrote {path} ({len(samples) / RATE:.2f} s)")

MOTIFS = {
    # Three short Gs and a held E-flat, as the strings play it.
    "beethoven5": [("G4", 0.16), ("G4", 0.16), ("G4", 0.16), ("Eb4", 1.4)],
    # D, Es, C, H in the German spelling: Shostakovich's initials.
    "dsch":       [("D4", 0.32), ("Eb4", 0.32), ("C4", 0.32), ("B3", 1.0)],
}

if __name__ == "__main__":
    out = sys.argv[1] if len(sys.argv) > 1 else os.path.dirname(os.path.abspath(__file__))
    for name, notes in MOTIFS.items():
        write(os.path.join(out, name + ".wav"), render(notes))

Run it once on each laptop and it drops two wav files, each about three and a half seconds long, next to itself in ~/.claude/sounds. Each machine only plays one of them, but the script generates both because it is the same script on both machines and I did not want two copies drifting apart. Want a different tune? Add a line to MOTIFS with note names and durations. That is the whole interface.

Why two sounds, and why Shostakovich

Two laptops, two Claude accounts, two things that can be waiting on me at any moment. If both played the same four notes I would hear the chime and still have to look at two screens to find out which one wanted me, which is most of the problem I was trying to solve. So each machine needed its own voice, and I needed to be able to tell them apart without thinking about it. That was the definition of done from the first prompt, not something I arrived at later.

The Mac got Beethoven because the Fifth is the most recognizable four notes ever written and “fate knocking at the door” is exactly the right energy for “your AI pair is finished and would like your attention now.” The Windows box got Shostakovich, and there was never any question about that one. Shostakovich is my favorite composer, and the reason is not really the music, or not only the music. He was a survivor and a fighter. He lived his whole working life under the tyranny of Stalin. He was denounced in Pravda in 1936, denounced again in 1948, and watched friends and colleagues disappear. The bureaucrats who ran Soviet culture wanted him humbled, and they had the power to do it.

What he did instead was play their game, and quietly beat them at it. When they demanded a repentant, optimistic symphony, he gave them the Fifth, let somebody call it “a Soviet artist’s creative response to just criticism,” and wrote a finale so overbearing in its triumph that the people who needed to hear it as triumph did, and everybody else heard what he meant. He sat through the meetings. He signed what he had to sign. He said the right things in public and put the truth in the notes, where the apparatchiks could not read it, and he outlived Stalin by more than twenty years. I find that enormously admirable. There is more than one way to fight.

The clearest of the things he hid is his own signature. D, Es, C, H in the German note names, which is D, E-flat, C, B natural, for D. Sch(ostakowitsch). He wrote his name into his work over and over, right under their noses. It opens the Eighth String Quartet and runs through all five movements. It gets hammered out in the third movement of the Tenth Symphony, which he finished the year Stalin died. It turns up in the first violin concerto and the first cello concerto. It is four notes that say “I am still here, and I am still me,” written by a man who was supposed to have been crushed. Once you know it is there you hear it everywhere, and I have been hearing it everywhere for years. Having those four notes come out of my laptop when something I built needs me is, frankly, a small joy.

It also happens to be a very good doorbell. Four notes, a little restless, and unmistakable next to the Beethoven. Beethoven means the Mac wants me, Shostakovich means the Windows box does. I can tell them apart from the next room, and I do not mind hearing either one.

The prompts

People ask what I actually typed, so here they are. The original sessions have long since scrolled off both machines, so these are reconstructed from memory and from what ended up on disk, but they are close. Notice there is nothing clever in them. The value was in being specific about the constraints: no downloaded audio, keep it quiet, cover both the done case and the permission case, and later, the minimum duration.

On the Mac:

I want Claude Code to play a sound when it’s done with a turn and waiting on me. Use the hooks system in ~/.claude/settings.json. For the sound, don’t download anything. Write a Python script that synthesizes the opening four-note motif of Beethoven’s Fifth as a piano sound and saves it under ~/.claude/sounds. Play it with afplay at a low volume. It should also fire when you’re blocked on a permission prompt, not just when you finish.

That produced the first version, the one that chimed on everything. The async flags and the low volume were already there. After a short and noisy afternoon, the fix:

This is way too repetitive. It’s playing on every single turn, including one-word answers. Only play the sound if the turn took a while, at least 30 seconds. Short back-and-forth should stay silent. I usually have several sessions open at once, so make sure a quick turn in one session doesn’t reset the clock for a long turn in another.

That is where the stamp file and the session id trick came from. I asked for the behavior, and the mechanism was Claude’s. Then on the Windows laptop, with its own Claude account that had never seen any of this:

What is Shostakovich’s famous motive?

followed by

Set up the same thing on this machine: a hook that plays a sound when you finish a turn or need a permission, but only if the turn ran 30 seconds or more, keyed by session id. This is Windows, so PowerShell instead of bash and no afplay. For the sound, synthesize DSCH as a piano with a Python script, standard library only, saved under ~/.claude/sounds. Have the script generate the Beethoven motif too so I can keep one copy of it on both laptops.

That is it. Three prompts and a question, split across two machines.

What bit me

  • Repetition is the enemy. Whatever sound you pick, you will hear it hundreds of times a week if you do not gate it. Put the minimum duration in from the start. If you share an office, this is the difference between a fun hack and a nickname.
  • Two accounts means two of everything. Nothing syncs between the Mac and the Windows laptop. Settings, hooks, sounds, the fix for the repetition problem, all of it had to be done twice. The one thing I kept identical is the Python synthesizer, which is why it generates both motifs.
  • The player is the only platform-specific line. afplay on the Mac, System.Media.SoundPlayer on Windows, and paplay or aplay if you are on Linux. Everything else is the same idea in whichever shell you have.
  • The session id arrives on stdin, not as an argument. I would have gone looking for an environment variable. If your hook seems to be running but the stamp file is always named default, that is why.
  • Volume is not a detail. The clip plays at 0.025 on afplay’s scale, which is a nudge rather than an alarm. Nobody at the next desk should be able to hum it.
  • Hooks run from your settings file, and Claude edits your settings file. Make sure you know which one it changed. There is a user-level file and a project-level file, and I wanted this at the user level so it follows me into every repo.

Conclusion

I did not expect this to change how I work, and it did. I used to sit and watch. Now I hand Claude something long, turn to the other laptop or to whatever else needs doing, and forget about it until four notes tell me to come back. Over a day that is a lot of minutes I used to lose to staring at a spinner, or worse, to not staring at it while it sat there for ten minutes waiting on me. And because each machine has its own composer, I know which way to turn before I have even looked up. It sounds trivial. It is not. It is the difference between managing two assistants and being interrupted by them.

What I find genuinely interesting about it is how little of it I did. I described what I wanted, including the music, and the tool I was configuring built its own doorbell. It wrote the notes. It figured out how to tell one session from another. When I complained that it was too noisy, it fixed that too. There is something a little recursive and a little delightful about asking an AI to compose Beethoven so it can let you know it is finished, and then having it do exactly that.

And I like that the sounds mean something. The Fifth is fate knocking at the door, which is about right for a machine that has finished your work and is now waiting for you to deal with it. DSCH is a man who lived under Stalin, played the bureaucrats’ game, and wrote his own name into his music where they could not hear it. Most of the time it is just a chime. But every once in a while I catch those four notes coming out of a laptop and think about who wrote them and what it cost him, and that is a better thing to have in my day than a beep.

If you build your own, pick music you actually love. You are going to hear it a lot.