블로그 목록
Fundamentals15분 읽기

PCM vs WAV — 44바이트 헤더의 오해, 컨테이너와 코덱의 차이

PCM은 사진 RAW 같은 인코딩 방식이고 WAV는 컨테이너. Linear PCM vs µ-law 차이, fmt chunk 읽는 법, sample rate·channel·bit depth 틀리면 깨지는 이유, 브라우저 PCM 재생(AudioContext), WAV→MP3 ffmpeg 변환까지.

PCMWAVRIFFMP3AudioContextffmpeg오디오기초

PCM과 WAV. 이름은 항상 나오는데 정확히 뭐가 다른지 물어보면 막힙니다. 핵심은 단순합니다: PCM은 소리를 숫자로 저장한 것이고, WAV는 그 숫자에 "설명서"를 붙인 파일입니다. 하지만 "44바이트 헤더", "둘 다 비압축" 같은 오해가 많아서, 시니어 엔지니어 시선으로 하나씩 검증합니다.


PCM — 소리를 숫자로 저장한 것

**PCM (Pulse Code Modulation)**은 아날로그 소리를 디지털 숫자로 변환하는 인코딩 방식입니다. 파일 포맷이 아닙니다.

마이크 → ADC → [숫자, 숫자, 숫자, ...] ← 이게 PCM

시간  0ms  → +0.5  → 16384
시간  1ms  → +1.0  → 32767
시간  2ms  → +0.3  → 9830
시간  3ms  → -0.7  → -22937
...

그냥 숫자의 나열. 헤더도 없고 메타정보도 없음.
"이 숫자가 몇 Hz로 찍은 건지" 아무 정보도 없음.

비유하면 사진의 RAW 파일과 같습니다. 가공 없이 센서 데이터 그대로. 용량은 크지만 정확합니다.


PCM의 종류 — "PCM이면 다 비압축이다?" 아닙니다

Linear PCM (LPCM) — 비압축

샘플 값을 그대로 저장. 가공 없음.

CD 음질 = 44100Hz, 16bit, 스테레오 LPCM
1초 = 44100 × 2바이트 × 2채널 = 176,400 바이트 (≈ 172KB)

[1, 2, 3, 4, 5, 6, 7, 8]  ← 숫자 그대로 저장

µ-law PCM — 로그 압축

µ-law는 PCM을 "압축"한 것입니다. 근데 압축 방식이 특이합니다.

왜 압축하냐?

PCM 원본은 용량이 큼.
특히 옛날 전화 시스템에서:
  대역폭 좁음
  데이터 빨리 보내야 함

→ "PCM 너무 크다 → 줄이자"
→ 근데 그냥 줄이면 품질 떨어짐
→ 그래서 "똑똑하게" 줄임
사람 귀의 특징:
  작은 소리 → 민감하게 구분 (속삭임 차이를 잘 느낌)
  큰 소리   → 둔감 (공사장 소음에서 약간의 차이를 못 느낌)

µ-law는 이걸 이용합니다:
  작은 소리 → 정밀하게 저장 (비트를 많이 할당)
  큰 소리   → 대충 저장 (어차피 못 느끼니까)

Linear PCM:  [1, 2, 3, 4, 5, 6, 7, 8]     ← 균일한 간격
µ-law:       [1, 2, 3, 4, 8, 12, 20, 30]   ← 작은 값은 촘촘, 큰 값은 듬성

중요한 부분은 살리고, 덜 중요한 건 줄임.

결과:
  16bit → 8bit로 줄임 (용량 절반)
  품질 일부 손실 (큰 소리 쪽에서)
  전화망(PSTN)에서 사용

핵심: **"소리를 로그 스케일로 압축"**한 것. PCM 계열이지만 비압축이 아닙니다.

PCM → µ-law, 왜 굳이 바꾸냐?

핵심 이유 3개:

1. 네트워크 대역폭 절약

  PCM (16bit, 8kHz, mono) → 128 kbps
  µ-law (8bit, 8kHz, mono) → 64 kbps

  → 딱 절반으로 줄어듦

2. 실시간 통신 (전화, VoIP)

  전화/콜센터는:
    지연(latency) 중요
    패킷 손실 있음
    네트워크 불안정

  여기서 중요한 건:
    "완벽한 음질"이 아니라
    "끊기지 않는 전달"

  → 가볍고 빠른 µ-law 사용

