Nari

LevelUp Piano — MIDI 피아노 연습 앱 · 블로그

친 음을 어떻게 채점하나 — 실시간 MIDI 판정 엔진

블로그 목록
기능 소개2026-07-01
실시간MIDI게임플레이알고리즘
게임플레이 — 흐르는 노트와 건반
게임플레이 — 위에서 흐르는 노트를 건반으로 맞추고, 빗나가면 그 자리에 "Miss"
±50msPerfect 판정 폭
4단계Perfect·Good·Bad·Miss
300ms입력 버퍼 (미리 친 음)
어택+지속2단계 채점 (0.7·0.3)

문제: "지금 이 건반이 맞나?"

리듬 게임처럼, LevelUp Piano도 화면에 노트가 흐르고 사용자가 건반을 눌러 맞춥니다. 하지만 피아노는 버튼 몇 개가 아니라 88건반의 화음입니다. 게다가 사람은 완벽히 정박에 치지 않습니다. 조금 빠르거나 늦게 치죠. 그 미묘한 어긋남을 "맞음/틀림"이 아니라 **정도(Perfect·Good·Bad·Miss)**로 판정해야 합니다.

시간축과 네 가지 판정

예정 시각−300−150−50+50+150+300MISSBadGoodPerfectGoodBadMISS← 빠름늦음 →
판정 윈도우 — 예정 시각을 중심으로 좌우 대칭. 등급은 |오차|로 매기고(±50 Perfect, ±150 Good, ±300 Bad, 그 밖 Miss), 부호는 "빠름/늦음" 방향 표시로만 남긴다.

판정의 기준은 경과 시간(elapsed time) 입니다. 곡이 시작된 뒤 흐른 밀리초를 축으로, 노트 차트의 각 음은 "몇 ms에 나와야 하는지"를, 사용자 입력은 "몇 ms에 눌렸는지"를 가집니다. 두 시각의 차이(오차)의 절댓값이 곧 등급입니다.

판정타이밍 오차
Perfect≤ 50ms
Good≤ 150ms
Bad≤ 300ms
Miss> 300ms (또는 안 침)

"이르다/늦다"는 별도의 등급이 아니라 오차의 부호로 화면에 표시됩니다. 즉 등급은 대칭이고, 방향은 참고용입니다. 이 윈도우 폭이 난이도 감각을 좌우합니다 — 너무 좁으면 좌절하고, 너무 넓으면 시시해집니다. 300ms는 "정확히 못 쳐도 박은 맞았다" 정도까지 관대하게 잡은 값입니다.

두 단계로 나눈 판정 — 어택과 지속

한 음의 점수는 누른 순간만으로 끝나지 않습니다. 길게 끌어야 하는 음을 툭 놓아버리면 정확히 눌렀어도 반쪽입니다. 그래서 판정을 두 단계로 나눴습니다.

  1. 어택(Attack) — NoteOn이 예정 시각에 얼마나 가까운가 (위의 Perfect/Good/Bad/Miss).
  2. 지속(Duration) — 실제 누른 길이가 악보 길이에 얼마나 맞는가. 짧은 음(≤200ms)은 어택만 보고 즉시 확정하고, 긴 음은 손을 뗄 때(NoteOff)까지 기다렸다가 채점합니다.
# 긴 음(>200ms)의 최종 점수
final = attack_score × 0.7 + duration_score × 0.3
 
# 지속 등급 — 실제 누른 길이 ÷ 악보 길이
0.8 – 1.2 → 1.0     0.5 – 1.5 → 0.7     0.3 – 2.0 → 0.3     그 밖 → 0.0

어택 등급은 코드로도 그대로입니다 — 오차의 절댓값을 세 윈도우와 비교할 뿐입니다.

JudgmentResult _classifyAttack(int absOffsetMs) {
  if (absOffsetMs <= perfectWindowMs) return JudgmentResult.perfect; // ≤ 50ms
  if (absOffsetMs <= goodWindowMs)    return JudgmentResult.good;    // ≤ 150ms
  if (absOffsetMs <= badWindowMs)     return JudgmentResult.bad;     // ≤ 300ms
  return JudgmentResult.miss;
}

곡 전체 점수를 100점으로 환산해 S(≥90)·A(≥80)·B(≥70) 등급을 매기고, 마디 연습은 정확도 80% 이상이면 통과로 칩니다.

입력 버퍼링 — 미리 친 음을 붙잡아두기

노트 판정 구간입력 (조금 이르게)buffer · 300ms구간 열리면 매칭✓ 정확time →
미리 친 입력은 버퍼에 최대 300ms 담아두고, 노트가 판정 구간에 들어오는 순간 매칭한다 — 살짝 빠른 연주도 공정하게.

가장 까다로운 지점은 순서였습니다. 사용자가 노트가 "활성화"되기 직전에 살짝 먼저 눌렀다면? 그 입력을 그냥 버리면 정직하게 친 사람이 억울해집니다.

