How I Can't Clap Gets Made
I Can't Clap · behind the videos

How the I Can't Clap videos get made

People keep asking how these get made. Claude Code agents write the songs, Suno sings them, a GPU script times every word, and each video is a three.js scene drawn from the audio clock. I pick the premise, leave notes on lines, choose takes, and click Create and Download. Here is the whole chain, using real data from The Steep Part.

Claude Code agentsSunoMe (Chris)
1Write

A writer agent and a judge it can't sway

Each song starts as a one-line premise: an AI or IT ideal against the messy reality. One writer agent reads a shared brief, the two earlier songs, my blog posts, and a Suno style guide, brainstorms about 25 gags, keeps the best dozen, and writes the song. Then it spawns a new judge agent every round. The judge scores the song against a rubric, quotes the weak lines, and says APPROVED or REVISE. A fresh judge each round means no one grades their own homework twice. Approval takes 8.5 or better with no rubric item below 7, in five rounds at most.

The Steep Part · judge rounds 10 0 approve at 8.5 7.18.28.28.6r1r2r3r4 ✓

Round 1 caught rap lines running 17 to 19 syllables and flat last words. By round 4 it passed at 8.6. Then I read it in the songbook app and left 15 line notes, and the writer applied them one by one.

The length check
# Suno sings roughly one line per two bars
seconds = lines × 480 / BPM × k
# k = 1.0 sung, 0.8 patter, 0.5 for rap lines

$ count.py songs/24-the-steep-part.md
110 BPM lines=54 rap=32 words=436
est=2:45 OK  (target ≤ 3:10)

I want songs under 3:30. An early song ran about 650 words and came back 4:07, so every draft now has to print OK before a judge sees it.

Then I read it, edit it, and hear it early

  1. Read it in the songbookA small Bun app shows every song. I tap a line and mark it Love it, Cut or Change, with a note, and give the song a status: Keeper, Needs work or Drop.
  2. Sketch it in SunoBefore many edits, I load a rough draft into Suno just to hear the style. Does the genre land, does the hook stick, can you hear the joke? If not, I change the style or drop the song while it's still cheap.
  3. The writer applies my notesClaude reads my notes file, revises the lyrics line by line, marks each note resolved and replies under it. The open page refreshes by itself.
  4. Repeat until it's a keeperRead, sketch, edit again. Only a keeper gets a final take, timings and a video.
My notes, as the writer sees them
// feedback/24-the-steep-part.json: what the songbook app saves when I tap a line
{
  "status": "rework",                 // keeper | rework | drop
  "pin": "working",
  "note": "Push verse 2 harder",
  "lines": [{
    "line": "So I made my loop look busy. Boss said \"clever\"",
    "kind": "cut",                     // love | cut | change
    "note": "too slow",
    "resolved": false,                 // the writer sets true once it's fixed…
    "reply": ""                        // …and answers here, under my note
  }]
}
An early sketch take
# a rough draft goes to Suno before the polishing rounds
$ bun suno 24-the-steep-part --fill     # fill the form, I click Create and listen
# does the genre land? does the hook stick? is the joke audible at this tempo?
# if not, change the Style or the premise now, while it's still cheap

Hearing it first is the cheapest test. A clever lyric in the wrong genre is still a dud.

The judge prompt and the log it leaves
# research/writer-process.md, step 4: the writer spawns this judge every round
"You are the judge for a comedic Suno song. Read research/brief.md
 (the 'Judge rubric' section is your job), skim research/suno-guide.md
 sections 1-3, and read the song at <song path>. Run count.py <song path>.
 Score each rubric item 1-10, then give a verdict: APPROVED or REVISE,
 with up to 6 prioritized, concrete fixes that quote weak lines. Be tough;
 earlier rounds' reviews are at <prior review paths>, so check whether
 those fixes landed. Write your review to reviews/<slug>.r<round>.md, and
 end with 'VERDICT: APPROVED (score X)' or 'VERDICT: REVISE (score X)'.
 Do not edit the song."

# songs/24-the-steep-part.md, the Judge log the writer keeps
- Round 1: REVISE (7.1) — V2 rap lines ran 17-19 syllables, weak last words…
- Round 4: APPROVED (score 8.6/10) — Listen-for on Suno: if V2 rushes,
  drop the (Approved?) ad-lib first; check "Q3" and "S-O-C two" pronunciation

The writer may push back on a fix that would hurt a joke, but it has to say why in the log.

I burst into the standup: "Guys, the singularity's here!"
Somebody said, "Great. File a ticket. We'll talk next year."

2Sing

Suno sings it, I pick the take

Each song file carries a Style prompt, an Exclude list, and lyrics with section tags like [Verse 1: rap, male] or [Bridge: spoken, male, sparse]. A script drives my own logged-in Chrome over the DevTools protocol, fills Suno's Advanced form, clicks Create, and records both clips it returns. I listen, pick the take I like, and download the MP3 myself.

