// 청명(Cheongmyeong) — Unity 에디터 사운드 매처 // 설치: 이 파일을 Assets/Editor/ 에 넣으면 끝. 메뉴: Window > Cheongmyeong > Sound Matcher // // 흐름: 문장으로 검색(아무 언어) → 후보를 에디터 안에서 바로 시청(▶) → // 마음에 들면 [임포트]로 원본 파일을 프로젝트에 복사 → (선택) AudioSource에 할당. // 인덱싱은 이 창이 아니라 인덱싱 킷(사이트에서 다운로드)이 담당한다 — 캡션 생성에 GPU가 // 필요해서 로컬 킷으로 돌리는 구조이고, 덕분에 원본 오디오는 서버로 올라가지 않는다. #if UNITY_EDITOR using System; using System.Collections.Generic; using System.IO; using System.Reflection; using UnityEditor; using UnityEngine; using UnityEngine.Networking; namespace Cheongmyeong { public class CheongmyeongMatchWindow : EditorWindow { private const string PREF_KEY = "CM_apiKey"; private const string PREF_URL = "CM_serverUrl"; private const string PREF_MAP_FROM = "CM_remapFrom"; private const string PREF_MAP_TO = "CM_remapTo"; private const string IMPORT_DIR = "Assets/Cheongmyeong"; private string serverUrl = "https://cheongmyeong.14dimension.com"; private string apiKey = ""; // 라이브러리 경로 리매핑 — 인덱싱한 PC와 지금 PC의 마운트 경로가 다를 때 쓴다. // 예: NAS를 A PC는 Z:\SFX, B PC는 /Volumes/nas/SFX 로 잡은 경우. private string remapFrom = ""; private string remapTo = ""; private bool showSettings; private string query = ""; private string axisHigh = "무거운"; private string axisLow = "가벼운"; private string status = ""; private MatchResponse result; private AxisResponse axisResult; private Vector2 scroll; private AudioClip playingClip; [MenuItem("Window/Cheongmyeong/Sound Matcher")] public static void Open() { var w = GetWindow("청명 Sound Matcher"); w.minSize = new Vector2(430, 420); } private void OnEnable() { apiKey = EditorPrefs.GetString(PREF_KEY, apiKey); serverUrl = EditorPrefs.GetString(PREF_URL, serverUrl); remapFrom = EditorPrefs.GetString(PREF_MAP_FROM, remapFrom); remapTo = EditorPrefs.GetString(PREF_MAP_TO, remapTo); } /// 인덱싱 당시 경로를 이 PC의 경로로 바꾼다. /// · 인덱스가 상대경로면 "라이브러리 루트"를 기준으로 이어붙인다. /// · 절대경로면 "인덱싱한 경로 → 라이브러리 루트" 접두사 치환. private string RemapPath(string p) { if (string.IsNullOrEmpty(p) || string.IsNullOrEmpty(remapTo)) return p; var norm = p.Replace('\\', '/'); var to = remapTo.Replace('\\', '/').TrimEnd('/'); bool rooted = norm.Length > 1 && (norm[1] == ':' || norm[0] == '/'); if (!rooted) return to + "/" + norm.TrimStart('/'); // 상대경로 → 라이브러리 루트 기준 if (string.IsNullOrEmpty(remapFrom)) return p; var from = remapFrom.Replace('\\', '/').TrimEnd('/'); if (!norm.StartsWith(from, StringComparison.OrdinalIgnoreCase)) return p; return to + norm.Substring(from.Length); } private void OnDisable() { StopPreview(); } 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); } // 다른 PC에서 인덱싱했다면 라이브러리 경로가 다를 수 있다 → 접두사 치환. showSettings = EditorGUILayout.Foldout(showSettings, "라이브러리 경로 (다른 PC에서 인덱싱한 경우)"); if (showSettings) { EditorGUI.indentLevel++; EditorGUI.BeginChangeCheck(); remapTo = EditorGUILayout.TextField("라이브러리 루트", remapTo); remapFrom = EditorGUILayout.TextField("인덱싱한 경로(선택)", remapFrom); if (EditorGUI.EndChangeCheck()) { EditorPrefs.SetString(PREF_MAP_FROM, remapFrom); EditorPrefs.SetString(PREF_MAP_TO, remapTo); } EditorGUILayout.LabelField( "예: Z:/SFX → G:/내 드라이브/SFX. 비워두면 인덱싱 당시 경로를 그대로 씁니다.", EditorStyles.miniLabel); EditorGUI.indentLevel--; } EditorGUILayout.Space(6); EditorGUILayout.LabelField("어떤 소리가 필요한가요? (아무 언어나 — 소리를 묘사할수록 정확)", EditorStyles.miniLabel); GUILayout.BeginHorizontal(); query = EditorGUILayout.TextField(query); if (GUILayout.Button("검색", GUILayout.Width(64)) && !string.IsNullOrWhiteSpace(query)) Search(); GUILayout.EndHorizontal(); // 축 정렬 — "이 중에 더 무거운/가벼운 것" (절대 쿼리는 강도를 못 듣는다 — 실측) GUILayout.BeginHorizontal(); axisHigh = EditorGUILayout.TextField(axisHigh, GUILayout.Width(110)); GUILayout.Label("↔", GUILayout.Width(18)); axisLow = EditorGUILayout.TextField(axisLow, GUILayout.Width(110)); if (GUILayout.Button("축 정렬", GUILayout.Width(74)) && !string.IsNullOrWhiteSpace(query)) AxisSort(); if (GUILayout.Button("■ 정지", GUILayout.Width(64))) StopPreview(); GUILayout.EndHorizontal(); if (!string.IsNullOrEmpty(status)) EditorGUILayout.HelpBox(status, MessageType.Info); scroll = EditorGUILayout.BeginScrollView(scroll); if (axisResult != null && axisResult.results != null) { EditorGUILayout.LabelField($"축: {axisResult.axis.high} ↔ {axisResult.axis.low}", EditorStyles.boldLabel); for (int i = 0; i < axisResult.results.Length; i++) DrawRow(axisResult.results[i].name, axisResult.results[i].caption, axisResult.results[i].duration_sec, axisResult.results[i].position.ToString("+0.000;-0.000"), axisResult.results[i].preview_url, axisResult.results[i].file_path); } else if (result != null && result.candidates != null) { if (result.normalized_query != null && !string.IsNullOrEmpty(result.normalized_query.text) && result.normalized_query.text != query) EditorGUILayout.LabelField("해석: " + result.normalized_query.text, EditorStyles.miniLabel); for (int i = 0; i < result.candidates.Length; i++) { var c = result.candidates[i]; DrawRow((i + 1) + ". " + FileNameOf(c.file_path), c.caption, c.duration_sec, c.score.ToString("0.000"), c.preview_url, c.file_path); } } EditorGUILayout.EndScrollView(); EditorGUILayout.Space(4); EditorGUILayout.LabelField("인덱싱은 킷으로: " + serverUrl.TrimEnd('/') + "/download/cheongmyeong_kit.zip", EditorStyles.miniLabel); } private static string FileNameOf(string path) { try { return Path.GetFileName(path.Replace('\\', '/')); } catch { return path; } } private void DrawRow(string title, string caption, float duration, string right, string previewUrl, string filePath) { EditorGUILayout.BeginVertical("box"); GUILayout.BeginHorizontal(); if (GUILayout.Button("▶", GUILayout.Width(28))) PlayPreview(previewUrl); EditorGUILayout.LabelField(title, EditorStyles.boldLabel); GUILayout.FlexibleSpace(); GUILayout.Label((duration > 0 ? duration.ToString("0.0") + "s · " : "") + right, EditorStyles.miniLabel, GUILayout.Width(110)); GUILayout.EndHorizontal(); if (!string.IsNullOrEmpty(caption)) EditorGUILayout.LabelField(caption, EditorStyles.wordWrappedMiniLabel); GUILayout.BeginHorizontal(); if (GUILayout.Button("프로젝트로 임포트", GUILayout.Width(130))) ImportToProject(filePath, previewUrl); if (GUILayout.Button("선택 오브젝트에 할당", GUILayout.Width(150))) AssignToSelection(filePath, previewUrl); EditorGUILayout.LabelField(filePath, EditorStyles.miniLabel); GUILayout.EndHorizontal(); EditorGUILayout.EndVertical(); } // ---- 검색 ---------------------------------------------------------- private void Search() { axisResult = null; status = "검색 중… (오랜만의 첫 검색은 언어 해석 때문에 수 초 걸릴 수 있음)"; Repaint(); var body = JsonUtility.ToJson(new MatchRequest { query = query, top_k = 5 }); var text = PostBlocking("/match", body, out var err); if (text == null) { status = "실패: " + err; return; } result = JsonUtility.FromJson(text); status = result.candidates != null && result.candidates.Length > 0 ? "" : "후보가 없습니다 — 인덱싱이 되어 있나요? (킷으로 라이브러리를 먼저 인덱싱)"; } private void AxisSort() { status = "축 정렬 중…"; Repaint(); var body = JsonUtility.ToJson(new AxisRequest { query = query, high = axisHigh, low = axisLow, top_k = 8 }); var text = PostBlocking("/axis", body, out var err); if (text == null) { status = "실패: " + err; return; } axisResult = JsonUtility.FromJson(text); status = ""; } private string PostBlocking(string route, string json, out string error) { 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"); req.SetRequestHeader("X-API-Key", apiKey); req.timeout = 120; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(30); if (req.result != UnityWebRequest.Result.Success) { error = req.error + " " + req.downloadHandler.text; return null; } error = null; return req.downloadHandler.text; } // ---- 프리뷰 단기 토큰 -------------------------------------------------- // URL 에 장기 API 키를 넣으면 브라우저 기록·프록시 로그·공유된 스크린샷에 남아 // 키 하나로 계정 전체가 넘어간다. 서버는 `?key=` 를 400 으로 거부하므로 // 단기 토큰(t=)을 받아 쓴다. 토큰은 짧게 만료되고 재생 외엔 쓸 수 없다. private string previewToken = null; private double previewTokenAt = 0; private string PreviewToken() { // 만료 여유를 두고 재사용 (매 재생마다 왕복하면 체감이 느려진다) if (previewToken != null && EditorApplication.timeSinceStartup - previewTokenAt < 240) return previewToken; var req = UnityWebRequest.Get(serverUrl.TrimEnd('/') + "/preview/token"); req.SetRequestHeader("x-api-key", apiKey); req.timeout = 15; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(20); if (req.result != UnityWebRequest.Result.Success) return null; var m = System.Text.RegularExpressions.Regex.Match( req.downloadHandler.text, @"""token""\s*:\s*""([^""]+)"""); if (!m.Success) return null; previewToken = m.Groups[1].Value; previewTokenAt = EditorApplication.timeSinceStartup; return previewToken; } // ---- 시청 (에디터 내 재생) ------------------------------------------ private void PlayPreview(string previewUrl) { if (string.IsNullOrEmpty(previewUrl)) { status = "프리뷰가 없습니다."; return; } StopPreview(); var tk = PreviewToken(); if (tk == null) { status = "프리뷰 토큰을 받지 못했습니다 (키·서버 확인)."; return; } var url = serverUrl.TrimEnd('/') + previewUrl + "?t=" + UnityWebRequest.EscapeURL(tk); var req = UnityWebRequestMultimedia.GetAudioClip(url, AudioType.OGGVORBIS); req.timeout = 30; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(20); if (req.result != UnityWebRequest.Result.Success) { status = "프리뷰 로드 실패: " + req.error; return; } playingClip = DownloadHandlerAudioClip.GetContent(req); AudioUtilCall("PlayPreviewClip", "PlayClip", playingClip); } private void StopPreview() { AudioUtilCall("StopAllPreviewClips", "StopAllClips", null); playingClip = null; } // Unity 버전에 따라 AudioUtil 메서드명이 다르다(2020.2+ PlayPreviewClip / 이전 PlayClip) private static void AudioUtilCall(string newName, string oldName, AudioClip clip) { try { var t = typeof(AudioImporter).Assembly.GetType("UnityEditor.AudioUtil"); MethodInfo m = null; foreach (var name in new[] { newName, oldName }) { if (clip != null) m = t.GetMethod(name, BindingFlags.Static | BindingFlags.Public, null, new[] { typeof(AudioClip), typeof(int), typeof(bool) }, null) ?? t.GetMethod(name, BindingFlags.Static | BindingFlags.Public, null, new[] { typeof(AudioClip) }, null); else m = t.GetMethod(name, BindingFlags.Static | BindingFlags.Public, null, Type.EmptyTypes, null); if (m != null) break; } if (m == null) return; if (clip == null) m.Invoke(null, null); else if (m.GetParameters().Length == 3) m.Invoke(null, new object[] { clip, 0, false }); else m.Invoke(null, new object[] { clip }); } catch (Exception e) { Debug.LogWarning("[청명] 에디터 재생 실패: " + e.Message); } } // ---- 임포트 · 할당 -------------------------------------------------- /// 원본(file_path = 인덱싱한 PC의 로컬 경로)을 프로젝트로 복사. 원본이 없으면 /// 서버 프리뷰(10초 저음질 ogg)로 폴백하되 파일명에 _preview를 붙여 구분한다. private string ImportToProject(string filePath, string previewUrl) { try { Directory.CreateDirectory(IMPORT_DIR); string dest; filePath = RemapPath(filePath); if (!string.IsNullOrEmpty(filePath) && File.Exists(filePath)) { dest = Path.Combine(IMPORT_DIR, Path.GetFileName(filePath)).Replace('\\', '/'); File.Copy(filePath, dest, true); } else { var tk = PreviewToken(); if (tk == null) { status = "임포트 실패: 프리뷰 토큰을 받지 못했습니다."; return null; } var url = serverUrl.TrimEnd('/') + previewUrl + "?t=" + UnityWebRequest.EscapeURL(tk); var req = UnityWebRequest.Get(url); req.timeout = 30; var op = req.SendWebRequest(); while (!op.isDone) System.Threading.Thread.Sleep(20); if (req.result != UnityWebRequest.Result.Success) { status = "임포트 실패: 원본도 프리뷰도 가져올 수 없음"; return null; } var baseName = Path.GetFileNameWithoutExtension(FileNameOf(filePath ?? "clip")); dest = IMPORT_DIR + "/" + baseName + "_preview.ogg"; File.WriteAllBytes(dest, req.downloadHandler.data); status = "원본이 이 PC에 없어 프리뷰(저음질 10초)로 임포트했습니다. " + "다른 PC에서 인덱싱했다면 위 '라이브러리 경로'를 설정하세요."; } AssetDatabase.Refresh(); var asset = AssetDatabase.LoadAssetAtPath(dest); if (asset != null) EditorGUIUtility.PingObject(asset); return dest; } catch (Exception e) { status = "임포트 실패: " + e.Message; return null; } } private void AssignToSelection(string filePath, string previewUrl) { var go = Selection.activeGameObject; if (go == null) { status = "먼저 하이어라키에서 오브젝트를 선택하세요."; return; } var dest = ImportToProject(filePath, previewUrl); if (dest == null) return; var clip = AssetDatabase.LoadAssetAtPath(dest); if (clip == null) { status = "AudioClip 로드 실패"; return; } var src = go.GetComponent(); if (src == null) src = Undo.AddComponent(go); Undo.RecordObject(src, "Assign Cheongmyeong clip"); src.clip = clip; EditorUtility.SetDirty(src); status = go.name + " 의 AudioSource에 할당했습니다: " + clip.name; } // ---- DTO ------------------------------------------------------------ [Serializable] private class MatchRequest { public string query; public int top_k; } [Serializable] private class NormalizedQuery { public string text; public bool llm; } [Serializable] private class Candidate { public string file_path; public string name_ko; public float score; public string preview_url; public string caption; public float duration_sec; } [Serializable] private class MatchResponse { public int winner_index; public string match_quality; public string reason; public NormalizedQuery normalized_query; public Candidate[] candidates; } [Serializable] private class AxisRequest { public string query; public string high; public string low; public int top_k; } [Serializable] private class AxisPoles { public string high; public string low; } [Serializable] private class AxisItem { public string file_path; public string name; public string caption; public float position; public string rid; public string preview_url; public float duration_sec; } [Serializable] private class AxisResponse { public AxisPoles axis; public AxisItem[] results; } } } #endif