신디사이저에서는 사운드를 만드는 Oscillator 파트 외에도 그 사운드에 간단한 효과를 입히는 FX 파트가 있습니다.
LFO, Filter부터 시작해서 Distortion이나 Reverb 등을 신디사이저 내에 구현하기도 합니다.
당장 이미지로 올린 Serum 부터가 그러하고요.
여기서는 그 중 가장 기본적인 ADSR에 대해 다룰 겁니다. 신디사이저를 다룰 때 잠깐 언급만 했습니다.
ADSR은 Attack, Decay, Sustain, Release를 말합니다.
신디사이저에서 사운드를 재생하기 시작한 시점에서 끝나는 시점까지의 4단계입니다.
Panning이나 Pitch 등에도 ADSR을 적용할 수 있지만 주로 Volume에서 많이 볼 수 있습니다.
여기서는 Volume을 예시로 설명하겠습니다.
Attack은 키보드 등을 통해 미디 노트가 입력된 시점에서 0부터 재생할 볼륨 수준까지 끌어올리는 단계입니다.
Decay는 Attack에서 도달한 최고 수준에서 Sustain까지 볼륨을 움직이는 단계입니다.
Sustain은 미디 노트가 입력되는 동안 (키보드를 누르는 동안) 유지할 볼륨입니다.
Release는 미디 노트가 끝난 시점 (키보르를 뗀 직후)에서 0까지 볼륨 수준을 내리는 단계입니다.
Attack, Decay, Release는 시간 단위 (ms 혹은 s)이고, Sustain은 볼륨 레벨 (주로 0 ~ 1)입니다.
좀 더 자세하게 들어가면, Attack으로 도달하는 볼륨 수준을 따로 정하게 할 수도 있습니다.
일반적으로는 Attack으로는 최대치 1.0까지 올라갔다가, Decay 단계에서 Sustain 레벨로 내려갑니다.
Juce에서는 ADSR를 쉽게 적용할 수 있도록 구현해서 기능을 제공하고 있습니다.
juce::ADSR adsr;
juce::ADSR::Parameters parameters;
void void startNote(int midiNoteNumber, float velocity, juce::SynthesiserSound* sound, int currentPitchWheelPosition) override
{
//...
adsr.reset();
adsr.setParameters(parameters);
adsr.noteOn();
}
void stopNote(float /*velocity*/, bool allowTailOff) override
{
//...
adsr.noteOff();
}
void renderNextBlock (juce::AudioSampleBuffer& outputBuffer, int startSample, int numSamples) override
{
//...
adsr.applyEnvelopeToBuffer(outputBuffer, startSample, numSamples);
}
applyEnvelopeToBuffer를 호출하기 전에 사운드 프로세싱을 완료해야 합니다.
다만 이렇게 적용한 ADSR에 문제점이 있었습니다.
키를 빠르게 연타하는 등의 경우에 Release가 정상적으로 안 끝나서 사운드가 재생이 안 되더군요,
기존 메서드만으로는 해결할 방도를 못 찾아서, custom으로 구현한 ADSR를 사용하기로 결정했습니다.
Reference : http://www.martin-finke.de/blog/articles/audio-plugins-011-envelopes/
ADSR의 구현은 간단합니다. Envelope의 그래프에 따라서 볼륨 값을 계산하기만 하면 되니까요.
직선이 아니라 복잡한 곡선을 쓴다거나 하면 어렵겠지만, 여기선 직선만 사용하겠습니다.
ADSR Envelope에서 사용할 각 단계의 enum부터 정의합시다.
enum EnvelopeStage
{
ENVELOPE_STAGE_OFF = 0,
ENVELOPE_STAGE_ATTACK,
ENVELOPE_STAGE_DECAY,
ENVELOPE_STAGE_SUSTAIN,
ENVELOPE_STAGE_RELEASE,
kNumEnvelopeStages
};
double stageValue[kNumEnvelopeStages];
stageValue에는 enum을 인덱스로 하는 각 단계의 값을 저장할 겁니다.
attack/decay/release는 시간, sustain은 볼륨 값이 되겠네요.
ADSREnvelope::ADSREnvelope(double sampleRate, juce::ADSR::Parameters params) : minimumLevel(0.0001), currentStage(ENVELOPE_STAGE_OFF), currentLevel(minimumLevel), multiplier(1.0), sampleRate(sampleRate), currentSampleIndex(0), nextStageSampleIndex(0)
{
stageValue[ENVELOPE_STAGE_OFF] = 0.0;
setParameters(params);
}
다른 건 초기값 설정을 위한 것이니 넘어가더라도, sampleRate는 필요한 값입니다.
sampleRate가 다르면 초당 샘플 수가 다르니 시간을 정확하게 계산할 수가 없습니다.
void ADSREnvelope::setParameters(juce::ADSR::Parameters params)
{
stageValue[ENVELOPE_STAGE_OFF] = 0.0;
stageValue[ENVELOPE_STAGE_ATTACK] = params.attack;
stageValue[ENVELOPE_STAGE_DECAY] = params.decay;
stageValue[ENVELOPE_STAGE_SUSTAIN] = params.sustain;
stageValue[ENVELOPE_STAGE_RELEASE] = params.release;
}
attack의 시작, release의 끝 처리를 위해 off에는 0을 저장합니다.
시간에 따라 값을 계산해서 가져오는 함수를 만듭시다. 특정 수치에 도달하면 다음 단계로 넘어가야 됩니다.
double ADSREnvelope::nextSample()
{
if (currentStage != ENVELOPE_STAGE_OFF && currentStage != ENVELOPE_STAGE_SUSTAIN)
{
while (currentStage != ENVELOPE_STAGE_OFF && currentStage != ENVELOPE_STAGE_SUSTAIN && currentSampleIndex == nextStageSampleIndex)
{
EnvelopeStage newStage = static_cast<EnvelopeStage>((currentStage + 1) % kNumEnvelopeStages);
enterStage(newStage);
}
if (currentStage != ENVELOPE_STAGE_OFF && currentStage != ENVELOPE_STAGE_SUSTAIN)
{
currentLevel += multiplier;
currentSampleIndex++;
}
}
return currentLevel;
}
Reference와는 약간 다릅니다. attack/decay/release가 0인 경우를 처리하기 위해서인데,
release는 0이어도 큰 상관은 없겠지만, attack/decay가 0이면 해당 단계에 진입하자마자 다음 단계로 넘어가야 되기 때문에 while 문을 작성했습니다.
조금 있다가 작성하겠지만 sustain 단계가 좀 이질적으로 작동하기 때문에 조건문이 좀 지저분합니다.
기본적으로는 현재 샘플 위치가 다음 단계로 넘어가기 위한 인덱스 값과 일치할 때 다음 단계로 넘어가는 구조입니다.
단계 진입이 끝나면 볼륨 값을 계산합니다. 여기서는 변화량을 따로 저장해서 그 값을 더하게 했습니다.
void ADSREnvelope::enterStage(EnvelopeStage newStage)
{
currentStage = newStage;
currentSampleIndex = 0;
if (currentStage == ENVELOPE_STAGE_OFF || currentStage == ENVELOPE_STAGE_SUSTAIN)
nextStageSampleIndex = 0;
else
nextStageSampleIndex = stageValue[currentStage] * sampleRate;
const double attackEndValue = 1.0;
switch (newStage)
{
case ENVELOPE_STAGE_OFF:
currentLevel = 0.0;
multiplier = 1.0;
break;
case ENVELOPE_STAGE_ATTACK:
currentLevel = minimumLevel;
calculateMultiplier(currentLevel, attackEndValue, nextStageSampleIndex);
break;
case ENVELOPE_STAGE_DECAY:
currentLevel = attackEndValue;
calculateMultiplier(currentLevel, std::fmax(stageValue[ENVELOPE_STAGE_SUSTAIN], minimumLevel), nextStageSampleIndex);
break;
case ENVELOPE_STAGE_SUSTAIN:
currentLevel = stageValue[ENVELOPE_STAGE_SUSTAIN];
multiplier = 1.0;
break;
case ENVELOPE_STAGE_RELEASE:
calculateMultiplier(currentLevel, minimumLevel, nextStageSampleIndex);
break;
default:
break;
}
}
off나 sustain은 유지되는 동안은 계속 그 단계에 머물러 있기 때문에, 다음 단계로 넘어가기 위한 특정 인덱스가 존재하지 않습니다. 다시 말하면, 시간이 지나면 단계가 넘어가는 게 아니므로 값이 필요하지 않습니다.
그 외의 단계에서는 정해진 시간이 지나면 다음 단계로 넘어가야 되므로, 그 값(stageValue에 저장되어 있는 값)을 sampleRate에 맞춰서 계산해줍니다.
switch문에서는 새로운 단계에 맞춰서 값을 갱신해줍니다.
currentLevel은 그 단계의 초기 값으로 설정해주고 변화량은 따로 계산해줍니다.
calculateMultiplier의 parameter는 시작 값, 끝 값, 구간의 길이입니다.
attackEndValue의 값을 바꾸면 attack 단계에서 도달하는 볼륨 값을 바꿀 수 있습니다.
void ADSREnvelope::calculateMultiplier(double startLevel, double endLevel, unsigned long long lengthInSamples)
{
multiplier = (endLevel - startLevel) / (lengthInSamples);
}
단순한 직선이니 식도 간단합니다. 여기에 사용하는 식을 바꾸면 다른 방식의 slope를 구현할 수 있습니다.
Voice에서 사용해봅시다.
void startNote(int midiNoteNumber, float velocity, juce::SynthesiserSound* sound, int currentPitchWheelPosition) override
{
//...
adsr.setParameters(parameters);
adsr.setSampleRate(sampleRate);
adsr.enterStage(ADSREnvelope::ENVELOPE_STAGE_ATTACK);
}
void stopNote(float /*velocity*/, bool allowTailOff) override
{
if (allowTailOff)
{
if (adsr.getCurrentStage() != ADSREnvelope::ENVELOPE_STAGE_OFF && adsr.getCurrentStage() != ADSREnvelope::ENVELOPE_STAGE_RELEASE)
adsr.enterStage(ADSREnvelope::ENVELOPE_STAGE_RELEASE);
}
else
{
clearCurrentNote();
adsr.enterStage(ADSREnvelope::ENVELOPE_STAGE_OFF);
}
}
stopNote에서 allowTailOff면 Release 단계에 들어가야 되고, 그렇지 않으면 음이 완전히 끝나야하니 Off로 설정해주면 됩니다.
float sample = level * adsr.nextSample();
for (auto i = outputBuffer.getNumChannels(); --i >= 0;)
outputBuffer.addSample(i, startSample + bufferIndex, processed.getSample(i, sourceIndex) * sample);
renderNextSample은 다 긁어오기가 좀 곤란하네요.
재생하려는 사운드 볼륨을 level이라고 했을 때, nextSample로 계산해온 값을 곱하고,
버퍼에 샘플 * 값을 넣어주면 됩니다.
이게 아니었으면 addFrom으로 대량으로 버퍼를 옮겨넣었을텐데, nextSample은 단일 샘플마다 돌아가야 되니 while문으로 작성하게 됐네요.
ADSR은 이쯤하면 될 것 같습니다.
다음 포스팅에서 Pitch Shift만 간단하게 다뤄보고 포스팅을 끝내야겠습니다.
'C++ > Sound' 카테고리의 다른 글
[JUCE] Juce Framework로 사운드 프로그래밍 #7 Pitch Shift (0) | 2022.07.11 |
---|---|
[JUCE] Juce Framework로 사운드 프로그래밍 #5 GUI (0) | 2022.07.02 |
[JUCE] Juce Framework로 사운드 프로그래밍 #4 Processor와 Value Tree (0) | 2022.06.24 |
[JUCE] Juce Framework로 사운드 프로그래밍 #3 신디사이저 만들기 (0) | 2022.06.12 |
[JUCE] Juce Framework로 사운드 프로그래밍 #2 구조 대충 알아보기 (0) | 2022.06.08 |