두 시계 이야기

정확한 웹 오디오 예약

크리스 윌슨
크리스 윌슨

소개

웹 플랫폼을 사용하여 훌륭한 오디오 및 음악 소프트웨어를 빌드하는 데 있어 가장 큰 과제 중 하나는 시간을 관리하는 것입니다. '코드 작성 시간'이 아니라 시계 시간과 마찬가지로 웹 오디오에 대해 잘 이해되지 않는 주제 중 하나는 오디오 시계를 올바르게 사용하는 방법입니다. 웹 Audio AudioContext 객체에는 이 오디오 시계를 노출하는 currentTime 속성이 있습니다.

특히 웹 오디오의 음악 애플리케이션(음악 시퀀서 및 신시사이저 작성뿐 아니라 드럼 머신, 게임, 기타 애플리케이션 등 오디오 이벤트를 리드미컬하게 사용하는 경우)의 경우 사운드를 시작하고 중지하는 것뿐만 아니라 사운드 변경(예: 주파수 또는 볼륨 변경)도 일관되고 정확한 오디오 이벤트의 타이밍을 갖는 것이 매우 중요합니다. Web Audio API를 사용하여 게임 오디오 개발의 머신건 데모와 같이 시간이 약간 무작위로 지정된 이벤트가 발생할 수도 있지만, 일반적으로는 음표의 타이밍을 일관되고 정확하게 하는 것이 좋습니다.

웹 오디오 시작하기웹 오디오 API를 사용하여 게임 오디오 개발에서 웹 오디오 noteOn 및 noteOff (지금은 시작 및 중지로 이름이 변경됨) 메서드의 시간 매개변수를 사용하여 메모를 예약하는 방법을 이미 보여드렸으나 긴 음악 시퀀스나 리듬 재생과 같은 더 복잡한 시나리오는 자세히 다루지 않았습니다. 자세히 알아보기 위해 먼저 시계에 대한 약간의 배경 지식이 필요합니다.

최고의 순간 - 웹 오디오 시계

Web Audio API는 오디오 하위 시스템의 하드웨어 시계에 대한 액세스를 노출합니다. 이 시계는 AudioContext가 생성된 후부터 부동 소수점 수의 초 단위로 .currentTime 속성을 통해 AudioContext 객체에 노출됩니다. 따라서 이 클록 (이하 '오디오 클록'이라고 함)의 정밀도가 매우 높아집니다. 높은 샘플링 레이트로도 개별 사운드 샘플 수준에서 정렬을 지정할 수 있도록 설계되었습니다. '2배'에는 십진수 약 15자리의 정밀도가 있으므로 오디오 시계가 며칠 동안 실행되었더라도 샘플링 레이트가 높은 경우에도 특정 샘플을 가리키는 비트가 여전히 많이 남아 있어야 합니다.

오디오 시계는 Web Audio API에서 start()stop()뿐만 아니라 AudioParams의 set*ValueAtTime() 메서드에도 매개변수 및 오디오 이벤트를 예약하는 데 사용됩니다. 이를 통해 매우 정확하게 시간이 지정된 오디오 이벤트를 미리 설정할 수 있습니다. 웹 오디오에서 모든 것을 시작/중지 시간으로 설정하고 싶은 유혹이 들겠지만, 실제로는 이 문제가 있습니다.

예를 들어 웹 오디오 인트로에서 다음과 같은 축소된 코드 스니펫을 살펴보세요. 8분음표 하이햇 패턴으로 두 소절을 설정하는 것입니다.

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);
  }

이 코드는 문제없이 작동합니다. 하지만 두 소절 중간에 템포를 바꾸거나 두 소절이 올라가기 전에 연주를 멈추고 싶을 수 있습니다. (개발자들이 자신의 사운드를 음소거할 수 있도록 사전에 예약된 AudioBufferSourceNode와 출력 사이에 게인 노드를 삽입하는 등의 작업을 하는 것을 보았습니다.)

간단히 말하면, 템포나 주파수나 게인과 같은 매개변수를 변경하거나 스케줄링을 완전히 중지할 수 있는 유연성이 필요하기 때문에 지나치게 많은 오디오 이벤트를 대기열에 추가하거나, 더 정확하게는 스케줄링을 완전히 변경하는 것이 좋기 때문에 너무 많은 시간을 앞두고 싶지는 않을 것입니다.