What Suno gets for The Steep Part
Style   aggressive hardcore pop-rap anthem, 110 BPM, booming hard-hitting drums
        and heavy 808s, dark minor piano riff, gritty distorted synth stabs,
        intense rapid-fire snarling male rap with clear diction, soaring
        haunting female pop hook, stadium build-ups, punchy loud mix
Exclude country, comedic voices, cheerful, autotune, guitar solo,
        long instrumental, sung verses, lo-fi, mellow, jazz

Exclude matters as much as Style. "comedic voices" keeps Suno from singing the jokes in a goofy voice, which kills them.

Filling Suno, then checking it took
// src/suno.ts: drive my logged-in Chrome over the DevTools protocol
async function fillForm(tab: Tab, song: Song) {
  const { title, style, exclude, lyrics } = song.final;
  await tab.eval(() => [...document.querySelectorAll('[role="tab"]')]
    .find(t => t.textContent?.trim() === "Advanced")?.click());
  await tab.waitFor('[aria-label="Lyrics editor"]');
  // The lyrics box is a Lexical editor; one paragraph per line is exactly what typing produces.
  await tab.eval(lyrics => { /* set the editor state, one paragraph per line */ }, lyrics);
  await tab.typeInto('textarea:not([aria-label]):not([placeholder^="Describe"])', style);
  await tab.typeInto('input[placeholder="Exclude styles"]', exclude);
  // read the form back: a silent paste failure would waste a generation
  const want = lyrics.split("\n").filter(l => l.trim()).length;
  const got = shown.lyrics.split("\n").filter(l => l.trim()).length;
  if (want !== got) throw new Error(`Lyrics box shows ${got} lines, expected ${want}. Check the form in Chrome.`);
}

It reads the form back before clicking Create, because a paste that silently fails still spends a generation.

3Time

Find every word to the millisecond

The video has to know exactly when each word lands. faster-whisper transcribes the MP3, but under a loud mix it mishears and its stamps drift. Since we already know the lyrics, the script aligns whisper's words to the real ones. Then demucs pulls out the vocal stem and a forced aligner (torchaudio's MMS) places each line's known words on it, searching only near where whisper put the line. A last pass measures loudness, bass and the beat grid at 30 frames a second.

The first four lines · what whisper heard, struck through
loudnesswhispermisheardaligned

Whisper missed "Three" entirely and heard "Prius" as "priest" and "gain" as "game". Aligning to the known lyrics fixes the words; the forced aligner fixes the times.

Step 1 · align to the known lyrics
// src/align.ts: line up what whisper heard with the lyrics we already know.
// A Needleman-Wunsch alignment: exact match 3, close spelling 2, anything else -1.
const score = (a: string, b: string) =>
  a === b ? 3 : a.length > 3 && lev(a, b) <= Math.floor(a.length / 4) ? 2 : -1;

for (let i = 1; i <= n; i++)
  for (let j = 1; j <= m; j++) {
    const d = S[i-1][j-1] + score(A[i-1], B[j-1]), u = S[i-1][j] + GAP, l = S[i][j-1] + GAP;
    if (d >= u && d >= l) { S[i][j] = d; T[i][j] = 1; } else if (u >= l) { S[i][j] = u; T[i][j] = 2; } else { S[i][j] = l; T[i][j] = 3; }
  }
// walk back: matched words take whisper's times; the rest ("Three") are
// interpolated between their matched neighbours
Step 2 · forced alignment on the vocal stem
# scripts/forced-align.py: tighten each line on the isolated vocal
sep = get_model("htdemucs")                         # 1. demucs pulls the vocal stem
bundle = torchaudio.pipelines.MMS_FA                # 2. CTC emissions, 20 ms frames
for i, line in enumerate(lines):                    # 3. per line, near whisper's guess
    lo, hi = ws[0]["s"] - PAD, ws[-1]["e"] + PAD
    tokens = ["*"] + [spell(w["w"]) for w in ws] + ["*"]   # "30" -> "three zero"
    spans = aligner(em[a:b], tokenizer(tokens))[1:-1]
    score = float(np.mean([s.score for sp in spans for s in sp]))
    if score < 0.2:                                 # shouted or vocoded: keep whisper
        kept.append(ws); continue
    for w, sp in zip(ws, spans):
        w["s"], w["e"] = (a + sp[0].start) / fps, (a + sp[-1].end) / fps
# whisper runs early by a fairly constant amount, so nudge the lines we couldn't refine
4Film

The video is a function of time

