직접 Saturation 플러그인 만들어보기

새츄레이션

음악 이펙트 효과중에 Saturation이 있다. 보통 소리가 심심할때 (추상적인 표현이긴 하지만) 활용한다. RC-20 플러그인이 대표적이다.

새츄레이션은 입력이 커질수록 출력이 같은 비율로 커지지 않게 만드는 비선형 처리이다. 이게 무슨 말일까?

Saturation 을 번역하자면 '가득 차서 더이상 들어갈 수 없는 상태'이다. 이 현상은 예전에 아날로그 장비로 음악 작업을 할때 너무 큰 신호를 넣을 때 발생했다.

마이크로 녹음을 한다고 가정했을 때, 녹음 된 소리는 전기신호로 바뀌어서 장비를 지나간다.

장비는 신호의 크기에 맞춰서 출력을 내보낸다.

마이크로 들어온 소리의 크기가 작을때는 비교적 정확하게 입출력 값이 동등했다.

입력 1 → 출력 1
입력 2 → 출력 2
입력 3 → 출력 3

하지만 입력이 커질수록 출력이 못따라가는 문제가 발생했다.

입력 1 → 출력 1
입력 2 → 출력 2
입력 3 → 출력 2.8
입력 4 → 출력 3.2
입력 5 → 출력 3.4

위처럼 입력은 커지는데, 장비의 포화상태로 인해서 출력의 증가 폭은 점점 줄어든다. 이를 비선형반응이라고 한다.

초기에는 당연히 출력이 입력을 못따라가니 문제로 취급했지만, 이를 음악적인 효과로 활용하면서 새츄레이션을 하나의 음향 처리 방식으로 변화했다. (이 노래가 대중적으로 새츄레이션 기법을 활용한 노래라고 알려져있다.)

이전 글에서 소리는 결국 공기의 진동이고, 이를 그림으로 그린 것을 파형이라고 한다고 했다.

처리 전
       /\
      /  \
_____/    \_____

처리 후
      ____
    /      \
___/        \___

새츄레이션을 적용하면 가장 높은 지점 (peak)이 눌러지면서 소리의 차이가 줄어든다. 예를 들어서 드럼스틱을 친다고 가정했을때, 처음에 탁! 소리는 매우 크고, 뒤에 이어지는 울림은 상대적으로 작아진다.

새츄레이션이 처음의 큰 타격을 누르면 뒤의 울림과의 차이가 줄어들면서, 뒤의 울림이 상대적으로 더 선명하게 들릴 수 있다. 이러한 기법으로 소리 자체를 더욱 단단하게 만들 수 있다.

또한 저렇게 파형 자체가 변화하게 되면서 음색 또한 바뀐다.

컴프레서(Compressor)와 비슷한 역할을 하는거 아닌가?라고 생각할 수도 있는데, 컴프레서는 소리가 기준보다 커졌는지 비교해서, 음량을 조절하고 얼마나 빠르게 원래 상태로 돌아갈지 조절한다. (Attack, Ratio 등)

하지만 새츄레이션은 순간적인 파형의 크기에 따라서 입,출력을 다르게 만들어서 파형 자체를 변화시킨다.

코드로 옮겨보기

위에서 가장 중요한건 비선형 반응이다. 작은 입력일땐 출력과의 차이가 그렇게 크지 않아야하고, 큰 입력일수록 출력의 증가량이 점점 줄어들어야한다.

이를 코드로 옮긴다면 어떻게 해야할까?

소리의 입력이 마이크를 통해 컴퓨터로 들어온다고 했을때, 이는 연속된 숫자로 저장된다.

그리고 이 숫자 하나하나를 sample이라고 한다. Sample Rate는 1초에 샘플링하는 횟수이다.

예시

0.02, 0.18, 0.46, 0.81, 0.43, 0.10, -0.25, -0.72 ...

각 숫자는 해당 순간에 파형이 어디에 있는지를 나타낸다. 부동소수점 방식으로 오디오를 처리할 때 -1부터 1까지를 일반적인 기준 범위로 사용한다.

새츄레이션 플러그인의 핵심기능은 다음과 같다.

샘플을 입력
숫자를 새츄레이션 함수에 넣음
변경된 숫자를 리턴

예를 들어서 입력값이 0.5로 치환되고, 그 값이 함수 안에 들어가서 0.46(예시)를 리턴하는 것이다.

