05 · Waveform & Peaks — 파형을 가볍게 시각화하기
이 문서가 답하는 질문: 1시간짜리 음원의 파형을 모바일에서 어떻게 60fps로 그리는가? 원본 PCM은 600 MB인데. 선행 지식:
01-sampling-and-quantization.md
한 줄 답
원본 PCM을 그대로 그릴 수 없으므로 시간축을 픽셀 단위로 다운샘플링해 (min, max) 한 쌍만 저장한다. BBC가 만든
audiowaveform도구가 이 변환의 사실상 표준이고, 산출물은 Peaks.js v2.dat포맷 (헤더 24바이트 + 8/16bit 페이로드). 8-bit + mono로 떨구면 16-bit stereo 대비 1/4 크기 — 프론트가 가볍게 받아 그릴 수 있다.
Why — 왜 다운샘플링이 필요한가
1) 원본은 너무 크다
44.1 kHz × 16 bit × 2ch = 176,400 bytes/s = 약 10 MB/min. 1시간 음원 = 600 MB. 모바일 데이터로 받기엔 부담.
2) 픽셀이 부족하다
1920px 너비 모니터에 1시간 음원을 그리려면 픽셀당 약 82,700 샘플. 어차피 한 픽셀에 그 많은 샘플을 다 그릴 수 없다 → 미리 (min, max)만 추출해두면 충분.
3) Random seek가 필요하다
scrubber로 임의 지점을 찾아갈 때 즉시 그려야 UX가 부드럽다 → 디코드 없는 사전 계산 데이터 필요.
→ 사전 계산 + 다운샘플링 + 컴팩트 포맷이 답.
How — 다운샘플링 알고리즘
1) Pixels-per-second (PPS)
핵심 파라미터는 초당 픽셀 수. 프론트의 줌 레벨에 맞춰 결정.
2) Min/Max 추출
각 픽셀이 담당하는 샘플 윈도우(예: 882 샘플 = 44.1k / 50pps)에서 최댓값과 최솟값만 추출.
samples_per_pixel = sample_rate // pixels_per_second
for px in range(total_pixels):
window = samples[px * spp : (px + 1) * spp]
out[px] = (min(window), max(window))→ 픽셀당 (min, max) 두 값. 8-bit이면 2 bytes/pixel, 16-bit이면 4 bytes/pixel.
3) 8-bit vs 16-bit 트레이드오프
| 항목 | 16-bit | 8-bit |
|---|---|---|
| 정밀도 | -32768~32767 | -128~127 |
| 픽셀당 크기 | 4 bytes | 2 bytes |
| 시각적 차이 | 거의 없음 (디스플레이 해상도 한계) | 거의 없음 |
| 적용처 | 정밀 편집기 | 웹 시각화 (사실상 표준) |
→ 화면 픽셀 높이가 200px이면 quantization step이 200/256 ≈ 0.78px. 사람 눈이 못 알아챔.
What — Peaks.js v2 .dat 포맷
헤더 구조 (24 bytes)
| Offset | Size | Type | Field | 의미 |
|---|---|---|---|---|
| 0 | 4 | Int32LE | version | 항상 2 |
| 4 | 4 | UInt32LE | flags | 0=16-bit, 1=8-bit |
| 8 | 4 | Int32LE | sample_rate | 48000 |
| 12 | 4 | Int32LE | samples_per_pixel | sampleRate / pps |
| 16 | 4 | UInt32LE | length | 총 픽셀 수 |
| 20 | 4 | Int32LE | channels | 1=mono, 2=stereo |
페이로드
각 픽셀당 (min, max) 페어. mono면 한 페어, stereo면 두 페어.
| 채널 | 비트 | 픽셀당 크기 |
|---|---|---|
| mono | 8 | 2 bytes |
| mono | 16 | 4 bytes |
| stereo | 8 | 4 bytes |
| stereo | 16 | 8 bytes |
실제 크기 계산
1시간 음원, 50 PPS, 8-bit, mono:
- 길이: 3600 × 50 = 180,000 픽셀
- 페이로드: 180,000 × 2 = 360,000 bytes
- 헤더 24 bytes
- 총 약 360 KB
원본 PCM 600 MB → 1,700배 압축.
Flat waveform — 오디오 없는 비디오용
비디오 스트림만 있고 오디오가 없는 경우(화면 녹화 등)도 일관된 UI를 위해 0으로 채운 .dat을 생성한다.
function generateFlatWaveform(durationSec, pps = 50, sampleRate = 48000) {
const totalPoints = Math.floor(durationSec * pps);
const buf = Buffer.alloc(24 + totalPoints * 2); // 8-bit mono
// header
buf.writeInt32LE(2, 0); // version
buf.writeUInt32LE(1, 4); // flags=8bit
buf.writeInt32LE(sampleRate, 8); // sample_rate
buf.writeInt32LE(Math.floor(sampleRate / pps), 12); // samples_per_pixel
buf.writeUInt32LE(totalPoints, 16); // length
buf.writeInt32LE(1, 20); // channels=mono
// payload: all zeros (이미 alloc이 0으로 채움)
return buf;
}→ 프론트는 audio가 있건 없건 같은 코드 경로로 렌더. 오디오 없는 영상도 빈 박스 대신 0 라인이 깔린다.
audiowaveform — BBC가 만든 도구
설치
# macOS
brew install audiowaveform
# Linux (Ubuntu)
sudo add-apt-repository ppa:chris-needham/ppa
sudo apt install audiowaveform
# Lambda layer로 구워서 사용 (서버리스 운영 사례)
# 빌드한 binary를 /opt/bin/ 에 두고 PATH에 추가기본 사용
# WAV → JSON (디버깅용)
audiowaveform -i input.wav -o output.json --output-format json
# WAV → 8-bit dat
audiowaveform -i input.wav -o output.dat \
--output-format dat \
--bits 8 \
--pixels-per-second 50
# 원하는 픽셀 수로 강제
audiowaveform -i input.wav -o output.dat \
--output-format dat \
--bits 8 \
--zoom 256
# stdin pipe (ffmpeg와 직접 연결)
ffmpeg -i video.mp4 -vn -c:a libopus -f opus pipe:1 | \
audiowaveform --input-format opus \
--output-format dat \
--pixels-per-second 50 \
--bits 8 \
-q -o output.dat→ stdin pipe가 핵심 — 영상에서 오디오를 디스크에 쓰지 않고 바로 audiowaveform으로 흘려보낸다. Lambda 환경에서 디스크 IO를 줄이는 패턴.
ffmpeg + audiowaveform 통합 파이프
ebur128 측정과 waveform 생성을 한 ffmpeg 디코드로 동시에 처리하는 패턴:
ffmpeg -i input.mp4 \
-vn \
-af ebur128=framelog=info:peak=true \
-c:a libopus -f opus pipe:1 \
| audiowaveform --input-format opus \
--output-format dat \
--pixels-per-second 50 \
--bits 8 \
-q -o output.dat \
2> ebur128.log- ffmpeg stdout → audiowaveform stdin (waveform)
- ffmpeg stderr → ebur128 framelog (loudness)
- 디코드 1회로 두 산출물
→ 큰 영상에서 처리 시간을 절반으로 줄이는 패턴. 양쪽 프로세스 종료를 둘 다 기다려야 안전 (SIGPIPE 처리 주의).
peaks.js — 프론트 렌더러
기본 사용
import Peaks from 'peaks.js';
const options = {
zoomview: { container: document.getElementById('zoomview') },
overview: { container: document.getElementById('overview') },
mediaElement: document.querySelector('audio'),
// .dat 파일 직접 로드
dataUri: {
arraybuffer: 'https://cdn.example.com/waveform.dat',
},
// 색상
zoomWaveformColor: '#3370b8',
overviewWaveformColor: '#aaaaaa',
playheadColor: '#b83333',
// 줌 레벨
zoomLevels: [256, 512, 1024, 2048, 4096],
};
Peaks.init(options, (err, peaks) => {
if (err) console.error(err);
});렌더링 알고리즘
각 픽셀당 (min, max) 페어를 수직선 하나로 그린다.
function renderWaveform(ctx, peaks, height) {
const mid = height / 2;
for (let x = 0; x < peaks.length; x++) {
const [min, max] = peaks[x];
const yMax = mid - (max / 128) * mid; // 8-bit normalize
const yMin = mid - (min / 128) * mid;
ctx.beginPath();
ctx.moveTo(x, yMin);
ctx.lineTo(x, yMax);
ctx.stroke();
}
}→ 1픽셀 1라인. 60fps에서 1920px = 1.92만 라인 = 매우 빠름.
What-if — 잘못 만들면 어떻게 깨지는가
1. PPS 너무 높음 (over-sampling)
오디오 5분에 PPS 500 → 150,000 픽셀 → 화면에서 줌해도 픽셀이 더 안 늘어남. 불필요한 트래픽 + 메모리 낭비. 줌 레벨에 맞는 PPS 선택이 중요.
2. 16-bit를 그대로 사용
웹 시각화에 16-bit가 의미 없는데 4 bytes/pixel을 그대로 보내면 모바일 트래픽 + 메모리 2배.
3. Stereo로 보내기
대부분의 시각화는 mono로 충분 (좌우가 거의 같음). stereo는 정밀 편집기에서만.
4. Header endian 실수
.dat은 little-endian이 표준. 빅엔디안 시스템에서 파일을 만들면 다른 곳에서 안 읽힘.
# Python으로 .dat 만들 때
import struct
header = struct.pack('<iIiiIi', # < = little-endian
2, 1, 48000, 960, total_points, 1)5. samples_per_pixel 미스매치
sample_rate / pps가 정수가 아니면 (예: 44100 / 50 = 882) → 정수로 떨어지지만, 44100 / 30 = 1470 같은 비정수 PPS는 반올림 오차가 누적된다. 48000 기반 + PPS는 48000의 약수로 잡으면 안전.
6. ffmpeg pipe close 타이밍
ffmpeg → audiowaveform pipe에서 ffmpeg 먼저 close되어도 audiowaveform이 stdin 데이터를 다 처리할 때까지 기다려야 한다. 양쪽 exit 이벤트를 둘 다 기다리는 게이트 필요.
let ffmpegExited = false;
let awfExited = false;
let settled = false;
function tryResolve() {
if (ffmpegExited && awfExited && !settled) {
settled = true;
resolve();
}
}
ffmpegProc.on('exit', () => { ffmpegExited = true; tryResolve(); });
awfProc.on('exit', () => { awfExited = true; tryResolve(); });7. Zero-amplitude .dat의 정상 표시
flat waveform은 모든 값이 0이라 일직선으로 표시된다 — 사용자가 “버그”로 인식할 수 있음. 해결: UI에 “오디오 없음” 라벨 추가 또는 dashed line으로 시각 차별화.
Insight — 흥미로운 이야기
“audiowaveform은 BBC가 만들었다”
2013년 BBC R&D가 라디오/팟캐스트 편집기(BBC Media Manager)용으로 개발. “방대한 라디오 아카이브의 파형을 빠르게 미리보기”라는 BBC 특수 요구가 출발점. Apache 2.0 오픈소스 + reference encoder + Peaks.js 프론트 = 사실상 표준으로 굳음. 음원 편집 도구 중 BBC가 만든 게 표준이 되는 건 드문 사례.
“.dat 헤더 24바이트의 디자인”
Version + flags + sample_rate + samples_per_pixel + length + channels. 웹용으로 만들었지만 데이터셋 단위로 쓰기 위해 자기 서술적(self-describing). 여기에 메타데이터(start_time, end_time, etc.)를 안 넣은 게 미니멀리즘.
“왜 8-bit가 표준이 됐는가”
16-bit가 자연스러워 보이지만, 화면 디스플레이 해상도 + 파형 시각화 컨벤션을 고려하면 8-bit로 충분. 8-bit는 -128~127. 화면 높이 256px이면 quantization step이 정확히 1px. “가장 작은 정밀도가 가장 큰 정밀도와 같다”의 한 사례.
“파형은 시각화이고, 파형 분석은 다른 문제다”
Min/max waveform은 눈에 보이는 음량을 그리지만, 실제 분석(BPM 검출, 비트 트래킹, 화성 분석)은 FFT 기반 spectrogram을 쓴다. 즉 .dat은 “눈으로 보는” 용도, mel-spectrogram은 “기계가 분석하는” 용도. 두 표현은 같은 신호의 서로 다른 단면.
“실시간 라이브에서 waveform은 어떻게 만드는가”
사전 계산이 불가능 → MediaSource Extensions로 들어오는 chunk마다 클라이언트가 직접 (min, max) 추출. Web Audio API의
AnalyserNode.getByteTimeDomainData로 실시간 buffer를 읽어 좌→우 스크롤로 그린다. 이때 “역사”는 클라이언트가 메모리에 쌓아둔 만큼만.
한 단락 요약 + Mermaid
파형 시각화는 시간축 다운샘플링 + (min, max) 추출 + 8-bit 압축의 3단 트릭이다. 사실상 표준은
audiowaveform+ Peaks.js v2.dat조합 — 헤더 24바이트 + 페이로드 (픽셀당 2 bytes). 1700배 압축으로 1시간 음원이 360 KB. ffmpeg과 stdin pipe로 연결해 디코드 1회로 loudness + waveform을 동시에 뽑는 패턴이 클라우드 환경의 표준.