How to add a piano-note spectrum with Claude

A five-octave piano keyboard sits along the bottom with a rainbow bar rising above every key, so the kick drum lifts the bass notes on the left while the snare lights up the whole keyboard in color.

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 piano-note spectrum to input.mp4
    MIDI number of the leftmost key (36 = C2): 36
    Keyboard length in octaves: 5

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 piano-note spectrum 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
LOWEST36MIDI number of the leftmost key (36 = C2).
OCTAVES5Keyboard length in octaves.
KEYS_TOP0.875Top of the keyboard, 0-1 down the frame.
MAX_HEIGHT0.18Tallest bar as a share of the frame height.
LIT0.75Bar height (0-1) at which its key lights up in the note's color.
SHADE0.65Opacity of the dark fade behind the bars and keys.
Q17Analysis sharpness: about one semitone per bar. Higher separates notes better but reacts more slowly in the bass.
RANGE30Decibels between an empty bar and a full one.
FALL0.80Share of its height a bar keeps each frame after its note stops.

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 piano-note spectrum to input.mp4 (540x960, it has a drum track), driven by its own audio. Along the bottom draw a five-octave piano keyboard, C2 (MIDI 36) to B6 (MIDI 95), 60 keys: 35 white keys spanning from 4% to 96% across the frame, their tops 87.5% of the way down (y=840) and 7% of the frame height long (67 pixels), pure white (255, 255, 255) with a 1-pixel dark gray (40, 40, 45) outline and 2-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. Above every key draw one bar for that note, centered on the key, 40% of the key's width (at least 3 pixels), with fully rounded ends, standing 4 pixels above the keyboard and at most 18% of the frame height tall, with an 8-pixel blurred glow behind the bars. Color each note by its pitch class: HSV hue = (pitch class / 12) x 0.9 with C = 0, saturation 0.85, value 1, so every C is red (255, 38, 38) and every G cyan (38, 222, 255). When a bar is at 75% of its full height or more, fill its key with the note's color instead of white or black. Behind it all, darken the bottom 40% of the frame with a vertical fade from transparent to black at 65% opacity at the bottom edge. Bar heights come from a constant-Q analysis of the audio at 48 kHz mono: for each note at frequency 440 x 2^((MIDI - 69) / 12), and for each video frame, take a Hann window 17 x 48000 / frequency samples long centered on that frame's time, multiply it by a complex sine at the note's frequency, sum and divide by the window length, and convert the magnitude to decibels. Scale each note so its own 99th-percentile level over the clip is full height and 30 dB below that is empty; bars rise instantly and fall back by keeping 80% of their height each frame, and an empty bar still shows as a small dot. Leave the top half of the frame untouched. Use Python with numpy for the analysis and Pillow to draw the keyboard and bars (don't use ffmpeg's drawtext filter), composite them with ffmpeg, keep the size, 30fps, 6 second length and the audio, and save it as output.mp4.

Run it without Claude

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

python3 piano-note-spectrum.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Piano-note spectrum: one color bar per musical note rises above a five-octave piano keyboard, and keys light up as their notes sound.

Usage: python3 piano-note-spectrum.py input.mp4 output.mp4
Needs ffmpeg, numpy and Pillow (pip install numpy pillow).
"""
import colorsys
import json
import math
import subprocess
import sys

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

LOWEST = 36                  # MIDI number of the leftmost key (36 = C2, 65 Hz)
OCTAVES = 5                  # keyboard length, C2 to B6
LEFT, RIGHT = 0.04, 0.96     # where the keyboard starts and ends (0-1 across the frame)
KEYS_TOP = 0.875             # top of the keyboard (0-1 down the frame)
KEYS_HEIGHT = 0.07           # white key length as a share of the frame height
MAX_HEIGHT = 0.18            # tallest bar as a share of the frame height
LIT = 0.75                   # bar height (0-1) at which its key lights up in the note's color
SHADE = 0.65                 # opacity of the dark fade behind the bars and keys
GLOW = 8                     # glow blur radius in pixels
Q = 17                       # analysis sharpness: about one semitone per bar
RANGE = 30                   # decibels between an empty bar and a full one
FALL = 0.80                  # share of its height a bar keeps each frame when the note stops
RATE = 48000                 # audio sample rate used for the analysis

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


def note_color(note):
    """A rainbow around the octave: every C is red, every F# cyan."""
    r, g, b = colorsys.hsv_to_rgb((note % 12) / 12 * 0.9, 0.85, 1.0)
    return round(r * 255), round(g * 255), round(b * 255)


