前言

音樂和視覺之間有一種天然的聯繫。你聽到低音鼓的重擊時,腦海中浮現的可能是深沉的脈動;高音鈴聲響起時,想到的也許是閃爍的星光。音訊視覺化(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(); } }

這個範例中:

  • 低音 → 大顆、紅色、向外爆發的粒子
  • 中音 → 中等、綠色、向上漂浮的粒子
  • 高音 → 小顆、藍色、隨機出現的閃爍點

小結

音訊視覺化是創意程式設計中最引人入勝的主題之一。把聽覺轉化為視覺,不只是技術練習,更是一種藝術表達。

這篇文章我們學了:

  1. p5.sound 的基本設定與音檔載入
  2. FFT 頻譜分析的原理與使用
  3. 頻率到視覺元素的映射策略
  4. 波形視覺化
  5. 即時麥克風輸入與節拍偵測
  6. 頻譜驅動的粒子系統

做音訊視覺化時,有一個我覺得很重要的原則:不要只是「呈現」數據,要用數據來驅動有美感的視覺效果。頻譜柱狀圖是最無聊的音訊視覺化——它雖然準確,但缺乏藝術感。讓數據成為靈感的來源,而不是束縛。

延伸閱讀

  • p5.sound 官方文件 — 完整的 API 參考
  • Daniel Shiffman, Coding Train: “Sound Visualization” 系列
  • Web Audio API 規格 — 如果你想更深入底層
  • Winamp 經典視覺化外掛 — 音訊視覺化的歷史經典
  • Three.js + Web Audio API — 如果你想做 3D 的音訊視覺化