NoteData.cs

프로젝트 전체에서 쓰는 데이터 구조(DTO)를 모아놓은 파일. 로직은 없고 순수하게 데이터만 담는다.

이 파일의 역할

NAS의 JSON ↔ C# 객체 ↔ Beat Saber .dat 사이의 데이터 형식을 정의한다. Unity의 JsonUtility가 읽고 쓸 수 있도록 [Serializable]이 붙는다.

NoteData.cs
using System;
using System.Collections.Generic;
using UnityEngine;

// [Serializable] = JsonUtility가 이 클래스를 JSON으로 변환/역변환할 수 있게 허용하는 속성
// 붙이지 않으면 JsonUtility.ToJson()이 빈 {} 를 반환한다
[Serializable]
public class NoteData
{
    // Beat Sage가 반환하는 .dat 파일의 _time(비트단위)을 BPM으로 나눠 초 단위로 변환한 값
    // → 이 time은 "음악 시작 후 몇 초에 이 노트를 쳐야 하나"
    public float time;

    // 가로 열 위치: 0(왼쪽) ~ 3(오른쪽). Beat Saber 공식 4열 그리드
    public int   position;

    // 세로 행 위치: 0(아래) ~ 2(위). Beat Saber 공식 3행 그리드
    public int   lineLayer;

    // 0 = 빨간 블록(왼손 사이버), 1 = 파란 블록(오른손 사이버)
    public int   colorType;

    // 어느 방향으로 칼날을 휘둘러야 하는지
    // 0=위 1=아래 2=왼 3=오른 4=왼위 5=오른위 6=왼아래 7=오른아래 8=아무방향(점)
    public int   cutDirection;
}

// 맵 JSON 파일의 최상위 구조 — { "target": [ ...NoteData 배열... ] }
[Serializable]
public class MapData
{
    // "target"이라는 이름으로 저장된다. JsonUtility는 필드명 = JSON 키
    public List<NoteData> target;
}

// NAS의 songs.json 전체 구조 — { "version": "1.0", "songs": [...] }
[Serializable]
public class SongsList
{
    public string           version;
    public List<SongInfo> songs;
}

// 곡 하나의 메타데이터. songs.json의 각 원소이자 GameSession으로 씬 간 전달되는 핵심 객체
[Serializable]
public class SongInfo
{
    // id = 곡의 유일 식별자. 파일 경로/캐시 폴더명에도 사용된다
    // 예: "my_song" → 캐시 폴더: temporaryCachePath/beatsaber/my_song/
    public string      id;
    public string      title;
    public string      artist;
    public float       bpm;
    public int         duration;        // 초 단위 길이 (현재는 사용 안 함)
    // audioFile = NAS 상의 상대 경로. 예: "music/my_song.mp3"
    // DownloadManager가 baseUrl + audioFile 로 전체 URL을 조합한다
    public string      audioFile;
    public long        audioSize;
    public string      coverImage;
    public DifficultyMap difficulties;  // 난이도별 맵 파일 정보
    public string      addedAt;         // "2026-05-22" 형식
}

// 4가지 난이도를 각각 필드로 가지는 구조
// Dictionary를 쓰면 더 유연하지만, JsonUtility는 Dictionary를 직렬화 못한다 → 필드로 하드코딩
[Serializable]
public class DifficultyMap
{
    public DifficultyInfo normal;
    public DifficultyInfo hard;
    public DifficultyInfo expert;
    public DifficultyInfo expertplus;

    // 문자열 key로 해당 난이도 정보를 꺼내는 헬퍼 메서드
    // C# 8의 switch expression: 중괄호 없이 => 로 값을 반환하는 switch
    // _ = default case. 알 수 없는 난이도면 null 반환
    public DifficultyInfo Get(string key) => key switch
    {
        "normal"     => normal,
        "hard"       => hard,
        "expert"     => expert,
        "expertplus" => expertplus,
        _            => null
    };
}

// 난이도 하나의 파일 정보. mapFile은 NAS 상대경로. 예: "maps/Map_my_song_hard.json"
[Serializable]
public class DifficultyInfo
{
    // NasPublisher.AssignMapFile()이 업로드 후 이 값을 채운다
    public string mapFile;
    public long   mapSize;
    public int    noteCount;  // 해당 난이도의 총 노트 수
}

GameSession.cs

SongSelect → Game 씬으로 "어떤 곡을 어떤 난이도로 플레이할지" 정보를 전달하는 전역 컨테이너.

씬 전환과 데이터 전달 문제

Unity는 씬이 바뀌면 모든 GameObject가 파괴된다. 씬 간 데이터를 넘기려면 파괴되지 않는 곳에 저장해야 한다. static 클래스는 앱 프로세스가 살아있는 동안 메모리에 남기 때문에 가장 단순한 해법이다.

GameSession.cs
// static class = 인스턴스를 만들 수 없다 (new GameSession() 불가)
// 모든 멤버가 자동으로 static → 어디서든 GameSession.SelectedSong 으로 접근
// MonoBehaviour가 아니므로 씬 전환으로 파괴되지 않는다
public static class GameSession
{
    // SongDetailPanel.OnPlayClicked()에서 여기에 저장하고
    // SongController.LoadAndPlay()에서 여기서 읽는다
    public static SongInfo SelectedSong;
    public static string   SelectedDifficulty;  // "normal" / "hard" / "expert" / "expertplus"
}
// ⚠️ 한계: Game 씬을 에디터에서 직접 Play하면 이 값이 null → SongController가 오류
// 항상 Menu/SongSelect 씬부터 시작해야 한다

BeatSageConverter.cs

Beat Sage AI가 반환하는 .dat JSON 포맷을 우리 내부 NoteData로 변환하는 순수 유틸리티.

Beat Saber .dat 포맷이란?

Beat Saber 공식 커스텀 맵 포맷. _time은 비트(beat) 단위이고 BPM으로 나눠야 실제 초가 된다. _type은 0=빨강, 1=파랑, 3=폭탄(스킵). 우리 게임은 NoteData 포맷을 쓰므로 변환이 필요하다.

BeatSageConverter.cs
// [Serializable] + 필드명 앞에 _ = Beat Saber .dat JSON 키와 정확히 일치해야 JsonUtility가 파싱
[System.Serializable] public class SongMetadata
{
    public string title; public string artist; public float bpm;
}

// info.dat 파일 구조 — Beat Sage가 ZIP에 포함하는 메타데이터 파일
[System.Serializable] public class BeatSageInfoDat
{
    public string _songName;        // JSON 키 "_songName"
    public string _songAuthorName;  // JSON 키 "_songAuthorName"
    public float  _beatsPerMinute;  // Beat Sage가 오디오에서 자동 감지한 BPM
}

// .dat 파일 최상위 구조
[System.Serializable] public class BeatSageRoot
{
    public string             _version;
    public List<BeatSageNote> _notes;  // 노트 배열
}

[System.Serializable] public class BeatSageNote
{
    public float _time;         // 비트 단위 시간
    public int   _lineIndex;    // 열 0-3
    public int   _lineLayer;    // 행 0-2
    public int   _type;         // 0=빨강 1=파랑 3=폭탄
    public int   _cutDirection; // 0-8
}

// static class = 상태(필드) 없이 순수 함수만 모아놓는 유틸리티 패턴
// 인스턴스 없이 BeatSageConverter.Convert() 로 바로 호출
public static class BeatSageConverter
{
    // rawJson: .dat 파일 전체 문자열 / bpm: info.dat에서 읽은 BPM
    public static List<NoteData> Convert(string rawJson, float bpm)
    {
        var result = new List<NoteData>();
        var root   = JsonUtility.FromJson<BeatSageRoot>(rawJson);

        // ?. = null 조건부 접근. root가 null이거나 _notes가 null이면 null 반환 → if가 잡아냄
        if (root?._notes == null)
        {
            Debug.LogWarning("[BeatSageConverter] Parse failed or no notes.");
            return result; // 빈 리스트 반환 (null이 아님 → 호출부에서 null 체크 불필요)
        }

        foreach (var note in root._notes)
        {
            // 폭탄(_type=3), 벽 등은 스킵. 우리는 일반 블록(0,1)만 처리
            if (note._type != 0 && note._type != 1) continue;

            result.Add(new NoteData
            {
                // ★ 핵심 변환: 비트 → 초
                // 공식: 초 = 비트 × 60 / BPM
                // 예) BPM=120, _time=8 → 8×60/120 = 4.0초
                time         = (note._time * 60f) / bpm,
                position     = note._lineIndex,
                lineLayer    = note._lineLayer,
                colorType    = note._type,
                cutDirection = note._cutDirection,
            });
        }

        Debug.Log($"[BeatSageConverter] Converted {result.Count} notes.");
        return result;
    }

    // NoteData 리스트를 우리 맵 JSON 형식으로 직렬화
    // true = prettyPrint (들여쓰기 포함 → 사람이 읽기 좋음)
    public static string ToMapJson(List<NoteData> notes)
        => JsonUtility.ToJson(new MapData { target = notes }, prettyPrint: true);

    // info.dat JSON을 파싱해서 제목/아티스트/BPM 추출
    public static SongMetadata ParseInfoDat(string json)
    {
        var info = JsonUtility.FromJson<BeatSageInfoDat>(json);
        if (info == null) return null;
        return new SongMetadata
        {
            // ?? "" = null이면 빈 문자열로 대체 (NullReferenceException 방지)
            // .Trim() = 앞뒤 공백 제거 (Beat Sage가 가끔 공백을 넣음)
            title  = (info._songName       ?? "").Trim(),
            artist = (info._songAuthorName ?? "").Trim(),
            bpm    = info._beatsPerMinute,
        };
    }
}

BeatSageUploader.cs

Beat Sage 서버에 오디오를 올리고, 비트맵 생성 완료를 폴링하고, 결과 ZIP을 받아 변환하는 전체 파이프라인.

전체 흐름 (4단계)

[1/4] POST 오디오 → levelId 획득 → [2/4] GET /heartbeat 5초마다 폴링 → [3/4] GET /download ZIP 다운로드 → [4/4] ZIP 해제 + .dat → NoteData 변환

BeatSageUploader.cs
public class BeatSageUploader : MonoBehaviour
{
    // const = 컴파일 타임 상수. 런타임에 변경 불가. 문자열 상수는 항상 const로 선언하는 게 관례
    private const string BASE_URL     = "https://beatsaber.com";
    private const string CREATE_EP    = "/beatsaber_custom_level_create";
    private const string HEARTBEAT_EP = "/beatsaber_custom_level_heartbeat/{0}"; // {0} = levelId
    private const string DOWNLOAD_EP  = "/beatsaber_custom_level_download/{0}";

    private const float POLL_INTERVAL = 5f;   // 5초마다 heartbeat 체크
    private const float POLL_TIMEOUT  = 300f; // 최대 5분 대기. 초과하면 오류 처리

    // Beat Sage API는 "Normal", "Hard"처럼 파스칼케이스를 요구
    // 우리 내부는 "normal", "hard" 소문자 사용 → 이 딕셔너리로 매핑
    private static readonly Dictionary<string, string> DiffNames = new()
    {
        { "normal", "Normal" }, { "hard", "Hard" },
        { "expert", "Expert" }, { "expertplus", "ExpertPlus" },
    };

    // ZIP 안의 파일명 매핑 (대소문자 정확히 일치해야 ZipArchive에서 찾음)
    private static readonly Dictionary<string, string> DatFileNames = new()
    {
        { "normal", "Normal.dat" }, { "hard", "Hard.dat" },
        { "expert", "Expert.dat" }, { "expertplus", "ExpertPlus.dat" },
    };

    // { get; private set; } = 외부에서 읽기만 가능, 쓰기는 이 클래스 내부만 가능
    // SongCreatorManager가 UI에 상태 텍스트를 표시하기 위해 읽는다
    public string       CurrentStatus { get; private set; } = "";
    public SongMetadata LastMetadata  { get; private set; } // info.dat에서 읽은 제목/아티스트/BPM

