2026-06-11·Sanchez

효과음 파일 0개 — Web Audio로 소리 합성하기

리빌드 내내 외부 에셋을 최소로 가져갔다.
배경음악(BGM)만 mp3를 쓰고, 효과음(SFX)은 파일을 한 개도 쓰지 않는다 — 수집·점프·피격·콤보 같은 모든 효과음을 Web Audio API로 그 자리에서 합성한다.

기본 도구는 단 둘이다.
오실레이터(파형을 내는 발진기)와 게인(음량).
여기에 시작 주파수, 길이, 파형 종류, 그리고 끝으로 갈수록 음량을 0에 수렴시키는 엔벨로프를 얹으면 짧은 효과음 하나가 된다.
이 한 함수를 돌려쓴다.

function blip(freq, dur, type, gain = 0.15, slide = 0) {
  const c = ac(); // 지연 생성한 AudioContext (브라우저/SSR 가드)
  const o = c.createOscillator();
  const g = c.createGain();
  o.type = type;
  o.frequency.setValueAtTime(freq, c.currentTime);
  if (slide !== 0) // 길이에 걸쳐 음 높이를 미끄러뜨림
    o.frequency.exponentialRampToValueAtTime(freq + slide, c.currentTime + dur);
  g.gain.setValueAtTime(gain, c.currentTime);
  g.gain.exponentialRampToValueAtTime(0.0001, c.currentTime + dur); // 엔벨로프
  o.connect(g).connect(c.destination);
  o.start();
  o.stop(c.currentTime + dur);
}

점프는 낮은 사인파, 피격은 거친 톱니파로 음색을 갈랐다.
제일 마음에 드는 디테일은 수집음이다.
콤보가 쌓일수록 음 높이가 올라간다.

export const playCollect = (combo: number) =>
  blip(520 + Math.min(combo, 24) * 40, 0.18, "triangle", 0.18, 120);

520 + min(combo,24)*40Hz.
한 개 주울 땐 520Hz, 콤보가 붙을수록 한 칸씩 높아져 상한에서 멈춘다.
연속으로 줍다 보면 음이 또르르 올라가는데, 이 상승감이 콤보 자체보다 더 짜릿했다.
같은 사운드를 반복 재생했다면 절대 안 나왔을 쾌감이다.

브라우저 환경 체크도 챙겼다. window나 AudioContext가 없으면 모든 함수가 조용히 no-op으로 빠진다.
SSR이나 미지원 환경에서 터지지 않게 하기 위해서다.

여기까지가 리빌드 개발기다.
결론은 단순하다 — 효과음 파일 없이도, 코드만으로 충분히 귀엽고 손맛 나는 소리를 만들 수 있었다.

여섯 번째 인자가 화음을 만든다

blip 하나로 낼 수 있는 건 단음이다.
그런데 게임에는 해금 팡파레도 있고 상승 아르페지오도 있다.
새 함수를 만들지 않고 인자 하나를 더 붙여서 해결했다 — delay다.

const t0 = c.currentTime + delay; // 모든 스케줄이 이 시각 기준

Web Audio는 오실레이터의 시작·정지 시각을 미리 예약할 수 있다.
그러니 blip을 여러 번 호출하면서 delay만 다르게 주면, 각 음이 알아서 순서대로 울린다. setTimeout 같은 게 필요 없다.
자바스크립트 타이머는 메인 스레드가 밀리면 같이 밀리지만, 오디오 스케줄은 오디오 스레드가 샘플 단위로 지킨다.
프레임이 떨어져도 팡파레는 안 흔들린다는 뜻이다.

해금 팡파레는 이렇게 생겼다.

export const playUnlock = () => {
  blip(523, 0.1, "triangle", 0.14);        // 도
  blip(659, 0.1, "triangle", 0.14, 0, 0.08); // 미, 0.08초 뒤
  blip(784, 0.1, "triangle", 0.14, 0, 0.16); // 솔
  blip(1047, 0.25, "triangle", 0.16, 0, 0.24); // 높은 도, 조금 길게
};

523·659·784·1047Hz는 도-미-솔-도, 그냥 C 메이저 아르페지오다.
마지막 음만 길이를 0.25초로 늘려서 끝을 맺었다.
악보를 코드로 적은 것에 가깝다.

소리 열네 개, 함수 하나

지금 게임에는 효과음이 열네 개 있는데 전부 이 blip의 조합이다.
파라미터만 다르다.

