ORBIT RUSH의 내 음악 기능이 업로드 없이 브라우저 안에서 음악 파일을 디코딩하고, 에너지 변화와 빗살 격자 점수로 BPM과 첫 박을 찾아 곡 길이에 맞는 궤도를 만드는 과정을 정리했습니다.">
DEV NOTE · ORBIT RUSH

내 음악으로 궤도 만들기: 브라우저 안에서 끝내는 BPM 분석

ORBIT RUSH 3.0.0부터는 갖고 있는 음악 파일을 불러와 그 곡으로 플레이할 수 있다. 파일은 서버로 가지 않고 브라우저 안에서 디코딩되고 분석된다. 이 글은 BPM과 첫 박 위치를 어떤 방식으로 찾는지, 분석이 틀릴 때를 어떻게 대비했는지, 그리고 분석 결과를 궤도 생성기에 어떻게 넘기는지 정리한다.

서버 없이 처리하기로 한 이유

사용자가 고른 음악 파일을 서버로 올려 분석하는 방식은 택하지 않았다. ORBIT RUSH는 계정 없이 동작하고 기록도 브라우저 저장소에만 남기는 게임이며, 음악 파일은 저작권과 개인 취향이 함께 얽힌 데이터라 서버에 머무는 순간 관리 책임이 생긴다. 또 분석에 필요한 도구는 이미 브라우저에 다 있다.

흐름은 이렇다. 파일 선택 창에서 받은 파일을 arrayBuffer()로 읽고, Web Audio의 decodeAudioData()로 PCM 샘플을 얻는다. MP3, WAV, OGG, M4A 중 무엇을 디코딩할 수 있는지는 브라우저가 정한다. 실패하면 지원 형식을 안내하는 메시지를 띄운다. 디코딩된 버퍼는 메모리에만 있고 저장소에도 기록하지 않으므로 페이지를 닫으면 사라진다. 같은 이유로 내 음악 플레이는 도전 링크를 만들지 않는다. 링크를 받은 사람에게는 그 곡이 없기 때문이다.

에너지 변화에서 박을 찾는 방법

BPM 분석은 audio.js의 analyzeBPM 한 함수에 들어 있다. 무거운 신호처리 라이브러리 없이 두 단계로 구성했다.

첫 단계는 온셋 포락선이다. 샘플을 512개씩 묶은 프레임마다 두 채널을 평균한 신호의 RMS 에너지를 구한다. 계산량을 줄이려고 프레임 안에서는 샘플을 하나 건너 하나씩만 쓴다. 그다음 프레임 사이 에너지 차이에서 양수 부분만 남긴다. 소리가 갑자기 커지는 순간, 즉 드럼이 치거나 음이 시작되는 순간만 뾰족하게 남는 곡선이다. 이 곡선을 최댓값으로 나눠 0~1로 맞춘다. 44.1kHz 음원이라면 1초에 약 86프레임이 나온다.

두 번째 단계는 빗살 격자 점수다. 후보 BPM 하나를 정하면 한 박이 몇 프레임인지 계산할 수 있다. 그 간격으로 빗살처럼 격자를 그어 격자가 닿는 프레임의 온셋 값을 평균한다. 박이 맞는 템포라면 격자가 드럼 위치에 계속 떨어져 점수가 높다. 격자의 시작 위치도 모르므로 한 박을 24등분해 시작점을 모두 시험하고 가장 높은 점수를 쓴다. 이렇게 하면 BPM과 위상(첫 박 위치)을 한 번에 얻는다.

// audio.js 발췌
for(let bpm=70;bpm<=190;bpm+=.5){ const r=scoreAt(bpm);
  if(r.score>best.score) best={bpm,score:r.score,off:r.off}; }
if(best.bpm<95){ const d=scoreAt(best.bpm*2);
  if(d.score>=best.score*.72) best={bpm:best.bpm*2,score:d.score,off:d.off}; }

탐색 범위는 70~190 BPM을 0.5 간격으로 훑고, 가장 좋은 값 주변 ±0.5를 0.1 간격으로 다시 훑는다. 분석 구간은 곡 앞부분 150초로 제한했다. 긴 곡 전체를 훑을 필요는 없고, 휴대폰에서 분석이 몇 초씩 걸리면 기능을 쓰지 않게 된다.

두 배·절반 템포 문제

이런 방식의 고질적인 약점은 템포를 절반이나 두 배로 착각하는 것이다. 격자를 박 두 개마다 그어도 강박에는 계속 맞기 때문에 점수 차이가 크지 않다. 실제로 이 게임에서 문제가 되는 쪽은 절반 템포다. 128 BPM 곡을 64 BPM으로 읽으면 입력 위치는 여전히 강박에 걸리지만 궤도가 모두 두 배 느리게 돌아, 음악의 속도감과 화면이 따로 노는 느낌이 든다.

