정밀하게 웹 오디오 예약
소개
웹 플랫폼을 사용하여 우수한 오디오 및 음악 소프트웨어를 빌드할 때 가장 큰 어려움 중 하나는 시간 관리입니다. '코드를 작성하는 데 걸리는 시간'이 아니라 시계 시간입니다. 웹 오디오에 관해 가장 잘 이해되지 않는 주제 중 하나는 오디오 시계를 올바르게 사용하는 방법입니다. Web Audio AudioContext 객체에는 이 오디오 시계를 노출하는 currentTime 속성이 있습니다.
특히 웹 오디오의 음악적 애플리케이션(시퀀서 및 신디사이저 작성뿐만 아니라 드럼 머신, 게임, 기타 애플리케이션과 같은 오디오 이벤트의 리드미컬한 사용)의 경우 오디오 이벤트의 일관되고 정확한 타이밍을 유지하는 것이 매우 중요합니다. 소리를 시작하고 중지하는 것뿐만 아니라 소리 변경(예: 주파수 또는 볼륨 변경)을 예약하는 것도 중요합니다. Web Audio API로 게임 오디오 개발의 기관총 데모와 같이 약간 시간 무작위 이벤트를 사용하는 것이 바람직한 경우도 있지만 일반적으로 음표의 타이밍은 일관되고 정확해야 합니다.
Web Audio 시작하기 및 Web Audio API로 게임 오디오 개발에서 Web Audio noteOn 및 noteOff (이제 start 및 stop으로 이름이 변경됨) 메서드의 시간 매개변수를 사용하여 음을 예약하는 방법을 이미 설명했습니다. 하지만 긴 음악 시퀀스나 리듬을 재생하는 등 더 복잡한 시나리오는 자세히 살펴보지 않았습니다. 이를 살펴보려면 먼저 시계에 관한 배경 지식이 필요합니다.
The Best of Times - 웹 오디오 시계
Web Audio API는 오디오 하위 시스템의 하드웨어 클록에 대한 액세스를 노출합니다. 이 시계는 AudioContext가 생성된 후의 부동 소수점 수(초)인 .currentTime 속성을 통해 AudioContext 객체에서 노출됩니다. 이를 통해 이 클록 (이하 '오디오 클록'이라고 함)을 매우 높은 정밀도로 만들 수 있습니다. 이 클록은 높은 샘플링 레이트에서도 개별 사운드 샘플 수준에서 정렬을 지정할 수 있도록 설계되었습니다. 'double'에는 약 15자리의 소수점 정확도가 있으므로 오디오 시계가 며칠 동안 실행되었더라도 높은 샘플링 레이트에서도 특정 샘플을 가리키기에 충분한 비트가 남아 있어야 합니다.
오디오 클록은 Web Audio API 전체에서 매개변수 및 오디오 이벤트를 예약하는 데 사용됩니다. 물론 start() 및 stop() 및 AudioParams의 set*ValueAtTime() 메서드에도 사용됩니다. 이를 통해 매우 정확한 시간의 오디오 이벤트를 미리 설정할 수 있습니다. 사실 Web Audio에서 모든 것을 시작/중지 시간으로 설정하고 싶은 유혹이 있지만 실제로는 문제가 있습니다.
예를 들어, 8분음표 하이햇 패턴의 두 소절을 설정하는 Web Audio Intro에서 축소된 코드 스니펫을 살펴보겠습니다.
for (var bar = 0; bar < 2; bar++) {
var time = startTime + bar * 8 * eighthNoteTime;
// Play the hi-hat every eighth note.
for (var i = 0; i < 8; ++i) {
playSound(hihat, time + i * eighthNoteTime);
}
이 코드는 잘 작동합니다. 하지만 두 마디 중간에 템포를 변경하거나 두 마디가 지나기 전에 재생을 중지하려면 불가능합니다. (개발자가 사전 예약된 AudioBufferSourceNodes와 출력 간에 이득 노드를 삽입하는 등의 작업을 통해 자체 사운드를 음소거하는 것을 본 적이 있습니다.)
간단히 말하면, 주파수나 게인과 같은 템포나 매개변수를 변경하거나 (또는 스케줄링을 완전히 중지할) 유연성이 필요하기 때문에 너무 많은 오디오 이벤트를 대기열에 넣거나 더 정확하게 말하면 너무 멀리 내다보지 않는 것이 좋습니다. 일정을 완전히 변경해야 할 수도 있기 때문입니다.
최악의 시대 - JavaScript 시계
또한 Date.now() 및 setTimeout()로 표시되는, 많은 사랑을 받으면서도 많은 비판을 받는 JavaScript 시계도 있습니다. JavaScript 시계의 장점은 시스템이 특정 시간에 코드를 다시 호출하도록 할 수 있는 매우 유용한 나중에 다시 호출해 달라는 window.setTimeout() 및 window.setInterval() 메서드가 있다는 것입니다.
JavaScript 시계의 단점은 정확하지 않다는 것입니다. 먼저 Date.now()는 밀리초 단위의 값(밀리초의 정수)을 반환하므로 기대할 수 있는 최적의 정밀도는 1밀리초입니다. 이는 일부 음악적 맥락에서는 그리 나쁘지 않습니다. 음표가 1밀리초 일찍 또는 늦게 시작되더라도 눈치채지 못할 수도 있습니다. 하지만 상대적으로 낮은 오디오 하드웨어 속도인 44.1kHz에서도 오디오 예약 시계로 사용하기에는 약 44.1배 느립니다. 샘플을 드롭하면 오디오 글리치가 발생할 수 있으므로 샘플을 함께 연결하는 경우 정확하게 순차적이어야 할 수 있습니다.
향후 출시될 고해상도 시간 사양은 window.performance.now()를 통해 훨씬 더 정확한 현재 시간을 제공합니다. 또한 많은 현재 브라우저에서 접두사가 추가되긴 했지만 구현되어 있습니다. 이는 JavaScript 타이밍 API의 가장 나쁜 부분과는 관련이 없지만 경우에 따라 도움이 될 수 있습니다.
JavaScript 타이밍 API에서 최악의 부분은 Date.now()의 밀리초 정밀도가 사용하기에는 그리 나쁘지 않은 것 같지만, window.setTimeout() 또는 window.setInterval을 통해 JavaScript의 타이머 이벤트 콜백이 레이아웃, 렌더링, 가비지 컬렉션, XMLHTTPRequest 및 기타 스레드에 의해 수십 밀리초 이상 쉽게 왜곡될 수 있습니다. Web Audio API를 사용하여 예약할 수 있는 '오디오 이벤트'를 언급했었죠? 이러한 작업은 모두 별도의 스레드에서 처리되므로 기본 스레드가 복잡한 레이아웃이나 기타 긴 작업을 실행하는 중에 일시적으로 중단되더라도 오디오는 지정된 시간에 정확하게 재생됩니다. 실제로 디버거의 중단점에서 중지되더라도 오디오 스레드는 예약된 이벤트를 계속 재생합니다.
오디오 앱에서 JavaScript setTimeout() 사용
기본 스레드는 한 번에 여러 밀리초 동안 쉽게 중단될 수 있으므로 JavaScript의 setTimeout을 사용하여 오디오 이벤트 재생을 직접 시작하는 것은 좋지 않습니다. 최선의 경우 노트가 실제로 재생되어야 하는 시점으로부터 1밀리초 이내에 실행되지만 최악의 경우 훨씬 더 지연될 수 있기 때문입니다. 최악의 경우 리드미컬한 시퀀스여야 하는 것이 정확한 간격으로 실행되지 않습니다. 타이밍이 기본 JavaScript 스레드에서 발생하는 다른 작업에 민감하기 때문입니다.
이를 보여주기 위해 '잘못된' 메트로놈 샘플 애플리케이션을 작성했습니다. 이 애플리케이션은 메모를 예약하기 위해 setTimeout을 직접 사용하고 많은 레이아웃을 실행합니다. 이 애플리케이션을 열고 '재생'을 클릭한 다음 재생 중 창 크기를 빠르게 조절합니다. 타이밍이 눈에 띄게 불안정해집니다 (리듬이 일관되지 않음을 들을 수 있음). '하지만 인위적입니다'라고 반문하시겠죠? 물론입니다. 하지만 그렇다고 해서 실제로도 그런 일이 발생하지 않는다는 의미는 아닙니다. 비교적 정적인 사용자 인터페이스도 리레이아웃으로 인해 setTimeout에서 타이밍 문제가 발생합니다. 예를 들어 창 크기를 빠르게 조정하면 훌륭한 WebkitSynth의 타이밍이 눈에 띄게 끊기는 것을 확인했습니다. 이제 오디오와 함께 전체 악보를 부드럽게 스크롤하려고 할 때 어떤 일이 일어나는지 상상해 보세요. 그러면 실제 복잡한 음악 앱에 어떤 영향을 미칠지 쉽게 짐작할 수 있습니다.
가장 자주 듣는 질문 중 하나는 '오디오 이벤트에서 콜백을 가져올 수 없는 이유는 무엇인가요?'입니다. 이러한 유형의 콜백은 유용하지만 당면한 특정 문제를 해결하지는 못합니다. 이러한 이벤트는 기본 JavaScript 스레드에서 실행되므로 setTimeout과 동일한 잠재적 지연이 모두 적용된다는 점을 이해하는 것이 중요합니다. 즉, 실제로 처리되기 전에 예약된 정확한 시간으로부터 알 수 없고 가변적인 밀리초 동안 지연될 수 있습니다.
그러면 어떻게 해야 할까요? 타이밍을 처리하는 가장 좋은 방법은 JavaScript 타이머(setTimeout(), setInterval() 또는 requestAnimationFrame()(이후에 자세히 설명))와 오디오 하드웨어 예약 간의 협업을 설정하는 것입니다.
미래를 내다보며 확실한 타이밍 확보
메트로놈 데모로 돌아가 보겠습니다. 사실 이 공동 작업 예약 기법을 보여주기 위해 이 간단한 메트로놈 데모의 첫 번째 버전을 올바르게 작성했습니다. (GitHub에서도 코드를 사용할 수 있습니다. 이 데모는 16분음표, 8분음표 또는 4분음표마다 높은 정밀도로 비프 소리(오실레이터에서 생성)를 재생하여 비트에 따라 피치를 변경합니다. 또한 연주하는 동안 템포와 음 간격을 변경하거나 언제든지 재생을 중지할 수 있습니다. 이는 실제 리듬 시퀀서의 주요 기능입니다. 이 메트로놈에서 사용하는 소리를 실시간으로 변경하는 코드를 추가하는 것도 매우 쉽습니다.
견고한 타이밍을 유지하면서 온도 제어를 허용하는 방법은 공동작업입니다. 이 공동작업은 가끔 한 번씩 실행되고 나중에 개별 음표에 대해 웹 오디오 예약을 설정하는 setTimeout 타이머입니다. setTimeout 타이머는 기본적으로 현재 템포를 기반으로 '곧' 예약해야 할 음표가 있는지 확인한 다음 다음과 같이 예약합니다.
실제로는 setTimeout() 호출이 지연될 수 있으므로 예약 호출의 타이밍이 시간이 지남에 따라 지터링(및 setTimeout 사용 방식에 따라 왜곡)될 수 있습니다. 이 예시의 이벤트는 약 50ms 간격으로 실행되지만, 이보다 약간 더 길거나 훨씬 더 길게 실행되는 경우가 많습니다. 그러나 각 호출 중에 지금 재생해야 하는 음표(예: 첫 번째 음표)뿐만 아니라 지금과 다음 간격 사이에 재생해야 하는 음표에 대해서도 Web Audio 이벤트를 예약합니다.
사실, setTimeout() 호출 간의 간격을 정확하게 앞당기고 싶지는 않습니다. 최악의 기본 스레드 동작(즉, 다음 타이머 호출을 지연시키는 기본 스레드에서 발생하는 최악의 기본 스레드 동작)을 수용하려면 이 타이머 호출과 다음 호출 간의 일정 중복이 필요합니다. 또한 오디오 블록 예약 시간(즉, 운영체제가 처리 버퍼에 유지하는 오디오 양)도 고려해야 합니다. 이 시간은 운영체제와 하드웨어에 따라 단일 자릿수 밀리초에서 약 50밀리초까지 다양합니다. 위에 표시된 각 setTimeout() 호출에는 이벤트를 예약하려고 시도하는 전체 시간 범위를 보여주는 파란색 간격이 있습니다. 예를 들어 위의 다이어그램에 예약된 네 번째 웹 오디오 이벤트는 다음 setTimeout 호출이 발생할 때까지 재생을 기다렸다면 '늦게' 재생되었을 수 있습니다(setTimeout 호출이 몇 밀리초만 늦게 발생한 경우). 실제 상황에서는 이러한 시간의 지터가 그보다 훨씬 더 심할 수 있으며, 앱이 더 복잡해질수록 이러한 중복이 더욱 중요해집니다.
전체적인 리드락 지연 시간은 템포 제어 (및 기타 실시간 제어)의 엄격도에 영향을 미칩니다. 예약 호출 간의 간격은 최소 지연 시간과 코드가 프로세서에 미치는 영향의 빈도 간에 절충됩니다. 미리보기가 다음 간격의 시작 시간과 겹치는 정도에 따라 앱이 여러 머신에서 얼마나 탄력적으로 작동하는지, 앱이 더 복잡해질 때 레이아웃과 가비지 컬렉션이 더 오래 걸릴 수 있는지 결정됩니다. 일반적으로 느린 머신과 운영체제에 대한 탄력성을 높이려면 전반적인 룩아헤드가 크고 간격이 적당히 짧아야 합니다. 더 짧은 오버랩과 더 긴 간격으로 조정하여 콜백을 더 적게 처리할 수 있지만, 어느 시점에는 긴 지연 시간으로 인해 템포 변경 등이 즉시 적용되지 않는다는 것을 알게 될 수도 있습니다. 반대로 전방을 너무 줄이면 일정 예약 호출이 과거에 발생했어야 할 '보상' 이벤트를 해야 할 수 있으므로 잡음이 들릴 수도 있습니다.
다음 타이밍 다이어그램은 메트로놈 데모 코드가 실제로 수행하는 작업을 보여줍니다. setTimeout 간격은 25ms이지만, 각 호출이 다음 100ms에 대해 예약하는 복원력이 훨씬 뛰어납니다. 이렇게 긴 리드아웃의 단점은 템포 변경 등이 적용되는 데 0.1초가 걸린다는 점입니다. 하지만 중단에 훨씬 더 탄력적입니다.
사실 이 예에서는 중간에 setTimeout이 중단된 것을 알 수 있습니다. 약 270ms에 setTimeout 콜백이 있어야 하지만, 어떤 이유로 인해 약 320ms까지 지연되었습니다. 즉, 예정보다 50ms 늦어진 것입니다. 하지만 lookahead 지연 시간이 길어 타이밍이 문제없이 유지되었으며, 바로 직전에 템포를 240bpm으로 높여 16분 음표를 연주해도 박자를 놓치지 않았습니다 (하드코어 드럼 앤 베이스 템포보다도 빠름!).
각 스케줄러 호출이 여러 개의 메모를 예약할 수도 있습니다. 더 긴 예약 간격(250ms 미리보기, 200ms 간격)과 중간에 템포 증가를 사용하는 경우를 살펴보겠습니다.
이 사례는 각 setTimeout() 호출이 여러 개의 오디오 이벤트를 예약하게 될 수 있음을 보여줍니다. 실제로 이 메트로놈은 한 번에 하나의 음표로만 구성된 간단한 애플리케이션이지만, 드럼 머신 (동시 음이 여러 개 있는 경우가 많음) 또는 시퀀서 (음 사이에 불규칙한 간격이 있을 수 있음)에서 이 방식이 어떻게 작동하는지 쉽게 확인할 수 있습니다.
실제로는 예약 간격과 리드아웃을 조정하여 레이아웃, 가비지 컬렉션, 기본 JavaScript 실행 스레드에서 진행되는 기타 작업의 영향을 확인하고 템포 등의 제어의 세부사항을 조정해야 합니다. 예를 들어 자주 발생하는 매우 복잡한 레이아웃이 있는 경우 리드아웃을 더 크게 설정하는 것이 좋습니다. 중요한 점은 지연을 방지하기에 충분하지만 템포 제어를 조정할 때 눈에 띄게 지연되지 않을 만큼 '미리 예약'하는 양을 조정해야 한다는 것입니다. 위의 사례도 겹치는 부분이 매우 작으므로 복잡한 웹 애플리케이션이 있는 느린 머신에서는 탄력성이 떨어집니다. 시작하는 좋은 방법은 간격을 25ms로 설정하고 100ms의 '미리보기' 시간을 사용하는 것입니다. 하지만 오디오 시스템 지연 시간이 많은 머신에서 실행되는 복잡한 애플리케이션에서는 여전히 문제가 발생할 수 있습니다. 이 경우 앞조정 시간을 늘리거나, 탄력성을 일부 포기하고 더 엄격한 제어가 필요한 경우 더 짧은 앞조정을 사용합니다.
예약 프로세스의 핵심 코드는 scheduler() 함수에 있습니다.
while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
scheduleNote( current16thNote, nextNoteTime );
nextNote();
}
이 함수는 현재 오디오 하드웨어 시간을 가져와서 시퀀스의 다음 음표 시간과 비교합니다. 이 정확한 시나리오에서는 대부분* 아무 일도 하지 않습니다 (예약 대기 중인 메트로놈 '음표'가 없기 때문). 하지만 성공하면 Web Audio API를 사용하여 해당 음표를 예약하고 다음 음표로 진행합니다.
scheduleNote() 함수는 실제로 다음 웹 오디오 '메모'가 재생되도록 예약하는 역할을 합니다. 이 경우 오실레이터를 사용하여 다양한 주파수로 비프음을 냈습니다. AudioBufferSource 노드를 만들고 버퍼를 드럼 소리 또는 원하는 다른 소리로 설정하는 것도 마찬가지로 쉽습니다.
currentNoteStartTime = time;
// create an oscillator
var osc = audioContext.createOscillator();
osc.connect( audioContext.destination );
if (! (beatNumber % 16) ) // beat 0 == low pitch
osc.frequency.value = 220.0;
else if (beatNumber % 4) // quarter notes = medium pitch
osc.frequency.value = 440.0;
else // other 16th notes = high pitch
osc.frequency.value = 880.0;
osc.start( time );
osc.stop( time + noteLength );
이러한 오실레이터가 예약되고 연결되면 이 코드는 이를 완전히 잊어버릴 수 있습니다. 오실레이터는 시작되었다가 중지된 후 자동으로 가비지 컬렉션됩니다.
nextNote() 메서드는 다음 16분 음표로 이동합니다. 즉, nextNoteTime 및 current16thNote 변수를 다음 음표로 설정합니다.
function nextNote() {
// Advance current note and time by a 16th note...
var secondsPerBeat = 60.0 / tempo; // picks up the CURRENT tempo value!
nextNoteTime += 0.25 * secondsPerBeat; // Add 1/4 of quarter-note beat length to time
current16thNote++; // Advance the beat number, wrap to zero
if (current16thNote == 16) {
current16thNote = 0;
}
}
매우 간단합니다. 단, 이 예에서는 '시퀀스 시간'(메트로놈을 시작한 후 경과 시간)을 추적하지 않는다는 점에 유의해야 합니다. 마지막 음을 연주한 시간을 기억하고 다음 음이 재생될 예정인 시간을 알아내면 됩니다. 이렇게 하면 템포를 쉽게 변경하거나 재생을 중지할 수 있습니다.
이 예약 기술은 Web Audio Drum Machine, 재미있는 Acid Defender 게임, Granular Effects 데모와 같은 심층적인 오디오 예시 등 웹의 다른 여러 오디오 애플리케이션에서 사용됩니다.
Yet Another Timing System
모든 오디오 애플리케이션에 필요한 것은 카우벨이 아니라 타이머입니다. 시각적 디스플레이를 올바르게 표시하는 방법은 서드 파티 타이밍 시스템을 사용하는 것입니다.
왜, 왜, 왜 또 다른 타이밍 시스템이 필요한 거야? 이 이미지는 requestAnimationFrame API를 통해 시각적 디스플레이, 즉 그래픽 새로고침 빈도에 동기화됩니다. 메트로놈 예시에서 상자를 그리는 경우 이 문제가 그리 중요하지 않을 수 있지만 그래픽이 점점 더 복잡해질수록 requestAnimationFrame()을 사용하여 시각적 새로고침 레이트와 동기화하는 것이 점점 더 중요해집니다. 그리고 실제로는 처음부터 setTimeout()을 사용하는 것만큼이나 쉽습니다. 매우 복잡하게 동기화된 그래픽(예: 음악 표기법 패키지에서 재생되는 밀집된 음표의 정확한 표시)을 사용하면 requestAnimationFrame()을 통해 가장 부드럽고 정확한 그래픽 및 오디오 동기화를 얻을 수 있습니다.
스케줄러에서 현재 재생목록의 비트를 추적했습니다.
notesInQueue.push( { note: beatNumber, time: time } );
메트로놈의 현재 시간과의 상호작용은 그래픽 시스템이 업데이트 준비가 될 때마다 (requestAnimationFrame을 사용하여) 호출되는 draw() 메서드에서 확인할 수 있습니다.
var currentTime = audioContext.currentTime;
while (notesInQueue.length && notesInQueue[0].time < currentTime) {
currentNote = notesInQueue[0].note;
notesInQueue.splice(0,1); // remove note from queue
}
이번에도 오디오 시스템의 시계를 확인한다는 사실을 알 수 있습니다. 이 시계가 실제로 음을 재생하기 때문에 동기화하고자 하는 시계이기 때문입니다. 새 상자를 그려야 할지, 하지 않을지 알 수 있습니다. 사실 requestAnimationFrame 타임스탬프는 전혀 사용하지 않습니다. 오디오 시스템 시계를 사용하여 현재 시간을 파악하기 때문입니다.
물론, setTimeout() 콜백을 모두 사용하여 그냥 건너 뛰고 메모 스케줄러를 requestAnimationFrame 콜백에 넣으면 다시 타이머 2개로 돌아가게 됩니다. 그것도 괜찮지만, 이 경우 requestAnimationFrame은 단지 setTimeout()의 대체물이라는 것을 이해하는 것이 중요합니다. 여전히 실제 메모에 대한 웹 오디오 타이밍의 예약 정확성이 필요할 수 있습니다.
결론
이 튜토리얼이 시계, 타이머, 웹 오디오 애플리케이션에 적절한 타이밍을 빌드하는 방법을 설명하는 데 도움이 되었기를 바랍니다. 이와 같은 기법을 쉽게 추론하여 시퀀스 플레이어, 드럼 머신 등을 제작할 수 있습니다. 다음에 또 뵙겠습니다.