2026-06-14 커스텀 채보

This commit is contained in:
skrwns304@gmail.com
2026-06-14 16:03:24 +09:00
parent fab54e162e
commit 19c26533f8
3 changed files with 196 additions and 11 deletions

View File

@@ -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<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;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4930b4a6230ad694da5ca148280f1d07

View File

@@ -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<Note> 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<int> LanePitches = new(); // 비우면 모든 노트를 레인 0으로. 채우면 (index=레인) 매핑
// MIDI를 파싱해 노트 시간 목록 생성
public List<Note> GenerateNotes()
{
List<Note> notes = new List<Note>();
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<Note>();
}
return MidiChartParser.Parse(MidiFile.bytes, Offset, BpmOverride, LanePitches);
}
}