def levels(src):
    """Height (0-1) of every note's bar on every frame, from a constant-Q transform of the audio."""
    pcm = subprocess.check_output(["ffmpeg", "-v", "error", "-i", src, "-vn", "-ac", "1", "-ar", str(RATE),
                                   "-f", "f32le", "-"])
    audio = np.frombuffer(pcm, np.float32)
    centers = np.round(np.arange(frames) / fps * RATE).astype(int)
    db = []
    for note in notes:
        freq = 440 * 2 ** ((note - 69) / 12)
        size = round(Q * RATE / freq)  # low notes need a longer window to tell neighbours apart
        kernel = np.hanning(size) * np.exp(-2j * np.pi * freq * np.arange(size) / RATE) / size
        padded = np.concatenate([np.zeros(size), audio, np.zeros(size)])
        windows = np.lib.stride_tricks.sliding_window_view(padded, size)[centers + size - size // 2]
        db.append(20 * np.log10(np.abs(windows @ kernel) + 1e-9))
    db = np.array(db).T
    top = np.percentile(db, 99, axis=0)  # each note scaled to its own loudest moments
    heights = np.clip((db - (top - RANGE)) / RANGE, 0, 1)
    for i in range(1, frames):  # rise at once, fall back gently
        heights[i] = np.maximum(heights[i], heights[i - 1] * FALL)
    return heights


def keyboard_layout():
    """x center and width of each key, in pixels, plus whether it is black."""
    white_w = (RIGHT - LEFT) * w / (OCTAVES * 7)
    keys, whites = [], 0
    for note in notes:
        if note % 12 in BLACK:
            keys.append((LEFT * w + whites * white_w, white_w * 0.6, True))
        else:
            keys.append((LEFT * w + (whites + 0.5) * white_w, white_w, False))
            whites += 1
    return keys


def overlay(heights):
    img = Image.new("RGBA", (w, h))
    shade = np.linspace(0, SHADE * 255, round(h * 0.4)).astype(np.uint8)
    img.paste(Image.new("RGBA", (w, len(shade)), (0, 0, 0, 255)), (0, h - len(shade)),
              Image.fromarray(np.repeat(shade[:, None], w, axis=1)))
    bars = Image.new("RGBA", (w, h))
    draw = ImageDraw.Draw(bars)
    top, white_bottom = KEYS_TOP * h, (KEYS_TOP + KEYS_HEIGHT) * h
    for (x, key_w, _), level, note in zip(keys, heights, notes):
        bar_w = max(3, key_w * 0.4)
        bar_top = top - 4 - max(bar_w, level * MAX_HEIGHT * h)
        draw.rounded_rectangle([x - bar_w / 2, bar_top, x + bar_w / 2, top - 4], radius=bar_w / 2, fill=note_color(note))
    img = Image.alpha_composite(img, bars.filter(ImageFilter.GaussianBlur(GLOW)))
    img = Image.alpha_composite(img, bars)
    draw = ImageDraw.Draw(img)
    for black in (False, True):  # white keys first, black keys on top
        for (x, key_w, is_black), level, note in zip(keys, heights, notes):
            if is_black != black:
                continue
            bottom = top + (white_bottom - top) * (0.62 if black else 1)
            fill = note_color(note) if level >= LIT else ((25, 25, 30) if black else (255, 255, 255))
            draw.rounded_rectangle([x - key_w / 2 + 1, top, x + key_w / 2 - 1, bottom], radius=2, fill=fill,
                                   outline=(40, 40, 45), width=1)
    return img


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, heights):
    """Draw the keyboard and bars on every frame of src and save to out, keeping the audio."""
    ff = subprocess.Popen(["ffmpeg", "-y", "-v", "error", "-i", src,
                           "-f", "rawvideo", "-pix_fmt", "rgba", "-s", f"{w}x{h}", "-r", rate, "-i", "-",
                           "-filter_complex", "[0:v][1:v]overlay=shortest=1,format=yuv420p[v]",
                           "-map", "[v]", "-map", "0:a?", "-c:v", "libx264", "-crf", "18", "-c:a", "copy", out],
                          stdin=subprocess.PIPE)
    for frame_levels in heights:
        ff.stdin.write(overlay(frame_levels).tobytes())
    ff.stdin.close()
    if ff.wait():
        sys.exit("ffmpeg failed")


src, out = sys.argv[1:3]
w, h, rate, fps, duration = probe(src)
frames = math.ceil(duration * fps)
notes = list(range(LOWEST, LOWEST + OCTAVES * 12))
keys = keyboard_layout()
render(src, out, levels(src))

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