DEV NOTE · FOURSTEP

샘플 없이 곡을 만들고 그 곡으로 게임을 굴리기: FOURSTEP 음악 엔진

FOURSTEP에는 오디오 파일이 하나도 없습니다. 킥 드럼부터 리버브의 잔향까지 모두 브라우저의 Web Audio API로 그 자리에서 합성합니다. 그리고 이 음악은 배경음이 아니라 게임의 시계입니다. 핍의 위치는 오디오 장치가 지금 몇 박째를 재생하는지에서 계산됩니다. 이 글은 트랙을 데이터로 표현하는 방식, 시드 멜로디, 지형별 음색 변화, 그리고 오디오 시계와 게임을 묶은 방법을 정리합니다.

왜 합성이고, 트랙은 어떻게 생겼나

ROGA GAME은 모든 음악을 자체 제작한다는 원칙을 갖고 있습니다. 녹음된 곡을 쓰려면 트랙마다 곡을 만들어 파일로 넣어야 하고, 속도 배율을 바꾸면 피치나 음질 문제가 생깁니다. FOURSTEP은 매일 새 곡이 필요한 DAILY 모드도 있어서 파일 방식은 처음부터 맞지 않았습니다. 곡을 몇 개의 숫자로 정의하고 연주는 코드가 하면, 트랙을 하나 늘리는 일은 데이터 한 줄을 추가하는 일이 됩니다. 템포를 바꾸면 악기가 그 템포로 연주할 뿐이라 음질 손실도 없습니다.

트랙 정의는 js/level.js의 TRACKS 배열에 있습니다. 실제 첫 줄은 다음과 같습니다.

{id:1,name:'SUNRISE HOP',bpm:112,root:60,mode:'major',prog:[0,4,5,3],
 style:'pop',bars:20,plan:['field','burrow','field'],d:[0.15,0.4]}

root는 으뜸음의 MIDI 번호(60은 가운데 도), mode는 장조·단조, prog는 음계 위 몇 번째 음에서 화음을 쌓을지(0, 4, 5, 3은 I–V–VI–IV)입니다. style은 드럼 패턴 세트를 고릅니다. pop, synth, drive 세 가지가 있고 각각 킥·스네어·하이햇·오픈햇·베이스의 16스텝 패턴을 문자열로 갖고 있습니다. 예를 들어 drive의 킥은 x...x...x...x...로 네 박 모두 치는 패턴입니다. 같은 줄에 레벨 생성용 정보(지형 계획, 난이도 범위)도 함께 있어서, 곡과 코스가 한 덩어리로 관리됩니다. DAILY 트랙은 날짜 시드로 BPM(118~138 중 하나), 으뜸음, 장·단조, 코드 진행, 드럼 스타일을 뽑아 같은 형식의 객체를 만듭니다.

시드 멜로디: 무작위지만 화음 안에서

멜로디는 'melody-'+트랙 id를 시드로 한 난수로 4마디(16분음표 64칸)를 만들어 반복합니다. 규칙은 단순하지만 몇 가지 장치가 들어 있습니다. 음을 놓을 확률은 박의 머리에서 0.85, 8분음표 자리에서 0.55, 나머지 16분음표 자리에서 0.3입니다. 그래서 강박에 음이 몰리고 리듬이 박자를 따라갑니다. 다음 음은 현재 음에서 음계로 −2~+2칸 움직이고, 65% 확률로 그 마디 화음의 구성음 중 가장 가까운 음으로 당겨집니다. 음역은 음계 인덱스 4~13으로 묶어 너무 높거나 낮게 흘러가지 않게 했고, 네 번째 마디의 마지막 네 칸은 비워 프레이즈가 숨을 쉬게 했습니다.

이 방식의 장점은 재현성입니다. 같은 트랙은 언제나 같은 멜로디를 연주하므로, 플레이어는 반복 플레이 중에 곡을 외우고 "그 멜로디가 올라갈 때 점프"처럼 음악을 단서로 쓸 수 있습니다. 리듬 게임에서 곡이 매번 바뀌면 이 단서가 사라집니다.

지형이 믹스를 바꾼다

변신이 화면에서만 일어나면 지형 전환이 밋밋합니다. 그래서 지형마다 믹스 설정을 따로 두었습니다. 실제 값은 다음과 같습니다.

지형로우패스하이햇리드 옥타브패드딜레이 / 리버브
들판18kHz (사실상 열림)있음원래0.50.22 / 0.25
땅굴620Hz, Q 3없음한 옥타브 아래0.30.1 / 0.35
바다1.9kHz, Q 1.2없음원래, 부드러운 어택0.80.4 / 0.5
산18kHz있음한 옥타브 위0.60.3 / 0.4