그래서 결과가 95 BPM 미만이면 두 배 템포의 점수를 따로 계산해, 원래 점수의 72% 이상만 되어도 두 배 쪽을 택한다. 느린 템포를 기본으로 의심하는 셈이다. 이 기준 때문에 진짜 느린 발라드가 두 배로 잡힐 수 있지만, 이 게임에서는 두 배로 틀리는 편이 덜 괴롭다. 두 배로 잡히면 패턴이 촘촘해져 어려워질 뿐, 입력은 여전히 실제 박을 잘게 나눈 위치에 떨어지고 화면의 속도감도 음악과 어긋나지 않는다.

첫 박과 신뢰도, 그리고 직접 수정

격자의 시작점만으로는 첫 박을 정할 수 없다. 곡 앞에 무음이 길면 격자가 무음 구간에서 시작할 수 있다. 그래서 에너지가 전체 최댓값의 10%를 처음 넘는 프레임을 찾고, 격자 위상을 그 이후로 한 박씩 밀어 첫 박을 정한다.

분석 결과에는 최종 점수를 신뢰도로 함께 돌려준다. 0.25보다 높으면 "높음", 0.12보다 높으면 "보통 (BPM이 2배/절반일 수 있어요)", 그 이하면 "낮음 (직접 입력 권장)"으로 표시한다. 점수는 온셋 평균이라 박이 또렷한 전자음악은 높게, 앰비언트나 클래식처럼 타격음이 약한 곡은 낮게 나온다.

트랙 선택 화면 하단의 내 음악 패널에 파일 이름, BPM 140, 첫 박 0.50초 입력칸과 자동 분석·PLAY 버튼이 있는 모습
내 음악 패널. 자동 감지 값을 보여 주고 BPM과 첫 박을 직접 고칠 수 있게 했다.

자동 분석이 아무리 좋아도 틀리는 곡은 있다. 그래서 감지 결과를 입력칸에 채워 넣고 사용자가 바로 고칠 수 있게 했다. BPM은 40~300, 첫 박은 0.01초 단위다. 원래 값으로 돌아가고 싶으면 "자동 분석" 버튼으로 다시 분석한다. 이 수정 기능이 있으면 분석기를 완벽하게 만들 필요가 없어진다. 분석은 좋은 출발점을 주는 역할이고, 마지막 결정은 곡을 아는 사람이 한다.

분석 결과를 궤도로 바꾸기

PLAY를 누르면 파일 이름을 해시해 시드를 만들고, BPM에서 강도 값을 계산한다. 90 BPM이면 0, 180 BPM이면 1이 되는 선형 값이다. 강도가 높을수록 ½박 궤도 비율이 25%에서 45%로 늘고, 분열과 블랙홀 확률도 오른다. 빠른 곡일수록 촘촘한 패턴이 나오는 것이 음악적으로도 자연스럽다. 같은 파일 이름이면 언제나 같은 별자리가 나오므로 같은 곡을 반복해 연습할 수 있다.

궤도 길이는 곡 길이에 맞춘다. 첫 박 이후 남은 재생 시간을 박 수로 바꾸고 4박을 뺀 지점에서 노드 생성을 멈춘다. 곡이 끝나기 직전에 GOAL에 도착해 여운을 남기기 위한 여유다.

재생은 디코딩한 버퍼를 오디오 시계 기준으로 예약한다. 게임의 0박 시각에서 첫 박 위치만큼 앞당겨 재생을 시작하면 곡의 첫 박과 게임의 0박이 겹친다. 그 앞에는 하이햇 4번으로 카운트인을 넣는다. 배속을 바꾸면 기본 트랙은 그 템포로 곡을 새로 합성하지만, 내 음악은 재생 속도(playbackRate)를 바꾸는 방식이라 음정도 함께 올라가거나 내려간다. 음정을 유지하는 타임 스트레칭은 브라우저 기본 기능만으로 구현하기 번거로워 지금은 넣지 않았고, 그 사실을 README와 가이드에 적어 두었다.

정리

내 음악 기능은 온셋 포락선과 빗살 격자라는 단순한 두 단계, 느린 템포를 의심하는 보정 한 줄, 그리고 사용자가 직접 고치는 입력칸으로 구성된다. 모든 처리가 브라우저 안에서 끝나므로 서버 비용도 개인정보 부담도 없다. 남은 과제는 템포가 중간에 바뀌는 곡이다. 지금 분석기는 곡 전체에 BPM 하나를 가정하므로, 라이브 녹음처럼 템포가 흔들리는 곡에서는 뒤로 갈수록 박이 어긋난다.