3. CPU 부담 거의 없음

  MP3: 인코딩/디코딩 무거움
  µ-law: 거의 연산 없음 (lookup table)

  → 서버/디바이스 부담 ↓
  → 실시간 처리에 최적
한 줄 비교:
  µ-law = 빠르게 보내기용 (실시간, 전화)
  MP3   = 작게 저장하기용 (다운로드, VOD)

디코딩 — 왜 다시 PCM으로 돌아와야 하나?

대부분의 시스템이 PCM 기준으로 돌아가기 때문입니다:

  브라우저 AudioContext → PCM 기반
  ffmpeg 처리           → PCM 기반
  WAV 파일              → PCM 기반
  믹싱, 필터, 볼륨 조절 → 다 PCM 기반

그래서:

  µ-law (압축 상태)
     ↓ 디코딩 (복원)
  PCM (처리 가능 상태)

"audio format이 1이 아니면 디코딩 필요" — 왜?

데이터가 "압축/인코딩된 상태"라서 그대로는 의미 있는 파형이 아니기 때문입니다.

✅ Linear PCM (audio format = 1)
  실제 소리 값:  [-1000, 2000, -500, ...]
  → 그대로 해석 가능 (decode 필요 없음)

❌ µ-law / MP3 / ADPCM (audio format ≠ 1)
  압축된 값:  [132, 255, 78, ...]
  → 이건 "소리 값"이 아니라 "압축된 표현"
  → 복원해야 진짜 파형이 됨

  µ-law → PCM (디코딩)
  MP3   → PCM (디코딩)

"Non-PCM 포맷은 압축된 표현이기 때문에, 후처리를 위해서는 반드시 Linear PCM으로 디코딩해야 합니다."

실제 흐름 — 소리는 항상 압축↔비압축을 반복한다

소리는 항상 이렇게 흐릅니다:

  압축 ↔ 비압축 ↔ 압축 ↔ 비압축

  저장/전송 → 압축 필요 (용량, 대역폭)
  처리/재생 → 비압축 필요 (시스템이 PCM을 기대)

케이스별 흐름:

  ✅ 전화 시스템
  PCM → µ-law → 네트워크 → µ-law → PCM → 재생

  ✅ TTS 서비스
  PCM → WAV → MP3 → 업로드 → 재생

  ✅ 전화 음성 저장
  µ-law → PCM → WAV → MP3

👉 PCM은 "기준 상태"
👉 코덱(Opus/µ-law/AAC)은 "목적에 따라 바꿔 쓰는 것"

WAV — PCM을 담는 박스

WAV는 컨테이너 포맷입니다. 박스라고 생각하면 됩니다.

PCM = 내용물 (소리 데이터)
WAV = 박스   (내용물 + 설명서)

WAV 파일
├── 헤더 (설명서)
│   ├── 샘플레이트
│   ├── 채널 수
│   ├── 비트 깊이
│   └── 코덱 (audio format)
└── 데이터 (오디오 샘플)

WAV는 그냥 "포장지".
안에 뭐가 들었는지는 헤더를 열어봐야 안다.

WAV 내부 구조 — RIFF Chunk 기반

RIFF
 └── fmt   ← 메타정보 (어떤 포맷인지)
 └── data  ← 실제 오디오 샘플

┌─────────────────────────────────────┐
│ RIFF Header (12B)                   │
│  "RIFF" + 파일크기 + "WAVE"        │
├─────────────────────────────────────┤
│ fmt Chunk (24B~)                    │
│  audio format (코덱)               │
│  채널 수                            │
│  샘플레이트                         │
│  바이트레이트                       │
│  비트 깊이                          │
├─────────────────────────────────────┤
│ [fact Chunk] (12B, non-PCM 필수)    │
├─────────────────────────────────────┤
│ [LIST, bext 등 옵셔널 Chunk...]     │
├─────────────────────────────────────┤
│ data Chunk Header (8B)              │
│  "data" + 데이터 크기              │
├─────────────────────────────────────┤
│ 오디오 샘플 데이터                   │
└─────────────────────────────────────┘

fmt Chunk — 겁먹을 필요 없음

fmt chunk는 WAV 헤더 안에 있는 정보 블록입니다. 가장 중요한 필드는 audio format:

fmt Chunk 핵심 필드:

┌──────────────┬──────────────────────────┐
│ 필드         │ 의미                     │
├──────────────┼──────────────────────────┤
│ audio format │ 코덱 (아래 표 참고)      │
│ channels     │ mono(1) / stereo(2)      │
│ sample rate  │ 1초에 몇 개 숫자 (Hz)    │
│ bit depth    │ 숫자 하나가 몇 비트      │
└──────────────┴──────────────────────────┘

audio format 값:

┌────────┬─────────────────┬──────────┐
│ 값     │ 의미            │ 압축     │
├────────┼─────────────────┼──────────┤
│ 1      │ Linear PCM      │ 비압축   │
│ 3      │ IEEE Float      │ 비압축   │
│ 6      │ A-law           │ 압축     │
│ 7      │ µ-law           │ 압축     │
└────────┴─────────────────┴──────────┘

WAV 파일 받았을 때:
  fmt chunk의 audio format부터 확인해야 합니다.
  1이면 Linear PCM, 7이면 µ-law. 처리 방법이 다릅니다.

"44바이트 헤더" — 맞기도 하고 틀리기도 함

가장 기본적인 PCM WAV의 경우:

  RIFF 헤더:        12바이트
  fmt  Chunk:        24바이트  (8B 청크헤더 + 16B 포맷정보)
  data Chunk 헤더:    8바이트
  ─────────────────────────
  합계:              44바이트  ← 여기서 나온 숫자

그래서 "오프셋 44부터 PCM 데이터 시작"이라는 공식이 퍼졌습니다.

하지만 실제로는:

  non-PCM (float, µ-law 등):
    → fmt Chunk가 18~40바이트로 커짐
    → fact Chunk 12바이트 추가 필수
    → 최소 48~68바이트

  메타데이터 포함 (녹음 소프트웨어가 추가):
    → LIST, bext, JUNK 등 옵셔널 Chunk
    → 수백 바이트까지 가능

  ⚠️ 오프셋 44 하드코딩은 위험합니다.
  → 반드시 Chunk ID를 파싱해서 "data" 청크 위치를 찾아야 함.

Raw PCM의 3가지 지뢰 — 하나라도 틀리면 깨짐

Raw PCM 파일에는 설명서가 없습니다. 샘플레이트, 채널, 비트 깊이를 외부에서 정확히 알려줘야 합니다. 하나라도 틀리면 소리가 깨집니다.

지뢰 1: Sample Rate — 1초에 숫자 몇 개?

같은 PCM 데이터를 다른 샘플레이트로 해석하면:

16kHz로 녹음한 데이터를 48kHz로 재생하면
→ 3배 빠르게 재생됨
→ 목소리가 다람쥐처럼 높아짐

48kHz로 녹음한 데이터를 16kHz로 재생하면
→ 3배 느리게 재생됨
→ 목소리가 느릿느릿 저음으로

속도와 음정이 동시에 깨집니다.

지뢰 2: Mono vs Stereo — 채널 해석 오류

Mono (1채널):
  [1][2][3][4]
  → 샘플 하나가 한 시점의 소리

Stereo (2채널, Interleaved):
  [L][R][L][R][L][R]
  → 좌/우가 번갈아 들어감

Stereo 데이터를 Mono로 착각하면:
  [L][R][L][R] → [샘플1][샘플2][샘플3][샘플4]

  좌/우 데이터가 뒤섞여서:
  → 음성 깨짐
  → STT 인식률 급락
  → 원인을 찾기 어려움 (소리가 "이상하게" 들리는 수준)

지뢰 3: Bit Depth — 숫자 하나가 몇 바이트?

16bit PCM:  숫자 하나 = 2바이트
  [00 FF] [10 02] [A3 01]
  → 2바이트씩 묶어서 읽어야 함

16bit 데이터를 8bit로 읽으면:
  [00][FF][10][02][A3][01]
  → 완전히 다른 숫자들
  → 결과: "지지직" 노이즈

비유:

  원본 데이터:    01001100 01001101

  16bit로 해석:   [0100110001001101]  → 하나의 숫자
  8bit로 해석:    [01001100][01001101] → 두 개의 다른 숫자

  → 결과 완전 다름

PCM vs WAV — 정확한 비교

