음악, 삶, 개발

시퀀서 만들기에 대한 고민해보기 본문

개발 공부/Juce 잡동사니

시퀀서 만들기에 대한 고민해보기

Lee_____ 2020. 10. 14. 00:24

< 내가 원하는 것 : 시퀀서 만들기 >

 

내가 VST 개발에 뛰어든 중요한 이유중 하나는 나만의 시퀀서를 가지고싶어서였다.

내가 말하는 시퀀서는 모든 DAW 가 가지고있는 Piano Roll 이다.

Piano Roll 을 만드는것은 매우 거대한 문제이다.

우리가 자동차를 운전하는것은 어렵지않지만, 자동차를 만드는것은 상상할수없는 난이도일것이다.

이 논리가 Piano Roll 을 만드는데에도 적용될것이라는 다짐을 일단 해야한다.


< Piano Roll 의 작동원리 간단히 생각해보기 >

 

사용자가 DAW 에서 보는것은 결국 GUI 이다.

사람들은 자신이 더블클릭하여 노트를 추가하면 노트 GUI 가 보여지고 이 노트는 연주된다라는 사실을 알고있다.

하지만 개발자의 입장에서 생각해보자.

실질적으로 미디 노트가 악기를 연주하게 만드는것은 GUI 가 하는 역할이 아니다.

GUI 는 오직 현재 미디 노트들의 상태를 시각화해줄뿐이다.

더블 클릭이라는 액션이 들어가면, 미디 노트가 이 미디 노트를 담을 냉장고에 추가될것이고,

새로운 노트가 추가되었음을 이 냉장고는 GUI 에게 알려줄것이다.

이 알림을 받은 GUI 는 개발자가 정해놓은 규칙에 따라 이 노트를 화면위에 그릴것이다.


< GUI 에 대한 생각은 가장 마지막에... >

 

자동차를 만들기위해서는 일단 내부를 만들어야한다.

Piano Roll 을 어떻게 이쁘게, 멋지게 디자인할것인가의 문제는 가장 마지막에 놓여있는 과제이다.

일단 Piano Roll 의 내부, 즉 알고리즘을 확립한뒤에 우리는 GUI 를 비로써 생각해볼수있는것이다.

또한, 우리의 VST 플러그인은 GUI 없이 동작할수있어야한다.

사용자는 VST 창을 자주 껐다 킨다. VST 창을 껐을때도 노트는 계속 연주되어야한다.

(나는 GUI 를 굉장히 좋아하지만, 가장 마지막에 해야하는것임을 C++ 를 배우며 알게되었다)

GUI 디자인을 그래도 미리 조금 해보고싶다면, Adobe 일러스트레이터에서 조금씩 프로토타이핑해놓는것이 현명하다.

코딩을 하면서 디자인을 결정하는것은 매우 고통스러운일이기때문이다.

(많은 개발자들도 이 방식을 추천한다. 시간을 엄청나게 절약해준다고함)


< 해결해야할 문제 >

인간은 거대한 문제를 해결하는데에 굉장히 취약하다. 

따라서, 우리는 이 Piano Roll 이라는 거대한 문제를 최대한 작은 여러개의 문제들로 쪼개서 하나씩 격파해나가는것이 옳다.

무엇이 필요할지 문제인지 한번 생각해보자.

내가 지금 생각해보는 문제들이, 모든 가능성들을 당연히 커버하지못할것이다.

개발이 중반으로 진입했을때 여기저기서 해결해야할 문제들이 랜덤하게 튀어나오기때문이다.

따라서 일단 러프하게 정리를 해본다.

 

1. Juce 에서 미디 노트는 어떻게 표현될수있는가? 

2. 미디 노트는 어디에 저장해야하는가?

3. 미디 노트는 어떻게 저장해야하는가?

3. 저장된 미디 노트는 어떻게 수정할수있는가?

4. 저장된 미디 노트는 어떻게 삭제할수있는가?

5. 저장된 미디 노트는 어떻게 복사할수있는가?

6. 저장된 미디 노트의 정보를 어떻게 가져오는가?

7. 저장된 미디 노트를 어떻게 재생할수있는가?

8. 저장된 미디 노트들은 어떻게 미디 파일이 될수있는가?

9. 저장된 미디 노트는 어떻게 GUI 로 그릴수있는가?


< 필요한 클래스 >

Juce 포럼 검색들을 통해, Piano Roll 의 내부를 만드는데에 필요한 클래스들을 일단 찾아놓았다.

이것들을 미리 공부하는것은 효율적이지않다.

위에 내가 작성한 해결해야할 문제에 기반하여 필요한것을 그때 그때 익혀나는것이 효율적일것이다.

일단 클래스명 정도만 눈에 익혀놓길 바란다.

 

MidiFile

MidiMessageSequence

MidiMessageSequence::MidiEventHolder