이 작업을 모든 샘플에 적용한다면 파형 전체의 모양이 달라지게 된다.

하지만 여기서 중요한건 Saturation 함수의 동작 방식인데, 단순히 인풋값을 무조건적으로 값을 줄여서 리턴해주는 것이 아니라 입력이 커질수록 출력이 증가하는 비율이 줄어들어야 한다.

이런 값을 만들어주는 함수를 hyperbolic tangent 라고 한다.

0에 가까운 작은 값에서는 입력과 출력이 거의 비슷하지만 위아래로 갈수록 값이 완만해진다.

tanh 함수의 비선형 곡선

// 예시 코드
function saturate(input, drive) {
  const driven = input * drive;
  return Math.tanh(driven);
}

플러그인 내부에서는 C++로 작성되어있지만 블로그상에선 편의상 자바스크립트로 갈음한다.

간략하게 표현하긴 한거지만, 샘플 하나당 위의 로직을 통해 새츄레이션의 핵심 기능을 거치게 된다.

하지만 보통 샘플 레이트는 44.1kHz이다. 이는 즉 1초당 44100개의 샘플이 들어가있는 것이고, 저 로직이 44100번이 실행되어야한다는 뜻이다.

function processSamples(samples, drive) {
  return samples.map((sample) => saturate(sample, drive));
}

const inputSamples = [
  0.02,
  0.18,
  0.46,
  0.81,
  0.43,
  0.1,
  -0.25,
  -0.72,
];

const outputSamples = processSamples(inputSamples, 2);

console.log(outputSamples);
// 0.04, 0.345, 0.726, 0.925, 0.696, 0.197, -0.462, -0.894

여기서 drive 값을 2주고 값을 확인해보면 위의 주석값으로 나온다. 2,3번째 인덱스 값을 살펴보면 인풋보다 아웃풋이 더 커진걸 볼수 있다.

위에서 tanh 함수는 작거나 클수록 값이 완만해진다고 했는데, 결과값이 더 커진건 왜일까?

이는 drive 값에 따라서 결과값이 달라지기 때문이다.

현재 driven 결과값은 인풋 * 드라이브 값인데, 만약 인풋이 0.5이고 drive가 2라면 driven 값은 1이 된다.

input = 0.5
drive = 2

driven은 0.5 × 2 = 1
Math.tanh(1) = 약 0.762

결국 리턴하는 값은 drive가 적용된 값보단 작지만 input보다는 크게 된다.

새츄레이션의 핵심 기능은 파형을 완만하게 해주는데에 있다고 했지만, 무조건 큰 소리를 단순히 작게 만들어주는 것이 아니다.

drive로 입력을 키우되, 신호가 커질수록 출력이 증가하는 폭을 점점 줄인다. 그 결과 파형의 꼭대기가 둥그래지면서 배음이 생긴다.

하지만 이런식으로 drive를 높이면, 새츄레이션이 강해지면서 동시에 전체 음량도 커진다.

소리가 커지게 되면 실제로 음색이 좋아진 것인지 단순히 음량이 커져서 좋아진 것인지 구별할 수가 없다.

이러한 구별을 위해 output 값이 추가된다.

function saturate(input, drive, output) {
  const driven = input * drive;
  const saturated = Math.tanh(driven);
  return saturated * output;
}

output이 1이면 tanh를 지난 값을 그대로 반환한다. output이 0.5라면 각 샘플의 진폭을 절반으로 줄인다.

여기서 진폭이 절반이 된다고 사람에게 들리는 음량까지 정확히 절반이 되는 것은 아니다.

정리해보면 이렇다.

Drive
→ tanh에 들어가기 전의 입력을 키움
→ 새츄레이션의 강도를 결정

Output
→ tanh를 지난 결과의 최종 크기를 조절
→ 처리 전후의 음량을 맞추는 데 사용

알아두어야할 부분은 output은 새츄레이션에 들어가기 전의 입력을 줄이는 값이 아니라, 이미 바뀐 파형의 최종 크기를 조절한다는 것이다.

처리 결과가 원본보다 크면 Output을 낮추고, 처리 결과가 원본보다 작으면 Output을 높일 수 있다. 하지만 정말 원본과 비슷한 음량으로 맞춘 것인지 확인하기 위해 RMS를 사용한다.

RMS란?

RMS(Root Mean Square)는 제곱 값 평균에 다시 제곱근을 씌운 값이다. 일정 구간의 신호가 어느 정도 크기를 유지하는지 숫자 하나로 비교할때 쓴다.

