C++/Sound

[JUCE] Juce Framework로 사운드 프로그래밍 #3 신디사이저 만들기

Kareus 2022. 6. 12. 03:10

소리를 재생해봅시다.

지난 포스팅에서 이야기했듯이, JUCE에서 사운드를 만들어내려면 juce::Synthesiser를 이용해야 합니다.

다만 정확히는 만들어낸 사운드를 Synthesiser를 통해서 내보낸다는 느낌에 가깝고,

사운드를 만들어내는 것 자체는 Voice에서 이루어집니다.

 

Synthesiser

Synthesiser에 보내줄 사운드는 Voice에서 만들고, Voice에서는 Sound 소스를 받아서 렌더링합니다.

만들 사운드의 특징은 Sound에 저장하고, Voice에서는 그런 Sound를 받아 렌더링을 한 뒤에, 연결된 Synthesiser로 보내주는 겁니다.

 

Buffer and Wave

사운드가 만들어지는 과정부터 천천히 알아봅시다.

이미지가 각 좌표에 해당하는 픽셀 값으로 이루어져 있듯이, 사운드는 각 시간대에 재생할 진폭의 값으로 이루어져 있습니다. 그 값들의 집합이 버퍼이고, 버퍼는 주로 float array (혹은 double array)의 타입입니다.

JUCE에서는 juce::AudioBuffer<float> 혹은 juce::AudioBuffer<double>를 사용합니다.

juce::AudioBuffer<float>은 juce::AudioSampleBuffer로 define되어 있기도 합니다.

 

지난 포스팅에서의 getNextAudioBlock 혹은 processBlock 에서 버퍼를 받아오면, Synthesiser에서 Voice를 이용해 버퍼를 채워넣게 됩니다.

/* Member Variables
juce::MidiKeyboardState& keyboardState;
juce::Synthesiser synth;
juce::MidiMessageCollector midiCollector;
*/

void getNextAudioBlock(const juce::AudioSourceChannelInfo& bufferToFill) override
{
    juce::MidiBuffer incomingMidi;
    midiCollector.removeNextBlockOfMessages(incomingMidi, bufferToFill.numSamples);

    keyboardState.processNextMidiBuffer(incomingMidi, bufferToFill.startSample, bufferToFill.numSamples, true);

    synth.renderNextBlock(*bufferToFill.buffer, incomingMidi, bufferToFill.startSample, bufferToFill.numSamples);
}

void processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages) override
{
    buffer.clear();
    keyboardState.processNextMidiBuffer(midiMessages, 0, buffer.getNumSamples(), true);
    synth.renderNextBlock(buffer, midiMessages, 0, buffer.getNumSamples());
}

getNextAudioBlock에서는 미디 데이터를 parameter로 받아오지 않기 때문에, 직접 MidiBuffer를 선언해서 입력해줘야 합니다. 미디 데이터 처리를 위해 멤버 변수로 juce::MidiMessageCollector를 선언해줬습니다.

removeNextBlockOfMessages를 호출하면 미디 메시지 큐에서 처리해야 될 이벤트를 빼내 줍니다.

processBlock에서는 미디 메시지를 parameter로 받아오기 때문에 그대로 사용하면 됩니다.

 

juce::MidiKeyboardState는 미디 키보드 상태에 따라 미디 데이터를 만들어줍니다.

키보드 버튼/건반을 눌렀는지 혹은 누르고 있는지, 뗐는지 등등이 미디 데이터에 입력됩니다.

Synthesiser에서 renderNextBlock을 호출하면서 이런 미디 데이터와 버퍼를 같이 보내주면 (시작 시간과 길이도 같이) 연결된 Voice를 이용해 사운드를 렌더링합니다.

 

이제 Voice를 연결해야겠네요. 쉽습니다. 사운드를 재생하기 전에 미리 준비되도록만 해주세요.

//...
const int size = 32;
for (int i = 0; i < size; i++)
    synth.addVoice(new juce::SynthesiserVoice());

synth가 있는 Constructor에서 호출해주면 좋습니다.

size는 동시에 재생가능한 Voice의 수입니다. 임의로 정해주시면 됩니다.

