How to add a flowing voice wave with Claude

Pink, cyan and violet ribbons weave through each other across a dark band at the bottle's base, swelling tall while the narrator speaks and sinking to a thin line when the voice stops.

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 flowing voice wave to input.mp4
    Ribbons: pink, cyan, violet

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 flowing voice wave 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
START0.2Second the wave fades in.
Y, HEIGHT0.80, 0.11Where the wave's center line sits (0-1 down the frame) and its tallest swell above and below it, as a share of the frame height.
RIBBONSpink, cyan, violetEach ribbon's color, how many waves cross the frame, how fast they drift and how tall it gets compared with the others.
FILL150Brightness (0-255) of each ribbon's fill. 0 leaves just the outlines.
SHADE150Opacity (0-255) of the dark band behind the wave. 0 removes it.
ATTACK, RELEASE0.6, 0.12How fast the wave rises with the voice and settles after it. Higher follows each syllable more closely.

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 flowing voice wave to input.mp4 (540x960, 30fps, with a voiceover), like an assistant's listening animation. From 0.2 seconds, fading in over 0.2 seconds, draw a soft dark band across the full width, centered 80% down the frame and 28.6% of the frame height tall: black at 150/255 opacity in the middle, fading to clear at its top and bottom edges (opacity times 1 - d squared, d going from -1 at the top to 1 at the bottom). On it, centered on the same line, draw three overlapping ribbons in pink (255, 60, 150), cyan (40, 210, 255) and violet (140, 90, 255). With u running from -1 at the left edge to 1 at the right, each ribbon's half-height is A * size * (1 - u^2)^2 * |sin(u * waves * pi + speed * t)|, mirrored above and below the center line, filled at 150/255 of full color with a 2 pixel full-color edge line on top and bottom, plus a glow of the same shape blurred with an 8 pixel Gaussian at 80% strength. Pink: waves 1.5, speed 4 radians per second, size 1.0. Cyan: waves 2.2, speed -5, size 0.8. Violet: waves 3.0, speed 6.5, size 0.6. Add the ribbons' light together (where they overlap, sum their colors; use the strongest channel as the opacity and scale the color to full strength). A is 11% of the frame height times the voice level: take the RMS of each frame's 1/30 second of audio, divide by the loudest frame's RMS, take the square root, then smooth it frame by frame, closing 60% of the gap when it rises and 12% when it falls; never let the level drop below 0.04, so a thin line stays during silence. Nothing is drawn before 0.2 seconds or above 66% down the frame. Use Python with numpy and Pillow to read the audio and draw it and ffmpeg to put it on the video (don't use ffmpeg's drawtext filter), keep the audio, and save it as output.mp4.

Run it without Claude

This is the exact script we tested. Save it as flowing-voice-wave.py and run:

python3 flowing-voice-wave.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Flowing voice wave: pink, cyan and violet sine ribbons overlap low in the frame and swell as the voice gets louder.