최악의 시간 - 자바스크립트 시계

Date.now() 및 setTimeout()으로 표현되는 많은 사랑을 받는 자바스크립트 시계도 있습니다. JavaScript 시계의 장점은 매우 유용한 콜-미백(call-me-back-later) window.setTimeout() 및 window.setInterval() 메서드가 몇 가지 있다는 것입니다. 이를 통해 시스템에서 특정 시간에 코드를 다시 호출할 수 있습니다.

자바스크립트 시계의 단점은 정밀하지 않다는 것입니다. 먼저 Date.now()는 밀리초 단위의 값(밀리초의 정수)을 반환하므로 원하는 최상의 정밀도는 1밀리초입니다. 이는 일부 음악적 맥락에서 그리 나쁘지는 않습니다. 음이 밀리초 단위로 일찍 또는 늦게 시작된 경우 알아차리지 못할 수도 있습니다. 오디오 하드웨어 속도가 44.1kHz라는 비교적 낮은 경우에도 오디오 스케줄링 클록으로 사용하기에는 약 44.1배 너무 느립니다. 샘플을 완전히 삭제하면 오디오 결함을 일으킬 수 있으므로 샘플을 함께 체이닝하는 경우 정밀하게 순차적이어야 할 수 있습니다.

새롭게 주목받고 있는 고해상도 시간 사양은 실제로 window.performance.now()를 통해 훨씬 더 정밀한 현재 시간을 제공합니다. 이 사양은 많은 최신 브라우저에서 (접두사로 지정되었지만) 구현되어 있습니다. 이는 상황에 따라 도움이 될 수 있지만 JavaScript 타이밍 API의 최악의 부분과는 실제로 관련이 없습니다.

JavaScript 타이밍 API의 최악의 부분은 Date.now()의 밀리초 정밀도가 유지하기에 너무 나쁘지 않지만 (window.setTimeout() 또는 window.setInterval을 통해) 레이아웃, 렌더링, 가비지 컬렉션, XMLHTTPRequest 및 XMLHTTPRequest 및 기타 콜백에 의해 자바스크립트에서 타이머 이벤트의 실제 콜백이 수십 밀리초 이상 쉽게 왜곡될 수 있다는 점입니다. 즉, 여러 가지 일에서 기본 실행 스레드에 의해 발생합니다. Web Audio API를 사용하여 예약할 수 있는 '오디오 이벤트'를 언급했던 것을 기억하시나요? 이러한 작업은 모두 별도의 스레드에서 처리됩니다. 따라서 복잡한 레이아웃이나 다른 긴 작업을 실행하면서 기본 스레드가 일시적으로 중단되더라도 오디오가 발생하라고 지시된 정확한 시간에 오디오가 계속 발생합니다. 실제로 디버거의 중단점에서 정지되어 있더라도 오디오 스레드는 예약된 이벤트를 계속 재생합니다.

오디오 앱에서 JavaScript setTimeout() 사용

기본 스레드는 한 번에 수 밀리초 동안 쉽게 중단될 수 있으므로 자바스크립트의 setTimeout을 사용하여 오디오 이벤트 재생을 직접 시작하는 것은 좋지 않습니다. 기껏해야 메모가 실제로 필요한 시간 밀리초 이내에 실행되고 최악의 경우 더 오래 지연되기 때문입니다. 무엇보다도 리드미컬한 시퀀스는 정확한 간격으로 실행되지 않습니다. 이는 기본 JavaScript 스레드에서 발생하는 다른 일들에 타이밍이 민감하기 때문입니다.

이를 보여주기 위해 '잘못된' 메트로놈 애플리케이션 샘플을 작성했습니다. 즉, setTimeout을 직접 사용하여 메모를 예약하고 많은 레이아웃도 실행합니다. 이 애플리케이션을 열고 '재생'을 클릭한 다음 재생되는 동안 창 크기를 빠르게 조정하세요. 타이밍이 눈에 띄게 불안정합니다 (리듬이 일정하게 유지되지 않음을 들을 수 있음). "하지만 이것은 억지로 하는 행동입니다."라고 말합니다. 물론입니다. 하지만 그렇다고 해서 현실에서도 불가능한 것은 아닙니다. 상대적으로 정적인 사용자 인터페이스가라도 레이아웃 재배치로 인해 setTimeout에 타이밍 문제가 발생합니다. 예를 들어 창의 크기를 빠르게 조정하면 다른 우수한 WebkitSynth의 타이밍이 눈에 띄게 버벅거림을 발견했습니다. 이제 오디오와 함께 전체 악보를 부드럽게 스크롤하려고 할 때 어떤 일이 일어날지 상상해 보면 실제로 복잡한 음악 앱에 어떤 영향을 미칠지 쉽게 상상할 수 있습니다.