Nothing in the video is animated in the usual sense. Every object, light, caption and overlay is computed from t, the current audio time, plus the word timings and the bass and loudness at that frame. Any frame can be drawn on its own, so you can scrub, jump with ?t=12, or have a headless browser render frame 4,512 without playing the first 4,511. Below is a toy version drawn from the real Steep Part data, with the real audio from the opening through "Ten years out" and "Linear brain on an exponential plane". Press play with sound, or drag the slider.

t = 8.00 s
Overlays are timed in words, not seconds
<!-- video/24-the-steep-part/song.ts -->
<div data-show="L1+0.1|L2-0.1">
  autopilot · hands off
  <big data-count="0,30"
       data-at="L1+0.1" data-until="E1"
       data-fmt="clock">00:00</big>
</div>

L(1) is when line 1 starts, E(1) when it ends, and W(5, /thrust/) is the moment "thrust" is sung. Retime the song and every overlay follows.

The music reaches into the scene
// video/tl.ts
bass(t)   // smoothed bass energy, 0..1
rms(t)    // loudness at this frame
beat(t)   // 1 on the beat, decays to 0
vis(t, a, b)  // fade in at a, out at b
rand(n)   // seeded, so frames repeat

Each song gets its own world: a neon curve for The Steep Part, a watercolor church for Read the Diff, a Broadway stage for Data Here, Data There. A shared film kit adds depth of field, bloom, grain and a letterbox.

A world: shots cut on lyric lines, lit by the bass
// video/24-the-steep-part/world.ts: the shot list is written in lyric lines
const shots = () => [
  { name: "intro",   at: 0,            label: "intro" },
  { name: "prius",   at: L(0) - 0.08,  label: "the prius" },
  { name: "tesla",   at: L(1) - 0.08,  label: "hands off" },
  { name: "engine",  at: L(4) - 0.08,  label: "one shot" },
  { name: "agents",  at: L(10) - 0.08, label: "forty agents" },
  { name: "standup", at: L(14) - 0.08, label: "the standup" },
  // …
];

update(t: number) {
  const shot = shotAt(t), b = bass(t), bt = beat(t);
  this.bloom.strength = 0.85 + b * 0.55;           // the bass blooms the lights
  this.sky.material.uniforms.pulse.value = b;
  switch (shot.name) {
    case "prius": case "tesla": this.roadShot(t, shot, look, b); break;
    case "engine": this.engineShot(t, shot.at, look, b); break;
    case "triple":
      // "Terrified. Thrilled. Tentative.": one cut per word
      if (t < W(51, /^thrilled/i) - 0.05) this.curveShot(t, shot, look, b, bt);
      else if (t < W(51, /^tentative/i) - 0.05) this.engineShot(t, W(51, /^thrilled/i) - 2.5, look, b);
      else this.roomShot(t, shot, look, bt);
  }
}

The camera, sky, bloom and every prop are set from t on each frame. Nothing carries over from the frame before.

5Ship

Check the stills, then build the pages

  1. Render stillsPlaywright and headless Chrome call __renderAt(t) once per shot and tile the frames into a contact sheet.
  2. Flag problemsThe script flags blown-out, too-dark and repeated frames. Claude looks at the sheet for clipped text and overlays covering the scene.
  3. Buildbun catalog writes the album page and one page per video into this blog: the timings inlined, the MP3 and poster beside it, and one shared player bundle.
  4. PublishThe pages ship with the blog. A hash of the code, timings and audio says when a YouTube upload is out of date.

All of them are collected on the I Can't Clap album page on my blog.

The stills check
// src/stills.ts: look at a video without watching it
await page.goto(`http://127.0.0.1:4321/video/${slug}/${clipId}?render`);
for (const [i, p] of picks.entries()) {           // one pick from the middle of every shot
  await page.evaluate(t => window.__renderAt(t), p.t);
  const png = await page.screenshot();
  // measure luminance: >0.97 counts as clipped, <0.02 as crushed,
  // and a tiny diff from the previous frame flags a shot that didn't change
}
One page per video
// src/catalog.ts: one page per video, sharing one player
for (const c of cards) {
  const data = videoData(c.slug, c.clipId, `${BASE}/audio/${c.name}.mp3`);
  copyFileSync(c.audio, join(siteDir, "audio", `${c.name}.mp3`));
  writeFileSync(join(siteDir, `${c.name}.html`), page(`<title>${c.title} · I Can't Clap</title>
${fonts}
<link rel="stylesheet" href="${BASE}/player.css">
${analytics}`, `${stage}
<script>window.__SONG__ = ${JSON.stringify(data)};</script>
<script type="module" src="${BASE}/player.js"></script>`));
}
By the numbers
26songs written
113judge reviews
4videos published
4,513lines of three.js across the four worlds
Claude Code · Opus 5.5SunoBun + TypeScriptfaster-whisperdemucstorchaudio MMS alignerthree.jsPlaywrightuv