땅굴에 들어가면 음악이 벽 너머에서 들리는 것처럼 먹먹해지고 킥은 1.3배로 세집니다. 바다에서는 고음이 깎이고 패드와 잔향이 커져 물속처럼 퍼집니다. 리드도 바다에서만 어택을 늘린 레가토 음색으로 연주합니다. 산에서는 리드가 한 옥타브 올라가 시야가 트인 느낌을 줍니다. 필터 값은 setTargetAtTime으로 0.25초 정도에 걸쳐 옮겨 가므로 끊기지 않고 미끄러지듯 바뀝니다.

리버브도 파일이 아닙니다. 1.8초 길이의 노이즈에 지수 감쇠를 곱한 충격 응답을 시작할 때 한 번 만들어 컨볼버에 넣습니다. 패드와 리드, 베이스의 사인 성분은 킥이 칠 때마다 0.4까지 눌렸다가 0.26초에 걸쳐 돌아오는 덕킹 버스를 지나, 저음이 킥과 겹쳐 뭉개지지 않습니다.

게임 시계는 AudioContext

리듬 게임에서 가장 중요한 것은 화면과 소리가 어긋나지 않는 것입니다. 브라우저에서 requestAnimationFrame의 시간과 오디오 장치의 시간은 서로 다른 시계라서, 오래 플레이하면 조금씩 벌어질 수 있습니다. FOURSTEP은 기준을 오디오 쪽에 두었습니다.

beat(){ ... return startBeat+(ctx.currentTime-startTime)*bpm/60; }

음악 엔진은 40ms마다 깨어나 앞으로 0.2초 안에 연주될 16분음표들을 미리 예약합니다(룩어헤드 스케줄링). 게임 루프는 매 프레임 AUDIO.beat()로 현재 박자를 읽고, 핍의 시뮬레이션을 그 박자까지 1/24박 단위로 진행시킵니다. 소리가 꺼져 있거나 오디오가 아직 준비되지 않았을 때는 performance.now() 기반의 보조 시계를 씁니다.

이 구조에서 나온 버그도 있었습니다. 1.0.1 변경 이력에 적힌 "일시정지 후 재개하면 히어로가 순간이동하던 버그"입니다. 일시정지 중에도 시계 하나가 흐르고 있었고, 재개하는 순간 시뮬레이션이 밀린 시간을 한꺼번에 따라잡은 것입니다. 지금은 일시정지 동안 오디오 시계를 멈춘 값으로 읽고, 보조 시계도 멈춘 시간만큼 기준점을 뒤로 미룹니다.

지형 경계에 맞춘 라이저와 속도 배율

게임은 음악을 시작할 때 트랙 정보에 지형 경계의 박자 목록(zoneBeats)을 붙여 넘깁니다. 지형 경계 x0을 박자당 3칸으로 나눈 값입니다. 음악 엔진은 16분음표를 예약할 때마다 4박 안에 경계가 있는지 보고, 정확히 4박 전이면 노이즈와 톱니파가 함께 올라가는 라이저를 걸고, 2박 전부터는 8분음표마다 음이 내려가는 탐 필인을 치고, 경계 박자에는 저음 드롭과 크래시를 합친 임팩트를 칩니다. 변신하는 순간이 곡의 전환점이 되는 것은 이 덕분입니다.

1.2.0에서 속도 배율(100~200%)을 넣을 때도 이 구조가 그대로 통했습니다. 게임은 음악 엔진에 BPM 대신 "트랙 BPM × 배율"을 넘길 뿐입니다. 물리는 박자 단위라 코스는 그대로이고, 오디오 시계가 더 빨리 박자를 세면 핍도 그만큼 빨리 달립니다. 일시정지 화면에서 배율을 바꾸면 현재 박자에서 음악을 새 템포로 다시 시작합니다. FEVER 상태에서는 로우패스를 20kHz까지 열고, 리드 위에 5도 위 하모니를 얹고, 화음 아르페지오를 추가해 음악으로도 보상을 줍니다.

박자에 맞춰 맥동하는 일식 태양 아래 들판을 달리는 핍
화면의 일식 태양도 박자에 맞춰 맥동한다. 그래픽과 물리가 같은 박자 값을 읽는다.

정리

FOURSTEP의 음악 엔진은 트랙을 데이터 한 줄로 줄이고, 연주를 코드에 맡겨 파일 없이 6개 트랙과 매일 새로운 DAILY 곡을 만듭니다. 시드 멜로디는 무작위성을 화음과 박자 안에 묶어 외울 수 있는 곡이 되게 했고, 지형별 믹스와 경계 라이저는 변신을 귀로도 느끼게 합니다. 무엇보다 오디오 시계를 게임의 기준으로 삼아 소리와 화면이 같은 박자를 공유합니다. 아직 기기별 오디오 출력 지연을 보정하는 설정은 없어서, 블루투스 이어폰처럼 지연이 큰 환경에서의 체감은 앞으로 살펴볼 부분입니다.