안녕하세요, 신동민입니다..! 소켓 통신으로 채팅 어플리케이션을 만든다고 하면 상대에게 문자열 데이터를 보내면 됩니다. 그럼 음성 채팅은 어떤 데이터를 보내야 할까요? 이것을 알기 위해서는 먼저 기본적인 디지털 오디오가 어떻게 저장되는지 알아야 합니다. 이를 위해서 비압축 포멧으로 가장 널리 쓰이는 WAV에 대해서 알아보겠습니다.
디지털 오디오의 기본: PCM에 대해서
소리는 공기의 압력 변화로 발생하는 파동이며 이것을 마이크를 사용해서 전기 신호로 변환할 수 있습니다. 즉, 소리의 진폭과 주파수 변화를 전압의 변화로 표현합니다. 컴퓨터에서 오디오를 저장하기 위해선 이 전기신호를 디지털 데이터로 변환하는 과정이 필요합니다.
PCM(Pulse-code modulation)은 아날로그 신호를 디지털로 표현하는 방법 중 하나입니다. 아날로그 신호를 특정한 시간 간격으로 측정(Sampling)하고, 측정된 각 시점에서의 신호 값을 디지털 값으로 변환합니다. 이 과정에서 아날로그 신호값은 가장 가까운 디지털 값으로 양자화(Quantization) 됩니다. 특히 많이 쓰이는 LPCM(Linear PCM)은 양자화 단계가 선형적으로 균일한 PCM입니다. WAV파일에도 이 LPCM이 사용됩니다. LPCM은 예시 사진으로 바로 이해할 수 있습니다.
위 다이어그램은 아날로그 신호를 4Hz, 4bit로 PCM으로 샘플링하고 양자화한 예시입니다. 특히 3.5와 3.75 시점에서 아날로그 신호가 4bit 범위의 바깥으로 넘는 것을 볼 수 있습니다 (-8을 넘어서는 그래프). 이런 경우 디지털 클리핑(Clipping)이 일어납니다. 더 큰 진폭은 4bit로 더이상 담을 수 없기 때문에 정보의 유실이 생깁니다.
두 PCM 데이터를 합칠 때(예시: 소리1과 소리2의 믹싱) 특히 클리핑이 발생할 가능성이 큽니다. 만약 프로그래밍을 잘못해서 오버플로우가 발생한다면, 결과물의 PCM은 도저히 들을 수 없을 정도의 노이즈가 있을 것입니다. 두 PCM 데이터를 합치는 로직을 작성할 때, 항상 포화 산술(Saturation arithmetic)(최대값과 최소값 사이의 고정된 범위로 제한되는 산술)을 이용하는 것이 좋습니다.
RIFF에 대해서
WAV 파일을 다루기 전에 RIFF에 대해서 먼저 간단히 다루고 가겠습니다. WAV 파일이 RIFF 형식을 기반으로 하여 저장되기 때문입니다. RIFF파일은 여러 청크라고 불리는 데이터 블록들로 구성되어 있습니다. 디지털 오디오 뿐만 아니라 영상 등 여러 분야에 쓰이고 있습니다.
청크는 다음과 같이 이루어져 있습니다:
- Chunk ID: 4개의 ASCII 문자로 이루어진 청크의 고유 식별자입니다.
- Chunk Size: 청크 데이터의 크기(바이트)를 나타내는 unsigned 4바이트 정수입니다. Little Endian으로 저장되어야 합니다!
- Chunk Data: 청크의 실제 데이터들입니다. Chunk Size만큼의 크기를 가집니다.
- Pad Byte: 만약 Chunk Size의 값이 홀수라면 청크의 마지막에 1바이트의 패딩을 합니다. 즉 쓰이지 않는 1바이트를 청크 뒤에 추가합니다.
Chunk ID가 "RIFF"인 청크, 즉 RIFF 청크가 파일의 시작을 나타냅니다. 마치 <html>태그 안에 모든 태그들이 있는것처럼, RIFF청크는 모든 청크의 부모 청크입니다. 형식은 다음과 같습니다.
- Chunk ID: 문자 "RIFF"가 들어갑니다.
- File Size: 4바이트(FileType의 크기) + Data의 크기를 Little Endian으로 저장합니다. 즉, 전체 파일 크기 - 8바이트(ChunkID와 FileSize 크기를 뺀 값)이 들어갑니다.
- File Type: 파일의 타입을 4바이트 문자열로 표현합니다. 특히 WAV파일은 이곳에 "WAVE"가 들어갑니다.
- Data: 하위 청크들이 나열됩니다.
WAV의 구조
WAV 파일은 적어도 다음의 3개의 청크로 구성되어 있습니다: "RIFF", "fmt ", "data"
모든 WAV 파일이 3개 이상의 청크를 가질 수 있지만 (노래의 가사가 적힌 청크 등), WAV 파일은 저 3개의 청크를 반드시 포함해야 합니다. 상세 구조는 다음과 같습니다.
RIFF청크는 위에 설명했으니 생략하고 fmt청크에 대해 설명드리겠습니다. fmt 청크는 오디오 데이터의 포맷을 정의합니다.
- Chunk ID: 문자 "fmt "가 들어갑니다. 뒤에 공백이 있습니다.
- Chunk Size: 청크 사이즈는 16 고정입니다. Little Endian으로 저장되어야 합니다. 참고로 16은 가장 기본적인 fmt청크 크기입니다. 더 확장된 18, 40도 있지만, 이번에는 기본적인 PCM 포맷의 오디오를 다룰 것이기 때문에 설명을 생략하겠습니다.
- wFormatTag: 2바이트 unsigned 정수형 입니다. Little Endian으로 1이 저장됩니다. 1의 의미는 뒤에 나올 데이터가 LPCM 데이터라는 의미입니다. 1 외의 값들은 Win32 API의 mmeapi.h에서 전부 확인할 수 있지만 대부분의 WAV는 LPCM데이터들이고 Non-PCM은 추가적인 청크(fact Chunk)가 필요하기 때문에 넘기겠습니다.
- nChannels: 2바이트 unsigned 정수형, 오디오 데이터의 채널 수입니다. mono라면 1, 스테레오라면 2를 저장하면 됩니다. Little Endian으로 저장됩니다.
- nSamplePerSec: 4바이트 unsigned 정수형, 오디오의 샘플 속도를 저장합니다. 일반적인 CD품질은 44,100Hz이며 가장 널리 쓰입니다. Little Endian으로 저장됩니다.
-
nAvgBytesPerSec: 4바이트 unsigned 정수형, 필요한 데이터 전송 속도
입니다(Bytes per Sec).
예를 들어 8,000Hz의 16비트 mono 오디오의 nAvgBytesPerSec 값 = 8,000Hz * 2바이트(16bit) * 1채널 = 16,000 입니다.
Little Endian으로 저장됩니다. -
nBlockAlign: 2바이트 unsigned 정수형, 블록/프레임의 크기입니다.
예를 들어 16비트 스테레오 오디오의 nBlockAlign 값 =2바이트(16bit) * 2채널 = 4 입니다.
Little Endian으로 저장됩니다. - wBitsPerSample: 2바이트 unsigned 정수형, 하나의 샘플당 비트 수 입니다. 자주 사용되는 값은 8bit, 16bit, 24bit 입니다. 이중에서 16bit가 가장 널리 쓰입니다. Little Endian으로 저장됩니다.
다음으로 data 청크는 Chunk ID가 "data"인 청크입니다. Chunk Data에 실제로 PCM 데이터가 들어갑니다.
- Chunk ID: 문자 "data"가 들어갑니다.
- PCM Size: 4바이트 unsigned 정수형 입니다. PCM Data의 크기가 들어갑니다. Little Endian으로 저장됩니다.
- PCM Data: PCM 데이터가 들어갑니다.
그렇다면 이 PCM Data는 어떤 값이 들어가야 할까요? 다음과 같습니다.
위 표는 3Hz, 16bit, 2ch PCM 바이너리의 예시입니다.
블록이 초당 3번씩 나오며 하나의 블록은 4바이트의 크기를 가집니다. 즉 이 PCM의 BlockAlign 값은 4 입니다.
또한 초당 바이트의 크기는 (블록의 크기) * (SampleRate) = 12바이트 입니다. 즉 AvgBytesPerSec 값은 12 입니다.
Left Channel의 샘플을 시작으로 Right Channel의 샘플이 번갈아서 나옵니다. 모든 값들은 Little Endian으로 저장되어야 합니다. 위의 예시에서 0x7B 0x7D는 즉 0x7D7B이며 이것을 부호있는 2의 보수에서 10진수로 변환하면 32,123입니다.
C++로 WAV파일 만들기
WAV의 구조에 대해 알았으니 그러면 바로 C++로 간단한 sin파 WAV파일을 만들어 보도록 하겠습니다.
먼저 위에서 봤던 규격에 따라서 WAV 구조체를 선언합니다.
// Wave.h
#pragma once
#include <cstdint>
#include <cstring>
#pragma pack(push, 1)
typedef struct {
char chunkID[4];
uint32_t fileSize;
char fileType[4];
} RIFF;
typedef struct {
char chunkID[4];
uint32_t chunkSize;
uint16_t wFormatTag;
uint16_t nChannels;
uint32_t nSamplePerSec;
uint32_t nAvgBytesPerSec;
uint16_t nBlockAlign;
uint16_t wBitsPerSample;
} FMT;
typedef struct {
char chunkID[4];
uint32_t chunkSize;
} DATA;
typedef struct WAVE_HEADER {
RIFF riff;
FMT fmt;
DATA data;
WAVE_HEADER(int sampleRate, int numChannels, int bitDepth, size_t pcmSize) {
// RIFF Chunk
memcpy(riff.chunkID, "RIFF", 4);
riff.fileSize = pcmSize + sizeof(RIFF) + sizeof(FMT) + sizeof(DATA) - 8;
memcpy(riff.fileType, "WAVE", 4);
// fmt Chunk
memcpy(fmt.chunkID, "fmt ", 4);
fmt.chunkSize = 16;
fmt.wFormatTag = 1;
fmt.nChannels = numChannels;
fmt.nSamplePerSec = sampleRate;
fmt.nAvgBytesPerSec = sampleRate * numChannels * bitDepth / 8;
fmt.nBlockAlign = numChannels * bitDepth / 8;
fmt.wBitsPerSample = bitDepth;
// data Chunk
memcpy(data.chunkID, "data", 4);
data.chunkSize = pcmSize;
}
} WAVE_HEADER;
#pragma pack(pop)
RIFF청크와 fmt청크, data청크의 구조체를 정의했습니다. data청크 구조체의 SampledData를 담는 부분은 Data의 길이가 가변적이기 때문에 제외했습니다.
#pragma pack 지시어로 구조체를 패딩 없이 메모리에 타이트하게 배치되도록 합니다. (없어도 상관없긴 합니다)
WAVE_HEADER 구조체의 마지막에는 생성자를 정의해서 각 청크들의 멤버값들을 채워줍니다.
다음으로 sin파 pcm 데이터를 생성해보겠습니다. 전체 코드를 보시죠.
// main.cpp
#include <cmath>
#include <cstdint>
#include <fstream>
#include <iostream>
#include <limits>
#include <vector>
#include "Wave.h"
constexpr auto SAMPLE_RATE = 48000;
constexpr auto NUM_CHANNELS = 1;
constexpr auto BIT_DEPTH = 16;
constexpr auto DURATION = 5;
constexpr auto PI = 3.141592653589793;
constexpr auto GAIN = 10000;
constexpr auto PITCH = 261.63;
template<typename Target, typename Source>
Target clipping(Source integer) {
if (integer > static_cast<Source>(std::numeric_limits<Target>::max())) {
return std::numeric_limits<Target>::max();
} else if (integer < static_cast<Source>(std::numeric_limits<Target>::min())) {
return std::numeric_limits<Target>::min();
}
return static_cast<Target>(integer);
}
int main() {
std::vector<int16_t> pcm;
for (int i = 0; i < DURATION * SAMPLE_RATE * NUM_CHANNELS; ++i) {
int64_t value = static_cast<int64_t>(GAIN * sin(2 * PI * i * PITCH / SAMPLE_RATE));
pcm.push_back(clipping<int16_t, int64_t>(value));
}
WAVE_HEADER header(
SAMPLE_RATE,
NUM_CHANNELS,
BIT_DEPTH,
pcm.size() * sizeof(int16_t)
);
std::ofstream wavFile("result.wav", std::ios::binary);
wavFile.write(reinterpret_cast<char*>(&header), sizeof(WAVE_HEADER));
wavFile.write(reinterpret_cast<char*>(pcm.data()), pcm.size() * sizeof(int16_t));
wavFile.close();
return 0;
}
코드가 복잡하죠.. 천천히 설명하겠습니다.
먼저 상수들에 대한 설명입니다.
- SAMPLE_RATE: PCM의 샘플레이트 입니다.
- NUM_CHANNELS: PCM의 채널 수 입니다.
- BIT_DEPTH: PCM의 BitDepth 입니다.
- DURATION: PCM의 지속 시간입니다. 초 단위 입니다.
- PI: 원주율 값입니다.
- GAIN: sin 함수의 치역은 [-1, 1] 입니다. 너무 작은 값이기 때문에 소리를 키워야 합니다. 소리를 얼마나 키울지에 대한 값입니다.
- PITCH: sin 함수의 주기 입니다. 특히 261.63Hz는 피아노의 4번째 도, 가온다(C4) 값입니다. 나머지 음들의 주파수는 여기서 확인할 수 있습니다.
다음으로 clipping 함수에 대한 설명입니다.
template<typename Target, typename Source>
Target clipping(Source integer) {
if (integer > static_cast<Source>(std::numeric_limits<Target>::max())) {
return std::numeric_limits<Target>::max();
} else if (integer < static_cast<Source>(std::numeric_limits<Target>::min())) {
return std::numeric_limits<Target>::min();
}
return static_cast<Target>(integer);
}
하나의 샘플 데이터는 위 코드에서 16bit 입니다. sin함수의 리턴 타입은 double이고 이것을 int64_t로 타입 변환 후 int16_t PCM 배열에 넣는다면 오버플로우가 발생할 수 있습니다. (물론 GAIN값이 32,768 이상일때 오버플로우가 발생합니다.)
위에서 설명했듯이 오버플로우는 신호를 엉망으로 만듭니다. int16_t의 범위를 벗어나는 값이 입력되면 int16_t의 최대/최소값으로 클리핑하는 함수입니다.
범용적으로 쓰기 위해 템플릿 함수로 구현했지만, 만약 BIT_DEPTH가 고정이라면 굳이 템플릿 함수로 작성할 필요는 없습니다.
다음으로 PCM 데이터를 만드는 부분에 대한 설명입니다.
std::vector<int16_t> pcm;
for (int i = 0; i < DURATION * SAMPLE_RATE * NUM_CHANNELS; ++i) {
int64_t value = static_cast<int64_t>(GAIN * sin(2 * PI * i * PITCH / SAMPLE_RATE));
pcm.push_back(clipping<int16_t, int64_t>(value));
}
PCM 데이터를 담을 int16_t 벡터 컨테이너를 만든 후 여기에 sin함수값들을 담아줍니다.
수식은 다음과 같습니다: \( f(i) = g \cdot sin(\frac{2 \pi p }{s} i)\)
g = gain, p = pitch,
s = SampleRate
참고로 데이터는 Little Endian으로 저장되어야 하는데, 현대 컴퓨터는 대부분 Little Endian이기 때문에 그냥 바로 값을 벡터 컨테이너에 집어넣어도 괜찮습니다.
마지막으로 WAV 바이너리 파일을 쓰는 부분에 대한 설명입니다.
WAVE_HEADER header(
SAMPLE_RATE,
NUM_CHANNELS,
BIT_DEPTH,
pcm.size() * sizeof(int16_t)
);
std::ofstream wavFile("result.wav", std::ios::binary);
wavFile.write(reinterpret_cast<char*>(&header), sizeof(WAVE_HEADER));
wavFile.write(reinterpret_cast<char*>(pcm.data()), pcm.size() * sizeof(int16_t));
wavFile.close();
위에서 정의했던 WAVE_HEADER 구조체 변수를 만든 다음 출력 파일 스트림(ofstream)을 사용하여
바이너리모드로 구조체를 그대로 파일에 씁니다. 구조체를
reinterpret_cast<char*>
를 사용해서 바이트 배열로 취급하고 WAVE_HEADER와 pcm을
순서대로 파일에 쓴 다음 파일을 닫습니다.
이제 코드를 빌드하고 실행하면 디렉토리에 result.wav 파일이 있을겁니다!
응용하기!
C4 소리만 나는 sin파 pcm을 만들어 보았습니다. 그렇다면 이제 C4 + E4 + G4 소리 3개를 섞어서 C코드 소리를 만들어 봅시다!
소리의 믹싱은 단순히 두 신호를 더하기만 하면 됩니다.
그렇다면 3개의 신호를 만들어서 다 더한 뒤 pcm 벡터 컨테이너에 넣으면 되겠죠?
for (int i = 0; i < DURATION * SAMPLE_RATE * NUM_CHANNELS; ++i) {
constexpr auto pitchC4 = 261.63;
constexpr auto pitchE4 = 329.63;
constexpr auto pitchG4 = 392.0;
double c4 = GAIN * sin(2 * PI * i * pitchC4 / SAMPLE_RATE);
double e4 = GAIN * sin(2 * PI * i * pitchE4 / SAMPLE_RATE);
double g4 = GAIN * sin(2 * PI * i * pitchG4 / SAMPLE_RATE);
int64_t value = static_cast<int64_t>(c4 + e4 + g4);
pcm.push_back(clipping<int16_t, int64_t>(value));
}
마무리
WAV를 C++로 만드는 방법과 PCM의 구조에 대해서 알아보았습니다. 앞으로 음성 통신 애플리케이션을 만들때 이 PCM 데이터를 압축한 데이터가 결국 보내고 받는 데이터가 됩니다. WAV를 작성하는 방법을 배운 이유는 WAV가 대표적인 비압축 오디오 포맷인 이유도 있지만, 앞으로 음성 통신 앱을 만들어보고 주고 받는 데이터가 유효한지 확인해야 할 때 이 데이터는 눈으로 확인할 수 없고 대부분 귀로 들어서 확인해야 하기 때문입니다. 이때 C++로 WAV 파일을 쓰는 방법은 꽤 유용하게 쓰일 것입니다.
이 포스트의 주 목적은 LPCM 데이터를 다루는 방법을 알아보기 위함이였습니다. 그래서 WAV의 전체적인 스펙이 아닌 최소한의 스펙만 다루었습니다. WAV 파일의 자세한 명세는 여기서 확인할 수 있습니다.
긴 글 읽어주셔서 감사합니다. 다음 포스트에서 뵙겠습니다!