sungyup's.

Getting Started with Web Audio API

Web Audio API 입문: AudioContext와 Audio Graph의 이해

Feb 11, 2025

원본 글 보기
Web

들어가며

회사에서 음악 관련 웹 애플리케이션을 기획/제작할 수 있는 기회를 받았다. 음악 관련 산업에 종사하고 싶었던 마음에 개발자의 세계로 뛰어든 프론트엔드 개발자로써, 이번 기회에 최선을 다해 관련 지식을 공부하고 기록으로 남겨야겠다는 생각이 들었다.

이번 포스팅 시리즈는 그간 잘 모르고 썼던 <audio> element 및 Web Audio API에 대해 자세히 알아보고자 좋은 글들을 찾아 정리하고, 그 과정에서 잘 이해가 안되는 부분은 보완해 포스팅을 남기고자 한다.

Web Audio API의 등장 배경

HTML5의 <audio> element 이전에는 웹에서 소리가 나게 하려면 Flash나 기타 plugin이 필요했다. <audio> element의 등장으로 plugin은 더 이상 필요하지 않지만, 복잡한 컨트롤 또는 element와 상호작용하는 애플리케이션을 만들 때는 여전히 제한사항이 있다.

이러한 제한 사항을 극복하고 <audio>를 웹 애플리케이션에서 프로세싱하고 합성하기 위해 등장한 고수준 JavaScript API가 Web Audio API이다.

AudioContext

AudioContext는 Web Audio API에서 제공하는, 사운드를 관리/재생하기 위한 JavaScript Object이다. Web Audio API로 사운드를 생성하려면, Sound Source를 하나 또는 그 이상 만든 후 AudioContext 인스턴스에서 제공하는 Sound Destination으로 연결해야 한다.

AudioContext
사운드 소스를 AudioContext안의 Sound Destination으로 연결해서 사운드를 관리/재생한다.

이 연결은 직접적일 필요는 없으며, 다양한 AudioNode를 거쳐도 문제 없다. AudioNode는 오디오 신호를 프로세싱하는 모듈이다.

한 개의 AudioContext 인스턴스는 여러 사운드 인풋을 처리할 수 있을 뿐 아니라, 각각의 오디오들이 어떤 노드를 거쳐(소리를 변형하기 위해) 어떻게 destination으로 갈지도 처리할 수 있다(이를 Audio Graph라고 한다. 위의 이미지처럼 사운드 소스가 어떻게 Destination으로 가는지를 표현하는 도식이라고 생각하면 된다). 그렇기에 오디오 애플리케이션을 만들때 AudioContext는 한 개만 만들면 된다.

사운드 로드

Web Audio API는 WAV, MP3, AAC, OGG 등의 포맷을 기본적으로 지원하지만, 브라우저마다 또 약간씩 다르다.

Web Audio API는 짧은 ~ 중간 길이의 사운드는 AudioBuffer를 써서 사운드를 로드한다. 이 방식은 XMLHttpRequest로 사운드 파일을 fetch하는 것이다. 실제 오디오 파일 데이터는 binary이고 text가 아니기 때문에, responseType은 arraybuffer로 설정해야 한다.

디코딩이 되지 않은 오디오 파일을 받아온 후, 해당 오디오 파일은 이후에 디코딩 할 수도 있고 AudioContext의 decodeAudioData() 메소드로 바로 디코드할 수도 있다. 이 메소드는 request.response로 저장된 오디오 파일 데이터의 ArrayBuffer를 받아 asynchronous하게 디코드한다. 즉, JavaScript의 메인 스레드를 막지 않는다.

decodeAudioData()가 끝났으면, WEb Audio API는 디코드된 PCM(pulse code modulation) 오디오 데이터를 AudioBuffer로 제공하는 콜백 함수를 호출한다.

사운드 재생

AudioBuffer가 로드되었으면, 재생할 준비가 된 것이다.

