前言
音樂和視覺之間有一種天然的聯繫。你聽到低音鼓的重擊時,腦海中浮現的可能是深沉的脈動;高音鈴聲響起時,想到的也許是閃爍的星光。音訊視覺化(Audio Visualization)就是把這種聯覺體驗具象化——把聲音的頻率、振幅、節奏,即時轉換成視覺元素。
p5.js 的 p5.sound 函式庫讓這件事變得出奇地簡單。你可以載入音檔、接入麥克風、做 FFT 頻譜分析,然後用分析的結果來驅動任何視覺效果。
我第一次做音訊視覺化的時候,播了一首喜歡的歌,看著畫面隨著旋律跳動、隨著副歌爆發——那個瞬間我覺得自己在「看」音樂。這篇文章就來分享這個令人著迷的主題。
p5.sound 基礎設定
首先要確保你的 HTML 有引入 p5.sound 函式庫:
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/p5.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.9.0/addons/p5.sound.min.js"></script>
載入音檔
let song;
let fft;
function preload() {
song = loadSound('your-music.mp3');
}
function setup() {
createCanvas(800, 600);
// 建立 FFT 分析器
fft = new p5.FFT(0.8, 256);
// 第一個參數:smoothing(平滑度,0~1),越大越平滑
// 第二個參數:bins(頻率分箱數,必須是 2 的冪次)
fft.setInput(song);
}
function mousePressed() {
if (song.isPlaying()) {
song.pause();
} else {
song.play();
}
}
注意:瀏覽器的自動播放政策要求音訊必須由使用者的手勢(如點擊)觸發。所以我們用 mousePressed() 來啟動播放。
FFT 頻譜分析
FFT(Fast Fourier Transform,快速傅立葉轉換)是音訊視覺化的核心。它把時間域的波形(你聽到的聲音)轉換成頻率域的頻譜(每個頻率的能量有多大)。
function draw() {
background(30, 30, 40);
// 取得頻譜資料
let spectrum = fft.analyze();
// spectrum 是一個陣列,每個值 0~255,代表該頻率的能量
// 取得波形資料
let waveform = fft.waveform();
// waveform 是一個陣列,每個值 -1~1,代表瞬時振幅
// 繪製頻譜圖
noStroke();
for (let i = 0; i < spectrum.length; i++) {
let x = map(i, 0, spectrum.length, 0, width);
let h = map(spectrum[i], 0, 255, 0, height * 0.8);
let hue = map(i, 0, spectrum.length, 0, 360);
colorMode(HSB, 360, 100, 100);
fill(hue, 80, 90);
rect(x, height - h, width / spectrum.length, h);
colorMode(RGB);
}
}
fft.analyze() 回傳的陣列,索引越小代表越低的頻率(低音),索引越大代表越高的頻率(高音)。人耳能聽到的範圍大約是 20 Hz 到 20,000 Hz。
頻率映射到視覺
頻譜資料的真正威力在於映射(mapping)——把不同的頻率範圍映射到不同的視覺元素。
分頻段的圓形視覺化
function draw() {
background(30, 30, 40, 50);
let spectrum = fft.analyze();
// 取得不同頻段的能量
let bass = fft.getEnergy("bass"); // 20-140 Hz
let lowMid = fft.getEnergy("lowMid"); // 140-400 Hz
let mid = fft.getEnergy("mid"); // 400-2600 Hz
let highMid = fft.getEnergy("highMid"); // 2600-5200 Hz
let treble = fft.getEnergy("treble"); // 5200-14000 Hz
let cx = width / 2;
let cy = height / 2;
// 低音 → 大圓,脈動感
let bassSize = map(bass, 0, 255, 50, 300);
fill(255, 60, 60, 100);
noStroke();
ellipse(cx, cy, bassSize);
// 中低音 → 中圓
let lowMidSize = map(lowMid, 0, 255, 30, 200);
fill(255, 180, 60, 100);
ellipse(cx, cy, lowMidSize);
// 中音 → 環狀
let midSize = map(mid, 0, 255, 100, 350);
noFill();
stroke(100, 255, 100, 150);
strokeWeight(3);
ellipse(cx, cy, midSize);
// 高音 → 小粒子
let trebleCount = floor(map(treble, 0, 255, 0, 30));
for (let i = 0; i < trebleCount; i++) {
let angle = random(TWO_PI);
let radius = random(100, 300);
let x = cx + cos(angle) * radius;
let y = cy + sin(angle) * radius;
fill(150, 150, 255, 200);
noStroke();
ellipse(x, y, random(2, 6));
}
}
這裡的設計邏輯:
- 低音(bass)→ 大範圍的脈動,因為低音給人「沉重、有力」的感覺
- 中音(mid)→ 輪廓線條,中音是旋律的主體
- 高音(treble)→ 小粒子散射,高音給人「明亮、尖銳」的感覺
環形頻譜
經典的環形頻譜視覺化:
function draw() {
background(30, 30, 40);
let spectrum = fft.analyze();
let cx = width / 2;
let cy = height / 2;
let baseRadius = 150;
// 外圈頻譜
beginShape();
noFill();
for (let i = 0; i < spectrum.length; i++) {
let angle = map(i, 0, spectrum.length, 0, TWO_PI);
let amp = map(spectrum[i], 0, 255, 0, 150);
let r = baseRadius + amp;
let x = cx + cos(angle) * r;
let y = cy + sin(angle) * r;
let hue = map(i, 0, spectrum.length, 0, 360);
colorMode(HSB, 360, 100, 100);
stroke(hue, 80, 90, 80);
strokeWeight(2);
colorMode(RGB);
vertex(x, y);
}
endShape(CLOSE);
// 內圈鏡像
beginShape();
noFill();
for (let i = 0; i < spectrum.length; i++) {
let angle = map(i, 0, spectrum.length, 0, TWO_PI);
let amp = map(spectrum[i], 0, 255, 0, 80);
let r = baseRadius - amp;
let x = cx + cos(angle) * r;
let y = cy + sin(angle) * r;
stroke(255, 100);
strokeWeight(1);
vertex(x, y);
}
endShape(CLOSE);
// 中央圓
let bass = fft.getEnergy("bass");
let size = map(bass, 0, 255, 80, 200);
fill(30, 30, 50, 200);
stroke(150, 200, 255, 100);
strokeWeight(2);
ellipse(cx, cy, size);
}
波形視覺化
除了頻譜,波形(waveform)也是很棒的視覺素材:
function draw() {
background(30, 30, 40);
let waveform = fft.waveform();
// 基礎波形線
noFill();
stroke(100, 200, 255);
strokeWeight(2);
beginShape();
for (let i = 0; i < waveform.length; i++) {
let x = map(i, 0, waveform.length, 0, width);
let y = map(waveform[i], -1, 1, 0, height);
vertex(x, y);
}
endShape();
// 多層波形,加入偏移
for (let layer = 0; layer < 5; layer++) {
let alpha = map(layer, 0, 5, 200, 30);
let offset = layer * 15;
stroke(100, 200, 255, alpha);
strokeWeight(1);
noFill();
beginShape();
for (let i = 0; i < waveform.length; i++) {
let x = map(i, 0, waveform.length, 0, width);
let y = map(waveform[i], -1, 1, offset, height - offset);
vertex(x, y);
}
endShape();
}
}
即時麥克風輸入
不一定要用預錄的音樂——接入麥克風就能做即時的聲音反應裝置:
let mic;
let fft;
let amplitude;
function setup() {
createCanvas(800, 600);
// 建立麥克風輸入
mic = new p5.AudioIn();
mic.start();
// FFT 分析麥克風
fft = new p5.FFT(0.8, 128);
fft.setInput(mic);
// 振幅分析
amplitude = new p5.Amplitude();
amplitude.setInput(mic);
}
function draw() {
background(30, 30, 40);
// 取得音量
let level = amplitude.getLevel(); // 0~1
let spectrum = fft.analyze();
// 音量驅動背景圓
let bgSize = map(level, 0, 0.5, 50, width);
fill(50, 50, 70, 100);
noStroke();
ellipse(width / 2, height / 2, bgSize);
// 頻譜粒子
for (let i = 0; i < spectrum.length; i++) {
if (spectrum[i] > 50) {
let angle = map(i, 0, spectrum.length, 0, TWO_PI);
let radius = map(spectrum[i], 50, 255, 50, 250);
let x = width / 2 + cos(angle) * radius;
let y = height / 2 + sin(angle) * radius;
let size = map(spectrum[i], 50, 255, 2, 12);
let hue = map(i, 0, spectrum.length, 0, 360);
colorMode(HSB, 360, 100, 100);
fill(hue, 80, 90, 80);
noStroke();
ellipse(x, y, size);
colorMode(RGB);
}
}
// 音量指示
fill(200);
textSize(14);
text('Volume: ' + nf(level, 1, 3), 20, 30);
text('Speak or make noise!', 20, 50);
}
拍手偵測
用音量的突變來偵測「拍手」或「節拍」:
let prevLevel = 0;
let threshold = 0.15;
let beatParticles = [];
function draw() {
background(30, 30, 40, 30);
let level = amplitude.getLevel();
let delta = level - prevLevel;
// 音量突然增大 → 偵測到節拍
if (delta > threshold) {
onBeat(level);
}
prevLevel = level;
// 更新粒子
for (let i = beatParticles.length - 1; i >= 0; i--) {
let p = beatParticles[i];
p.radius += 5;
p.alpha -= 3;
noFill();
stroke(p.r, p.g, p.b, p.alpha);
strokeWeight(3);
ellipse(p.x, p.y, p.radius * 2);
if (p.alpha <= 0) {
beatParticles.splice(i, 1);
}
}
}
function onBeat(intensity) {
beatParticles.push({
x: random(width),
y: random(height),
radius: map(intensity, 0, 0.5, 10, 50),
alpha: 255,
r: random(150, 255),
g: random(100, 200),
b: random(100, 255)
});
}
進階:結合粒子系統的頻譜視覺化
把頻譜分析和粒子系統結合,創造更豐富的視覺體驗:
let particles = [];
let fft;
let song;
function preload() {
song = loadSound('music.mp3');
}
function setup() {
createCanvas(800, 600);
fft = new p5.FFT(0.8, 64);
fft.setInput(song);
}
function draw() {
background(20, 20, 30, 40);
if (!song.isPlaying()) return;
let spectrum = fft.analyze();
let bass = fft.getEnergy("bass");
let mid = fft.getEnergy("mid");
let treble = fft.getEnergy("treble");
// 根據頻段發射不同類型的粒子
if (bass > 180) {
for (let i = 0; i < 3; i++) {
particles.push(createParticle('bass'));
}
}
if (mid > 150) {
particles.push(createParticle('mid'));
}
if (treble > 120) {
for (let i = 0; i < 2; i++) {
particles.push(createParticle('treble'));
}
}
// 更新與繪製
for (let i = particles.length - 1; i >= 0; i--) {
let p = particles[i];
p.vel.add(p.acc);
p.pos.add(p.vel);
p.life -= p.decay;
p.acc.mult(0);
if (p.life <= 0) {
particles.splice(i, 1);
continue;
}
noStroke();
fill(p.r, p.g, p.b, p.life);
ellipse(p.pos.x, p.pos.y, p.size);
}
// 限制粒子數量
if (particles.length > 500) {
particles.splice(0, particles.length - 500);
}
}
function createParticle(type) {
let cx = width / 2;
let cy = height / 2;
switch (type) {
case 'bass':
return {
pos: createVector(cx + random(-50, 50), cy + random(-50, 50)),
vel: p5.Vector.random2D().mult(random(2, 5)),
acc: createVector(0, 0),
size: random(15, 30),
life: 200,
decay: 2,
r: 255, g: random(50, 100), b: random(50, 80)
};
case 'mid':
return {
pos: createVector(cx, cy),
vel: p5.Vector.random2D().mult(random(1, 3)),
acc: createVector(0, -0.05),
size: random(5, 12),
life: 180,
decay: 1.5,
r: random(100, 200), g: 255, b: random(100, 150)
};
case 'treble':
return {
pos: createVector(random(width), random(height)),
vel: createVector(0, 0),
acc: createVector(0, 0),
size: random(2, 5),
life: 150,
decay: 3,
r: random(150, 200), g: random(150, 200), b: 255
};
}
}
function mousePressed() {
if (song.isPlaying()) {
song.pause();
} else {
song.play();
}
}
這個範例中:
- 低音 → 大顆、紅色、向外爆發的粒子
- 中音 → 中等、綠色、向上漂浮的粒子
- 高音 → 小顆、藍色、隨機出現的閃爍點
小結
音訊視覺化是創意程式設計中最引人入勝的主題之一。把聽覺轉化為視覺,不只是技術練習,更是一種藝術表達。
這篇文章我們學了:
- p5.sound 的基本設定與音檔載入
- FFT 頻譜分析的原理與使用
- 頻率到視覺元素的映射策略
- 波形視覺化
- 即時麥克風輸入與節拍偵測
- 頻譜驅動的粒子系統
做音訊視覺化時,有一個我覺得很重要的原則:不要只是「呈現」數據,要用數據來驅動有美感的視覺效果。頻譜柱狀圖是最無聊的音訊視覺化——它雖然準確,但缺乏藝術感。讓數據成為靈感的來源,而不是束縛。
延伸閱讀
- p5.sound 官方文件 — 完整的 API 參考
- Daniel Shiffman, Coding Train: “Sound Visualization” 系列
- Web Audio API 規格 — 如果你想更深入底層
- Winamp 經典視覺化外掛 — 音訊視覺化的歷史經典
- Three.js + Web Audio API — 如果你想做 3D 的音訊視覺化