    // ── 공개 API: 로컬 파일 업로드 ──────────────────────────────
    // IEnumerator = 코루틴. yield return으로 Unity에게 "여기서 한 프레임 쉬고 와"를 알림
    // Action<T> = 반환값 없는 콜백 델리게이트. 코루틴은 값을 return 못하므로 콜백으로 결과 전달
    public IEnumerator Upload(
        string audioPath,                                              // 로컬 MP3 경로
        List<string> difficulties,                                    // ["normal","hard",...]
        float bpm,                                                     // UI에서 입력한 BPM (fallback)
        Action<float> onProgress,                                     // 0.0~1.0 진행률 콜백
        Action<Dictionary<string, List<NoteData>>> onComplete,      // 성공: 변환된 맵 전달
        Action<string> onError)                                       // 실패: 오류 메시지 전달
    {
        SetStatus("[1/4] Uploading audio...");
        string levelId = null;

        // yield return StartCoroutine() = 내부 코루틴이 끝날 때까지 여기서 기다린다
        // levelId는 람다(id => levelId = id)를 통해 값을 받아온다
        yield return CreateLevel(audioPath, difficulties, id => levelId = id, onError);
        if (levelId == null) yield break; // CreateLevel이 오류를 호출했으면 중단
        onProgress?.Invoke(0.15f);

        yield return PollAndDownload(levelId, difficulties, bpm, onProgress, onComplete, onError);
    }

    // URL 업로드는 오디오 파일 대신 URL 문자열을 전송 → Beat Sage 서버가 직접 다운로드
    // 이후 PollAndDownload는 동일하므로 공통 메서드로 분리되어 있음
    public IEnumerator UploadFromUrl(
        string audioUrl, List<string> difficulties, float bpm,
        Action<float> onProgress,
        Action<Dictionary<string, List<NoteData>>> onComplete,
        Action<string> onError)
    {
        SetStatus("[1/4] Sending URL to Beat Sage...");
        string levelId = null;
        yield return CreateLevelFromUrl(audioUrl, difficulties, id => levelId = id, onError);
        if (levelId == null) yield break;
        onProgress?.Invoke(0.15f);
        yield return PollAndDownload(levelId, difficulties, bpm, onProgress, onComplete, onError);
    }

    // ── 2~4단계 공통 파이프라인 ──────────────────────────────────
    private IEnumerator PollAndDownload(
        string levelId, List<string> difficulties, float bpm,
        Action<float> onProgress,
        Action<Dictionary<string, List<NoteData>>> onComplete,
        Action<string> onError)
    {
        SetStatus("[2/4] Generating beatmap...");
        bool  ready   = false;
        float elapsed = 0f;

        // Beat Sage 생성 완료 폴링: 5초마다 상태 확인, 최대 300초 대기
        while (!ready && elapsed < POLL_TIMEOUT)
        {
            yield return new WaitForSeconds(POLL_INTERVAL); // 5초 대기 (Unity 메인 스레드 블로킹 없음)
            elapsed += POLL_INTERVAL;

            bool error = false;
            yield return PollHeartbeat(levelId,
                status => {
                    // OrdinalIgnoreCase = 대소문자 무시 비교. "Generated" vs "generated" 둘 다 인식
                    ready = string.Equals(status, "generated", StringComparison.OrdinalIgnoreCase)
                         || string.Equals(status, "done",      StringComparison.OrdinalIgnoreCase);
                    error = string.Equals(status, "error",     StringComparison.OrdinalIgnoreCase);
                },
                onError);

            if (error) { onError?.Invoke("Beat Sage generation failed"); yield break; }

            // 진행률: 15%~75% 구간을 폴링 시간 비율로 채움
            // Clamp01 = 0~1 사이로 클램프 (elapsed가 POLL_TIMEOUT 초과해도 1.0 이상 안 됨)
            onProgress?.Invoke(0.15f + Mathf.Clamp01(elapsed / POLL_TIMEOUT) * 0.6f);
            SetStatus($"[2/4] Generating... {(int)elapsed}s elapsed");
        }

        if (!ready) { onError?.Invoke("Beat Sage timeout (>5 min)"); yield break; }

        SetStatus("[3/4] Downloading result...");
        byte[] zipBytes = null;
        yield return DownloadZip(levelId, b => zipBytes = b, onError);
        if (zipBytes == null) yield break;
        onProgress?.Invoke(0.9f);

        SetStatus("[3/4] Converting map data...");
        Dictionary<string, List<NoteData>> maps = null;
        // try-catch: ZIP 파싱/변환은 동기 작업이므로 코루틴 밖에서 예외가 바로 던져짐
        // 코루틴 안에서 예외가 나면 Unity가 조용히 중단하므로 명시적으로 잡아야 한다
        try   { maps = ExtractAndConvert(zipBytes, difficulties, bpm); }
        catch (Exception e) { onError?.Invoke($"Conversion failed: {e.Message}"); yield break; }

        onProgress?.Invoke(1f);
        onComplete?.Invoke(maps); // 성공! 변환된 맵을 SongCreatorManager에 전달
    }

    // ── CreateLevel: 파일을 multipart/form-data로 POST ──────────
    private IEnumerator CreateLevel(string audioPath, List<string> difficulties,
        Action<string> onSuccess, Action<string> onError)
    {
        byte[] audioBytes = File.ReadAllBytes(audioPath); // 파일 전체를 메모리로 읽음
        string fileName   = Path.GetFileName(audioPath);

        // 내부 "normal" 키를 API가 요구하는 "Normal" 등으로 변환
        var mappedDiffs = new List<string>();
        foreach (string d in difficulties)
            if (DiffNames.TryGetValue(d, out var n)) mappedDiffs.Add(n);

        if (mappedDiffs.Count == 0) { onError?.Invoke("No supported difficulties."); yield break; }

        // IMultipartFormSection 리스트로 form-data 구성
        // MultipartFormFileSection = 파일 파트 (Content-Disposition: form-data; name=".."; filename="..")
        // MultipartFormDataSection = 텍스트 파트
        var form = new List<IMultipartFormSection>
        {
            new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg"),
            new MultipartFormDataSection("audio_metadata_title",  " "), // Beat Sage가 info.dat로 자동 감지
            new MultipartFormDataSection("audio_metadata_artist", " "),
            new MultipartFormDataSection("difficulties", string.Join(",", mappedDiffs)), // "Normal,Hard,Expert"
            new MultipartFormDataSection("modes",       "Standard"),
            new MultipartFormDataSection("events",      "DotBlocks,Obstacles,Bombs"),
            new MultipartFormDataSection("environment", "DefaultEnvironment"),
            new MultipartFormDataSection("system_tag",  "v2"),
        };

        // using var = C# 8 선언형 using. 이 변수가 스코프를 벗어나면 자동 Dispose (메모리 해제)
        using var req = UnityWebRequest.Post(BASE_URL + CREATE_EP, form);
        req.SetRequestHeader("Accept",     "*/*");
        req.SetRequestHeader("User-Agent", "VRBeatSaber/1.0");
        yield return req.SendWebRequest(); // 요청 완료까지 대기

        if (req.result != UnityWebRequest.Result.Success)
        { onError?.Invoke($"Level create failed: {req.error}"); yield break; }

        // 응답 예: {"id":"abc123def"} → ParseJsonString으로 "abc123def" 추출
        string levelId = ParseJsonString(req.downloadHandler.text, "id");
        if (string.IsNullOrEmpty(levelId))
        { onError?.Invoke($"Failed to parse levelId: {req.downloadHandler.text}"); yield break; }

        onSuccess?.Invoke(levelId);
    }

    // URL 버전 — 파일 바이트 대신 audio_url 필드에 URL 문자열을 보냄
    private IEnumerator CreateLevelFromUrl(string audioUrl, List<string> difficulties,
        Action<string> onSuccess, Action<string> onError)
    {
        var mappedDiffs = new List<string>();
        foreach (string d in difficulties)
            if (DiffNames.TryGetValue(d, out var n)) mappedDiffs.Add(n);
        if (mappedDiffs.Count == 0) { onError?.Invoke("No supported difficulties."); yield break; }

        var form = new List<IMultipartFormSection>
        {
            new MultipartFormDataSection("audio_url", audioUrl), // 파일 업로드 대신 URL
            new MultipartFormDataSection("audio_metadata_title",  " "),
            new MultipartFormDataSection("audio_metadata_artist", " "),
            new MultipartFormDataSection("difficulties", string.Join(",", mappedDiffs)),
            new MultipartFormDataSection("modes",       "Standard"),
            new MultipartFormDataSection("events",      "DotBlocks,Obstacles,Bombs"),
            new MultipartFormDataSection("environment", "DefaultEnvironment"),
            new MultipartFormDataSection("system_tag",  "v2"),
        };
        using var req = UnityWebRequest.Post(BASE_URL + CREATE_EP, form);
        req.SetRequestHeader("Accept", "*/*"); req.SetRequestHeader("User-Agent", "VRBeatSaber/1.0");
        yield return req.SendWebRequest();
        if (req.result != UnityWebRequest.Result.Success)
        { onError?.Invoke($"Level create (URL) failed: {req.error}"); yield break; }
        string levelId = ParseJsonString(req.downloadHandler.text, "id");
        if (string.IsNullOrEmpty(levelId))
        { onError?.Invoke($"Failed to parse levelId: {req.downloadHandler.text}"); yield break; }
        onSuccess?.Invoke(levelId);
    }

    // ── Heartbeat: 생성 완료 여부 1회 체크 ──────────────────────
    private IEnumerator PollHeartbeat(string levelId,
        Action<string> onStatus, Action<string> onError)
    {
        // string.Format = {0} 자리에 levelId 삽입
        using var req = UnityWebRequest.Get(BASE_URL + string.Format(HEARTBEAT_EP, levelId));
        yield return req.SendWebRequest();
        if (req.result != UnityWebRequest.Result.Success)
        { onError?.Invoke($"Heartbeat failed: {req.error}"); yield break; }
        // ?? "" = status가 null이면 빈 문자열 (PollAndDownload의 Equals가 null에 안전)
        onStatus?.Invoke(ParseJsonString(req.downloadHandler.text, "status") ?? "");
    }

    // ── ZIP 다운로드: 서버 500 오류 시 최대 3회 재시도 ────────────
    private IEnumerator DownloadZip(string levelId,
        Action<byte[]> onSuccess, Action<string> onError)
    {
        string url = BASE_URL + string.Format(DOWNLOAD_EP, levelId);

        // 재시도 루프: attempt 1~3
        for (int attempt = 1; attempt <= 3; attempt++)
        {
            using var req = UnityWebRequest.Get(url);
            yield return req.SendWebRequest();

            if (req.result == UnityWebRequest.Result.Success)
            { onSuccess?.Invoke(req.downloadHandler.data); yield break; } // 성공 → byte[] 전달

            // HTTP 500 = 서버 내부 오류. Beat Sage가 처리 중일 때 발생할 수 있어 재시도
            if (req.responseCode == 500 && attempt < 3)
            {
                SetStatus($"[3/4] Server error, retrying ({attempt}/3)...");
                yield return new WaitForSeconds(5f);
                continue; // 다음 시도로
            }
            onError?.Invoke($"ZIP download failed: {req.error} (HTTP {req.responseCode})");
            yield break;
        }
    }

    // ── ZIP 해제 + 각 .dat 파일을 NoteData로 변환 ────────────────
    private Dictionary<string, List<NoteData>> ExtractAndConvert(
        byte[] zipBytes, List<string> difficulties, float fallbackBpm)
    {
        var result = new Dictionary<string, List<NoteData>>();

        // byte[]를 MemoryStream으로 감싸면 파일 없이 ZipArchive로 다룰 수 있다
        using var ms      = new MemoryStream(zipBytes);
        using var archive = new ZipArchive(ms, ZipArchiveMode.Read);

        // info.dat 먼저 파싱 → BPM 및 메타데이터 획득
        float bpm = fallbackBpm;
        foreach (var e in archive.Entries)
        {
            if (!string.Equals(e.Name, "info.dat", StringComparison.OrdinalIgnoreCase)) continue;
            using var r = new StreamReader(e.Open(), Encoding.UTF8);
            var meta = BeatSageConverter.ParseInfoDat(r.ReadToEnd());
            if (meta != null)
            {
                LastMetadata = meta; // SongCreatorManager가 이 값으로 UI 자동완성
                if (meta.bpm > 0) bpm = meta.bpm; // info.dat BPM이 있으면 우선 사용
            }
            break;
        }

        // 각 난이도 .dat 파일을 찾아 변환
        foreach (string diff in difficulties)
        {
            if (!DatFileNames.TryGetValue(diff, out string datName)) continue;

            ZipArchiveEntry entry = null;
            foreach (var e in archive.Entries)
                if (string.Equals(e.Name, datName, StringComparison.OrdinalIgnoreCase))
                { entry = e; break; }

            if (entry == null) { Debug.LogWarning($"{datName} not found"); continue; }

            using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
            // BeatSageConverter.Convert = .dat JSON → NoteData 리스트 (비트→초 변환 포함)
            result[diff] = BeatSageConverter.Convert(reader.ReadToEnd(), bpm);
        }
        return result;
    }

