C++/Sound

[JUCE] Juce Framework로 사운드 프로그래밍 #7 Pitch Shift

Kareus 2022. 7. 11. 22:30

음의 높낮이는 주파수에 따라 정해집니다. 주파수가 높으면 높은 음, 낮으면 낮은 음이 됩니다.

그래서 신디사이저에서 음의 높낮이를 조절하려면, wavetable을 훑는 delta 값을 조절해줘야 합니다.

 

//prepare
auto frequency = 440.0 * pow (2.0, (midiNote - 69.0) / 12.0);

auto cyclesPerSample = frequency / sampleRate;
delta = cyclesPerSample * juce::MathConstants<float>::twoPi;
    
//render

currentSample = std::sin (currentAngle);
currentAngle += delta;
if (currentAngle >= juce::MathConstants<float>::twoPi)
    currentAngle -= juce::MathConstants<float>::twoPi;
    
for (auto i = outputBuffer.getNumChannels(); --i >= 0;)
    outputBuffer.addSample(i, startSample + bufferIndex, currentSample);

frequency, 즉 주파수는 각 음마다 고유값이 있습니다.

주로 쓰는 표준 값은 A4의 440Hz입니다. midi note number는 C0가 12이고 1 octave = 12이므로 A4 = 69입니다.

근데 제가 쓰는 환경에서는 C0를 0으로 계산하는 경우가 있어서 제 github에서는 A4 = 57로 두고 계산한 코드를 볼 수 있습니다.

 

건반악기에서 주로 쓰는 음율은 평균율이고, 1 octave 높은 음은 원음의 주파수의 2배입니다.

그리고 그 사이의 음은 균일하게 등분합니다.

뭐, 자세한 건 모르겠고 비례식을 이용하면 다음과 같은 식이 나온다는 거죠.

$freq = 440Hz \times 2^{{note - A4} \over 12}$

 

이에 따라 초당 사이클 수를 구해주고, delta 값을 계산해주면 됩니다.

코드에서는 sine 파를 재생할 것이기 때문에, $2 \pi$를 곱해줬습니다.

 

이제 렌더링할 때 이 delta 값을 더해주면서 재생할 샘플의 위치 혹은 샘플 값을 구해주면 됩니다.

sine 파는 그대로 sin 값을 구해주면 되겠죠.

 

 

샘플 사운드를 재생할 때도, 원리는 같습니다.

다만 음이 높아질 수록 건너뛰는 샘플 수도 커지기 때문에, 샘플 길이가 짧아집니다.

결과적으로 빠르게 재생해서 음이 높아지게 됩니다.

juce::SamplerSound / SamplerVoice를 사용할 때 이런 결과가 나왔기 때문에, 직접 샘플러를 구현하게 됐습니다.

Juce에서 이 방법 이외에 높낮이를 조절할 방법이 없기 때문에, 길이를 유지하면서 높낮이를 바꾸려면 직접 샘플을 가공해줘야 됩니다. 그리고 그걸 Pitch Shift라고 부릅니다.

 

Pitch Shift 기능을 제공하는 라이브러리는 많은데, 대부분 유료입니다.

사운드는 퍼포먼스가 중요하니, 실시간급으로 빠른 알고리즘이 필요하다면 그만큼의 대가를 지불하는 건 당연하겠죠.

근데 여기는 그런 게 필요한 곳이 아닙니다. 그냥 취미로 시작한 프로젝트니까요.

비상업용으로 쓸만한 라이센스는 어느 정도 있습니다.

여기서는 대표적인 오픈소스 Pitch Shift 라이브러리인 SoundTouch를 사용하겠습니다.

 

SoundTouch의 퍼포먼스가 그렇게 빠른 편은 아니기 때문에, 저는 미리 프로세싱해서 사용하기로 했습니다.

음 하나 재생할 때는 괜찮은데, 3개 이상의 화음을 동시에 연주하면 latency가 확 늘어나더군요.

이게 1~2초 미만의 짧은 샘플을 재생할 때 그러했으니 샘플이 길면 latency도 더 클 겁니다.

 

Juce에서 라이브러리를 포함하는 방법은 #1에서 다뤘습니다. 보니까 거기서도 SoundTouch를 썼네요.

SoundTouch에서 Pitch Shift를 사용하는 방법은 간단합니다.

#include <SoundTouch.h>

soundtouch::SoundTouch touch;

//init
touch.setChannels(numChannels);
touch.setSampleRate(sampleRate);
touch.setPitch(pitch);

//process
//float* sample; float* output;

touch.putSamples(sample, numSamples);

int processedSize = 0;
float* buffer = output;
do
{
    int processedSize = touch.receiveSamples(buffer, numSamples);
    buffer += processedSize;
} while (processedSize != 0);

먼저 채널 수와 sample rate를 설정해줘야 됩니다. 이건 AudioSampleBuffer의 설정을 그대로 따라가시면 됩니다.

 

샘플을 집어넣기 전에 pitch를 정해줘야 됩니다. 반드시 샘플을 넣기 전에 설정해줘야 됩니다.

이후에 하니까 pitch가 안 변하더라구요.

 

pitch ratio는 위에서 계산한 frequency의 그 비율을 사용하면 됩니다.