┌───────────────┬──────────────────────────────┬──────────────────────────────┐
│               │         PCM (Raw)            │            WAV               │
├───────────────┼──────────────────────────────┼──────────────────────────────┤
│ 정체          │ 인코딩 방식 (코덱)           │ 컨테이너 포맷 (파일)         │
├───────────────┼──────────────────────────────┼──────────────────────────────┤
│ 비유          │ 사진 RAW 파일               │ RAW + EXIF 메타데이터        │
├───────────────┼──────────────────────────────┼──────────────────────────────┤
│ 헤더          │ 없음                         │ 있음 (가변, PCM 기본 44B)    │
├───────────────┼──────────────────────────────┼──────────────────────────────┤
│ 재생          │ 샘플레이트/비트깊이/채널을   │ fmt chunk에 메타 내장        │
│               │ 외부에서 알려줘야 재생 가능   │ → 바로 재생 가능             │
├───────────────┼──────────────────────────────┼──────────────────────────────┤
│ 압축          │ LPCM은 비압축                │ 담긴 코덱에 따라 다름        │
│               │ µ-law/A-law은 로그 압축      │ (PCM이면 비압축)             │
├───────────────┼──────────────────────────────┼──────────────────────────────┤
│ 용도          │ 스트리밍, SDK 내부 버퍼,     │ 파일 저장, 편집 소프트웨어,  │
│               │ 하드웨어 인터페이스           │ 배포                         │
├───────────────┼──────────────────────────────┼──────────────────────────────┤
│ 관계          │ WAV 안에 담기는 데이터       │ PCM을 담는 그릇 중 하나      │
│               │                              │ (AIFF, AU, RF64도 가능)      │
└───────────────┴──────────────────────────────┴──────────────────────────────┘

오디오 PCM 데이터는 단순한 숫자 배열이기 때문에, sample rate, channel 수, bit depth, byte order, signed 여부를 정확히 맞춰 해석하지 않으면 왜곡이나 노이즈가 발생합니다. WAV 같은 포맷이 존재하는 이유도 바로 이 "해석 방식을 헤더에 명시"하기 위해서입니다.

# 예: numpy로 raw PCM 읽을 때 dtype을 잘못 지정하면 바로 터짐
import numpy as np

audio = np.frombuffer(raw_bytes, dtype=np.int16)   # ✅ 16bit signed PCM
audio = np.frombuffer(raw_bytes, dtype=np.uint8)    # ❌ 완전히 다른 데이터로 해석됨

브라우저에서의 차이 — PCM vs MP3

Raw PCM — 브라우저가 직접 재생 못 함

PCM (raw) = "소리 데이터만 있음. 설명서 없음."

샘플레이트? 모름
채널 수?    모름
비트 깊이?  모름

→ ❌ new Audio(url).play() 불가능
→ ⭕ AudioContext로 직접 디코딩해야 함

MP3 — 브라우저가 바로 재생

MP3 = "압축된 오디오 파일"

용량: WAV 대비 약 1/10
품질: 조금 손실 (사람이 못 느끼는 범위)
브라우저: 완벽 지원

→ ⭕ new Audio(url).play() 가능

AudioContext로 Raw PCM 재생하는 법

브라우저는 raw PCM을 new Audio()로 못 틉니다. PCM에 샘플레이트, 채널 수, 비트 깊이 정보가 없어서 브라우저가 이 바이트들을 어떻게 읽어야 하는지 모릅니다. 직접 알려줘야 합니다.

// 16-bit signed PCM, mono, 22050Hz 기준

async function playPCMFromUrl(url) {
  // 1) raw PCM 바이트 가져오기
  const response = await fetch(url);
  const arrayBuffer = await response.arrayBuffer();

  // 2) 16-bit PCM으로 해석
  const int16 = new Int16Array(arrayBuffer);

  // 3) AudioContext 생성
  const audioContext = new AudioContext();

  const sampleRate = 22050;
  const numChannels = 1;

  // 4) AudioBuffer 만들기
  const audioBuffer = audioContext.createBuffer(
    numChannels,
    int16.length,
    sampleRate
  );

  // 5) PCM(Int16) → Float32(-1.0 ~ 1.0) 변환
  //    AudioContext는 -1.0~1.0 범위의 float을 기대함
  //    16bit PCM 범위: -32768 ~ 32767
  const channelData = audioBuffer.getChannelData(0);
  for (let i = 0; i < int16.length; i++) {
    channelData[i] = int16[i] / 32768;
  }

  // 6) 재생
  const source = audioContext.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(audioContext.destination);
  source.start();
}

WAV → MP3 변환 — 왜 굳이?

이유는 딱 3개: 용량, 비용, 호환성.

