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 Parse(byte[] data, float offset, float bpmOverride, List lanePitches) { var notes = new List(); 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; } }