Usage: python3 flowing-voice-wave.py input.mp4 output.mp4  (the input needs a voice track)
Needs ffmpeg, Pillow and numpy (pip install pillow numpy).
"""
import json
import math
import subprocess
import sys

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

START = 0.2               # second the wave fades in
FADE = 0.2                # seconds the fade-in takes
Y = 0.80                  # wave center (0-1 down the frame)
HEIGHT = 0.11             # tallest swell above and below the center, as a share of the frame height
RIBBONS = [               # color, waves across the frame, drift speed (radians per second), height multiplier
    ((255, 60, 150), 1.5, 4.0, 1.0),     # pink
    ((40, 210, 255), 2.2, -5.0, 0.8),    # cyan
    ((140, 90, 255), 3.0, 6.5, 0.6),     # violet
]
FILL = 150                # brightness (0-255) of each ribbon's fill; its edge lines are full brightness
GLOW = 8                  # blur radius of the glow around each ribbon, in pixels
SHADE = 150               # opacity (0-255) of the soft dark band behind the wave
IDLE = 0.04               # wave height left during silence, so a thin line stays on screen
ATTACK, RELEASE = 0.6, 0.12   # share of the gap to the new loudness closed each frame, rising and falling
RATE = 48000              # audio samples per second read from the input


def audio(path):
    """The soundtrack as mono floats from -1 to 1."""
    raw = subprocess.check_output(["ffmpeg", "-v", "error", "-i", path, "-f", "s16le", "-ac", "1", "-ar", str(RATE), "-"])
    return np.frombuffer(raw, np.int16).astype(np.float32) / 32768


def loudness(sound, frames):
    """Smoothed loudness (0-1) for every frame from the RMS of its slice of sound, quick to rise and slow to fall."""
    step = RATE / fps
    sound = np.pad(sound, (0, max(0, round(frames * step) - len(sound))))   # silence past the end of the track
    rms = np.array([np.sqrt(np.mean(sound[round(i * step):round((i + 1) * step)] ** 2)) for i in range(frames)])
    rms = np.sqrt(rms / max(rms.max(), 1e-6))   # square root lifts quieter words so they still show
    level, out = 0.0, []
    for r in rms:
        level += (r - level) * (ATTACK if r > level else RELEASE)
        out.append(level)
    return out


def band():
    """A dark band behind the wave that fades out at its top and bottom edges."""
    bh = round(h * HEIGHT * 2.6)
    rows = 1 - np.abs(np.linspace(-1, 1, bh)) ** 2
    alpha = np.repeat((rows * SHADE).astype(np.uint8)[:, None], w, axis=1)
    img = Image.new("RGBA", (w, bh), (0, 0, 0, 0))
    img.putalpha(Image.fromarray(alpha))
    return img


def wave(t, level):
    """The ribbons for time t: each is a sine curve mirrored about the center line and tapered at both ends.
    Their light adds up, so where ribbons cross, their colors blend and glow brighter."""
    bh = band_img.height
    mid = bh / 2
    u = np.linspace(-1, 1, w)
    taper = (1 - u ** 2) ** 2
    amp = h * HEIGHT * max(level, IDLE)
    light = np.zeros((bh, w, 3), np.float32)
    for color, waves, speed, size in RIBBONS:
        y = amp * size * taper * np.abs(np.sin(u * waves * math.pi + speed * t))
        top = list(zip(range(w), mid - y))
        bottom = list(zip(range(w), mid + y))
        shape = Image.new("L", (w, bh))
        draw = ImageDraw.Draw(shape)
        draw.polygon(top + bottom[::-1], fill=FILL)
        draw.line(top, fill=255, width=2)
        draw.line(bottom, fill=255, width=2)
        glow = shape.filter(ImageFilter.GaussianBlur(GLOW))
        mask = np.maximum(np.asarray(shape, np.float32), np.asarray(glow, np.float32) * 0.8) / 255
        light += mask[:, :, None] * np.array(color, np.float32)
    alpha = np.clip(light.max(axis=2), 0, 255)
    rgb = np.clip(light * 255 / np.maximum(light.max(axis=2), 1)[:, :, None], 0, 255)   # full-strength color, alpha carries the brightness
    rgba = np.dstack([rgb, alpha]).astype(np.uint8)
    img = band_img.copy()
    img.alpha_composite(Image.fromarray(rgba))
    return img


def overlay(i):
    t = i / fps
    img = Image.new("RGBA", (w, h))
    fade = min(max((t - START) / FADE, 0), 1)
    if fade > 0:
        s = wave(t, levels[i])
        s.putalpha(s.getchannel("A").point(lambda a: round(a * fade)))
        img.alpha_composite(s, (0, round(h * Y - s.height / 2)))
    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):
    """Draw overlay(i) 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 i in range(frames):
        ff.stdin.write(overlay(i).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)
levels = loudness(audio(src), frames)
band_img = band()
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