샘플 파일 없이 Web Audio로 효과음과 음악 만들기: 게임 일곱 개의 오디오 코드 비교
우리 게임 폴더에는 mp3도 wav도 없다. 효과음과 배경음악은 전부 각 게임의 js/audio.js가 실행 중에 오실레이터와 노이즈로 만들어 낸다. 파일 길이는 STARLINK의 37줄부터 ORBIT RUSH의 272줄까지 다양한데, 뼈대는 거의 같고 차이는 "소리가 게임 상태를 얼마나 따라가느냐"에서 생긴다. 일곱 게임의 실제 코드를 단계별로 비교해 본다.
왜 샘플을 쓰지 않았나
첫째 이유는 사이트 푸터에 적어 둔 "모든 게임·음악·그래픽은 자체 제작"이라는 원칙이다. 외부 효과음 팩을 쓰면 출처와 라이선스를 표기해야 하고 이 문구를 바꿔야 한다. 둘째는 오프라인 캐시다. 게임마다 서비스 워커가 셸 파일을 미리 받는데, 소리를 코드로 만들면 오디오 파일이 캐시 목록에 하나도 없다. 셋째가 가장 실용적인 이유로, 합성한 소리는 숫자 하나로 바꿀 수 있다. 줄이 팽팽할수록 음이 올라가거나, 적이 가까울수록 드론이 낮아지는 식의 연속적인 변화를 샘플로 하려면 녹음본이 여러 개 필요하지만 합성음은 주파수 인자만 바꾸면 된다.
대가도 있다. 사람 목소리나 실제 악기 같은 복잡한 음색은 만들기 어렵고, 소리를 손보려면 코드를 고쳐야 한다. 우리 게임들의 소리가 대체로 전자음이고 기하학적인 것도 이 선택과 무관하지 않다.
공통 뼈대: 톤 하나, 노이즈 하나
가장 작은 STARLINK의 오디오 코드를 보면 기본 단위가 보인다. 오실레이터 하나에 게인 노드 하나를 붙이고, 게인을 짧게 올렸다가 지수 곡선으로 떨어뜨린다.
const osc=c.createOscillator(),g=c.createGain(); osc.type=type; osc.frequency.value=freq;
g.gain.setValueAtTime(0,t); g.gain.linearRampToValueAtTime(vol,t+attack);
g.gain.exponentialRampToValueAtTime(.0001,t+dur);
osc.connect(g); g.connect(master); osc.start(t); osc.stop(t+dur+.05);
감쇠 목표가 0이 아니라 .0001인 것은 지수 램프가 0에 도달할 수 없기 때문이다. 이 패턴이 FLIP, SLINGSTAR, ECHO에도 거의 그대로 있고 FOURSTEP과 JJ는 같은 일을 하는 env() 함수로 분리했다. 여기에 시작 주파수에서 비율만큼 미끄러지는 slide 옵션이 붙으면 점프음, 착지음, 킥 드럼이 전부 같은 함수에서 나온다.
노이즈는 무작위 값으로 채운 버퍼를 BiquadFilter에 통과시키고 필터 주파수를 쓸어내리는 방식이다. 하이패스로 8kHz 위만 남기면 하이햇, 밴드패스로 1.9kHz 근처를 남기면 스네어가 된다. 리버브도 녹음된 공간 응답 파일 대신 지수적으로 줄어드는 노이즈를 직접 만들어 ConvolverNode에 넣는다.
STARLINK는 이 위에 음계 하나만 얹었다. 선을 이을 때마다 5음 음계(PENTA)에서 한 칸씩 올라가는 음을 내는데, 5음 음계에는 반음 충돌이 없어서 어떤 순서로 이어도 불협화음이 나지 않는다. 퍼즐을 풀수록 음이 높아지는 것이 진행감을 준다.
박자를 지켜야 하는 소리: 미리 예약하는 스케줄러
FLIP부터는 배경에 메트로놈 같은 루프가 깔린다. 55Hz에서 38Hz로 떨어지는 사인파 킥과 반 박자 뒤 1760Hz 사각파 틱이다. 여기서 중요한 것은 소리를 언제 트느냐다. setTimeout으로 박자마다 소리를 내면 탭이 백그라운드로 가거나 프레임이 밀릴 때 박자가 흔들린다. 그래서 50ms마다 깨어나 앞으로 0.12초 안에 올 박자를 오디오 클록 기준 시각으로 미리 예약한다.
리듬 게임인 FOURSTEP과 ORBIT RUSH는 같은 구조를 더 크게 쓴다. FOURSTEP은 40ms마다 0.2초 앞을, ORBIT RUSH는 25ms마다 0.14초 앞을 16분음표 단위로 예약한다. FOURSTEP은 한발 더 나가 게임 시계 자체를 오디오에 맞췄다. 현재 박자를 startBeat + (ctx.currentTime - startTime) × bpm / 60으로 계산하고, 게임 로직은 음악이 재생 중이면 이 값을 박자로 쓴다. 화면 프레임과 음악이 서로 다른 시계를 따르면 몇 분 뒤 판정이 어긋나기 때문이다.