    // ── 간이 JSON 파서: {"key":"value"} 에서 value만 추출 ────────
    // Newtonsoft.Json 없이 단순 응답을 파싱하기 위한 수동 구현
    // 주의: 값에 이스케이프된 따옴표(\")가 있으면 틀린다 → 단순 응답에만 사용
    private static string ParseJsonString(string json, string key)
    {
        string search = $"\"{key}\":";   // 예: "id":"  를 찾는다
        int start = json.IndexOf(search, StringComparison.Ordinal);
        if (start < 0) return null;  // 키 없음
        start += search.Length;          // 값 시작 위치로 이동
        int end = json.IndexOf('"', start); // 닫는 " 찾기
        return end > start ? json.Substring(start, end - start) : null;
    }

    private void SetStatus(string msg) => CurrentStatus = msg;
}

NasPublisher.cs

Synology DSM FileStation API로 NAS에 MP3·맵 JSON을 업로드하고 songs.json을 갱신한다.

왜 수동 multipart?

Unity의 UnityWebRequest.Post(form)가 생성하는 multipart boundary 포맷을 DSM이 거부(401)한다. GUID boundary를 직접 만들고 CRLF 줄바꿈을 손으로 조립해야 DSM이 받아들인다.

NasPublisher.cs
public class NasPublisher : MonoBehaviour
{
    // [Header] = Inspector에서 구분선+라벨 표시. 코드에는 영향 없음
    [Header("NAS Connection")]
    [SerializeField] private string nasBaseUrl  = "http://192.168.55.3:5000"; // 로컬 NAS IP
    [SerializeField] private string nasAccount  = "admin";
    [SerializeField] private string nasRootPath = "/web/beatsaber";

    [Header("Static Server URL")]
    [SerializeField] private string staticBaseUrl = "http://whdwo798.synology.me/beatsaber";

    private string _sid       = ""; // DSM 로그인 세션 ID. 모든 API 요청에 포함
    private string _synoToken = ""; // CSRF 방지 토큰. 업로드 헤더에 추가
    private string _password  = ""; // nas_config.json에서 읽음. 코드에 하드코딩 금지

    // Awake = Start보다 먼저 실행. 다른 컴포넌트 Start()가 실행되기 전에 설정 완료
    private void Awake() => LoadConfig();

    private void LoadConfig()
    {
        // StreamingAssets = APK/앱 빌드 후에도 읽기 가능한 에셋 폴더
        // 비밀번호를 코드에 넣지 않고 별도 파일로 관리 (gitignore에 추가됨)
        string path = Path.Combine(Application.streamingAssetsPath, "nas_config.json");
        if (!File.Exists(path)) { Debug.LogWarning("nas_config.json not found"); return; }
        var cfg = JsonUtility.FromJson<NasConfig>(File.ReadAllText(path));
        if (cfg == null) return;
        _password = cfg.password ?? "";
        // cfg 값이 있으면 Inspector 기본값을 덮어씀 → 환경별로 config만 바꾸면 됨
        if (!string.IsNullOrEmpty(cfg.host))      nasBaseUrl    = cfg.host;
        if (!string.IsNullOrEmpty(cfg.account))   nasAccount    = cfg.account;
        if (!string.IsNullOrEmpty(cfg.rootPath))  nasRootPath   = cfg.rootPath;
        if (!string.IsNullOrEmpty(cfg.staticUrl)) staticBaseUrl = cfg.staticUrl;
    }

    // private nested class = NasPublisher 안에서만 쓰는 JSON 역직렬화용 구조체
    [Serializable] private class NasConfig
    {
        public string host; public string account; public string rootPath;
        public string staticUrl; public string password;
    }

    // ── 메인 업로드 파이프라인 ──────────────────────────────────
    public IEnumerator Publish(
        SongInfo song,
        string audioPath,     // null이면 오디오 업로드 스킵 (URL 모드)
        Dictionary<string, List<NoteData>> maps,
        Action<float> onProgress, Action onComplete, Action<string> onError)
    {
        bool failed = false;
        // 로컬 함수 = 이 메서드 안에서만 쓰는 인라인 함수. failed 플래그와 onError를 캡처
        void OnErr(string e) { onError?.Invoke(e); failed = true; }

        yield return Login(OnErr);
        // 로그인 성공 여부는 _sid가 비어있는지로 판단
        if (string.IsNullOrEmpty(_sid)) yield break;
        onProgress?.Invoke(0.1f);

        // URL 모드(audioPath==null)면 오디오는 이미 NAS에 있으므로 스킵
        if (!string.IsNullOrEmpty(audioPath))
        {
            // {nasRootPath}/music 폴더에 {song.id}.mp3 로 업로드
            yield return UploadFile(audioPath, $"{nasRootPath}/music", $"{song.id}.mp3", OnErr);
            if (failed) { yield return Logout(); yield break; }
        }
        onProgress?.Invoke(0.4f);

        int total = maps.Count, done = 0;
        foreach (var kv in maps) // kv.Key = "normal", kv.Value = List<NoteData>
        {
            string fileName = $"Map_{song.id}_{kv.Key}.json"; // 예: Map_my_song_hard.json
            byte[] bytes    = Encoding.UTF8.GetBytes(BeatSageConverter.ToMapJson(kv.Value));
            // song의 DifficultyInfo.mapFile 필드를 채워줌 → songs.json에 경로가 기록됨
            AssignMapFile(song, kv.Key, fileName);

            yield return UploadBytes(bytes, fileName, $"{nasRootPath}/maps", OnErr);
            if (failed) { yield return Logout(); yield break; }

            done++;
            onProgress?.Invoke(0.4f + (float)done / total * 0.3f);
        }

        yield return PatchSongsJson(song, OnErr); // songs.json 갱신 (upsert)
        if (failed) { yield return Logout(); yield break; }

        yield return Logout();
        onProgress?.Invoke(1f);
        onComplete?.Invoke();
    }

    // ── DSM 로그인: SID + SynoToken 획득 ──────────────────────
    private IEnumerator Login(Action<string> onError)
    {
        // DSM Auth API: GET 요청으로 파라미터를 쿼리스트링으로 전달
        // EscapeURL = 특수문자를 URL 인코딩 (비밀번호에 @, & 등이 있을 때 필요)
        string url = $"{nasBaseUrl}/webapi/auth.cgi"
                   + $"?api=SYNO.API.Auth&version=6&method=login"
                   + $"&account={UnityWebRequest.EscapeURL(nasAccount)}"
                   + $"&passwd={UnityWebRequest.EscapeURL(_password)}"
                   + $"&session=FileStation&format=sid&enable_syno_token=yes";

        using var req = UnityWebRequest.Get(url);
        yield return req.SendWebRequest();
        if (req.result != UnityWebRequest.Result.Success)
        { onError?.Invoke($"DSM login failed: {req.error}"); yield break; }

        string resp = req.downloadHandler.text;
        _sid       = ParseJsonString(resp, "sid");      // 세션 ID
        _synoToken = ParseJsonString(resp, "synotoken"); // CSRF 토큰 (DSM 7.x 필수)
        if (string.IsNullOrEmpty(_sid))
            onError?.Invoke("DSM sid parse failed — check credentials.");
    }

    private IEnumerator Logout()
    {
        string url = $"{nasBaseUrl}/webapi/auth.cgi?api=SYNO.API.Auth&version=1&method=logout&session=FileStation&_sid={_sid}";
        using var req = UnityWebRequest.Get(url);
        yield return req.SendWebRequest();
        _sid = ""; // 세션 무효화
    }

    private IEnumerator UploadFile(string localPath, string nasFolder, string fileName, Action<string> onError)
        => UploadBytes(File.ReadAllBytes(localPath), fileName, nasFolder, onError);

    // ── 수동 multipart/form-data 바디 조립 ────────────────────
    private IEnumerator UploadBytes(byte[] bytes, string fileName,
        string nasFolder, Action<string> onError)
    {
        string uploadUrl = $"{nasBaseUrl}/webapi/entry.cgi?api=SYNO.FileStation.Upload&version=2&method=upload&_sid={UnityWebRequest.EscapeURL(_sid)}";

        // GUID("N" 형식) = 하이픈 없는 32자리 랜덤 문자열. boundary로 사용
        // boundary는 바디 안 데이터와 충돌하지 않는 유일한 구분자여야 한다
        string boundary = Guid.NewGuid().ToString("N");
        const string CRLF = "\r\n"; // HTTP 규격상 줄바꿈은 반드시 CRLF(\r\n)

        // MemoryStream에 multipart 바디를 직접 조립
        using var body = new MemoryStream();
        // 로컬 함수: 문자열을 UTF-8 바이트로 변환해서 스트림에 쓰기
        void WriteText(string s) { var b = Encoding.UTF8.GetBytes(s); body.Write(b, 0, b.Length); }
        void WriteField(string name, string value)
        {
            WriteText($"--{boundary}{CRLF}");
            WriteText($"Content-Disposition: form-data; name=\"{name}\"{CRLF}{CRLF}");
            WriteText(value + CRLF);
        }

        // DSM FileStation.Upload API가 요구하는 필드
        WriteField("path",           nasFolder);  // 업로드 대상 NAS 폴더
        WriteField("create_parents", "true");    // 폴더 없으면 자동 생성
        WriteField("overwrite",      "true");    // 동일 파일명 덮어쓰기

        // 파일 파트: boundary 뒤에 Content-Disposition + Content-Type + 실제 바이트
        WriteText($"--{boundary}{CRLF}");
        WriteText($"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"{CRLF}");
        WriteText($"Content-Type: application/octet-stream{CRLF}{CRLF}");
        body.Write(bytes, 0, bytes.Length); // 바이너리 데이터 직접 쓰기
        WriteText(CRLF + $"--{boundary}--{CRLF}"); // 종료 boundary (뒤에 -- 붙음)

        using var req = new UnityWebRequest(uploadUrl, "POST");
        req.uploadHandler   = new UploadHandlerRaw(body.ToArray()); // 조립한 바디를 raw bytes로
        req.downloadHandler = new DownloadHandlerBuffer();           // 응답을 메모리에 받음
        req.SetRequestHeader("Content-Type", $"multipart/form-data; boundary={boundary}");
        if (!string.IsNullOrEmpty(_synoToken))
            req.SetRequestHeader("X-SYNO-TOKEN", _synoToken); // DSM 7.x CSRF 토큰

        yield return req.SendWebRequest();
        if (req.result != UnityWebRequest.Result.Success)
        { onError?.Invoke($"Upload failed ({fileName}): {req.error}"); yield break; }
        // DSM은 HTTP 200이어도 바디에 "success":false 를 넣어 오류를 알릴 수 있다
        if (req.downloadHandler.text.Contains("\"success\":false"))
            onError?.Invoke($"Upload rejected ({fileName}): {req.downloadHandler.text}");
    }

    // ── songs.json 갱신 (upsert) ──────────────────────────────
    private IEnumerator PatchSongsJson(SongInfo newSong, Action<string> onError)
    {
        SongsList list = null;
        // 현재 songs.json 읽기 (실패해도 새로 만들면 되므로 onError 없음)
        using (var req = UnityWebRequest.Get($"{staticBaseUrl}/songs.json"))
        {
            yield return req.SendWebRequest();
            if (req.result == UnityWebRequest.Result.Success)
                list = JsonUtility.FromJson<SongsList>(req.downloadHandler.text);
        }
        // ??= : list가 null이면 새 SongsList 할당 (C# 8 null 병합 할당)
        list ??= new SongsList { version = "1.0", songs = new List<SongInfo>() };

        // upsert: 같은 id가 있으면 교체, 없으면 추가
        // FindIndex: 조건 람다를 만족하는 첫 번째 인덱스. 없으면 -1
        int idx = list.songs.FindIndex(s => s.id == newSong.id);
        if (idx >= 0) list.songs[idx] = newSong;
        else          list.songs.Add(newSong);

        // 갱신된 JSON을 NAS에 다시 업로드
        yield return UploadBytes(
            Encoding.UTF8.GetBytes(JsonUtility.ToJson(list, true)),
            "songs.json", nasRootPath, onError);
    }

    private static string ParseJsonString(string json, string key)
    {
        string search = $"\"{key}\":";
        int start = json.IndexOf(search, StringComparison.Ordinal);
        if (start < 0) return null;
        start += search.Length;
        int end = json.IndexOf('"', start);
        return end > start ? json.Substring(start, end - start) : null;
    }

    // DifficultyInfo.mapFile 필드에 NAS 상대 경로를 채워줌
    // 이 값이 songs.json에 기록되어 나중에 DownloadManager가 다운로드 URL을 조합함
    private static void AssignMapFile(SongInfo song, string diff, string fileName)
    {
        var info = song.difficulties.Get(diff);
        if (info != null) info.mapFile = $"maps/{fileName}";
    }
}

DownloadManager.cs

NAS 정적 서버에서 MP3·맵 JSON을 로컬 캐시로 내려받는다. 이미 있는 파일은 스킵(멱등성).

DownloadManager.cs
public class DownloadManager : MonoBehaviour
{
    [SerializeField] private string baseUrl = "http://whdwo798.synology.me/beatsaber";