MidiMessage

MidiBuffer

AudioPlayHead

AudioPlayHead::CurrentPositionInfo


< 큰 그림 파악해보기 >

세부적인 내용에 들어가기 앞서서, 개발의 큰 그림을 조금 그려놓는다면 도움이 될것이다.

각 클래스에서 일단 add 나 set 이 함수명에 포함된것들을 적어보았다.

add 의 인자를 확인해봄으로써 클래스간의 상하관계를 파악할수있기때문이다.

위의 클래스 함수들을 보면서 우리는 몇가지를 추측해볼수있다.

 

1. MidiFile 클래스의 addTrack 함수는 MidiMessageSequence 객체를 인자로 받는다.

 

여기서 MidiMessageSequence 객체가 MidiFile 의 하위에 위치할것임을 유추해볼수있다.

MidiMessageSequence 객체는 MidFile 의 하나의 track 이 된다.

 

2. MidiMessageSequence 클래스의 addEvent 함수는 MidiMessage 객체를 인자로 받는다.

 

여기서 MidiMessage MidiMessageSequence 의 하위에 위치할것임을 유추해볼수있다.

 

3. MidiMessage 클래스는 따로 add 함수가 없다.

 

따라서 가장 최소의 객체임을 유추해볼수있다.

set 함수명을 살펴보면 NoteNumber, Velocity, Channel 등이 존재하며,

이는 미디 노트를 연상케한다.

 

4. MidiBuffer addEvent?

 

MidiMessageSequence addEvent 는 track 에 노트를 추가해주는것이라 볼수있다.

반면 MidiBuffer addEvent 는 실질적으로 DAW 로 노트를 쏴주는 가장 마지막 문일것이다.

저장된 미디 노트를 어떻게 재생할수있는가? 에 대한 대답일지도 모른다.

우리는 아마도 processBlock() 안에서 현재 DAW 위치와 동일한 위치에 위치한 미디 노트를,

MidiFile 객체로부터 알아내어 MidiBuffer addEvent 해줘야할것이다. (아닐수도있지만 일단 느낌이..)

이때 MidiFile 객체를 GUI thread 에서 write 하고, audio thread 에서 read 하는것인데,

어떻게 thread 안전성을 보장하며 이것을 구현할지도 마지막즈음에 고민해야할 문제일것이다.


< 시각화 >

위의 추측사항들을 바탕으로, 클래스들을 시각화해보면 대략 아래와 같다.

 

 


< 결국은 MidiMessageSequenceMidiMessage >

위의 그림에서 각 Track 은 말그대로 Track 이다.

각 Track 마다 Ableton Live 의 미디 클립이 얹어져있다고 보면 된다.

우리는 위의 그림과 같은 형태로 Midi 파일을 만들지않을것이다. 

우리의 미디 파일은 단일 track 을 가진 아래와 같은 형태일것이다.

결국 MidiFile 클래스는 정말로 미디 파일이 되기위한 일종의 Wrapper 라고 봐야한다.

우리가 만든 sequence 를 미디파일로 변환하는것은 그리 어렵지않고, 매우 차후의 문제이다.

따라서 우리가 신경써야할것은 이 부분이다.

MidiMessageSequence

 

MidiMessageSequence MidiMessage 를 추가, 수정, 이동하고

어떻게 MidiBuffer 에서 재생할것인가에 관심을 가져야한다.

 

MidiMessage : 일종의 미디 노트가 될 녀석

MidiMessageSequence : 이 노트들을 sequence 할 녀석 (container)

MidiBuffer : 이 sequence 를 재생해줄 녀석이다.


< 예제 코드 >

Piano Roll 에 대한 예제 코드는 사실 전무하다.

다행히도 Juce 포럼에서 미디파일을 processBlock() 에서 재생하는 예제 코드를 겨우 찾았다.

추후 이 코드를 통해 기능을 많은것을 배울것이다.

 

Playing a MIDI File.cpp
0.01MB

class MidiPlayer  : public juce::AudioProcessor {

public:

    MidiPlayer() {

        numTracks.store(0);

    }
    ~MidiPlayer();

    void prepareToPlay (double sampleRate, int samplesPerBlock) override {

        ignoreUnused(sampleRate);
        ignoreUnused(samplesPerBlock);

        nextStartTime = -1.0;

    }
    void releaseResources() override {

        // When playback stops, you can use this as an opportunity to free up any
        // spare memory, etc.

    }
    