32라면 동시에 최대 32개의 음을 재생할 수 있겠네요.

 

참고로 위 코드는 예시 코드입니다. SynthesiserVoice는 추상 클래스이기 때문에, 저렇게 생성해서 넣어주질 못합니다.

구체적인 사운드 처리는 상속 클래스를 만들고 직접 구현해줘야 됩니다. 좀 있다가 해봅시다.

 

Voice에서 사용할 Sound도 synth에 추가해줘야 됩니다.

synth에서 사운드를 재생할 때, 등록된 Sound들을 Voice에 보내서 사운드를 렌더링합니다.

Sound 추가도 Voice와 마찬가지입니다.

synth.addSound(new juce::SynthesiserSound());

SynthesiserSound 역시 추상 클래스이기 때문에, 사운드 소스에 대해 구현해줘야 됩니다.

이것부터 해봅시다.

 

Sound는 재생할 사운드 소스에 대한 정보를 갖고 있어야 됩니다.

그런데 Sound의 특징이 달라질 일이 없다면 굳이 Sound에 무언가 정의하지 않아도 됩니다.

무슨 이야기나면,

struct SineWaveSound   : public juce::SynthesiserSound
{
    SineWaveSound() {}

    bool appliesToNote    (int) override        { return true; }
    bool appliesToChannel (int) override        { return true; }
};

공식 튜토리얼에 있는 사인파의 Sound 구현입니다.

override해야 할 두 함수만 구현하고, 다른 것은 아무 것도 없습니다.

사인파는 말그대로 sin(x)만 버퍼에 넣어주면 됩니다.

음의 높낮이는 주파수에 따라 다른 것이기 때문에, x의 변화 속도만 다르게 해주면 됩니다.

만약 Voice에서 사인파같은 것만 만들어낼 거라면, Voice에 x를 정의해줘도 아무런 문제가 없는 거죠.

 

물론 Chiptune 같은 비교적 단순한 구조에서나 가능한 일이고, 파일을 불러와야 된다거나 임의로 다른 사운드를 재생해야 된다면 Sound에 정의해주는 편이 좋습니다. 그 경우에 대해 알아봅시다.

 

예를 들어 wav 같은 오디오 샘플을 재생하고 싶다고 가정해봅시다.

juce에서 이미 juce::SamplerSound, juce::SamplerVoice를 제공하고 있지만 여기서는 직접 만들어봅시다.

먼저 재생할 샘플이 Sound마다 필요하다는 것을 알 수 있습니다.

무슨 샘플을 재생할지 Voice 입장에서는 아직 모르니까요. Sound마다 다를 수 있습니다.

샘플 역시 특정 시간 재생되는 사운드 데이터, 즉 버퍼이기 때문에 juce::AudioBuffer로 표현할 수 있습니다.

여기서는 juce::AudioSampleBuffer로 쓰겠습니다.

 

struct AudioSound : public juce::SynthesiserSound
{
    AudioSound(juce::File file)
    {
        if (file == juce::File{}) return;
        
        juce::AudioFormatManager formatManager;
        formatManager.registerBasicFormats();
        std::unique_ptr<juce::AudioFormatReader> reader(formatManager.createReaderFor(file));
        
        if (reader.get())
        {
            duration = (float)reader->lengthInSamples / reader->sampleRate;
            
            buffer.setSize((int)reader->numChannels, (int)reader->lengthInSamples);
            buffer.clear();
            
            reader->read(&buffer, 0, (int)reader->lengthInSamples, 0, true, true);
            sampleRate = reader->sampleRate;
        }
    }
    
    bool appliesToNote(int note) override
    {
        return true;
    }
    
    bool appliesToChannel(int channel) override
    {
        return true;
    }
    
private:
    friend class AudioVoice;
    juce::AudioSampleBuffer buffer;
    int sampleRate;
    float duration; //second
}

JUCE에서 파일을 읽어오려면 그 포맷에 대해 등록이 되어있어야 하기 때문에

formatManager를 선언하고 register를 호출했습니다. BasicFormats에 wav나 aiff같은 포맷이 포함됩니다.

