Web Audio API 로 순수 합성 BGM·SFX 만들기 — 샘플 파일 없이
외부 샘플 파일 없이 Web Audio API 만으로 게임 BGM 과 SFX 를 만드는 실전 노트. Oscillator·Envelope·Filter 조합부터 iOS Safari unlock 같은 함정까지.
시작 — 왜 외부 샘플을 안 쓰나?
처음 캔버스 게임에 사운드를 넣기로 했을 때, 자연스러운 선택지는 .mp3 나 .wav 파일을 가져다 쓰는 것이었습니다. 몇 가지 이유로 그 길을 가지 않았습니다.
- 번들 크기: 짧은 효과음 하나만 수백 KB. 10개쯤 쌓이면 금세 수 MB.
- 라이선스: CC0 무료 리소스도 출처 관리가 번거로움.
- 변주: 한 번 녹음된 샘플은 그 값만 나옴. 상황에 따라 미세하게 다른 느낌을 주기 어려움.
- 일관된 톤: 여러 리소스 사이트에서 모은 샘플은 음색·볼륨이 제각각.
그래서 전부 Web Audio API 로 합성 하기로 했습니다. 결과적으로 번들에 오디오 파일이 단 한 개도 없는 사이트가 되었습니다.
이 포스트는 그 과정에서 정리한 실전 패턴을 남깁니다.
Web Audio 기초 — 3개 노드면 됩니다
놀랍게도 복잡한 합성의 기반은 3가지 노드로 요약됩니다.
| 노드 | 역할 |
|---|---|
OscillatorNode | 주파수 기반 파형 생성 (sine·square·sawtooth·triangle) |
GainNode | 볼륨 조절 + 시간축 제어 (envelope) |
BiquadFilterNode | 주파수 필터 (lowpass·highpass·bandpass·peaking) |
연결 순서: Oscillator → Filter → Gain → destination.
간단한 "삐" 소리:
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.frequency.value = 440; // A4
osc.type = 'sine';
osc.connect(gain);
gain.connect(ctx.destination);
osc.start();
osc.stop(ctx.currentTime + 0.2); // 0.2초 후 정지
Envelope — 소리를 자연스럽게 깎기
그냥 start/stop 만 하면 "삐-탁" 하는 클릭 노이즈가 들립니다. GainNode 의 volume 을 시간축으로 조절해 ADSR(Attack-Decay-Sustain-Release) 을 구현해야 자연스러워집니다.
const now = ctx.currentTime;
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.3, now + 0.02); // Attack 20ms
gain.gain.linearRampToValueAtTime(0.15, now + 0.05); // Decay
gain.gain.setValueAtTime(0.15, now + 0.2); // Sustain
gain.gain.linearRampToValueAtTime(0, now + 0.3); // Release
이것만 적용해도 "삐" 가 "띵~" 으로 부드러워집니다.
⚠️ 함정: 연속
linearRamp호출 시 이전 ramp 끝점에서 값이 점프할 수 있습니다. 매번 시작 전에cancelScheduledValues(now); setValueAtTime(현재값, now)로 초기화하는 습관이 중요합니다.
실전 레이어링 — 엔진 럼블 만들기
로켓 엔진 소리 같은 두꺼운 럼블은 여러 레이어를 합성 해서 만듭니다. 이 프로젝트의 Rocket Workshop 엔진음은 4 레이어 구조입니다.
| 레이어 | 소스 | 필터 |
|---|---|---|
| Main roar | White noise | LPF 200Hz + peaking 50Hz |
| Exhaust crackle | White noise | BPF 450Hz |
| Sub-bass | Sine 20Hz | — |
| Pulse | Triangle 35Hz | — |
각 레이어를 개별 GainNode 로 믹싱한 뒤 마스터 bus 에 연결. 스로틀 값에 따라 각 레이어 gain 을 다르게 반응시키면 엔진 출력의 느낌이 생생해집니다.
BGM 과 SFX 의 분리
초기에 실수한 부분입니다. 처음엔 master gain 하나에 BGM·SFX 를 다 연결했는데, "뮤트 버튼은 BGM 만 끄고 싶다" 는 요구가 생기면서 분리가 필요해졌습니다.
// 2 bus 구조
let _bgmGain: GainNode | null = null;
let _sfxGain: GainNode | null = null;
function init(ctx: AudioContext) {
_bgmGain = ctx.createGain();
_sfxGain = ctx.createGain();
_bgmGain.connect(ctx.destination);
_sfxGain.connect(ctx.destination);
}
// 뮤트 버튼은 BGM 만 조정
setBGMVolume(muted ? 0 : 1);
// SFX 는 항상 재생
게임 feedback 성 SFX 는 뮤트 상태여도 "무음이면 UX 훼손" 이라는 판단이었습니다. 이게 맞다는 건 다른 게임들 레퍼런스에서도 드러나는 패턴이더군요.
Module Singleton 패턴
여러 컴포넌트에서 오디오 초기화/해제를 안전하게 하려면 module-level singleton 이 편합니다.
// audio/engineRumble.ts
let _ctx: AudioContext | null = null;
let _timer: ReturnType<typeof setTimeout> | null = null;
let _active = false;
export function startEngineRumble() {
if (_active) return;
_ctx = new AudioContext();
_active = true;
// ... 노드 구성
}
export function stopEngineRumble() {
if (!_active) return;
// ... 노드 정리
_ctx = null;
_active = false;
}
start/stop 페어를 export 하고, 내부 상태는 모듈 스코프에 가둡니다. Context 생성·파괴의 타이밍이 애매해지지 않습니다.
iOS Safari AudioContext Unlock
iOS Safari 는 사용자 제스처 콜백 바로 그 안에서 resume() 이 호출돼야만 오디오 재생을 허용합니다. onClick → setState → useEffect → start 같은 체인은 React batched flush 이후에 실행되기 때문에 제스처 컨텍스트가 끊깁니다.
해결 패턴: onClick 핸들러 시작부에 더미 context unlock 을 삽입합니다.
onClick={(e) => {
// iOS Safari: 더미 unlock — 사용자 제스처 카운터 활성화
try {
const AC = window.AudioContext || (window as any).webkitAudioContext;
if (AC) {
const u = new AC();
void u.resume().finally(() => u.close());
}
} catch {}
// 기존 시작 로직
setState('playing');
}}
더미 ctx 는 실제로 쓰지 않지만, 이 호출 자체가 iOS 의 "user-gesture 카운터" 를 활성화해서 이후 실제 AudioContext 도 resume 가능해집니다.
노드 정리 — stop + disconnect + null
osc.stop() 만 호출하면 그래프 참조가 살아있어 GC 가 안 됩니다. 장기간 쌓이면 메모리 누수.
function cleanup() {
// 1. 정지
_osc?.stop();
_gain?.gain.cancelScheduledValues(_ctx!.currentTime);
// 2. 연결 해제
_osc?.disconnect();
_gain?.disconnect();
// 3. 참조 제거
_osc = null;
_gain = null;
// 4. Context 도 함께
_ctx?.close();
_ctx = null;
}
4단계를 모두 거쳐야 안전합니다. stop 만 하면 절반만 된 것.
Cave Reverb 라우팅 — 의외의 볼륨 폭발
이 프로젝트에서 "동굴 같은 공간감" 을 만들 때 DelayNode 로 간단한 리버브를 구현했는데, 초기에 이런 실수를 했습니다.
// ❌ 실수 — raw oscillator 를 delay 에 직결
osc.connect(caveDelay);
증상: 볼륨이 폭발하고 osc.stop() 순간 "탁!" 하는 클릭 노이즈.
원인: Envelope 을 거치지 않은 raw oscillator 는 진폭 ~1.0 로 터져나가며, stop 시 파형이 즉시 끊어지면서 discontinuity 가 클릭으로 재생됩니다.
// ✅ 수정 — envelope 을 반드시 경유
osc.connect(env);
env.connect(caveDelay);
규칙: raw oscillator 를 Delay·Filter·Reverb 에 직결하지 말 것. 항상
Gain(envelope)을 중간에 두기.
정리 — 도입 시 체크리스트
Web Audio 합성을 처음 도입하신다면:
- Oscillator + Filter + Gain 3개 노드 구조 이해
- Envelope(ADSR) 없이
start/stop금지 — 클릭 노이즈 발생 -
linearRampToValueAtTime연속 호출 전cancelScheduledValues필수 - BGM / SFX 별도 bus
- Module singleton 으로 start/stop 페어 export
- iOS Safari 더미 unlock 트릭
- 노드 정리는 stop + disconnect + null 4단계
- Raw oscillator 를 필터·리버브 직결 금지
회고
Web Audio 합성은 처음 배울 때는 "왜 굳이 이렇게까지" 싶다가, 한 번 익히면 샘플로 돌아갈 수 없는 자유로움이 있습니다. 스로틀 값에 따라 실시간으로 엔진 톤이 달라지고, 상황마다 미세하게 다른 feedback SFX 를 줄 수 있고, 번들엔 바이트 하나 추가되지 않는 것.
음악 이론을 깊이 알 필요는 없었습니다. "주파수 X 를 올리면 밝아지고, 낮추면 묵직해진다" 수준의 감각이면 대부분의 게임 SFX 는 구현 가능합니다. 나머지는 원하는 느낌을 여러 오실레이터 조합으로 근사하는 과정입니다.
외부 샘플 관리가 번거롭거나 번들 크기가 신경 쓰인다면, 한 번쯤 합성의 세계에 발을 들여볼 만합니다.
방명록
이 글에 대한 한 줄을 남겨주세요
불러오는 중...