    /** Play Midi File here ! */
    void processBlock (juce::AudioBuffer<float>& buffer, juce::MidiBuffer& midiMessages) override {

        juce::ScopedNoDenormals noDenormals;
        
        auto totalNumOutputChannels = getTotalNumOutputChannels();

        // We clear all the incoming audio here
        for (auto i = 0; i < totalNumOutputChannels; ++i) buffer.clear(i, 0, buffer.getNumSamples());
            
        const juce::ScopedTryLock myScopedLock(processLock);

        if (myScopedLock.isLocked()) {

            juce::AudioPlayHead::CurrentPositionInfo thePositionInfo;
            getPlayHead()->getCurrentPosition(thePositionInfo);
            
            if (numTracks.load() > 0)
            {
                // The MIDI file is played only when the transport is active
                if (thePositionInfo.isPlaying) {

                    const juce::MidiMessageSequence* theSequence = theMIDIFile.getTrack(currentTrack.load());

                    auto startTime = thePositionInfo.timeInSeconds;
                    auto endTime = startTime + buffer.getNumSamples() / getSampleRate();
                    auto sampleLength = 1.0 / getSampleRate();

                    // If the transport bar position has been moved by the user or because of looping
                    if (std::abs(startTime - nextStartTime) > sampleLength && nextStartTime > 0.0) sendAllNotesOff(midiMessages);
                        
                    nextStartTime = endTime;

                    // If the MIDI file doesn't contain any event anymore
                    if (isPlayingSomething && startTime >= theSequence->getEndTime()) sendAllNotesOff(midiMessages);

                    else {

                        // Called when the user changes the track during playback
                        if (trackHasChanged) {

                            trackHasChanged = false;
                            sendAllNotesOff(midiMessages);

                        }
                        
                        // Iterating through the MIDI file contents and trying to find an event that
                        // needs to be called in the current time frame
                        for (auto i = 0; i < theSequence->getNumEvents(); i++) {

                            juce::MidiMessageSequence::MidiEventHolder* event = theSequence->getEventPointer(i);

                            if (event->message.getTimeStamp() >= startTime && event->message.getTimeStamp() < endTime) {

                                auto samplePosition = roundToInt((event->message.getTimeStamp() - startTime) * getSampleRate());
                                midiMessages.addEvent(event->message, samplePosition);

                                isPlayingSomething = true;
                            }
                        }
                    }
                }

                else {

                    // If the transport isn't active anymore
                    if (isPlayingSomething) sendAllNotesOff(midiMessages);
                        
                }
                
            }
        }

        else {
            
            // If we have just opened a MIDI file with no content
            if (isPlayingSomething) sendAllNotesOff(midiMessages);
                      
        }

    }

    /** This function can be called to load a MIDI file so that it can be played. */
    void loadMIDIFile(juce::File fileMIDI) {

        const ScopedLock myScopedLock(processLock);

        theMIDIFile.clear();
        
        FileInputStream theStream(fileMIDI);
        theMIDIFile.readFrom(theStream);

        /** This function call means that the MIDI file is going to be played with the
            original tempo and signature.

            To play it at the host tempo, we might need to do it manually in processBlock
            and retrieve all the time the current tempo to track tempo changes.
        */
        theMIDIFile.convertTimestampTicksToSeconds();
        
        numTracks.store(theMIDIFile.getNumTracks());
        currentTrack.store(0);
        trackHasChanged = false;

    }
    
    /** Returns the number of tracks in the MIDI file. */
    int getNumTracks() {

        return numTracks.load();

    }

    /** Sets the current track from the MIDI file that needs to be played. */
    void setCurrentTrack(int value) {

        jassert(value >= 0 && value < numTracks.load());
    
        if (numTracks.load() == 0) return;
            
        currentTrack.store(value);
        trackHasChanged = true;

    }

    /** Returns the MIDI file track currently played. */
    int getCurrentTrack() {

        if (numTracks.load() == 0) return -1;
        else return currentTrack.load();

    }

private:

    /** Sends Note Off / Controller Off / Sound Off on all the MIDI channels */
    void sendAllNotesOff(juce::MidiBuffer& midiMessages) {

        for (auto i = 1; i <= 16; i++) {

            midiMessages.addEvent(juce::MidiMessage::allNotesOff(i), 0);
            midiMessages.addEvent(juce::MidiMessage::allSoundOff(i), 0);
            midiMessages.addEvent(juce::MidiMessage::allControllersOff(i), 0);
        }

        isPlayingSomething = false;

    }

    juce::CriticalSection processLock;

    juce::MidiFile theMIDIFile;                 // The current MIDI file content
    bool isPlayingSomething;                    // Tells if the last audio buffer included some MIDI content to play
    bool trackHasChanged = false;
    
    std::atomic<int> currentTrack;              // Current MIDI file track that is played
    std::atomic<int> numTracks;                 // Current MIDI file number of tracks
    
    double nextStartTime = -1.0;                // The start time in seconds of the next audio buffer
                                                // That information is used to know when the transport bar position 
                                                // has been moved by the user or the looping system in the DAW, so
                                                // we can call sendAllNotesOff there

    JUCE_DECLARE_NON_COPYABLE_WITH_LEAK_DETECTOR (MidiPlayer)
};