비트 찍기 완료 및 클로드를 통한 api작업
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking;
|
||||
|
||||
// Beat Sage API 연동 (beatsage.com 역분석 기반)
|
||||
// 출처: BadgerHobbs/BeatSage-Downloader 소스코드
|
||||
public class BeatSageUploader : MonoBehaviour
|
||||
{
|
||||
private const string BASE_URL = "https://beatsage.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}"; // {0} = levelId
|
||||
|
||||
private const float POLL_INTERVAL = 5f;
|
||||
private const float POLL_TIMEOUT = 300f;
|
||||
|
||||
// Beat Sage 난이도 이름 매핑 (내부 → API)
|
||||
private static readonly Dictionary<string, string> DiffNames = new()
|
||||
{
|
||||
{ "easy", "Easy" },
|
||||
{ "normal", "Normal" },
|
||||
{ "hard", "Hard" },
|
||||
{ "expert", "Expert" },
|
||||
};
|
||||
|
||||
// Beat Sage .zip 내 .dat 파일명 매핑 (내부 → zip 내 파일명)
|
||||
private static readonly Dictionary<string, string> DatFileNames = new()
|
||||
{
|
||||
{ "easy", "Easy.dat" },
|
||||
{ "normal", "Normal.dat" },
|
||||
{ "hard", "Hard.dat" },
|
||||
{ "expert", "Expert.dat" },
|
||||
};
|
||||
|
||||
public string CurrentStatus { get; private set; } = "";
|
||||
|
||||
// ── Public API ───────────────────────────────────────────
|
||||
|
||||
public IEnumerator Upload(
|
||||
string audioPath,
|
||||
List<string> difficulties,
|
||||
float bpm,
|
||||
Action<float> onProgress,
|
||||
Action<Dictionary<string, List<NoteData>>> onComplete,
|
||||
Action<string> onError)
|
||||
{
|
||||
// 1단계: 레벨 생성 요청
|
||||
SetStatus("Beat Sage에 음원 전송 중...");
|
||||
string levelId = null;
|
||||
|
||||
yield return CreateLevel(audioPath, difficulties,
|
||||
id => levelId = id,
|
||||
onError);
|
||||
|
||||
if (levelId == null) yield break;
|
||||
onProgress?.Invoke(0.15f);
|
||||
|
||||
// 2단계: 생성 완료 폴링
|
||||
SetStatus("Beat Sage AI 맵 생성 중...");
|
||||
bool ready = false;
|
||||
float elapsed = 0f;
|
||||
|
||||
while (!ready && elapsed < POLL_TIMEOUT)
|
||||
{
|
||||
yield return new WaitForSeconds(POLL_INTERVAL);
|
||||
elapsed += POLL_INTERVAL;
|
||||
|
||||
bool error = false;
|
||||
yield return PollHeartbeat(levelId,
|
||||
status =>
|
||||
{
|
||||
ready = status == "generated";
|
||||
error = status == "error";
|
||||
},
|
||||
onError);
|
||||
|
||||
if (error) { onError?.Invoke("Beat Sage 생성 실패 (error 상태)"); yield break; }
|
||||
|
||||
float progress = Mathf.Clamp01(elapsed / POLL_TIMEOUT);
|
||||
onProgress?.Invoke(0.15f + progress * 0.6f);
|
||||
SetStatus($"Beat Sage AI 맵 생성 중... ({(int)elapsed}s)");
|
||||
}
|
||||
|
||||
if (!ready) { onError?.Invoke("Beat Sage 타임아웃 (5분 초과)"); yield break; }
|
||||
|
||||
// 3단계: .zip 다운로드
|
||||
SetStatus("결과 다운로드 중...");
|
||||
byte[] zipBytes = null;
|
||||
|
||||
yield return DownloadZip(levelId,
|
||||
bytes => zipBytes = bytes,
|
||||
onError);
|
||||
|
||||
if (zipBytes == null) yield break;
|
||||
onProgress?.Invoke(0.9f);
|
||||
|
||||
// 4단계: .zip 해제 + BeatSageConverter 변환
|
||||
SetStatus("맵 데이터 변환 중...");
|
||||
Dictionary<string, List<NoteData>> maps = null;
|
||||
|
||||
try
|
||||
{
|
||||
maps = ExtractAndConvert(zipBytes, difficulties, bpm);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
onError?.Invoke($"변환 실패: {e.Message}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
onProgress?.Invoke(1f);
|
||||
SetStatus("변환 완료");
|
||||
onComplete?.Invoke(maps);
|
||||
}
|
||||
|
||||
// ── Beat Sage API 요청 ────────────────────────────────────
|
||||
|
||||
private IEnumerator CreateLevel(string audioPath, List<string> difficulties,
|
||||
Action<string> onSuccess, Action<string> onError)
|
||||
{
|
||||
byte[] audioBytes = File.ReadAllBytes(audioPath);
|
||||
string fileName = Path.GetFileName(audioPath);
|
||||
|
||||
// 난이도 문자열 변환: ["easy","hard"] → "Easy,Hard"
|
||||
var diffStr = string.Join(",", difficulties.ConvertAll(d =>
|
||||
DiffNames.TryGetValue(d, out var n) ? n : d));
|
||||
|
||||
var form = new List<IMultipartFormSection>
|
||||
{
|
||||
new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg"),
|
||||
new MultipartFormDataSection("audio_metadata_title", ""),
|
||||
new MultipartFormDataSection("audio_metadata_artist", ""),
|
||||
new MultipartFormDataSection("difficulties", diffStr),
|
||||
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($"레벨 생성 요청 실패: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
string json = req.downloadHandler.text;
|
||||
string levelId = ParseJsonString(json, "id");
|
||||
Debug.Log($"[BeatSageUploader] 생성 응답: {json}");
|
||||
|
||||
if (string.IsNullOrEmpty(levelId))
|
||||
{
|
||||
onError?.Invoke($"levelId 파싱 실패. 응답: {json}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
onSuccess?.Invoke(levelId);
|
||||
}
|
||||
|
||||
private IEnumerator PollHeartbeat(string levelId, Action<string> onStatus, Action<string> onError)
|
||||
{
|
||||
string url = BASE_URL + string.Format(HEARTBEAT_EP, levelId);
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"상태 확인 실패: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 응답 예: { "status": "generated" } | { "status": "pending" } | { "status": "error" }
|
||||
string status = ParseJsonString(req.downloadHandler.text, "status");
|
||||
Debug.Log($"[BeatSageUploader] 상태: {status}");
|
||||
onStatus?.Invoke(status ?? "");
|
||||
}
|
||||
|
||||
private IEnumerator DownloadZip(string levelId, Action<byte[]> onSuccess, Action<string> onError)
|
||||
{
|
||||
string url = BASE_URL + string.Format(DOWNLOAD_EP, levelId);
|
||||
using var req = UnityWebRequest.Get(url);
|
||||
yield return req.SendWebRequest();
|
||||
|
||||
if (req.result != UnityWebRequest.Result.Success)
|
||||
{
|
||||
onError?.Invoke($"다운로드 실패: {req.error}");
|
||||
yield break;
|
||||
}
|
||||
|
||||
onSuccess?.Invoke(req.downloadHandler.data);
|
||||
}
|
||||
|
||||
// ── .zip 해제 + 변환 ──────────────────────────────────────
|
||||
|
||||
private static Dictionary<string, List<NoteData>> ExtractAndConvert(
|
||||
byte[] zipBytes, List<string> difficulties, float bpm)
|
||||
{
|
||||
var result = new Dictionary<string, List<NoteData>>();
|
||||
|
||||
using var ms = new MemoryStream(zipBytes);
|
||||
using var archive = new ZipArchive(ms, ZipArchiveMode.Read);
|
||||
|
||||
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($"[BeatSageUploader] {datName} 없음 — 건너뜀");
|
||||
continue;
|
||||
}
|
||||
|
||||
using var reader = new StreamReader(entry.Open(), Encoding.UTF8);
|
||||
result[diff] = BeatSageConverter.Convert(reader.ReadToEnd(), bpm);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── 유틸 ─────────────────────────────────────────────────
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
private void SetStatus(string msg) => CurrentStatus = msg;
|
||||
}
|
||||
Reference in New Issue
Block a user