Guitar Auto-Strum의 코드 반주는 녹음된 기타 소리를 재생하지 않습니다. 매번 줄이 어떻게 진동하는지를 계산해서 파형을 만듭니다. 이 글은 그 소리를 만드는 코드(web-app/js/engine/string-voice.js)가 어떤 생각으로 짜였는지, 그리고 이전 방식이 왜 막다른 길이었는지를 정리합니다. 신호처리 지식이 없어도 따라올 수 있도록 개념 위주로 썼고, 수치는 모두 코드 주석에 적힌 측정값입니다.
처음 방식: 사인파 일곱 개
처음 만든 소리는 사인파 부분음 일곱 개를 더하고 지수적으로 줄어들게 한 것이었습니다. 기타 음은 기본 진동수와 그 정수배 진동수들의 합이라는 사실에서 나온 자연스러운 접근입니다. 그런데 이 모델은 강철 줄(스틸 기타)과 나일론 줄(클래식 기타)을 구별하지 못했습니다. 코드 주석이 이유를 측정값으로 적어 두었습니다.
- 부분음이 정확한 정수배라서 비조화성(inharmonicity)이 0이었다.
- 부분음이 일곱 개뿐이라 상한이
7 × f0이다. 가장 낮은 E줄에서 574Hz밖에 안 된다. - 악기 사이 차이를 만드는 것이 부분음 2~6에 곱하는 숫자 하나뿐이었다.
- 2kHz 위에 에너지의 0.03%만 있었다.
주석의 결론은 이렇습니다(번역). “여기서 출발하면 상수를 아무리 조정해도 금속성 줄에 닿을 수 없다.” 상수를 다듬는 문제가 아니라 모델의 구조가 틀렸다는 진단이었습니다.
바꾼 방식: 줄을 하나의 고리로 본다
새 방식은 줄에서 일어나는 일을 그대로 흉내 냅니다. 줄을 튕기면 파동이 줄을 따라 왕복하고, 한 바퀴 돌 때마다 조금씩 에너지를 잃습니다. 이것을 지연선(delay line) 하나가 고리로 이어진 구조로 표현합니다(Karplus-Strong 방식, 도파관 합성이라고도 합니다).
┌──────── 지연선 (줄의 길이) ────────┐
│ │
[줄에 든 잡음] → 손실 필터 → 분산 올패스 → 튜닝 올패스 → (한 바퀴 돌아옴)
│
└─→ 몸통 공명 → 출력
고리를 도는 동안 무엇이 일어나는지가 곧 소리의 성격이 됩니다. 코드 주석은 네 요소가 각각 무엇을 하는지 적었습니다.
| 요소 | 하는 일 | 소리에 미치는 영향 |
|---|---|---|
| 넓은 대역의 잡음으로 시작 | 줄을 튕긴 순간의 에너지 | 고음 성분이 아예 존재하게 한다 |
| 손실 필터 | 고리를 한 바퀴 돌 때마다 높은 소리를 더 깎는다 | 강철(밝음)과 나일론(어두움)의 차이. 음이 울릴수록 두 악기의 차이가 커진다 |
| 분산 올패스 | 뻣뻣한 줄일수록 위쪽 부분음이 정수배보다 살짝 높아진다 | 금속성으로 들리는 원인 |
| 튕긴 위치의 빗살 필터 | 튕긴 자리에 마디가 있는 배음을 지운다 | 신디사이저 같은 “부저” 소리를 막는다 |
계산 비용도 이전보다 작습니다. 주석은 “샘플마다 sin()을 여덟 번 부르는 것보다 훨씬 싸다”고 적었습니다.
강철과 나일론은 필터 하나로 갈린다
악기 차이를 만드는 것은 프로파일(profile)의 몇 가지 숫자입니다. 손실 필터의 계수는 프로파일의 brightness에서 나옵니다.
const brightness = clamp01(profile.brightness + velocity * 0.06); // 세게 칠수록 밝다
const s = 0.5 - brightness * 0.42; // 손실 필터 계수
s가 클수록 고리를 돌 때 높은 소리가 빨리 죽습니다. 이 필터는 “0 주파수에서는 이득이 1”이 되도록 짜여 있어서, 기본음이 얼마나 오래 울리는지는 필터가 아니라 고리 이득(loopGain)이 정합니다. 즉 “얼마나 오래 울리나”와 “얼마나 빨리 어두워지나”를 따로 조절할 수 있습니다.
분산(뻣뻣함)은 일부러 작게 두었습니다. 주석은 이렇게 설명합니다(번역). “기타는 피아노가 아니다. 측정하면 9번째 부분음이 강철에서 약 0.5센트, 나일론에서 0.2센트 높아지고, 실제 강철 줄도 한 자리 수 안쪽이다. 두 악기의 차이를 만드는 것은 이쪽이 아니라 손실 필터이므로, 줄이 음이 어긋난 것처럼 들릴 만큼 부풀리지 않았다.”
그 밖에 튕기는 세기(velocity)가 세면 잡음의 높은 소리를 더 남기고(더 밝은 음), 소리의 마지막은 40ms에 걸쳐 줄여서 버퍼가 끝나는 곳에서 딱 소리가 나지 않게 합니다. 튕긴 뒤의 몸통 울림은 2극 공진기로 섞습니다(기타 종류마다 공진 주파수와 Q가 다릅니다).
어려운 부분: 음이 정확히 맞아야 한다
이런 고리 합성에서 가장 어려운 것은 음정입니다. 지연선의 길이는 정수 샘플뿐이므로, 소수 부분은 별도의 필터(튜닝 올패스)로 맞춰야 합니다. 그런데 고리 안의 모든 필터가 각자 지연을 더합니다. 손실 필터, 분산 필터, 튜닝 필터가 모두 시간을 잡아먹기 때문에 전체 지연이 원하는 주기와 같아지도록 예산을 나눠야 합니다.
const remaining =
loopSamples - oneZeroPhaseDelay(s, w0) - DISPERSION_STAGES * dispPhase;
let lineLength = Math.floor(remaining) - 1;
const frac = remaining - lineLength;
const tune = allpassForDelay(frac, w0);
각 필터의 지연은 그 음의 진동수에서 계산합니다. 교과서에 나오는 식 (1-c)/(1+c)는 0Hz에서만 맞는 근사입니다. 주석의 측정으로는, 그 근사로 예산을 세우면 높은 음에서 음정이 약 0.8센트 높게 나옵니다. 정확한 식을 써도 비용이 같으므로 정확한 식을 썼습니다. 주석의 표현은 “기타 앱이 음정에서는 변명할 수 없다”입니다.
분산 필터의 단수도 코드 주석에 비교 기록이 있습니다. 주석에 따르면 네 단과 한 단 모두 9번째 부분음의 이동량이 0.4센트였고, 단수를 늘리면 파형의 주기성은 더 낮아졌습니다. 그래서 현재 코드는 한 단(DISPERSION_STAGES = 1)을 사용합니다. 이 수치는 주석에 남은 비교 결과이며, 이번 검토에서 다시 측정한 결과는 아닙니다.
잡음도 결정적이어야 한다
줄을 튕길 때 쓰는 잡음은 난수입니다. 그런데 이 난수는 같은 입력에 같은 결과가 나오도록 시드를 고정한 간단한 생성기(Park-Miller)로 만듭니다.
const rnd = noise(f0 * 1000 + velocity * 7919 + 1);
고정 시드를 사용하면 같은 입력에서 같은 파형을 재현할 수 있고, 파형 스냅샷을 정확히 비교할 수 있습니다. 예약 취소 뒤 재계산할 때 이벤트 시각을 고정하는 휴머나이즈와는 검사 대상이 다릅니다. 이 규칙은 스윙과 휴머나이즈를 다루는 코드와 같은 원칙입니다.
순수 함수라서 귀 없이 측정한다
renderPluck은 진동수, 세기, 프로파일, 길이, 샘플레이트를 받아 Float32Array 하나를 돌려주는 순수 함수입니다. 오디오 장치도, 시계도, 브라우저 객체도 필요 없습니다. 그래서 소리를 Node에서 그대로 측정할 수 있습니다(test/timbre-assert.mjs). 이 테스트가 확인하는 것은 두 종류입니다.
어떤 엔진이든 지켜야 하는 성질
- 같은 입력이면 파형이 완전히 같다.
- 모든 표본이 유한하고 세기 1.0에서도 [-1, 1] 안에 있다.
- 세게 칠수록 크다(모든 음높이, 모든 악기).
- 먹은(muted) 줄은 짧고 음이 있는 소리보다 작다.
- 음정이 3센트 안이다. 여섯 개의 개방현과 440Hz, 659.26Hz에서 측정합니다. 주석은 현재 엔진이 개방현에서 1.5센트 안에 든다고 적었습니다.
- 강철이 나일론보다 밝다. 95% 에너지가 들어가는 주파수(롤오프) 같은 “밝다”를 뜻하는 여러 지표에서, 낮은 E·A3·높은 e 세 음높이에서 확인합니다.
소리가 바뀌지 않았다는 증거(골든 스냅샷)
세 프로파일(steel, nylon, electric)에 음높이·세기 조합을 돌려서 파형의 지문(표본 몇 개와 합계)과 스펙트럼 지표를 파일에 기록합니다. 지문은 허용 오차 0으로 비교합니다. 주석의 이유는 “양쪽 모두 결정적인 부동소수점 계산이므로, 허용 오차를 두면 잡으려는 바로 그 변화를 가린다”입니다.
이 테스트가 생기기 전에는 “소리 자체에는 검증이 전혀 없었다”고 적혀 있습니다. 다른 테스트는 가짜 신디사이저로 언제 음이 예약되는지만 검사하므로, 신디사이저를 통째로 바꿔도 나머지 테스트가 전부 통과할 수 있었기 때문입니다. 이 골든 테스트에 대한 이야기는 별도의 글에 이어집니다.
모델에서 빠진 실제 기타의 소리
- 실제 기타는 아닙니다. 줄 하나의 진동과 몸통 공명 하나를 흉내 낼 뿐, 줄 사이의 공명, 피크와 손가락의 세밀한 차이, 프렛 소음 같은 것은 없습니다.
- 먹은 줄은 단순합니다. 음이 없는 “탁” 소리는 잡음과 낮은 진동 하나로 만든 흉내입니다.
- 프로파일 값을 고른 과정은 확인이 필요합니다.
brightness,stiffness등의 수치와 측정 설명은 코드에 있지만, 최종값을 귀로 골랐는지 녹음과 비교했는지는 코드로 확인할 수 없습니다.