How to add a falling notes piano roll with Claude

Yellow and pink bars slide down a dark lane onto a two-octave piano keyboard at the bottom, and each key turns the bar's color and plays its note the moment the bar lands on a drum beat.

Get this effect

  1. Switch to Code in your Claude Desktop App

  2. Install the skill (once)

    Show the prompt
    Install the Recharm video effects skill: download https://recharm-growth-staging.s3.us-east-2.amazonaws.com/website/downloads/recharm-video-effects.zip and unzip it into ~/.claude/skills
  3. Ask for the effect

    Add a falling notes piano roll to input.mp4
    Melody: 8 notes, B4 to G5

Several videos, several effects, one ask

Put your clips in the folder, mention them with @, and ask for everything at once. Claude runs one tested script after another on each clip, then joins them.

Add a falling notes piano roll to @clip1.mp4, put a sale badge on @clip2.mp4, give @clip3.mp4 a VHS look, then join all three with crossfades.

Settings you can change

Ask Claude to change any of these, or edit them at the top of the script.

SettingDefaultWhat it does
MELODY8 notes, B4 to G5Right-hand notes as (beat number, MIDI note, length in beats).
BASSC4, G4Left-hand notes, one per bar.
LOWEST60MIDI number of the leftmost key (60 = middle C).
OCTAVES2Keyboard length in octaves.
KEYS_TOP0.87Top of the keyboard, where notes land, 0-1 down the frame.
LANE_TOP0.42Where falling notes first appear, 0-1 down the frame.
FALL_TIME1.2Seconds a note takes to fall onto the keys.
MELODY_COLOR, BASS_COLOR(255, 196, 40), (255, 70, 150)Right-hand and left-hand note colors.
SHADE0.75Opacity of the dark fade behind the falling notes.
PIANO_VOLUME0.35Loudness of the piano notes mixed over the soundtrack (0 = silent).

Without the skill

Don’t want to install the skill? Paste this prompt into Claude Code and Claude writes the script itself.

Show the prompt
Add a falling-notes piano roll (the Synthesia look) to input.mp4 (540x960, 30fps, 6 seconds, a 120 BPM drum loop), with notes landing on the beats listed in beats.json ({"beats": [0.0, 0.5, 1.0, ...]}; beat number n is the n-th time in that list, counting from 0). Along the bottom draw a two-octave piano keyboard, C4 (MIDI 60) to B5 (MIDI 83): 14 white keys spanning from 3% to 97% across the frame, their tops 87% of the way down (y=835) and 10% of the frame height long (96 pixels), pure white (255, 255, 255) with a 1-pixel dark gray (40, 40, 45) outline and 3-pixel rounded corners; black keys (25, 25, 30) 60% as wide as a white key and 62% as long, centered on the line between their two white keys. Draw a 4-pixel deep red (150, 20, 35) strip along the top edge of the keyboard. Behind the notes, darken the frame from 37% of the way down to the bottom with a vertical fade from transparent to (8, 8, 14) at 75% opacity, reaching full strength about 60% of the way down that fade. The notes, as (beat number, MIDI note, length in beats): right hand (3, 72, 1), (4, 76, 1), (5, 79, 1), (6, 78, 1), (7, 74, 1), (8, 71, 1), (9, 75, 1), (10, 79, 2) in amber yellow (255, 196, 40); left hand (4, 60, 4), (8, 67, 4) in pink (255, 70, 150). Each note sounds for 92% of its length (one beat = 0.5 s, so a 1-beat note lasts 0.46 s). Notes on black keys use their color at 80% brightness. Draw each note as a rounded rectangle (5-pixel corner radius) as wide as its key minus 2 pixels on each side, falling straight down at a constant speed: it first appears at 42% down the frame (y=403) 1.2 seconds before it plays, its bottom edge reaches the top of the keyboard exactly when the note starts, and its length in pixels is its duration times that speed (360 pixels per second). Nothing is drawn above y=403: clip the bars there. While a note is playing its bar sits on the keyboard and shrinks from the top until it ends, and its key is filled with the note's color instead of white or black. Add a 10-pixel blurred glow behind the bars. Before 0.3 seconds no note is on screen, and leave the top 36% of the frame untouched. Also mix a simple piano tone for each note into the soundtrack (harmonics 1 to 4 at amplitudes 1/k^2, a 5 ms attack, exponential decay exp(-3t), and a 0.3 second release after the note ends), scaled so its peak is 0.35, without lowering the drums. Use Python with numpy and Pillow to draw the frames (don't use ffmpeg's drawtext filter), composite them with ffmpeg, keep the size, 30fps, 6 second length and the original audio, and save it as output.mp4.

