FlutterRiverpodYINPitch Detection하모니카

하모니카 앱 개발기 1편 - "소리가 두 개 나요"를 눈으로 확인하고 싶어서

September 24, 20268 min read🌐 Only Korean available

이 글은 하모니카 연습 앱 개발기 시리즈의 1편입니다.

  • 1편: "소리가 두 개 나요"를 눈으로 확인하고 싶어서 (지금 글)
  • 2편: 숫자 악보, 조옮김, 에뮬레이터에서 잡은 버그 3개

선생님, 저 지금 제대로 불고 있나요?

요즘 하모니카를 배우고 있습니다. 24홀 트레몰로 하모니카인데, 초등학교 음악 시간에 불어 본 그 하모니카의 조금 더 큰 버전이라고 생각하면 됩니다.

레슨 때마다 선생님이 자주 하시는 말이 있습니다.

"지금 경음 나요. 소리가 두 개 나잖아요."

경음은 원하는 구멍 하나만 불어야 하는데 입이 옆 구멍까지 걸쳐서 두 음이 동시에 나는 상태를 말합니다. 문제는 제 귀에는 그게 잘 안 들린다는 겁니다. 부는 사람 입장에서는 소리가 하나로 뭉쳐서 들리고, 선생님 앞에서만 지적을 받으니 집에서 혼자 연습할 때는 제가 맞게 불고 있는지 알 방법이 없었습니다.

그래서 생각했습니다. 지금 내가 내는 소리가 무슨 음인지, 목표 음과 얼마나 차이가 나는지 화면에 계속 보여주면 혼자서도 확인할 수 있지 않을까?

앱이 이미 많은데 왜 직접 만들었나

당연히 스토어부터 뒤져 봤습니다. 튜너 앱도 많고 하모니카 앱도 꽤 있습니다. 그런데 막상 써 보니 제가 원하는 조합이 없었습니다.

필요한 것일반 튜너 앱하모니카 앱들내가 원한 것
현재 음 실시간 표시O (C4, 440Hz 식)일부O
숫자 악보(17) / 계이름(도시)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)

작업지시서에 넣은 핵심 항목은 이렇습니다.

  1. 목표: 단순 튜너가 아니라 "학습/연습 앱"
  2. 최우선 순서: 악보·UI보다 실제 하모니카 소리를 안정적으로 감지할 수 있는지부터 검증
  3. 메인 메뉴: 자유 연주 / 음계 연습 / 악보 연주 / 내 악보 / 연습 기록 / 설정
  4. 하모니카 데이터: 홀 배열을 코드에 하드코딩하지 말 것
  5. Key 변경: C부터 B까지 12개, 숫자 1은 항상 현재 Key의 도
  6. 판정 기준: ±10 cents Excellent, ±25 Good, ±50 Warning, 그 이상 Wrong
  7. 구조: UI와 음정 감지/음악 이론/악보 분석 로직을 반드시 분리

특히 2번이 중요했습니다. 악보 화면을 예쁘게 만들어 놔도 음을 못 잡으면 의미가 없으니까요. "음이 잡히는지부터 확인하고 다음 단계로 간다"를 문서에 박아 두니, 작업 순서가 흔들리지 않았습니다.

기술 스택은 이렇게 정했습니다.

영역선택이유
프레임워크FlutterAndroid 먼저, iOS 확장 가능
상태관리Riverpod (flutter_riverpod 3.x)서비스 교체가 쉬움
마이크 입력recordPCM 스트림을 바로 받을 수 있음
권한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;
}

실제로 쓴 파라미터는 이렇습니다.

파라미터값의미
threshold0.15CMND 임계값. 낮을수록 엄격
minFrequency130 Hz (C3)하모니카 음역 밖을 잘라 옥타브 아래 오검출 방지
maxFrequency2100 Hz (C7)탐색 상한
프레임 크기 / hop2048 / 1024분석 창 크기와 이동 간격

그리고 한 가지 더. 정수 단위로 찾은 주기(tau)를 포물선 보간으로 소수점까지 다듬었습니다. 이게 없으면 고음에서 cents 오차가 수십 단위로 튀어서, 인디케이터가 맞게 불어도 정신없이 흔들립니다.

Step 4. UI가 끊기지 않게 - 아이솔레이트 분리

YIN은 프레임 하나에 곱셈을 수십만 번 합니다. 이걸 UI 스레드에서 돌리면 인디케이터 애니메이션이 뚝뚝 끊깁니다. 그래서 Dart의 Isolate(별도 실행 공간)로 분리했습니다.

여기서 고민한 게 하나 있습니다. 계산 중에 새 프레임이 들어오면 어떻게 할까?

  • 큐에 쌓는다 → 계산이 밀리면 0.5초 전 소리를 지금 보여 주게 됨
  • 버린다 → 가끔 한 프레임 건너뜀

실시간 연습에서는 늦은 정보가 틀린 정보보다 더 헷갈립니다. 그래서 계산 중에 도착한 프레임은 버리는 쪽을 택했습니다.

Step 5. 인디케이터가 덜덜 떨리지 않게 - Smoothing

감지 결과를 그대로 화면에 뿌리면 숫자가 파르르 떨립니다. PitchSmoother에서 두 단계로 다듬었습니다.

  1. 중앙값 필터 (최근 5프레임): 한두 프레임짜리 튀는 값 제거
  2. 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개를 정리하겠습니다.

PM

backtodev

A 40-something PM returns to code. Learning, failing, and growing.