How to add a voice message bubble with Claude

A white chat voice note pops up under the bottle, and its waveform bars, shaped by the real voiceover, turn blue left to right as the voice speaks while a timer counts the seconds.

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 voice message bubble to input.mp4
    Second the bubble pops in: 0.1
    The stretch of the clip's audio the voice note plays and draws its waveform from: 0.3, 3.6

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 voice message bubble 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.1Second the bubble pops in.
PLAY_FROM, PLAY_TO0.3, 3.6The stretch of the clip's audio the voice note plays and draws its waveform from.
BARS26Number of waveform bars.
WIDTH0.84Bubble width as a share of the frame width.
Y0.80Bubble center, as a share of the frame height.
PLAYED, UNPLAYED(0, 122, 255), (190, 192, 198)Colors of the played and still-to-play bars.

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 voice message bubble to input.mp4, like a voice note in a chat app, that plays along with the clip's own voiceover. The bubble is pure white (255, 255, 255), 84% of the frame width wide and 27% of its own width tall, centered across and with its center 80% of the way down, with corners rounded to 30% of its height, a small tail at the bottom-right corner and a soft dark shadow. It pops in at 0.1 seconds over 0.2 seconds with a slight overshoot, growing out of the tail corner. On its left is a blue (0, 122, 255) circle 60% of the bubble's height across, showing a white pause icon while the voice plays and a white play triangle before and after. To its right, filling the rest of the bubble, are 26 rounded waveform bars whose heights (15% to 100% of half the bubble's height) come from the loudness (RMS) of the audio between 0.3 and 3.6 seconds, split into 26 equal slices. Bars are gray (190, 192, 198) and turn blue (0, 122, 255) from left to right in step with playback from 0.3 to 3.6 seconds, so all are blue by 3.6 seconds. Under the bars on the left, a gray (110, 112, 118) timer 3.6% of the frame width tall shows the seconds played as 0:00, 0:01, 0:02, 0:03. Use Python with numpy for the loudness and Pillow to draw it, with ffmpeg to put it on the video (don't rely on 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 voice-message-bubble.py and run:

python3 voice-message-bubble.py input.mp4 output.mp4
Show the script
#!/usr/bin/env python3
"""Voice message bubble: a chat voice note whose waveform bars turn blue as the clip's own voice plays.

Usage: python3 voice-message-bubble.py input.mp4 output.mp4
Needs ffmpeg, numpy and Pillow (pip install numpy pillow).
"""
import json
import math
import subprocess
import sys
from pathlib import Path

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

START = 0.1                      # second the bubble pops in
POP = 0.2                        # seconds the pop takes
PLAY_FROM, PLAY_TO = 0.3, 3.6    # the stretch of the clip's audio the voice note plays
BARS = 26                        # waveform bars
WIDTH = 0.84                     # bubble width as a share of the frame width
Y = 0.80                         # bubble center, as a share of the frame height
BUBBLE = (255, 255, 255)         # bubble color
PLAYED = (0, 122, 255)           # play button and played bars
UNPLAYED = (190, 192, 198)       # bars still to play
INK = (110, 112, 118)            # timer text
FONTS = ["/System/Library/Fonts/Supplemental/Arial.ttf",          # macOS
         "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",       # Linux
         "C:/Windows/Fonts/arial.ttf"]                            # Windows
FONT_FILE = next((f for f in FONTS if Path(f).exists()), None) or sys.exit("No font found: add one to FONTS")


def loudness(src):
    """Each bar's height (0.15-1) from the loudness of its slice of the audio between PLAY_FROM and PLAY_TO."""
    sample_rate = 8000
    pcm = subprocess.check_output(["ffmpeg", "-v", "error", "-ss", str(PLAY_FROM), "-t", str(PLAY_TO - PLAY_FROM),
                                   "-i", src, "-ac", "1", "-ar", str(sample_rate), "-f", "s16le", "-"])
    samples = np.frombuffer(pcm, np.int16).astype(np.float32)
    rms = np.array([np.sqrt((part ** 2).mean()) for part in np.array_split(samples, BARS)])
    return 0.15 + 0.85 * rms / max(rms.max(), 1)


def bubble():
    """The parts that never change (bubble, tail, shadow, play button circle) and where the rest goes."""
    bw, bh = round(w * WIDTH), round(w * WIDTH * 0.27)
    m = round(w * 0.03)  # room for the tail and shadow
    img = Image.new("RGBA", (bw + 2 * m, bh + 2 * m))
    shape = Image.new("L", img.size)
    d = ImageDraw.Draw(shape)
    d.rounded_rectangle([m, m, m + bw, m + bh], radius=round(bh * 0.3), fill=255)
    d.polygon([(m + bw - m * 1.2, m + bh - m * 0.1), (m + bw + m * 0.6, m + bh),
               (m + bw + m * 0.2, m + bh - m * 1.2)], fill=255)
    shadow = Image.new("RGBA", img.size, (0, 0, 0, 0))
    shadow.putalpha(shape.point(lambda v: v * 0.35).filter(ImageFilter.GaussianBlur(m / 3)))
    img.alpha_composite(shadow, (0, round(m * 0.15)))
    img.paste(Image.new("RGBA", img.size, BUBBLE), mask=shape)
    mid = m + bh * 0.45                                      # vertical center of the button and bars
    button_x, button_r = m + bh * 0.45, bh * 0.3
    ImageDraw.Draw(img).ellipse([button_x - button_r, mid - button_r, button_x + button_r, mid + button_r], fill=PLAYED)
    bars_x = button_x + button_r * 1.6
    step = (m + bw - bh * 0.25 - bars_x) / BARS
    return img, dict(mid=mid, button_x=button_x, button_r=button_r, bars_x=bars_x, step=step,
                     bar_w=step * 0.55, bars_h=bh * 0.5, timer_y=m + bh * 0.9)


def voice_note(t):
    """The bubble at time t: played bars in blue, a pause icon while it plays and the seconds played."""
    progress = min(max((t - PLAY_FROM) / (PLAY_TO - PLAY_FROM), 0), 1)
    g = geometry
    mid, half_h, step, bar_w = g["mid"], g["bars_h"] / 2, g["step"], g["bar_w"]
    img = base.copy()
    draw = ImageDraw.Draw(img)
    for i, level in enumerate(levels):
        x = g["bars_x"] + i * step
        color = PLAYED if (i + 0.5) / BARS <= progress else UNPLAYED
        draw.rounded_rectangle([x, mid - level * half_h, x + bar_w, mid + level * half_h], radius=bar_w / 2, fill=color)
    x, r = g["button_x"], g["button_r"] * 0.42
    if 0 < progress < 1:  # pause icon
        for left in (x - r * 0.6, x + r * 0.25):
            draw.rectangle([left, mid - r * 0.8, left + r * 0.35, mid + r * 0.8], fill="white")
    else:                 # play triangle
        draw.polygon([(x - r * 0.45, mid - r * 0.85), (x - r * 0.45, mid + r * 0.85), (x + r * 0.9, mid)], fill="white")
    seconds = int(progress * (PLAY_TO - PLAY_FROM))
    draw.text((g["bars_x"], g["timer_y"]), f"0:{seconds:02d}", font=timer_font, anchor="ls", fill=INK)
    return img


def overlay(t):
    img = Image.new("RGBA", (w, h))
    scale = ease_out_back((t - START) / POP) if t >= START else 0
    if scale > 0.02:
        b = voice_note(t)
        s = b.resize((max(1, round(b.width * scale)), max(1, round(b.height * scale))), Image.LANCZOS)
        # grow out of the tail's corner, the way chat apps do
        x, y = round((w - b.width) / 2), round(h * Y - b.height / 2)
        img.alpha_composite(s, (x + b.width - s.width, y + b.height - s.height))
    return img


def ease_out_back(p):
    """0 -> 1 with a small overshoot past 1 before settling, which reads as a 'pop'."""
    p = min(max(p, 0), 1)
    return 1 + 2.70158 * (p - 1) ** 3 + 1.70158 * (p - 1) ** 2


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 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(math.ceil(duration * fps)):
        ff.stdin.write(overlay(i / fps).tobytes())
    ff.stdin.close()
    if ff.wait():
        sys.exit("ffmpeg failed")


src, out = sys.argv[1:3]
w, h, rate, fps, duration = probe(src)
levels = loudness(src)
base, geometry = bubble()
timer_font = ImageFont.truetype(FONT_FILE, round(w * 0.036))
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