Run it without Claude

This is the exact script we tested. Save it as falling-notes-piano.py and run:

python3 falling-notes-piano.py input.mp4 beats.json output.mp4
Show the script
#!/usr/bin/env python3
"""Falling notes piano: colored bars drop onto a two-octave keyboard and light each key as its note plays on the beat.

Usage: python3 falling-notes-piano.py input.mp4 beats.json output.mp4
beats.json holds the music's beat times in seconds: {"beats": [0.0, 0.5, 1.0, ...]}. The notes below are placed
on those beats; for a real song, fill MELODY and BASS from its MIDI file instead.
Needs ffmpeg, numpy and Pillow (pip install numpy pillow).
"""
import json
import math
import subprocess
import sys
import tempfile
import wave

import numpy as np
from PIL import Image, ImageDraw, ImageFilter

MELODY = [(3, 72, 1), (4, 76, 1), (5, 79, 1), (6, 78, 1),    # (beat number, MIDI note, length in beats):
          (7, 74, 1), (8, 71, 1), (9, 75, 1), (10, 79, 2)]   # the right hand, B4 to G5
BASS = [(4, 60, 4), (8, 67, 4)]                              # the left hand, C4 then G4, one per bar
LOWEST = 60                  # MIDI number of the leftmost key (60 = middle C)
OCTAVES = 2                  # keyboard length, C4 to B5
LEFT, RIGHT = 0.03, 0.97     # where the keyboard starts and ends (0-1 across the frame)
KEYS_TOP = 0.87              # top of the keyboard, where notes land (0-1 down the frame)
KEYS_HEIGHT = 0.10           # white key length as a share of the frame height
LANE_TOP = 0.42              # where falling notes appear (0-1 down the frame)
FALL_TIME = 1.2              # seconds a note takes from LANE_TOP to the keys
MELODY_COLOR = (255, 196, 40)  # right-hand notes, amber yellow (a little darker on black keys)
BASS_COLOR = (255, 70, 150)    # left-hand notes, pink
SHADE = 0.75                 # opacity of the dark fade behind the falling notes
GLOW = 10                    # glow blur radius in pixels
PIANO_VOLUME = 0.35          # loudness of the piano notes mixed over the soundtrack (0 = silent)
RATE = 48000                 # audio sample rate for the piano notes

BLACK = {1, 3, 6, 8, 10}     # pitch classes of the black keys (C = 0)


def timed_notes(beats):
    """(start, end, MIDI note, color) in seconds for every note, from beat numbers."""
    step = beats[1] - beats[0]
    notes = []
    for part, color in ((MELODY, MELODY_COLOR), (BASS, BASS_COLOR)):
        for beat, note, length in part:
            shade = tuple(round(c * 0.8) for c in color) if note % 12 in BLACK else color
            notes.append((beats[beat], beats[beat] + length * step * 0.92, note, shade))
    return notes


def keyboard_layout():
    """{MIDI note: (x center, width)} in pixels for every key."""
    white_w = (RIGHT - LEFT) * w / (OCTAVES * 7)
    keys, whites = {}, 0
    for note in range(LOWEST, LOWEST + OCTAVES * 12):
        if note % 12 in BLACK:
            keys[note] = (LEFT * w + whites * white_w, white_w * 0.6)
        else:
            keys[note] = (LEFT * w + (whites + 0.5) * white_w, white_w)
            whites += 1
    return keys


def background():
    """The dark fade the notes fall through."""
    img = Image.new("RGBA", (w, h))
    top = round((LANE_TOP - 0.05) * h)
    fade = np.clip(np.linspace(0, SHADE * 255 * 1.6, h - top), 0, SHADE * 255).astype(np.uint8)
    img.paste(Image.new("RGBA", (w, h - top), (8, 8, 14, 255)), (0, top), Image.fromarray(np.repeat(fade[:, None], w, axis=1)))
    return img