그래서 매칭에 실패한 건반 입력을 버퍼에 최대 300ms 담아둡니다. 매 프레임(tick)마다 노트를 활성 구간으로 승격시키고, 버퍼에 남은 입력을 다시 매칭해봅니다. 300ms가 지나도록 붙을 노트가 없으면 버퍼에서 버립니다(오래된 입력이 엉뚱한 노트에 붙는 것을 막기 위해). 즉 판정은 "노트가 입력을 기다리기도, 입력이 노트를 기다리기도" 하는 양방향입니다.

💡 Insight

판정은 양방향이다 — 노트가 입력을 기다리기도, 입력이 노트를 기다리기도 한다. 그래서 아주 살짝 빠른 연주도 억울하지 않게 잡아낸다.

화음과 손 — 어느 노트에 붙일까

피아노는 여러 음을 동시에 칩니다. 같은 순간에 여러 건반이 눌리면, 각 입력을 어느 노트에 붙일지가 문제입니다. 규칙은 best-fit입니다 — 입력과 음높이가 같은 미매칭 활성 노트 중에서 예정 시각과 가장 가까운 하나에 붙이고, 그 노트를 즉시 "매칭됨"으로 잠급니다.

// 활성·미매칭·같은 음높이 노트 중 시간이 가장 가까운 것에 붙인다.
_PendingNote? best;
var bestOffset = badWindowMs + 1;
for (final p in _active) {
  if (p.matched || p.note.noteNumber != noteNumber) continue;
  final offset = (inputTimeMs - p.note.startTimeMs).round().abs();
  if (offset < bestOffset) { bestOffset = offset; best = p; }
}
if (best == null) return null;   // 아직 붙을 노트 없음 → 호출부가 버퍼에 담는다
best.matched = true;             // 잠금: 한 입력이 두 노트에 붙지 않는다

"매칭됨" 잠금 덕분에 같은 화음 안에서도 한 입력이 여러 노트에 중복으로 붙지 않습니다. 노트 차트는 각 음이 어느 손인지도 담고 있어(왼손·오른손), 한 손만 연습하거나 손별로 약점을 진단하는 기능의 토대가 됩니다.

연결이 끊겨도 무너지지 않게

MIDI 연결 화면
MIDI 연결 — 상태를 connected·disconnected·scanning으로 관리하고, 끊기면 재스캔을 안내한다.

실기기 테스트에서 발견한 현실적 문제들이 있었습니다.

  • 모호한 엔드포인트 — 같은 피아노가 여러 MIDI 엔드포인트로 잡히기도 합니다. 이때 아무거나 자동 연결하면 입력이 안 오는 엔드포인트를 잡아버릴 수 있어, 2개 이상이면 자동 연결하지 않고 사용자가 picker로 고르게 했습니다.
  • 연주 도중 끊김 — 케이블 접촉이든 절전이든 USB-MIDI는 갑자기 멈출 수 있습니다. 연결 상태를 게임 상태와 분리해, 신호가 끊기면 채점을 밀어붙이는 대신 연결이 끊겼음을 알리고 재연결을 안내합니다. "엔진은 도는데 입력만 없는" 상태를 "사용자 실수"로 오인하지 않기 위해서입니다.

선택하지 않은 대안 — Bluetooth-MIDI. 무선이 편하지만, 블루투스 MIDI는 지연과 순간 끊김이 커서 ms 단위 판정에는 부적합했습니다. 그래서 실시간 판정 경로는 USB만 지원합니다.

결정적이라 테스트할 수 있다

이 판정 엔진의 좋은 성질은 결정적(deterministic) 이라는 점입니다. 입력은 (시각, 노트번호) 이벤트의 나열이고, 시간 계산은 전부 정수 ms입니다. 그래서 고정된 NoteChart와 미리 녹음한 입력 이벤트 로그를 넣으면 항상 같은 판정이 나옵니다.

덕분에 실기기(피아노)가 없어도 리플레이 테스트로 회귀를 잡을 수 있습니다 — "이 입력 시퀀스는 Perfect 3 + Good 1이 나와야 한다"를 단위 테스트로 고정해두면, 윈도우 값이나 버퍼 로직을 건드렸을 때 어긋남이 바로 드러납니다. 실시간·하드웨어 의존 기능일수록, 순수 함수로 짜서 테이블 테스트가 가능하게 만드는 것이 가장 큰 안전장치였습니다.

마치며

판정 엔진은 겉보기엔 단순합니다 — "제 시각에 맞는 음을 눌렀나?" 하지만 대칭 윈도우, 미리 친 음의 버퍼링, 화음의 best-fit 매칭, 어택과 지속의 분리, 하드웨어 불확실성까지 감안하면, 공정하면서도 기분 좋은 판정에는 꽤 많은 결정이 들어갑니다. 그리고 그 결정들을 전부 숫자(±50/150/300ms, 0.7·0.3, 300ms)와 순수 함수로 내려두었기 때문에, 값 하나를 바꿔도 그 효과를 테스트로 확인할 수 있었습니다.