    // static 프로퍼티: 인스턴스 없이 클래스 이름으로 접근 가능
    // => 로 한 줄 표현 (expression-bodied property)
    private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");

    // ── 공개 API ───────────────────────────────────────────────

    // songs.json 전체 목록 가져오기
    public void FetchSongsList(Action<SongsList> onSuccess, Action<string> onError = null)
    {
        // StartCoroutine: MonoBehaviour에서 코루틴 시작. void 메서드에서도 비동기 로직 실행 가능
        StartCoroutine(GetText($"{baseUrl}/songs.json", json => {
            SongsList list = JsonUtility.FromJson<SongsList>(json);
            if (list == null) onError?.Invoke("songs.json 파싱 실패");
            else             onSuccess?.Invoke(list);
        }, onError));
    }

    // 곡 다운로드 시작 (오디오 + 맵 JSON)
    public void DownloadSong(SongInfo song, string difficulty,
        Action<float> onProgress, Action onComplete, Action<string> onError = null)
        => StartCoroutine(DownloadSongCoroutine(song, difficulty, onProgress, onComplete, onError));

    public void DeleteSong(string songId)
    {
        string dir = SongDir(songId);
        if (Directory.Exists(dir))
            Directory.Delete(dir, recursive: true); // 폴더 전체 삭제
    }

    // 오디오 파일 존재 = 곡이 다운로드됐다는 증거
    public bool IsSongDownloaded(string songId) => File.Exists(AudioPath(songId));

    public bool IsDifficultyDownloaded(SongInfo song, string difficulty)
    {
        string path = MapPath(song, difficulty);
        return path != null && File.Exists(path);
    }

    public string AudioPath(string songId) => Path.Combine(SongDir(songId), $"{songId}.mp3");

    public string MapPath(SongInfo song, string difficulty)
    {
        DifficultyInfo info = song.difficulties.Get(difficulty);
        if (info == null || string.IsNullOrEmpty(info.mapFile)) return null;
        string fileName = Path.GetFileName(info.mapFile); // "maps/Map_id_hard.json" → "Map_id_hard.json"
        if (string.IsNullOrEmpty(fileName)) return null;
        return Path.Combine(SongDir(song.id), fileName);
    }

    // ── 내부 구현 ──────────────────────────────────────────────

    private IEnumerator DownloadSongCoroutine(SongInfo song, string difficulty,
        Action<float> onProgress, Action onComplete, Action<string> onError)
    {
        // GetFullPath = 상대경로를 절대경로로 정규화. DownloadHandlerFile이 절대경로를 요구
        string songDir = Path.GetFullPath(SongDir(song.id));
        Directory.CreateDirectory(songDir); // 없으면 생성, 있으면 무시 (안전)

        // 1단계: 오디오 (전체 진행률의 0~70%)
        string audioPath = Path.Combine(songDir, $"{song.id}.mp3");
        if (!File.Exists(audioPath)) // 멱등성: 이미 있으면 스킵
        {
            bool failed = false;
            yield return DownloadFile(
                $"{baseUrl}/{song.audioFile}", audioPath,
                p => onProgress?.Invoke(p * 0.7f),              // 0.0~0.7로 스케일
                err => { onError?.Invoke(err); failed = true; });
            if (failed) yield break;
        }

        // 2단계: 맵 JSON (전체 진행률의 70~100%)
        DifficultyInfo diffInfo = song.difficulties.Get(difficulty);
        if (diffInfo == null) { onError?.Invoke($"난이도 '{difficulty}' 없음"); yield break; }
        if (string.IsNullOrEmpty(diffInfo.mapFile))
        { onError?.Invoke("맵 파일 정보 없음 — 곡을 다시 생성해주세요"); yield break; }

        string mapPath = MapPath(song, difficulty);
        if (mapPath != null) mapPath = Path.GetFullPath(mapPath);
        if (mapPath == null) { onError?.Invoke("맵 경로 계산 실패"); yield break; }

        if (!File.Exists(mapPath))
        {
            bool failed = false;
            yield return DownloadFile(
                $"{baseUrl}/{diffInfo.mapFile}", mapPath,
                p => onProgress?.Invoke(0.7f + p * 0.3f),     // 0.7~1.0으로 스케일
                err => { onError?.Invoke(err); failed = true; });
            if (failed) yield break;
        }

        onProgress?.Invoke(1f);
        onComplete?.Invoke();
    }

    // ── 단일 파일 다운로드 ─────────────────────────────────────
    private IEnumerator DownloadFile(string url, string savePath,
        Action<float> onProgress, Action<string> onError)
    {
        using var req = UnityWebRequest.Get(url);
        // DownloadHandlerFile = 응답 바이트를 메모리에 올리지 않고 바로 파일에 씀 (메모리 절약)
        req.downloadHandler = new DownloadHandlerFile(savePath);
        req.SendWebRequest(); // yield return 없이 시작 → 아래 while에서 진행률 폴링

        while (!req.isDone)
        {
            onProgress?.Invoke(req.downloadProgress); // 0.0~1.0 다운로드 진행률
            yield return null; // 한 프레임 대기
        }

        if (req.result != UnityWebRequest.Result.Success)
        {
            // 실패 시 불완전 파일 삭제 → 다음 시도 때 "캐시 히트"로 잘못 판단하는 걸 방지
            if (File.Exists(savePath)) File.Delete(savePath);
            onError?.Invoke($"다운로드 실패: {url} — {req.error}");
        }
    }

    private IEnumerator GetText(string url, Action<string> onSuccess, Action<string> onError)
    {
        using var req = UnityWebRequest.Get(url);
        yield return req.SendWebRequest();
        if (req.result != UnityWebRequest.Result.Success)
            onError?.Invoke($"요청 실패: {url} — {req.error}");
        else
            onSuccess?.Invoke(req.downloadHandler.text);
    }

    // 모든 경로 계산의 기준점. 이 값이 SongController의 CacheRoot와 동일해야 파일을 공유 가능
    private static string SongDir(string songId) => Path.Combine(CacheRoot, songId);
}

SongController.cs

Game 씬의 핵심 브릿지. GameSession에서 선택 정보를 읽어 오디오·맵을 로드하고 카운트다운 후 큐브를 스폰한다.

SongController.cs
public class SongController : MonoBehaviour
{
    // [SerializeField] = private이지만 Inspector에서 값 할당 가능
    // Spawneable = VRBeatsKit의 큐브 프리팹 베이스 타입
    [SerializeField] private Spawneable  cubePrefab;
    // GameEvent = VRBeatsKit의 ScriptableObject 기반 이벤트. 씬 완료 시 발동
    [SerializeField] private GameEvent   onLevelComplete;
    [SerializeField] private TMP_Text    countdownText;

    private AudioManager _audio; // VRBeatsKit AudioManager: 실제 AudioSource 래핑

    // static 프로퍼티: CacheRoot는 DownloadManager와 완전히 동일한 경로를 반환해야 한다
    private static string CacheRoot => Path.Combine(Application.temporaryCachePath, "beatsaber");

    private void Start()
    {
        // FindObjectOfType: 씬 전체에서 AudioManager를 찾음 (느리지만 Start에서 1번만 호출)
        _audio = FindObjectOfType<AudioManager>();
        StartCoroutine(LoadAndPlay());
    }

    private IEnumerator LoadAndPlay()
    {
        SongInfo song = GameSession.SelectedSong;
        string   diff = GameSession.SelectedDifficulty;

        // Game 씬을 직접 실행하면 null → 에러 후 종료
        if (song == null || string.IsNullOrEmpty(diff))
        { Debug.LogError("[SongController] No song/difficulty selected"); yield break; }

        // ── 오디오 로드 (비동기) ──────────────────────────────────
        string audioPath = Path.Combine(CacheRoot, song.id, song.id + ".mp3");
        AudioClip clip;
        // "file://" 프리픽스 = 로컬 파일을 URL 형식으로 접근
        // GetAudioClip은 비동기 → yield return으로 완료까지 대기
        using (var req = UnityWebRequestMultimedia.GetAudioClip("file://" + audioPath, AudioType.MPEG))
        {
            yield return req.SendWebRequest();
            if (req.result != UnityWebRequest.Result.Success)
            { Debug.LogError($"Audio load failed: {req.error}"); yield break; }
            // GetContent = 요청에서 AudioClip 추출. using 블록 내에서 호출해야 함
            clip = DownloadHandlerAudioClip.GetContent(req);
        }

        // ── 맵 로드 (동기) ────────────────────────────────────────
        DifficultyInfo diffInfo = song.difficulties.Get(diff);
        if (diffInfo == null) { Debug.LogError($"Difficulty '{diff}' not found"); yield break; }

        string mapPath = Path.Combine(CacheRoot, song.id, Path.GetFileName(diffInfo.mapFile));
        if (!File.Exists(mapPath)) { Debug.LogError($"Map missing: {mapPath}"); yield break; }

        // File.ReadAllText + JsonUtility.FromJson = 동기 파싱. 파일이 작으므로 문제없음
        MapData map = JsonUtility.FromJson<MapData>(File.ReadAllText(mapPath));
        if (map?.target == null) { Debug.LogError("Map parse failed"); yield break; }
        // time 기준으로 오름차순 정렬 → SpawnRoutine이 앞에서부터 순서대로 처리
        map.target.Sort((a, b) => a.time.CompareTo(b.time));

        // ── 카운트다운 → 음악 시작 → 스폰 루프 ──────────────────
        yield return StartCoroutine(Countdown());

        _audio.PlayClip(clip); // 음악 재생 시작

        // SpawnRoutine과 WaitForCompletion을 동시에 시작
        // StartCoroutine() = 코루틴을 "백그라운드"로 실행하고 즉시 반환
        // yield return StartCoroutine() = 완료까지 대기
        StartCoroutine(SpawnRoutine(map.target));      // 병렬 실행 (대기 안 함)
        yield return StartCoroutine(WaitForCompletion(clip.length)); // 곡 끝날 때까지 대기
    }

