// 풍류(Pungnyu) — Unity 에디터 BGM 생성기 // 설치: 이 파일을 Assets/Editor/ 에 넣으면 끝. 메뉴: Window > Pungnyu > BGM Generator // // 흐름: 프리셋/문장으로 요청 → 서버 큐에 작업 등록 → 완료되면 wav 를 프로젝트에 바로 저장. // 루프를 켜면 이음매가 들리지 않게 후처리된 wav 와 루프 구간 메타(loop.json)가 함께 온다. // // 에디터를 멈추지 않는다: 생성은 수 초-수십 초, GPU 가 자고 있으면 1-2분까지 걸리므로 // 요청을 블로킹으로 기다리면 유니티가 그동안 얼어붙는다. EditorApplication.update 로 폴링한다. #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using UnityEditor; using UnityEngine; using UnityEngine.Networking; namespace Pungnyu { public class PungnyuGenerateWindow : EditorWindow { private const string PREF_KEY = "PN_apiKey"; private const string PREF_URL = "PN_serverUrl"; private const string IMPORT_DIR = "Assets/Pungnyu"; private const string CLIENT_HEADER = "unity-editor"; private static readonly string[] PresetKeys = { "", "dungeon", "battle", "boss", "town", "field", "chiptune", "synthwave", "horror", "victory", "gameover", }; private static readonly string[] PresetLabels = { "(프리셋 없음)", "던전", "전투", "보스전", "마을", "필드", "칩튠", "신스웨이브", "호러", "승리 징글", "게임오버", }; private string serverUrl = "https://pungnyu.14dimension.com"; private string apiKey = ""; private int presetIdx; private string prompt = ""; private bool jingle; private int duration = 60; private bool loop = true; private bool variations; private string status = ""; private Vector2 scroll; private readonly List imported = new List(); // 진행 중 작업 (한 번에 하나만 — 서버도 계정당 동시 2건까지라 굳이 늘리지 않는다) private string jobId; private double nextPollAt; private bool polling; [MenuItem("Window/Pungnyu/BGM Generator")] public static void Open() { var w = GetWindow("풍류 BGM Generator"); w.minSize = new Vector2(420, 430); } private void OnEnable() { apiKey = EditorPrefs.GetString(PREF_KEY, apiKey); serverUrl = EditorPrefs.GetString(PREF_URL, serverUrl); } private void OnDisable() { StopPolling(); } private void OnGUI() { EditorGUI.BeginChangeCheck(); serverUrl = EditorGUILayout.TextField("서버 URL", serverUrl); apiKey = EditorGUILayout.PasswordField("API 키", apiKey); if (EditorGUI.EndChangeCheck()) { EditorPrefs.SetString(PREF_KEY, apiKey); EditorPrefs.SetString(PREF_URL, serverUrl); } EditorGUILayout.LabelField( "키 발급: " + serverUrl.TrimEnd('/') + "/app (로그인하면 자동 발급)", EditorStyles.miniLabel); EditorGUILayout.Space(6); presetIdx = EditorGUILayout.Popup("프리셋", presetIdx, PresetLabels); EditorGUILayout.LabelField("설명 (분위기·악기·템포 — 아티스트명은 쓸 수 없습니다)", EditorStyles.miniLabel); prompt = EditorGUILayout.TextArea(prompt, GUILayout.Height(46)); EditorGUILayout.Space(4); var wasJingle = jingle; jingle = EditorGUILayout.Toggle("징글 (4-15초)", jingle); if (jingle != wasJingle) duration = jingle ? 8 : 60; duration = EditorGUILayout.IntSlider("길이(초)", duration, jingle ? 4 : 15, jingle ? 15 : 180); loop = EditorGUILayout.Toggle("심리스 루프", loop); variations = EditorGUILayout.Toggle(new GUIContent("변주 세트 (+3곡)", "한 테마에서 기본·차분·긴장·앰비언트 4곡. Indie 플랜부터."), variations); EditorGUILayout.Space(6); using (new EditorGUI.DisabledScope(polling || string.IsNullOrEmpty(apiKey))) { if (GUILayout.Button(polling ? "생성 중…" : "생성", GUILayout.Height(30))) Generate(); } if (polling && GUILayout.Button("취소(대기만 중단)")) { StopPolling(); status = "대기를 중단했습니다. 생성은 서버에서 계속되며 앱(/app)에서 받을 수 있습니다."; } if (!string.IsNullOrEmpty(status)) EditorGUILayout.HelpBox(status, MessageType.Info); if (imported.Count > 0) { EditorGUILayout.Space(4); EditorGUILayout.LabelField("이번 세션에 받은 파일", EditorStyles.boldLabel); scroll = EditorGUILayout.BeginScrollView(scroll, GUILayout.Height(110)); for (int i = imported.Count - 1; i >= 0; i--) { GUILayout.BeginHorizontal("box"); EditorGUILayout.LabelField(Path.GetFileName(imported[i]), EditorStyles.miniLabel); if (GUILayout.Button("선택", GUILayout.Width(46))) { var obj = AssetDatabase.LoadAssetAtPath(imported[i]); if (obj != null) { Selection.activeObject = obj; EditorGUIUtility.PingObject(obj); } } GUILayout.EndHorizontal(); } EditorGUILayout.EndScrollView(); } EditorGUILayout.Space(2); EditorGUILayout.LabelField( "생성물은 서버에 7일만 보관됩니다 — 받은 파일은 프로젝트에 남습니다.", EditorStyles.miniLabel); } // ---- 생성 요청 ------------------------------------------------------- private void Generate() { var preset = PresetKeys[presetIdx]; if (string.IsNullOrEmpty(preset) && string.IsNullOrWhiteSpace(prompt)) { status = "프리셋을 고르거나 설명을 입력해 주세요."; return; } var req = new GenerateRequest { prompt = string.IsNullOrWhiteSpace(prompt) ? "game background music" : prompt.Trim(), preset = string.IsNullOrEmpty(preset) ? null : preset, kind = jingle ? "jingle" : "bgm", duration = duration, loop = loop, variations = variations, }; status = "요청 보내는 중…"; Repaint(); var text = Post("/api/generate", JsonUtility.ToJson(req), out var err); if (text == null) { status = "실패: " + err; return; } var res = JsonUtility.FromJson(text); if (string.IsNullOrEmpty(res.job)) { status = "실패: 작업 번호를 받지 못했습니다."; return; } jobId = res.job; status = "대기열에 들어갔습니다. GPU 가 자고 있으면 깨우는 데 1-2분 걸릴 수 있습니다."; StartPolling(); } private void StartPolling() { if (polling) return; polling = true; nextPollAt = EditorApplication.timeSinceStartup + 3; EditorApplication.update += OnUpdate; } private void StopPolling() { if (!polling) return; polling = false; EditorApplication.update -= OnUpdate; } private void OnUpdate() { if (EditorApplication.timeSinceStartup < nextPollAt) return; nextPollAt = EditorApplication.timeSinceStartup + 3; var text = Get("/api/jobs", out var err); if (text == null) { status = "조회 실패: " + err; Repaint(); return; } var jobs = JsonUtility.FromJson(text); if (jobs == null || jobs.jobs == null) return; foreach (var j in jobs.jobs) { if (j.id != jobId) continue; if (j.status == "queued") { status = jobs.worker == "warm" ? "대기 중… (GPU 준비됨)" : "GPU 를 깨우는 중… 1-2분 걸릴 수 있습니다."; } else if (j.status == "running") { status = "생성 중…"; } else if (j.status == "failed") { status = "생성 실패: " + (j.error ?? ""); StopPolling(); } else if (j.status == "done") { StopPolling(); Download(j); } Repaint(); return; } } // ---- 내려받아 프로젝트에 저장 --------------------------------------- private void Download(Job j) { if (!Directory.Exists(IMPORT_DIR)) Directory.CreateDirectory(IMPORT_DIR); int got = 0; foreach (var f in j.files) { // 오디오만 받는다. loop.json 은 같은 이름으로 함께 저장한다(엔진 루프 설정용). var route = "/api/tracks/" + j.id + "/" + f; var bytes = GetBytes(route, out var err); if (bytes == null) { status = "다운로드 실패: " + err; continue; } var stamp = DateTime.Now.ToString("MMdd_HHmmss"); var label = Sanitize(j.label); var baseName = string.Format("{0}_{1}_{2}", label, stamp, f); var path = Path.Combine(IMPORT_DIR, baseName).Replace('\\', '/'); File.WriteAllBytes(path, bytes); if (f.EndsWith(".wav", StringComparison.OrdinalIgnoreCase)) { imported.Add(path); got++; } } AssetDatabase.Refresh(); status = got > 0 ? got + "곡을 " + IMPORT_DIR + " 에 저장했습니다." : "받은 파일이 없습니다."; if (imported.Count > 0) { var clip = AssetDatabase.LoadAssetAtPath(imported[imported.Count - 1]); if (clip != null) { Selection.activeObject = clip; EditorGUIUtility.PingObject(clip); } } Repaint(); } private static string Sanitize(string s) { if (string.IsNullOrEmpty(s)) return "track"; var chars = s.Trim().ToCharArray(); for (int i = 0; i < chars.Length; i++) if (!char.IsLetterOrDigit(chars[i])) chars[i] = '_'; var outp = new string(chars); return outp.Length > 28 ? outp.Substring(0, 28) : outp; } // ---- HTTP ------------------------------------------------------------ private void Prepare(UnityWebRequest req) { req.SetRequestHeader("X-Api-Key", apiKey); // 공식 클라이언트 표식 — 서버가 웹 콘솔과 동등하게 취급한다(원시 API 는 Studio 전용). req.SetRequestHeader("X-Pungnyu-Client", CLIENT_HEADER); } private string Post(string route, string json, out string error) { using (var req = new UnityWebRequest(serverUrl.TrimEnd('/') + route, "POST")) { req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(json)); req.downloadHandler = new DownloadHandlerBuffer(); req.SetRequestHeader("Content-Type", "application/json"); Prepare(req); req.timeout = 60; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(20); if (req.result != UnityWebRequest.Result.Success) { error = Explain(req); return null; } error = null; return req.downloadHandler.text; } } private string Get(string route, out string error) { using (var req = UnityWebRequest.Get(serverUrl.TrimEnd('/') + route)) { Prepare(req); req.timeout = 30; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(20); if (req.result != UnityWebRequest.Result.Success) { error = Explain(req); return null; } error = null; return req.downloadHandler.text; } } private byte[] GetBytes(string route, out string error) { using (var req = UnityWebRequest.Get(serverUrl.TrimEnd('/') + route)) { Prepare(req); req.timeout = 180; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(20); if (req.result != UnityWebRequest.Result.Success) { error = Explain(req); return null; } error = null; return req.downloadHandler.data; } } /// 서버가 돌려준 detail 을 그대로 보여준다 — 한도 초과·플랜 안내가 여기 담긴다. private static string Explain(UnityWebRequest req) { var body = req.downloadHandler != null ? req.downloadHandler.text : ""; var m = System.Text.RegularExpressions.Regex.Match(body, @"""detail""\s*:\s*""([^""]*)"""); if (m.Success) return m.Groups[1].Value; return req.error + (string.IsNullOrEmpty(body) ? "" : " " + body); } // ---- DTO ------------------------------------------------------------- [Serializable] private class GenerateRequest { public string prompt; public string preset; public string kind; public int duration; public bool loop; public bool variations; } [Serializable] private class GenerateResponse { public string job; public bool queued; } [Serializable] private class Job { public string id; public string status; public string kind; public int duration; public bool loop; public string label; public string created; public string[] files; public string error; } [Serializable] private class JobsResponse { public Job[] jobs; public string worker; } } } #endif