예를 들어 원본 샘플이 C5이고 E6로 pitch를 조절하고 싶다면 pitch 값은 다음과 같습니다.

template <typename T>
inline float getFrequencyFromNote(T note)
{
    return 440.0 * std::pow(2.0, (note - 69) / 12.0);
}

int C5 = 72;
int E5 = 76;

float pitch = getFrequencyFromNote(E5) / getFrequencyFromNote(C5);

 

그리고 putSamples로 가공할 샘플을 집어넣고, receiveSamples로 받아오면 됩니다.

receiveSamples가 샘플을 한꺼번에 모두 반환하는 게 아니기 때문에, return으로 프로세싱한 샘플 수를 받아와서 0이 되기 전까지 반복해주면 됩니다.

 

그런데 이걸 그대로 Juce에서 사용하기엔 필요한 과정이 조금 있습니다.

스테레오 샘플을 작업하는 경우에, Juce에서는 각 채널마다 버퍼가 따로 존재하는데 SoundTouch에는 각각의 채널 버퍼를 입력할 수가 없습니다.

readme를 보면 자세한 설명이 있습니다.

SoundTouch readme

내용은, 스테레오 데이터인 경우에는 interleaved sample이어야한다는 말입니다. Juce에서는 기본적으로 스테레오 오디오 파일을 불러오면 deinterleaved된 sample이기 때문에, 이 처리를 위한 가공이 또 필요합니다.

고맙게도 Juce에 이러한 유틸이 있습니다.

 

juce::AudioSampleBuffer buffer; //input

//init
soundtouch::SoundTouch touch;

juce::AudioSampleBuffer processor(1, buffer.getNumSamples() * buffer.getNumChannels());
juce::AudioSampleBuffer output = processor;
juce::AudioSampleBuffer result(buffer.getNumChannels(), buffer.getNumSamples());

touch.setChannels(1);
touch.setSampleRate(sampleRate);
touch.setPitch(pitch);

//interleave
juce::AudioDataConverters::interleaveSamples(buffer.getArrayOfReadPointers(), processor.getWritePointer(0), buffer.getNumSamples(), buffer.getNumChannels());

//process
touch.putSamples(processor.getReadPointer(0), processor.getNumSamples());

int processedSize = 0;

do
{
    processedSize = touch.receiveSamples(output.getWritePointer(0), output.getNumSamples());
} while (processedSize != 0);

//deinterleave
juce::AudioDataConverters::deinterleaveSamples(output.getReadPointer(0), result.getArrayOfWritePointers(), result.getNumSamples(), result.getNumChannels());


//variable info
/*
original input : buffer
interleaved input : processor
interleaved pitch-shift result : output
deinterleaved result (final result) : result
*/

복잡하네요.

interleave를 하기 때문에 채널 수는 1로 고정해줬습니다.

 

매번 AudioSampleBuffer에서 float pointer를 가져오기가 귀찮았기 때문에, 관련 wrapper class를 만들어줬습니다.

void PitchShifter::put(const juce::AudioSampleBuffer& buffer)
{
    shifter->putSamples(buffer.getReadPointer(0), buffer.getNumSamples());
}

unsigned int PitchShifter::receive(juce::AudioSampleBuffer& buffer)
{
    return shifter->receiveSamples(buffer.getWritePointer(0), buffer.getNumSamples());
}

github에서는 뭔가 만들다 만 상태로 냅뒀네요. 관련 버그나 에러가 보이지 않는 선에서는 그냥 계속 냅둘 겁니다.

다시 빌드하고 테스트하는 게 너무 시간이 걸려서 귀찮네요.

 

 

Tip) sample rate는 재생할 디바이스의 sample rate여야 합니다.

여기서는 voice에서 getSampleRate()로 구할 수 있는 값이 되겠네요.

 

반면에 샘플 파일 자체의 sample rate가 따로 있습니다.

AudioSampleBuffer는 이 sample rate를 따라 가기 때문에 실제로 샘플을 재생할 때 pitch가 미묘하게 다르게 들릴 수 있습니다.

예를 들어 샘플 파일은 44100Hz로 저장되어 있는데 디바이스는 48000Hz로 재생해서 원본보다 약간 높게 소리가 재생되는 겁니다.

이 문제를 해결하기 위해서 사운드를 리샘플링해줘야 됩니다.

 

sample rate가 올라가는 경우에는 빈 샘플을 pitch의 변동 없이 그럴듯하게 메워야하기 때문에 interpolator가 필요합니다.

Juce에서는 Lagrange Interpolator를 사용할 수 있습니다.

juce::AudioSampleBuffer resample(juce::AudioSampleBuffer& buffer, int rate_from, int rate_to)
{
    if (rate_from == rate_to) return buffer;
    juce::AudioSampleBuffer result(buffer.getNumChannels(), std::ceil((double)buffer.getNumSamples() * rate_to / rate_from));
    juce::LagrangeInterpolator interpolator;

    double ratio = (double)rate_from / rate_to;
    for (int i = 0; i < buffer.getNumChannels(); i++)
        interpolator.process(ratio, buffer.getReadPointer(i), result.getWritePointer(i), result.getNumSamples());

    return result;
}

 

그러면 Juce는 이쯤에서 마치고, 저는 좀 쉬다가 다시 게임 쪽으로 돌아오겠습니다.