평균값과 매우 유사한 값이라고 하는데.. 그럼 그냥 더해서 평균 값을 내면 되지 않을까?

하지만 소리의 파형을 0을 기준으로 양수와 음수를 오가기때문에, 만약 그냥 더해서 평균을 내게 되면 제대로 된 계산을 할 수 없다.

만약 두 샘플이 0.5와 -0.5 값을 가지고 있다면,

(0.5 + -0.5) ÷ 2 = 0

가 되므로, 제대로 된 계산을 할 수 없다.

function getRms(samples) {
  if (samples.length === 0) {
    throw new Error('RMS를 계산할 샘플이 없습니다.');
  }

  const squareSum = samples.reduce(
    (sum, sample) => sum + sample * sample, // 각 샘플을 제곱
    0,
  );

  const meanSquare = squareSum / samples.length; // 제곱한 값의 평균
  return Math.sqrt(meanSquare); // 평균의 제곱근
}

만약 이렇게 되면 음수도 양수가 되어서 서로 상쇄관계가 아니게 된다 (양수끼리만 더해지므로)

이 값들의 평균을 구한 뒤 제곱근을 씌우면, 제곱되어 바뀐 스케일을 원래 샘플 크기의 스케일로 되돌릴 수 있다.

const inputSamples = [
  0.02,
  0.18,
  0.46,
  0.81,
  0.43,
  0.1,
  -0.25,
  -0.72,
];

const saturatedSamples = processSamples(inputSamples, 2);

console.log('처리 전 RMS:', getRms(inputSamples).toFixed(6)); // 0.457753
console.log('처리 후 RMS:', getRms(saturatedSamples).toFixed(6)); // 0.616300

drive를 2로 주었을때, RMS 값이 더 커진걸 볼 수 있다.

이 샘플들을 원본과 비슷하게 맞추려면 원본의 RMS를 구하고 새츄레이션 된 RMS로 나눈다.

// 몇을 곱해야 원본 크기가 되는 지알아보기 위해 원본 RMS ÷ 처리 후 RMS
const output =
  saturatedRms === 0
    ? 1
    : inputRms / saturatedRms;

console.log('Output:', output.toFixed(6)); // 0.742743

그러고 구한 아웃풋을 새츄레이션 된 샘플들에 곱해준다.

const matchedSamples = saturatedSamples.map(
  (sample) => sample * output,
);

총 흐름을 정리해보자면 다음과 같다.

// 1. 원본 RMS 크기를 측정한다.
const inputRms = getRms(inputSamples);

// 2. Drive와 tanh를 적용한다.
const saturatedSamples = processSamples(inputSamples, 2);

// 3. 처리된 결과의 RMS크기를 측정한다.
const saturatedRms = getRms(saturatedSamples);

// 4. 원본과 맞추기 위한 Output 비율을 구한다.
const output =
  saturatedRms === 0
    ? 1
    : inputRms / saturatedRms;

// 5. 처리된 샘플에 Output을 곱한다.
const matchedSamples = saturatedSamples.map(
  (sample) => sample * output,
);

여기까지가 새츄레이션의 핵심로직이었고, 이를 실제 플러그인으로 만드는 것은 JS가 하는 것이 아니다.

실제 플러그인으로 만들기 위해서는 C++와 JUCE라는 프레임워크가 필요하다.

JUCE는 C++로 작성 된 코드를 VST3 규격과 연결하는 프레임워크다. 큐베이스 같은 DAW에서 VST3 규격으로 전달한 오디오 블록과 파라미터 변경을 JUCE가 processBlock() 같은 C++ 함수로 연결한다.

C++ 컴파일러는 우리가 작성한 코드와 JUCE의 연결 코드를 함께 네이티브 바이너리로 변환한다.

Cubase에서 실행한 GNDY Saturation 플러그인

위의 로직을 바탕으로 실제로 만든 플러그인이다.

현재는 drive와 output 옵션만 존재하지만, warp vinyl 등 다양한 옵션을 추가하려면 훨씬 더 많은 개념들이 필요하다.

Cubase SuperVision에서 확인한 RMS Max

새츄레이션을 적용한 후 RMS 값을 맞춰준다고 했는데,

  1. 새츄레이션 적용 안했을때 RMS 값
  2. 적용했을때의 RMS 값

위 두개를 비교해보면서 output 값을 조절해주면 된다.