음악을 생성하는 두 가지 방식
FOURSTEP의 곡 하나는 데이터 몇 줄이다. 드럼은 'x...x..xx...x...' 같은 16글자 문자열로 패턴을 적고, 스타일은 pop, synth, drive 세 가지다. 멜로디는 트랙 시드로 만든 난수에서 뽑되, 65% 확률로 현재 코드의 구성음 가운데 가장 가까운 음으로 끌어당긴다. 완전한 무작위보다 코드에 붙어 있는 멜로디가 훨씬 노래처럼 들렸다. 마디 첫 박에 밀도 0.85, 짝수 16분음표에 0.55, 나머지에 0.3을 주어 강박에 음이 몰리게 한 것도 같은 이유다.
이 게임의 재미있는 부분은 지형별 믹스다. 들판, 땅굴, 바다, 산 네 지형마다 필터 차단 주파수, 하이햇 유무, 리드 옥타브, 딜레이·리버브 양을 표로 정해 두었다. 땅굴에 들어가면 차단 주파수가 620Hz로 내려가 소리가 흙 속처럼 먹먹해지고, 바다에서는 딜레이 0.4와 리버브 0.5로 번지며, 산에서는 리드가 한 옥타브 올라간다. 곡을 새로 만들지 않고 같은 곡을 다른 공간에서 듣게 하는 방식이라 지형 전환이 자연스럽다. 전환 4박 전에는 라이저와 탐 필인을, 도착 순간에는 임팩트를 예약한다. 킥이 칠 때마다 패드와 저음 버스의 게인을 0.4로 눌렀다가 0.26초에 걸쳐 되돌리는 사이드체인 효과도 넣었다.
ORBIT RUSH는 반복을 더 의식했다. 리드는 한 마디(16분음표 16칸)짜리 모티프 세 개를 만들어 8마디에 [0,0,1,2,0,0,1,2] 순서로 배치하고, 4마디마다 마지막 마디는 음계에서 두 칸 올려 프레이즈 끝을 표시한다. 같은 모티프가 돌아와야 사람이 따라 흥얼거릴 수 있다. 또 강도 0에서 3까지 층을 나눠 하이햇, 리드, 아르페지오를 하나씩 쌓는다. ORBIT RUSH는 이 사이트에서 외부 오디오를 받는 유일한 게임이기도 한데, 사용자가 자기 음악 파일을 고르면 브라우저 안에서 디코딩한 뒤 소리 에너지가 갑자기 커지는 지점을 찾아 70~190 BPM 범위를 0.5 단위로 훑고 0.1 단위로 다듬어 박자를 추정한다. 파일은 서버로 가지 않는다.
상태를 따라 움직이는 소리: ECHO와 JJ
ECHO와 JJ는 정해진 곡이 거의 없다. 대신 매 프레임 게임 상태를 받아 지속음을 조절한다. ECHO는 가장 가까운 사냥꾼까지의 거리로 0에서 1 사이의 긴장도를 만들고, 드론의 기본음을 41Hz에서 최대 4.5Hz 내리면서 필터는 160Hz에서 최대 580Hz까지 연다. 사냥꾼이 경계 상태로 7칸 안에 들어오면 심장 박동이 시작되고, 박동 속도는 거리에 따라 분당 60에서 140회까지 올라간다. 음파 핑은 벽까지의 거리를 소리의 지연으로 바꾼다. 한 칸에 1/14초씩 늦게, 멀수록 낮은 음으로 메아리가 돌아온다. 화면이 거의 어두운 게임이라 소리가 지도의 일부를 맡는다. 잔향에도 71ms 간격의 초기 반사를 섞어 동굴 벽에 부딪히는 느낌을 냈고, 잡히는 순간에는 전체 출력을 0.3초 만에 260Hz 로우패스로 막아 귀가 먹먹해지는 효과를 준다.
JJ의 핵심 소리는 두 코어를 잇는 줄이다. 줄 장력에 따라 110Hz 톱니파와 사인파 험의 주파수, 필터, 떨림 속도가 함께 올라가서 줄이 끊어지기 직전이면 소리만 듣고도 알 수 있다. 줄을 당기는 동안에는 22Hz 사각파로 노이즈를 끊어 릴 감는 래칫 소리를 낸다. 두 플레이어는 음색으로 구분했다. EMBER는 FM 합성 타격음, AZURE는 배음 비율 2.76과 5.4를 쓴 유리 종소리이고, 근음은 5도 관계(A2와 E3)라 동시에 울려도 어울린다. 배경 아르페지오의 간격은 두 사람의 SYNC 게이지에 따라 0.42초에서 0.28초까지 짧아진다. 호흡이 맞을수록 음악이 빨라지는 셈이다.
정리
일곱 게임을 나란히 놓고 보면 오디오 코드는 세 단계로 자랐다. 톤과 노이즈를 내는 함수 두 개, 오디오 클록으로 미리 예약하는 스케줄러, 그리고 게임 상태를 연속값으로 받는 지속음이다. 샘플 없이 시작한 덕분에 세 번째 단계가 쉬웠다. 남은 과제도 있다. ECHO의 심장 박동은 아직 setTimeout으로 다음 박을 부르는데, 리듬 판정이 없는 게임이라 문제는 없지만 탭이 백그라운드에 있으면 박동이 늦어진다. 다음 게임 PRISM은 수신기가 켜질 때마다 유리 하모닉스 패드에 화음 성부를 하나씩 더하는 방식을 설계해 두었고, 이 역시 합성으로 만든다.