소리 파일 없이 Web Audio 로 엔진·바람·타이어 소리 만들기
2026년 11월 1일 · 그림·소리
이 사이트의 효과음은 처음부터 소리 파일이 없었다. 점수, 부딪힘, 끝 같은 짧은 소리는 모두 Web Audio API 로 그 자리에서 만든다. 오실레이터(정해진 파형을 내는 소리 발생기) 하나를 켜고, 크기를 빠르게 올렸다 내리고, 0.2초쯤 뒤에 끈다. 음 높이와 파형만 바꿔 소리 열세 가지를 만들었다.
고속도로 드라이버는 그것으로는 모자랐다. 레이싱 게임에는 달리는 내내 나는 소리가 필요하다. 엔진은 속도에 따라 높아져야 하고, 기어를 바꾸면 회전이 떨어져야 하고, 빨라질수록 바람 소리가 커져야 하고, 급하게 꺾으면 타이어가 끽 소리를 내야 한다. 녹음한 엔진 소리를 재생 속도만 바꿔 틀 수도 있지만, 짧은 소리처럼 합성으로 만들기로 했다.
Web Audio 에서 쓴 것들
Web Audio API 는 소리를 만드는 노드를 선으로 이어 그래프를 만드는 방식이다. 소리를 내는 노드에서 출발해 소리를 바꾸는 노드를 거쳐 스피커에 닿는다. 이 글에서 쓰는 것은 몇 개 되지 않는다.
AudioContext: 모든 노드를 만들고 시간을 재는 곳이다. 페이지에 하나 두고 같이 쓴다.currentTime이 소리 시계(초)이고,destination이 스피커다. 처음에는 멈춘(suspended) 상태일 수 있어 입력이 있을 때resume()으로 깨운다.OscillatorNode: 정해진 파형을 계속 내는 소리 발생기다.type으로 사인파(sine), 네모파(square), 톱니파(sawtooth), 세모파(triangle)를 고르고,frequency로 높이(Hz)를 정한다.start()하면stop()할 때까지 계속 난다. 사인파는 맑고, 톱니파와 네모파는 배음이 많아 거칠고 밝다.AudioBuffer와AudioBufferSourceNode: 소리 샘플을 담은 배열과, 그것을 재생하는 노드다. 소리 파일을 읽어 채울 수도 있지만, 여기서는 -1~1 의 무작위 값을 직접 채워 흰 소음을 만든다.loop를 켜면 끝없이 되풀이한다.BiquadFilterNode: 특정 주파수를 깎거나 남기는 필터다.type이lowpass면frequency보다 낮은 소리만,bandpass면frequency근처만 통과시킨다.Q는 날카로움이다. 높으면 경계 근처를 강조하거나 통과 대역을 좁히고, 낮으면 넓게 통과시킨다.GainNode: 소리 크기를 곱하는 노드다.gain이 0 이면 무음, 1 이면 그대로다. 소리를 섞을 때 각각의 비율을 정하고, 전체를 끄고 켜는 데도 쓴다.AudioParam:frequency,gain,Q처럼 시간에 따라 바꿀 수 있는 값이다..value로 바로 바꿀 수도 있지만,setTargetAtTime(목표, 시작 시각, 시간 상수)를 쓰면 목표 값을 향해 지수 곡선으로 부드럽게 다가간다. 그리고 다른 노드의 출력을AudioParam에 연결할 수 있다. 느린 오실레이터를GainNode의gain에 이으면 크기가 그 빠르기로 흔들린다. 이것을 LFO(저주파 오실레이터)라고 부른다.
노드는 connect() 로 잇는다. connect() 는 이어진 노드를 돌려주어서 한 줄로 쓸 수 있다.
const ctx = new AudioContext();
const osc = ctx.createOscillator();
const filter = ctx.createBiquadFilter();
const amp = ctx.createGain();
osc.type = 'sawtooth';
osc.frequency.value = 110;
filter.type = 'lowpass';
filter.frequency.value = 800;
amp.gain.value = 0.1;
osc.connect(filter).connect(amp).connect(ctx.destination);
osc.start();
// wobble the volume 8 times a second with an LFO
const lfo = ctx.createOscillator(), depth = ctx.createGain();
lfo.frequency.value = 8;
depth.gain.value = 0.05;
lfo.connect(depth).connect(amp.gain);
lfo.start();
이 정도면 톱니파를 필터로 깎은 '부우웅' 소리가 크기가 흔들리며 계속 난다. 아래의 엔진·바람·타이어 소리는 모두 이 조합에서 재료와 숫자만 다르다.
계속 나는 소리를 위한 공통 기능
게임 코드는 브라우저의 소리 기능을 직접 쓰지 않고 사이트가 주는 api 로만 쓴다. 소리를 끄거나 일시정지하거나 다른 탭으로 가면 사이트가 대신 조용히 하기 위해서다. 짧은 소리는 금방 끝나니 문제가 없었는데, 계속 나는 소리는 다르다. 일시정지 중에는 게임 루프가 멈춰 소리 값을 바꾸지 않으니, 사이트가 꺼 주지 않으면 엔진 소리가 멈춤 화면에서도 계속 난다.
그래서 공통 기능 api.hum 을 새로 만들었다. 소리 하나의 구성은 이렇다.
- 소리 재료: 오실레이터 여러 개와, 필요하면 흰 소음(무작위 값을 채운 2초짜리 버퍼를 반복 재생)
- 필터 하나(
BiquadFilterNode): 낮은 소리만 통과(lowpass)하거나 한 대역만 통과(bandpass) - 떨림: 느린 오실레이터(LFO)가 크기를 흔든다
- 출력 크기: 사이트가 소리 끄기·일시정지·다른 탭에서 0 으로 내리는 곳
게임은 처음에 재료를 정해 소리를 만들고, 매 프레임 높이·필터 주파수·크기·떨림을 넘긴다.
const engine = api.hum({
voices: [
{ type: 'sawtooth' },
{ type: 'sawtooth', mult: 1.007, gain: 0.8 },
{ type: 'square', mult: 0.5, gain: 0.5 },
],
filter: 'lowpass', q: 4,
});
// every frame
engine.set({ freq, cutoff, gain, wob, wobFreq });
값을 바로 바꾸지 않고 setTargetAtTime 으로 목표를 향해 짧은 시간 동안 따라가게 한다. 프레임마다 값이 계단처럼 뛰면 딸깍거리는 잡음이 나기 때문이다. 사이트 쪽에서는 소리마다 마지막 크기를 기억해 두었다가, 소리를 켜거나 일시정지를 풀면 그 크기로 돌려놓는다. 다시 하기와 나가기에서는 노드를 모두 멈춘다.
set({ freq, cutoff, gain }, smooth = 0.03) {
const t = audio.currentTime;
if (freq != null) for (const v of voices) v.osc.frequency.setTargetAtTime(freq * v.mult, t, smooth);
if (cutoff != null) filter.frequency.setTargetAtTime(cutoff, t, smooth);
if (gain != null) { volume = gain; gate(); }
},
gate() { // called on mute, pause and tab switch too
const open = soundOn && !paused && !document.hidden;
out.gain.setTargetAtTime(open ? volume * 0.08 : 0, audio.currentTime, 0.04);
},
엔진: 톱니파 둘과 네모파 하나
엔진 소리의 재료는 오실레이터 셋이다. 톱니파 하나, 그보다 0.7% 높은 톱니파 하나, 반 옥타브 아래(0.5배)의 네모파 하나다. 거의 같은 높이의 두 소리를 겹치면 둘이 조금씩 어긋나며 맥놀이가 생겨 소리가 두꺼워진다. 네모파는 아래를 받쳐 준다. 톱니파와 네모파는 배음이 많아 그대로는 거칠어서, lowpass 필터로 높은 소리를 깎는다. 필터의 Q 를 4 로 높여 잘리는 주파수 근처를 살짝 강조했다.
엔진의 상태는 회전 rpm 하나로 나타낸다. 0 에서 1 사이 값이고, 이 값에 따라 세 가지가 함께 움직인다.
- 기본 높이: 70Hz 에서 280Hz 까지
- 필터 주파수: 500Hz 에서 2600Hz 까지. 회전이 오르면 소리가 밝아진다
- 크기: 0.54 에서 0.9 까지
떨림은 크기를 0.15 만큼, 기본 높이의 절반 빠르기로 흔든다. 배기가 덜컹거리는 느낌이 여기서 난다.
기어를 올리면 회전이 떨어진다
속도를 그대로 높이로 바꾸면 사이렌처럼 계속 올라가기만 한다. 엔진 소리답게 하려면 기어가 있어야 한다. 기어 경계를 속도 비율로 정해 두고, 한 기어 안에서는 회전이 0.5 에서 1 까지 오르게 했다. 다음 기어로 넘어가면 회전이 다시 0.5 로 떨어진다. 1단만 공회전 0.18 에서 시작한다.
// gear boundaries as speed / 260 km/h (top speed rises to 420, so 6th and 7th exist)
const GEARS = [0, 0.16, 0.33, 0.52, 0.74, 1.0, 1.3, 1.62];
function rpmOf(v) {
let g = 0;
while (g < GEARS.length - 2 && v >= GEARS[g + 1]) g++;
const lo = g === 0 ? 0.18 : 0.5; // idle only in first gear
return Math.min(1.05, lo + ((1 - lo) * (v - GEARS[g])) / (GEARS[g + 1] - GEARS[g]));
}
이 게임은 체크포인트를 지날 때마다 최고 속도가 260km/h 에서 20km/h 씩 올라 420km/h 까지 간다. 그래서 260km/h 를 넘는 6단과 7단도 있다. 가속하면 소리가 오르다 떨어지기를 되풀이하며 '웅–웅–' 하고 올라간다. 높이 값을 기록해 보니 280Hz 쯤까지 오르다 183Hz 쯤으로 떨어지기를 기어마다 되풀이했다.
바람과 타이어는 흰 소음으로
바람은 오실레이터 없이 흰 소음만 쓴다. 흰 소음을 bandpass 필터에 통과시키면 한 대역만 남아 '쉬익' 하는 소리가 된다. Q 를 0.7 로 낮게 두어 대역을 넓게 잡았다. 속도가 오르면 통과 대역이 높아지고(500Hz + km/h × 3), 크기는 속도의 제곱으로 커진다(0.6 × (km/h ÷ 420)²). 느릴 때는 거의 안 들리다가 빨라질수록 확 커진다.
타이어 끽 소리는 흰 소음에 1300~1500Hz 의 세모파를 섞어, 2300~2800Hz 를 통과시키는 bandpass 에 넣는다. 높이와 필터 주파수를 매 프레임 그 범위 안에서 무작위로 조금씩 흔들고, 크기를 초당 17번 크게 떨게 했다. 이 떨림이 타이어가 미끄러지며 노면을 긁는 느낌을 낸다. 소리가 나는 조건은 바퀴 연기와 같다. 도로 위에서 빠르게 달리며 브레이크를 밟거나, 빠르게 0.2초 넘게 꺾을 때다.
처음 값으로 들어 보니 끽 소리가 엔진에 묻혀 거의 들리지 않았다. 처음에는 필터 Q 가 6 으로 통과하는 대역이 좁았고, 크기도 작았다. 브레이크를 밟을 때 실제 출력 크기를 기록해 보니 엔진이 0.045, 끽 소리가 0.028 이었다. Q 를 3 으로 낮춰 대역을 넓히고 크기를 4배쯤 키웠다. 그리고 끽 소리가 나는 동안에는 엔진을 0.55배로 낮췄다. 같은 상황에서 엔진 0.028, 끽 소리 0.118 이 되어 이제는 끽 소리가 엔진보다 크다. 끽 소리만 키우지 않고, 그동안 엔진이 잠깐 비켜 주게 한 것이다.
끝날 때와 처음 시작할 때
시간이 다 되면 차가 미끄러지듯 멈추는 연출이 1.8초 동안 이어지고, 그 뒤 결과 창이 뜬다. 소리도 1.6초에 걸쳐 0 으로 줄여, 결과 창에서는 조용하다.
브라우저는 페이지를 열자마자 소리를 내지 못하게 막는다. 자동 재생 정책 때문에 화면을 누르거나 키를 누른 뒤에야 소리를 낼 수 있다. 이 게임은 시작 전에 차가 출발선에서 공회전하는 연출이 있는데, 이 공회전 소리는 첫 입력 뒤부터 들린다.
소리를 듣지 않고 확인하기
화면은 글자로 읽어 확인할 수 있지만 소리는 그럴 수 없다. 그래서 브라우저에서 AudioParam.prototype.setTargetAtTime 을 감싸, 게임이 넘기는 높이와 크기를 배열에 쌓았다.
const log = [];
const orig = AudioParam.prototype.setTargetAtTime;
AudioParam.prototype.setTargetAtTime = function (value, time, k) {
log.push({ value, time });
return orig.call(this, value, time, k);
};
기어마다 높이가 오르다 떨어지는지, 일시정지하면 크기가 0 이 되고 계속하면 돌아오는지, 브레이크 때 엔진과 끽 소리의 크기가 얼마인지를 모두 이 기록으로 봤다. 앞에 적은 숫자들이 여기서 나왔다. 숫자가 맞는지는 기록으로 확인하고, 듣기 좋은지는 실제로 들어 보고 정했다.
소리 파일 대신 합성으로 해서 좋았던 점
- 받을 파일이 없다. 소리 세 가지가 모두 코드 몇십 줄이다. 공통 기능
api.hum이 70줄쯤, 게임 쪽 소리 코드가 20줄이 안 된다. 게임을 열 때 소리 파일을 내려받거나 해독하느라 기다릴 일이 없고, 그림에 쓰는 용량을 소리와 나눌 필요도 없다. - 높이·밝기·크기를 따로 움직인다. 녹음한 소리는 재생 속도를 바꾸면 높이와 길이가 함께 바뀌고, 소리의 밝기는 녹음된 그대로다. 합성은 높이, 필터 주파수, 크기, 떨림이 모두 따로 있는 값이라 속도와 기어에서 각각 계산해 넣으면 된다. 기어를 올릴 때 회전이 뚝 떨어지는 것도 숫자 하나로 된다.
- 이음매가 없다. 오실레이터는 멈출 때까지 끊기지 않고 이어진다. 녹음한 소리를 반복 재생할 때처럼 끝과 처음을 맞추는 작업이 필요 없다.
- 고치기 쉽다. 끽 소리가 안 들릴 때 다시 녹음하거나 새 파일을 찾지 않았다. Q 와 크기, 엔진을 낮추는 비율 세 숫자를 바꿨다.
- 라이선스를 따질 일이 없다. 이 사이트는 외부 그림·소리를 상업적으로 쓸 수 있는 CC0·CC BY 만 쓰고 출처를 적는다. 직접 합성한 소리는 그 확인이 필요 없다.
- 변화가 부드럽다. 값을
setTargetAtTime으로 넘기면 바뀌는 과정은 오디오 처리 쪽에서 소리 시계에 맞춰 이어진다. 게임이 한 프레임에 한 번씩 값을 넘겨도 소리는 계단 없이 바뀐다.
물론 녹음한 진짜 엔진 소리만큼 실감 나지는 않는다. 파형 몇 개로 만든 소리라 어딘가 옛날 게임기 같은 느낌이 남는다. 실제 차 소리처럼 들려야 하는 게임이라면 녹음한 소리가 낫다.
참고
- MDN, Web Audio API: https://developer.mozilla.org/en-US/docs/Web/API/Web_Audio_API
- MDN, OscillatorNode: https://developer.mozilla.org/en-US/docs/Web/API/OscillatorNode
- MDN, BiquadFilterNode: https://developer.mozilla.org/en-US/docs/Web/API/BiquadFilterNode
- MDN, AudioParam.setTargetAtTime(): https://developer.mozilla.org/en-US/docs/Web/API/AudioParam/setTargetAtTime
- MDN, Autoplay guide for media and Web Audio APIs: https://developer.mozilla.org/en-US/docs/Web/Media/Guides/Autoplay
이 글과 관련된 게임
다른 글
- 젤리 캐릭터는 뼈대 대신 모양 다섯 개로 움직인다 2026년 11월 1일
- 의사 3D(pseudo 3D) 방식으로 2D 캔버스에서 3D 레이싱 게임처럼 만들기 2026년 11월 1일
- 라이브러리 없이 3D 주사위를 굴려 원하는 면에서 멈추기 2026년 11월 1일
- 카드를 옮길 때마다 아이폰에서 판이 번쩍였다 2026년 11월 1일
- 3D 모델의 텍스처가 하얗게 나왔다 2026년 10월 30일
