우리가 듣는 소리는 연속적인(Analog) 신호이다. 소리 데이터가 컴퓨터에서 연산되기 위해서는 이산적으로(Digital) 변환되어야 한다. 이를 아날로그-디지털 변환(ADC, Analog-Digital Convert)이라고 한다. ADC 과정은 CS보다는 전자 공학에 가깝다. 그래서 다른 블로그에 정리했다. 이 글을 읽기 전에 저 글을 먼저 읽으면 도움이 될 것이다.
* JUCE 프레임워크 코드 예시로 작성된 포스팅입니다.
샘플은 아날로그 신호를 일정한 간격으로 측정한 값이다. 즉, 디지털 오디오의 가장 작은 단위이다. 샘플의 개수는 샘플링 레이트(Sampling Rate)에 의해 결정된다. 단위는 Hz인데, 1초 동안 몇 개의 샘플을 취하는지 나타낸다.
대중 음악에서 가장 보편적인 샘플링 레이트는 44.1kHz인데, 1초에 44100개의 샘플을 취한다. 샘플링 레이트가 높을 수록 음성 데이터는 메모리를 많이 차지하고, 낮을 수록 원래 음성 데이터를 잘 나타내지 못한다.
juce::AudioBuffer<float> buffer (2, 512); // 스테레오 버퍼 (2채널, 512 샘플)
for (int channel = 0; channel < buffer.getNumChannels(); ++channel) {
float* channelData = buffer.getWritePointer(channel); // 채널별 샘플에 대한 포인터 가져오기
for (int i = 0; i < buffer.getNumSamples(); ++i) {
// 각 샘플을 0.5로 설정 (진폭이 50%로 줄어듦)
channelData[i] = channelData[i] * 0.5f;
}
}
버퍼는 여러 샘플을 담는 메모리 공간이다. 버퍼를 사용해 여러 개의 오디오 샘플을 한 번에 처리할 수 있다. 버퍼는 여러 개의 채널(channel) 단위로 구성된다. 이때, 채널은 오디오 신호의 경로를 의미한다. 단일 사운드로 이루어진 오디오를 모노, 좌우 다르게 들리는 오디오를 스테레오라고 한다.
juce::AudioBuffer<float> buffer (2, 512); // 2채널 (스테레오) 버퍼, 512개의 샘플
buffer.clear(); // 버퍼를 0으로 초기화, 메모리에 남아있는 이전 데이터 혹은 불필요한 값을 제거함
// 왼쪽 채널에 샘플 값을 설정 (0번째 채널)
float* leftChannel = buffer.getWritePointer(0); // 0번째 채널 = 왼쪽 채널
for (int i = 0; i < buffer.getNumSamples(); ++i) {
leftChannel[i] = 0.5f; // 모든 샘플을 0.5로 설정 (왼쪽 채널)
}
// 오른쪽 채널에 샘플 값을 설정 (1번째 채널)
float* rightChannel = buffer.getWritePointer(1); // 1번째 채널 = 오른쪽 채널
for (int i = 0; i < buffer.getNumSamples(); ++i) {
rightChannel[i] = -0.5f; // 모든 샘플을 -0.5로 설정 (오른쪽 채널)
}
모노에서 사인파 생성 후 스테레오 변환(동일한 각 채널로 확장)
juce::AudioBuffer<float> monoBuffer(1, 512); // 모노 버퍼 (1채널, 512 샘플)
juce::AudioBuffer<float> stereoBuffer(2, 512); // 스테레오 버퍼 (2채널, 512 샘플)
// 모노 버퍼에 샘플 값을 설정 (단일 채널)
float* monoChannel = monoBuffer.getWritePointer(0);
for (int i = 0; i < monoBuffer.getNumSamples(); ++i) {
monoChannel[i] = std::sin(2.0f * M_PI * 440.0f * i / 44100.0f); // 사인파 생성
}
// 모노 데이터를 스테레오 버퍼로 복사 (양쪽 채널에 동일한 값)
float* leftChannel = stereoBuffer.getWritePointer(0);
float* rightChannel = stereoBuffer.getWritePointer(1);
for (int i = 0; i < stereoBuffer.getNumSamples(); ++i) {
leftChannel[i] = monoChannel[i]; // 왼쪽 채널에 모노 데이터 복사
rightChannel[i] = monoChannel[i]; // 오른쪽 채널에도 동일한 데이터 복사
}
블록은 오디오 데이터를 작은 단위로 처리하기 위한 샘플들의 묶음이다. 버퍼와는 다른 점은 버퍼는 실제 메모리 공간, 블록은 특정 시간 동안 버퍼에서 처리하는 단위를 의미한다. 주로 오디오 프로세서가 한 번에 처리하는 샘플들의 집합을 블록이라고 한다.
Low Cut Filter를 적용하는 코드
// 2채널, 512 샘플 버퍼 생성
juce::AudioBuffer<float> buffer (2, 512);
// AudioBlock을 생성하여 버퍼를 감쌈
juce::dsp::AudioBlock<float> audioBlock(buffer);
// Low Cut 필터 생성 및 설정
juce::dsp::IIR::Filter<float> lowCutFilter;
auto coefficients = juce::dsp::IIR::Coefficients<float>::makeHighPass(44100.0, 200.0); // 200Hz 이상만 통과시키는 필터
lowCutFilter.coefficients = coefficients;
// Low Cut 필터를 적용하기 위한 프로세서 준비
juce::dsp::ProcessSpec spec;
spec.sampleRate = 44100.0;
spec.maximumBlockSize = (juce::uint32) buffer.getNumSamples();
spec.numChannels = (juce::uint32) buffer.getNumChannels();
lowCutFilter.prepare(spec);
// Low Cut 필터를 각 채널에 적용 (AudioBlock 사용)
juce::dsp::ProcessContextReplacing<float> context(audioBlock);
lowCutFilter.process(context);
프로세서는 오디오 데이터를 처리하는 객체이다. 흔히 접하는 오디오 효과인 EQ, 컴프레서, 리버브 등은 모두 프로세서라고 생각하면 된다. 프로세서는 입력된 오디오 데이터를 읽어서 변형하고 출력 버퍼에 결과를 기록한다.
class SimpleGainProcessor : public juce::AudioProcessor {
public:
void processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer&) override {
for (int channel = 0; channel < buffer.getNumChannels(); ++channel) {
float* channelData = buffer.getWritePointer(channel);
for (int i = 0; i < buffer.getNumSamples(); ++i)
channelData[i] *= gain; // 모든 샘플에 gain을 적용
}
}
void setGain(float newGain) { gain = newGain; }
private:
float gain = 1.0f; // 기본 게인 값은 1.0 (변하지 않음)
};
컨텍스트는 오디오 데이터를 처리하는 동안 프로세서가 참조해야 할 상태 정보를 포함한 객체이다. 주로 입력 버퍼, 출력 버퍼, 샘플링 레이트, 채널 수 등의 정보를 포함하며, 오디오 처리 루프 내에서 사용된다.
juce::AudioBuffer<float> buffer (2, 512); // 스테레오 버퍼
juce::dsp::ProcessSpec spec {
44100.0,
(uint32) buffer.getNumSamples(),
(uint32) buffer.getNumChannels()
};
juce::dsp::ProcessContextReplacing<float> context (buffer);
// 프로세서를 준비하고 컨텍스트를 사용해 처리하는 예시
juce::dsp::Gain<float> gainProcessor;
gainProcessor.prepare(spec); // 프로세서 준비
gainProcessor.setGainDecibels(-6.0f); // -6dB로 게인 설정
gainProcessor.process(context); // 오디오 블록을 처리
체인은 여러 개의 프로세서를 순차적으로 연결하여 데이터를 처리하는 방식이다. 각 프로세서는 차례대로 오디오 데이터를 처리하며, 직렬로 연결된다.
juce::AudioBuffer<float> buffer (2, 512);
juce::dsp::AudioBlock<float> block (buffer);
juce::dsp::ProcessContextReplacing<float> context (block);
juce::dsp::ProcessorChain<juce::dsp::Gain<float>, juce::dsp::IIR::Filter<float>> processorChain;
// Gain -> Filter 순으로 체인
// 체인 준비
juce::dsp::ProcessSpec spec {
44100.0,
(uint32) buffer.getNumSamples(),
(uint32) buffer.getNumChannels()
};
processorChain.prepare(spec);
// 각 프로세서에 설정을 적용
processorChain.get<0>().setGainDecibels(-6.0f); // Gain 프로세서
processorChain.get<1>().state = juce::dsp::IIR::Coefficients<float>::makeLowPass(44100, 2000.0f); // Low-pass 필터
// 체인을 통해 오디오 데이터 처리: -6dB 게인 -> 2000Hz Low Pass Filter
processorChain.process(context);