하모니카 앱 개발기 1편 - "소리가 두 개 나요"를 눈으로 확인하고 싶어서
이 글은 하모니카 연습 앱 개발기 시리즈의 1편입니다.
- 1편: "소리가 두 개 나요"를 눈으로 확인하고 싶어서 (지금 글)
- 2편: 숫자 악보, 조옮김, 에뮬레이터에서 잡은 버그 3개
선생님, 저 지금 제대로 불고 있나요?
요즘 하모니카를 배우고 있습니다. 24홀 트레몰로 하모니카인데, 초등학교 음악 시간에 불어 본 그 하모니카의 조금 더 큰 버전이라고 생각하면 됩니다.
레슨 때마다 선생님이 자주 하시는 말이 있습니다.
"지금 경음 나요. 소리가 두 개 나잖아요."
경음은 원하는 구멍 하나만 불어야 하는데 입이 옆 구멍까지 걸쳐서 두 음이 동시에 나는 상태를 말합니다. 문제는 제 귀에는 그게 잘 안 들린다는 겁니다. 부는 사람 입장에서는 소리가 하나로 뭉쳐서 들리고, 선생님 앞에서만 지적을 받으니 집에서 혼자 연습할 때는 제가 맞게 불고 있는지 알 방법이 없었습니다.
그래서 생각했습니다. 지금 내가 내는 소리가 무슨 음인지, 목표 음과 얼마나 차이가 나는지 화면에 계속 보여주면 혼자서도 확인할 수 있지 않을까?
앱이 이미 많은데 왜 직접 만들었나
당연히 스토어부터 뒤져 봤습니다. 튜너 앱도 많고 하모니카 앱도 꽤 있습니다. 그런데 막상 써 보니 제가 원하는 조합이 없었습니다.
| 필요한 것 | 일반 튜너 앱 | 하모니카 앱들 | 내가 원한 것 |
|---|---|---|---|
| 현재 음 실시간 표시 | O (C4, 440Hz 식) | 일부 | O |
| 숫자 악보(1 | X | 일부 | 둘 다 동시에 |
| 몇 번 홀인지 표시 | X | 일부 | O (24홀 기준) |
| 목표 음 대비 높고 낮음 | O | 거의 없음 | 막대 + cents |
| 악보 올려서 보면서 연습 | X | 거의 없음 | O |
| 디자인 | 기능 위주 | 광고 많음 | 한 화면에 깔끔하게 |
튜너 앱은 "C4, +5 cents"처럼 서양식 음이름만 보여줍니다. 그런데 한국에서 하모니카 악보는 대부분 숫자 악보로 돌아다닙니다. 5 5 6 6 5 5 3 같은 식이죠. 레슨에서도 "5번 불고 6번 마시고"처럼 숫자로 이야기합니다. 머릿속에서 C4를 "도, 1번 홀"로 한 번 더 번역해야 하는 건 연습할 때 꽤 거슬립니다.
결국 원하는 건 이 화면 하나였습니다.
현재 연주
1
도
Hole 1
+5 cents
낮음 ─────●───── 높음
숫자 + 계이름 + 홀 번호 + 음정 오차를 한 화면에서 동시에. 이게 없으니 만들기로 했습니다.
Step 1. 코드보다 작업지시서부터
요즘 제 개발 방식은 Claude Code와 같이 만드는 겁니다. 다만 "하모니카 앱 만들어 줘" 한 줄로 시작하면 결과물이 제 머릿속과 달라지기 쉬워서, 먼저 작업지시서를 마크다운으로 썼습니다. (tremolo_harmonica_practice_app_spec.md, 약 14KB)
작업지시서에 넣은 핵심 항목은 이렇습니다.
- 목표: 단순 튜너가 아니라 "학습/연습 앱"
- 최우선 순서: 악보·UI보다 실제 하모니카 소리를 안정적으로 감지할 수 있는지부터 검증
- 메인 메뉴: 자유 연주 / 음계 연습 / 악보 연주 / 내 악보 / 연습 기록 / 설정
- 하모니카 데이터: 홀 배열을 코드에 하드코딩하지 말 것
- Key 변경: C부터 B까지 12개, 숫자
1은 항상 현재 Key의 도 - 판정 기준: ±10 cents Excellent, ±25 Good, ±50 Warning, 그 이상 Wrong
- 구조: UI와 음정 감지/음악 이론/악보 분석 로직을 반드시 분리
특히 2번이 중요했습니다. 악보 화면을 예쁘게 만들어 놔도 음을 못 잡으면 의미가 없으니까요. "음이 잡히는지부터 확인하고 다음 단계로 간다"를 문서에 박아 두니, 작업 순서가 흔들리지 않았습니다.
기술 스택은 이렇게 정했습니다.
| 영역 | 선택 | 이유 |
|---|---|---|
| 프레임워크 | Flutter | Android 먼저, iOS 확장 가능 |
| 상태관리 | Riverpod (flutter_riverpod 3.x) | 서비스 교체가 쉬움 |
| 마이크 입력 | record | PCM 스트림을 바로 받을 수 있음 |
| 권한 | permission_handler | 마이크 권한 요청 |
| 악보 선택 | image_picker, file_picker | 사진/파일 업로드 |
| 저장 | shared_preferences | 악보·설정·연습 기록 로컬 저장 |
Step 2. 폴더 구조 - UI가 모르는 로직을 따로 둔다
완성된 구조는 이렇습니다.
lib/
├── core/ UI를 모르는 순수 로직
│ ├── audio/ 마이크 입력, PCM 변환, 프레임 버퍼
│ ├── pitch/ YIN 알고리즘, smoothing, 아이솔레이트 워커
│ ├── music/ Hz↔MIDI↔계이름 변환, 판정, 음계 문제
│ ├── practice/ 연습 진행 엔진 (정확도/자동 진행)
│ └── constants/
├── data/
│ ├── models/ 핵심 모델
│ ├── repositories/ 저장소 인터페이스
│ └── local/ SharedPreferences 기반 구현
├── features/ 화면
├── providers/ Riverpod 연결부
└── services/ 오디오·피치·하모니카·조옮김·악보 파서
core/와 services/는 Flutter 위젯을 하나도 import하지 않습니다. 덕분에 음정 감지, 계이름 변환, 조옮김 같은 로직을 마이크 없이 단위 테스트로 검증할 수 있었습니다. 하모니카를 매번 불어 가며 디버깅할 수는 없으니까요.
Step 3. 음정 감지 - 왜 FFT가 아니라 YIN인가
처음엔 "소리 → FFT → 가장 큰 주파수 = 음"이면 될 줄 알았습니다. 그런데 하모니카, 특히 트레몰로 하모니카는 이 방식이 잘 안 맞습니다.
트레몰로 하모니카는 한 구멍에 리드가 두 개 들어 있고, 두 리드를 일부러 아주 살짝 다른 주파수로 맞춰 둡니다. 그래서 "와우와우~" 하는 떨림(맥놀이)이 생기고, 이게 트레몰로 특유의 소리입니다. 여기에 배음(2배, 3배 주파수)도 강합니다. 그러면 FFT로는 기본음보다 2배음이 더 크게 잡혀서 한 옥타브 위로 오검출되는 일이 생깁니다.
그래서 YIN 알고리즘을 썼습니다. 전체 파이프라인은 이렇습니다.
마이크 → 프레임 버퍼(2048 / hop 1024) → Noise Gate(RMS)
→ YIN(별도 아이솔레이트) → 중앙값 + EMA smoothing
→ Hz → MIDI → 계이름/숫자 → cents
YIN의 핵심은 누적 평균 정규화 차분(CMND) 입니다. 파형을 조금씩 밀어 가며 자기 자신과 얼마나 다른지 계산하고, 그 값을 누적 평균으로 나눠서 "주기처럼 보이는 가짜 골짜기"를 눌러 줍니다. 진폭이 아니라 파형의 주기를 보기 때문에 맥놀이의 영향도 덜 받습니다.
/// 차분 함수와 누적 평균 정규화를 한 번에 계산한다.
Float64List _cumulativeMeanNormalizedDifference(
List<double> samples, int windowSize, int maxTau,
) {
final cmnd = Float64List(maxTau + 1);
cmnd[0] = 1.0;
var runningSum = 0.0;
for (var tau = 1; tau <= maxTau; tau++) {
var difference = 0.0;
for (var i = 0; i < windowSize; i++) {
final delta = samples[i] - samples[i + tau];
difference += delta * delta;
}
runningSum += difference;
cmnd[tau] = runningSum == 0 ? 1.0 : difference * tau / runningSum;
}
return cmnd;
}
실제로 쓴 파라미터는 이렇습니다.
| 파라미터 | 값 | 의미 |
|---|---|---|
threshold | 0.15 | CMND 임계값. 낮을수록 엄격 |
minFrequency | 130 Hz (C3) | 하모니카 음역 밖을 잘라 옥타브 아래 오검출 방지 |
maxFrequency | 2100 Hz (C7) | 탐색 상한 |
| 프레임 크기 / hop | 2048 / 1024 | 분석 창 크기와 이동 간격 |
그리고 한 가지 더. 정수 단위로 찾은 주기(tau)를 포물선 보간으로 소수점까지 다듬었습니다. 이게 없으면 고음에서 cents 오차가 수십 단위로 튀어서, 인디케이터가 맞게 불어도 정신없이 흔들립니다.
Step 4. UI가 끊기지 않게 - 아이솔레이트 분리
YIN은 프레임 하나에 곱셈을 수십만 번 합니다. 이걸 UI 스레드에서 돌리면 인디케이터 애니메이션이 뚝뚝 끊깁니다. 그래서 Dart의 Isolate(별도 실행 공간)로 분리했습니다.
여기서 고민한 게 하나 있습니다. 계산 중에 새 프레임이 들어오면 어떻게 할까?
- 큐에 쌓는다 → 계산이 밀리면 0.5초 전 소리를 지금 보여 주게 됨
- 버린다 → 가끔 한 프레임 건너뜀
실시간 연습에서는 늦은 정보가 틀린 정보보다 더 헷갈립니다. 그래서 계산 중에 도착한 프레임은 버리는 쪽을 택했습니다.
Step 5. 인디케이터가 덜덜 떨리지 않게 - Smoothing
감지 결과를 그대로 화면에 뿌리면 숫자가 파르르 떨립니다. PitchSmoother에서 두 단계로 다듬었습니다.
- 중앙값 필터 (최근 5프레임): 한두 프레임짜리 튀는 값 제거
- EMA (
alpha = 0.35): 남은 미세한 떨림을 부드럽게
그런데 EMA만 쓰면 부작용이 있습니다. 도에서 레로 넘어갈 때 인디케이터가 스르륵 미끄러지면서 중간에 "도#"를 잠깐 보여 줍니다. 그래서 80 cents 이상 벌어지면 음이 바뀐 것으로 보고 즉시 새 값으로 리셋하게 했습니다.
final previous = _smoothed;
if (previous == null ||
_centsBetween(previous, median).abs() > jumpThresholdCents) {
_smoothed = median; // 음이 바뀜: 즉시 점프
} else {
_smoothed = previous + emaAlpha * (median - previous); // 같은 음: 부드럽게
}
신뢰도가 0.5 미만이거나 3프레임 연속으로 소리가 없으면 상태를 초기화해서, 입을 뗐는데도 마지막 음이 화면에 남는 잔상도 없앴습니다.
그래서 경음은 잡히나?
솔직하게 말하면, 이 앱은 "지금 경음입니다"라고 딱 짚어 주는 기능은 아닙니다. YIN은 기본적으로 하나의 기본 주파수를 찾는 알고리즘이라, 두 음이 동시에 나면 그중 하나를 고르거나 신뢰도가 떨어집니다.
대신 그 증상이 화면에 드러납니다. 실제 하모니카로 불어 보니 이랬습니다.
- 경음이 잡힙니다. 옆 구멍까지 걸쳐 불면 화면에 바로 티가 나서, 귀로는 구분이 안 되던 "소리 두 개"를 눈으로 확인할 수 있었습니다.
- 지금 내는 음이 무슨 계이름인지 확실히 보입니다. 숫자·계이름·홀 번호가 한 화면에 떠 있으니 "지금 도를 불고 있다"를 바로 알 수 있습니다.
"소리가 두 개 난다"를 귀로는 못 들어도 화면을 보고 입 모양을 고칠 수 있게 된 겁니다. 제가 원했던 게 바로 이거였습니다.
다만 써 보니 불편한 점이 하나 있었습니다. 연습하는 도중에 화면이 자꾸 꺼집니다. 하모니카를 부는 동안에는 폰을 만지지 않으니, 폰 입장에서는 아무도 안 쓰는 것처럼 보여서 자동 잠금 시간이 지나면 화면이 꺼지는 거죠. 두 손으로 하모니카를 잡은 채 화면을 다시 켜는 건 꽤 번거롭습니다. 다음 작업 때 연습 화면에서는 화면이 계속 켜져 있도록 업데이트할 예정입니다.
한 가지 헷갈리지 말아야 할 점도 있습니다. 트레몰로 하모니카의 "와우와우" 떨림은 한 구멍 안의 리드 두 개가 만드는 정상적인 소리입니다. 선생님이 말하는 경음은 옆 구멍까지 같이 부는 것이고요. 앱을 만들면서 이 차이를 처음 제대로 이해했습니다.
합성 신호로 검증하기
매번 하모니카를 불 수는 없어서, 테스트에서는 가짜 하모니카 소리를 만들어 넣었습니다.
- 기본음 + 배음 4개
- 살짝 다른 주파수의 리드 2개 (맥놀이 재현)
- 잡음
이 신호를 C3~C7 전 음역에 대해 만들어서 YIN이 옥타브를 틀리지 않는지 확인합니다. (test/core/pitch/yin_pitch_detector_test.dart)
flutter test
정리
| 단계 | 한 일 | 핵심 |
|---|---|---|
| 문제 | 경음 지적을 받는데 스스로 확인할 방법이 없음 | 소리를 눈으로 보고 싶다 |
| 기획 | 작업지시서 먼저 작성 | 음 감지 검증이 최우선 |
| 구조 | core/·services/를 UI와 분리 | 마이크 없이 단위 테스트 |
| 감지 | FFT 대신 YIN + 포물선 보간 | 배음·맥놀이로 인한 옥타브 오류 억제 |
| 성능 | 아이솔레이트 + 밀린 프레임 버리기 | 늦은 정보보다 건너뛰는 게 낫다 |
| 표시 | 중앙값 + EMA + 80 cents 점프 리셋 | 안정적이면서 반응 빠르게 |
| 실사용 | 실제 하모니카로 테스트 | 경음과 현재 계이름 확인 OK, 화면 꺼짐은 다음 과제 |
다음 편에서는 이 앱의 두 번째 목적인 악보 업로드, 한국식 숫자 악보를 텍스트로 입력하는 파서, Key를 바꿔도 전부 따라오게 만든 데이터 설계, 그리고 에뮬레이터에서 잡은 Riverpod 버그 3개를 정리하겠습니다.
backtodev
40대 PM, 다시 개발자로 돌아갑니다. 실패하고 배우며 성장하는 기록.