📁 File4. 오디오05 · Waveform & Peaks — 파형을 가볍게 시각화하기

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-bit8-bit
정밀도-32768~32767-128~127
픽셀당 크기4 bytes2 bytes
시각적 차이거의 없음 (디스플레이 해상도 한계)거의 없음
적용처정밀 편집기웹 시각화 (사실상 표준)

→ 화면 픽셀 높이가 200px이면 quantization step이 200/256 ≈ 0.78px. 사람 눈이 못 알아챔.


What — Peaks.js v2 .dat 포맷

헤더 구조 (24 bytes)

OffsetSizeTypeField의미
04Int32LEversion항상 2
44UInt32LEflags0=16-bit, 1=8-bit
84Int32LEsample_rate48000
124Int32LEsamples_per_pixelsampleRate / pps
164UInt32LElength총 픽셀 수
204Int32LEchannels1=mono, 2=stereo

페이로드

각 픽셀당 (min, max) 페어. mono면 한 페어, stereo면 두 페어.

채널비트픽셀당 크기
mono82 bytes
mono164 bytes
stereo84 bytes
stereo168 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 실수

.datlittle-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을 동시에 뽑는 패턴이 클라우드 환경의 표준.