def overlay(t):
    speed = (KEYS_TOP - LANE_TOP) * h / FALL_TIME  # pixels per second
    land = KEYS_TOP * h
    bars = Image.new("RGBA", (w, h))
    draw = ImageDraw.Draw(bars)
    playing = {}
    for start, end, note, color in notes:
        bottom = min(land, land - (start - t) * speed)
        top = land - (end - t) * speed
        if bottom > top and bottom > LANE_TOP * h:
            x, key_w = keys[note]
            draw.rounded_rectangle([x - key_w / 2 + 2, max(top, LANE_TOP * h), x + key_w / 2 - 2, bottom],
                                   radius=min(5, key_w / 4), fill=color)
        if start <= t < end:
            playing[note] = color
    img = Image.alpha_composite(lane, bars.filter(ImageFilter.GaussianBlur(GLOW)))
    img = Image.alpha_composite(img, bars)
    draw = ImageDraw.Draw(img)
    white_bottom = (KEYS_TOP + KEYS_HEIGHT) * h
    for black in (False, True):  # white keys first, black keys on top
        for note, (x, key_w) in keys.items():
            if (note % 12 in BLACK) != black:
                continue
            bottom = land + (white_bottom - land) * (0.62 if black else 1)
            fill = playing.get(note, (25, 25, 30) if black else (255, 255, 255))
            draw.rounded_rectangle([x - key_w / 2 + 1, land, x + key_w / 2 - 1, bottom], radius=3, fill=fill,
                                   outline=(40, 40, 45), width=1)
    draw.rectangle([LEFT * w, land - 4, RIGHT * w, land], fill=(150, 20, 35))  # red felt strip above the keys
    return img


def piano(path, duration):
    """Write a simple piano sound for every note: a few harmonics that ring and fade."""
    audio = np.zeros(round((duration + 1) * RATE))
    for start, end, note, _ in notes:
        freq = 440 * 2 ** ((note - 69) / 12)
        t = np.arange(round((end - start + 0.3) * RATE)) / RATE
        tone = sum(np.sin(2 * np.pi * freq * k * t) / k ** 2 for k in (1, 2, 3, 4))
        envelope = np.minimum(t / 0.005, 1) * np.exp(-3 * t) * np.clip((end - start + 0.3 - t) / 0.3, 0, 1)
        i = round(start * RATE)
        audio[i:i + len(t)] += tone * envelope
    audio = audio / max(np.abs(audio).max(), 1e-9) * PIANO_VOLUME
    with wave.open(path, "wb") as f:
        f.setnchannels(1)
        f.setsampwidth(2)
        f.setframerate(RATE)
        f.writeframes((audio * 32767).astype(np.int16).tobytes())


def probe(path):
    info = json.loads(subprocess.check_output(["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
                                               "stream=width,height,r_frame_rate:format=duration", "-of", "json", path]))
    s = info["streams"][0]
    num, den = s["r_frame_rate"].split("/")
    return s["width"], s["height"], s["r_frame_rate"], float(num) / float(den), float(info["format"]["duration"])


def render(src, out):
    """Draw overlay(t) on every frame of src and mix the piano into its soundtrack."""
    with tempfile.NamedTemporaryFile(suffix=".wav") as piano_wav:
        piano(piano_wav.name, duration)
        ff = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-i", src, "-i", piano_wav.name,
                               "-f", "rawvideo", "-pix_fmt", "rgba", "-s", f"{w}x{h}", "-r", rate, "-i", "-",
                               "-filter_complex", "[0:v][2:v]overlay=shortest=1,format=yuv420p[v];"
                                                  "[0:a][1:a]amix=inputs=2:duration=first:normalize=0[a]",
                               "-map", "[v]", "-map", "[a]", "-c:v", "libx264", "-crf", "18", "-c:a", "aac", out],
                              stdin=subprocess.PIPE)
        for i in range(math.ceil(duration * fps)):
            ff.stdin.write(overlay(i / fps).tobytes())
        ff.stdin.close()
        if ff.wait():
            sys.exit("ffmpeg failed")


src, beats_file, out = sys.argv[1:4]
w, h, rate, fps, duration = probe(src)
with open(beats_file) as f:
    notes = timed_notes(json.load(f)["beats"])
keys = keyboard_layout()
lane = background()
render(src, out)

Find 3x winning ads

Recharm cuts your ad footage into tagged, searchable clips: hooks, testimonials, product shots. Find the shots that win, add the effect with Claude, and ship 3x more ad edits.

Start 14-day free trial