블로그 목록
Fundamentals15분 읽기

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

"WAV는 무조건 44바이트 헤더 다음부터 데이터다", "PCM이면 다 비압축이다" 이런 말을 믿고 코딩하다 소리가 깨진 적 있나요? PCM은 소리를 숫자로 저장한 인코딩 방식이고 WAV는 그 숫자에 설명서를 붙인 컨테이너라는 차이부터, µ-law 같은 압축 PCM의 존재, 그리고 오프셋 44 하드코딩이 왜 위험한지까지 짚습니다. 샘플레이트와 채널, 비트 깊이를 잘못 해석하면 어디서 음성이 망가지는지, 브라우저와 STT 실무에서 무엇을 챙겨야 하는지 정리했습니다.

PCMWAVRIFFMP3AudioContextffmpeg오디오기초
목차(31개 항목)

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.