362 lines
13 KiB
C#
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; }
|
||
|
|
}
|