WAV:  10MB  →  저장 비용 높음, 전송 느림
MP3:   1MB  →  1/10 용량, 브라우저 바로 재생

R2/S3 저장 비용 + 트래픽 = 돈

ffmpeg 변환 명령어

ffmpeg -y -i input.wav \
  -codec:a libmp3lame \
  -b:a 64k \
  -ar 22050 \
  -ac 1 \
  output.mp3
하나씩 해석:

-i input.wav        → WAV 입력
-codec:a libmp3lame → MP3 코덱으로 변환
-b:a 64k            → 비트레이트 64kbps (음성이면 충분)
-ar 22050           → 샘플레이트 낮춤 (용량 줄이기)
-ac 1               → mono (채널 1개 → 용량 절반)

실제 파이프라인 예시 — TTS → 브라우저 재생

TTS 엔진 (Kokoro 등)
    ↓
WAV 생성 (크고 무거움)
    ↓
ffmpeg 변환
    ↓
MP3 (작고 가벼움)
    ↓
R2/S3 업로드
    ↓
브라우저
    ↓
new Audio(url).play()  ← MP3니까 바로 재생 가능

실무 케이스

1. Agora RTC SDK — Raw PCM 스트림

Agora SDK가 콜백으로 주는 오디오 데이터:

onPlaybackAudioFrame(buffer) {
  // buffer = Raw PCM (Linear PCM)
  // 헤더 없음, 메타정보 없음
  // SDK 설정에서 지정한 샘플레이트/채널로 해석해야 함

  // 예: 16000Hz, 16bit, 모노
}

→ 실시간 처리에서는 헤더 오버헤드 없는 PCM이 효율적

2. 녹음 저장 — PCM을 WAV로 래핑

// PCM 버퍼만 있으면 재생 불가 → WAV 헤더를 붙여야 함

function pcmToWav(pcmBuffer, sampleRate, numChannels, bitDepth) {
  const header = new ArrayBuffer(44);  // PCM WAV 기본 헤더
  const view = new DataView(header);

  // RIFF 헤더
  writeString(view, 0, 'RIFF');
  view.setUint32(4, 36 + pcmBuffer.byteLength, true);
  writeString(view, 8, 'WAVE');

  // fmt chunk
  writeString(view, 12, 'fmt ');
  view.setUint32(16, 16, true);           // PCM = 16바이트
  view.setUint16(20, 1, true);            // audio format: 1 = PCM
  view.setUint16(22, numChannels, true);
  view.setUint32(24, sampleRate, true);
  // ...바이트레이트, 블록얼라인, 비트깊이

  // data chunk
  writeString(view, 36, 'data');
  view.setUint32(40, pcmBuffer.byteLength, true);

  return concat(header, pcmBuffer);  // 44B 헤더 + PCM 데이터
}

3. STT API — WAV 입력 vs PCM 입력

Google Speech-to-Text API:

WAV 파일 전송 시:
  → API가 fmt chunk에서 자동으로 샘플레이트/채널 파악
  → config에 별도 지정 불필요

Raw PCM 전송 시:
  → 반드시 config에 명시해야 함
  config: {
    encoding: 'LINEAR16',
    sampleRateHertz: 16000,
    audioChannelCount: 1
  }
  → 하나라도 틀리면 인식률 0%

핵심 정리

1. PCM은 포맷이 아니라 인코딩 방식
   → 소리를 숫자로 바꾸는 규칙 (사진 RAW와 같음)

2. PCM이 다 비압축은 아님
   → Linear PCM = 비압축
   → µ-law = 로그 압축 (작은 소리 정밀, 큰 소리 대충)

3. WAV는 컨테이너 (박스)
   → fmt chunk의 audio format으로 안에 뭐가 담겼는지 확인
   → PCM 외에 float, µ-law, MP3도 담을 수 있음

4. "44바이트 헤더"는 PCM WAV의 최솟값
   → non-PCM이면 더 커짐. 오프셋 하드코딩 금지.

5. Raw PCM 쓸 때는 3가지 반드시 명시
   → sample rate, channels, bit depth
   → 하나라도 틀리면 소리 깨짐, STT 실패

6. 브라우저에서 PCM 직접 재생 불가
   → AudioContext로 수동 디코딩 필요
   → MP3/WAV로 변환하면 new Audio()로 바로 재생

© 2026 Frank Kim. All rights reserved.