    private IEnumerator Countdown()
    {
        if (countdownText == null) yield break;
        countdownText.gameObject.SetActive(true);

        string[] labels    = { "3", "2", "1", "GO!" };
        float[]  durations = { 1f,  1f,  1f,  0.6f };

        for (int i = 0; i < labels.Length; i++)
        {
            countdownText.text = labels[i];
            yield return new WaitForSeconds(durations[i]);
        }
        countdownText.gameObject.SetActive(false);
    }

    private IEnumerator SpawnRoutine(List<NoteData> notes)
    {
        float travelTime = VR_BeatManager.instance.GameSettings.TargetTravelTime; // 현재 1.8초

        foreach (NoteData note in notes)
        {
            // spawnAt = 이 노트를 스폰해야 하는 오디오 시간
            // = 실제 칠 시간 - travelTime (큐브가 날아오는 데 걸리는 시간)
            // Mathf.Max(0f, ...) = 음수 방지. 곡 시작 직후 노트는 즉시 스폰
            float spawnAt = Mathf.Max(0f, note.time - travelTime);
            // WaitUntil: 람다가 true가 될 때까지 매 프레임 체크
            yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
            SpawnNote(note);
        }
    }

    private void SpawnNote(NoteData note)
    {
        // Beat Saber 4열 그리드 → 월드 X 좌표 선형 매핑
        // 열0=-0.375, 열1=-0.125, 열2=0.125, 열3=0.375 (중앙 기준 대칭)
        float x = -0.375f + note.position * 0.25f;
        // 3행 그리드 → 월드 Y 좌표. 행0=-0.333(아래), 행1=0(중간), 행2=0.333(위)
        float y = -0.333f + note.lineLayer * 0.333f;

        // ★ 핵심: travelTimeOverride 계산
        // 문제: foreach로 동시 노트 2개를 처리하면 1프레임(~16ms) 차이가 남
        // 두 노트가 같은 TargetTravelTime(1.8s)으로 스폰되면 히트존 도착이 16ms 어긋남
        // 해결: 스폰 시점의 실제 남은 시간(remaining)을 각 노트의 travelTime으로 사용
        // → 노트A 스폰 시 remaining=1.800, 노트B 스폰 시 remaining=1.784
        // → 각자 다른 speed로 발사되지만 같은 시각에 히트존 도착
        float remaining = note.time - _audio.CurrentTime;
        float travelTime = Mathf.Max(0.05f, remaining); // 최소 0.05s (0이 되면 즉시 소멸)

        var info = new SpawnEventInfo
        {
            position           = new Vector3(x, y, 0f),
            // colorType 0=빨강=왼손(Left), 1=파랑=오른손(Right)
            colorSide          = note.colorType == 0 ? ColorSide.Left : ColorSide.Right,
            hitDirection       = MapCutDirection(note.cutDirection),
            useSpark           = true,
            speed              = 2f,
            travelTimeOverride = travelTime, // VRBeatsKit이 이 값을 TargetTravelTime 대신 사용
        };

        VR_BeatManager.instance.Spawn(cubePrefab, info);
    }

    // ── cutDirection 조회 테이블 ──────────────────────────────
    // Beat Saber 숫자(0-8) → VRBeatsKit Direction enum
    // if-else 8개 대신 배열 인덱스로 O(1) 변환. static readonly = 앱 수명 동안 1번만 할당
    private static readonly Direction[] CutDirMap =
    {
        Direction.Up,        // 0
        Direction.Down,      // 1
        Direction.Left,      // 2
        Direction.Right,     // 3
        Direction.UpperLeft, // 4
        Direction.UpperRight,// 5
        Direction.LowerLeft, // 6
        Direction.LowerRight,// 7
        Direction.Center,   // 8 = Any (점 블록)
    };

    // 범위 체크 후 매핑. 범위 밖이면 Center(아무 방향)로 안전 처리
    private static Direction MapCutDirection(int cut)
        => (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;

    private IEnumerator WaitForCompletion(float clipLength)
    {
        // 0.5초 여유: 마지막 노트 판정 후 ResultsPanel이 뜨기 전 잠깐 대기
        yield return new WaitUntil(() => _audio.CurrentTime >= clipLength - 0.5f);
        // ?. = onLevelComplete가 Inspector에 연결 안 됐으면 아무것도 안 함
        onLevelComplete?.Invoke(); // VRBeatsKit GameEvent 발동 → ResultsPanel 등 연결된 리스너 실행
    }
}

AudioManager.cs (VRBeatsKit)

VRBeatsKit 내장 오디오 관리자. 우리 코드에서 PlayClip()CurrentTime만 추가했다.

VRBeatsKit/Scripts/Core/AudioManager.cs
namespace VRBeats
{
    // [RequireComponent] = 이 컴포넌트를 붙이려면 AudioSource도 반드시 있어야 함
    // Inspector에서 AudioSource 없이 추가하려 하면 Unity가 자동으로 같이 추가
    [RequireComponent(typeof(AudioSource))]
    public class AudioManager : MonoBehaviour
    {
        [SerializeField] private AudioMixerGroup mixerGroup = null;
        [SerializeField] private float fadeOutTime = 4.0f; // 피치 페이드 시간

        private AudioSource audioSource = null;

        private void Start()
        {
            // GetComponent: 같은 GameObject의 다른 컴포넌트를 참조
            audioSource = GetComponent<AudioSource>();
            // AudioMixerGroup: 여러 AudioSource의 볼륨/이펙트를 믹서에서 일괄 제어
            audioSource.outputAudioMixerGroup = mixerGroup;
            ResetThisComponent();
        }

        private void ResetThisComponent()
        {
            SetAudioMixerPitch(1.0f); // 피치 초기화
            // CancelAllTweens: PlatinioTween 라이브러리 - 진행 중인 트윈 취소
            gameObject.CancelAllTweens();
        }

        // BlendAudioMixerPitch: 피치를 from → to로 서서히 변환 (슬로우 다운 효과)
        // BaseTween 반환 = 트윈 핸들. 호출부에서 취소하거나 완료 콜백을 추가할 수 있음
        public BaseTween BlendAudioMixerPitch(float from, float to)
        {
            // PlatinioTween.ValueTween: 숫자값을 보간하는 트윈
            // SetEase(EaseOutExpo): 처음엔 빠르고 끝에서 느려지는 곡선
            // SetOnUpdateFloat: 매 프레임 보간된 값을 콜백으로 받음
            // SetOwner: 해당 GameObject가 파괴되면 트윈도 자동 취소
            return PlatinioTween.instance.ValueTween(from, to, fadeOutTime)
                .SetEase(Ease.EaseOutExpo)
                .SetOnUpdateFloat(delegate(float v) {
                    if (audioSource != null) SetAudioMixerPitch(v);
                })
                .SetOwner(gameObject);
        }

        // AudioMixer의 "Pitch" 노출 파라미터를 직접 설정
        // AudioMixer에서 Pitch 파라미터를 Exposed Parameters에 등록해야 동작
        public void SetAudioMixerPitch(float value)
            => audioSource.outputAudioMixerGroup.audioMixer.SetFloat("Pitch", value);

        // ★ 우리가 추가한 메서드 ─────────────────────────────────
        // SongController.LoadAndPlay()에서 로드된 AudioClip을 재생할 때 호출
        public void PlayClip(AudioClip clip)
        {
            audioSource.clip = clip;  // 재생할 클립 교체
            audioSource.Play();       // 즉시 재생 시작
        }

        // 현재 재생 위치(초). SpawnRoutine의 WaitUntil 조건에서 매 프레임 읽힌다
        // audioSource가 null이면 0 반환 (씬 초기화 중 안전)
        public float CurrentTime => audioSource != null ? audioSource.time : 0f;
    }
}

SongCreatorManager.cs

Creator 씬의 UI 관리자. 파일 선택/URL 입력 → Beat Sage → NAS 업로드까지의 전체 플로우를 조율한다.

SongCreatorManager.cs
public class SongCreatorManager : MonoBehaviour
{
    // Inspector에서 UI 컴포넌트들을 연결. 각 [Header]는 Inspector에서 구분선 역할
    [Header("Audio Source")]
    [SerializeField] private TMP_Dropdown  audioDropdown;
    [SerializeField] private Button         refreshBtn;
    [SerializeField] private TMP_Text       inputPathHint; // MP3 폴더 경로 안내 텍스트
    [SerializeField] private TMP_InputField urlInput;
    [SerializeField] private Button         urlDownloadBtn;
    [SerializeField] private TMP_InputField titleInput, artistInput, bpmInput;
    [SerializeField] private Toggle toggleNormal, toggleHard, toggleExpert, toggleExpertPlus;
    [SerializeField] private Button  generateButton, backButton;
    [SerializeField] private GameObject progressGroup;
    [SerializeField] private TMP_Text    statusText;
    [SerializeField] private Slider      progressSlider;
    [SerializeField] private BeatSageUploader beatSageUploader;
    [SerializeField] private NasPublisher    nasPublisher;

    // persistentDataPath = 앱 재시작 후에도 유지되는 폴더 (Documents/게임이름/)
    // temporaryCachePath와 달리 OS가 임의로 삭제하지 않는다
    private static string InputPath => Path.Combine(Application.persistentDataPath, "input");

    private readonly List<string> audioFiles = new(); // 드롭다운에 표시할 MP3 경로 목록
    private string _pendingFilePath; // 파일 다이얼로그(STA 스레드)에서 메인 스레드로 경로 전달용

    private void Start()
    {
        Directory.CreateDirectory(InputPath); // 없으면 생성
        if (inputPathHint != null) inputPathHint.text = $"Path: {InputPath}";

        // ?. = null 조건부 호출. Inspector에서 연결 안 된 버튼도 안전하게 처리
        refreshBtn?.onClick.AddListener(RefreshAudioList);
        generateButton?.onClick.AddListener(OnGenerateClicked);
        // 람다: 클릭 시 menuSceneName 씬으로 이동
        backButton?.onClick.AddListener(() => SceneManager.LoadScene(menuSceneName));
        urlDownloadBtn?.onClick.AddListener(OnUrlDownloadClicked);
        if (progressGroup != null) progressGroup.SetActive(false);
        RefreshAudioList();
    }

    private void Update()
    {
        // _pendingFilePath는 STA 스레드(파일 다이얼로그)에서 설정된다
        // Unity API는 메인 스레드에서만 호출 가능 → Update()에서 처리
        if (_pendingFilePath != null)
        { CopyToInput(_pendingFilePath); _pendingFilePath = null; }
    }

    private void RefreshAudioList()
    {
        audioFiles.Clear();
        audioDropdown?.ClearOptions();
        var options = new List<string>();
        // InputPath 폴더의 모든 .mp3 파일을 드롭다운에 추가
        foreach (string f in Directory.GetFiles(InputPath, "*.mp3"))
        {
            audioFiles.Add(f);
            options.Add(Path.GetFileNameWithoutExtension(f)); // 확장자 없는 파일명만 표시
        }
        if (options.Count == 0) options.Add("-- no .mp3 files --");
        audioDropdown?.AddOptions(options);
    }

    private void OnGenerateClicked()
    {
        string directUrl = urlInput != null ? urlInput.text.Trim() : "";
        bool   hasUrl    = !string.IsNullOrEmpty(directUrl);
        bool   hasFile   = audioFiles.Count > 0;
        if (!hasUrl && !hasFile) { SetStatus("No audio source."); return; }

        // float.TryParse: 파싱 실패해도 예외 없음. out float = 결과값 수신
        float.TryParse(bpmInput?.text, out float bpmHint);
        // 현재 4개 난이도 전체를 항상 생성 (Toggle 값은 현재 미사용)
        var diffs = new List<string> { "normal", "hard", "expert", "expertplus" };

        if (hasUrl)
        {
            // Uri.TryCreate: URL 형식이 올바른지 검증. 잘못된 URL이면 false
            if (!Uri.TryCreate(directUrl, UriKind.Absolute, out var uri) ||
                (uri.Scheme != "http" && uri.Scheme != "https"))
            { SetStatus("Invalid URL."); return; }
            StartCoroutine(GenerateFlowFromUrl(uri.AbsoluteUri, bpmHint, diffs));
        }
        else
        {
            // audioDropdown.value = 현재 선택된 항목의 인덱스
            StartCoroutine(GenerateFlow(audioFiles[audioDropdown.value], bpmHint, diffs));
        }
    }

