음악, 삶, 개발
Juce 로 미디이펙트를 만들기위한 필수지식 본문
Juce 로 미디이펙트를 만들기위한 가장 필수적인 지식들과
예제코드들을 정리해보려한다.
이 포스트는 VST3 플러그인 기준이며, Standalone 이나 iOS 는 해당되지않는다.
< Juce 의 미디 입출력 : Max 와 다르다!!!! >
Max 에서는 미디 메세지의 움직임은 모두 MSP object 가 아닌, Max object 에서 일어난다.
하지만 Juce 는 그렇지않다.
미디의 입,출력이 audio thread, 즉 processBlock() 안에서 일어난다.
void processBlock(juce::AudioBuffer<float>& audioBuffer, juce::MidiBuffer& midiBuffer) override {
/*
이안에서 지지고볶고 하는것이다.
*/
}
processBlock() 함수는 우리가 호출하는것이 아닌,
DAW, 즉 host 가 호출하는 callback 함수이다.
이 호출은 audio thread 에서 발생한다.
host 는 굉장히 빠른 속도로 계속해서 processBlock() 을 호출하며,
우리의 플러그인에 audioBuffer 와 midiBuffer 를 계속해서 넘긴다.
이 인자들을 보면 reference 인것을 알수있는데,
한마디로 이 reference 들을 수정함으로써 우리가 하고자하는
오디오나 미디 프로세싱을 하는것이다.
일단 먼저 기억해야할 중요한 사항이있다.
processBlock() 은 DAW 의 transport 가 꺼져있어도 호출된다 이다.
이는 어찌보면 당연히 그래야한다.
우리가 만든 음악을 듣다가 Transport 를 멈춰도 Delay 나 Reverb 같은 잔향은 계속해서 들리지않는가.
< 미디 플러그인인데, audioBuffer 가 필요합니까?... >
나 역시 이 질문을 똑같이 했었다. Max 를 사용했었으니까.
Max 에서 미디 이펙트를 만들때는 MSP object, 즉 오디오 관련된 object 가 왠만해서는 쓰이지않기때문이다.
정답은 "필요하다" 이다.
나중에 보겠지만, midiBuffer 를 프로세싱할때 audioBuffer 가 제공해주는 값이 필요하다.
Juce 에서는 미디 프로세싱도 오디오 프로세싱으로 봐야한다.
둘다 audio thread 에서 함께 일어나기때문이다.
< audioBuffer 가 DAW 안에서 넘겨지는 모습 >
audioBuffer 는 procesBlock() 을 통해 대략 다음과 같이 연속적으로 플러그인에 넘겨진다.
위의 그림을 보면, audioBuffer 의 크기가 조금씩 다른것을 알수있는데,
매번 processBlock() 에 audioBuffer 가 넘겨질때마다 이 buffer 의 크기가 일정한것이 이상적이지만 현실은 그렇지않다고한다.
host 마다 내부 구조가 틀리고, VST 를 구동하는 방식들이 달라서
때로는 빈 buffer 가 넘겨지기도 한다고 한다. (FL Studio 가 특히 심하다고함)
따라서 audioBuffer 의 크기가 일정하지않다라는 전제아래 코드를 작성해야한다.
< 나는 DAW 와 sync 되는 미디플러그인을 만들고싶다 >
위의 그림은 시각화하기위해 조금 크게 그린것이고,
실제로 audioBuffer 의 크기는 매우 작다.
현실은 이런 느낌이라고 볼수있다.
사실 이것도 현실이 아니지만, 매우 짧은 길이라고 볼수있다.
위의 그림을 보면 알수있듯이,
procesBlock() 은 DAW 와 싱크된 타이밍에 audioBuffer 를 넘겨주지않는다.
그냥 계속해서 바보처럼 넘겨줄뿐이다.
우리의 미디플러그인이 DAW 와 sync 되기위해서는 1차적으로 여러 정보를 여기저기서 가져와야한다.
< 미디 플러그인을 만들기위해 필요한 데이터들>
미디 플러그인이 host 와 타이밍이 sync 되는것은 사용자에게는 매우 당연한것이고, 필수적인것이다.
Arpeggiator 를 생각해보라.
만약 사용자가 Arpeggiator 를 사용하고있는데, 내가 찍어놓은 drum 과 박자가 어긋난다면
그들은 매우 지랄할것이다.
미디 플러그인의 host sync 를 구현하기위해 필요한 데이터는 아래와 같다.
1. DAW 의 transport
2. BPM
3. 샘플 레이트
4. audioBuffer 의 시작지점
5. audioBuffer 의 샘플 갯수
void processBlock(juce::AudioBuffer<float>& audioBuffer, juce::MidiBuffer& midiBuffer) override {
// 1. transport
juce::AudioPlayHead::CurrentPositionInfo transport;
getPlayHead()->getCurrentPosition(transport);
// 2. bpm
double bpm = transport.bpm;
// 3. 샘플 레이트
double sampleRate = getSampleRate();
// 4. audioBuffer 의 시작지점
double bufferStartInBeat = transport.ppqPosition;
// 5. audioBuffer 의 샘플 갯수
int samples = audioBuffer.getNumSamples();
}
위의 값들을 서로 조합하여, 계산을 하면
우리가 원하는 위치에 미디 노트를 꽃을수있게 된다.
위의 값들을 시각적으로 표현하면 아래와 같다.
위와 같이 Juce 에서 제공하는 여러 함수들과 객체들을 사용하여 정보를 가져와야한다.
다시 정리해보자.
1. transport : getPlayHead()->getCurrentPosition 함수로 juce::AudioPlayHead::CurrentPositionInfo 객체를 가져온다.
이때 getCurrentPosition 함수의 인자는 output 파라미터이다.
따라서 아래와 같이 작성해야한다.
juce::AudioPlayHead::CurrentPositionInfo transport;
getPlayHead()->getCurrentPosition(transport);
2. 위의 transport 의 객체로 여러가지 DAW 의 상태를 알수있다.
bool isPlaying = transport.isPlaying; // DAW 의 transport on, off 상태
double bpm = transport.bpm; // BPM
double ppq = transport.ppqPosition; // 현재 buffer 의 시작지점. 단위는 beat 이다.
int samples = audioBuffer.getNumSamples(); // 현재 buffer 가 가지고있는 sample 의 갯수
double sampleRate = getSampleRate(); // AudioProcessor 의 함수로, 사용자의 샘플 레이트를 return
< 노트의 위치 단위는 beat 로 >
플러그인의 time 에 대한 단위는 매우 다양하다.
하지만 우리는 DAW sync 를 하기때문에, 기본적으로 beat 단위를 사용하는것이 현명하다.
beat 단위로 우리가 원하는 노트의 지점을 찾은후, 이 위치를
sample 로 변환해야한다.
void processBlock(juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiBuffer) override {
juce::AudioPlayHead::CurrentPositionInfo transport;
getPlayHead()->getCurrentPosition(transport);
if (transport.isPlaying) {
// time unit is beat.
auto noteStart = 2.0;
auto beatPerSample = 1.0 / (60.0 / transport.bpm * getSampleRate());
auto bufferStart = std::fmod(transport.ppqPosition, 4.0);
auto bufferEnd = bufferStart + (buffer.getNumSamples() * beatPerSample);
if (noteStart >= bufferStart && noteStart < bufferEnd) {
// convert beat to sample.
auto noteStartInSample = (int)std::ceil((noteStart - bufferStart) / beatPerSample);
auto noteOn = juce::MidiMessage::noteOn (1, 64, 0.5f);
auto noteOff = juce::MidiMessage::noteOff (1, 64, 0.5f);
// add note on.
midiBuffer.addEvent(noteOn, noteStartInSample);
midiBuffer.addEvent(noteOff, noteStartInSample + 1000);
}
}
}
< Note 의 Duration 은 존재하지않는다! >
우리가 흔히 note 를 떠올리면, note 의 길이는 존재한다고 생각한다.
하지만 midi 프로토콜에는 노트의 길이를 표현할수있는 방법이 없다.
오직 note on 과 note off 만이 존재할뿐이다.
이 note on 과 note off 사이의 거리를 duration 으로 시각화하는것일뿐이다.
한 buffer 안에 noteOn 과 noteOff 메세지가 모두 존재할수도있겠지만, 현실적으로 대부분의 경우 이렇지않다.
buffer 크기가 1024 라고 가정해도, 샘플레이트가 44100일때, 시간으로 환산하면 23ms 밖에 안되기때문이다.
buffer 의 크기가 2048 이라고해도, 50ms이다.
50ms 를 소리가 들어봤자 pluck 도 아닌 엄청나게 짧은 소리로 들려올것이다.
요새는 48000 샘플레이트로 작업하는사람들이 대부분이기때문에, 실제 길이는 더 짧아진다.
따라서, note duration 을 표현하기위해서는
현재 buffer 에 noteOn 메세지를 쏴주고, 다음 buffer 에 noteOff 메세지를 쏴주어야한다.
Juce 에서는 midi message 에 대한 클래스만 존재하고,
note 에 대한 클래스는 존재하지않는다.
따라서 Note 클래스를 직접 구현해야한다.