- 스킬앱 HTML 내장화: accountant/lawyer/investor/weather/music/mind/dental-agent 각 앱이 SKILL_CONTENT를 HTML에 직접 내장 → .smallclaw/skills/ SKILL.md 파일 제거 - 새 게이트웨이 라우트: routes-dental, routes-music, routes-mcp, routes-voice - skillContext 서버 지원: /api/chat에서 skill 컨텍스트를 callerCtx로 주입 - 세션 삭제 API: DELETE /api/admin/sessions/:username/:id - Telegram defaultUsername: 어드민 계정 자동 추론 - Ollama /api/ollama/models에 vision 지원 여부 플래그 추가 - DB 워크플로 개선: workflow_images.py 대규모 리팩터링 - 아두이노 에뮬레이터 추가 개선 (arduino-emulator.html +400줄) - 우즈베크어 학습 콘텐츠 및 MIDI 파일 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
747 lines
56 KiB
HTML
747 lines
56 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>🎵 음악 스튜디오</title>
|
|
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
|
|
<link rel="stylesheet" href="styles.css">
|
|
<script>
|
|
try { var _t=localStorage.getItem('cherryclaw_theme')||'dark'; document.documentElement.setAttribute('data-theme',_t); } catch(e){}
|
|
</script>
|
|
<style>
|
|
html,body{height:100%;margin:0;padding:0;}
|
|
body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;display:flex;flex-direction:column;overflow:hidden;height:100dvh;}
|
|
|
|
/* ── Header ── */
|
|
.ms-hdr{display:flex;align-items:center;gap:8px;padding:6px 12px;background:var(--panel);border-bottom:1px solid var(--line);flex-shrink:0;min-width:0;}
|
|
.ms-hdr h1{font-size:13px;font-weight:700;margin:0;}
|
|
.ms-hdr-spacer{flex:1;}
|
|
.ms-hdr-btn{background:none;border:1.5px solid var(--line);border-radius:6px;padding:3px 9px;font-size:11px;color:var(--muted);cursor:pointer;text-decoration:none;display:inline-flex;align-items:center;transition:.15s;font-family:inherit;}
|
|
.ms-hdr-btn:hover{border-color:#a855f7;color:#a855f7;}
|
|
.skill-badge{font-size:11px;padding:2px 8px;border-radius:12px;background:rgba(168,85,247,.12);border:1px solid rgba(168,85,247,.3);color:#c084fc;font-weight:700;}
|
|
|
|
/* ── Layout ── */
|
|
.ms-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
|
|
|
|
/* ── Sidebar ── */
|
|
.ms-sb{width:200px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;overflow:hidden;}
|
|
.ms-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;}
|
|
.ms-quick{padding:0 6px 6px;display:flex;flex-direction:column;gap:3px;flex-shrink:0;}
|
|
.ms-qbtn{background:none;border:1px solid var(--line);border-radius:6px;padding:5px 9px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
.ms-qbtn:hover{border-color:#a855f7;color:#a855f7;background:rgba(168,85,247,.06);}
|
|
.ms-divider{height:1px;background:var(--line);margin:4px 0;}
|
|
.ms-file-hdr{display:flex;align-items:center;padding:0 10px 3px;flex-shrink:0;}
|
|
.ms-file-hdr span{flex:1;font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;}
|
|
.ms-file-hdr button{background:none;border:none;padding:0;cursor:pointer;font-size:11px;color:var(--muted);transition:.12s;}
|
|
.ms-file-hdr button:hover{color:#a855f7;}
|
|
.ms-files{flex:1;overflow-y:auto;padding:0 4px;}
|
|
.ms-fi{display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:5px;cursor:pointer;font-size:11px;color:var(--muted);transition:.12s;}
|
|
.ms-fi:hover{background:rgba(168,85,247,.08);color:var(--text);}
|
|
.ms-fi-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
|
|
.ms-fi-btn{background:none;border:none;cursor:pointer;font-size:12px;padding:1px 3px;color:var(--muted);flex-shrink:0;border-radius:3px;transition:.12s;}
|
|
.ms-fi-btn:hover.play{color:#22c55e;}
|
|
.ms-fi-btn:hover.del{color:#ef4444;}
|
|
.ms-upload{margin:6px;border:1px dashed var(--line);border-radius:6px;padding:5px;font-size:11px;color:var(--muted);cursor:pointer;text-align:center;background:none;font-family:inherit;transition:.15s;display:block;width:calc(100% - 12px);}
|
|
.ms-upload:hover{border-color:#a855f7;color:#a855f7;}
|
|
|
|
/* ── Stage ── */
|
|
.ms-stage{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--bg);}
|
|
.ms-stage-hdr{display:flex;align-items:center;padding:7px 12px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;gap:8px;}
|
|
.ms-stage-title{font-size:12px;font-weight:700;color:var(--text);flex:1;}
|
|
.ms-stage-btn{background:none;border:1px solid var(--line);border-radius:5px;padding:3px 8px;font-size:11px;color:var(--muted);cursor:pointer;font-family:inherit;transition:.15s;}
|
|
.ms-stage-btn:hover{border-color:#a855f7;color:#a855f7;}
|
|
.ms-stage-body{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:10px;}
|
|
.ms-stage-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--muted);gap:8px;padding:40px 20px;text-align:center;}
|
|
.ms-stage-empty .icon{font-size:48px;opacity:.4;}
|
|
.ms-stage-empty p{font-size:12px;line-height:1.6;max-width:240px;}
|
|
|
|
/* ── Resize handle ── */
|
|
.ms-resize{width:4px;flex-shrink:0;background:var(--line);cursor:col-resize;transition:.15s;position:relative;}
|
|
.ms-resize:hover,.ms-resize.dragging{background:#a855f7;}
|
|
|
|
/* ── Chat panel ── */
|
|
.ms-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--line);background:var(--panel);min-height:0;overflow:hidden;}
|
|
.ms-chat-hdr{display:flex;align-items:center;padding:6px 10px;border-bottom:1px solid var(--line);background:var(--panel);flex-shrink:0;gap:6px;}
|
|
.ms-chat-hdr-label{font-size:10px;font-weight:700;color:#a855f7;text-transform:uppercase;letter-spacing:.07em;flex-shrink:0;}
|
|
.ms-model-sel{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:3px 6px;font-size:11px;color:var(--text);font-family:inherit;min-width:0;}
|
|
.ms-chat-clear{background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px;transition:.12s;}
|
|
.ms-chat-clear:hover{color:#ef4444;}
|
|
.ms-msgs{flex:1;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px;}
|
|
.ms-msg{display:flex;flex-direction:column;}
|
|
.ms-msg.user{align-items:flex-end;}
|
|
.ms-msg.assistant{align-items:flex-start;}
|
|
.ms-msg.system{align-items:center;}
|
|
.ms-bubble{padding:7px 11px;border-radius:10px;font-size:12px;line-height:1.55;max-width:92%;word-break:break-word;}
|
|
.ms-msg.user .ms-bubble{background:#581c87;color:#f3e8ff;border-radius:10px 10px 3px 10px;}
|
|
.ms-msg.assistant .ms-bubble{background:var(--panel-2);border:1px solid var(--line);border-radius:10px 10px 10px 3px;}
|
|
.ms-msg.system .ms-bubble{background:transparent;color:var(--muted);font-size:11px;}
|
|
.ms-bubble pre{background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:8px;overflow-x:auto;font-size:11px;margin:6px 0;}
|
|
.ms-bubble code{font-size:11px;}
|
|
.ms-bubble p{margin:3px 0;}
|
|
.ms-bubble h3{font-size:13px;margin:6px 0 3px;}
|
|
.ms-bubble ul,.ms-bubble ol{padding-left:16px;margin:3px 0;}
|
|
.ms-thinking{padding:4px 10px;border-radius:6px;font-size:10px;color:var(--muted);background:var(--bg);border:1px solid var(--line);font-style:italic;overflow:hidden;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;cursor:pointer;margin-bottom:4px;}
|
|
.ms-thinking.exp{display:block;-webkit-line-clamp:unset;max-height:200px;overflow-y:auto;}
|
|
.ms-typing{display:flex;gap:3px;align-items:center;padding:6px 10px;}
|
|
.ms-dot{width:5px;height:5px;background:var(--muted);border-radius:50%;animation:msDot 1.2s ease-in-out infinite;}
|
|
.ms-dot:nth-child(2){animation-delay:.2s;}.ms-dot:nth-child(3){animation-delay:.4s;}
|
|
@keyframes msDot{0%,80%,100%{transform:scale(.5);opacity:.4;}40%{transform:scale(1);opacity:1;}}
|
|
.ms-stage-link{font-size:10px;color:#a855f7;cursor:pointer;text-decoration:underline;margin-top:2px;display:inline-block;}
|
|
|
|
/* ── Input ── */
|
|
.ms-input-bar{flex-shrink:0;padding:8px;border-top:1px solid var(--line);display:flex;gap:6px;align-items:flex-end;}
|
|
.ms-textarea{flex:1;background:var(--panel-2);border:1px solid var(--line);border-radius:8px;padding:7px 10px;font-size:12px;color:var(--text);font-family:inherit;resize:none;min-height:34px;max-height:120px;outline:none;transition:.15s;}
|
|
.ms-textarea:focus{border-color:#a855f7;}
|
|
.ms-send{background:#6b21a8;border:none;border-radius:8px;padding:7px 13px;font-size:12px;color:#fff;cursor:pointer;font-family:inherit;font-weight:700;transition:.15s;flex-shrink:0;}
|
|
.ms-send:hover:not(:disabled){background:#7e22ce;}
|
|
.ms-send:disabled{opacity:.5;cursor:not-allowed;}
|
|
|
|
/* ── MIDI widgets (stage) ── */
|
|
.music-widget{border:1px solid var(--line);border-radius:8px;background:var(--panel);overflow:hidden;}
|
|
.music-widget-header{display:flex;align-items:center;justify-content:space-between;padding:6px 10px;background:var(--panel-2);border-bottom:1px solid var(--line);}
|
|
.music-widget-label{font-size:12px;font-weight:700;color:var(--text);}
|
|
.music-render-btn{background:#1e3a5f;border:1px solid #3b82f6;color:#93c5fd;border-radius:5px;padding:2px 8px;font-size:11px;cursor:pointer;font-family:inherit;transition:.15s;}
|
|
.music-render-btn:hover:not(:disabled){background:#1d4ed8;color:#fff;}
|
|
.music-render-btn:disabled{opacity:.5;cursor:not-allowed;}
|
|
.music-widget-body{padding:8px 10px;}
|
|
.music-widget-body.collapsed{display:none;}
|
|
.music-output{margin-top:6px;}
|
|
.music-actions{display:flex;flex-wrap:wrap;gap:5px;margin-top:6px;}
|
|
.music-dl-btn{display:inline-flex;align-items:center;gap:3px;background:var(--panel);border:1px solid var(--line);border-radius:5px;padding:2px 8px;font-size:11px;color:var(--muted);cursor:pointer;text-decoration:none;font-family:inherit;transition:.15s;}
|
|
.music-dl-btn:hover{border-color:var(--brand);color:var(--brand);}
|
|
.music-dl-btn.btn-green{background:#14532d;border-color:#22c55e;color:#86efac;}
|
|
.music-dl-btn.btn-purple{background:#3b0764;border-color:#a855f7;color:#d8b4fe;}
|
|
.music-dl-btn.btn-amber{background:#451a03;border-color:#f59e0b;color:#fcd34d;}
|
|
.music-page-btn{background:none;border:1px solid var(--line);border-radius:4px;padding:2px 7px;font-size:11px;cursor:pointer;color:var(--muted);font-family:inherit;transition:.15s;}
|
|
.music-page-btn:hover{border-color:#a855f7;color:#a855f7;}
|
|
.music-page-nav{display:flex;align-items:center;gap:6px;margin-bottom:5px;font-size:11px;color:var(--muted);}
|
|
.midi-router-panel{display:flex;flex-wrap:wrap;gap:5px;align-items:center;margin-top:5px;}
|
|
.midi-router-sel{background:var(--panel-2);border:1px solid var(--line);border-radius:5px;padding:2px 5px;font-size:11px;color:var(--text);}
|
|
|
|
/* ── MIDI editor iframe overlay ── */
|
|
.ms-editor{display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden;}
|
|
.ms-editor.visible{display:flex;}
|
|
.ms-editor-bar{display:flex;align-items:center;gap:8px;padding:6px 12px;background:#181825;border-bottom:1px solid #313244;flex-shrink:0;}
|
|
.ms-editor-back{background:#313244;border:1px solid #45475a;color:#cdd6f4;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:inherit;transition:.15s;}
|
|
.ms-editor-back:hover{background:#45475a;}
|
|
.ms-editor-title{font-size:12px;font-weight:700;color:#cdd6f4;flex:1;}
|
|
.ms-editor iframe{flex:1;border:none;min-height:0;}
|
|
|
|
/* ── Drop overlay ── */
|
|
.ms-drop{position:fixed;inset:0;background:rgba(168,85,247,.15);border:3px dashed #a855f7;border-radius:12px;z-index:100;display:none;align-items:center;justify-content:center;font-size:18px;color:#a855f7;font-weight:700;pointer-events:none;}
|
|
.ms-drop.on{display:flex;}
|
|
|
|
/* ── Scrollbars ── */
|
|
.ms-files::-webkit-scrollbar,.ms-msgs::-webkit-scrollbar,.ms-stage-body::-webkit-scrollbar{width:3px;}
|
|
.ms-files::-webkit-scrollbar-thumb,.ms-msgs::-webkit-scrollbar-thumb,.ms-stage-body::-webkit-scrollbar-thumb{background:var(--line);border-radius:3px;}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="ms-drop" id="drop-overlay">🎵 MIDI 파일을 드롭하세요</div>
|
|
|
|
<header class="ms-hdr">
|
|
<a href="/" class="ms-hdr-btn">← 홈</a>
|
|
<h1>🎵 음악 스튜디오</h1>
|
|
<span class="skill-badge" id="skill-badge">스킬 로딩 중...</span>
|
|
<div class="ms-hdr-spacer"></div>
|
|
<button class="ms-hdr-btn" onclick="toggleTheme()">🌙 테마</button>
|
|
</header>
|
|
|
|
<div class="ms-layout">
|
|
|
|
<!-- ── Left sidebar ── -->
|
|
<aside class="ms-sb">
|
|
<div class="ms-sb-sec">빠른 요청</div>
|
|
<div class="ms-quick">
|
|
<button class="ms-qbtn" onclick="sendQuick('밝고 경쾌한 피아노 소곡을 C장조로 작곡해서 MIDI로 생성해주세요.')">🎹 피아노 소곡 작곡</button>
|
|
<button class="ms-qbtn" onclick="sendQuick('재즈 코드 진행 Am7-D7-Gmaj7-Cmaj7를 분석하고 MIDI로 생성해주세요.')">🎸 재즈 코드 진행</button>
|
|
<button class="ms-qbtn" onclick="sendQuick('밝고 신나는 재즈 잼을 작곡해주세요. 피아노, 베이스, 드럼으로 구성해주세요.')">🎷 재즈 잼 작곡</button>
|
|
<button class="ms-qbtn" onclick="sendQuick('C장조 음계, 코드, 기본 화성법을 간단히 설명해주세요.')">📚 이론 설명</button>
|
|
<button class="ms-qbtn" onclick="sendQuick('업로드한 악보나 이미지를 분석해주세요.')">📄 악보 분석</button>
|
|
</div>
|
|
<div class="ms-divider"></div>
|
|
<div class="ms-file-hdr">
|
|
<span>MIDI 파일</span>
|
|
<button onclick="loadMidiFiles()" title="새로고침">↺</button>
|
|
</div>
|
|
<div class="ms-files" id="midi-file-list">
|
|
<div style="font-size:11px;color:var(--muted);padding:4px 6px">로딩 중...</div>
|
|
</div>
|
|
<label class="ms-upload">
|
|
⬆ MIDI 업로드
|
|
<input type="file" accept=".mid,.midi" style="display:none" onchange="uploadMidiFile(this.files[0]);this.value=''">
|
|
</label>
|
|
</aside>
|
|
|
|
<!-- ── Center stage ── -->
|
|
<section class="ms-stage" id="stage">
|
|
<!-- Player stage -->
|
|
<div id="stage-player" style="display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;">
|
|
<div class="ms-stage-hdr">
|
|
<span class="ms-stage-title">🎵 플레이어 스테이지</span>
|
|
<button class="ms-stage-btn" onclick="clearStage()">전체 삭제</button>
|
|
</div>
|
|
<div class="ms-stage-body" id="stage-body">
|
|
<div class="ms-stage-empty" id="stage-empty">
|
|
<div class="icon">🎵</div>
|
|
<p>사이드바에서 파일을 불러오거나<br>AI에게 작곡을 요청하면<br>여기에 MIDI 플레이어가 나타납니다.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<!-- MIDI editor iframe -->
|
|
<div class="ms-editor" id="stage-editor">
|
|
<div class="ms-editor-bar">
|
|
<button class="ms-editor-back" onclick="closeEditor()">← 스테이지로</button>
|
|
<span class="ms-editor-title" id="editor-title">MIDI 편집기</span>
|
|
</div>
|
|
<iframe id="editor-frame" src="" style="flex:1;border:none;min-height:0;width:100%;"></iframe>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- ── Resize handle ── -->
|
|
<div class="ms-resize" id="resize-handle"></div>
|
|
|
|
<!-- ── Right chat panel ── -->
|
|
<div class="ms-chat" id="chat-panel">
|
|
<div class="ms-chat-hdr">
|
|
<span class="ms-chat-hdr-label">🎵 AI</span>
|
|
<select class="ms-model-sel" id="model-sel" onchange="saveModel()">
|
|
<option value="">기본 모델</option>
|
|
</select>
|
|
<button class="ms-chat-clear" onclick="clearChat()" title="대화 지우기">🗑</button>
|
|
</div>
|
|
<div class="ms-msgs" id="chat-msgs"></div>
|
|
<div class="ms-input-bar">
|
|
<textarea class="ms-textarea" id="chat-input" rows="1" placeholder="음악에 대해 물어보세요..." onkeydown="handleKey(event)" oninput="autoResize(this)"></textarea>
|
|
<button class="ms-send" id="send-btn" onclick="sendMessage()">전송</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<script>
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
const TOKEN_KEY = 'smallclaw_token';
|
|
function getToken() { try { return sessionStorage.getItem(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY) || ''; } catch { return ''; } }
|
|
function authH(extra) { const t=getToken(); const h={...(extra||{})}; if(t) h['Authorization']='Bearer '+t; return h; }
|
|
|
|
async function checkAuth() {
|
|
if (!getToken()) { location.href='/login.html?redirect='+encodeURIComponent(location.pathname); return false; }
|
|
try {
|
|
const r = await fetch('/api/auth/status', { headers: authH() });
|
|
if (!r.ok) { location.href='/login.html?redirect='+encodeURIComponent(location.pathname); return false; }
|
|
return true;
|
|
} catch { location.href='/login.html?redirect='+encodeURIComponent(location.pathname); return false; }
|
|
}
|
|
|
|
// ── Theme ─────────────────────────────────────────────────────────────────────
|
|
function toggleTheme() {
|
|
const d=document.documentElement;
|
|
const n=d.getAttribute('data-theme')==='dark'?'light':'dark';
|
|
d.setAttribute('data-theme',n);
|
|
try{localStorage.setItem('cherryclaw_theme',n);}catch{}
|
|
}
|
|
|
|
// ── Resize handle ─────────────────────────────────────────────────────────────
|
|
(function() {
|
|
const CHAT_WIDTH_KEY = 'music_chat_width';
|
|
const handle = document.getElementById('resize-handle');
|
|
const chatPanel = document.getElementById('chat-panel');
|
|
let dragging = false, startX = 0, startW = 0;
|
|
|
|
const savedW = parseInt(localStorage.getItem(CHAT_WIDTH_KEY) || '340', 10);
|
|
chatPanel.style.width = savedW + 'px';
|
|
|
|
handle.addEventListener('mousedown', e => {
|
|
dragging = true; startX = e.clientX; startW = chatPanel.offsetWidth;
|
|
handle.classList.add('dragging');
|
|
document.body.style.userSelect = 'none';
|
|
document.body.style.cursor = 'col-resize';
|
|
e.preventDefault();
|
|
});
|
|
document.addEventListener('mousemove', e => {
|
|
if (!dragging) return;
|
|
const delta = startX - e.clientX;
|
|
const newW = Math.max(260, Math.min(600, startW + delta));
|
|
chatPanel.style.width = newW + 'px';
|
|
});
|
|
document.addEventListener('mouseup', () => {
|
|
if (!dragging) return;
|
|
dragging = false;
|
|
handle.classList.remove('dragging');
|
|
document.body.style.userSelect = '';
|
|
document.body.style.cursor = '';
|
|
try { localStorage.setItem(CHAT_WIDTH_KEY, chatPanel.offsetWidth); } catch {}
|
|
});
|
|
})();
|
|
|
|
// ── Model selector ────────────────────────────────────────────────────────────
|
|
const MODEL_KEY = 'music_model';
|
|
let selectedModel = localStorage.getItem(MODEL_KEY) || '';
|
|
|
|
async function loadModels() {
|
|
const sel = document.getElementById('model-sel');
|
|
try {
|
|
const [settingRes, ollamaRes] = await Promise.all([
|
|
fetch('/api/settings/model', { headers: authH() }),
|
|
fetch('/api/ollama/models', { headers: authH() }),
|
|
]);
|
|
const setting = settingRes.ok ? await settingRes.json() : {};
|
|
const ollamaData = ollamaRes.ok ? await ollamaRes.json() : {};
|
|
const primary = setting.primary || '기본';
|
|
const models = (ollamaData.models || []).map(m => m.name);
|
|
sel.innerHTML = `<option value="">기본 (${primary})</option>`;
|
|
if (selectedModel && !models.includes(selectedModel)) models.unshift(selectedModel);
|
|
for (const m of models) {
|
|
const opt = document.createElement('option');
|
|
opt.value = m; opt.textContent = m;
|
|
if (m === selectedModel) opt.selected = true;
|
|
sel.appendChild(opt);
|
|
}
|
|
if (!selectedModel) sel.value = '';
|
|
} catch {}
|
|
}
|
|
|
|
function saveModel() {
|
|
selectedModel = document.getElementById('model-sel').value;
|
|
try { localStorage.setItem(MODEL_KEY, selectedModel); } catch {}
|
|
}
|
|
|
|
// ── Session & skill ───────────────────────────────────────────────────────────
|
|
const SID_KEY = 'music_app_session';
|
|
let skillContext = '';
|
|
let abortCtrl = null;
|
|
|
|
function getSessionId() {
|
|
try {
|
|
let sid = sessionStorage.getItem(SID_KEY);
|
|
if (!sid) { sid = 'music_' + Math.random().toString(36).slice(2,12); sessionStorage.setItem(SID_KEY, sid); }
|
|
return sid;
|
|
} catch { return 'music_main'; }
|
|
}
|
|
|
|
const SKILL_CONTENT="---\nname: Musician\ndescription: 작곡·편곡·이론 분석·연주 코칭·음악사 해설을 아우르는 범장르 음악 어시스턴트. 클래식부터 재즈·팝·프로듀싱까지.\nemoji: \"🎵\"\nversion: 1.0.0\ntriggers: [음악, 악기, 작곡, 편곡, 코드, 멜로디, 화음, 리듬, 박자, 조성, 악보, MIDI, 음계, 화성, 반주, 연주, 피아노, 기타, 바이올린, 드럼, 노래, 장르, 클래식, 재즈]\n---\n\n## ⚠️ MIDI 직접 생성 — 반드시 읽을 것\n\n이 시스템에는 **Python mido로 MIDI 직접 생성 → 워크스페이스 저장 → 재생/편집 위젯 자동 표시** 파이프라인이 내장되어 있습니다.\n\n작곡 요청 시 **반드시 아래 절차를 따르세요**:\n\n### 1단계: Python으로 MIDI 생성\n\n`python_eval` 도구로 mido 라이브러리를 사용해 MIDI를 직접 생성하고 워크스페이스에 저장합니다.\n\n```python\nimport mido, os\nfrom mido import MidiFile, MidiTrack, Message, MetaMessage\n\nTITLE = \"가을 왈츠\" # 파일명·폴더명으로 사용\nBPM = 120\nTICKS = 480\n\nmid = MidiFile(ticks_per_beat=TICKS)\ntempo = mido.bpm2tempo(BPM)\n\n# 트랙 1: Keyboard (ch 0, program 0=피아노)\nt1 = MidiTrack(); mid.tracks.append(t1)\nt1.append(MetaMessage('track_name', name='Keyboard', time=0))\nt1.append(MetaMessage('set_tempo', tempo=tempo, time=0))\nt1.append(Message('program_change', channel=0, program=0, time=0))\n# ... 음표 추가 (note_on/note_off) ...\n\n# 트랙 2: Guitar (ch 1, program 25=어쿠스틱 기타)\nt2 = MidiTrack(); mid.tracks.append(t2)\nt2.append(MetaMessage('track_name', name='Guitar', time=0))\nt2.append(Message('program_change', channel=1, program=25, time=0))\n# ...\n\n# 트랙 3: Bass (ch 2, program 33=핑거 베이스)\nt3 = MidiTrack(); mid.tracks.append(t3)\nt3.append(MetaMessage('track_name', name='Bass', time=0))\nt3.append(Message('program_change', channel=2, program=33, time=0))\n# ...\n\n# 트랙 4: Drums (ch 9, GM 드럼 채널)\nt4 = MidiTrack(); mid.tracks.append(t4)\nt4.append(MetaMessage('track_name', name='Drums', time=0))\n# note 36=킥, 38=스네어, 42=클로즈햇, 46=오픈햇\n# ...\n\nimport pathlib\nsafe = TITLE.replace(\"/\",\"_\").replace(\"\\\\\",\"_\").replace(\" \",\"_\")\nout_dir = pathlib.Path(os.getcwd()) / \"music\" / safe\nout_dir.mkdir(parents=True, exist_ok=True)\nout_path = out_dir / (safe + \".mid\")\nmid.save(str(out_path))\nprint(str(out_path))\n```\n\n### 2단계: 내부 API로 등록\n\n`shell` 도구로 생성된 MIDI를 등록합니다 (게이트웨이 내부 전용):\n\n```bash\ncurl -s -X POST http://localhost:18789/api/music/midi-from-path \\\n -H \"Content-Type: application/json\" \\\n -d '{\"path\":\"<1단계에서 출력된 경로>\",\"filename\":\"<제목>.mid\"}'\n```\n\n### 3단계: midi-widget 코드블록 출력\n\n등록 API 응답 JSON을 그대로 아래 코드블록에 담아 출력하면, 채팅창에 ▶ 재생 · 🎹 편집 · ⬇ MIDI 위젯이 자동으로 렌더링됩니다:\n\n~~~\n```midi-widget\n{\"id\":\"midi-xxxx\",\"filename\":\"가을 왈츠.mid\",\"midi_url\":\"/api/music/file/midi-xxxx/midi\",\"has_svg\":false,\"page_count\":0,\"svg_url\":null,\"pdf_url\":null,\"saved_to\":\"/path/to/가을 왈츠.mid\"}\n```\n~~~\n\n**절대 금지 문구** (시스템이 처리하므로 언급 불필요):\n- \"직접 생성 불가\" / \"파일을 만들어 드릴 수 없습니다\"\n- \"MIDI로 저장이 어렵습니다\" / \"별도로 저장해 드릴 수 없습니다\"\n- \"LilyBin\", \"온라인 편집기\", \"붙여넣으세요\"\n\n---\n\n## 음악 어시스턴트\n\n당신은 클래식 작곡·재즈 화성·대중음악 프로듀싱·악기 코칭에 두루 능통한 음악 어시스턴트입니다. 열정적이되 정확하게, 이론은 쉬운 말로, 실습은 구체적으로 안내하세요. 음악 용어는 한국어 먼저, 영어를 괄호에 병기하세요. (예: 으뜸음(tonic), 딸림화음(dominant chord))\n\n---\n\n### 사용 가능한 도구\n\n| 도구 | 활용 상황 |\n|---|---|\n| `web_search` | 악보·리드시트·음원 자료, 음악가 정보, 장르 레퍼런스 검색 |\n| `pdf_read` | 텍스트 기반 악보 PDF 분석·조성/박자/구조 파악 |\n| `pdf_extract_images` | 이미지(스캔) 기반 악보 PDF에서 페이지 이미지 추출 |\n| `image_read` | 추출된 악보 이미지 시각 분석 (음표·코드·기보 판독) |\n| `python_eval` | 조옮김 계산, 음정·스케일·코드 자동 생성, BPM 변환 |\n| `pubmed_search` | 연습 효율·음악 심리학·운동 손상 예방 연구 조회 |\n\n### 우선 참조 사이트\n\n`web_search` 호출 시 아래 **site: 연산자를 query에 포함**해 악보·음악 정보를 정확하게 검색하세요.\n\n| 주제 | site: 연산자 | 설명 |\n|---|---|---|\n| 클래식 악보 (무료) | `site:imslp.org` | IMSLP 페트루치 악보 라이브러리 (퍼블릭 도메인) |\n| 대중음악 악보 | `site:musescore.com` | MuseScore 악보 커뮤니티 |\n| 음악 이론 | `site:musictheory.net` | 기초~고급 이론 강의·연습 |\n| 국악 자료 | `site:gugak.go.kr` | 국립국악원 (국악 악보·음원·이론) |\n| 저작권 정보 | `site:komca.or.kr` | 한국음악저작권협회 |\n| 재즈 리드시트 | `site:jazzstandards.com` | 재즈 스탠다드 코드·멜로디 분석 |\n| MIDI 다운로드 (팝·록·클래식) | `site:freemidi.org` | 팝·록·클래식·게임 MIDI 무료 대형 라이브러리 |\n| MIDI 다운로드 (대량) | `site:bitmidi.com` | 10만+ MIDI 파일 무료 검색·스트리밍·다운로드 |\n| MIDI 다운로드 (고품질 클래식 피아노) | `site:piano-midi.de` | 베토벤·쇼팽·리스트 등 고품질 클래식 피아노 MIDI |\n| MIDI 다운로드 (종합 아카이브) | `site:midiworld.com` | 클래식·팝·게임·영화 음악 MIDI 아카이브 |\n\n예시: 베토벤 소나타 악보 → `web_search(\"site:imslp.org Beethoven piano sonata op.13\")`\n예시: 쇼팽 야상곡 MIDI → `web_search(\"site:piano-midi.de Chopin nocturne\")`\n예시: 팝 MIDI 검색 → `web_search(\"site:freemidi.org 곡 제목\")`\n\n---\n\n### 작곡 / 편곡 도움\n\n작곡·편곡 요청 시 다음 순서로 접근하세요:\n\n1. **컨셉 파악** — 장르, 분위기, 조성, 박자, 길이, 편성(악기 구성)\n2. **구조 제안** — Intro → Verse → Chorus → Bridge → Outro 등 형식 제안\n3. **화성 진행** — 기능화성 또는 재즈/모달 화성 기반 코드 제안 (로마 숫자 표기 병기)\n4. **멜로디 아이디어** — 음역·리듬 패턴·모티프 발전 방향 제시\n5. **편성 조언** — 음역대·음색·밸런스 고려한 악기 배치\n6. **Full Score 출력** — 반드시 ABC notation 코드블록으로 출력하되, **키보드(V:1) · 기타(V:2) · 베이스(V:3) · 드럼(V:4) 4파트를 모두 포함**한 풀 스코어로 작성 (렌더링·저장·재생 버튼이 자동 생성됨)\n\n**코드 표기 규칙**: 로마 숫자(I, IV, V7, ii, iii°) + 팝/재즈 표기(Cmaj7, Dm7, G7) 병기.\n\n**[필수] Full Score 규칙**: 작곡 시 반드시 위 \"MIDI 직접 생성\" 절차를 따르세요. **키보드(ch 0) · 기타(ch 1) · 베이스(ch 2) · 드럼(ch 9)** 4파트를 모두 포함한 풀 스코어로 생성하고, `midi-widget` 코드블록으로 출력합니다.\n\n**GM 드럼 음표 번호 참고**:\n- 36 = 킥드럼, 38 = 스네어, 42 = 클로즈 하이햇, 46 = 오픈 하이햇\n- 49 = 크래시 심벌, 51 = 라이드 심벌, 45 = 로우 탐, 48 = 하이 탐\n\n> ABC notation 기본 규칙: `C` = 중간 C, `c` = 한 옥타브 위, `C,` = 한 옥타브 아래. 음표 길이: `C2`=2박, `C/2`=반박. 쉼표: `z`. 마디 구분: `|`. 코드: `\"Am\"A4`.\n\n---\n\n### 악보 PDF 분석 방법\n\nPDF 악보가 업로드되면 먼저 유형을 파악하고 아래 절차를 따르세요:\n\n**1단계 — PDF 유형 판별**\n- `pdf_read`로 먼저 시도. 텍스트가 정상 추출되면 → 텍스트 기반 PDF (바로 분석)\n- 텍스트가 없거나 깨지면 → 스캔/이미지 기반 PDF\n\n**2단계 — 이미지 기반 PDF 처리**\n```\npdf_extract_images(path, mode=\"figures\", page_from=1, page_to=N)\n→ 페이지별 이미지 파일 추출 (uploads/{악보명}-images/ 폴더)\n\nimage_read(추출된 이미지 경로)\n→ 악보 이미지 시각 분석: 음자리표·박자표·조성·음표·코드 기호 판독\n```\n\n**3단계 — 분석 결과 정리**\n- 조성(Key), 박자(Time signature), 빠르기말(Tempo marking)\n- 코드 기호(chord symbol) 또는 화성 진행 추출\n- 특수 기호(반복 기호·다카포·페르마타 등) 설명\n\n> **주의**: 이미지 해상도가 낮거나 손글씨 악보는 판독 정확도가 낮을 수 있어요. 불확실한 음표는 \"판독 불확실\" 명시.\n\n---\n\n### 악보 / 이론 분석\n\n악보나 코드 진행이 주어지면:\n\n1. **조성·선법 판별** — 장·단조, 교회선법(도리안·믹솔리디안 등), 블루스 스케일\n2. **화성 기능 분석** — 으뜸화음·딸림화음·버금딸림화음·전조 지점 표시\n3. **선율 구조** — 동기(motif)·악구(phrase)·절정(climax) 위치\n4. **리듬 패턴** — 당김음·헤미올라·폴리리듬 등 특이사항\n5. **대위법적 특징** — 캐논·푸가·인벤션 구조 시 성부 진행 설명\n\n---\n\n### 연주 / 악기 연습 코칭\n\n| 악기군 | 핵심 포인트 |\n|---|---|\n| **현악기** | 활 속도·압력·접촉점 삼각관계, 포지션 이동·비브라토 발전, 더블스탑·플라제올렛 |\n| **건반** | 손 독립성 훈련, 페달링, 스케일·아르페지오, 보이싱·즉흥 |\n| **기타/베이스** | CAGED 시스템, 바레 코드, 슬랩·태핑·핑거스타일 |\n| **관악기** | 앙부쉬르(embouchure), 호흡 지지(air support), 음역 확장 |\n| **보컬** | 복식호흡, 두성·흉성·혼합 발성, 패시지오(passaggio) 통과 |\n\n**공통**: 느린 템포 → 정확도 → 속도 증가(메트로놈 필수). 어려운 구간 분리 drill 후 연결. 통증 즉시 중단·스트레칭.\n\n---\n\n### 음악사 / 장르 해설\n\n| 장르 | 핵심 포인트 |\n|---|---|\n| **바로크** | 통주저음·대위법·장식음 (바흐·헨델·비발디) |\n| **고전** | 소나타 형식·균형·명료성 (하이든·모차르트·베토벤 초기) |\n| **낭만** | 표제음악·반음계주의·교향시 (쇼팽·리스트·브람스·말러) |\n| **현대** | 무조·12음·스펙트럴·미니멀리즘 |\n| **재즈** | ii-V-I·블루스 스케일·비밥·모달·퓨전 |\n| **팝/록** | 4코드 진행·후크·프로듀싱 구조 |\n| **힙합/R&B** | 샘플링·비트 제작·펜타토닉 보컬 |\n| **국악** | 장단(굿거리·자진모리 등)·선법(평조·계면조)·시김새 |\n\n---\n\n### 응답 톤 가이드\n\n- **열정적·격려적**: \"이 화성 진행 정말 흥미롭네요!\", \"한 번 이렇게 해보면 어떨까요?\"\n- **이론은 쉽게**: 전문 용어 사용 시 반드시 한 줄 설명 병기\n- **구체적 예시**: 추상적 조언 대신 \"3박자에 C장조로 시작해서 Am으로 연결해보세요\" 형식\n- **단계 제시**: 한꺼번에 많은 정보 대신 우선순위 1~3단계로 나눠 안내\n";
|
|
async function loadSkill() {
|
|
skillContext = SKILL_CONTENT;
|
|
document.getElementById('skill-badge').textContent = '🎵 뮤지션 스킬 활성';
|
|
}
|
|
|
|
// ── Stage ─────────────────────────────────────────────────────────────────────
|
|
function addToStage(html, label) {
|
|
const empty = document.getElementById('stage-empty');
|
|
if (empty) empty.remove();
|
|
const body = document.getElementById('stage-body');
|
|
const wrap = document.createElement('div');
|
|
wrap.innerHTML = html;
|
|
body.appendChild(wrap);
|
|
body.scrollTop = body.scrollHeight;
|
|
}
|
|
|
|
function clearStage() {
|
|
if (!confirm('스테이지를 비울까요?')) return;
|
|
const body = document.getElementById('stage-body');
|
|
body.innerHTML = '<div class="ms-stage-empty" id="stage-empty"><div class="icon">🎵</div><p>사이드바에서 파일을 불러오거나<br>AI에게 작곡을 요청하면<br>여기에 MIDI 플레이어가 나타납니다.</p></div>';
|
|
}
|
|
|
|
function openEditor(midiUrl, filename) {
|
|
document.getElementById('stage-player').style.display = 'none';
|
|
const ed = document.getElementById('stage-editor');
|
|
ed.classList.add('visible');
|
|
document.getElementById('editor-title').textContent = '🎹 ' + (filename || 'MIDI 편집기');
|
|
document.getElementById('editor-frame').src = '/midi-editor.html?url=' + encodeURIComponent(midiUrl);
|
|
}
|
|
|
|
function closeEditor() {
|
|
document.getElementById('stage-editor').classList.remove('visible');
|
|
document.getElementById('editor-frame').src = '';
|
|
document.getElementById('stage-player').style.display = 'flex';
|
|
document.getElementById('stage-player').style.flexDirection = 'column';
|
|
document.getElementById('stage-player').style.flex = '1';
|
|
document.getElementById('stage-player').style.minHeight = '0';
|
|
document.getElementById('stage-player').style.overflow = 'hidden';
|
|
}
|
|
|
|
// ── MIDI file list ────────────────────────────────────────────────────────────
|
|
function escHtml(s){return String(s||'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
|
function escAttr(s){return String(s||'').replace(/'/g,"\\'");}
|
|
|
|
async function loadMidiFiles() {
|
|
const el = document.getElementById('midi-file-list');
|
|
try {
|
|
const r = await fetch('/api/music/list', { headers: authH() });
|
|
const data = await r.json();
|
|
if (!data.files || !data.files.length) {
|
|
el.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 6px">파일 없음</div>';
|
|
return;
|
|
}
|
|
el.innerHTML = data.files.map(f => `
|
|
<div class="ms-fi">
|
|
<span class="ms-fi-name" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
|
|
<button class="ms-fi-btn play" onclick="loadMidiToStage('${escAttr(f.path)}','${escAttr(f.name)}')" title="스테이지에 불러오기">▶</button>
|
|
<button class="ms-fi-btn del" onclick="deleteMidiFile('${escAttr(f.name)}',this)" title="삭제">✕</button>
|
|
</div>`).join('');
|
|
} catch {
|
|
el.innerHTML = '<div style="font-size:11px;color:var(--err);padding:4px 6px">로드 실패</div>';
|
|
}
|
|
}
|
|
|
|
async function loadMidiToStage(filePath, name) {
|
|
try {
|
|
const blob = await fetch(filePath, { headers: authH() }).then(r => {
|
|
if (!r.ok) throw new Error('파일 로드 실패 ' + r.status);
|
|
return r.blob();
|
|
});
|
|
const filename = name.match(/\.(mid|midi)$/i) ? name : name + '.mid';
|
|
const file = new File([blob], filename, { type: 'audio/midi' });
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
const r = await fetch('/api/music/midi', { method:'POST', headers: authH(), body: form });
|
|
const data = await r.json();
|
|
if (!r.ok) throw new Error(data.error || '등록 실패');
|
|
const { html } = buildMidiPickerWidget(data, filename);
|
|
addToStage(html, filename);
|
|
} catch (e) { alert('MIDI 로드 실패: ' + e.message); }
|
|
}
|
|
|
|
async function deleteMidiFile(name, btn) {
|
|
if (!confirm(`"${name}" 을(를) 삭제할까요?`)) return;
|
|
try {
|
|
const r = await fetch('/api/music/workspace-file/' + encodeURIComponent(name), { method:'DELETE', headers: authH() });
|
|
const data = await r.json();
|
|
if (!r.ok) throw new Error(data.error || '삭제 실패');
|
|
btn.closest('.ms-fi').remove();
|
|
} catch (e) { alert('삭제 실패: ' + e.message); }
|
|
}
|
|
|
|
async function uploadMidiFile(file) {
|
|
if (!file) return;
|
|
addChatMsg('system', '🎵 ' + escHtml(file.name) + ' 업로드 중...');
|
|
try {
|
|
const form = new FormData();
|
|
form.append('file', file);
|
|
const r = await fetch('/api/music/midi', { method:'POST', headers: authH(), body: form });
|
|
const data = await r.json();
|
|
if (!r.ok) throw new Error(data.error || '업로드 실패');
|
|
const { html } = buildMidiPickerWidget(data, file.name);
|
|
addToStage(html, file.name);
|
|
addChatMsg('system', '✅ ' + escHtml(file.name) + ' 스테이지에 추가됨');
|
|
loadMidiFiles();
|
|
} catch (e) { addChatMsg('system', '업로드 실패: ' + escHtml(e.message)); }
|
|
}
|
|
|
|
// ── Chat ──────────────────────────────────────────────────────────────────────
|
|
function addChatMsg(role, html, raw) {
|
|
const msgs = document.getElementById('chat-msgs');
|
|
const div = document.createElement('div');
|
|
div.className = 'ms-msg ' + role;
|
|
const bubble = document.createElement('div');
|
|
bubble.className = 'ms-bubble';
|
|
bubble.innerHTML = html;
|
|
div.appendChild(bubble);
|
|
msgs.appendChild(div);
|
|
msgs.scrollTop = msgs.scrollHeight;
|
|
return bubble;
|
|
}
|
|
|
|
function renderMd(text) {
|
|
return text
|
|
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
|
.replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
|
if (lang === 'abc' || lang === 'lilypond') return buildAbcWidget(code.trim(), lang);
|
|
return `<pre><code>${code}</code></pre>`;
|
|
})
|
|
.replace(/`([^`]+)`/g,'<code>$1</code>')
|
|
.replace(/\*\*(.+?)\*\*/g,'<strong>$1</strong>')
|
|
.replace(/\*(.+?)\*/g,'<em>$1</em>')
|
|
.replace(/^#{1,3}\s+(.+)$/gm,'<h3>$1</h3>')
|
|
.replace(/\n\n/g,'</p><p>').replace(/^/,'<p>').replace(/$/,'</p>')
|
|
.replace(/<p><\/p>/g,'')
|
|
.replace(/<p>(<h3>)/g,'$1').replace(/(<\/h3>)<\/p>/g,'$1')
|
|
.replace(/<p>(<pre>)/g,'$1').replace(/(<\/pre>)<\/p>/g,'$1')
|
|
.replace(/<p>(<div )/g,'$1').replace(/(<\/div>)<\/p>/g,'$1');
|
|
}
|
|
|
|
function processMidiWidgets(html) {
|
|
return html.replace(/<pre><code>([\s\S]*?)<\/code><\/pre>/g, (match, inner) => {
|
|
const dec = inner.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
let data; try { data = JSON.parse(dec); } catch { return match; }
|
|
if (data && data.type === 'midi-widget' && data.midi_url) {
|
|
const filename = data.filename || 'output.mid';
|
|
const { html: whtml } = buildMidiPickerWidget(data, filename);
|
|
// Also add to stage
|
|
setTimeout(() => addToStage(whtml.replace(/id="(midi-[^"]+)"/g, (m,id) => `id="${id}-s"`).replace(/onclick="([^"]*)'(midi-[^']+)'/g, (m,fn,id) => `onclick="${fn}'${id}-s'`), data.filename || 'output.mid'), 0);
|
|
return '<span class="ms-stage-link" onclick="document.getElementById(\'stage-body\').scrollIntoView({behavior:\'smooth\'})">↑ 스테이지에서 보기</span>';
|
|
}
|
|
return match;
|
|
});
|
|
}
|
|
|
|
async function sendMessage() {
|
|
const input = document.getElementById('chat-input');
|
|
const msg = input.value.trim();
|
|
if (!msg || document.getElementById('send-btn').disabled) return;
|
|
input.value = ''; autoResize(input);
|
|
addChatMsg('user', escHtml(msg));
|
|
await callChat(msg);
|
|
}
|
|
|
|
function sendQuick(msg) {
|
|
document.getElementById('chat-input').value = msg;
|
|
sendMessage();
|
|
}
|
|
|
|
async function callChat(message) {
|
|
const btn = document.getElementById('send-btn');
|
|
btn.disabled = true; btn.textContent = '...';
|
|
|
|
const msgs = document.getElementById('chat-msgs');
|
|
const typingDiv = document.createElement('div');
|
|
typingDiv.className = 'ms-msg assistant';
|
|
typingDiv.innerHTML = '<div class="ms-typing"><div class="ms-dot"></div><div class="ms-dot"></div><div class="ms-dot"></div></div>';
|
|
msgs.appendChild(typingDiv);
|
|
msgs.scrollTop = msgs.scrollHeight;
|
|
|
|
abortCtrl = new AbortController();
|
|
let bubble = null, textBuf = '', thinkBuf = '';
|
|
|
|
try {
|
|
const body = {
|
|
message,
|
|
sessionId: getSessionId(),
|
|
...(selectedModel ? { model: selectedModel } : {}),
|
|
...(skillContext ? { skillContext } : {}),
|
|
};
|
|
const r = await fetch('/api/chat', {
|
|
method:'POST', headers: authH({'Content-Type':'application/json'}),
|
|
body: JSON.stringify(body), signal: abortCtrl.signal
|
|
});
|
|
if (!r.ok) throw new Error('서버 오류 ' + r.status);
|
|
|
|
typingDiv.remove();
|
|
const msgDiv = document.createElement('div');
|
|
msgDiv.className = 'ms-msg assistant';
|
|
bubble = document.createElement('div');
|
|
bubble.className = 'ms-bubble';
|
|
msgDiv.appendChild(bubble);
|
|
msgs.appendChild(msgDiv);
|
|
|
|
const reader = r.body.getReader();
|
|
const dec = new TextDecoder();
|
|
let buf = '';
|
|
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
buf += dec.decode(value, { stream: true });
|
|
const parts = buf.split('\n\n'); buf = parts.pop() || '';
|
|
for (const part of parts) {
|
|
if (!part.startsWith('data: ')) continue;
|
|
let ev; try { ev = JSON.parse(part.slice(6)); } catch { continue; }
|
|
if (ev.type === 'thinking_delta' && ev.text) { thinkBuf += ev.text; }
|
|
else if (ev.type === 'token' || ev.type === 'text_delta') {
|
|
textBuf += (ev.text || '');
|
|
if (bubble) {
|
|
const rendered = processMidiWidgets(renderMd(textBuf));
|
|
bubble.innerHTML = (thinkBuf ? `<div class="ms-thinking" onclick="this.classList.toggle('exp')" title="클릭하여 펼치기">💭 ${escHtml(thinkBuf.slice(0,120))}${thinkBuf.length>120?'…':''}</div>` : '') + rendered;
|
|
msgs.scrollTop = msgs.scrollHeight;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (textBuf && /midi|mid\b|저장|생성/.test(textBuf)) setTimeout(loadMidiFiles, 1500);
|
|
|
|
} catch (e) {
|
|
typingDiv.remove();
|
|
if (e.name !== 'AbortError') {
|
|
if (!bubble) { const d=document.createElement('div'); d.className='ms-msg assistant'; bubble=document.createElement('div'); bubble.className='ms-bubble'; d.appendChild(bubble); document.getElementById('chat-msgs').appendChild(d); }
|
|
bubble.innerHTML = '<span style="color:var(--err)">오류: ' + escHtml(e.message) + '</span>';
|
|
}
|
|
} finally {
|
|
btn.disabled = false; btn.textContent = '전송'; abortCtrl = null;
|
|
}
|
|
}
|
|
|
|
function clearChat() {
|
|
if (!confirm('대화를 지울까요?')) return;
|
|
document.getElementById('chat-msgs').innerHTML = '';
|
|
try { sessionStorage.removeItem(SID_KEY); } catch {}
|
|
addChatMsg('system', '🎵 새 대화가 시작되었습니다.');
|
|
}
|
|
|
|
function handleKey(e) { if (e.key==='Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); } }
|
|
function autoResize(el) { el.style.height='auto'; el.style.height=Math.min(el.scrollHeight,120)+'px'; }
|
|
|
|
// ── Drag-and-drop ─────────────────────────────────────────────────────────────
|
|
document.addEventListener('dragover', e => {
|
|
if ([...e.dataTransfer.items].some(i => i.type.includes('midi')||i.type==='')) {
|
|
e.preventDefault(); document.getElementById('drop-overlay').classList.add('on');
|
|
}
|
|
});
|
|
document.addEventListener('dragleave', e => {
|
|
if (!e.relatedTarget||e.relatedTarget===document.documentElement) document.getElementById('drop-overlay').classList.remove('on');
|
|
});
|
|
document.addEventListener('drop', e => {
|
|
e.preventDefault(); document.getElementById('drop-overlay').classList.remove('on');
|
|
const files = [...e.dataTransfer.files].filter(f => /\.(mid|midi)$/i.test(f.name));
|
|
if (files.length) uploadMidiFile(files[0]);
|
|
});
|
|
|
|
// ── MIDI router & widget functions ────────────────────────────────────────────
|
|
const midiRouter = {
|
|
access:null, timers:[], isPlaying:false,
|
|
stop() {
|
|
this.timers.forEach(t=>clearTimeout(t)); this.timers=[]; this.isPlaying=false;
|
|
if(this.access) this.access.outputs.forEach(out=>{
|
|
for(let ch=0;ch<16;ch++){try{out.send([0xB0|ch,123,0]);}catch{}try{out.send([0xB0|ch,121,0]);}catch{}}
|
|
});
|
|
},
|
|
_parse(buf) {
|
|
const u8=new Uint8Array(buf),dv=new DataView(buf);
|
|
if(dv.getUint32(0)!==0x4D546864)return[];
|
|
const tpq=dv.getUint16(12),ntrk=dv.getUint16(10);let tempo=500000;const ev=[];
|
|
const rv=p=>{let v=0,b;do{b=u8[p++];v=(v<<7)|(b&0x7F);}while(b&0x80);return{v,p};};
|
|
let p=14;
|
|
for(let t=0;t<ntrk;t++){
|
|
if(p+8>u8.length||dv.getUint32(p)!==0x4D54726B)break;
|
|
const tl=dv.getUint32(p+4);p+=8;const e=p+tl;let tk=0,rs=0;
|
|
while(p<e){const d=rv(p);p=d.p;tk+=d.v;let b=u8[p];
|
|
if(b&0x80){rs=b;p++;}else b=rs;
|
|
const ty=b&0xF0,ch=b&0x0F,ms=(tk/tpq)*(tempo/1000);
|
|
if(ty===0x90||ty===0x80){const n=u8[p++],vl=u8[p++];ev.push({ms,msg:[ty|ch,n,vl]});}
|
|
else if(ty===0xA0||ty===0xB0||ty===0xE0){ev.push({ms,msg:[b,u8[p],u8[p+1]]});p+=2;}
|
|
else if(ty===0xC0){ev.push({ms,msg:[0xC0|ch,u8[p++]]});}
|
|
else if(ty===0xD0){ev.push({ms,msg:[b,u8[p++]]});}
|
|
else if(b===0xFF){const mt=u8[p++];const l=rv(p);p=l.p;if(mt===0x51&&l.v>=3)tempo=(u8[p]<<16)|(u8[p+1]<<8)|u8[p+2];p+=l.v;}
|
|
else if(b===0xF0||b===0xF7){const l=rv(p);p=l.p+l.v;}else p++;
|
|
}p=e;
|
|
}
|
|
ev.sort((a,b)=>a.ms-b.ms);return ev;
|
|
},
|
|
async play(url,outId,cb){
|
|
this.stop();const out=this.access.outputs.get(outId);if(!out)throw new Error('기기 없음');
|
|
if(cb)cb('로딩 중...');
|
|
const buf=await fetch(url,{headers:authH()}).then(r=>r.arrayBuffer());
|
|
const evs=this._parse(buf);if(!evs.length)throw new Error('MIDI 파싱 실패');
|
|
try{out.send(new Uint8Array([0xF0,0x7E,0x7F,0x09,0x01,0xF7]));}catch{}
|
|
const seen=new Set();
|
|
evs.forEach(e=>{if((e.msg[0]&0xF0)===0xC0&&!seen.has(e.msg[0]&0x0F)){seen.add(e.msg[0]&0x0F);const ch=e.msg[0]&0x0F;try{out.send(new Uint8Array([0xB0|ch,0,0]));}catch{}try{out.send(new Uint8Array([0xB0|ch,32,0]));}catch{}try{out.send(new Uint8Array(e.msg));}catch{}}});
|
|
const ND=200,tot=evs[evs.length-1].ms+ND+500;this.isPlaying=true;if(cb)cb('재생 중...');
|
|
evs.forEach(e=>{this.timers.push(setTimeout(()=>{if(!this.isPlaying)return;try{out.send(new Uint8Array(e.msg));}catch{}},e.ms+ND));});
|
|
this.timers.push(setTimeout(()=>{this.isPlaying=false;if(cb)cb('완료');},tot));
|
|
}
|
|
};
|
|
|
|
async function midiRouterToggle(rid){
|
|
const panel=document.getElementById(rid);if(!panel){alert('패널 없음: '+rid);return;}
|
|
const hidden=panel.style.display==='none'||!panel.style.display;
|
|
if(hidden){
|
|
Object.assign(panel.style,{display:'flex',flexWrap:'wrap',gap:'6px',alignItems:'center'});
|
|
const sel=document.getElementById(rid+'-sel'),st=document.getElementById(rid+'-st');
|
|
try{
|
|
if(!navigator.requestMIDIAccess)throw new Error('Web MIDI 미지원');
|
|
if(!midiRouter.access)midiRouter.access=await navigator.requestMIDIAccess();
|
|
if(sel){sel.innerHTML='<option value="">-- MIDI 기기 선택 --</option>';midiRouter.access.outputs.forEach((o,id)=>{const op=document.createElement('option');op.value=id;op.textContent=o.name;sel.appendChild(op);});if(sel.options.length<=1&&st)st.textContent='MIDI 출력 없음';}
|
|
}catch(e){if(st)st.textContent='오류: '+e.message;}
|
|
}else{panel.style.display='none';midiRouter.stop();}
|
|
}
|
|
async function midiRouterPlay(rid,url){const sel=document.getElementById(rid+'-sel'),st=document.getElementById(rid+'-st'),id=sel?sel.value:'';if(!id){if(st)st.textContent='기기를 선택하세요';return;}try{await midiRouter.play(url,id,m=>{if(st)st.textContent=m;});}catch(e){if(st)st.textContent='오류: '+e.message;}}
|
|
function midiRouterStop(rid){midiRouter.stop();const st=document.getElementById(rid+'-st');if(st)st.textContent='정지';}
|
|
|
|
function buildMusicActions(data,dl){
|
|
const url=data.midi_url,pdf=data.pdf_url,rid='mr-'+Math.random().toString(36).slice(2,10);
|
|
let h='<div class="music-actions">';
|
|
if(data.saved_to)h+='<div style="font-size:11px;color:var(--muted);margin-bottom:4px;width:100%">💾 '+escHtml(data.saved_to)+'</div>';
|
|
if(url){
|
|
h+='<div class="midi-router-panel" id="'+rid+'" style="display:none;margin-bottom:4px"><select class="midi-router-sel" id="'+rid+'-sel"><option value="">-- MIDI 기기 선택 --</option></select><button class="music-page-btn" onclick="midiRouterPlay(\''+rid+'\',\''+url+'\')">▶</button><button class="music-page-btn" onclick="midiRouterStop(\''+rid+'\')">■</button><span style="font-size:11px;color:var(--muted)" id="'+rid+'-st"></span></div>';
|
|
h+='<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\''+rid+'\')">▶ 외부 신디</button>';
|
|
h+='<button class="music-dl-btn btn-purple" onclick="openEditor(\''+url+'\',\''+escAttr(filename||'')+'\')" >🎹 편집</button>';
|
|
h+='<a class="music-dl-btn" href="'+url+'" download="'+escHtml(dl||'score.midi')+'">⬇ MIDI</a>';
|
|
}
|
|
if(pdf)h+='<a class="music-dl-btn" href="'+pdf+'" download="score.pdf">⬇ PDF</a>';
|
|
return h+'</div>';
|
|
}
|
|
|
|
function buildSvgPagesHtml(data){
|
|
const pg=data.page_count||1,nid='mnav-'+Math.random().toString(36).slice(2,10);
|
|
const s1=pg===1?data.svg_url:'/api/music/file/'+data.id+'/svg/1';
|
|
let nav='';
|
|
if(pg>1)nav='<div class="music-page-nav" id="'+nid+'" data-page="1" data-total="'+pg+'" data-id="'+data.id+'"><button class="music-page-btn" onclick="musicPageNav(\''+nid+'\',-1)" id="'+nid+'-prev" disabled>◀</button><span id="'+nid+'-label">1/'+pg+'</span><button class="music-page-btn" onclick="musicPageNav(\''+nid+'\',1)" id="'+nid+'-next"'+(pg<=1?' disabled':'')+'>▶</button></div>';
|
|
return nav+'<img id="'+nid+'-img" src="'+s1+'" alt="악보" style="width:100%;height:auto;background:#fff;border-radius:6px;padding:6px;display:block"/>';
|
|
}
|
|
function musicPageNav(nid,d){
|
|
const nav=document.getElementById(nid);if(!nav)return;
|
|
const cur=parseInt(nav.dataset.page,10),tot=parseInt(nav.dataset.total,10),nx=cur+d;
|
|
if(nx<1||nx>tot)return;nav.dataset.page=String(nx);
|
|
const img=document.getElementById(nid+'-img'),lbl=document.getElementById(nid+'-label'),pv=document.getElementById(nid+'-prev'),nt=document.getElementById(nid+'-next');
|
|
if(img)img.src='/api/music/file/'+nav.dataset.id+'/svg/'+nx;
|
|
if(lbl)lbl.textContent=nx+'/'+tot;if(pv)pv.disabled=nx<=1;if(nt)nt.disabled=nx>=tot;
|
|
}
|
|
|
|
function buildMidiPickerWidget(data,filename){
|
|
const wid='midi-'+Math.random().toString(36).slice(2,10),url=data.midi_url,rid='mr-'+Math.random().toString(36).slice(2,10);
|
|
const saved=data.saved_to?'<div style="font-size:11px;color:var(--muted);margin-bottom:6px">💾 '+escHtml(data.saved_to)+'</div>':'';
|
|
const pp='<div id="'+rid+'" class="midi-router-panel" style="display:none;margin-top:6px"><select class="midi-router-sel" id="'+rid+'-sel"><option value="">-- MIDI 기기 선택 --</option></select><button class="music-page-btn" onclick="midiRouterPlay(\''+rid+'\',\''+url+'\')">▶</button><button class="music-page-btn" onclick="midiRouterStop(\''+rid+'\')">■</button><a class="music-dl-btn" href="'+url+'" download="'+escHtml(filename)+'" style="padding:2px 7px;font-size:11px">⬇ MIDI</a><span style="font-size:11px;color:var(--muted)" id="'+rid+'-st"></span></div>';
|
|
const si=(data.has_svg&&data.svg_url)?buildSvgPagesHtml(data)+buildMusicActions(data,filename):'';
|
|
const sa='<div id="'+wid+'-score" style="margin-top:6px;display:none">'+si+'</div>';
|
|
const html='<div class="music-widget" id="'+wid+'"><div class="music-widget-header"><span class="music-widget-label">🎵 '+escHtml(filename)+'</span><div style="display:flex;gap:5px;align-items:center"><button onclick="toggleMusicWidget(\''+wid+'\')" style="background:none;border:none;cursor:pointer;font-size:13px;color:var(--muted);padding:1px 3px" title="접기/펼치기">▾</button><button onclick="closeMusicWidget(\''+wid+'\')" style="background:none;border:none;cursor:pointer;font-size:13px;color:var(--muted);padding:1px 3px" title="닫기">✕</button></div></div><div class="music-widget-body"><div class="music-output">'+saved+'<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center"><button class="music-dl-btn btn-green" onclick="midiRouterToggle(\''+rid+'\')">▶ 재생</button><button class="music-dl-btn btn-purple" onclick="openEditor(\''+url+'\',\''+escAttr(filename)+'\')">🎹 편집</button><button class="music-dl-btn btn-amber" id="'+wid+'-score-btn" onclick="midiPickerScore(\''+wid+'\',\''+data.id+'\',\''+escAttr(filename)+'\')">🎼 악보</button></div>'+pp+sa+'</div></div></div>';
|
|
return {wid,html};
|
|
}
|
|
|
|
async function midiPickerScore(wid,midiId,filename){
|
|
const sd=document.getElementById(wid+'-score'),btn=document.getElementById(wid+'-score-btn');if(!sd)return;
|
|
if(sd.style.display!=='none'&&sd.innerHTML.trim()){sd.style.display='none';if(btn)btn.textContent='🎼 악보';return;}
|
|
if(sd.innerHTML.trim()){sd.style.display='';if(btn)btn.textContent='🎼 닫기';return;}
|
|
sd.style.display='';sd.innerHTML='<div style="font-size:11px;color:var(--muted);padding:6px 0">📄 변환 중... <span id="'+wid+'-sp"></span></div>';if(btn)btn.disabled=true;
|
|
try{
|
|
const r=await fetch('/api/music/midi-score',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({id:midiId})});
|
|
if(!r.ok||!r.body)throw new Error('서버 오류');
|
|
const rd=r.body.getReader(),dc=new TextDecoder();let buf='';
|
|
while(true){const {done,value}=await rd.read();if(done)break;buf+=dc.decode(value,{stream:true});
|
|
const pts=buf.split('\n\n');buf=pts.pop()||'';
|
|
for(const pt of pts){if(!pt.startsWith('data: '))continue;let ev;try{ev=JSON.parse(pt.slice(6));}catch{continue;}
|
|
if(ev.type==='progress'){const sp=document.getElementById(wid+'-sp');if(sp)sp.textContent=ev.message;}
|
|
else if(ev.type==='done'){sd.innerHTML=buildSvgPagesHtml(ev)+buildMusicActions(ev,filename);if(btn){btn.disabled=false;btn.textContent='🎼 닫기';}}
|
|
else if(ev.type==='error'){sd.innerHTML='<span style="color:var(--err);font-size:11px">실패: '+escHtml(ev.message)+'</span>';if(btn){btn.disabled=false;btn.textContent='🎼 악보';}}
|
|
}
|
|
}
|
|
}catch(e){sd.innerHTML='<span style="color:var(--err);font-size:11px">오류: '+escHtml(e.message)+'</span>';if(btn){btn.disabled=false;btn.textContent='🎼 악보';}}
|
|
}
|
|
|
|
function buildAbcWidget(src,lang){
|
|
const wid='abc-'+Math.random().toString(36).slice(2,10),enc=encodeURIComponent(src);
|
|
return '<div class="music-widget" id="'+wid+'"><div class="music-widget-header"><span class="music-widget-label">🎵 '+(lang==='abc'?'ABC Notation':'LilyPond')+'</span><div style="display:flex;gap:5px;align-items:center"><button class="music-render-btn" onclick="renderAbcBlock(\''+wid+'\',decodeURIComponent(\''+enc+'\'))">악보 렌더링</button><button onclick="toggleMusicWidget(\''+wid+'\')" style="background:none;border:none;cursor:pointer;font-size:13px;color:var(--muted);padding:1px 3px" title="접기/펼치기">▾</button><button onclick="closeMusicWidget(\''+wid+'\')" style="background:none;border:none;cursor:pointer;font-size:13px;color:var(--muted);padding:1px 3px" title="닫기">✕</button></div></div><div class="music-widget-body"><pre style="margin:0;font-size:11px;overflow-x:auto">'+escHtml(src)+'</pre><div class="music-output" id="'+wid+'-out" style="display:none"></div></div></div>';
|
|
}
|
|
async function renderAbcBlock(wid,src){
|
|
const w=document.getElementById(wid);if(!w)return;
|
|
const btn=w.querySelector('.music-render-btn'),out=w.querySelector('.music-output');
|
|
if(btn){btn.disabled=true;btn.textContent='렌더링 중…';}
|
|
try{
|
|
const data=await fetch('/api/music/render',{method:'POST',headers:authH({'Content-Type':'application/json'}),body:JSON.stringify({source:src})}).then(r=>r.json());
|
|
if(data.error)throw new Error(data.error);
|
|
if(out){out.innerHTML=buildSvgPagesHtml(data)+buildMusicActions(data,'score.midi');out.style.display='block';}
|
|
if(btn){btn.textContent='다시 렌더링';btn.disabled=false;}
|
|
loadMidiFiles();
|
|
}catch(e){if(out){out.innerHTML='<span style="color:var(--err);font-size:11px">실패: '+escHtml(e.message)+'</span>';out.style.display='block';}if(btn){btn.textContent='악보 렌더링';btn.disabled=false;}}
|
|
}
|
|
|
|
function toggleMusicWidget(id){
|
|
const w=document.getElementById(id);if(!w)return;
|
|
const b=w.querySelector('.music-widget-body'),btn=w.querySelector('[title="접기/펼치기"]');
|
|
if(!b)return;const c=b.classList.toggle('collapsed');if(btn)btn.textContent=c?'▸':'▾';
|
|
}
|
|
function closeMusicWidget(id){const el=document.getElementById(id);if(!el)return;(el.closest('.music-widget')||el).remove();}
|
|
|
|
// ── Init ──────────────────────────────────────────────────────────────────────
|
|
(async()=>{
|
|
const ok = await checkAuth();
|
|
if (!ok) return;
|
|
await Promise.all([loadSkill(), loadModels(), loadMidiFiles()]);
|
|
addChatMsg('system', '🎵 음악 스튜디오에 오신 것을 환영합니다!');
|
|
document.getElementById('chat-input').focus();
|
|
})();
|
|
</script>
|
|
</body>
|
|
</html>
|