소리를 직접 합성하며 키운 값들: 긴장 연출과 발사음
개발노트 · 2026-10-08 · GamePool 편집팀
GamePool 게임의 소리에는 mp3 파일이 하나도 없습니다. 공용 파일 public/games/_shared/audio.js 한 곳(585줄)에서 Web Audio 로 그때그때 합성하고, 현재 효과음 181종과 배경음악 테마 50종이 모두 이 파일의 코드로 정의되어 있습니다. 이 글은 그 구조를 설명하는 글이 아니라, 최근 이틀 사이 이 파일과 두 게임의 소리를 고치면서 실제로 바뀐 값들의 기록입니다. 값은 모두 git 이력(레오니스·군단 점령전 등 관련 커밋)과 현재 소스에서 다시 확인한 것입니다.
소리가 만들어지는 길: 효과음 181종과 세 개의 볼륨 단
효과음은 SFX 라는 객체에 이름별 함수로 들어 있고, 함수 안에서 tone(파형 하나)과 noise(필터를 건 잡음)를 겹쳐 소리를 만듭니다. 예를 들어 pop 은 380Hz 에서 900Hz 로 올라가는 사인파 0.09초짜리 하나이고, thock 은 190Hz 에서 95Hz 로 내려가는 사인파 0.1초에 2200Hz 대역의 짧은 잡음 0.035초를 얹은 것입니다. 게임 코드는 GameAudio.sfx('pop', { vol: 0.42, pitch: 1.2 }) 처럼 이름과 음량, 음높이 배율만 넘깁니다.
이 값들이 귀에 닿기까지는 볼륨 단이 세 번 곱해집니다. 효과음 버스는 0.5, 배경음악 버스는 0.15, 그 위의 마스터가 0.9 이고 맨 끝에 압축기(임계값 -14dB, 비율 5:1)가 걸려 있습니다. 게임 쪽에서 vol 0.14 로 부른 효과음은 효과음 버스와 마스터를 지나 0.14 × 0.5 × 0.9, 곧 0.063 배로 줄어든 뒤에야 압축기에 닿습니다. 뒤에 나오는 숫자들은 이 곱셈을 감안하고 읽어야 합니다.
vol 을 1이 아닌 값으로 넘기면 sfx() 가 그 호출만을 위한 게인 노드를 하나 만들어 효과음 버스에 연결하고, 재생이 끝날 만한 3초 뒤 연결을 끊습니다. 한 번에 수십 개가 울리는 전투 장면에서는 이 임시 노드와 소리 하나하나가 곧 비용이기 때문에, 뒤에서 다룰 겹침 제어가 필요해졌습니다.
긴장도 하나로 템포와 저음을 함께 올리기
레오니스는 요새 체력이 30%(CFG.RAGE_AT 0.3) 아래로 떨어지면 위기 연출이 시작됩니다. 해당 커밋에서 이때 쓸 장치로 GameAudio.tension(0~1) 을 audio.js 에 새로 만들었습니다. 값을 올리면 배경음악 한 스텝의 길이가 stepDur / (1 + 0.4 × 긴장도) 로 줄어들어, 긴장도 1 이면 템포가 정확히 40% 빨라집니다. 전투 음악 lw_boss 는 172BPM 이므로 약 241BPM, 88BPM 인 kingdom 테마라면 약 123BPM 이 됩니다.
긴장도는 곧바로 바뀌지 않고 25ms 마다 목표값과의 차이의 3%씩만 따라갑니다. 계산하면 시간 상수가 약 0.8초이고, 소스 주석에 적어 둔 대로 1~2초에 걸쳐 서서히 변합니다. 템포가 한 박자에 뚝 바뀌면 음악이 끊긴 것처럼 들리기 때문입니다. 테마를 새로 시작할 때는 긴장도를 0으로 되돌리게 해 두었고, 레오니스도 전투 시작(startBattle)과 전투 종료(endBattle)에서 GameAudio.tension(0) 을 부릅니다.
템포만 올려서는 불안하게 들리지 않았기 때문에 dread() 라는 층을 따로 얹었습니다. 긴장도가 0.15 를 넘으면 8스텝마다 톱니파 드론 두 개가 깔리는데, 하나는 테마 근음보다 두 옥타브 아래이고 다른 하나는 거기서 반음 6개 위인 증4도입니다. 증4도는 어느 화음에도 안정적으로 녹지 않는 간격이라 음악 전체가 살짝 어긋난 느낌을 줍니다. 두 드론의 음량은 각각 0.5 와 0.32 에 긴장도를 곱한 값이라, 긴장도가 낮을 때는 거의 들리지 않습니다.
심장 박동은 긴장도 0.3 초과에서 시작합니다. 2스텝마다 120Hz 에서 48Hz 로 떨어지는 사인파(0.22초, 음량 0.9×긴장도), 4스텝 중 네 번째에 96Hz 에서 44Hz 로 떨어지는 더 약한 사인파(0.18초, 0.65×긴장도)를 넣어 8스텝 안에 큰 박동 4번과 작은 박동 2번이 들어갑니다. 긴장도 0.5 초과가 되면 8스텝의 마지막에 3000Hz 이상의 잡음 0.35초가 숨 들이쉬는 소리처럼 얹힙니다. 임계값을 0.15, 0.3, 0.5 로 나눈 이유는 긴장도가 서서히 오르는 동안 소리 층이 하나씩 쌓이게 하려는 것이었습니다.
챔피언마다 다른 울음: VOICE 표
같은 커밋에서 레오니스의 아군 챔피언 아홉 마리와 영웅에게 공격음과 피격음을 따로 붙였습니다. SoundManager.VOICE 라는 표에 챔피언 id 별로 공격(atk)과 피격(hurt)마다 [효과음, 음량, 음높이] 묶음을 적고, 여러 개면 겹쳐 재생합니다. 새 소리를 만든 것이 아니라 이미 있던 효과음을 음높이로 갈라 쓴 것이라는 점이 핵심입니다.
- 미어캣은 공격이 squeak 음량 0.2·음높이 1.7, 피격이 squeak 0.22·2.3 으로 가장 높고 가볍게 잡았습니다.
- 고릴라는 공격이 roar 0.2·1.4 에 stomp 0.16 을 겹치고, 피격은 groan 0.25·0.8 로 낮게 깔립니다.
- 아르마딜로는 공격이 thock 0.2·0.7 과 clink 0.14·0.8, 피격이 clink 0.26·0.6 과 shell 0.16·0.8 로 단단한 껍질 소리를 만듭니다.
- 그리핀은 공격이 flap 0.2·0.9 에 wail 0.13·1.9 를 높게 얹고, 영웅은 피격 hurt 0.28·0.9 하나만 가집니다.
표를 읽는 함수 voice() 는 두 가지를 합니다. 첫째, 챔피언 id 와 종류(atk·hurt)를 합친 키로 마지막 재생 시각을 기억해 공격은 0.12초, 피격은 0.22초 안에 다시 오는 요청을 버립니다. 둘째, 음높이에 0.92~1.08 배의 무작위 흔들림을 곱합니다. 고쳐 놓고 보니 이전에 영웅이 맞을 때 쓰던 sfxT('hurt', { vol: 0.25 }, 0.2) 한 줄이 이 구조에 흡수되어 VOICE.hero.hurt 로 바뀌었습니다.
몬스터 쪽은 표 대신 덩치로 계산합니다. 일반 몬스터의 공격음은 growl 음량 0.1, 음높이 max(0.55, 1.5 - 크기×0.5), 간격 0.14초이고, 엘리트는 growl 0.17, 보스는 roar 0.22·음높이 0.7·간격 0.7초입니다. 크기가 클수록 음높이가 내려가 덩치 큰 몹이 낮게 으르렁거립니다. 원거리와 투척형 몬스터는 mobAtk() 가 첫 줄에서 바로 반환해 이 공격음을 내지 않습니다. 같은 커밋에서 근접 무기 소리도 키웠습니다. 휘두르는 소리 whoosh 는 0.13/0.07 에서 0.2/0.12 로, 타격 hitflesh 는 0.13 에서 0.24 로, 금속 clink 는 0.12 에서 0.2 로 올렸습니다.
군단 점령전 발사음: 확률 40%·0.14 에서 매 발사·0.42 로
군단 점령전(mobpush)의 대포 발사음은 그 변경 이전에 Math.random() < 0.4 일 때만 pop 을 음량 0.14, 음높이 1.2~1.6 으로 울렸습니다. 이 커밋은 그 줄을 모든 발사에서 pop 음량 0.42, 음높이 1.0~1.4 로 바꿨고, 대장 병사가 아닌 일반 발사에서 연속 카운터(st.champ)가 짝수일 때는 thock 0.3(음높이 1.1~1.5)을 저음 층으로 한 번 더 깔았습니다. 소스 주석은 한 줄입니다. 대포 발사음: 또렷하게 + 묵직한 저음.
숫자로 옮기면 변화가 큽니다. 연사 속도는 fireRate(레벨) = 5.5 + 레벨×0.8 발/초이고 업그레이드 최대 레벨이 12 이므로 초당 5.5발에서 15.1발까지 나옵니다. 이전에는 확률 0.4 때문에 초당 평균 2.2번(최대 레벨에서 6.0번)만 울렸고, 지금은 5.5번(15.1번)입니다. 음량 진폭은 0.14 에서 0.42 로 정확히 3배, 데시벨로 약 9.5dB 입니다. 대장 병사가 나가는 발사에서는 powerup 음량도 0.35 에서 0.5 로 올리고 blast 0.35 를 새로 붙였습니다.
충돌음은 반대로 '매번'이 아니라 '몇 번에 한 번'으로 설계되어 있습니다. 병사 한 발에 세 명(volley 3)이 튀어나오고 충돌이 한꺼번에 많이 일어나므로, 충돌음은 하나뿐인 카운터 hitSnd 를 올려 가며 특정 배수일 때만 냅니다. 이 커밋에서 벽 충돌은 4번에 한 번(thock 0.35), 문 통과는 12번에 한 번 select 0.12 에서 0.2 로(증가형 문), 감산 문은 10번에 한 번 wrong 0.12 에서 0.2 로 올렸고, 병사끼리 부딪히는 pop 은 10번에 한 번 0.18 에서 6번에 한 번 0.3 으로, 기지에 닿는 hit 은 8번에 한 번 0.2 에서 0.3 으로 바꿨습니다. 대형 유닛이 죽는 explode 는 0.35 에서 0.5 입니다.
겹침 제어를 두 가지 방식으로 나눈 이유
두 게임의 겹침 제어는 방식이 다릅니다. 레오니스는 시간 간격(throttle)을 씁니다. SoundManager.play(이름, 음량, 음높이, 간격)는 같은 이름의 마지막 재생 시각을 performance.now() 로 비교해 간격 안의 요청을 버리고, 그 위에 ±10% 음높이 흔들림을 곱합니다. 전투 속도가 일정하지 않고 유닛 수가 수십 마리까지 오르내리는 게임이라, 몇 번에 한 번 방식으로는 유닛이 적을 때 소리가 영영 안 날 수 있기 때문입니다.
군단 점령전은 발사 간격이 이미 fireT 타이머로 제한되어 있어서(최대 초당 15.1발) 발사음에는 별도 제어가 없고, 제한되지 않은 충돌만 카운터로 솎아 냅니다. 이렇게 하면 충돌이 많아질수록 소리가 자연스럽게 촘촘해지고 적을 때는 한두 번씩 드문드문 들립니다. 대신 카운터 하나를 여러 종류의 충돌이 함께 쓰므로 어떤 소리가 나는지는 충돌이 섞이는 순서에 따라 달라집니다. 일부러 단순함을 택한 대신 정확한 주기는 포기한 것입니다.
여기서 얻은 규칙과 아직 확인하지 못한 것
이 작업에서 남은 규칙은 세 가지입니다. 발사처럼 플레이어가 직접 만드는 소리는 매번 내되 음량을 충분히 올리고, 충돌처럼 시스템이 쏟아 내는 소리는 간격이나 카운터로 솎습니다. 크기와 역할을 음높이로 구분해서 새 효과음을 만들지 않고도 챔피언 아홉 마리와 몬스터 덩치를 가릅니다. 긴장 연출은 템포, 저음 드론, 박동을 임계값(0.15·0.3·0.5)으로 나눠 쌓습니다.
이력으로 확인되지 않는 것도 적어 둡니다. 이 커밋들의 설명에는 음량을 이 숫자로 정한 청취 환경이나 비교 측정이 남아 있지 않습니다. 따라서 0.42 가 최적이라는 주장은 하지 않고, 이전 값(0.14·확률 40%)과 현재 값만 사실로 적었습니다. 값을 더 손보게 되면 같은 방식으로 이력에 남기겠습니다.