Building Podcast Audiograms with Remotion: Full Automation Guide
Building Podcast Audiograms with Remotion: Full Automation Guide
A podcast audiogram is a short video clip — typically 60 seconds — designed for sharing on Instagram, Twitter/X, LinkedIn, or YouTube Shorts. It pairs an excerpt of a podcast episode with a visual: waveform animation, episode artwork, the speaker’s name, and a progress bar. The goal is to give a silent-scrolling social media feed enough visual interest to earn a tap and a listen.
Audiograms solve a real distribution problem. Most podcast platforms are not social platforms. Spotify and Apple Podcasts are discovery dead zones unless you are already popular. Social video, on the other hand, reaches people who have never heard of your show. A well-produced audiogram is one of the most efficient promotional assets a podcast can publish.
The challenge is production volume. A weekly podcast that pulls three clips per episode generates 156 audiogram videos per year. Doing those manually in a video editor is not sustainable. Remotion solves this with composable, parameterizable React components and a Node.js render API that can accept data from an RSS feed and output finished MP4 files without human intervention.
This guide walks through every layer of the implementation: the <Audio> component, the @remotion/media-utils waveform visualizer API, the progress bar, the episode metadata overlay, and a complete batch generation pipeline.
What Is @remotion/media-utils?
@remotion/media-utils is an official Remotion package that provides utilities for working with audio and video data. The two functions used in audiogram production are:
getAudioData(src: string): Promise<AudioData> — Decodes an audio file and returns an AudioData object containing the sample rate, number of channels, decoded audio samples, and duration. This is called once during composition setup.
visualizeAudio({ audioData, frame, fps, numberOfSamples }): Float32Array — Given the decoded AudioData, the current frame, the fps, and a desired number of frequency buckets, returns a Float32Array of frequency magnitude values normalized between 0 and 1. These values directly drive the waveform bars.
Install the package:
npm install @remotion/media-utils
Composition Setup
An audiogram composition for Instagram/Twitter typically uses a square (1080×1080) or portrait (1080×1920) format. For Twitter/X and LinkedIn, square works well. For Instagram Reels and TikTok, portrait is preferred.
// Root.tsx
import { Composition } from "remotion";
import { AudiogramComposition } from "./Audiogram";
export const RemotionRoot: React.FC = () => {
return (
<Composition
id="Audiogram"
component={AudiogramComposition}
width={1080}
height={1080}
fps={30}
durationInFrames={1800} // 60 seconds at 30fps
defaultProps={{
audioSrc: "https://example.com/podcast-clip.mp3",
episodeTitle: "How AI is Reshaping Product Development",
guestName: "Sarah Chen, CPO at BuildFast",
artworkSrc: "https://example.com/artwork.jpg",
accentColor: "#6366f1",
numberOfBars: 80,
}}
/>
);
};
The <Audio> Component
Remotion’s built-in <Audio> component synchronizes audio playback with the composition timeline. In the Remotion Studio browser preview, it plays the audio in real time as you scrub. During headless rendering, it processes the audio into the output file.
import { Audio } from "remotion";
// Inside your composition component:
<Audio src={audioSrc} />
The <Audio> component accepts:
src: URL or imported static asset path to the audio filestartFrom: frame to start playback from (default: 0)endAt: frame to stop playback atvolume: a number between 0 and 1, or a function(frame: number) => numberfor dynamic volume
For audiograms where you want the audio to start exactly at frame 0 and end at the composition duration, the minimal usage above is sufficient.
Loading Remote Audio Files
If the audio is hosted remotely (common for RSS-driven pipelines), you need to tell Remotion to allow the remote URL:
// remotion.config.ts
import { Config } from "@remotion/cli/config";
Config.setChromiumOpenGlRenderer("angle");
And in the bundler/renderer configuration:
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "mp4",
outputLocation: "out/audiogram.mp4",
chromiumOptions: {
disableWebSecurity: false,
},
// For remote assets, ensure they are accessible at render time
});
For production pipelines, it is more reliable to download audio clips locally before rendering rather than referencing remote URLs.
Waveform Visualizer with visualizeAudio
The waveform is the visual centerpiece of the audiogram. Here is a complete implementation:
import { useCurrentFrame, useVideoConfig } from "remotion";
import { visualizeAudio, useAudioData } from "@remotion/media-utils";
interface WaveformProps {
audioSrc: string;
numberOfBars?: number;
barColor?: string;
barWidth?: number;
gap?: number;
maxHeight?: number;
}
export const Waveform: React.FC<WaveformProps> = ({
audioSrc,
numberOfBars = 80,
barColor = "#6366f1",
barWidth = 6,
gap = 3,
maxHeight = 120,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
// useAudioData is a hook wrapper around getAudioData
// Returns null while loading, AudioData when ready
const audioData = useAudioData(audioSrc);
if (!audioData) {
// Render static placeholder bars during loading
return (
<div style={{ display: "flex", alignItems: "center", gap, height: maxHeight }}>
{Array.from({ length: numberOfBars }).map((_, i) => (
<div
key={i}
style={{
width: barWidth,
height: maxHeight * 0.1,
background: barColor,
borderRadius: barWidth / 2,
opacity: 0.3,
}}
/>
))}
</div>
);
}
const frequencyData = visualizeAudio({
fps,
frame,
audioData,
numberOfSamples: numberOfBars,
});
const totalWidth = numberOfBars * barWidth + (numberOfBars - 1) * gap;
return (
<div
style={{
display: "flex",
alignItems: "center",
gap,
height: maxHeight,
width: totalWidth,
}}
>
{frequencyData.map((value, index) => {
const barHeight = Math.max(4, value * maxHeight);
return (
<div
key={index}
style={{
width: barWidth,
height: barHeight,
background: barColor,
borderRadius: barWidth / 2,
flexShrink: 0,
}}
/>
);
})}
</div>
);
};
useAudioData is the hook equivalent of getAudioData — it returns null while the audio is loading and the decoded AudioData once ready. Always handle the null case or Remotion will throw during the initial render frames.
visualizeAudio uses a short-time Fourier transform (STFT) to extract frequency information at the current timestamp. The returned Float32Array has exactly numberOfSamples elements, each a normalized value between 0 and 1 representing the magnitude of that frequency bucket at this moment in the audio.
Progress Bar
A thin progress bar at the bottom of the audiogram showing how far through the clip the listener is.
import { useCurrentFrame, useVideoConfig, interpolate } from "remotion";
interface ProgressBarProps {
color?: string;
height?: number;
backgroundColor?: string;
}
export const ProgressBar: React.FC<ProgressBarProps> = ({
color = "#6366f1",
height = 6,
backgroundColor = "rgba(255,255,255,0.2)",
}) => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const progress = interpolate(frame, [0, durationInFrames - 1], [0, 100], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
style={{
width: "100%",
height,
background: backgroundColor,
borderRadius: height / 2,
overflow: "hidden",
}}
>
<div
style={{
width: `${progress}%`,
height: "100%",
background: color,
borderRadius: height / 2,
}}
/>
</div>
);
};
Episode Metadata Overlay
The title, guest name, and episode artwork form the static visual layer of the audiogram.
import { Img, staticFile } from "remotion";
import { interpolate, useCurrentFrame } from "remotion";
interface MetadataOverlayProps {
episodeTitle: string;
guestName: string;
artworkSrc: string;
accentColor: string;
}
export const MetadataOverlay: React.FC<MetadataOverlayProps> = ({
episodeTitle,
guestName,
artworkSrc,
accentColor,
}) => {
const frame = useCurrentFrame();
// Slide in from bottom in the first 30 frames
const translateY = interpolate(frame, [0, 30], [40, 0], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
const opacity = interpolate(frame, [0, 30], [0, 1], {
extrapolateLeft: "clamp",
extrapolateRight: "clamp",
});
return (
<div
style={{
opacity,
transform: `translateY(${translateY}px)`,
display: "flex",
alignItems: "center",
gap: 24,
fontFamily: '-apple-system, "Segoe UI", Roboto, sans-serif',
}}
>
{/* Episode Artwork */}
<Img
src={artworkSrc}
style={{
width: 120,
height: 120,
borderRadius: 16,
objectFit: "cover",
flexShrink: 0,
border: `3px solid ${accentColor}`,
}}
/>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: 13,
fontWeight: 600,
color: accentColor,
letterSpacing: "0.1em",
textTransform: "uppercase",
marginBottom: 8,
}}
>
PODCAST EPISODE
</div>
<div
style={{
fontSize: 26,
fontWeight: 700,
color: "#ffffff",
lineHeight: 1.3,
marginBottom: 8,
// Prevent overflow for long titles
display: "-webkit-box",
WebkitLineClamp: 3,
WebkitBoxOrient: "vertical",
overflow: "hidden",
}}
>
{episodeTitle}
</div>
<div
style={{
fontSize: 16,
color: "rgba(255,255,255,0.7)",
}}
>
{guestName}
</div>
</div>
</div>
);
};
Use Remotion’s <Img> component (not a plain <img> tag) to ensure the image is loaded and available before the frame renders. This prevents blank frames in the rendered output.
Composing the Full Audiogram
import { AbsoluteFill, Audio } from "remotion";
interface AudiogramProps {
audioSrc: string;
episodeTitle: string;
guestName: string;
artworkSrc: string;
accentColor: string;
numberOfBars: number;
}
export const AudiogramComposition: React.FC<AudiogramProps> = ({
audioSrc,
episodeTitle,
guestName,
artworkSrc,
accentColor,
numberOfBars,
}) => {
return (
<AbsoluteFill
style={{
background: "#0f0f1a",
padding: 60,
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
}}
>
{/* Bind audio to timeline */}
<Audio src={audioSrc} />
{/* Top: Waveform visualization */}
<div
style={{
flex: 1,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Waveform
audioSrc={audioSrc}
numberOfBars={numberOfBars}
barColor={accentColor}
barWidth={8}
gap={4}
maxHeight={200}
/>
</div>
{/* Middle: Episode metadata */}
<MetadataOverlay
episodeTitle={episodeTitle}
guestName={guestName}
artworkSrc={artworkSrc}
accentColor={accentColor}
/>
{/* Bottom: Progress bar */}
<div style={{ marginTop: 32 }}>
<ProgressBar color={accentColor} height={6} />
</div>
</AbsoluteFill>
);
};
Simulated Waveform Fallback
For cases where you want to render an audiogram without loading real audio data at composition build time — for example, when generating thumbnails or testing layouts — a simulated waveform using seeded random values is useful:
import { useCurrentFrame } from "remotion";
const simulatedWaveform = (
frame: number,
numberOfBars: number
): number[] => {
// Slow oscillation + per-bar pseudo-random variation
return Array.from({ length: numberOfBars }, (_, i) => {
const baseWave = Math.sin((frame / 30) * Math.PI + i * 0.3) * 0.4 + 0.5;
const noise = Math.sin(i * 137.5 + frame * 0.7) * 0.15;
return Math.max(0.05, Math.min(1, baseWave + noise));
});
};
This produces organic-looking oscillation without requiring audio data. It is suitable for template previews but should be replaced with real visualizeAudio output for production audiograms.
Batch Generation from an RSS Feed
The real power of Remotion for podcasters is automation. Here is a complete pipeline that reads a podcast RSS feed, extracts recent episodes, and renders one audiogram per episode:
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
import { XMLParser } from "fast-xml-parser";
import { execSync } from "child_process";
import * as fs from "fs";
import * as path from "path";
// Parse RSS feed
const fetchRSSEpisodes = async (rssUrl: string) => {
const response = await fetch(rssUrl);
const xml = await response.text();
const parser = new XMLParser({ ignoreAttributes: false });
const result = parser.parse(xml);
const items = result.rss.channel.item;
return Array.isArray(items) ? items : [items];
};
// Download audio clip (first 60 seconds)
const downloadClip = async (audioUrl: string, outputPath: string): Promise<void> => {
// Use ffmpeg to extract first 60 seconds
execSync(
`ffmpeg -i "${audioUrl}" -t 60 -c:a libmp3lame -q:a 2 "${outputPath}" -y`,
{ stdio: "pipe" }
);
};
// Main pipeline
const generateAudiograms = async () => {
const RSS_URL = process.env.PODCAST_RSS_URL ?? "";
const OUTPUT_DIR = "./out/audiograms";
const ARTWORK_URL = process.env.PODCAST_ARTWORK_URL ?? "";
const ACCENT_COLOR = process.env.ACCENT_COLOR ?? "#6366f1";
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
fs.mkdirSync("./tmp/audio", { recursive: true });
const bundleLocation = await bundle({
entryPoint: "./src/index.ts",
});
const episodes = await fetchRSSEpisodes(RSS_URL);
const latestEpisodes = episodes.slice(0, 5); // Process 5 most recent
for (const episode of latestEpisodes) {
const title = episode.title as string;
const audioUrl = episode.enclosure?.["@_url"] as string;
const safeTitle = title.replace(/[^a-z0-9]/gi, "-").toLowerCase().slice(0, 40);
const clipPath = path.resolve(`./tmp/audio/${safeTitle}.mp3`);
const outputPath = path.resolve(`${OUTPUT_DIR}/${safeTitle}.mp4`);
if (fs.existsSync(outputPath)) {
console.log(`Skipping (already exists): ${title}`);
continue;
}
console.log(`Downloading clip: ${title}`);
await downloadClip(audioUrl, clipPath);
const inputProps = {
audioSrc: clipPath,
episodeTitle: title,
guestName: (episode["itunes:author"] as string) ?? "Your Podcast",
artworkSrc: ARTWORK_URL,
accentColor: ACCENT_COLOR,
numberOfBars: 80,
};
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "Audiogram",
inputProps,
});
console.log(`Rendering: ${title}`);
await renderMedia({
composition,
serveUrl: bundleLocation,
codec: "mp4",
outputLocation: outputPath,
inputProps,
});
console.log(`Done: ${outputPath}`);
}
console.log("All audiograms rendered.");
};
generateAudiograms().catch(console.error);
Run this as a cron job after each episode release, and your entire promotional clip library is generated automatically.
Platform-Specific Considerations
Instagram and TikTok (9:16 portrait, 1080×1920)
Use a portrait composition. Adjust the layout to place the waveform near the center vertically and the artwork + metadata in the upper third. The progress bar stays at the bottom.
// In Root.tsx
<Composition
id="AudiogramPortrait"
component={AudiogramComposition}
width={1080}
height={1920}
fps={30}
durationInFrames={1800}
defaultProps={...}
/>
Twitter/X and LinkedIn (1:1, 1080×1080)
Square format is ideal. Twitter/X autoplays GIF-like behavior for square videos. LinkedIn gives square videos extra feed real estate compared to landscape.
YouTube Shorts (9:16, same as Instagram Reels)
YouTube Shorts expects 1080×1920. Maximum duration is 60 seconds, which maps exactly to 1800 frames at 30fps — a perfect fit for the audiogram format.
RenderComp Audiogram Templates
Building a polished audiogram composition from scratch takes a few hours of development work. RenderComp provides production-ready audiogram templates for Remotion, with multiple waveform styles (bar, circle, mirror), dark and light themes, and pre-wired inputProps for episode title, guest, artwork, and accent color. The templates include both square (1:1) and portrait (9:16) compositions.
Get started at rendercomp.com and have your first audiogram rendered in minutes.
FAQ
Q: Does visualizeAudio from @remotion/media-utils work with MP3 files?
A: Yes. getAudioData and visualizeAudio support MP3, WAV, AAC, and OGG audio formats. The decoding is handled by the browser’s Web Audio API running inside Remotion’s Chromium instance, so any format Chromium supports is supported.
Q: What is useAudioData and how does it differ from getAudioData?
A: getAudioData is an async function that returns a Promise<AudioData>. useAudioData is a React hook that wraps getAudioData, manages the loading state, and returns null while loading and AudioData when ready. Inside a Remotion component, always use useAudioData rather than calling getAudioData directly in a useEffect.
Q: The waveform bars are all the same height — they are not responding to the audio. What is wrong? A: This is usually caused by CORS restrictions on the audio URL. When running in Remotion Studio (browser-based preview), the audio file must be served from the same origin or include appropriate CORS headers. For production rendering (which uses a headless Chromium process), local file paths bypass this issue entirely. Download the audio clip locally before rendering.
Q: How do I trim a podcast episode to a specific 60-second highlight rather than always using the first minute?
A: Use ffmpeg to extract the desired segment before passing it to Remotion: ffmpeg -i input.mp3 -ss 00:12:30 -t 60 -c:a copy clip.mp3. Pass the resulting clip.mp3 as the audioSrc prop. The Remotion composition does not need to know the segment offset — it plays whatever audio file you provide from the start.
Q: Can I add subtitles or transcribed text to the audiogram that is synced to the audio?
A: Yes. If you have a transcript with word-level timestamps (from a speech-to-text service like Whisper), you can render caption overlays by checking which words fall within the current frame’s time window: const currentWords = transcript.filter(w => w.start <= currentTime && w.end >= currentTime). Display the current words as an overlay inside the composition.
Q: Is it possible to render a portrait audiogram and a square audiogram from the same source audio automatically?
A: Yes. Register two separate compositions (AudiogramSquare and AudiogramPortrait) with different width/height values but the same defaultProps structure. In your batch script, call renderMedia() twice per episode with different composition IDs and output filenames.
Q: What is the maximum audio file size that useAudioData can handle?
A: useAudioData decodes the entire audio file into memory as PCM samples. Very long audio files (over 30 minutes) at high sample rates can consume significant RAM. For audiogram use cases (60-second clips), this is not a practical concern — a 60-second MP3 decodes to roughly 5–10MB of PCM data, well within normal browser memory limits.
Now available
Get 1,000+ Remotion Templates
Pay once — no subscription. Lifetime updates. TypeScript-first.
View pricing →