효과음주파수파형특징
점프220Hzsine짧고 낮게, 위로 미끄러짐
수집520Hz~triangle콤보에 따라 상승
니어미스880Hzsine0.08초, 아주 짧게
피격140Hzsawtooth아래로 미끄러짐
트램폴린180Hzsine+340Hz 상승 — "보잉"
부스터330Hzsawtooth+480Hz 급상승
보석880Hztriangle1320Hz 겹침 — 맑게
게임오버330Hztriangle아래로 −180Hz

규칙이 하나 보인다. 좋은 일은 올라가고 나쁜 일은 내려간다. 수집·트램폴린·부스터·보석은 전부 주파수가 위로 미끄러지고, 피격과 게임오버는 아래로 떨어진다.
파형도 갈렸다 — 부드러운 일에는 사인과 삼각파, 거친 일에는 톱니파와 사각파.

이건 음악 이론이 아니라 그냥 사람들이 이미 알고 있는 관습이다.
설명하지 않아도 "올라가는 소리 = 잘한 것"으로 읽힌다.
소리를 직접 합성하면 이런 규칙을 파일 이름이 아니라 숫자로 표현하게 되는데, 그게 의외로 설계에 도움이 됐다.
새 효과음을 만들 때 "이건 좋은 일인가 나쁜 일인가"만 정하면 slide 부호가 정해지니까.

exponentialRamp 는 0으로 못 간다

엔벨로프에 exponentialRampToValueAtTime(0.0001, ...)를 쓴 이유가 있다.
0이 아니라 0.0001이다.

지수 램프는 정의상 0에 도달할 수 없다 — 0을 넣으면 브라우저가 에러를 던진다.
그래서 들리지 않을 만큼 작은 값으로 수렴시킨다.
그렇다고 선형 램프(linearRampToValueAtTime)를 쓰면 음이 끝날 때 "딱" 하고 끊기는 느낌이 난다.
사람 귀는 음량을 로그로 듣기 때문에, 선형으로 줄이면 마지막 순간에 급격히 사라지는 것처럼 들린다.
지수 감쇠가 자연스러운 이유다.

slide에도 비슷한 안전장치가 있다.

o.frequency.exponentialRampToValueAtTime(Math.max(40, freq + slide), t0 + dur);

Math.max(40, ...)가 없으면 피격음처럼 slide가 음수인 경우 목표 주파수가 0 이하로 내려가 같은 이유로 터진다.
처음엔 이 가드가 없어서, 낮은 음에 큰 하강을 주면 소리가 나다 말고 콘솔에 에러가 찍혔다.

AudioContext 는 잠긴 채로 태어난다

브라우저는 사용자가 페이지를 건드리기 전에는 소리를 못 내게 막는다.
그래서 new AudioContext()로 만든 컨텍스트는 대개 suspended 상태로 시작한다.

if (ctx.state === "suspended") void ctx.resume();

ac()가 호출될 때마다 상태를 확인하고 깨우는 이유다.
첫 효과음이 재생되는 시점은 이미 사용자가 화면을 눌러 게임을 시작한 뒤라, 이 시점의 resume()은 정상적으로 통과한다.
컨텍스트를 모듈 로드 시점이 아니라 첫 소리를 낼 때 만드는 것도 같은 이유다 — 미리 만들어 두면 잠긴 채로 남아 있다가 첫 소리를 놓친다.

파일을 안 쓴 대가와 이득

이득은 분명했다.
효과음 열네 개의 용량이 0바이트다.
내려받을 게 없으니 첫 소리가 나기까지 지연도 없다.
그리고 무엇보다, 콤보에 따라 음이 올라가는 것 같은 동적인 소리는 파일로는 애초에 불가능하다.
24단계의 수집음을 따로 녹음해 둘 수는 없으니까.

대가도 있다.
발소리나 흙이 부스러지는 소리처럼 질감이 있는 소리는 오실레이터로 안 된다.
그건 노이즈와 필터의 영역인데, 거기까지 가면 그냥 파일을 쓰는 게 낫다.
그래서 이 게임의 효과음은 전부 "삐빅" 계열이다.
캐주얼한 톤에는 잘 맞았지만, 사실적인 사운드가 필요한 게임이었다면 이 선택은 틀렸을 것이다.

배경음악만 mp3를 쓰는 것도 같은 이유다.
12곡짜리 BGM을 합성으로 만들 수는 없다.

직접 플레이해보기 →