    // ── URL 모드 파이프라인 (오디오 업로드 없음) ──────────────────
    private IEnumerator GenerateFlowFromUrl(string audioUrl, float bpm, List<string> diffs)
    {
        SetInteractable(false); // 진행 중 버튼 비활성화
        progressGroup.SetActive(true);

        Dictionary<string, List<NoteData>> maps = null;
        bool failed = false;

        // 1~3단계: Beat Sage API (진행률 0~80%)
        yield return beatSageUploader.UploadFromUrl(
            audioUrl, diffs, bpm,
            onProgress: p => {
                progressSlider.value = p * 0.8f;
                SetStatus($"{beatSageUploader.CurrentStatus}  ({(int)(p * 80)}%)");
            },
            onComplete: result => maps = result, // 변환된 맵 수신
            onError:    err    => { SetStatus($"Error: {err}"); failed = true; });

        if (failed) { SetInteractable(true); yield break; }

        // SongInfo 조립: info.dat 메타데이터 + UI 입력 합산
        SongInfo song = BuildSongInfo(audioUrl, bpm, maps);

        // 4단계: NAS 업로드 (진행률 80~100%)
        // audioPath=null → NAS에 오디오 업로드 스킵 (URL로 접근 가능하므로)
        yield return nasPublisher.Publish(
            song, null, maps,
            onProgress: p => {
                progressSlider.value = 0.8f + p * 0.2f;
                SetStatus($"[4/4] Uploading to NAS...  ({(int)((0.8f + p * 0.2f) * 100)}%)");
            },
            onComplete: () => { progressSlider.value = 1f; SetStatus($"Done! '{song.title}' created."); },
            onError:    err => { SetStatus($"NAS upload failed: {err}"); failed = true; });

        SetInteractable(true);
    }

    // ── 로컬 파일 모드 파이프라인 (오디오도 NAS에 업로드) ─────────
    private IEnumerator GenerateFlow(string audioPath, float bpm, List<string> diffs)
    {
        SetInteractable(false);
        progressGroup.SetActive(true);
        Dictionary<string, List<NoteData>> maps = null;
        bool failed = false;

        yield return beatSageUploader.Upload(audioPath, diffs, bpm,
            onProgress: p => { progressSlider.value = p * 0.8f;
                SetStatus($"{beatSageUploader.CurrentStatus}  ({(int)(p * 80)}%)"); },
            onComplete: result => maps = result,
            onError:    err    => { SetStatus($"Error: {err}"); failed = true; });
        if (failed) { SetInteractable(true); yield break; }

        SongInfo song = BuildSongInfo(audioPath, bpm, maps);

        // audioPath를 전달 → NasPublisher가 MP3도 NAS에 업로드
        yield return nasPublisher.Publish(song, audioPath, maps,
            onProgress: p => { progressSlider.value = 0.8f + p * 0.2f;
                SetStatus($"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)"); },
            onComplete: () => { progressSlider.value = 1f; SetStatus($"Done! '{song.title}' created."); },
            onError:    err => { SetStatus($"NAS upload failed: {err}"); failed = true; });
        SetInteractable(true);
    }

    // ── SongInfo 조립: info.dat 자동감지값 + UI 입력값 합산 ─────
    // 우선순위: UI 입력 > info.dat > fallback(파일명/타임스탬프)
    private SongInfo BuildSongInfo(string audioPath, float fallbackBpm,
        Dictionary<string, List<NoteData>> maps)
    {
        var meta    = beatSageUploader?.LastMetadata; // info.dat에서 읽은 자동 감지값
        string uiTitle  = titleInput?.text.Trim()  ?? "";
        string uiArtist = artistInput?.text.Trim() ?? "";
        float.TryParse(bpmInput?.text, out float uiBpm);

        // 3단계 우선순위 선택: UI입력 → info.dat → 빈 문자열
        string title  = !string.IsNullOrEmpty(uiTitle)  ? uiTitle  : (meta?.title  ?? "");
        string artist = !string.IsNullOrEmpty(uiArtist) ? uiArtist : (meta?.artist ?? "");
        // BPM도 3단계: info.dat(자동감지) → UI입력 → fallback
        float  bpm    = (meta != null && meta.bpm > 0) ? meta.bpm : (uiBpm > 0 ? uiBpm : fallbackBpm);

        // 제목이 없으면 파일명 → 그것도 없으면 타임스탬프 기반 id
        if (string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(audioPath))
            title = Path.GetFileNameWithoutExtension(audioPath);
        if (string.IsNullOrEmpty(title))
            title = $"song_{DateTime.Now:yyyyMMdd_HHmmss}";

        // id = 소문자+언더스코어. 파일 경로/URL에 안전하게 사용하기 위해
        string id = title.ToLower().Replace(" ", "_");

        // maps에 있는 난이도만 DifficultyMap에 채움
        var diffMap = new DifficultyMap();
        foreach (var kv in maps)
        {
            var info = new DifficultyInfo { noteCount = kv.Value.Count };
            switch (kv.Key)
            {
                case "normal":     diffMap.normal     = info; break;
                case "hard":       diffMap.hard       = info; break;
                case "expert":     diffMap.expert     = info; break;
                case "expertplus": diffMap.expertplus = info; break;
            }
        }
        return new SongInfo
        {
            id = id, title = title, artist = artist, bpm = bpm,
            audioFile    = $"music/{id}.mp3",  // NAS 상대경로
            difficulties = diffMap,
            addedAt      = DateTime.Now.ToString("yyyy-MM-dd"),
        };
    }

    // ── 파일 선택 다이얼로그 (플랫폼별 분기) ────────────────────
    private void OnFilePickerClicked()
    {
// #if = 조건부 컴파일. 해당 플랫폼에서만 코드가 포함됨
// UNITY_EDITOR = 에디터에서 실행 시
#if UNITY_EDITOR
        // EditorUtility.OpenFilePanel = 에디터 전용 파일 선택 다이얼로그
        string path = UnityEditor.EditorUtility.OpenFilePanel("Select audio file", "", "mp3");
        if (!string.IsNullOrEmpty(path)) CopyToInput(path);
#elif UNITY_STANDALONE_WIN
        // Windows Forms 파일 다이얼로그는 STA(Single Thread Apartment) 스레드에서 실행해야 함
        // Unity 메인 스레드는 MTA → 별도 Thread 생성 필요
        var t = new Thread(() => {
            var dlg = new System.Windows.Forms.OpenFileDialog { Filter = "MP3|*.mp3" };
            if (dlg.ShowDialog() == System.Windows.Forms.DialogResult.OK)
                _pendingFilePath = dlg.FileName; // 메인 스레드 Update()가 이 값을 처리
        });
        t.SetApartmentState(ApartmentState.STA); // Windows Forms 요구사항
        t.Start();
#else
        // Quest/Android: 파일 다이얼로그 없음 → ADB로 복사하는 방법 안내
        SetAddStatus($"Copy file via ADB:\n{InputPath}");
#endif
    }

    // ── URL에서 직접 MP3 다운로드 ────────────────────────────────
    private IEnumerator DownloadFromUrl(string url)
    {
        SetAddStatus("Downloading...");
        if (urlDownloadBtn != null) urlDownloadBtn.interactable = false;

        // URL 경로에서 파일명 추출. 없거나 .mp3가 아니면 "download.mp3"
        string fileName;
        try   { string uriPath = new Uri(url).AbsolutePath;
                fileName = Path.GetFileName(uriPath);
                if (string.IsNullOrEmpty(fileName) || !fileName.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase))
                    fileName = "download.mp3"; }
        catch { fileName = "download.mp3"; }

        string savePath = Path.GetFullPath(Path.Combine(InputPath, fileName));
        using var req = UnityWebRequest.Get(url);
        req.downloadHandler = new DownloadHandlerFile(savePath); // 파일로 직접 저장
        yield return req.SendWebRequest();

        if (urlDownloadBtn != null) urlDownloadBtn.interactable = true;

        if (req.result == UnityWebRequest.Result.Success)
        {
            RefreshAudioList();
            // 방금 다운받은 파일을 드롭다운에서 자동 선택
            int idx = audioFiles.FindIndex(
                f => Path.GetFileNameWithoutExtension(f) == Path.GetFileNameWithoutExtension(fileName));
            if (idx >= 0 && audioDropdown != null) audioDropdown.value = idx;
            SetAddStatus($"Downloaded: {fileName}");
        }
        else
        {
            if (File.Exists(savePath)) File.Delete(savePath); // 실패 시 불완전 파일 삭제
            SetAddStatus($"Download failed: {req.error}");
        }
    }

    private void SetInteractable(bool value)
    {
        // 진행 중 모든 입력 비활성화 → 완료 후 다시 활성화
        if (generateButton != null) generateButton.interactable = value;
        if (audioDropdown  != null) audioDropdown.interactable  = value;
        if (refreshBtn     != null) refreshBtn.interactable     = value;
        if (urlDownloadBtn != null) urlDownloadBtn.interactable = value;
    }
    private void SetStatus   (string msg) { if (statusText  != null) statusText.text  = msg; }
    private void SetAddStatus(string msg) { if (addStatusText != null) addStatusText.text = msg; }
}

SongDetailPanel.cs

곡 카드 클릭 시 열리는 상세 패널. 난이도 선택 → 다운로드 → 플레이까지 처리한다.

SongDetailPanel.cs
public class SongDetailPanel : MonoBehaviour
{
    // diffSlots: 난이도 4개의 (키, 버튼접근자) 쌍을 배열로 관리
    // Func<SongDetailPanel, Button> = 이 패널을 받아서 해당 버튼을 반환하는 함수
    // 이렇게 하면 foreach 하나로 4개 난이도를 동일하게 처리 가능 (코드 중복 제거)
    private readonly (string key, Func<SongDetailPanel, Button> btn)[] diffSlots =
    {
        ("normal",     p => p.btnNormal),
        ("hard",       p => p.btnHard),
        ("expert",     p => p.btnExpert),
        ("expertplus", p => p.btnExpertPlus),
    };

    // ── 외부에서 호출하는 진입점 ──────────────────────────────
    public void Show(SongInfo song, DownloadManager dm, SongSelectManager sm)
    {
        currentSong = song; downloadManager = dm; selectManager = sm;
        selectedDifficulty = null; // 새 곡을 열면 난이도 선택 초기화

        titleText.text  = song.title;
        artistText.text = song.artist;
        // duration이 있으면 "BPM 120  |  3:45" 형식, 없으면 BPM만
        infoText.text = song.duration > 0
            ? $"BPM {Mathf.RoundToInt(song.bpm)}  |  {FormatDuration(song.duration)}"
            : $"BPM {Mathf.RoundToInt(song.bpm)}";
        RefreshUI();
    }

    private void RefreshUI()
    {
        bool downloaded = SongLibrary.Instance.IsSongDownloaded(currentSong.id);

        // 튜플 분해: (key, getBtn) = diffSlots의 각 원소
        foreach (var (key, getBtn) in diffSlots)
        {
            Button btn    = getBtn(this);
            bool   exists = currentSong.difficulties.Get(key) != null;

            // 다운로드됐고 이 난이도가 존재할 때만 활성화
            btn.interactable = downloaded && exists;
            btn.onClick.RemoveAllListeners(); // 이전 곡의 리스너 제거 (메모리 누수 방지)

            if (downloaded && exists)
            {
                // string captured = key: foreach 클로저 버그 방지 (각 람다가 자신의 key를 캡처)
                string captured = key;
                btn.onClick.AddListener(() => SelectDifficulty(captured));
            }
        }

        UpdateDiffColors();
        downloadButton.gameObject.SetActive(!downloaded);  // 미다운로드 시 다운로드 버튼
        deleteButton.gameObject.SetActive(downloaded);     // 다운로드 완료 시 삭제 버튼
        playButton.interactable = downloaded && selectedDifficulty != null;

        // 리스너를 항상 초기화 후 재등록 → 이전 곡의 리스너가 남는 버그 방지
        downloadButton.onClick.RemoveAllListeners(); downloadButton.onClick.AddListener(OnDownloadClicked);
        deleteButton.onClick.RemoveAllListeners();   deleteButton.onClick.AddListener(OnDeleteClicked);
        playButton.onClick.RemoveAllListeners();    playButton.onClick.AddListener(OnPlayClicked);
        closeButton?.onClick.RemoveAllListeners();
        closeButton?.onClick.AddListener(() => gameObject.SetActive(false));
    }

