AI·

How the “I Can't Clap” Videos 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. The whole chain, with real data from The Steep Part.
From the video for The Steep Part: a wireframe Prius drifts across a neon highway under the caption "Three years back my Prius barely held its lane"

People keep asking how the videos for my album I Can't Clap 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.

It started with my kids. After hearing a few AI-generated songs that weren't half bad, I began making spelling-word and reading songs for them while I played with Farwonder, the app I'm building for them. The ones I post here are the grown-up side of that: humorous songs about working with AI.

Watch The Steep Part → It's the one this post takes apart: the lyrics, the judge rounds, the word timings and the video are all its real files.

1. Write: 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.

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.

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:

# 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)

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

  1. Read it in the songbook. A 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 Suno. Before 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 notes. Claude 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 keeper. Read, 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. Hearing it first is the cheapest test; a clever lyric in the wrong genre is still a dud.

# 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

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

# 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

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

2. Sing: 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.

The script reads the form back before clicking Create, because a paste that silently fails still spends a generation:

// 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.`);
}

3. Time: 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.

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

4. Film: 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.

Press play for sound, or drag the slider. The toy frame is drawn from the real Steep Part data, with the real audio from the opening through "Linear brain on an exponential plane".

Overlays are timed in words, not seconds. 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.

<!-- 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>

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's shots cut on lyric lines and are lit by the bass. The camera, sky, bloom and every prop are set from t on each frame; nothing carries over from the frame before.

// 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);
  }
}

5. Ship: check the stills, then build the pages

  1. Render stills. Playwright and headless Chrome call __renderAt(t) once per shot and tile the frames into a contact sheet.
  2. Flag problems. The script flags blown-out, too-dark and repeated frames. Claude looks at the sheet for clipped text and overlays covering the scene.
  3. Build. bun catalog writes the album page and one page per video into this blog: the song's timings inlined, its MP3 and poster beside it, and one shared player bundle for every video.
  4. Publish. The pages ship with the blog. A hash of the code, timings and audio says when a YouTube upload is out of date.
// 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
}
// 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>`));
}

Why they live here

Every video, and the first version of this post, started as a Claude artifact: one self-contained HTML file and a link I could share. But an artifact gives you no metrics. I couldn't tell how many people opened one, or whether anyone watched past the first chorus.

So they live on this blog now. The pages count visits and send a few events: when a video starts, when it reaches 25, 50 and 75%, and when it finishes. Same player, just hosted where I can see whether anyone's listening.

The videos

By the numbers

  • 26 songs written
  • 113 judge reviews
  • 5 videos published
  • 5,458 lines of three.js across the five worlds

Built with Claude Code and Opus 5.5, Suno, Bun and TypeScript, faster-whisper, demucs, the torchaudio MMS aligner, three.js, Playwright and uv. The timings, loudness and judge scores in this post are the real files from The Steep Part.