매번 포맷을 등록하고 불러오는 건 비효율적이라 저같은 경우에는 static reader를 따로 선언해뒀습니다.

 

Voice에서 Sound의 member에 접근할 수 있어야 하기 때문에 friend 선언을 해줬습니다.

getter 메서드를 따로 만들어줘도 됩니다.

 

비슷한 방식으로 Serum 같은 신디사이저에서 사용하는 Wavetable도 만들 수 있습니다.

Wavetable도 마찬가지로 오디오 샘플이나 다름없으니까요.

이건 JUCE에 튜토리얼로 있습니다. 여기선 Voice나 Sound가 아니라 Oscillator라고 합니다. 왜 그럴까요 헷갈리게시리

 

appliesToNote는 해당 높이의 note에서 사운드를 재생할 수 있는지를 반환해줍니다.

appliesToChannel은 해당 채널에서 사운드를 재생할 수 있는지를 반환해줍니다.

둘 다 어지간하면 true를 반환하면 됩니다.

재생할 수 있는 음의 구간이 다르다거나, 채널 설정이 다른 경우에는 그 조건에 따라 true/false를 반환할 수 있게 해주면 됩니다.

appliesToChannel은 잘 모르겠는데, appliesToNote는 드럼 패드 같은 악기를 생각하면 될 것 같네요.

 

Sound는 이쯤하고, Voice로 넘어갑시다.
Voice에서는 노트가 시작될 때, 노트가 끝날 때, 사운드를 렌더링할 때의 함수를 정의해줘야 됩니다.

SineWaveVoice부터 시작해봅시다. SineWaveSound에 아무 것도 정의를 안 해줬으니, 여기서는 해줘야 됩니다.

struct SineWaveVoice   : public juce::SynthesiserVoice
{
    SineWaveVoice() {}

    bool canPlaySound (juce::SynthesiserSound* sound) override
    {
        return dynamic_cast<SineWaveSound*> (sound) != nullptr;
    }

    void startNote (int midiNoteNumber, float velocity,
                    juce::SynthesiserSound*, int /*currentPitchWheelPosition*/) override
    {
        currentAngle = 0.0;
        level = velocity * 0.15;
        tailOff = 0.0;

        auto cyclesPerSecond = juce::MidiMessage::getMidiNoteInHertz (midiNoteNumber);
        auto cyclesPerSample = cyclesPerSecond / getSampleRate();

        angleDelta = cyclesPerSample * 2.0 * juce::MathConstants<double>::pi;
    }

    void stopNote (float /*velocity*/, bool allowTailOff) override
    {
        if (allowTailOff)
        {
            if (tailOff == 0.0)
                tailOff = 1.0;
        }
        else
        {
            clearCurrentNote();
            angleDelta = 0.0;
        }
    }

    void pitchWheelMoved (int) override      {}
    void controllerMoved (int, int) override {}

    void renderNextBlock (juce::AudioSampleBuffer& outputBuffer, int startSample, int numSamples) override
    {
        if (angleDelta != 0.0)
        {
            if (tailOff > 0.0) // [7]
            {
                while (--numSamples >= 0)
                {
                    auto currentSample = (float) (std::sin (currentAngle) * level * tailOff);

                    for (auto i = outputBuffer.getNumChannels(); --i >= 0;)
                        outputBuffer.addSample (i, startSample, currentSample);

                    currentAngle += angleDelta;
                    ++startSample;

                    tailOff *= 0.99; // [8]

                    if (tailOff <= 0.005)
                    {
                        clearCurrentNote(); // [9]

                        angleDelta = 0.0;
                        break;
                    }
                }
            }
            else
            {
                while (--numSamples >= 0) // [6]
                {
                    auto currentSample = (float) (std::sin (currentAngle) * level);

                    for (auto i = outputBuffer.getNumChannels(); --i >= 0;)
                        outputBuffer.addSample (i, startSample, currentSample);

                    currentAngle += angleDelta;
                    ++startSample;
                }
            }
        }
    }

private:
    double currentAngle = 0.0, angleDelta = 0.0, level = 0.0, tailOff = 0.0;
};

마찬가지로, 튜토리얼 코드를 그대로 긁어왔습니다.