    private void UpdateDiffColors()
    {
        foreach (var (key, getBtn) in diffSlots)
        {
            Button btn      = getBtn(this);
            bool   selected = key == selectedDifficulty;
            // targetGraphic is Image = 타입 패턴 매칭. 캐스트 성공 시 img에 저장
            if (btn.targetGraphic is Image img)
                img.color = selected ? SelectedColor : DeselectedImgColor;
        }
    }

    // ── 다운로드: 존재하는 모든 난이도를 순서대로 다운로드 ──────
    private IEnumerator DownloadAllCoroutine()
    {
        // 이 곡에 존재하는 난이도 목록 수집
        var diffs = new List<string>();
        foreach (var (key, _) in diffSlots)
            if (currentSong.difficulties.Get(key) != null) diffs.Add(key);
        if (diffs.Count == 0) yield break;

        SetInteractable(false);
        progressGroup.SetActive(true);

        int  totalSteps = diffs.Count;
        int  doneSteps  = 0;
        bool failed     = false;

        // 난이도 하나씩 순서대로 다운로드 (병렬 X)
        foreach (string diff in diffs)
        {
            bool stepDone = false;
            downloadManager.DownloadSong(
                currentSong, diff,
                onProgress: p => {
                    // 전체 진행률 = (완료된 단계 + 현재 단계 진행률) / 총 단계 수
                    float overall = (doneSteps + p) / totalSteps;
                    progressSlider.value = overall;
                    progressText.text    = $"{diffs[Mathf.Min(doneSteps, diffs.Count-1)].ToUpper()}  {(int)(overall*100)}%";
                },
                onComplete: () => {
                    SongLibrary.Instance.MarkDownloaded(currentSong.id, diff); // 라이브러리에 기록
                    doneSteps++;
                    stepDone = true;
                },
                onError: err => { Debug.LogError(err); failed = true; stepDone = true; });

            // stepDone이 true가 될 때까지 대기 (콜백 기반 → 코루틴 완료 감지)
            yield return new WaitUntil(() => stepDone);
            if (failed) break;
        }

        SetInteractable(true);
        progressGroup.SetActive(false);
        selectManager.RefreshCards(); // 카드 목록에 OWNED 배지 반영
        RefreshUI();
    }

    // ── 플레이: GameSession에 선택 저장 후 씬 이동 ──────────────
    private void OnPlayClicked()
    {
        GameSession.SelectedSong       = currentSong;
        GameSession.SelectedDifficulty = selectedDifficulty;
        SceneManager.LoadScene(gameSceneName); // "Game" 씬으로 이동
    }

    // "3:45" 형식 포맷터. D2 = 최소 2자리, 부족하면 앞에 0 채움 (45 → "45", 5 → "05")
    private static string FormatDuration(int seconds)
        => $"{seconds / 60}:{seconds % 60:D2}";
}

SongSelectManager.cs

NAS에서 곡 목록을 불러오고 코드로 카드 UI를 동적 생성한다. 오프라인 캐시 폴백 포함.

SongSelectManager.cs
public class SongSelectManager : MonoBehaviour
{
    // 탭 색상: 활성=불투명(0.45), 비활성=반투명(0.12)
    private static readonly Color TabActive   = new Color(1f, 1f, 1f, 0.45f);
    private static readonly Color TabInactive = new Color(1f, 1f, 1f, 0.12f);

    // persistentDataPath: 앱 재시작 후에도 살아남는 폴더. songs_cache.json 저장에 사용
    private static string CachePath => Path.Combine(Application.persistentDataPath, "songs_cache.json");

    private void Start()
    {
        // Resources.Load: Resources 폴더에서 에셋 로드. 없으면 null
        _cardFont = Resources.Load<TMP_FontAsset>("Fonts & Materials/NanumGothic SDF");
        if (_cardFont == null) // 폴백: 한글 폰트 없으면 기본 폰트
            _cardFont = Resources.Load<TMP_FontAsset>("Fonts & Materials/LiberationSans SDF");

        tabAllBtn  .onClick.AddListener(() => SwitchTab(false));
        tabOwnedBtn.onClick.AddListener(() => SwitchTab(true));
        detailPanel.gameObject.SetActive(false);
        SetTabVisual(false);
        FetchSongs();
    }

    // ── 서버에서 목록 가져오기 (오프라인 폴백 포함) ─────────────
    private void FetchSongs()
    {
        loadingOverlay.SetActive(true);
        errorOverlay.SetActive(false);

        downloadManager.FetchSongsList(
            onSuccess: list => {
                allSongs = list.songs;
                SaveCache(list);   // 성공 시 로컬에 캐시 저장
                // 파일시스템 검증: SongLibrary가 알고 있는 다운로드 상태를 실제 파일 존재와 비교
                SongLibrary.Instance.ValidateWithFileSystem(downloadManager, list.songs);
                loadingOverlay.SetActive(false);
                RefreshCards();
            },
            onError: _ => {
                // 네트워크 실패 → 캐시가 있으면 오프라인 목록 표시
                SongsList cached = LoadCache();
                loadingOverlay.SetActive(false);
                if (cached != null) { allSongs = cached.songs; RefreshCards(); }
                else { errorOverlay.SetActive(true);
                        errorText.text = "Failed to connect to server\nPlease check your internet connection"; }
            });
    }

    public void RefreshCards()
    {
        // DestroyImmediate: 기존 카드 즉시 삭제. Destroy()는 프레임 끝에 삭제 → 레이아웃 계산 오류 발생
        for (int i = cardContainer.childCount - 1; i >= 0; i--)
            DestroyImmediate(cardContainer.GetChild(i).gameObject);

        // showingOwned=true면 다운로드된 곡만 필터링
        List<SongInfo> songs = showingOwned
            ? allSongs.FindAll(s => SongLibrary.Instance.IsSongDownloaded(s.id))
            : allSongs;

        foreach (SongInfo song in songs) SpawnCard(song);

        // 레이아웃 재계산 순서가 중요: ForceRebuild → ForceUpdateCanvases
        // 순서가 바뀌면 카드 크기 계산이 틀려서 겹침 현상 발생
        LayoutRebuilder.ForceRebuildLayoutImmediate(cardContainer);
        Canvas.ForceUpdateCanvases();
    }

    // ── 카드 1개 동적 생성 ────────────────────────────────────
    private void SpawnCard(SongInfo song)
    {
        bool downloaded = SongLibrary.Instance.IsSongDownloaded(song.id);

        // new GameObject + AddComponent: 프리팹 없이 코드로 UI 생성
        var card = new GameObject(song.title);
        card.transform.SetParent(cardContainer, false); // false = 로컬 트랜스폼 유지

        // LayoutElement: VerticalLayoutGroup이 이 카드의 높이/너비를 어떻게 배분할지 제어
        // preferredHeight=13f → 13 units 고정 높이. flexibleWidth=1 → 가로는 꽉 채움
        var le = card.AddComponent<LayoutElement>();
        le.preferredHeight = 13f; le.flexibleWidth = 1f;

        var bg  = card.AddComponent<Image>();
        bg.color = new Color(1f, 1f, 1f, 0.06f); // 반투명 흰색 배경

        var btn = card.AddComponent<Button>();
        btn.targetGraphic = bg;
        var bc = btn.colors;
        bc.normalColor      = new Color(1f, 1f, 1f, 0.06f);
        bc.highlightedColor = new Color(0.4f, 0.75f, 1f, 0.25f); // 호버 시 파란 하이라이트
        bc.pressedColor     = new Color(0.3f, 0.6f,  0.9f, 0.45f);
        bc.fadeDuration     = 0.1f; // 색상 전환 속도
        btn.colors = bc;

        // 마퀴 구조: RectMask2D 안에서 텍스트가 스크롤
        // TitleMask(RectMask2D) → Title(TMP_Text + MarqueeText)
        var titleMask = new GameObject("TitleMask");
        titleMask.transform.SetParent(card.transform, false);
        var tmr = titleMask.AddComponent<RectTransform>();
        // anchorMin/Max: 0~1 범위로 부모 대비 위치 지정. (0,0.5)~(1,1) = 상반부 전체
        tmr.anchorMin = new Vector2(0f, 0.5f); tmr.anchorMax = new Vector2(1f, 1f);
        // offsetMin/Max: anchor 기준 픽셀 오프셋. downloaded면 배지 공간(-20f) 확보
        tmr.offsetMin = new Vector2(5f, 0f); tmr.offsetMax = new Vector2(downloaded ? -20f : -3f, 0f);
        titleMask.AddComponent<RectMask2D>(); // 이 컨테이너 영역 밖을 잘라냄

        var titleGO = new GameObject("Title");
        titleGO.transform.SetParent(titleMask.transform, false);
        var tr = titleGO.AddComponent<RectTransform>();
        // pivot=(0,0.5): 텍스트 왼쪽 중앙을 기준점으로. MarqueeText가 x를 음수로 이동시켜 스크롤
        tr.anchorMin = new Vector2(0f,0f); tr.anchorMax = new Vector2(0f,1f); tr.pivot = new Vector2(0f,0.5f);
        tr.anchoredPosition = Vector2.zero; tr.sizeDelta = new Vector2(500f, 0f); // 넓게 설정 → 마스크 밖으로 나옴
        var tTmp = titleGO.AddComponent<TextMeshProUGUI>();
        if (_cardFont != null) tTmp.font = _cardFont;
        tTmp.text = song.title; tTmp.fontSize = 5f; tTmp.color = Color.white;
        tTmp.overflowMode = TextOverflowModes.Overflow; // 영역 넘어도 잘리지 않음 (RectMask2D가 처리)
        tTmp.enableWordWrapping = false; // 줄바꿈 금지 → 한 줄로
        titleGO.AddComponent<MarqueeText>(); // 텍스트가 컨테이너보다 길면 자동 스크롤

        // ★ 클로저 버그 방지: foreach 변수 song을 captured에 복사
        SongInfo captured = song;
        btn.onClick.AddListener(() => OnCardClicked(captured));
    }

    // ── 캐시 저장/로드 ───────────────────────────────────────
    private static void SaveCache(SongsList list)
    {
        // 빈 try-catch: 파일 쓰기 실패(권한 등)해도 앱 크래시 방지
        try { File.WriteAllText(CachePath, JsonUtility.ToJson(list, true)); } catch { }
    }
    private static SongsList LoadCache()
    {
        if (!File.Exists(CachePath)) return null;
        try { return JsonUtility.FromJson<SongsList>(File.ReadAllText(CachePath)); } catch { return null; }
    }
}

SongLibrary.cs

다운로드 상태를 song_library.json에 영구 저장하는 싱글턴. 파일시스템과 동기화 기능 포함.

SongLibrary.cs
// 싱글턴(Singleton) 패턴: 앱 전체에서 인스턴스 1개만 존재를 보장
public class SongLibrary : MonoBehaviour
{
    // { get; private set; } = 외부 읽기 가능, 내부만 쓰기 가능
    public static SongLibrary Instance { get; private set; }

    private const string FileName = "song_library.json";
    private static string SavePath => Path.Combine(Application.persistentDataPath, FileName);

    private LibraryData _data = new LibraryData(); // 메모리 내 상태

    private void Awake()
    {
        // 이미 Instance가 있으면(씬 재로드 등) 이 객체 파괴 → 1개 보장
        if (Instance != null) { Destroy(gameObject); return; }
        Instance = this;
        // DontDestroyOnLoad: 씬 전환 시 파괴되지 않음. 앱 수명 동안 유지
        DontDestroyOnLoad(gameObject);
        Load(); // 앱 시작 시 저장된 데이터 로드
    }