가장 자주 묻는 질문 중 하나는 '왜 오디오 이벤트에서 콜백을 받을 수 없나요?'입니다. 이러한 유형의 콜백에 사용할 수 있지만 당면한 특정 문제는 해결되지 않을 것입니다. 이러한 이벤트는 기본 JavaScript 스레드에서 발생하므로 설정된 시간 제한과 동일한 모든 잠재적 지연이 발생할 수 있습니다. 즉, 정확한 시간 동안 정확한 시간(밀리초 단위)이 처리되기 전까지 알 수 없는 가변 시간 동안 지연될 수 있습니다.

그렇다면 우리는 어떻게 해야 할까요? 타이밍을 처리하는 가장 좋은 방법은 JavaScript 타이머 (setTimeout(), setInterval() 또는 requestAnimationFrame() 등에서 자세히 설명함)와 오디오 하드웨어 스케줄링 간의 협업을 설정하는 것입니다.

미래를 내다보고 정확한 타이밍 확보

다시 메트로놈 데모로 돌아가겠습니다. 사실 제가 이 간단한 메트로놈 데모의 첫 번째 버전을 올바르게 작성하여 이 협업 스케줄링 기법을 시연해 보죠. (GitHub에서도 이 코드를 사용할 수 있습니다. 이 데모는 16분음표, 8분음표 또는 4분음표마다 삐 소리가 나면서 오실레이터에 의해 생성된 삐 소리가 나면 비트에 따라 음이 바뀝니다. 또한 재생 중에 템포와 음 간격을 변경하거나 언제든지 재생을 중지할 수 있습니다. 이 기능은 실제 리듬 시퀀서의 핵심 기능입니다. 이 메트로놈에서 사용하는 소리를 즉석에서 변경하는 코드를 추가하는 것도 매우 쉽습니다.

완벽한 타이밍을 유지하면서도 온도를 제어할 수 있는 방법은 공동작업입니다. 가끔씩 한 번씩 실행되고 향후 개별 메모에 대해 웹 오디오 예약을 설정하는 setTimeout 타이머입니다. setTimeout 타이머는 기본적으로 현재 템포에 따라 음을 '곧' 예약해야 하는지 확인한 후 다음과 같이 예약합니다.

setTimeout() 및 오디오 이벤트 상호작용
기능을 사용할 수 있습니다.
setTimeout() 및 오디오 이벤트 상호작용.

실제로 setTimeout() 호출이 지연될 수 있으므로 스케줄링 호출의 타이밍은 시간이 지남에 따라 지터(및 setTimeout 사용 방법에 따라 편향)가 발생할 수 있습니다. 이 예의 이벤트는 약 50ms 간격으로 실행되지만 대개는 이보다 약간 더 많으며 때로는 훨씬 더 많습니다. 하지만 통화가 진행되는 동안 Google은 지금 재생해야 하는 모든 음 (예: 맨 처음 음)뿐만 아니라 현재부터 다음 구간까지 재생해야 하는 모든 음에 대한 웹 오디오 이벤트를 예약합니다.

실제로 setTimeout() 호출 사이의 간격만으로 앞을 보고 싶지는 않습니다. 최악의 경우 기본 스레드 동작, 즉 가비지 컬렉션, 레이아웃, 렌더링 또는 다음 타이머 호출을 지연시키는 기타 코드가 기본 스레드에서 발생하는 최악의 경우를 수용하려면 이 타이머 호출과 다음 호출 사이에 일정 예약 중복이 필요합니다. 오디오 블록 예약 시간, 즉 운영체제가 처리 버퍼에 유지하는 오디오의 양도 고려해야 합니다. 이는 운영체제와 하드웨어에 따라 다르며, 한 자릿수의 낮은 자릿수에서 약 50ms까지 다양합니다. 위에 표시된 각 setTimeout() 호출에는 이벤트 예약을 시도하는 전체 시간 범위를 보여주는 파란색 간격이 있습니다. 예를 들어 위 다이어그램에 예약된 네 번째 웹 오디오 이벤트는 다음 setTimeout 호출이 발생할 때까지 재생하기 위해 대기했다면 '늦게' 재생되었을 수 있습니다(setTimeout 호출이 몇 밀리초 후 불과한 경우). 실생활에서는 이러한 시간의 잡음이 그보다 더 극심할 수 있으며, 앱이 더욱 복잡해짐에 따라 이러한 중첩의 중요성이 더욱 커집니다.

전체 전방 지연 시간은 템포 컨트롤 (및 기타 실시간 컨트롤)의 강도에 영향을 미칩니다. 호출 예약 사이의 간격은 최소 지연 시간과 코드가 프로세서에 영향을 미치는 빈도 사이의 절충안입니다. 전방 탐색이 다음 간격의 시작 시간과 얼마나 겹치는지는 여러 시스템에서 앱의 복원력이 어느 정도가 되는지, 그리고 앱이 더 복잡해짐에 따라 결정되며 레이아웃과 가비지 컬렉션이 더 오래 걸릴 수 있습니다. 일반적으로 느린 머신과 운영체제에 대한 복원력을 갖추려면 전반적으로 큰 전향을 보이고 충분히 짧은 간격을 두는 것이 가장 좋습니다. 더 적은 콜백을 처리하기 위해 더 짧은 오버랩과 긴 간격을 갖도록 조정할 수 있지만, 언젠가는 긴 지연 시간으로 인해 템포 변경 등이 즉시 적용되지 않는다는 이야기를 들을 수 있습니다. 반대로 전방을 너무 줄이면 약간의 잡음이 들릴 수 있습니다 (예약 통화는 과거에 발생했어야 했던 이벤트를 '보완'해야 할 수 있음).

다음 타이밍 다이어그램은 메트로놈 데모 코드의 실제 기능을 보여줍니다. setTimeout 간격은 25ms이지만 복원력이 훨씬 더 겹치는 중첩이 훨씬 더 많습니다. 각 호출은 다음 100ms 동안 예약됩니다. 이처럼 긴 전제의 단점은 템포 변경 등이 적용되는 데 10분의 1초가 걸린다는 것입니다. 하지만 우리는 중단에 훨씬 더 탄력적으로 대처할 수 있습니다.

중복되는 부분이 긴 일정 예약
긴 중복 예약

실제로 이 예에서 중간에 setTimeout 중단이 발생했음을 알 수 있습니다. 약 270ms의 setTimeout 콜백이 있어야 하지만 어떤 이유로 지연되어 원래보다 약 320ms - 50ms 늦어졌습니다. 하지만 전방위적으로 보기 지연 시간이 길기 때문에 타이밍이 문제없이 계속되었고 우리는 그 직전의 템포를 240bpm (하드코어 드럼과 베이스 템포뿐 아니라)의 16분음표로 연주하도록 했음에도 한 비트도 놓치지 않았습니다.

또한 각 스케줄러 호출이 여러 메모를 예약하게 될 수도 있습니다. 더 긴 스케줄링 간격 (250ms 전방, 200ms 간격)을 사용하고 중간에서 템포를 높이면 어떻게 되는지 살펴보겠습니다.

setTimeout()은 긴 전방 탐색 및 긴 간격으로 사용할 수 있습니다.
긴 전방 및 긴 간격이 있는 setTimeout()

이 사례는 각 setTimeout() 호출이 여러 오디오 이벤트를 예약하게 될 수 있음을 보여줍니다. 실제로 이 메트로놈은 한 번에 하나의 노트를 하는 간단한 애플리케이션이지만 드럼 머신 (동시 음이 자주 있는 곳) 또는 시퀀서 (음 사이에 불규칙한 간격이 자주 있을 수 있음)에서 어떻게 작동하는지 쉽게 확인할 수 있습니다.

실제로는 스케줄링 간격과 전방 탐색(lookahead)을 조정하여 레이아웃, 가비지 컬렉션 및 기본 JavaScript 실행 스레드에서 발생하는 기타 사항들에 의해 얼마나 영향을 받는지 확인하고 템포에 대한 제어 세분성 등을 조정하는 것이 좋습니다. 예를 들어 자주 발생하는 매우 복잡한 레이아웃이 있는 경우 전방을 크게 만드는 것이 좋습니다. 요점은 '미리 스케줄링'하는 양이 지연을 피할 수 있을 정도로 충분히 크지만, 템포 컨트롤을 조정할 때 눈에 띄는 지연을 만들 정도로 크지는 않도록 해야 한다는 것입니다. 위의 사례라도 겹치는 부분이 매우 적기 때문에 웹 애플리케이션이 복잡한 느린 시스템에서는 탄력성이 떨어집니다. 시작하기에 좋은 시점은 아마도 100ms의 "전망" 시간이고 간격은 25ms로 설정하는 것입니다. 오디오 시스템 지연 시간이 많은 머신의 복잡한 애플리케이션에서는 여전히 문제가 될 수 있습니다. 이 경우 전방 탐색 시간을 늘려야 하며, 복원력이 다소 저하되어 보다 철저하게 제어해야 하는 경우에는 짧은 전방 탐색 시간을 사용해야 합니다.

스케줄링 프로세스의 핵심 코드는 scheduler() 함수에 있습니다.

while (nextNoteTime < audioContext.currentTime + scheduleAheadTime ) {
  scheduleNote( current16thNote, nextNoteTime );
  nextNote();
}

이 함수는 현재 오디오 하드웨어 시간을 가져오고 이를 시퀀스의 다음 음의 시간과 비교합니다. 이 정확한 시나리오에서는 대부분* 아무 일도 일어나지 않습니다 (예약 대기 중인 메트로놈 '노트'가 없지만 성공하면 웹 오디오 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;
  }
}

