Podcast 音波視覺化:useAudioData × visualizeAudio
用 @remotion/media-utils 把 .mp3 變成跟著音訊高低起伏的 64 根動態音波柱。學會 useAudioData 載入、visualizeAudio 取樣、加上模糊封面底圖做成 YouTube 可上傳的 Podcast 影片。
成品預覽
8 秒、64 根對稱音波柱在畫面中央跟著音訊起伏跳動:中央高、兩側低、上半實心、下半半透明做出鏡像反射,背景是模糊化的封面底圖加深色遮罩,左上角放集數資訊。整個視覺的核心只有一個 hook(useAudioData)+ 一個函式(visualizeAudio),剩下都是 CSS。
這篇教什麼
把一個聲音變成「可看」的影片是 Podcast YouTuber 必備的工作流——但 99% 的人會用 Audiogram 之類的網頁工具,每次匯出要等幾分鐘、無法客製化視覺。Remotion 的 @remotion/media-utils 提供了完整的音訊解碼 API,讓你直接在 React 程式碼裡:
useAudioData— 載入 mp3 並解碼成 Float32Array 振幅資料visualizeAudio— 給定當前 frame,回傳該瞬間的 N 個振幅取樣- 把陣列畫成 64 根 div — 純 CSS,沒有 canvas、沒有外部庫
懂這套之後,你可以把任何音訊(Podcast、有聲書、Lo-fi 音樂)變成 YouTube 影片,並且完全控制視覺風格——換配色、換律動、加 logo、改尺寸都是改 CSS。
這篇專注在「音波本身」。下一篇 影片章節導航 會用通用的方式做章節進度條 + 切換字卡,套到 Podcast 影片上就是完整的 Podcast 視覺化工作流。
前置知識
- T1:Skills 安裝 — 確認你的 Remotion 專案能跑
- T12:短影音自動上字幕 — 熟悉
<Audio>與staticFile()
素材準備:
- 一集 Podcast 的
.mp3(10~60 分鐘都可以) - 一張封面圖(任意尺寸,會被模糊化)
Step 1:載入音訊 + 動態決定影片長度
寫死 30 分鐘很醜——Podcast 每集長度都不一樣。用 getAudioDurationInSeconds 自動讀檔長:
// src/Root.tsx
import {Composition, staticFile} from 'remotion';
import {getAudioDurationInSeconds} from '@remotion/media-utils';
import {PodcastEpisode} from './compositions/PodcastEpisode';
<Composition
id="PodcastEpisode"
component={PodcastEpisode}
fps={30}
width={1920}
height={1080}
durationInFrames={1} // 會被 calculateMetadata 覆寫
calculateMetadata={async () => {
const duration = await getAudioDurationInSeconds(
staticFile("podcast/episode-12.mp3"),
);
return {
durationInFrames: Math.floor(duration * 30),
};
}}
/>calculateMetadata 是 Remotion 的 metadata-at-compile-time 機制——在 render 開始前就會跑這個 async 函式,動態決定 durationInFrames。對 Podcast 這種長度不固定的內容是必備功能。沒有它,你每換一集 Podcast 就要手動改 frame 數,遲早會漏改。
接著在 component 裡載入音訊:
import {AbsoluteFill, Audio, staticFile} from 'remotion';
export const PodcastEpisode: React.FC = () => {
return (
<AbsoluteFill style={{backgroundColor: "#0a0a0f"}}>
<Audio src={staticFile("podcast/episode-12.mp3")} />
{/* 視覺元件等下加 */}
</AbsoluteFill>
);
};<Audio> 不會渲染任何視覺,只負責「在 render 出來的影片裡塞進這條音軌」。
💡
calculateMetadata與動態時長的完整說明在 /docs/dynamic-metadata。
Step 2:模糊封面底圖
Podcast 影片的標準視覺是「封面圖放大模糊化當背景」——這樣不管封面是什麼比例,畫面都能填滿,而且看起來像電影海報。
import {Img, staticFile} from 'remotion';
const BlurredCover: React.FC = () => (
<AbsoluteFill>
<Img
src={staticFile("podcast/cover.jpg")}
style={{
width: "100%",
height: "100%",
objectFit: "cover",
filter: "blur(60px) saturate(1.3)",
transform: "scale(1.15)",
}}
/>
<AbsoluteFill style={{backgroundColor: "rgba(10,10,15,0.7)"}} />
</AbsoluteFill>
);幾個重點:
filter: blur(60px):模糊半徑要夠大,否則看得出原圖內容transform: scale(1.15):模糊會在邊緣產生「白框」(因為 blur kernel 跑出邊界),把圖放大 15% 把邊緣推到畫面外- 疊一層深色
AbsoluteFill:把模糊圖再壓暗 70%,讓前景的音波更顯眼。沒有這層的話,背景太亮、白色音波會被吃掉
如果你不想用真的封面圖、想要動態漸層當背景,把 <Img> 換成 linear-gradient 也可以——本篇 demo 就是用 linear-gradient(135deg, #7c3aed, #3b82f6, ...) + 模糊模擬出來的。
Step 3:useAudioData — 載入解碼資料
這是這篇的核心 API:
import {useAudioData, visualizeAudio} from '@remotion/media-utils';
import {staticFile, useCurrentFrame} from 'remotion';
const Waveform: React.FC = () => {
const audioData = useAudioData(staticFile("podcast/episode-12.mp3"));
const frame = useCurrentFrame();
// 第一次載入時 audioData 會是 null
if (!audioData) return null;
const samples = visualizeAudio({
audioData,
frame,
fps: 30,
numberOfSamples: 64,
});
// samples 是長度 64 的 number[],每個值在 0~1 之間
return (
<div style={waveformContainerStyle}>
{samples.map((amplitude, i) => (
<Bar key={i} amplitude={amplitude} />
))}
</div>
);
};兩個關鍵函式:
useAudioData(src) — 第一次呼叫時會:
- fetch 整個 mp3 檔
- 用 Web Audio API 解碼成 Float32Array(左右聲道的振幅)
- 回傳一個物件包含
channelWaveforms、sampleRate、numberOfChannels、durationInSeconds - 解碼期間 hook 回傳
null,所以一定要 early return
第一次載入要花 5~10 秒(30 分鐘 mp3),但 Remotion 會 cache 結果,後續 frame 不會重複載。
visualizeAudio({audioData, frame, fps, numberOfSamples}) — 給定當前 frame,回傳該瞬間的 N 個振幅取樣:
numberOfSamples: 64就是「我要 64 根柱子」- 回傳的每個值都是 0~1 normalized 振幅
- 取樣是「以當前 frame 為中心的小窗口」做 FFT,所以靜音時所有值接近 0、有人聲時某些 frequency band 的值會跳
Step 4:把陣列畫成 64 根 div
const Bar: React.FC<{amplitude: number}> = ({amplitude}) => {
const maxHeight = 420;
const height = Math.max(8, amplitude * maxHeight); // 最小 8px 避免 div 消失
return (
<div style={{width: 16, display: "flex", flexDirection: "column", alignItems: "center", gap: 4}}>
{/* 上半實心 */}
<div style={{
width: 16,
height,
backgroundColor: "rgba(255,255,255,0.9)",
borderRadius: 4,
boxShadow: "0 0 12px rgba(255,255,255,0.4)",
}} />
{/* 下半鏡像(半透明) */}
<div style={{
width: 16,
height: height * 0.6,
backgroundColor: "rgba(255,255,255,0.4)",
borderRadius: 4,
}} />
</div>
);
};
const waveformContainerStyle: React.CSSProperties = {
position: "absolute",
top: "50%",
left: 0,
right: 0,
transform: "translateY(-50%)",
display: "flex",
justifyContent: "center",
alignItems: "center",
gap: 6,
};幾個值得記下來的細節:
Math.max(8, ...)— 振幅 0 時 div 高度是 0、視覺上會「消失」,給個最小高度讓所有 bar 永遠可見alignItems: "center"— 整排 bar 從容器中央對齊,所以每根 bar 都是「往上往下對稱伸」- 下半半透明做鏡像 — 不是真的反射,是用第二個 div 加
opacity: 0.4模擬。比真的算 mirror 簡單一百倍、視覺效果幾乎一樣 boxShadow加白光暈 — 讓 bar 有「發光」感而不是貼在背景上
Step 5:把所有東西組起來
export const PodcastEpisode: React.FC = () => {
return (
<AbsoluteFill style={{backgroundColor: "#0a0a0f"}}>
<Audio src={staticFile("podcast/episode-12.mp3")} />
<BlurredCover />
<Waveform />
<EpisodeInfo /> {/* 左上角集數資訊,純 CSS */}
</AbsoluteFill>
);
};<EpisodeInfo> 就是固定在左上角的三行文字:「EP 12」/「Debug 土撥鼠 Podcast」/ 本集標題。沒有動畫、全程顯示。Demo 影片裡看到的就是這個。
Step 6:渲染
Podcast 影片很長,渲染要久。記得用並行:
npx remotion render PodcastEpisode out/podcast-ep12.mp4 --codec=h264 --concurrency=4--concurrency=4 開 4 個 worker 同時 render 不同 frame range。對 30 分鐘的影片來說可以從 20 分鐘縮短到 6~7 分鐘。
常見問題
Q:音波看起來都很扁平,沒有高低起伏?
通常是 numberOfSamples 設太多或太少。visualizeAudio 是把當前 frame 的音訊切成 N 段做 FFT 然後平均——N 太大每段太短、結果會被平均化變扁;N 太小取樣不夠細、看起來很「塊狀」。32~128 之間找一個甜蜜點,64 是經驗值。
另一個常見原因是音訊本身音量太小。visualizeAudio 回傳的是相對值,但太靜的音訊會讓所有 bar 都黏在底部。可以在 amplitude 再乘一個 booster(比如 amplitude * 1.8)然後 clamp 在 1.0。
Q:useAudioData 第一次載入 Studio 卡住幾秒?
正常。30 分鐘 mp3 大概 5~10 秒解碼時間。只會在 Studio 第一次載入時發生,之後 cache 命中、frame seek 都是即時的。如果你的 Podcast 是 90 分鐘以上、開發時 Studio 預覽會卡,可以先用 ffmpeg 轉一個低品質版(-b:a 64k)給開發用:
ffmpeg -i episode-12.mp3 -b:a 64k public/podcast/episode-12-dev.mp3開發時讀 dev 版、render 時讀原版,用環境變數切換。
Q:可以做頻率分析(不只是振幅)嗎?比如 bass band 一個顏色、treble 一個顏色?
visualizeAudio 只給你振幅。如果要按頻率分 band 染色,要自己用 Web Audio API 的 AnalyserNode.getByteFrequencyData(),但那個 API 只在瀏覽器執行時可用、Remotion server-side render 時跑不起來。Server render 時要分頻率,要用 @remotion/media-utils 的 getWaveformPortion 自己算 FFT——進階主題,本篇不展開。
Q:音波可以左右走(像跑馬燈)而不是中央對稱嗎?
可以,把 samples 用 frame offset 一下:
const offset = Math.floor(frame / 2); // 每 2 frame 往左推一格
const visible = [...samples.slice(offset), ...samples.slice(0, offset)];這樣音波會持續往左捲、像 oscilloscope。視覺上比靜態置中更「動感」,但跟音訊內容的對應就沒那麼精準。
Q:可以同時顯示多個聲道(左/右)嗎?
useAudioData 回傳 channelWaveforms 是個 Float32Array[](每個聲道一條)。visualizeAudio 預設用 channel 0,可以用 channel: 1 參數指定右聲道:
const left = visualizeAudio({audioData, frame, fps: 30, numberOfSamples: 64, channel: 0});
const right = visualizeAudio({audioData, frame, fps: 30, numberOfSamples: 64, channel: 1});兩條畫成上下兩排就是經典的「立體聲示波器」視覺。
Q:音波卡頓、不夠流暢?
如果你的 fps 是 30 但音波看起來「跳格」,可能是 numberOfSamples 大到讓每 frame 計算量爆掉。試試降到 32 或 48。visualizeAudio 內部會跑 FFT,sample 數翻倍計算量也翻倍。
本篇涵蓋的官方文件
- /docs/audio —
<Audio>元件 - /docs/audio-visualization —
useAudioData+visualizeAudio完整 API - /docs/dynamic-metadata —
calculateMetadata與動態時長 - /docs/staticfile — 載入
public/資源
下一步
T10:影片章節導航 — 音波只是視覺裝飾,真正讓長影片好用的是「章節導航」。下一篇是通用版本——任何長影片(Podcast、課程、Vlog、會議錄影)都能套用,學會用 <Sequence> 在每個章節切換時跳出全螢幕標題卡、底部做出跟著時間推進的章節進度條。