Files
BeatSaber/Assets/Script/SongCreatorManager.cs
T
whdwo798 4dad9e5d5b feat: SongCreator 씬 완성 — Beat Sage URL 지원, info.dat 메타데이터 자동 추출
- BeatSageUploader: audio_url 지원(UploadFromUrl), PollAndDownload 공통화, ZIP 500 오류 3회 재시도
- BeatSageConverter: info.dat 파싱(SongMetadata), BPM 자동 감지 → 노트 타이밍 변환에 적용
- SongCreatorManager: title/BPM 필수 입력 제거, 난이도 4개 자동 선택, GenerateFlowFromUrl 버그 수정
- NasPublisher: audioPath null 허용(URL 흐름에서 로컬 파일 없는 경우 스킵)
- .gitignore/.gitattributes 초기 설정

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 23:37:34 +09:00

362 lines
13 KiB
C#

using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using TMPro;
using UnityEngine;
using UnityEngine.Networking;
using UnityEngine.SceneManagement;
using UnityEngine.UI;
public class SongCreatorManager : MonoBehaviour
{
[Header("Audio Source")]
[SerializeField] private TMP_Dropdown audioDropdown;
[SerializeField] private Button refreshBtn;
[SerializeField] private TMP_Text inputPathHint;
[Header("Audio — Local File")]
[SerializeField] private Button filePickerBtn;
[SerializeField] private TMP_Text addStatusText;
[Header("Audio — URL")]
[SerializeField] private TMP_InputField urlInput;
[SerializeField] private Button urlDownloadBtn;
[Header("Metadata")]
[SerializeField] private TMP_InputField titleInput;
[SerializeField] private TMP_InputField artistInput;
[SerializeField] private TMP_InputField bpmInput;
[Header("Difficulty")]
[SerializeField] private Toggle toggleNormal;
[SerializeField] private Toggle toggleHard;
[SerializeField] private Toggle toggleExpert;
[SerializeField] private Toggle toggleExpertPlus;
[Header("Actions")]
[SerializeField] private Button generateButton;
[SerializeField] private Button manualEditorButton;
[SerializeField] private Button backButton;
[SerializeField] private string menuSceneName = "Menu";
[Header("Progress")]
[SerializeField] private GameObject progressGroup;
[SerializeField] private TMP_Text statusText;
[SerializeField] private Slider progressSlider;
[Header("References")]
[SerializeField] private BeatSageUploader beatSageUploader;
[SerializeField] private NasPublisher nasPublisher;
private static string InputPath =>
Path.Combine(Application.persistentDataPath, "input");
private readonly List<string> audioFiles = new();
private string _pendingFilePath;
private void Start()
{
Directory.CreateDirectory(InputPath);
if (inputPathHint != null)
inputPathHint.text = $"Path: {InputPath}";
refreshBtn?.onClick.AddListener(RefreshAudioList);
generateButton?.onClick.AddListener(OnGenerateClicked);
backButton?.onClick.AddListener(() => SceneManager.LoadScene(menuSceneName));
filePickerBtn?.onClick.AddListener(OnFilePickerClicked);
urlDownloadBtn?.onClick.AddListener(OnUrlDownloadClicked);
if (progressGroup != null) progressGroup.SetActive(false);
RefreshAudioList();
}
private void Update()
{
if (_pendingFilePath != null)
{
CopyToInput(_pendingFilePath);
_pendingFilePath = null;
}
}
private void RefreshAudioList()
{
audioFiles.Clear();
audioDropdown?.ClearOptions();
var options = new List<string>();
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. Add a file or enter a URL."); return; }
// BPM input is optional — Beat Sage auto-detects from audio; use as fallback only
float.TryParse(bpmInput?.text, out float bpmHint);
var diffs = new List<string> { "normal", "hard", "expert", "expertplus" };
if (hasUrl)
{
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
{
StartCoroutine(GenerateFlow(audioFiles[audioDropdown.value], bpmHint, diffs));
}
}
private IEnumerator GenerateFlowFromUrl(string audioUrl, float bpm, List<string> diffs)
{
SetInteractable(false);
if (progressGroup != null) progressGroup.SetActive(true);
Dictionary<string, List<NoteData>> maps = null;
bool failed = false;
yield return beatSageUploader.UploadFromUrl(
audioUrl, diffs, bpm,
onProgress: p =>
{
if (progressSlider != null) 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(audioUrl, bpm, maps);
yield return nasPublisher.Publish(
song, null, maps,
onProgress: p =>
{
if (progressSlider != null) progressSlider.value = 0.8f + p * 0.2f;
SetStatus($"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)");
},
onComplete: () =>
{
if (progressSlider != null) progressSlider.value = 1f;
SetStatus($"Done! '{song.title}' created successfully.");
},
onError: err => { SetStatus($"NAS upload failed: {err}"); failed = true; });
SetInteractable(true);
}
private IEnumerator GenerateFlow(string audioPath, float bpm, List<string> diffs)
{
SetInteractable(false);
if (progressGroup != null) progressGroup.SetActive(true);
Dictionary<string, List<NoteData>> maps = null;
bool failed = false;
yield return beatSageUploader.Upload(
audioPath, diffs, bpm,
onProgress: p =>
{
if (progressSlider != null) 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);
yield return nasPublisher.Publish(
song, audioPath, maps,
onProgress: p =>
{
if (progressSlider != null) progressSlider.value = 0.8f + p * 0.2f;
SetStatus($"[4/4] Uploading to NAS... ({(int)((0.8f + p * 0.2f) * 100)}%)");
},
onComplete: () =>
{
if (progressSlider != null) progressSlider.value = 1f;
SetStatus($"Done! '{song.title}' created successfully.");
},
onError: err => { SetStatus($"NAS upload failed: {err}"); failed = true; });
SetInteractable(true);
}
private SongInfo BuildSongInfo(string audioPath, float fallbackBpm,
Dictionary<string, List<NoteData>> maps)
{
// Prefer values from info.dat (auto-detected by Beat Sage); UI inputs override if non-empty
var meta = beatSageUploader != null ? beatSageUploader.LastMetadata : null;
string uiTitle = titleInput?.text.Trim() ?? "";
string uiArtist = artistInput?.text.Trim() ?? "";
float.TryParse(bpmInput?.text, out float uiBpm);
string title = !string.IsNullOrEmpty(uiTitle) ? uiTitle : (meta?.title ?? "");
string artist = !string.IsNullOrEmpty(uiArtist) ? uiArtist : (meta?.artist ?? "");
float bpm = (meta != null && meta.bpm > 0) ? meta.bpm : (uiBpm > 0 ? uiBpm : fallbackBpm);
// Fallback id from filename if title is still empty
if (string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(audioPath))
title = Path.GetFileNameWithoutExtension(audioPath);
if (string.IsNullOrEmpty(title))
title = $"song_{DateTime.Now:yyyyMMdd_HHmmss}";
string id = title.ToLower().Replace(" ", "_");
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",
difficulties = diffMap,
addedAt = DateTime.Now.ToString("yyyy-MM-dd"),
};
}
private void SetStatus(string msg) { if (statusText != null) statusText.text = msg; }
private void SetInteractable(bool value)
{
if (generateButton != null) generateButton.interactable = value;
if (audioDropdown != null) audioDropdown.interactable = value;
if (refreshBtn != null) refreshBtn.interactable = value;
if (filePickerBtn != null) filePickerBtn.interactable = value;
if (urlDownloadBtn != null) urlDownloadBtn.interactable = value;
}
private void OnFilePickerClicked()
{
#if UNITY_EDITOR
string path = UnityEditor.EditorUtility.OpenFilePanel("Select audio file", "", "mp3");
if (!string.IsNullOrEmpty(path)) CopyToInput(path);
#elif UNITY_STANDALONE_WIN
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;
});
t.SetApartmentState(ApartmentState.STA);
t.Start();
#else
SetAddStatus($"Copy file via ADB:\n{InputPath}");
#endif
}
private void CopyToInput(string srcPath)
{
try
{
string dest = Path.Combine(InputPath, Path.GetFileName(srcPath));
File.Copy(srcPath, dest, overwrite: true);
RefreshAudioList();
string nameNoExt = Path.GetFileNameWithoutExtension(srcPath);
int idx = audioFiles.FindIndex(f => Path.GetFileNameWithoutExtension(f) == nameNoExt);
if (idx >= 0 && audioDropdown != null) audioDropdown.value = idx;
SetAddStatus($"Added: {Path.GetFileName(srcPath)}");
}
catch (Exception e) { SetAddStatus($"File copy failed: {e.Message}"); }
}
private void OnUrlDownloadClicked()
{
string url = urlInput != null ? urlInput.text.Trim() : "";
if (string.IsNullOrEmpty(url)) { SetAddStatus("Please enter a URL."); return; }
if (!Uri.TryCreate(url, UriKind.Absolute, out var uri) ||
(uri.Scheme != "http" && uri.Scheme != "https"))
{
SetAddStatus($"Invalid URL: must start with http:// or https://");
return;
}
// Streaming service URLs cannot be downloaded directly
string host = uri.Host.ToLower();
if (host.Contains("youtube.com") || host.Contains("youtu.be") ||
host.Contains("spotify.com") || host.Contains("soundcloud.com") ||
host.Contains("music.apple.com"))
{
SetAddStatus("Streaming URLs not supported.\nUse a direct .mp3 download link or Browse File.");
return;
}
StartCoroutine(DownloadFromUrl(uri.AbsoluteUri));
}
private IEnumerator DownloadFromUrl(string url)
{
SetAddStatus("Downloading...");
if (urlDownloadBtn != null) urlDownloadBtn.interactable = false;
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 SetAddStatus(string msg) { if (addStatusText != null) addStatusText.text = msg; }
}