블로그 목록
Fundamentals15분 읽기

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

PCM encoding과 RIFF/WAVE container를 서로 다른 계층으로 구분합니다. WAVE의 chunk 구조 때문에 audio data offset을 44로 고정하면 안 되는 이유, linear PCM과 G.711 µ-law의 차이, sample rate·channel count·sample format을 잘못 해석했을 때 생기는 증상을 설명합니다.

PCMWAVRIFFMP3AudioContextffmpeg오디오기초

PCM과 WAV는 같은 층위의 개념이 아닙니다. PCM은 오디오 신호를 수치로 부호화하는 방식이고, WAVE는 오디오 데이터와 해석 정보를 청크로 담는 RIFF 기반 컨테이너입니다. "44바이트 헤더", "WAV는 항상 비압축" 같은 오해를 중심으로 차이를 정리합니다.


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

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

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

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

Raw PCM 바이트 스트림이라면 헤더도 메타정보도 없음.
"이 숫자가 몇 Hz로 찍은 건지" 아무 정보도 없음.

여기서 "raw"는 카메라 RAW 파일이 아니라, 컨테이너 헤더 없이 샘플 바이트만 이어진 스트림이라는 뜻입니다. PCM 자체는 믹싱·필터링·리샘플링된 결과일 수도 있습니다.


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는 선형 신호를 로그에 가까운 비선형 규칙으로 companding한 뒤 8비트 코드워드로 표현하는 G.711 방식입니다.

왜 압축하냐?

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

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

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

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

작은 진폭 구간에는 상대적으로 더 촘촘한 양자화 단계를 배정합니다.

결과:
  16bit → 8bit로 줄임 (용량 절반)
  선형 16비트 PCM보다 양자화 정밀도는 낮음
  전화망(PSTN)에서 사용

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

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

핵심 이유 3개:

1. 네트워크 대역폭 절약

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

  → 딱 절반으로 줄어듦

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

  전화/콜센터는:
    지연(latency) 중요
  8kHz 샘플링·8비트 코드워드로 64kbps의 일정한 전송률을 제공

  단, G.711 자체가 패킷 손실을 복구하는 것은 아님

3. 비교적 단순한 변환

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

  → 서버/디바이스 부담 ↓
  → 구현 비용이 낮지만, 어떤 실시간 서비스에나 최적이라는 뜻은 아님
한 줄 비교:
  µ-law = 빠르게 보내기용 (실시간, 전화)
  MP3   = 작게 저장하기용 (다운로드, VOD)

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

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

  브라우저 AudioContext → PCM 기반
  ffmpeg 처리           → PCM 기반
  오디오 처리            → 대개 PCM 샘플 기반
  믹싱, 필터, 볼륨 조절 → 다 PCM 기반

그래서:

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

audio format tag는 해석 규칙을 고른다

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

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

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

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

WAVE format tag 3인 IEEE Float와 WAVE_FORMAT_EXTENSIBLE처럼 tag 1이 아니어도 비압축 샘플인 경우가 있습니다. 따라서 audio format != 1을 곧바로 "압축"으로 판정하지 말고, tag와 extensible subformat을 해석한 뒤 압축 코덱인 경우에만 디코더를 거칩니다.

실제 흐름 — 처리 지점에서 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] (포맷에 따라 필요)      │
├─────────────────────────────────────┤
│ [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 데이터 시작"이라는 공식이 퍼졌습니다.

하지만 실제로는:

  확장 포맷이나 압축 코덱:
    → fmt Chunk가 16바이트보다 커질 수 있음
    → 포맷에 따라 fact 등 추가 Chunk가 필요할 수 있음

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

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

Raw PCM 해석에 필요한 항목

Raw PCM 파일에는 설명서가 없습니다. 샘플레이트, 채널, 비트 깊이뿐 아니라 byte order, signed/unsigned 또는 float 여부, 채널 배치도 외부에서 정확히 알려줘야 합니다.

지뢰 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='<i2')      # ✅ little-endian 16bit signed PCM
audio = np.frombuffer(raw_bytes, dtype=np.uint8)    # ❌ 완전히 다른 데이터로 해석됨

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

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

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

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

→ ❌ new Audio(url).play() 불가능
→ ⭕ 애플리케이션이 계약된 포맷대로 샘플을 해석해 AudioBuffer에 복사해야 함