javascript
const context = new AudioContext(); function playSound(buffer) { const source = context.createBufferSource(); // sound source 생성 source.buffer = buffer; // 소스에 audio buffer 연결 source.connect(context.destination); // 소스에 audio context의 destination(스피커) 연결 source.noteOn(0); // 바로 소스 재생시키기 }

noteOn(time) 함수는 사운드를 정확한 타이밍에 스케줄링 할 수 있게 해준다. 물론, sound buffer가 모두 로드된 상태여야만 한다.

이렇게 만들어진 playSound() 함수는 언제든 호출할 수 있다.

박자 처리하기 : 리듬이 있는 사운드 재생하기

Web Audio API는 사운드 재생을 정확하게 스케줄링 할 수 있게 해준다.

예를 들면, 아래는 기본 락 비트이다.

javascript
for (const bar = 0; bar < 2; bar++) { const time = startTime + bar * 8 * eighthNoteTime; // Play the bass (kick) drum on beats 1, 5 playSound(kick, time); playSound(kick, time + 4 * eighthNoteTime); // Play the snare drum on beats 3, 7 playSound(snare, time + 2 * eighthNoteTime); playSound(snare, time + 6 * eighthNoteTime); // Play the hi-hat every eighth note. for (const i = 0; i < 8; ++i) { playSound(hihat, time + i * eighthNoteTime); } }

사운드의 볼륨 조절

AudioGraph with Gain Node
Gain Node를 거치는 오디오 그래프

Web Audio API로 볼륨을 조절하려면, AudioGainNode라는 노드를 소스에서 거쳐 destination으로 보내면 된다.

javascript
// gainNode 생성 const gainNode = context.createGainNode(); // 소스를 gainNode에 연결 source.connect(gainNode); // gainNode를 destination에 연결 gainNode.connect(context.destination);

이렇게 연결하고 나면 gainNode.gain.value를 조정함으로 볼륨 조절을 할 수 있다.

javascript
// 볼륨 줄이기 gainNode.gain.value = 0.5;

두 사운드 간 크로스-페이딩

DJ들이 하는 것처럼, 두 소리를 자연스럽게 이어지게 하고 싶을 수 있다.

AudioGraph with Two Sources
두 개의 소스가 있는 오디오 그래프

이렇게 하기 위해서는 두 개의 AudioGainNode를 만들고, 각각의 소스를 노드에 연결한다.

javascript
// 두 개의 볼륨 노드 생성 function createSource(buffer) { const source = context.createBufferSource(); // Create a gain node. const gainNode = context.createGainNode(); source.buffer = buffer; // Turn on looping. source.loop = true; // Connect source to gain. source.connect(gainNode); // Connect gain to destination. gainNode.connect(context.destination); return { source: source, gainNode: gainNode, }; }

동일 파워 크로스페이딩

선형적으로 크로스페이딩을 하는 것보다는 동일 파워 크로스페이딩이 더 자연스럽다.

Equal Power Crossfade
동일 파워 크로스페이딩

플레이리스트 크로스페이딩

음악 재생기 애플리케이션에서는 DJ 케이스와 달리, 한 오디오가 다 페이드아웃 되고나서 새로운 오디오가 페이드인 되어야 한다. 이런 크로스페이딩을 하려면, 크로스페이딩을 스케줄링 해야한다. 이런 종류의 작업엔 setTimeout을 쓸 것 같지만, setTimeout은 정확하지 않다. Web Audio API로는 AudioParam 인터페이스를 사용해 미래 파라미터 값들을 스케줄링한다.

javascript
function playHelper(bufferNow, bufferLater) { const playNow = createSource(bufferNow); const source = playNow.source; const gainNode = playNow.gainNode; const duration = bufferNow.duration; const currTime = context.currentTime; // Fade the playNow track in. gainNode.gain.linearRampToValueAtTime(0, currTime); gainNode.gain.linearRampToValueAtTime(1, currTime + ctx.FADE_TIME); // Play the playNow track. source.noteOn(0); // At the end of the track, fade it out. gainNode.gain.linearRampToValueAtTime(1, currTime + duration - ctx.FADE_TIME); gainNode.gain.linearRampToValueAtTime(0, currTime + duration); // Schedule a recursive track change with the tracks swapped. const recurse = arguments.callee; ctx.timer = setTimeout(function () { recurse(bufferLater, bufferNow); }, duration - ctx.FADE_TIME - 1000); }

Web Audio API는 파라미터 값을 부드럽게 바꾸기 위해 linearRampToValueAtTimeexponentialRampToValueAtTime 메소드를 제공한다. 커스터마이징도 할 수 있는데, setValueCurveAtTime 함수로 파라미터 값들의 array를 제공하면 된다.

사운드에 필터 더하기

AudioGraph with a Filter
필터가 있는 오디오 그래프

사운드 소스에서 destination으로 이어지는 파이프라인 사이에는 다양한 노드가 들어가 소리를 변형할 수 있는데, 그 중 BiquadFilterNode가 있다.

제공되는 필터는 아래와 같다:

  • Low pass filter
  • High pass filter
  • Band pass filter
  • Low shelf filter
  • High shelf filter
  • Peaking filter
  • Notch filter
  • All pass filter

모든 필터들은 얼마나 필터를 적용할지(gain)를 지정하는 파라미터, 필터를 적용할 주파수대를 지정하는 파라미터 및 성질 계수(quality factor)를 지정할 수 있다.

예를 들면, 아래는 Low-pass filter이다.

javascript
// 필터 생성 const filter = context.createBiquadFilter(); // Create the audio graph. source.connect(filter); filter.connect(context.destination); // Create and specify parameters for the low-pass filter. filter.type = 0; // Low-pass filter. See BiquadFilterNode docs filter.frequency.value = 440; // Set cutoff to 440 HZ // Playback the sound. source.noteOn(0);

필터는 코드로 제거가 가능해, AudioContext 그래프를 다이나믹하게 변경할 수 있다. node.disconnect(outputNumber)가 필터를 제거해주는 메소드이다.

javascript
// Disconnect the source and filter. source.disconnect(0); filter.disconnect(0); // Connect the source directly. source.connect(context.destination);