이는 매우 간단합니다. 하지만 이 스케줄링 예에서는 '시퀀스 시간', 즉 메트로놈이 시작된 이후의 시간을 추적하지 않고 있다는 점을 이해하는 것이 중요합니다. 마지막 음을 연주한 시점을 기억하고 다음 음이 언제 재생될 예정인지 알아내기만 하면 됩니다. 이렇게 하면 아주 쉽게 템포를 바꾸거나 연주를 중지할 수 있습니다.

이 스케줄링 기술은 웹의 다른 여러 오디오 애플리케이션에서 사용됩니다. 예를 들어 아주 재미있는 Acid Defender 게임Web Audio Drum MachineGranular Effects 데모와 같이 더 심층적인 오디오 예가 있습니다.

또 다른 타이밍 시스템

훌륭한 뮤지션이라면 누구나 알고 있듯이, 모든 오디오 애플리케이션에는 더 많은 타이머, 즉 더 많은 카우벨이 필요합니다. 시각적 디스플레이를 하는 올바른 방법은 THIRD 타이밍 시스템을 활용하는 것입니다.

세상에, 왜 또 다른 타이밍 시스템이 필요한 거야? 이 프레임은 requestAnimationFrame API를 통해 시각적 디스플레이, 즉 그래픽 새로고침 빈도에 동기화됩니다. 메트로놈 예제의 그리기 상자는 그리 큰 문제가 아닌 것처럼 보일 수 있지만 그래픽이 점점 복잡해짐에 따라 requestAnimationFrame()을 사용하여 시각적 새로고침 빈도에 동기화하는 것이 점점 더 중요해지며, 실제로 처음부터 setTimeout()을 사용하는 것만큼 쉽게 사용할 수 있습니다. 매우 복잡한 동기화된 그래픽(예: 고밀도 음표와 정확한 음표 표시)을 사용하면 고밀도 음표와 음표의 정확한 동기화는 음표와 음표 패키지를 정확하게 할 수 없습니다.

스케줄러에서 대기열에 있는 비트를 추적했습니다.

notesInQueue.push( { note: beatNumber, time: time } );

메트로놈의 현재 시간과의 상호작용은 draw() 메서드에서 찾을 수 있습니다. 이 메서드는 그래픽 시스템이 업데이트할 준비가 될 때마다 (requestAnimationFrame을 사용하여) 호출됩니다.

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()을 대신할 뿐이라는 점을 이해하는 것이 중요합니다. 여전히 실제 음에 대한 웹 오디오 타이밍의 예약 정확도를 원할 것입니다.

결론

이 튜토리얼이 시계, 타이머 및 웹 오디오 애플리케이션에 최적의 타이밍을 구축하는 방법을 설명하는 데 도움이 되었기를 바랍니다. 이와 동일한 기법을 쉽게 외삽하여 시퀀스 플레이어, 드럼 머신 등을 만들 수 있습니다. 다음에 또 뵙겠습니다.