MP3 — 브라우저가 바로 재생

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

용량: 비트레이트와 재생 시간에 따라 결정
품질: 손실 정도는 인코더·비트레이트·음원에 따라 다름
브라우저: 실제 대상 브라우저에서 `canPlayType()` 또는 Media Capabilities로 확인

→ ⭕ 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);
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  const arrayBuffer = await response.arrayBuffer();

  // 2) little-endian 16-bit PCM으로 해석
  const view = new DataView(arrayBuffer);
  const sampleCount = Math.floor(arrayBuffer.byteLength / 2);

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

  const sampleRate = 22050;
  const numChannels = 1;

  // 4) AudioBuffer 만들기
  const audioBuffer = audioContext.createBuffer(
    numChannels,
    sampleCount,
    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 < sampleCount; i++) {
    channelData[i] = view.getInt16(i * 2, true) / 32768;
  }

  // 6) 재생
  const source = audioContext.createBufferSource();
  source.buffer = audioBuffer;
  source.connect(audioContext.destination);
  await audioContext.resume();
  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           → 출력 샘플레이트 변경. 고정 64kbps에서는 파일 크기를 직접 줄이지 않음
-ac 1               → mono (채널 1개 → 용량 절반)

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

WAV를 출력하도록 설정한 TTS 엔진
    ↓
WAV 생성 (크고 무거움)
    ↓
ffmpeg 변환
    ↓
MP3 (작고 가벼움)
    ↓
R2/S3 업로드
    ↓
브라우저
    ↓
new Audio(url).play()  ← MP3니까 바로 재생 가능

실무 케이스

1. Agora RTC SDK — Raw PCM 스트림

오디오 프레임 observer API가 제공하는 데이터의 일반적인 형태:

onPlaybackAudioFrame(buffer) {
  // buffer = Raw PCM (Linear PCM)
  // 헤더 없음, 메타정보 없음
  // 사용 중인 SDK 버전의 콜백 계약과 설정에서 샘플레이트/채널을 확인

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

Agora의 콜백 이름·지원 샘플레이트·채널 수는 SDK와 버전에 따라 다르므로 해당 버전의 API 레퍼런스를 기준으로 구현해야 합니다.

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);
  const blockAlign = numChannels * (bitDepth / 8);
  view.setUint32(28, sampleRate * blockAlign, true);
  view.setUint16(32, blockAlign, true);
  view.setUint16(34, bitDepth, true);

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

  const output = new Uint8Array(44 + pcmBuffer.byteLength);
  output.set(new Uint8Array(header), 0);
  output.set(new Uint8Array(pcmBuffer), 44);
  return output.buffer;
}

function writeString(view, offset, value) {
  for (let i = 0; i < value.length; i++) {
    view.setUint8(offset + i, value.charCodeAt(i));
  }
}

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

Google Speech-to-Text API:

지원되는 WAV/FLAC 파일 전송 시:
  → encoding과 sample rate를 생략하면 헤더에서 자동 감지할 수 있음
  → 값을 명시했다면 헤더와 일치해야 하며, API 버전의 요청 스키마도 확인

Raw PCM 전송 시:
  → 반드시 config에 명시해야 함
  config: {
    encoding: 'LINEAR16',
    sampleRateHertz: 16000,
    audioChannelCount: 1
  }
  → 실제 바이트 형식과 설정이 다르면 오류나 심각한 인식 품질 저하가 발생

핵심 정리

1. PCM은 오디오 신호를 수치로 부호화하는 방식
   → raw PCM은 그 샘플 바이트에 별도 컨테이너 헤더가 없는 형태

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

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

4. "44바이트 헤더"는 가장 단순한 PCM WAVE 배치
   → 확장·메타데이터 청크가 있으면 더 커짐. 오프셋 하드코딩 금지.

5. Raw PCM은 포맷 계약을 모두 명시
   → sample rate, channels, bit depth, endianness, signed/float, channel layout

6. 브라우저에서 raw PCM URL을 미디어 파일처럼 바로 재생할 수 없음
   → 포맷대로 해석해 AudioBuffer를 만들거나 WAVE 같은 컨테이너로 래핑
   → MP3/WAV로 변환하면 new Audio()로 바로 재생

관련 글

참고 자료

© 2026 Frank Kim. All rights reserved.