    // MarkDownloaded: 다운로드 완료 후 라이브러리에 기록
    public void MarkDownloaded(string songId, string difficulty)
    {
        LibraryEntry entry = GetOrCreate(songId);
        // Contains: 이미 있으면 중복 추가 방지
        if (!entry.difficulties.Contains(difficulty))
            entry.difficulties.Add(difficulty);
        // "o" 형식 = ISO 8601 (2026-05-22T13:00:00Z). UTC 기준으로 시간대 무관하게 비교 가능
        entry.lastAccessedAt = DateTime.UtcNow.ToString("o");
        Save(); // 즉시 파일에 저장 (앱 크래시해도 데이터 보존)
    }

    public void MarkSongRemoved(string songId)
    {
        // RemoveAll: 조건 람다를 만족하는 모든 항목 제거
        _data.entries.RemoveAll(e => e.songId == songId);
        Save();
    }

    public bool IsSongDownloaded(string songId) => Find(songId) != null;

    // ?. = null 조건부 접근. Find가 null이면 false 반환 (NullReferenceException 없음)
    // ?? false = Contains 결과가 null일 수 없지만 ?. 때문에 nullable → ?? 로 기본값
    public bool IsDifficultyDownloaded(string songId, string difficulty)
        => Find(songId)?.difficulties.Contains(difficulty) ?? false;

    // ── 파일시스템 검증: 라이브러리 기록 vs 실제 파일 동기화 ─────
    // 실제 파일이 없는데 라이브러리엔 있는 경우 (외부 삭제 등) 제거
    public void ValidateWithFileSystem(DownloadManager dm, List<SongInfo> songs)
    {
        bool dirty = false;
        foreach (SongInfo song in songs)
        {
            LibraryEntry entry = Find(song.id);
            if (entry == null) continue;

            // MP3 파일 없으면 이 곡 전체 제거
            if (!dm.IsSongDownloaded(song.id))
            { _data.entries.Remove(entry); dirty = true; continue; }

            // 각 난이도 파일 없는 것 제거
            entry.difficulties.RemoveAll(d => !dm.IsDifficultyDownloaded(song, d));
            if (entry.difficulties.Count == 0)
            { _data.entries.Remove(entry); dirty = true; }
        }
        // dirty 플래그: 변경이 있을 때만 파일 저장 (불필요한 IO 방지)
        if (dirty) Save();
    }

    private LibraryEntry Find(string songId)
        => _data.entries.Find(e => e.songId == songId); // 없으면 null

    private LibraryEntry GetOrCreate(string songId)
    {
        LibraryEntry entry = Find(songId);
        if (entry != null) return entry;
        entry = new LibraryEntry { songId = songId };
        _data.entries.Add(entry);
        return entry;
    }

    private void Load()
    {
        if (!File.Exists(SavePath)) return;
        try
        {
            string json = File.ReadAllText(SavePath);
            // ?? new: 파일이 있지만 파싱 실패하면 빈 데이터로 초기화
            _data = JsonUtility.FromJson<LibraryData>(json) ?? new LibraryData();
        }
        catch (Exception e)
        {
            Debug.LogWarning($"[SongLibrary] 로드 실패, 초기화: {e.Message}");
            _data = new LibraryData();
        }
    }

    private void Save() => File.WriteAllText(SavePath, JsonUtility.ToJson(_data, true));
}

[Serializable] public class LibraryData
{
    public List<LibraryEntry> entries = new List<LibraryEntry>();
}

[Serializable] public class LibraryEntry
{
    public string       songId;
    public List<string> difficulties   = new List<string>(); // ["normal","hard"]
    public string       lastAccessedAt; // ISO 8601 UTC 시각
}

MarqueeText.cs

텍스트가 컨테이너보다 길 때 자동으로 옆으로 스크롤하는 컴포넌트. 정지 → 왼쪽 스크롤 → 정지 → 반복.

MarqueeText.cs
// [RequireComponent] = TMP_Text 없이 이 컴포넌트만 붙이면 Unity가 자동으로 TMP_Text도 추가
[RequireComponent(typeof(TMP_Text))]
public class MarqueeText : MonoBehaviour
{
    public float speed      = 35f;  // 스크롤 속도 (units/초)
    public float pauseStart = 1.5f; // 시작 위치에서 대기 시간
    public float pauseEnd   = 0.6f; // 끝 위치에서 대기 시간 후 처음으로 돌아감

    private void Awake()
    {
        _label = GetComponent<TMP_Text>();
        _rect  = GetComponent<RectTransform>();
    }

    // Start가 IEnumerator를 반환하면 Unity가 자동으로 코루틴으로 실행
    private IEnumerator Start()
    {
        // yield return null: 1프레임 대기. 레이아웃이 완전히 계산된 후 크기를 읽어야 정확하다
        // Awake/Start에서 바로 읽으면 0이 나올 수 있음
        yield return null;

        _label.ForceMeshUpdate(); // TMP 메시 강제 갱신 → preferredWidth가 정확한 값 반환
        float textW      = _label.preferredWidth;                              // 텍스트 실제 너비
        float containerW = ((RectTransform)transform.parent).rect.width;  // 부모(TitleMask) 너비
        float dist       = textW - containerW; // 스크롤해야 할 거리

        // 텍스트가 컨테이너보다 1 unit 이상 길 때만 마퀴 시작. 짧으면 정지 상태 유지
        if (dist > 1f) StartCoroutine(ScrollLoop(dist));
    }

    private IEnumerator ScrollLoop(float dist)
    {
        while (true) // 무한 반복
        {
            SetX(0f); // 시작 위치(왼쪽 끝) 복귀
            yield return new WaitForSeconds(pauseStart); // 1.5초 정지

            float x = 0f;
            // x가 -dist에 도달할 때까지 매 프레임 이동 (왼쪽으로 스크롤)
            while (x > -dist)
            {
                // MoveTowards: 현재값에서 목표값 방향으로 최대 speed*dt 만큼 이동
                // 단순 x -= speed*dt 와 달리 목표값을 절대 넘지 않음
                x = Mathf.MoveTowards(x, -dist, speed * Time.deltaTime);
                SetX(x);
                yield return null; // 매 프레임 실행
            }

            yield return new WaitForSeconds(pauseEnd); // 0.6초 정지 후 처음으로
        }
    }

    // anchoredPosition의 y는 유지하고 x만 변경
    private void SetX(float x) =>
        _rect.anchoredPosition = new Vector2(x, _rect.anchoredPosition.y);
}

DesktopUIMode.cs

에디터/PC 전용 헬퍼. Quest 빌드에선 컴파일 자체가 안 된다. 마우스 클릭 활성화 + ESC 뒤로가기.

DesktopUIMode.cs
// #if !UNITY_ANDROID || UNITY_EDITOR
// = "안드로이드가 아니거나(PC/Mac) 에디터면" → Quest 실제 빌드에서는 이 파일 전체가 제외됨
public class DesktopUIMode : MonoBehaviour
{
// 이하 코드는 에디터 또는 PC 빌드에서만 포함
#if !UNITY_ANDROID || UNITY_EDITOR

    // [RuntimeInitializeOnLoadMethod] = 씬 시작 시 Unity가 자동 호출하는 static 메서드
    // AfterSceneLoad = 씬 오브젝트 Awake/Start 후에 실행
    // → 씬에 직접 배치하지 않아도 자동으로 생성됨
    [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.AfterSceneLoad)]
    private static void AutoCreate()
    {
        if (FindObjectOfType<DesktopUIMode>() != null) return; // 이미 있으면 스킵
        new GameObject("[DesktopUIMode]").AddComponent<DesktopUIMode>();
    }

    // 씬별 뒤로가기 맵: ESC 누르면 이 씬으로 이동
    private static readonly Dictionary<string, string> BackMap = new()
    {
        { "SongSelect", "Menu" }, { "SongCreator", "Menu" },
        { "Game", "SongSelect" },
    };

    private void Awake()
    {
        // FindObjectsByType: FindObjectsOfType의 최신 버전. 정렬 없음 = 더 빠름
        if (FindObjectsByType<DesktopUIMode>(FindObjectsSortMode.None).Length > 1)
        { Destroy(gameObject); return; } // 중복 방지

        DontDestroyOnLoad(gameObject);
        // 이벤트 구독: 씬 로드될 때마다 OnSceneLoaded 호출
        SceneManager.sceneLoaded += OnSceneLoaded;
        PatchCanvases();
    }

    // OnDestroy에서 이벤트 구독 해제 → 메모리 누수 방지
    private void OnDestroy() => SceneManager.sceneLoaded -= OnSceneLoaded;

    // 씬 로드 직후 1프레임 대기 후 패치 (새 씬 오브젝트가 모두 Awake 된 후)
    private void OnSceneLoaded(Scene s, LoadSceneMode m) => StartCoroutine(PatchAfterFrame());
    private System.Collections.IEnumerator PatchAfterFrame()
    { yield return null; PatchCanvases(); }

    private void Update()
    {
        RefreshCanvasCameras();
        // Keyboard.current?. = Input System. ?. = 키보드 없으면 null 안전
        // wasPressedThisFrame = 이번 프레임에 처음 눌렸을 때만 true
        if (Keyboard.current?.escapeKey.wasPressedThisFrame == true) GoBack();
    }

    private static void PatchCanvases()
    {
        foreach (var canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
        {
            if (canvas.renderMode != RenderMode.WorldSpace) continue;
            // VRBeatsKit Canvas에는 TrackedDeviceGraphicRaycaster가 붙어있음
            // XR Raycaster는 PC에서 마우스 클릭 안 됨 → 일반 GraphicRaycaster로 교체
            var tracked = canvas.GetComponent("TrackedDeviceGraphicRaycaster");
            if (tracked != null)
            {
                DestroyImmediate(tracked); // 즉시 삭제 (다음 줄에서 null 체크)
                if (canvas.GetComponent<GraphicRaycaster>() == null)
                    canvas.gameObject.AddComponent<GraphicRaycaster>();
            }
        }
        RemoveDuplicateAudioListeners(); // AudioListener 중복 시 경고 방지
        RefreshCanvasCameras();
    }

    private static void RefreshCanvasCameras()
    {
        // WorldSpace Canvas는 worldCamera가 설정돼야 화면에 올바르게 렌더링됨
        Camera cam = Camera.main;
        if (cam == null) // main 카메라 없으면 활성 카메라 중 첫 번째
            foreach (var c in FindObjectsByType<Camera>(FindObjectsSortMode.None))
                if (c.enabled && c.gameObject.scene.name != "DontDestroyOnLoad") { cam = c; break; }
        cam ??= FindObjectOfType<Camera>(); // 최후의 수단
        if (cam == null) return;

        foreach (var canvas in FindObjectsByType<Canvas>(FindObjectsSortMode.None))
            if (canvas.renderMode == RenderMode.WorldSpace && canvas.worldCamera != cam)
                canvas.worldCamera = cam;
    }

    // TryGetValue: 키가 없으면 false 반환 (KeyNotFoundException 없음)
    private static void GoBack()
    {
        if (BackMap.TryGetValue(SceneManager.GetActiveScene().name, out string target))
            SceneManager.LoadScene(target);
    }
#endif
}

XRSimulatorLoader.cs

에디터/PC에서 XR Interaction Simulator 프리팹을 자동으로 생성해주는 단순 로더.

XRSimulatorLoader.cs
public class XRSimulatorLoader : MonoBehaviour
{
    // Inspector에서 XR Interaction Simulator 프리팹을 연결
    // 경로: Assets/Samples/XR Interaction Toolkit/버전/XR Interaction Simulator/
    [SerializeField] private GameObject simulatorPrefab;

    private void Awake()
    {
// 에디터 또는 PC 빌드에서만 실행. Quest Android 빌드에선 이 블록이 제외됨
#if !UNITY_ANDROID || UNITY_EDITOR
        if (simulatorPrefab != null)
            Instantiate(simulatorPrefab); // 씬에 프리팹 인스턴스 생성
        else
            Debug.LogWarning("[XRSimulatorLoader] simulatorPrefab is not assigned.");
#endif
    }
    // 조작 방법 (에디터 실행 시):
    // 우클릭 드래그 = 머리 회전 | G + 마우스 = 오른쪽 컨트롤러 | Shift+G = 왼쪽
    // Space = 트리거 (UI 클릭)
}