하나씩 살펴봅시다.

 

canPlaySound : 주어진 Sound를 Voice에서 재생할 수 있는지 여부입니다.

사인파만 재생할 것이므로, SineWaveSound인 경우에만 true를 반환합니다.

 

startNote : 노트가 시작될 때 Voice에서 처리할 것을 구현해주면 됩니다.

여기서는 변수 초기화를 하고 재생할 note(midiNoteNumber)에 따라 주파수를 구하고 x의 변화량을 계산해주네요.

velocity는 건반을 누른 세기의 값입니다.

SynthesiserSound는 재생할 사운드의 포인터입니다. 사운드에서 가져와야될 데이터가 있다면 (버퍼 등) 여기서 가져오면 될 겁니다.

currentPitchWheelPosition은 현재 피치 휠의 위치입니다. 미디 키보드에 붙어있는 휠이 있는데

얘를 굴리면 높낮이가 오르내려갑니다. 그걸 구현할 때 이 값을 사용하면 됩니다.

반영한다면 코드가 이렇게 바뀔 겁니다.

float bendAmount = bendRange * (float)(currentPitchWheelPosition - 8192) / 8192.0;
float delta = std::pow(2.0, (midiNoteNumber + bendAmount - 60) / 12.0) * cyclePerSample * juce::MathConstants<double>::pi;

확실한 지는 모르겠네요. 워낙 코드 예제들이 다 달라서 원

bendRange는 pitch bend의 최대 크기입니다. 한 옥타브가 12개의 키로 이루어져 있으니

bendRange가 12라면 -12 ~ +12, 즉 원음에서 -1옥타브 에서 +1옥타브까지 조절할 수 있습니다.

 

stopNote에서는 tailOff를 넣느냐 마냐에 따라 다릅니다.

잔향...이라고 하면 되나요. 잔향을 넣기 위한 장치입니다.

소리를 끝내려면 반드시 clearCurrentNote를 호출해줘야 됩니다.

안 그러면 소리는 나지 않아도 노트 이벤트가 계속 남아서 돌아가게 됩니다.

 

pitchWheelMoved는 피치 휠이 움직였을 때 호출됩니다. bendAmount를 사용한다면 값을 업데이트해주면 됩니다.

controllerMoved는 미디 키보드 등에 있는 컨트롤러가 돌아갈 때의 이벤트인데...

얘는 뭐라고 설명하기가 어렵네요. 제가 컨트롤러를 써본 적이 없어서;;

 

renderNextBlock. 얘가 핵심입니다. 사운드를 렌더링하는 함수거든요.

버퍼, 재생 시작 시간, 길이를 받아옵니다.

스트림에 데이터를 쓰듯이 버퍼에 값을 넣어주면 됩니다.

스테레오처럼 채널이 여러 개 일 수 있기 때문에, 각 채널마다 버퍼를 써줘야 됩니다.

addSample, addFrom, copyFrom 등등의 함수가 있는데

addSample은 단일 값, addFrom은 어느 버퍼에서 특정 길이를 갖고와서 더해줄 때 사용합니다.

copy는 말그대로 덮어씌우는 건데 여러 개의 사운드를 재생하려면 add를 사용해야 합니다.

 

이렇게 하면 사운드를 만들어낼 수 있습니다.

여기다가 ADSR같은 효과도 넣을 수 있습니다.

JUCE에서도 juce::ADSR를 지원해서, adsr.applyEnvelopeToBuffer()를 호출하면 됩니다.

* 이걸 좀 써봤는데, 함수 호출을 사운드 프로세싱 이후에 해야되더군요. 그래도 좀 마음에 안 드는 게 있었는데, 이건 다음에 얘기합시다.

 

** AudioSound를 여기서 설명해보려 했는데, 너무 할 게 많아서 포기했습니다.

   결과물은 있는데 Pitch Shift도 그렇고 섞여들어간 게 너무 많아서 쳐내기가 힘드네요.

 

이쯤 하고, 다음에 프로세싱 후처리랑 GUI를 해봅시다.

근데 이런거 쓰려고 시작한 게 아닌데 너무 귀찮다