diff --git a/Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs b/Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs new file mode 100644 index 00000000..afeb8150 --- /dev/null +++ b/Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs @@ -0,0 +1,177 @@ +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; + } +} diff --git a/Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs.meta b/Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs.meta new file mode 100644 index 00000000..d8a754f8 --- /dev/null +++ b/Assets/02_Scripts/Data/Rhythm/MidiChartParser.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4930b4a6230ad694da5ca148280f1d07 \ No newline at end of file diff --git a/Assets/02_Scripts/Data/Rhythm/RhythmChart.cs b/Assets/02_Scripts/Data/Rhythm/RhythmChart.cs index 3875b3ed..2d4dcfd8 100644 --- a/Assets/02_Scripts/Data/Rhythm/RhythmChart.cs +++ b/Assets/02_Scripts/Data/Rhythm/RhythmChart.cs @@ -3,22 +3,28 @@ public class Note { - [HideInInspector] public float Time; + [HideInInspector] public float Time; // 판정 시각(초) + [HideInInspector] public int Lane; // 레인 인덱스 (단일 레인이면 0) } [CreateAssetMenu(fileName = "RhythmChart", menuName = "CatsRoom/RhythmChart")] public class RhythmChart : ScriptableObject { - public AudioClip SongClip; // 노래 파일 - public float Bpm; // 분당 박자 - public float Offset; // 첫 박자 시작 시각 - public int NoteCount; // 노트 개수 (또는 곡 길이로 자동) - public List GenerateNotes() // BPM·offset으로 시간 목록 자동 생성 + public AudioClip SongClip; // 노래 파일 + public TextAsset MidiFile; // FL Studio에서 내보낸 .mid (확장자를 .bytes로 임포트) + public float Offset; // 곡 시작과 MIDI 0틱 사이 보정(초) + public float BpmOverride; // 0이면 MIDI 내장 템포 사용, >0이면 이 BPM으로 강제 + public List LanePitches = new(); // 비우면 모든 노트를 레인 0으로. 채우면 (index=레인) 매핑 + + // MIDI를 파싱해 노트 시간 목록 생성 + public List GenerateNotes() { - List notes = new List(); - float interval = 60f / Bpm; - for (int i = 0; i < NoteCount; i++) - notes.Add(new Note { Time = Offset + interval * i }); - return notes; + if (MidiFile == null) + { + Debug.LogWarning($"[RhythmChart] {name}: MidiFile이 비어 있습니다."); + return new List(); + } + + return MidiChartParser.Parse(MidiFile.bytes, Offset, BpmOverride, LanePitches); } }