Ver4.0 웹 오디오와 Web Audio API로 구현하는 브라우저 기반 실시간 음향 처리 기술

웹 오디오와 Web Audio API로 구현하는 브라우저 기반 실시간 음향 처리 기술
브라우저 안에 들어온 작은 녹음 스튜디오 — 노드 그래프, 필터, 공간 음향, 그리고 AudioWorklet까지
🎧 오디오 그래프 🎚️ DSP 🌐 프론트엔드 ⚡ 실시간1. 왜 브라우저가 스튜디오가 되었을까
한때 웹에서 소리를 낸다는 건 <embed>에 미디 파일을 얹거나 플래시 플레이어를 띄우는 일이었습니다.
소리는 "재생하거나 멈추거나" 둘 중 하나였고, 개발자가 손댈 수 있는 여지는 거의 없었죠.
HTML5가 <audio> 태그를 들고 오면서 상황이 조금 나아졌지만, 그것도 결국 재생기였습니다.
볼륨을 줄이고, 위치를 옮기고, 끝나면 이벤트를 받는 정도.
소리를 "가공"하는 건 여전히 불가능했습니다.
2011년 즈음 등장해 지금은 W3C 표준(Web Audio API 1.0, 2021년 권고)으로 자리 잡은 Web Audio API는 이 판을 완전히 뒤집었습니다.
이제 브라우저는 단순 재생기가 아니라 모듈러 신시사이저이자 믹싱 콘솔입니다.
핵심 아이디어: 오디오는 "그래프"다
Web Audio API의 세계관은 명료합니다.
소스 노드에서 소리가 태어나고, 처리 노드들을 거치며 변형되고, 최종적으로 목적지 노드(스피커)로 흘러갑니다.
기타리스트가 기타 → 오버드라이브 → 딜레이 → 리버브 → 앰프 순으로 케이블을 연결하는 것과 정확히 같은 발상입니다.
차이가 있다면 케이블 대신 .connect() 메서드를 쓴다는 것뿐이죠.
2. 오디오 그래프 구조 들여다보기
AudioContext — 모든 것의 시작점
Web Audio의 모든 노드는 AudioContext 안에서만 존재합니다.
이 컨텍스트는 샘플레이트(보통 44,100Hz 또는 48,000Hz)와 고정밀 오디오 클럭을 관리합니다.
⚠️ 반드시 알아야 할 자동재생 정책
Chrome, Safari, Firefox 모두 사용자 제스처(클릭·터치) 없이는 AudioContext를 자동으로 실행하지 않습니다.
생성 직후 상태는 suspended이며, 사용자 인터랙션 안에서 await ctx.resume()을 호출해야 running이 됩니다.
"왜 소리가 안 나지?"의 체감상 8할은 이 문제입니다.
const ctx = new (window.AudioContext || window.webkitAudioContext)();
playBtn.addEventListener('click', async () => {
if (ctx.state === 'suspended') await ctx.resume();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 220; // A3
gain.gain.value = 0.0001;
osc.connect(gain).connect(ctx.destination);
const now = ctx.currentTime;
// 클릭 노이즈 방지를 위한 부드러운 엔벨로프
gain.gain.exponentialRampToValueAtTime(0.3, now + 0.02);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 1.2);
osc.start(now);
osc.stop(now + 1.3);
});
💡 프로 팁 — 게인을 0으로 두지 마세요
exponentialRampToValueAtTime()은 목표값이 0이면 예외를 던집니다.
지수 곡선은 수학적으로 0에 도달할 수 없기 때문이죠.
관행적으로 0.0001(약 -80dB, 사실상 무음)을 사용합니다.
3. AudioParam — 시간 위에 소리를 그리는 붓
Web Audio API의 진짜 강력함은 AudioParam에 있습니다.
게인, 주파수, Q값 같은 파라미터는 단순한 숫자가 아니라 시간축 위에 스케줄링되는 자동화 곡선입니다.
| 메서드 | 동작 | 주 용도 |
|---|---|---|
setValueAtTime(v, t) | 지정 시각에 즉시 값 변경 | 엔벨로프 시작점 고정 |
linearRampToValueAtTime(v, t) | 직선 보간 | 패닝, 크로스페이드 |
exponentialRampToValueAtTime(v, t) | 지수 보간 | 음량·피치 (사람 귀는 로그 반응) |
setTargetAtTime(v, t, τ) | 시상수 τ로 점근 접근 | 아날로그풍 릴리즈 |
setValueCurveAtTime(arr, t, d) | 배열 그대로 재생 | 커스텀 LFO·페이드 곡선 |
cancelScheduledValues(t) | 예약 취소 | 급작스런 노트 오프 |
ADSR 엔벨로프 구현
신시사이저의 기본기, Attack-Decay-Sustain-Release를 AudioParam으로 그려봅시다.
function applyADSR(param, now, {a=0.01, d=0.15, s=0.6, r=0.4}, peak=1) {
param.cancelScheduledValues(now);
param.setValueAtTime(0.0001, now);
param.exponentialRampToValueAtTime(peak, now + a); // Attack
param.exponentialRampToValueAtTime(peak * s, now + a + d); // Decay → Sustain
return (releaseTime) => { // Release
param.cancelScheduledValues(releaseTime);
param.setValueAtTime(param.value, releaseTime);
param.exponentialRampToValueAtTime(0.0001, releaseTime + r);
};
}
4. 필터와 이펙트 — DSP를 노드 하나로
BiquadFilterNode
2차 IIR 필터를 구현한 노드로, type 속성 하나로 8가지 성격이 바뀝니다.
| type | 특성 | 실전 활용 |
|---|---|---|
| lowpass | 고역 차단 | DJ 필터, 벽 너머 소리 |
| highpass | 저역 차단 | 보컬 럼블 제거, 전화기 톤 |
| bandpass | 특정 대역만 통과 | 라디오·워키토키 효과 |
| lowshelf / highshelf | 한쪽 전체 부스트·컷 | 베이스/트레블 톤 컨트롤 |
| peaking | 특정 주파수 산·골 | 파라메트릭 EQ |
| notch | 좁은 대역 제거 | 60Hz 험 노이즈 제거 |
| allpass | 위상만 변경 | 페이저 이펙트 |
// 3밴드 EQ 체인
const low = ctx.createBiquadFilter();
const mid = ctx.createBiquadFilter();
const high = ctx.createBiquadFilter();
low.type = 'lowshelf'; low.frequency.value = 250; low.gain.value = +4;
mid.type = 'peaking'; mid.frequency.value = 1200; mid.Q.value = 1.2; mid.gain.value = -3;
high.type = 'highshelf'; high.frequency.value = 6000; high.gain.value = +2;
source.connect(low).connect(mid).connect(high).connect(ctx.destination);
🔍 Q 값의 의미
Q(Quality Factor)는 필터의 날카로움입니다.
lowpass에서 Q를 높이면 차단 주파수 부근에 레조넌스 피크가 생기며, 이것이 아날로그 신스 특유의 "쥐이잉" 하는 맛을 만듭니다.
Q가 20을 넘으면 자체 발진에 가까워지므로 리미터를 함께 두세요.
ConvolverNode — 공간을 복사하다
컨볼루션 리버브는 실제 공간에서 측정한 임펄스 응답(IR, Impulse Response)을 원본 신호와 컨볼루션 연산해 그 공간의 잔향을 재현합니다.
성당, 콘서트홀, 지하 주차장에서 풍선을 터뜨려 녹음한 파일 하나면
내 브라우저 소리가 그 공간 안에 놓입니다.
const conv = ctx.createConvolver();
const irBuf = await fetch('/ir/cathedral.wav')
.then(r => r.arrayBuffer())
.then(b => ctx.decodeAudioData(b));
conv.buffer = irBuf;
// Dry/Wet 병렬 믹싱
const dry = ctx.createGain(), wet = ctx.createGain();
dry.gain.value = 0.7; wet.gain.value = 0.3;
source.connect(dry).connect(ctx.destination);
source.connect(conv).connect(wet).connect(ctx.destination);
DynamicsCompressorNode & WaveShaperNode
DynamicsCompressorNode는 threshold·knee·ratio·attack·release를 갖춘 정통 컴프레서입니다.
마스터 버스 끝에 걸어두면 갑작스러운 피크로 인한 클리핑을 방지할 수 있습니다.
WaveShaperNode는 입력 진폭을 커브 배열에 매핑하는 비선형 왜곡기입니다.
디스토션, 새츄레이션, 비트크러시 흉내까지 모두 이 한 노드로 해결됩니다.
function makeDistortionCurve(amount = 50) {
const n = 44100, curve = new Float32Array(n);
const deg = Math.PI / 180;
for (let i = 0; i < n; i++) {
const x = (i * 2) / n - 1;
curve[i] = ((3 + amount) * x * 20 * deg) / (Math.PI + amount * Math.abs(x));
}
return curve;
}
const shaper = ctx.createWaveShaper();
shaper.curve = makeDistortionCurve(80);
shaper.oversample = '4x'; // 에일리어싱 완화
5. AnalyserNode — 소리를 눈으로 보다
AnalyserNode는 신호를 변형하지 않고 통과시키면서 FFT 분석 데이터를 뽑아줍니다.
시각화의 핵심 도구죠.
| 속성 / 메서드 | 설명 |
|---|---|
fftSize | 32~32768(2의 거듭제곱). 클수록 주파수 해상도↑, 시간 응답↓ |
frequencyBinCount | fftSize / 2. 실제 얻는 주파수 빈 개수 |
smoothingTimeConstant | 0~1. 프레임 간 평활화(기본 0.8) |
getByteFrequencyData() | 0~255 스펙트럼 (스펙트럼 바) |
getByteTimeDomainData() | 0~255 파형 (오실로스코프) |
getFloatFrequencyData() | dB 단위 정밀 스펙트럼 |
const analyser = ctx.createAnalyser();
analyser.fftSize = 2048;
analyser.smoothingTimeConstant = 0.85;
source.connect(analyser); // destination 연결 없이도 분석 가능
const data = new Uint8Array(analyser.frequencyBinCount);
const c2d = canvas.getContext('2d');
(function draw() {
requestAnimationFrame(draw);
analyser.getByteFrequencyData(data);
c2d.clearRect(0, 0, canvas.width, canvas.height);
const bw = canvas.width / data.length * 2.2;
for (let i = 0; i < data.length; i++) {
const h = (data[i] / 255) * canvas.height;
c2d.fillStyle = `hsl(${200 + i * 0.25}, 80%, 60%)`;
c2d.fillRect(i * bw, canvas.height - h, bw - 1, h);
}
})();
6. AudioWorklet — 내 손으로 DSP 짜기
과거의 ScriptProcessorNode는 메인 스레드에서 동작했습니다.
UI 렌더링이 버벅이면 오디오도 같이 끊겼죠. 그래서 공식적으로 deprecated 되었습니다.
대체재인 AudioWorklet은 별도의 실시간 오디오 렌더링 스레드에서 돌아갑니다.
128 샘플 단위(렌더 쿼텀)로 콜백이 호출되며, 48kHz 기준 약 2.67ms 주기입니다.
// noise-gate-processor.js
class NoiseGateProcessor extends AudioWorkletProcessor {
static get parameterDescriptors() {
return [{ name: 'threshold', defaultValue: 0.02, minValue: 0, maxValue: 1 }];
}
process(inputs, outputs, params) {
const input = inputs[0], output = outputs[0];
const th = params.threshold;
if (!input.length) return true;
for (let ch = 0; ch < input.length; ch++) {
const inC = input[ch], outC = output[ch];
for (let i = 0; i < inC.length; i++) {
const t = th.length > 1 ? th[i] : th[0];
outC[i] = Math.abs(inC[i]) < t ? 0 : inC[i];
}
}
return true; // false 반환 시 노드 종료
}
}
registerProcessor('noise-gate', NoiseGateProcessor);
// 메인 스크립트
await ctx.audioWorklet.addModule('/js/noise-gate-processor.js');
const gate = new AudioWorkletNode(ctx, 'noise-gate');
gate.parameters.get('threshold').value = 0.03;
mic.connect(gate).connect(ctx.destination);
// 스레드 간 통신은 port로
gate.port.postMessage({ cmd: 'reset' });
⛔ AudioWorklet 안에서 하면 안 되는 것
오디오 렌더링 스레드는 절대 블로킹되면 안 됩니다.
process() 내부에서 메모리 할당(new Array, 객체 리터럴), 가비지 생성, 동기 I/O를 피하세요.
버퍼는 생성자에서 미리 할당해두고 재사용하는 것이 정석입니다.
또한 addModule()은 보안 컨텍스트(HTTPS 또는 localhost)에서만 동작합니다.
WebAssembly와의 조합
고성능 DSP(피치 시프트, FFT 기반 보코더, 실시간 노이즈 제거)가 필요하다면
C/C++ 또는 Rust로 작성한 알고리즘을 WASM으로 컴파일해 AudioWorklet 안에서 호출하는 패턴이 표준으로 자리 잡았습니다.
JS 엔진의 JIT 워밍업 편차 없이 일정한 처리 시간을 보장할 수 있다는 것이 가장 큰 이점입니다.
7. 공간 음향 — 3D 사운드스케이프
PannerNode와 AudioListener는 3차원 공간에 음원과 청자를 배치합니다.
WebGL·Three.js 기반 3D 씬과 결합하면 몰입형 경험이 완성되죠.
| 설정 | 값 | 효과 |
|---|---|---|
panningModel | 'HRTF' | 머리전달함수 기반 정밀 입체 음향(헤드폰 권장) |
panningModel | 'equalpower' | 가벼운 좌우 패닝(CPU 저비용) |
distanceModel | inverse / linear / exponential | 거리에 따른 감쇠 곡선 |
coneInnerAngle 등 | 각도 | 지향성 음원(스피커가 향한 방향) |
const panner = new PannerNode(ctx, {
panningModel: 'HRTF',
distanceModel: 'inverse',
refDistance: 1, maxDistance: 10000, rolloffFactor: 1,
positionX: 3, positionY: 0, positionZ: -5
});
// 청자 이동 (모던 문법: AudioParam이므로 스케줄링 가능)
const L = ctx.listener;
L.positionX.linearRampToValueAtTime(camera.x, ctx.currentTime + 0.05);
L.positionZ.linearRampToValueAtTime(camera.z, ctx.currentTime + 0.05);
좌우 패닝만 필요하다면 StereoPannerNode가 훨씬 가볍습니다. pan 값 -1(좌) ~ +1(우) 하나로 끝.
8. 정밀 타이밍 — setTimeout으로는 절대 안 되는 이유
메트로놈이나 시퀀서를 setInterval로 만들면 반드시 흔들립니다.
JS 타이머는 메인 스레드 상황에 따라 수십 ms씩 밀리기 때문이죠.
해답은 Chris Wilson의 룩어헤드 스케줄링(Lookahead Scheduling) 패턴입니다.
JS 타이머는 "깨우는 역할"만 하고, 실제 노트 시각은 ctx.currentTime 기반으로 미리 예약합니다.
let nextNoteTime = 0;
const lookahead = 25; // ms, 타이머 주기
const scheduleAhead = 0.1; // sec, 미리 예약할 범위
const tempo = 120;
function scheduleNote(time) {
const o = ctx.createOscillator(), g = ctx.createGain();
o.frequency.value = 1000;
g.gain.setValueAtTime(0.0001, time);
g.gain.exponentialRampToValueAtTime(0.5, time + 0.002);
g.gain.exponentialRampToValueAtTime(0.0001, time + 0.05);
o.connect(g).connect(ctx.destination);
o.start(time); o.stop(time + 0.06);
}
function scheduler() {
while (nextNoteTime < ctx.currentTime + scheduleAhead) {
scheduleNote(nextNoteTime);
nextNoteTime += 60.0 / tempo;
}
}
setInterval(scheduler, lookahead);
🎯 정확도 차이
setInterval 직접 발음: 지터 ±15~50ms (귀로 확실히 들림)
룩어헤드 + 오디오 클럭 예약: 지터 샘플 단위(0.02ms 수준)
9. 마이크 입력과 실시간 처리
getUserMedia로 얻은 스트림을 MediaStreamAudioSourceNode로 그래프에 꽂으면 실시간 입력 처리가 가능합니다.
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: false, // 음악 분석 시엔 끄는 게 유리
sampleRate: 48000
}
});
const mic = ctx.createMediaStreamSource(stream);
const hp = ctx.createBiquadFilter();
hp.type = 'highpass'; hp.frequency.value = 80; // 럼블 제거
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = -24; comp.ratio.value = 4;
mic.connect(hp).connect(comp).connect(analyser);
🔁 피드백 하울링 주의
마이크를 ctx.destination에 그대로 연결하면 스피커 → 마이크 루프로 하울링이 발생합니다.
모니터링이 필요하면 헤드폰 착용을 전제하거나, 분석 전용으로만 연결하세요.
녹음: MediaStreamAudioDestinationNode
처리된 결과물을 파일로 저장하려면 그래프 출력을 다시 MediaStream으로 되돌려 MediaRecorder에 넘깁니다.
const dest = ctx.createMediaStreamDestination();
masterBus.connect(dest);
const rec = new MediaRecorder(dest.stream, { mimeType: 'audio/webm;codecs=opus' });
const chunks = [];
rec.ondataavailable = e => chunks.push(e.data);
rec.onstop = () => {
const url = URL.createObjectURL(new Blob(chunks, { type: 'audio/webm' }));
downloadLink.href = url;
};
rec.start();
OfflineAudioContext — 실시간보다 빠르게
파형 썸네일 생성, 배치 이펙트 적용, 라우드니스 측정처럼 실시간이 필요 없는 작업은 OfflineAudioContext로 처리합니다.
CPU가 허용하는 한 실시간보다 수십 배 빠르게 렌더링됩니다.
const off = new OfflineAudioContext(2, 44100 * 30, 44100);
const src = off.createBufferSource();
src.buffer = decodedBuffer;
src.connect(off.destination);
src.start();
const rendered = await off.startRendering(); // AudioBuffer 반환
10. 성능 최적화 체크리스트
1) AudioContext는 하나만
브라우저별 컨텍스트 개수 제한이 있습니다. 앱 전역에서 싱글톤으로 관리하세요.
2) 노드는 재사용하지 말고 버려라
OscillatorNode와 AudioBufferSourceNode는 일회용입니다. stop() 후 재시작 불가.
대신 onended에서 disconnect()를 호출해 GC 대상이 되도록 정리하세요.
3) Convolver와 HRTF Panner는 비쌉니다
동시에 수십 개를 띄우면 CPU가 비명을 지릅니다. 공용 리버브 버스 하나에 Send 방식으로 보내세요.
4) decodeAudioData 결과는 캐싱
디코딩은 비용이 큽니다. 같은 사운드는 AudioBuffer를 Map에 저장해 재사용합니다.
5) 긴 트랙은 MediaElementAudioSourceNode
수 분짜리 음악 전체를 AudioBuffer로 올리면 메모리 폭탄입니다.
<audio> 요소를 소스로 삼아 스트리밍하면서 이펙트만 얹으세요.
6) 탭 비활성 시 suspend
document.visibilitychange에서 ctx.suspend()를 호출하면 배터리와 CPU를 아낄 수 있습니다.
브라우저 호환성 현황
| 기능 | Chrome | Firefox | Safari |
|---|---|---|---|
| Web Audio 기본 노드 | 지원 | 지원 | 지원(webkit 접두사 이력) |
| AudioWorklet | 66+ | 76+ | 14.1+ |
| 생성자 문법(new GainNode) | 지원 | 지원 | 지원 |
| Listener AudioParam 방식 | 지원 | 지원 | 일부 setPosition 폴백 필요 |
iOS Safari는 무음 스위치, 백그라운드 진입 시 컨텍스트 자동 suspend 등 별도 예외가 많아
실기기 테스트가 필수입니다.
11. 실전 활용 시나리오
🎮 웹 게임 사운드 엔진
동일 효과음을 수십 개 동시 재생할 땐 AudioBuffer 하나를 공유하고 BufferSource만 즉석 생성합니다.
거리 기반 감쇠는 PannerNode, 폭발음 저역 컷은 BiquadFilter, 마스터 클리핑 방지는 Compressor.
🎓 온라인 음악 교육
마이크 입력에 자기상관(autocorrelation) 알고리즘을 적용하면 실시간 피치 감지가 가능합니다.
학습자가 부른 음정을 화면에 그려주는 튜너·보컬 트레이너가 브라우저만으로 완성됩니다.
실제로 재능넷 같은 재능 공유 플랫폼에서는 음악 레슨·믹싱 강의 콘텐츠에 이런 인터랙티브 데모를 얹어
수강생 몰입도를 크게 끌어올리는 사례가 늘고 있습니다.
📊 데이터 소니피케이션
시각 장애 사용자를 위한 접근성 차트, 서버 모니터링 알림음처럼
숫자를 주파수·음색으로 매핑하는 소니피케이션(Sonification)도 Web Audio의 영역입니다.
🎛️ 브라우저 DAW
Tone.js, WaveSurfer.js, Soundtrap 같은 프로젝트들이 이미 증명했듯
멀티트랙 편집·시퀀싱·이펙트 체인을 전부 웹에서 구현할 수 있습니다.
설치 없이 URL 하나로 공유되는 협업 워크플로가 가장 큰 무기죠.
12. 마무리 — 소리를 다루는 프론트엔드 개발자
Web Audio API를 배운다는 것은 단순히 API 문서 하나를 외우는 일이 아닙니다.
샘플레이트, 주파수, 진폭, 위상, 컨볼루션 같은 신호 처리의 기본기를 함께 익히는 과정입니다.
처음엔 오실레이터로 "삐—" 소리 하나 내는 것도 벅찹니다.
하지만 그래프 개념이 손에 붙는 순간, 여러분은 브라우저 탭 하나를 신시사이저로 바꾸는 사람이 됩니다.
🚀 추천 학습 경로
1단계 — Oscillator + Gain으로 단음 신스 만들기
2단계 — ADSR 엔벨로프와 필터 추가, 키보드 매핑
3단계 — AnalyserNode로 스펙트럼 시각화
4단계 — 룩어헤드 스케줄러로 드럼머신 제작
5단계 — AudioWorklet으로 커스텀 이펙트 구현
6단계 — Tone.js 같은 라이브러리 내부 구조 분석
웹은 이제 문서를 보여주는 곳을 넘어 감각을 전달하는 플랫폼이 되었습니다.
그 감각의 절반은 눈이 아니라 귀에 있습니다.
다음 프로젝트에서 new AudioContext() 한 줄을 적어보세요.
그 순간부터 여러분의 브라우저는 소리를 가진 게 아니라 소리를 만드는 도구가 됩니다. 🎹
관련 키워드
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.

댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.