DEV NOTE · ECHO

샘플 없이 만드는 공포: ECHO의 Web Audio 사운드 합성

ECHO에는 오디오 파일이 하나도 없다. 핑, 숨소리, 심장 박동, 동굴의 울림까지 전부 Web Audio API의 오실레이터와 노이즈 버퍼로 실시간 합성한다. 2.0에서 사운드를 전면 개편했고, 2.0.1에서 "더 무섭게, 돌발음은 줄여라"라는 피드백을 받아 다시 조정했다. 이 글은 그 과정에서 정한 신호 경로와 수치, 그리고 왜 그렇게 정했는지를 기록한다.

모든 소리가 지나가는 길

합성 사운드는 쉽게 "전자음"처럼 들린다. 같은 소리라도 동굴 안에서 울리는 것처럼 들리게 하는 것이 첫 번째 과제였다. audio.js의 모든 소리는 같은 길을 거친다. 개별 소리는 스테레오 패너를 지나 dry 버스로 가고, 동시에 컨볼루션 잔향으로도 보내진다. 두 갈래가 master에서 합쳐진 뒤 마지막에 저역통과 필터(muffle)를 지나 스피커로 나간다.

잔향에 쓰는 임펄스 응답도 녹음이 아니라 코드로 만든다. 길이 2.8초의 스테레오 노이즈를 (1−t)^2.6 곡선으로 감쇠시키고, 약 71ms마다 짧은 노이즈 덩어리를 덧붙여 벽에서 튕겨 나오는 초기 반사를 흉내 낸다. 왼쪽과 오른쪽 채널의 크기를 약간 다르게 두어 공간이 넓게 느껴지게 했다. 잔향 출력은 0.38배로 섞인다. 소리마다 잔향으로 보내는 양을 따로 정할 수 있어서, 핑과 돌발음은 깊게 울리고 발소리와 UI 클릭은 거의 마른 소리로 남는다.

마지막 단의 muffle 필터는 평소엔 20kHz로 열려 있어 아무 일도 하지 않는다. 이 필터는 죽는 순간을 위해 있다. 잡히면 0.3초 만에 260Hz까지 닫혀서 모든 소리가 물속처럼 먹먹해지고, 그 위로 노이즈 폭발과 38Hz까지 내려가는 저음이 깔린다. 다음 런을 시작할 때 다시 20kHz로 연다.

핑: 화면과 귀가 같은 시각을 가리키게

핑은 1250Hz에서 500Hz로 0.4초 동안 내려가는 사인파 스윕이다. 여기에 벽에서 돌아오는 메아리를 덧붙인다. 시뮬레이션이 핑을 계산할 때 시야가 닿는 벽 타일까지의 거리를 모으는데, 그중 서로 1.2칸 이상 떨어진 가까운 거리 네 개를 골라 오디오로 넘긴다.

// audio.js 발췌: 거리 d마다 지연된 메아리
for(const e of (echoes||[])){ const d=e.d;
  tone(Math.max(220,900-d*45),{type:'triangle',dur:.1,
    vol:Math.min(.14,.22/(1+d*.35)), delay:d/14,
    pan:(Math.random()*2-1)*.5, wet:1.8}); }

지연 시간 d/14는 화면에서 파면이 그 벽에 도착하는 시각과 같다. 파면 속도가 초당 14칸이기 때문이다. 그래서 벽 윤곽이 켜지는 순간 그 방향에서 "톡" 하는 메아리가 들린다. 먼 벽일수록 소리가 낮고 작아진다. 화면을 보지 않고도 주변이 좁은지 넓은지 대략 짐작할 수 있는데, 에코로케이션이라는 이름에 걸맞은 부분이라고 생각한다.

긴장도 하나로 움직이는 배경음

공포감은 대부분 배경음에서 나온다. ECHO의 배경은 41Hz 사인파와 82.6Hz 삼각파, 2.0.1에서 추가한 27.5Hz 서브 베이스를 160Hz 저역통과 필터로 묶은 드론이다. 0.11Hz의 느린 LFO가 음량을 흔들어 숨 쉬는 것처럼 들린다.

이 드론을 움직이는 값은 "긴장도" 하나다. 매 프레임 가장 가까운 사냥꾼까지의 거리를 받아 1 − 거리/9로 0~1 값을 만들고, 어떤 사냥꾼도 경계 상태가 아니면 0.55를 곱한다. 이 목표값을 향해 긴장도가 초당 1.5 비율로 천천히 따라간다. 긴장도가 1에 가까워지면 드론 피치가 4.5Hz 내려가고, 필터가 420Hz 더 열려 거친 배음이 드러나고, 음량이 0.12 커진다. 사냥꾼이 다가올수록 바닥이 가라앉는 듯한 소리가 된다.

