작은 모바일 패링 게임이 가야금 배경음의 음 하나하나를 실행 중에 계산해 내는 방법. 아래에서 직접 뜯어 볼 수 있어요.
보름달(FullMoon), 1인 개발 · 2026년 10월
선비검객은 조선 시대를 배경으로 한 한 손가락 패링 게임이에요. 원 바깥에서 공격이 날아오고, 맞는 순간에 그쪽으로 손가락을 밀면 막아 냅니다. 소리만큼은 한국답게 하고 싶었고, 저한테 한국의 소리는 가야금이었어요. 명주실 줄을 왼손으로 누르고 떨고 꺾는 그 소리요.
쓸 수 있는 녹음이 없었고, 게임은 모바일 웹뷰에서도 돌 만큼 가벼워야 했어요. 그래서 이 게임에는 오디오 파일이 하나도 없습니다. 음악과 효과음은 Web Audio API 위에서 돌아가는 TypeScript 약 1,100줄이 전부 만들어요. 이 글은 그 방법을 정리한 거예요.
줄을 누르면 소리가 나요. 게임에 들어간 합성 코드 그대로가 지금 브라우저에서 돌아갑니다. '비단 들뜸'을 끄면 교과서 그대로의 소리와 비교할 수 있어요.
게임 합성기에서 오프라인으로 바로 뽑은 소리예요. 끝에 소리를 줄인 것 말고는 손대지 않았어요.
카플러스-스트롱은 줄 뜯는 소리를 만드는 고전적인 방법이에요. 짧은 지연 버퍼를 잡음으로 채우고, 그 출력을 부드러운 평균 필터에 통과시켜 다시 넣기를 반복해요. 버퍼 길이가 음 높이를 정하고, 평균을 낼수록 고음이 서서히 사라져요. 진짜 줄이 울리다 잦아드는 모습과 같아요.
문제는 음정이에요. 지연 버퍼 길이는 샘플 단위 정수라서 44.1kHz에서 높은 줄로 갈수록 음이 눈에 띄게 어긋나요. 그래서 고리 안에 1차 올패스 필터를 넣어 모자란 소수점 샘플만큼을 채웠어요.
const period = sampleRate / freq;
const L = Math.floor(period - 0.5); // 정수 샘플 (평균 필터가 0.5 더함)
const d = period - 0.5 - L; // 남는 소수
const C = (1 - d) / (1 + d); // 올패스 계수
// 샘플마다:
const avg = 0.5 * (x + last) * rho; // 평균 + 감쇠
const ap = C * avg + apX - C * apY; // 소수 지연
줄마다 울림 길이는 T60(소리가 60dB 줄어드는 시간)으로 정해요: rho = 10 ** (-3 / (freq * t60)). 낮은 줄은 3초쯤, 높은 줄은 1초가 안 되게 울려서 저음은 퍼지고 고음은 또렷해요.
기본 카플러스-스트롱은 쨍한 쇠줄이나 하프시코드처럼 들려요. 가야금 줄은 꼰 명주실이라 뜯는 순간이 부드럽고 속이 빈 듯한 소리가 나요. 작은 변화 세 가지로 대부분을 잡았어요. 위 데모의 '비단 들뜸' 단추로 차이를 들을 수 있어요.
마지막으로 음 시작을 4ms 동안 키워 올려서, '딱' 하는 클릭 대신 손끝이 비단에 닿는 느낌을 냈어요.
음마다 고리를 실시간으로 돌리면 휴대폰에서는 낭비예요. 그래서 줄이 처음 필요할 때 한 번만 AudioBuffer 로 구워 두고(줄마다 잡음 씨앗이 정해져 있어 늘 같은 소리), AudioBufferSourceNode 로 틀어요. 게임 전체에서 쓰는 줄은 많아야 스무 개 남짓이라 저장 공간도 작아요.
가야금을 가야금답게 만드는 건 뜯는 소리가 아니라 그 뒤 왼손이에요. 안족 뒤에서 줄을 눌러 음을 떨거나(농현), 밀어 올렸다 돌아오게(꺾기) 하죠. 둘 다 버퍼의 playbackRate 자동화일 뿐이에요.
// 꺾기: 두 반음 밀어 올렸다 돌아오기
const up = 2 ** (2 / 12);
rate.setValueAtTime(1, t + 0.18);
rate.linearRampToValueAtTime(up, t + 0.34);
rate.setValueAtTime(up, t + 0.50);
rate.linearRampToValueAtTime(1, t + 0.68);
// 농현: 뜯은 뒤 점점 깊어지는 5.2Hz 떨림
curve[i] = 2 ** (depth * Math.min(1, u * 2.5) * Math.sin(u * dur * 2 * Math.PI * 5.2) / 12);
rate.setValueCurveAtTime(curve, t + 0.22, dur);
곡선 모양보다 시간 차이가 더 중요했어요. 떨림은 뜯고 0.2초쯤 뒤에 시작해서 점점 깊어져요. 연주자가 소리를 먼저 내보내고 나서 흔드는 것처럼요.
모든 음 높이는 함수 하나에서 나와요. 다섯 음 평조(황·태·중·임·남)이고, 황을 E♭4 근처인 311.13Hz로 잡았어요.
const PYEONGJO = [0, 2, 5, 7, 9]; // 황 기준 반음
function scaleFreq(step) {
const oct = Math.floor(step / 5);
const deg = ((step % 5) + 5) % 5;
return 311.13 * 2 ** (oct + PYEONGJO[deg] / 12);
}
패링 소리도 같은 함수를 써서, 완벽한 패링은 늘 음계 안의 한 음에 떨어져요(어느 음인지는 미는 방향에 따라 달라요). 정신없는 싸움 중에도 막는 소리와 음악이 부딪히지 않고, 막는 소리가 거의 가락의 일부처럼 들려요.
백 번 다시 하는 게임에서 반복되는 곡은 금방 질려요. 그래서 곡 대신, 12박짜리 가락을 그때그때 짓는 작은 작곡기를 넣었어요.
빽빽함과 빠르기는 게임에서 받아요. 오래 버틸수록 빨라지고(76→102 BPM) 짧은 음이 많아져요. 무한 수련은 낮·해질녘·밤·새벽을 지나며 음역을 내리거나 올리고, 밤에는 농현을 더 깊게 걸어요. 보스 판에는 걸러 낸 잡음과 음높이가 미끄러지는 사인파로 만든 장구 장단이 얹혀요.
패링 게임은 큰 타격에서 몇 프레임 시간을 멈추는 히트스톱을 좋아해요. 음악이 게임 시간을 따라가면 히트스톱마다 박이 덜컹거려요. 그래서 예약기는 AudioContext.currentTime 만 봐요. 매 프레임 앞으로 0.4초 안에 들어오는 박만 예약하고, 게임 시간은 전혀 끼어들지 않아요.
믹스는 늘 패링이 주인공이 되게 맞췄어요. 판 중 음악은 −17dBFS 근처, 막는 소리는 −6 근처까지 올라가요. 막을 때마다 음악은 20ms 안에 45%로 물러났다가 0.3초쯤에 걸쳐 돌아와요. 체력이 바닥나면 음악 전체의 저역 통과가 4.2kHz에서 700Hz로 미끄러져서, 소리를 더 넣지 않고도 물속처럼 먹먹해져요.
샘플링한 가야금은 아니라서, 실제 연주자는 차이를 알아들을 거예요. 그래도 작고, 되풀이하지 않고, 게임과 늘 음이 맞고, 게임의 배경이 된 곳의 소리가 나요. 그게 목표였어요.
선비검객은 아이폰과 안드로이드에 나와 있어요. 한 번 사면 끝이고 광고도 앱 내 구매도 없어요.