지난 글에서 Tone.js를 다루면서, 내부적으로 Web Audio API를 쓴다는 사실은 알고 있었지만 실제로 어떻게 동작하는지는 깊이 들여다보지 않았습니다. Tone.js가 추상화해주는 것들을 한 꺼풀 벗겨보고 싶어서 Web Audio API를 직접 파봤습니다.
HTML5 Audio의 한계
브라우저에서 소리를 다루는 가장 간단한 방법은 <audio> 요소입니다. 재생, 정지, 일시정지, 탐색까지는 문제없습니다. 하지만 이것으로 할 수 없는 것들이 있습니다.
- 소리의 주파수나 파형 데이터에 접근할 수 없습니다
- 실시간으로 이펙트(리버브, 필터)를 적용할 수 없습니다
- 3D 공간 오디오를 구현할 수 없습니다
- 정밀한 타이밍 제어가 불가능합니다
<audio>와 Web Audio API의 관계는 <img>와 <canvas>의 관계와 비슷합니다. 단순 재생에는 <audio>로 충분하지만, 소리를 분석하거나 가공해야 한다면 Web Audio API가 필요합니다.
AudioContext: 모든 것의 시작점
Web Audio API의 진입점은 AudioContext입니다. 모든 오디오 노드는 하나의 AudioContext 안에서 생성되고 연결됩니다.
const audioCtx = new AudioContext();핵심 속성
| 속성 | 설명 |
|---|---|
sampleRate | 오디오 샘플레이트 (보통 44100Hz 또는 48000Hz) |
currentTime | 컨텍스트 생성 시점부터 단조 증가하는 시간 (초). 일시정지 불가 |
state | "suspended", "running", "closed" 중 하나 |
destination | 최종 출력 (스피커/헤드폰) |
AudioContext는 하나만 생성하여 재사용하는 것이 좋습니다. 브라우저는 동시 활성 AudioContext 수를 제한하고 있습니다.
Autoplay 정책
AudioContext는 기본적으로 "suspended" 상태로 시작됩니다. 사용자 제스처(클릭 등) 없이는 소리를 낼 수 없습니다.
const audioCtx = new AudioContext(); // state: "suspended"
document.getElementById("play")?.addEventListener("click", () => {
if (audioCtx.state === "suspended") {
audioCtx.resume();
}
// 이제 소리를 낼 수 있습니다
});OfflineAudioContext
실시간이 아닌 사전 렌더링이 필요할 때 사용합니다. 스피커 대신 AudioBuffer로 출력합니다. 예를 들어 리버브가 적용된 오디오를 파일로 내보내는 경우에 유용합니다.
// 44.1kHz 스테레오 10초짜리 버퍼 렌더링
const offlineCtx = new OfflineAudioContext(2, 44100 * 10, 44100);
// 노드 구성 후...
const renderedBuffer = await offlineCtx.startRendering();오디오 그래프: 노드를 연결하는 구조
Web Audio API는 모듈러 라우팅 시스템입니다. AudioNode들이 방향성 비순환 그래프(DAG)를 형성합니다. 기본 흐름은 이렇습니다.
Source → Processing → Destination
const audioCtx = new AudioContext();
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.start();중요한 점은 오디오 처리가 메인 스레드와 분리된 별도의 오디오 렌더링 스레드에서 실행된다는 것입니다. DOM 조작이 오디오를 방해하지 않고, 오디오 처리가 UI를 블록하지도 않습니다.
AudioNode 종류
Source 노드 (소리 생성)
OscillatorNode — 주기적 파형을 생성합니다. 신시사이저의 기본 빌딩 블록입니다.
const osc = audioCtx.createOscillator();
osc.type = "sawtooth"; // sine, square, sawtooth, triangle
osc.frequency.value = 440; // A4 음
osc.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + 1); // 1초 후 정지한 가지 주의할 점이 있었습니다. stop() 이후에는 같은 노드를 다시 start할 수 없습니다. 새 노드를 생성해야 합니다. "fire and forget" 방식입니다. 노드 생성 비용이 매우 저렴하기 때문에 이 패턴이 가능합니다.
AudioBufferSourceNode — 메모리에 로드된 오디오 파일을 재생합니다.
const response = await fetch("/samples/drum.wav");
const arrayBuffer = await response.arrayBuffer();
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
const source = audioCtx.createBufferSource();
source.buffer = audioBuffer;
source.loop = true;
source.playbackRate.value = 1.5; // 1.5배속
source.connect(audioCtx.destination);
source.start();AudioBuffer 자체는 재사용할 수 있지만, AudioBufferSourceNode는 OscillatorNode와 마찬가지로 한 번 사용 후 폐기해야 합니다.
MediaElementAudioSourceNode — HTML <audio> 요소를 Web Audio 그래프에 연결합니다. 기존 <audio> 태그에 이펙트를 걸 수 있습니다.
const audioEl = document.querySelector("audio") as HTMLAudioElement;
const source = audioCtx.createMediaElementSource(audioEl);
source.connect(audioCtx.destination);Processing 노드 (소리 가공)
| 노드 | 역할 | 핵심 파라미터 |
|---|---|---|
| GainNode | 볼륨 제어 | gain (기본 1.0) |
| BiquadFilterNode | 필터 (lowpass, highpass 등 8종) | frequency, Q, type |
| ConvolverNode | 합성곱 리버브 | buffer (임펄스 응답) |
| DelayNode | 시간 지연 | delayTime |
| DynamicsCompressorNode | 다이나믹 압축 | threshold, ratio, attack |
| WaveShaperNode | 비선형 왜곡 (디스토션) | curve, oversample |
| StereoPannerNode | 좌우 패닝 | pan (-1.0 ~ 1.0) |
| PannerNode | 3D 공간 오디오 | positionX/Y/Z, panningModel |
| AnalyserNode | 주파수/파형 분석 (시각화용) | fftSize, smoothingTimeConstant |
이펙트 체인을 구성하는 예시입니다.
const source = audioCtx.createBufferSource();
const filter = audioCtx.createBiquadFilter();
const gain = audioCtx.createGain();
const compressor = audioCtx.createDynamicsCompressor();
filter.type = "lowpass";
filter.frequency.value = 1000;
gain.gain.value = 0.8;
source.connect(filter);
filter.connect(gain);
gain.connect(compressor);
compressor.connect(audioCtx.destination);
source.buffer = audioBuffer;
source.start();Tone.js의 .chain() 메서드가 이 .connect() 체인을 추상화한 것이었습니다.
AudioParam: 시간 기반 자동화
AudioParam은 Web Audio API에서 가장 강력한 기능 중 하나입니다. 오디오 파라미터를 샘플 정확도로 시간에 따라 변화시킬 수 있습니다.
왜 setTimeout보다 정밀한가
setTimeout은 메인 스레드의 이벤트 루프에서 실행되므로 ~100ms 오차가 흔합니다. AudioParam 자동화는 오디오 렌더링 스레드에서 실행되어 서브밀리초 정확도를 보장합니다.
자동화 메서드
const gain = audioCtx.createGain();
// 특정 시간에 즉시 값 변경
gain.gain.setValueAtTime(0, audioCtx.currentTime);
// 선형으로 서서히 변화 (페이드 인)
gain.gain.linearRampToValueAtTime(1, audioCtx.currentTime + 2);
// 지수적으로 변화 (더 자연스러운 감쇠)
gain.gain.exponentialRampToValueAtTime(0.01, audioCtx.currentTime + 4);
// 주의: 목표값이 0이면 안 됩니다. 0에 가까운 값(0.01)을 사용합니다.| 메서드 | 설명 |
|---|---|
setValueAtTime(value, time) | 특정 시간에 즉시 값 변경 |
linearRampToValueAtTime(value, endTime) | 선형 보간 |
exponentialRampToValueAtTime(value, endTime) | 지수 보간 (목표값 ≠ 0) |
setTargetAtTime(target, startTime, timeConstant) | 지수적 감쇠로 목표값 접근 |
setValueCurveAtTime(values, startTime, duration) | 커스텀 커브를 따라 변화 |
cancelScheduledValues(cancelTime) | 지정 시간 이후 예약 취소 |
중요한 점이 있었습니다. 예약된 이벤트가 있을 때 value를 직접 할당하면 기존 자동화와 충돌하거나 기대와 다른 결과가 나올 수 있습니다. 자동화 메서드와 직접 할당을 섞기보다, 한 방식으로 일관되게 제어하는 편이 안전합니다.
실전 패턴: ADSR 엔벨로프
신시사이저의 기본인 Attack-Decay-Sustain-Release 엔벨로프를 AudioParam으로 직접 구현할 수 있습니다.
function triggerNote(
osc: OscillatorNode,
gain: GainNode,
now: number
) {
// Attack: 0에서 1로 빠르게 올림
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(1, now + 0.02);
// Decay + Sustain: 1에서 0.3으로 감쇠
gain.gain.linearRampToValueAtTime(0.3, now + 0.1);
// Release: 0.3에서 0으로
gain.gain.linearRampToValueAtTime(0, now + 0.5);
osc.start(now);
osc.stop(now + 0.5);
}Tone.js의 Synth가 내부적으로 이 패턴을 자동화해주고 있었습니다.
오디오 로딩과 메모리
fetch + decodeAudioData가 표준 패턴입니다.
async function loadAudio(url: string): Promise<AudioBuffer> {
const response = await fetch(url);
const arrayBuffer = await response.arrayBuffer();
return audioCtx.decodeAudioData(arrayBuffer);
}
const kickBuffer = await loadAudio("/samples/kick.wav");디코딩된 오디오는 비압축 32비트 PCM으로 RAM에 저장됩니다. 크기를 가늠하면 이렇습니다.
44.1kHz 스테레오 1분 = 44100 × 2채널 × 4바이트 × 60초 ≈ 약 10MB
원본 MP3가 1MB여도 디코딩하면 10MB가 될 수 있습니다. 대용량 파일을 여러 개 로드하면 메모리가 빠르게 차오르므로, 디코딩된 AudioBuffer를 캐싱하여 반복 디코딩을 피하는 것이 중요합니다.
AnalyserNode로 시각화
AnalyserNode는 오디오를 변경하지 않고 통과시키면서 주파수/파형 데이터를 추출합니다.
주파수 바 차트
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
const bufferLength = analyser.frequencyBinCount; // fftSize / 2 = 128
const dataArray = new Uint8Array(bufferLength);
source.connect(analyser);
analyser.connect(audioCtx.destination);
function draw(ctx: CanvasRenderingContext2D, width: number, height: number) {
requestAnimationFrame(() => draw(ctx, width, height));
analyser.getByteFrequencyData(dataArray); // 0~255 범위
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, width, height);
const barWidth = width / bufferLength;
for (let i = 0; i < bufferLength; i++) {
const barHeight = (dataArray[i] / 255) * height;
ctx.fillStyle = `hsl(${(i / bufferLength) * 360}, 80%, 50%)`;
ctx.fillRect(i * barWidth, height - barHeight, barWidth - 1, barHeight);
}
}파형 (오실로스코프)
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 2048;
const bufferLength = analyser.frequencyBinCount;
const dataArray = new Uint8Array(bufferLength);
function drawWaveform(ctx: CanvasRenderingContext2D, width: number, height: number) {
requestAnimationFrame(() => drawWaveform(ctx, width, height));
analyser.getByteTimeDomainData(dataArray); // 0~255 범위 (128 = 무음)
ctx.fillStyle = "#000";
ctx.fillRect(0, 0, width, height);
ctx.lineWidth = 2;
ctx.strokeStyle = "#0f0";
ctx.beginPath();
const sliceWidth = width / bufferLength;
for (let i = 0; i < bufferLength; i++) {
const v = dataArray[i] / 128.0;
const y = (v * height) / 2;
if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * sliceWidth, y);
}
ctx.stroke();
}fftSize에 따라 해상도가 달라집니다. 주파수 바에는 256 정도의 작은 값이, 파형에는 2048 이상이 적합했습니다.
AudioWorklet: 커스텀 오디오 처리
이전에는 ScriptProcessorNode로 커스텀 오디오 처리를 했지만, 이것은 메인 스레드에서 실행되어 UI 차단과 오디오 글리치를 유발했습니다. AudioWorklet이 이를 대체합니다.
AudioWorklet은 커스텀 JS 코드를 오디오 렌더링 스레드에서 직접 실행합니다. 메인 스레드 왕복으로 인한 지연과 글리치 위험을 줄여주지만, 오디오 버퍼링과 렌더 퀀텀 자체가 사라지는 것은 아닙니다.
구현 방법
워크릿 프로세서 파일을 별도로 작성합니다.
// white-noise-processor.ts
class WhiteNoiseProcessor extends AudioWorkletProcessor {
process(
_inputs: Float32Array[][],
outputs: Float32Array[][],
_parameters: Record<string, Float32Array>
): boolean {
const output = outputs[0];
for (const channel of output) {
for (let i = 0; i < channel.length; i++) {
channel[i] = Math.random() * 2 - 1;
}
}
return true; // true = 계속 실행
}
}
registerProcessor("white-noise-processor", WhiteNoiseProcessor);메인 스레드에서 워크릿을 등록하고 사용합니다.
await audioCtx.audioWorklet.addModule("/white-noise-processor.js");
const noiseNode = new AudioWorkletNode(audioCtx, "white-noise-processor");
noiseNode.connect(audioCtx.destination);파라미터 정의
class GainProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [
{
name: "customGain",
defaultValue: 1.0,
minValue: 0,
maxValue: 2,
automationRate: "a-rate", // 샘플 단위 정밀도
},
];
}
process(
inputs: Float32Array[][],
outputs: Float32Array[][],
parameters: Record<string, Float32Array>
): boolean {
const input = inputs[0];
const output = outputs[0];
const gainValues = parameters.customGain;
for (let ch = 0; ch < output.length; ch++) {
for (let i = 0; i < output[ch].length; i++) {
// a-rate: gainValues.length === 128 (매 샘플마다 다른 값)
// k-rate: gainValues.length === 1 (블록 전체에 동일 값)
const gain = gainValues.length > 1 ? gainValues[i] : gainValues[0];
output[ch][i] = input[ch][i] * gain;
}
}
return true;
}
}
registerProcessor("gain-processor", GainProcessor);메인 스레드에서 파라미터를 자동화할 수 있습니다.
const gainParam = gainNode.parameters.get("customGain");
gainParam?.linearRampToValueAtTime(0, audioCtx.currentTime + 2);워크릿 내에서는 가비지 컬렉션이 오디오 글리치를 유발할 수 있으므로, 객체 생성을 최소화하고 버퍼를 재사용하는 것이 중요합니다.
공간 오디오
StereoPannerNode (단순 좌우 패닝)
const panner = audioCtx.createStereoPanner();
panner.pan.value = -1; // -1 = 왼쪽, 0 = 중앙, 1 = 오른쪽
source.connect(panner);
panner.connect(audioCtx.destination);
// 2초에 걸쳐 왼쪽에서 오른쪽으로 이동
panner.pan.linearRampToValueAtTime(1, audioCtx.currentTime + 2);PannerNode (3D 공간 오디오)
게임이나 VR/AR 경험에서 사용합니다.
const panner = audioCtx.createPanner();
panner.panningModel = "HRTF"; // 더 사실적 (CPU 높음). "equalpower"는 가벼움
panner.distanceModel = "inverse";
panner.refDistance = 1;
panner.maxDistance = 100;
panner.rolloffFactor = 1;
// 소리 원의 3D 위치 설정
panner.positionX.value = 5;
panner.positionY.value = 0;
panner.positionZ.value = -3;
// 청취자 위치 (AudioContext당 하나)
const listener = audioCtx.listener;
listener.positionX.value = 0;
listener.positionY.value = 0;
listener.positionZ.value = 0;
source.connect(panner);
panner.connect(audioCtx.destination);HRTF 모델은 인간의 두 귀가 소리를 다르게 인식하는 것을 시뮬레이션하여 헤드폰에서 사실적인 3D 효과를 줍니다. 다만 CPU 소모가 크므로 다수의 음원에 적용할 때는 주의가 필요합니다.
성능과 주의점
1) Source 노드는 일회용입니다
OscillatorNode와 AudioBufferSourceNode는 stop() 이후 재사용할 수 없습니다. 반복 재생이 필요하면 매번 새 노드를 생성해야 합니다. 노드 생성 비용은 매우 저렴하므로 성능 문제는 없습니다.
2) CPU 집약적인 노드를 파악해야 합니다
ConvolverNode(합성곱 리버브)와 HRTF 모드의 PannerNode가 가장 CPU를 많이 소모합니다. 모바일 환경에서는 이 노드들의 사용을 제한하는 것이 좋습니다.
3) AudioBuffer 메모리를 관리해야 합니다
디코딩된 오디오는 비압축 PCM으로 RAM에 올라갑니다. 1분짜리 스테레오 파일 하나가 ~10MB입니다. 사용하지 않는 버퍼는 참조를 해제하여 GC 대상이 되도록 해야 합니다.
4) AudioWorklet은 HTTPS에서만 동작합니다
보안 컨텍스트(Secure Context) 요구사항이 있으므로, 로컬 개발 시 localhost를 사용하거나 HTTPS를 설정해야 합니다.
Raw Web Audio API vs Tone.js
| 영역 | Raw Web Audio API | Tone.js |
|---|---|---|
| 타이밍 | 초 단위 (currentTime) | 음악적 표기 ("4n", "8n") |
| 전송 제어 | 없음 | Transport (시작/정지/루프) |
| 악기 | OscillatorNode + 수동 엔벨로프 | Synth, FMSynth 등 + ADSR 내장 |
| 이펙트 | 개별 노드 수동 연결 | 프리빌트 이펙트 + .chain() |
| 폴리포니 | 수동 보이스 관리 | PolySynth 자동 할당 |
| Autoplay | 수동 resume() | Tone.start() |
Raw API가 적합한 경우:
- 단순한 오디오 처리 (알림음, 효과음)
- 번들 사이즈가 중요할 때
- 프레임워크 없이 최대 제어가 필요할 때
- 학습 목적
Tone.js가 적합한 경우:
- 음악적 애플리케이션 (시퀀서, 드럼머신)
- 복잡한 스케줄링과 Transport 제어
- 다양한 악기와 이펙트 체인
Tone.js는 내부적으로 네이티브 Web Audio 노드를 사용하므로, 프레임워크 자체의 성능 오버헤드는 크지 않습니다.
결론
Web Audio API를 직접 다뤄보면서, Tone.js가 얼마나 많은 것을 추상화해주는지 체감했습니다. 동시에 Raw API를 이해하고 나니 Tone.js의 동작을 예측하기가 훨씬 수월해졌습니다.
핵심 포인트를 정리하면 이렇습니다.
- AudioParam 자동화가 Web Audio API의 진짜 강점입니다.
setTimeout으로는 불가능한 샘플 정확도의 시간 제어를 제공합니다 - Source 노드는 일회용이라는 점을 기억해야 합니다. 한 번 stop하면 새로 만들어야 합니다
- AudioWorklet이 ScriptProcessorNode를 대체했고, 커스텀 DSP를 오디오 스레드에서 직접 실행합니다
- 디코딩된 AudioBuffer의 메모리 크기(MP3 대비 10~20배)를 항상 의식해야 합니다
단순 효과음이나 알림음 정도라면 Raw API만으로 충분하고, 음악적 기능이 필요하다면 Tone.js가 개발 시간을 크게 줄여줍니다.
읽어주셔서 감사합니다.
참고 자료
- MDN - Web Audio API
- MDN - Basic concepts behind Web Audio API
- MDN - Web Audio API best practices
- MDN - AudioParam
- MDN - Using AudioWorklet
- MDN - AnalyserNode
- MDN - Visualizations with Web Audio API
- MDN - PannerNode
- W3C - Web Audio API 1.0 Recommendation
- Chrome - Enter Audio Worklet
- Chrome - Autoplay Policy