노래 연습을 하다 보면 “지금 음이 낮은가, 높은가”를 귀로만 판단하기 어렵습니다. Vocal Check는 이 질문 하나에 답하려고 만든 도구입니다. 마이크로 들어온 목소리의 높이를 계산해 음이름(예: A4), 기준음과의 차이(센트), 최근 12초 동안의 움직임을 그래프로 보여 줍니다.
이 글에서는 브라우저 안에서 소리 → 주파수 → 음이름으로 바꾸는 과정을 실제 서비스 코드 기준으로 정리합니다. 서버로 소리를 보내지 않고 모든 계산을 탭 안에서 끝내는 구조라, 알고리즘 자체가 가볍고 안정적이어야 했습니다.
1. 마이크 소리를 숫자 배열로 받기
Web Audio API에서 마이크 입력은 getUserMedia로 받은 스트림을 AudioContext에 연결해 다룹니다. Vocal Check는 AnalyserNode의 fftSize를 4096으로 두고, getFloatTimeDomainData()로 시간 영역 샘플 4096개를 그대로 가져옵니다. 48kHz 기준으로 약 85ms 분량입니다.
여기서 중요한 선택이 하나 있습니다. 브라우저는 통화 품질을 위해 기본적으로 에코 제거, 잡음 억제, 자동 음량 조절을 켭니다. 말소리에는 좋지만 음정 측정에는 방해가 됩니다. 잡음 억제는 길게 끄는 모음을 “잡음”으로 보고 깎아 내기도 하고, 자동 음량 조절은 신호 크기를 계속 바꿉니다. 그래서 먼저 세 옵션을 모두 끈 조건으로 마이크를 요청하고, 기기가 이를 거부하면 기본 조건으로 한 번 더 요청합니다.
const strict = { audio: { echoCancellation: false, noiseSuppression: false, autoGainControl: false }, video: false };
try { return { stream: await navigator.mediaDevices.getUserMedia(strict), relaxed: false }; }
catch { return { stream: await navigator.mediaDevices.getUserMedia({ audio: true, video: false }), relaxed: true }; }
측정 루프는 requestAnimationFrame으로 돌지만 계산은 65ms마다 한 번만 합니다. 화면은 매 프레임 부드럽게 그리되, 계산 비용이 큰 피치 검출은 초당 15회 정도로 제한하는 절충입니다.
2. 왜 FFT가 아니라 YIN인가
주파수를 구한다고 하면 먼저 FFT(고속 푸리에 변환)가 떠오릅니다. 하지만 사람 목소리에는 기본 주파수 외에 배음이 강하게 섞여 있어서, 스펙트럼에서 가장 큰 봉우리가 기본음이 아니라 2배음이나 3배음인 경우가 흔합니다. 그러면 한 옥타브 이상 틀린 값이 나옵니다. 게다가 4096개 샘플의 FFT 해상도는 약 11.7Hz라, 저음역에서는 반음 차이도 구분하기 어렵습니다.
YIN은 2002년 de Cheveigné와 Kawahara가 발표한 시간 영역 알고리즘입니다. “신호를 얼마만큼 밀었을 때 자기 자신과 가장 닮는가”를 찾아 주기를 구하고, 주기의 역수로 주파수를 얻습니다. 배음이 섞여도 주기는 바뀌지 않기 때문에 옥타브 오류에 강합니다.
3. 단계별 구현
3-1. 너무 조용하면 계산하지 않는다
먼저 샘플의 평균을 빼고 RMS(제곱평균제곱근)를 구합니다. RMS가 0.008 미만이면 “소리가 없다”고 보고 바로 끝냅니다. 조용한 방의 잡음에서 억지로 음정을 찾으면 화면이 무작위로 흔들리기 때문입니다. 이 값은 입력 레벨 막대에도 그대로 쓰입니다.
3-2. 탐색 범위: 60Hz – 1200Hz
지연(lag) τ의 범위는 찾으려는 주파수 범위에서 나옵니다. 가장 낮은 60Hz는 τ = 샘플레이트 / 60, 가장 높은 1200Hz는 τ = 샘플레이트 / 1200입니다. 남성 저음부터 소프라노 고음, 휘파람 일부까지 들어가는 범위입니다. 범위를 좁힐수록 계산은 줄지만 연습 대상이 줄어들기 때문에 이 정도로 정했습니다.
3-3. 차이 함수와 누적 평균 정규화
각 τ에 대해, 신호와 τ만큼 민 신호의 차이 제곱합 d(τ)를 구합니다. 주기와 같은 τ에서 d(τ)는 0에 가까워집니다. 문제는 τ가 커질수록 d(τ)가 자연스럽게 작아지는 경향이 있다는 점입니다. YIN은 이를 누적 평균 정규화로 보정합니다.
d’(τ) = d(τ) × τ / Σ(1..τ) d(j)
이렇게 하면 “지금까지의 평균보다 얼마나 작은가”를 보게 되어, 짧은 τ에서 생기는 가짜 최솟값을 피할 수 있습니다.
for (let lag = 1; lag <= maxLag; lag++) {
let sum = 0;
for (let i = 0; i < size; i++) { const delta = buffer[i] - buffer[i + lag]; sum += delta * delta; }
cumulative += sum;
difference[lag] = cumulative ? sum * lag / cumulative : 1;
}
3-4. 절대 임계값과 극솟값 따라가기
정규화된 값이 처음으로 0.13보다 작아지는 τ를 찾습니다. 전역 최솟값 대신 “처음으로 충분히 작은 값”을 고르는 것이 YIN의 핵심입니다. 두 배 주기(한 옥타브 아래)에서도 값이 작아지기 때문에, 가장 작은 값을 고르면 옥타브 오류가 다시 생깁니다. 임계값을 넘은 뒤에는 값이 계속 줄어드는 동안 τ를 한 칸씩 옮겨 그 골짜기의 바닥을 찾습니다.
3-5. 포물선 보간으로 소수점 τ 구하기
τ는 정수라서 그대로 쓰면 해상도가 부족합니다. 48kHz에서 A4(440Hz)의 주기는 약 109.1샘플인데, 109와 110은 각각 약 440.4Hz와 436.4Hz라 16센트 가까이 벌어집니다. 그래서 바닥 τ와 양옆 세 점으로 포물선을 그려 꼭짓점 위치를 소수점까지 계산합니다.
const a = difference[lag - 1], b = difference[lag], c = difference[lag + 1];
const denominator = 2 * (2 * b - a - c);
const refined = lag + (denominator ? (c - a) / denominator : 0);
const frequency = sampleRate / refined;
4. 주파수를 음이름과 센트로 바꾸기
평균율에서 MIDI 번호 m의 주파수는 f = 기준음 × 2^((m − 69) / 12)입니다. 거꾸로 주파수에서 MIDI 번호를 구하면 m = 69 + 12 × log2(f / 기준음)이 되고, 이 값은 소수입니다. 가장 가까운 정수가 음이름이고, 차이에 100을 곱하면 센트입니다(반음 = 100센트).
기준음은 440Hz가 기본이지만, 합창단이나 악기에 따라 442Hz나 432Hz를 쓰기도 해서 세 값을 고를 수 있게 했습니다. 목표음 모드에서는 가장 가까운 음 대신 사용자가 고른 목표음과 비교하고, ±5센트 안에 들어오면 “좋아요” 상태로 표시합니다.
5. 화면을 흔들리지 않게 하는 장치들
알고리즘이 맞아도 결과를 그대로 보여 주면 숫자가 너무 튑니다. 실제 서비스에서 효과가 컸던 장치는 세 가지입니다.
- 최근 3개 값의 중앙값: 평균이 아니라 중앙값을 쓰면, 한 번씩 튀는 옥타브 오류가 화면에 나타나지 않습니다.
- 450ms 유지: 숨을 쉬거나 자음을 발음하는 짧은 순간에 값이 사라져도 0.45초 동안은 마지막 읽기를 유지합니다. 그 뒤에는 “소리를 기다리는 중” 상태로 돌아갑니다.
- 12초 기록 그래프: 순간값보다 흐름을 보는 편이 연습에 도움이 됩니다. 소리가 없는 구간은 선을 끊어서, 음이 이어진 구간만 선으로 보이게 했습니다.
6. 개인정보를 지키는 구조
Vocal Check는 녹음 파일을 만들지 않고 소리를 서버로 보내지 않습니다. 메모리에 남는 것은 최근 12초의 음정 숫자뿐입니다. 또 탭이 백그라운드로 가면(visibilitychange) 측정을 멈추고 마이크를 끕니다. 사용자가 탭을 잊고 떠났을 때 마이크가 켜진 채로 남지 않게 하기 위해서입니다.
정리
| 단계 | 값 | 이유 |
|---|---|---|
| 분석 창 | 4096 샘플 | 60Hz 저음의 주기를 두 번 이상 담기 위해 |
| 계산 주기 | 65ms | 반응성과 CPU 사용의 균형 |
| 무음 기준 | RMS 0.008 | 잡음에서 가짜 음정 방지 |
| YIN 임계값 | 0.13 | 옥타브 오류와 누락 사이의 절충 |
| 안정화 | 3개 중앙값, 450ms 유지 | 튀는 값 제거, 끊김 완화 |
피치 검출은 오래된 주제지만, 실제 사용자 기기에서 잘 동작하게 만드는 일은 알고리즘 바깥의 결정이 더 많았습니다. 마이크 처리 옵션, 계산 빈도, 화면 안정화, 권한 해제 시점 같은 결정입니다. 직접 목소리로 확인해 보고 싶다면 Vocal Check에서 마이크 없이 데모부터 볼 수 있습니다.