Files
WhaleAdventure_VR/Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs
2026-06-14 16:03:24 +09:00

178 lines
6.4 KiB
C#

using System.Collections.Generic;
using UnityEngine;
// 표준 MIDI 파일(.mid)을 파싱해 Note 리스트로 변환하는 미니 파서.
// 단일 템포 가정. Note-On(velocity>0) 이벤트만 사용한다.
public static class MidiChartParser
{
// data: .mid 원본 바이트 / offset: 곡 시작 보정(초)
// bpmOverride: 0이면 MIDI 내장 템포 사용, >0이면 이 BPM으로 강제
// lanePitches: 비우면 모든 노트를 레인 0으로, 채우면 해당 음높이만 레인으로 매핑
public static List<Note> Parse(byte[] data, float offset, float bpmOverride, List<int> lanePitches)
{
var notes = new List<Note>();
if (data == null || data.Length < 14)
{
Debug.LogWarning("[MidiChartParser] MIDI 데이터가 비었거나 너무 짧습니다.");
return notes;
}
int p = 0;
// ---- 헤더 청크 (MThd) ----
if (!MatchId(data, p, 'M', 'T', 'h', 'd'))
{
Debug.LogWarning("[MidiChartParser] MThd 헤더가 아닙니다. (TextAsset이 .mid가 맞는지 확인)");
return notes;
}
p += 4;
ReadUInt32(data, ref p); // 헤더 길이(=6), 사용 안 함
ReadUInt16(data, ref p); // 포맷
int trackCount = ReadUInt16(data, ref p);
int division = ReadUInt16(data, ref p);
if ((division & 0x8000) != 0)
Debug.LogWarning("[MidiChartParser] SMPTE 타임코드는 미지원입니다. PPQN으로 가정합니다.");
int ticksPerQuarter = division & 0x7FFF;
if (ticksPerQuarter <= 0) ticksPerQuarter = 480;
// (절대틱, 음높이) 수집 + 첫 템포
var rawNotes = new List<(int tick, int pitch)>();
int tempoMicros = -1; // 4분음표당 마이크로초 (미발견 시 -1)
// ---- 트랙 청크들 (MTrk) ----
for (int t = 0; t < trackCount && p + 8 <= data.Length; t++)
{
if (!MatchId(data, p, 'M', 'T', 'r', 'k'))
break;
p += 4;
long len = ReadUInt32(data, ref p);
int trackEnd = p + (int)len;
if (trackEnd > data.Length) trackEnd = data.Length;
int absTicks = 0;
int runningStatus = 0;
while (p < trackEnd)
{
absTicks += ReadVarLen(data, ref p);
int status = data[p];
if (status < 0x80)
{
status = runningStatus; // 러닝 스테이터스: 상태 바이트 생략
if (status == 0) { p = trackEnd; break; } // 손상된 데이터 방어
}
else
{
p++;
runningStatus = status;
}
if (status == 0xFF) // 메타 이벤트
{
int metaType = data[p++];
int mlen = ReadVarLen(data, ref p);
if (metaType == 0x51 && mlen == 3 && tempoMicros < 0)
tempoMicros = (data[p] << 16) | (data[p + 1] << 8) | data[p + 2];
p += mlen;
runningStatus = 0;
}
else if (status == 0xF0 || status == 0xF7) // SysEx
{
int slen = ReadVarLen(data, ref p);
p += slen;
runningStatus = 0;
}
else // 채널 메시지
{
int type = status & 0xF0;
switch (type)
{
case 0x90: // Note-On
int pitch = data[p++];
int velocity = data[p++];
if (velocity > 0) rawNotes.Add((absTicks, pitch));
break;
case 0x80: // Note-Off
case 0xA0: // 폴리 애프터터치
case 0xB0: // 컨트롤 체인지
case 0xE0: // 피치 벤드
p += 2;
break;
case 0xC0: // 프로그램 체인지
case 0xD0: // 채널 애프터터치
p += 1;
break;
default:
p = trackEnd; // 알 수 없는 상태 → 트랙 종료(무한루프 방지)
break;
}
}
}
p = trackEnd; // 트랙 경계 보정
}
// ---- 틱 → 초 변환 ----
double usPerQuarter = bpmOverride > 0f
? 60000000.0 / bpmOverride
: (tempoMicros > 0 ? tempoMicros : 500000.0); // 기본 120 BPM
double secPerTick = usPerQuarter / 1000000.0 / ticksPerQuarter;
bool useLanes = lanePitches != null && lanePitches.Count > 0;
foreach (var raw in rawNotes)
{
int lane = 0;
if (useLanes)
{
lane = lanePitches.IndexOf(raw.pitch);
if (lane < 0) continue; // 레인에 매핑 안 된 음높이는 무시
}
notes.Add(new Note
{
Time = offset + (float)(raw.tick * secPerTick),
Lane = lane
});
}
notes.Sort((a, b) => a.Time.CompareTo(b.Time));
return notes;
}
// ---- 바이트 읽기 헬퍼 (MIDI는 빅엔디안) ----
private static bool MatchId(byte[] d, int p, char a, char b, char c, char e)
=> p + 4 <= d.Length && d[p] == a && d[p + 1] == b && d[p + 2] == c && d[p + 3] == e;
private static uint ReadUInt32(byte[] d, ref int p)
{
uint v = (uint)((d[p] << 24) | (d[p + 1] << 16) | (d[p + 2] << 8) | d[p + 3]);
p += 4;
return v;
}
private static int ReadUInt16(byte[] d, ref int p)
{
int v = (d[p] << 8) | d[p + 1];
p += 2;
return v;
}
// 가변 길이 수치(Variable-Length Quantity)
private static int ReadVarLen(byte[] d, ref int p)
{
int value = 0;
byte b;
do
{
b = d[p++];
value = (value << 7) | (b & 0x7F);
}
while ((b & 0x80) != 0 && p < d.Length);
return value;
}
}