2.0.1에서 같은 긴장도에 묶인 층을 두 개 더 얹었다. 하나는 1.9kHz 대역 노이즈를 계속 흘리는 "속삭임"으로, 음량이 긴장도의 1.6제곱에 비례한다. 제곱을 쓴 이유는 멀리 있을 땐 거의 안 들리다가 가까워지면 급격히 커지게 하려는 것이다. 필터 중심 주파수는 0.37Hz로 ±600Hz씩 흔들려 무언가 중얼거리는 것처럼 들린다. 다른 하나는 손전등을 껐을 때만 깔리는 5.2kHz 고역 히스다. 음량은 0.022로 아주 작지만, 빛을 끄는 순간 귀가 예민해지는 느낌을 준다.

심장 박동은 어떤 사냥꾼이 경계 중이고 가장 가까운 사냥꾼이 7칸 안에 있을 때 시작한다. 속도는 거리에 따라 60bpm에서 140bpm까지 올라가고, 한 박은 52Hz와 48Hz 두 음을 박자의 30% 간격으로 겹쳐 "쿵-쿵"을 만든다. 빨라질수록 음량이 커지고, 절반을 넘으면 가끔 헐떡이는 노이즈가 섞인다. 사운드를 켜고 플레이하면 이 박동이 사실상 가장 정확한 위험 경보 역할을 한다.

사냥꾼마다 다른 목소리

두 종류의 사냥꾼은 생김새만큼 소리도 다르다. 귀 사냥꾼은 순찰 중 0.5초짜리 대역 노이즈에 느린 어택을 줘서 숨을 들이쉬는 소리를 내고, 그 아래 62Hz 둔탁한 울림을 깐다. 경계 상태가 되면 숨이 짧고 거칠어지며 사각파 펄스가 섞인다. 눈 사냥꾼은 2.6kHz 근처의 아주 짧은 클릭을 세 번(경계 시 다섯 번) 연달아 내는 딸깍거림이다. 귀 사냥꾼의 소리가 낮고 무겁다면 눈 사냥꾼의 소리는 높고 날카롭다. 화면 밖에 있어도 소리만으로 어느 쪽인지 구분할 수 있게 한 것이다.

모든 사냥꾼 소리는 플레이어 기준 좌우 거리를 7칸으로 나눈 값으로 패닝되고, 거리가 멀수록 작아진다. 경계에 들어서는 순간의 소리도 다르다. 귀 사냥꾼은 상승하는 톱니파와 46Hz 으르렁거림을, 눈 사냥꾼은 520Hz에서 위로 치솟는 비명 두 겹을 낸다.

ECHO의 어두운 3D 던전 화면. 랜턴 불빛 주변만 밝고 나머지는 거의 검다
화면 대부분이 어둡기 때문에 소리가 정보의 절반을 맡는다.

돌발음은 드물어야 무섭다

2.0.0에는 12~25초마다 물방울이나 돌 구르는 소리를 무작위 방향에서 내는 환경 돌발음이 있었다. 실제 위협은 아니고 긴장을 유지하려는 장치였다. 그런데 이 간격이면 한 층을 내려가는 동안 여러 번 울리게 되고, 자주 나는 소리는 결국 배경음처럼 묻힌다. 사용자 피드백도 "더 무섭게, 돌발음은 줄여라"였다.

2.0.1에서는 간격을 28~60초로 두 배 이상 늘리고, 대신 종류를 다섯 가지로 늘려 하나하나를 더 불쾌하게 만들었다. 새로 추가한 것 중 하나는 "귀 바로 옆 숨소리"다. 다른 돌발음은 좌우 ±0.9 범위의 무작위 위치에서 나지만, 이 숨소리는 패닝을 ±0.95 끝으로 몰아서 한쪽 귀에만 들린다. 이어폰으로 들으면 누가 옆에 서 있는 것 같은 느낌이 든다. 나머지는 어둠 속에서 무언가 긁는 소리(짧은 노이즈 네 번), 높은 사인 스윕으로 만든 물방울, 저역 노이즈의 돌 구르는 소리, 2.2초에 걸친 먼 신음(톱니파)이다. 설계 문서에는 이 결정을 "드물수록 무섭다, 자주 나면 배경음이 된다"라는 한 줄로 남겨 두었다.

손전등 스위치에도 소리를 붙였다. 켤 때는 금속 클릭과 70Hz의 짧은 쿵, 끌 때는 클릭과 60Hz로 내려가는 저음이다. 끄는 소리를 더 길고 낮게 만들어, 어둠으로 들어가는 쪽이 더 불안하게 들리도록 했다.

정리

ECHO의 사운드는 수십 줄짜리 합성 함수 두 개(tone, noise)와 공용 잔향 하나로 만든다. 무섭게 만드는 핵심은 개별 소리의 품질보다 "언제 들리는가"였다. 핑 메아리는 화면의 파면과 같은 시각에, 드론과 심장 박동은 실제 사냥꾼 거리에, 돌발음은 익숙해지지 않을 만큼 드물게 울린다. 다음 과제는 2.0.3에서 추가한 긴장도 NaN 방어처럼 장시간 플레이에서 오디오 그래프가 안정적인지 확인하고, 돌발음 간격이 실제 플레이 시간 대비 적절한지 측정하는 것이다.