Files
homeclaw/web-ui/music-app.html
T
kimandClaude Sonnet 4.6 75a36cdc7d v3.2.2: 폴더블 폰 모바일 패널 토글 전면 수정 + 탐정앱 네트워크 분석 + 음성 유틸
- mobToggle 가드 640→1024: 음악·슬라이드·코드·날씨·언어·치과·ESP32·아두이노 앱
  (CSS max-width:1024px인데 JS guard가 >640으로 남아 폴드 내부화면에서 토글 불가)
- #mob-tabbar z-index 400/490→600, 패널 bottom 0→52px, backdrop inset 0 0 52px 0
  (backdrop이 탭바를 가려 열린 상태에서 다른 탭 전환 불가 문제 해결)
- 모든 innerWidth<=640 자동트리거 →<=1024 통일 (9개 앱)
- 탐정앱: 네트워크 분석 패널 (IP조회/Traceroute/Nmap/DNS/WHOIS/SSL/취약점/해시/웹스캔/배너/CVE)
- voice-utils.js 분리 — 서브앱 공통 음성입력+TTS 유틸
- app.js: 컨텍스트 토큰 게이지 (세션 목록에 tok/max 표시), usage 이벤트 처리
- routes-music.ts: Demucs 보컬분리 + YouTube 다운로드 잡 관리 (TTL 자동정리)
- server-v2.ts: 탐정 네트워크 API (traceroute/dns/whois/ssl/nmap/vuln/hash/webscan/cve)
- session.ts: resolveNumCtx export, /api/agent/session contextTokenEstimate 노출
- config.json: 카카오 채널 설정, ITS 교통 API 키 vault 참조
- styles.css: app-mic-btn / app-tts-btn 공통 스타일

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 19:16:59 +09:00

4409 lines
257 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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 src="https://cdn.jsdelivr.net/npm/@coderline/alphatab@latest/dist/alphaTab.min.js"></script>
<script>
try { var _t=localStorage.getItem('cherryclaw_music_theme')||'dark'; document.documentElement.setAttribute('data-theme',_t); } catch(e){}
</script>
<style>
/* ── Premium studio theme tokens ── */
:root{
--studio-bg: #141520;
--studio-surface: #1b1c2c;
--studio-panel: #1f2032;
--studio-panel2: #272840;
--studio-border: rgba(255,255,255,.09);
--studio-border2: rgba(255,255,255,.16);
--studio-text: #d8daf0;
--studio-muted: #6e7096;
--studio-accent: #9333ea;
--studio-accent2: #a855f7;
--studio-glow: rgba(147,51,234,.35);
--studio-glow2: rgba(168,85,247,.15);
--studio-green: #22c55e;
--studio-red: #ef4444;
/* Remap global tokens */
--bg: #141520;
--panel: #1f2032;
--panel-2: #272840;
--line: rgba(255,255,255,.16);
--text: #d8daf0;
--muted: #6e7096;
--brand: #a855f7;
}
:root[data-theme="dark"]{
--bg: #141520;
--panel: #1f2032;
--panel-2: #272840;
--line: rgba(255,255,255,.16);
--text: #d8daf0;
--muted: #6e7096;
--brand: #a855f7;
}
:root[data-theme="light"]{
--studio-bg: #dddaf0;
--studio-surface: #e4e1f5;
--studio-panel: #eceaf8;
--studio-panel2: #e0ddf0;
--studio-border: rgba(0,0,0,.09);
--studio-border2: rgba(0,0,0,.17);
--studio-text: #1a1830;
--studio-muted: #6a6888;
--studio-glow: rgba(147,51,234,.2);
--studio-glow2: rgba(168,85,247,.1);
--bg: #dddaf0;
--panel: #eceaf8;
--panel-2: #e0ddf0;
--line: rgba(0,0,0,.17);
--text: #1a1830;
--muted: #6a6888;
}
html,body{height:100%;margin:0;padding:0;}
body{
background:var(--studio-bg);
color:var(--studio-text);
font-family:'Inter',system-ui,-apple-system,sans-serif;
display:flex;flex-direction:column;overflow:hidden;height:100dvh;
}
/* ── Header ── */
.ms-hdr{
display:flex;align-items:center;gap:10px;padding:0 16px;height:44px;
background:var(--studio-panel);
border-bottom:1px solid var(--studio-border);
flex-shrink:0;min-width:0;
box-shadow:0 1px 0 var(--studio-border);
}
.ms-hdr h1{font-size:13px;font-weight:700;margin:0;letter-spacing:.02em;background:linear-gradient(135deg,#c084fc,#818cf8);-webkit-background-clip:text;-webkit-text-fill-color:transparent;}
.ms-hdr-spacer{flex:1;}
.ms-hdr-btn{
background:transparent;border:1px solid var(--studio-border2);border-radius:7px;
padding:4px 11px;font-size:11px;color:var(--studio-muted);cursor:pointer;
text-decoration:none;display:inline-flex;align-items:center;transition:.2s;font-family:inherit;
letter-spacing:.02em;
}
.ms-hdr-btn:hover{border-color:var(--studio-accent2);color:var(--studio-accent2);background:var(--studio-glow2);}
.skill-badge{font-size:11px;padding:3px 10px;border-radius:20px;background:linear-gradient(135deg,rgba(147,51,234,.2),rgba(99,102,241,.2));border:1px solid rgba(168,85,247,.4);color:#c084fc;font-weight:700;letter-spacing:.03em;}
/* ── Layout ── */
.ms-layout{flex:1;display:flex;min-height:0;overflow:hidden;}
/* ── Sidebar ── */
.ms-sb{
width:208px;flex-shrink:0;display:flex;flex-direction:column;
border-right:1px solid var(--studio-border);
background:var(--studio-panel);
min-height:0;overflow:hidden;
}
.ms-sb-sec{
font-size:9.5px;font-weight:700;color:var(--studio-muted);
text-transform:uppercase;letter-spacing:.1em;padding:10px 12px 5px;
}
.ms-quick{padding:0 8px 8px;display:flex;flex-direction:column;gap:4px;flex-shrink:0;}
.ms-qbtn{
background:transparent;border:1px solid var(--studio-border2);border-radius:8px;
padding:6px 10px;font-size:11px;color:var(--studio-muted);cursor:pointer;
font-family:inherit;text-align:left;transition:.2s;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
.ms-qbtn:hover{border-color:var(--studio-accent2);color:var(--studio-text);background:var(--studio-glow2);}
.ms-divider{height:1px;background:var(--studio-border);margin:5px 0;}
.ms-file-hdr{display:flex;align-items:center;padding:0 12px 4px;flex-shrink:0;}
.ms-file-hdr span{flex:1;font-size:9.5px;font-weight:700;color:var(--studio-muted);text-transform:uppercase;letter-spacing:.1em;}
.ms-file-hdr button{background:none;border:none;padding:0;cursor:pointer;font-size:12px;color:var(--studio-muted);transition:.15s;}
.ms-file-hdr button:hover{color:var(--studio-accent2);}
.ms-files{flex:1;overflow-y:auto;padding:0 6px;}
.ms-fi{display:flex;align-items:center;gap:5px;padding:4px 7px;border-radius:7px;cursor:pointer;font-size:11px;color:var(--studio-muted);transition:.15s;}
.ms-fi:hover{background:var(--studio-glow2);color:var(--studio-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:2px 4px;color:var(--studio-muted);flex-shrink:0;border-radius:4px;transition:.15s;}
.ms-fi-btn:hover.play{color:var(--studio-green);}
.ms-fi-btn:hover.del{color:var(--studio-red);}
.ms-upload{
margin:8px;border:1px dashed var(--studio-border2);border-radius:8px;
padding:7px;font-size:11px;color:var(--studio-muted);cursor:pointer;
text-align:center;background:none;font-family:inherit;transition:.2s;
display:block;width:calc(100% - 16px);box-sizing:border-box;
}
.ms-upload:hover{border-color:var(--studio-accent2);color:var(--studio-accent2);background:var(--studio-glow2);}
/* ── Guitar Pro viewer ── */
#stage-gp{display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden;}
.gp-bar{
display:flex;align-items:center;gap:8px;padding:7px 14px;
border-bottom:1px solid var(--studio-border);
background:var(--studio-panel);flex-shrink:0;
}
.gp-bar-title{font-size:12px;font-weight:700;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;letter-spacing:.02em;}
.gp-ctrl-btn{
background:transparent;border:1px solid var(--studio-border2);border-radius:6px;
padding:3px 10px;font-size:11px;color:var(--studio-muted);cursor:pointer;
font-family:inherit;transition:.2s;
}
.gp-ctrl-btn:hover{border-color:var(--studio-accent2);color:var(--studio-accent2);background:var(--studio-glow2);}
.gp-ctrl-btn.active{background:var(--studio-accent);border-color:var(--studio-accent);color:#fff;box-shadow:0 0 10px var(--studio-glow);}
#gp-tempo{background:transparent;border:none;border-bottom:1px solid var(--studio-border2);color:var(--studio-text);font-size:13px;font-family:inherit;padding:1px 2px;text-align:center;outline:none;width:44px;}
#gp-tempo:focus{border-bottom-color:var(--studio-accent2);}
#gp-tempo::-webkit-inner-spin-button,#gp-tempo::-webkit-outer-spin-button{-webkit-appearance:none;margin:0;}
#gp-tempo[type=number]{-moz-appearance:textfield;}
.gp-viewport{flex:1;overflow:auto;background:var(--studio-bg);}
.at-main{padding:16px;position:relative;}
[data-theme="dark"] .at-main svg text{fill:#d1d5db !important;}
[data-theme="dark"] .at-main svg line,[data-theme="dark"] .at-main svg path{stroke:#4b5563 !important;}
.gp-track-list{display:flex;flex-wrap:wrap;gap:5px;padding:7px 14px;border-bottom:1px solid var(--studio-border);background:var(--studio-surface);flex-shrink:0;}
.gp-track-chip{
font-size:11px;padding:4px 10px 4px 12px;border-radius:999px;
border:1px solid var(--studio-border2);cursor:pointer;
background:var(--studio-panel2);color:var(--studio-muted);
transition:.2s;user-select:none;display:inline-flex;align-items:center;gap:6px;
}
.gp-track-chip.on{background:linear-gradient(135deg,var(--studio-accent),#7c3aed);border-color:var(--studio-accent);color:#fff;box-shadow:0 0 8px var(--studio-glow);}
.gp-track-chip:hover{border-color:var(--studio-accent2);color:var(--studio-text);}
.gp-track-chip.on:hover{opacity:.88;}
.gp-mute-btn{font-size:13px;line-height:1;cursor:pointer;opacity:.55;transition:.15s;flex-shrink:0;}
.gp-mute-btn:hover{opacity:1;}
.gp-mute-btn.muted{opacity:1;color:#f87171;}
.gp-track-chip .gp-vol{width:54px;cursor:pointer;accent-color:var(--studio-accent2);}
.gp-track-chip .gp-vol-val{font-size:10px;opacity:.7;min-width:22px;text-align:right;font-variant-numeric:tabular-nums;}
.gp-fx-e{display:inline-flex;align-items:center;gap:5px;color:var(--studio-text);cursor:pointer;white-space:nowrap;}
.gp-fx-val{font-size:10px;color:var(--studio-muted);min-width:24px;text-align:right;font-variant-numeric:tabular-nums;}
#gp-fx-panel .gp-vol{width:70px;accent-color:var(--studio-accent2);}
.gp-empty{flex:1;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;color:var(--studio-muted);}
.gp-loading{font-size:12px;color:var(--studio-muted);text-align:center;padding:40px;}
.gp-fx-bar{display:flex;align-items:center;gap:10px;padding:5px 14px;border-bottom:1px solid var(--studio-border);background:var(--studio-surface);flex-shrink:0;}
.gp-fx-label{font-size:10px;color:var(--studio-muted);white-space:nowrap;letter-spacing:.04em;}
/* AlphaTab cursor */
.at-cursor-bar{background:rgba(147,51,234,.12);border-left:2px solid var(--studio-accent2);}
.at-cursor-beat{background:rgba(168,85,247,.55);width:2px;}
.at-highlight .at-note-head{fill:var(--studio-accent2) !important;}
/* ── Stage ── */
.ms-stage{flex:1;min-width:0;display:flex;flex-direction:column;overflow:hidden;background:var(--studio-bg);}
#audiomass-panel{flex:1;min-height:0;flex-direction:column;}
.ms-stage-hdr{
display:flex;align-items:center;padding:7px 14px;
border-bottom:1px solid var(--studio-border);
background:var(--studio-panel);flex-shrink:0;gap:8px;
box-shadow:0 1px 0 var(--studio-border);
}
.ms-stage-title{font-size:12px;font-weight:700;color:var(--studio-text);flex:1;letter-spacing:.03em;}
.ms-stage-btn{
background:transparent;border:1px solid var(--studio-border2);border-radius:6px;
padding:3px 9px;font-size:11px;color:var(--studio-muted);cursor:pointer;font-family:inherit;transition:.2s;
}
.ms-stage-btn:hover{border-color:var(--studio-accent2);color:var(--studio-accent2);}
.ms-stage-body{flex:1;overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:12px;}
.ms-stage-empty{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;color:var(--studio-muted);gap:10px;padding:40px 20px;text-align:center;}
.ms-stage-empty .icon{font-size:52px;opacity:.25;}
.ms-stage-empty p{font-size:12px;line-height:1.7;max-width:240px;opacity:.7;}
/* ── Sep model cards ── */
.sep-model-opt{display:block;padding:8px 10px;border:1.5px solid var(--studio-border2);border-radius:9px;cursor:pointer;transition:.15s;background:transparent;color:var(--studio-text);}
.sep-model-opt:hover{border-color:#a855f7;background:rgba(168,85,247,.06);}
.sep-model-opt.sep-model-active{border-color:#a855f7;background:rgba(168,85,247,.12);}
/* ── Resize handle ── */
.ms-resize{width:3px;flex-shrink:0;background:var(--studio-border);cursor:col-resize;transition:.2s;}
.ms-resize:hover,.ms-resize.dragging{background:var(--studio-accent2);box-shadow:0 0 6px var(--studio-glow);}
/* ── Chat panel ── */
.ms-chat{width:340px;flex-shrink:0;display:flex;flex-direction:column;border-left:1px solid var(--studio-border);background:var(--studio-panel);min-height:0;overflow:hidden;}
.ms-chat-hdr{display:flex;align-items:center;padding:8px 12px;border-bottom:1px solid var(--studio-border);background:var(--studio-panel);flex-shrink:0;gap:6px;}
.ms-chat-hdr-label{font-size:9.5px;font-weight:700;color:var(--studio-accent2);text-transform:uppercase;letter-spacing:.1em;flex-shrink:0;}
.ms-model-sel{flex:1;background:var(--studio-panel2);border:1px solid var(--studio-border2);border-radius:6px;padding:4px 7px;font-size:11px;color:var(--studio-text);font-family:inherit;min-width:0;}
.ms-chat-clear{background:none;border:none;cursor:pointer;font-size:15px;color:var(--studio-muted);padding:2px 4px;transition:.15s;}
.ms-chat-clear:hover{color:var(--studio-red);}
.ms-msgs{flex:1;overflow-y:auto;padding:12px 10px;display:flex;flex-direction:column;gap:10px;}
.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:8px 12px;border-radius:12px;font-size:12px;
line-height:1.6;max-width:92%;word-break:break-word;
}
.ms-msg.user .ms-bubble{
background:linear-gradient(135deg,#4c1d95,#5b21b6);
color:#f3e8ff;border-radius:12px 12px 3px 12px;
box-shadow:0 2px 8px rgba(91,33,182,.3);
}
.ms-msg.assistant .ms-bubble{
background:var(--studio-panel2);
border:1px solid var(--studio-border2);
border-radius:12px 12px 12px 3px;
}
.ms-msg.system .ms-bubble{background:transparent;color:var(--studio-muted);font-size:11px;}
.ms-bubble pre{background:var(--studio-bg);border:1px solid var(--studio-border2);border-radius:8px;padding:10px;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:5px 10px;border-radius:7px;font-size:10px;color:var(--studio-muted);background:var(--studio-surface);border:1px solid var(--studio-border);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:4px;align-items:center;padding:6px 10px;}
.ms-dot{width:5px;height:5px;background:var(--studio-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:.35;}40%{transform:scale(1);opacity:1;}}
.ms-stage-link{font-size:10px;color:var(--studio-accent2);cursor:pointer;text-decoration:underline;margin-top:2px;display:inline-block;}
/* ── Input ── */
.ms-input-bar{flex-shrink:0;padding:10px;border-top:1px solid var(--studio-border);display:flex;gap:7px;align-items:flex-end;background:var(--studio-panel);}
.ms-textarea{
flex:1;background:var(--studio-panel2);border:1px solid var(--studio-border2);
border-radius:10px;padding:8px 12px;font-size:12px;color:var(--studio-text);
font-family:inherit;resize:none;min-height:36px;max-height:120px;outline:none;transition:.2s;
}
.ms-textarea:focus{border-color:var(--studio-accent2);box-shadow:0 0 0 2px var(--studio-glow2);}
.ms-send{
background:linear-gradient(135deg,var(--studio-accent),#7c3aed);
border:none;border-radius:10px;padding:8px 15px;font-size:12px;color:#fff;
cursor:pointer;font-family:inherit;font-weight:700;transition:.2s;flex-shrink:0;
box-shadow:0 2px 8px var(--studio-glow);
}
.ms-send:hover:not(:disabled){background:linear-gradient(135deg,#a855f7,#8b5cf6);box-shadow:0 4px 14px var(--studio-glow);}
.ms-send:disabled{opacity:.45;cursor:not-allowed;}
/* ── MIDI widgets (stage) ── */
.music-widget{border:1px solid var(--studio-border2);border-radius:12px;background:var(--studio-panel);overflow:hidden;box-shadow:0 2px 12px rgba(0,0,0,.3);}
.music-widget-header{display:flex;align-items:center;justify-content:space-between;padding:8px 12px;background:var(--studio-panel2);border-bottom:1px solid var(--studio-border);}
.music-widget-label{font-size:12px;font-weight:700;color:var(--studio-text);letter-spacing:.02em;}
.music-render-btn{background:#1e3a5f;border:1px solid #3b82f6;color:#93c5fd;border-radius:6px;padding:3px 9px;font-size:11px;cursor:pointer;font-family:inherit;transition:.2s;}
.music-render-btn:hover:not(:disabled){background:#1d4ed8;color:#fff;box-shadow:0 0 8px rgba(59,130,246,.4);}
.music-render-btn:disabled{opacity:.5;cursor:not-allowed;}
.music-widget-body{padding:10px 12px;}
.music-widget-body.collapsed{display:none;}
.music-output{margin-top:6px;}
.music-actions{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px;}
.music-dl-btn{display:inline-flex;align-items:center;gap:4px;background:var(--studio-panel);border:1px solid var(--studio-border2);border-radius:7px;padding:4px 10px;font-size:11px;color:var(--studio-muted);cursor:pointer;text-decoration:none;font-family:inherit;transition:.2s;}
.music-dl-btn:hover{border-color:var(--studio-accent2);color:var(--studio-accent2);}
.music-dl-btn.btn-green{background:#052e16;border-color:#22c55e;color:#86efac;}
.music-dl-btn.btn-purple{background:#1e0442;border-color:var(--studio-accent2);color:#d8b4fe;}
.music-dl-btn.btn-amber{background:#1c0700;border-color:#f59e0b;color:#fcd34d;}
.music-page-btn{background:transparent;border:1px solid var(--studio-border2);border-radius:5px;padding:2px 8px;font-size:11px;cursor:pointer;color:var(--studio-muted);font-family:inherit;transition:.2s;}
.music-page-btn:hover{border-color:var(--studio-accent2);color:var(--studio-accent2);}
.music-page-nav{display:flex;align-items:center;gap:6px;margin-bottom:6px;font-size:11px;color:var(--studio-muted);}
.midi-router-panel{display:flex;flex-wrap:wrap;gap:5px;align-items:center;margin-top:6px;}
.midi-router-sel{background:var(--studio-panel2);border:1px solid var(--studio-border2);border-radius:6px;padding:2px 6px;font-size:11px;color:var(--studio-text);}
/* ── MIDI editor (piano roll) ── */
.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:7px 14px;background:#191a2c;border-bottom:1px solid rgba(255,255,255,.06);flex-shrink:0;}
.ms-editor-back{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8cadc;border-radius:7px;padding:4px 12px;font-size:12px;cursor:pointer;font-family:inherit;transition:.2s;}
.ms-editor-back:hover{background:#2a2a3e;border-color:var(--studio-accent2);}
.ms-editor-title{font-size:12px;font-weight:700;color:#c8cadc;flex:1;letter-spacing:.03em;}
#me-body{display:flex;flex-direction:column;flex:1;min-height:0;overflow:hidden;color:#c8cadc;font-size:13px;user-select:none;}
#toolbar{display:flex;align-items:center;gap:3px;padding:5px 10px;background:#191a2c;border-bottom:1px solid rgba(255,255,255,.06);flex-shrink:0;flex-wrap:wrap;min-height:38px;}
.tb-sep{width:1px;height:20px;background:rgba(255,255,255,.08);margin:0 5px;}
.tb-btn{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#9a9cb8;border-radius:6px;padding:3px 9px;cursor:pointer;font-size:11px;white-space:nowrap;transition:.15s;}
.tb-btn:hover{background:#282840;border-color:rgba(168,85,247,.4);color:#c8cadc;}
.tb-btn.active{background:linear-gradient(135deg,#4c1d95,#5b21b6);color:#fff;border-color:var(--studio-accent);box-shadow:0 0 8px var(--studio-glow);}
.tb-lbl{color:#4a4d6a;font-size:11px;letter-spacing:.04em;}
select.tb-sel{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8cadc;border-radius:6px;padding:2px 5px;font-size:11px;}
input.tb-num{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8cadc;border-radius:6px;padding:2px 7px;width:58px;font-size:11px;text-align:center;}
#midi-out-sel{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8cadc;border-radius:6px;padding:2px 5px;font-size:11px;max-width:160px;}
#me-main{display:flex;flex:1;overflow:hidden;}
#track-panel{width:168px;flex-shrink:0;background:#191a2c;border-right:1px solid rgba(255,255,255,.06);display:flex;flex-direction:column;overflow:hidden;}
#tp-head{padding:5px 10px;color:#4a4d6a;font-size:10px;border-bottom:1px solid rgba(255,255,255,.06);display:flex;align-items:center;justify-content:space-between;flex-shrink:0;letter-spacing:.06em;text-transform:uppercase;}
.tp-add-btn{background:none;border:1px solid rgba(255,255,255,.15);border-radius:4px;color:#6a6888;cursor:pointer;font-size:13px;line-height:1;padding:0 5px;}
#track-list{flex:1;overflow-y:auto;}
.ti{padding:5px 8px 4px;border-bottom:1px solid rgba(255,255,255,.04);cursor:pointer;transition:.15s;}
.ti:hover{background:rgba(255,255,255,.03);}
.ti.sel{background:rgba(168,85,247,.1);border-left:2px solid var(--studio-accent2);}
.ti-top{display:flex;align-items:center;gap:5px;}
.ti-dot{width:10px;height:10px;border-radius:3px;flex-shrink:0;cursor:pointer;}
.ti-name{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:11px;color:#c8cadc;}
.ti-ch{color:#4a4d6a;font-size:10px;flex-shrink:0;}
.ti-btns{display:flex;gap:2px;margin-top:3px;}
.ti-btn{background:#272840;border:1px solid rgba(255,255,255,.1);color:#6e7096;border-radius:4px;padding:1px 6px;cursor:pointer;font-size:10px;transition:.12s;}
.ti-btn:hover{background:#252538;color:#c8cadc;}
.ti-btn.m-on{background:#7f1d1d;color:#fca5a5;border-color:#ef4444;}
.ti-btn.s-on{background:#14532d;color:#86efac;border-color:#22c55e;}
.ti-prog{font-size:9px;color:#383b52;margin-top:2px;}
#roll-wrap{flex:1;display:flex;flex-direction:column;overflow:hidden;min-width:0;}
#ruler-row{display:flex;flex-shrink:0;border-bottom:1px solid rgba(255,255,255,.06);}
#corner{width:56px;flex-shrink:0;background:#161727;border-right:1px solid rgba(255,255,255,.06);height:24px;display:flex;align-items:center;justify-content:center;}
#corner span{color:#2e3050;font-size:9px;}
#ruler-canvas{flex:1;display:block;background:#161727;height:24px;cursor:pointer;}
#roll-row{flex:1;display:flex;overflow:hidden;min-height:0;}
#keys-canvas{display:block;flex-shrink:0;width:56px;background:#161727;border-right:1px solid rgba(255,255,255,.06);cursor:pointer;}
#roll-canvas{display:block;flex:1;min-width:0;cursor:default;}
#vel-row{height:72px;flex-shrink:0;display:flex;border-top:1px solid rgba(255,255,255,.06);}
#vel-label{width:56px;flex-shrink:0;background:#161727;border-right:1px solid rgba(255,255,255,.06);display:flex;align-items:center;justify-content:center;color:#2e3050;font-size:9px;}
#vel-canvas{display:block;flex:1;min-width:0;cursor:ns-resize;background:#191a2c;}
#master-channel{display:flex;align-items:center;gap:6px;padding:4px 10px;background:#141425;border-top:1px solid rgba(255,255,255,.1);flex-shrink:0;flex-wrap:wrap;}
.mc-label{font-size:9px;font-weight:700;letter-spacing:.1em;color:#6a5fa8;text-transform:uppercase;white-space:nowrap;}
.mc-lbl{font-size:10px;color:#8888a8;white-space:nowrap;display:inline-flex;align-items:center;gap:3px;cursor:pointer;}
.mc-sep{width:1px;height:16px;background:rgba(255,255,255,.1);flex-shrink:0;}
.mc-sl{width:72px;accent-color:#a78bfa;cursor:pointer;vertical-align:middle;}
.mc-val{font-size:10px;color:#6a6888;min-width:30px;}
#transport{display:flex;align-items:center;gap:7px;padding:5px 12px;background:#191a2c;border-top:1px solid rgba(255,255,255,.06);flex-shrink:0;}
.tr-btn{background:#1e1e30;border:1px solid rgba(255,255,255,.1);color:#c8cadc;border-radius:6px;padding:3px 12px;cursor:pointer;font-size:14px;transition:.15s;}
.tr-btn:hover{background:#282840;border-color:rgba(168,85,247,.4);}
#pos-disp{font-family:'SF Mono',Monaco,monospace;color:#86efac;font-size:12px;min-width:90px;letter-spacing:.05em;}
#status-txt{font-size:11px;color:#4a4d6a;}
/* ── Drop overlay ── */
.ms-drop{position:fixed;inset:0;background:rgba(147,51,234,.12);border:2px dashed var(--studio-accent2);border-radius:16px;z-index:100;display:none;align-items:center;justify-content:center;font-size:18px;color:var(--studio-accent2);font-weight:700;pointer-events:none;backdrop-filter:blur(2px);}
.ms-drop.on{display:flex;}
/* ── Scrollbars ── */
*::-webkit-scrollbar{width:4px;height:4px;}
*::-webkit-scrollbar-track{background:transparent;}
*::-webkit-scrollbar-thumb{background:var(--studio-border2);border-radius:4px;}
*::-webkit-scrollbar-thumb:hover{background:var(--studio-accent2);}
/* ── Drum box ── */
#stage-drums{display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden;}
#drum-grid{flex:1;overflow:auto;padding:12px 14px;display:flex;flex-direction:column;gap:6px;background:var(--studio-bg);}
.drum-row{display:flex;align-items:center;gap:6px;}
.drum-label{width:88px;flex-shrink:0;font-size:11px;color:var(--studio-muted);text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}
.drum-cells{display:flex;gap:3px;flex:1;min-width:0;}
.drum-cell{flex:1;min-width:14px;height:26px;border-radius:5px;background:var(--studio-surface);border:1px solid var(--studio-border);cursor:pointer;transition:.1s;padding:0;}
.drum-cell:hover{border-color:var(--studio-accent2);background:var(--studio-glow2);}
.drum-cell.on{background:linear-gradient(135deg,var(--studio-accent),#7c3aed);border-color:var(--studio-accent);box-shadow:0 0 6px var(--studio-glow);}
.drum-cell.cur{outline:2px solid #fbbf24;outline-offset:-1px;}
.drum-row.beat .drum-cell{background:var(--studio-panel);}
.drum-row.beat .drum-cell.on{background:linear-gradient(135deg,var(--studio-accent),#7c3aed);}
.drum-steps-sel{background:var(--studio-panel2);border:1px solid var(--studio-border2);border-radius:6px;padding:2px 6px;font-size:11px;color:var(--studio-text);}
/* ── Looper card ── */
.drum-loop-panel{border-top:1px solid var(--studio-border);background:var(--studio-bg);padding:12px 14px 14px;flex-shrink:0;}
.loop-card{border:1px solid var(--studio-border2);border-radius:14px;background:var(--studio-panel);overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,.4);}
.loop-card-hdr{display:flex;align-items:center;gap:8px;padding:10px 14px;border-bottom:1px solid var(--studio-border);background:var(--studio-panel2);}
.loop-card-title{font-size:13px;font-weight:700;color:var(--studio-text);letter-spacing:.03em;}
.loop-card-status{margin-left:auto;font-size:11px;color:var(--studio-muted);white-space:nowrap;}
.loop-meter-bar{height:4px;background:var(--studio-surface);position:relative;flex-shrink:0;}
.loop-meter-fill{height:100%;width:0%;background:linear-gradient(90deg,#22c55e,#eab308,#ef4444);transition:width .05s linear;}
.loop-transport{display:flex;align-items:center;gap:10px;padding:12px 14px 8px;}
.loop-btn{
display:flex;flex-direction:column;align-items:center;gap:4px;
border:1px solid var(--studio-border2);border-radius:12px;
padding:10px 18px;font-size:11px;color:var(--studio-muted);cursor:pointer;
background:var(--studio-panel2);font-family:inherit;transition:.2s;min-width:62px;
letter-spacing:.03em;
}
.loop-btn .lb-icon{font-size:22px;line-height:1;}
.loop-btn:hover{border-color:var(--studio-accent2);color:var(--studio-text);background:var(--studio-glow2);}
.loop-btn.rec{border-color:rgba(239,68,68,.5);color:#fca5a5;}
.loop-btn.rec:hover,.loop-btn.rec.active{background:#7f1d1d;border-color:var(--studio-red);color:#fff;box-shadow:0 0 12px rgba(239,68,68,.4);animation:lpPulse 1s ease-in-out infinite;}
.loop-btn.play.active{background:rgba(34,197,94,.12);border-color:var(--studio-green);color:#86efac;box-shadow:0 0 10px rgba(34,197,94,.25);}
.loop-sec{display:flex;align-items:center;gap:7px;padding:0 14px 12px;flex-wrap:wrap;}
.loop-sec select,.loop-sec button{font-size:11px;font-family:inherit;padding:4px 10px;border:1px solid var(--studio-border2);border-radius:7px;background:var(--studio-panel2);color:var(--studio-muted);cursor:pointer;transition:.2s;}
.loop-sec select:hover,.loop-sec button:hover{border-color:var(--studio-accent2);color:var(--studio-text);}
.loop-sec .loop-del-all{border-color:rgba(239,68,68,.4);color:#fca5a5;}
.loop-sec .loop-del-all:hover{background:#7f1d1d;border-color:var(--studio-red);color:#fff;}
.loop-layers{display:flex;flex-direction:column;gap:0;border-top:1px solid var(--studio-border);}
.loop-empty{color:var(--studio-muted);font-size:11px;line-height:1.7;padding:12px 14px;text-align:center;}
.loop-layer{display:flex;align-items:center;gap:9px;padding:8px 14px;border-bottom:1px solid var(--studio-border);}
.loop-layer:last-child{border-bottom:none;}
.loop-layer .ll-num{width:22px;height:22px;border-radius:50%;background:rgba(147,51,234,.18);border:1px solid rgba(168,85,247,.5);color:#c084fc;font-size:10px;font-weight:700;display:flex;align-items:center;justify-content:center;flex-shrink:0;}
.loop-layer .ll-mute{background:none;border:1px solid var(--studio-border2);border-radius:6px;padding:3px 7px;font-size:12px;cursor:pointer;color:var(--studio-muted);font-family:inherit;flex-shrink:0;transition:.15s;}
.loop-layer .ll-mute.muted{background:#7f1d1d;border-color:var(--studio-red);color:#fca5a5;}
.loop-layer input[type=range]{flex:1;min-width:0;accent-color:var(--studio-accent2);}
.loop-layer .ll-dur{font-size:10px;color:var(--studio-muted);width:36px;text-align:right;flex-shrink:0;}
.loop-layer .ll-del{background:none;border:none;cursor:pointer;color:var(--studio-muted);font-size:15px;padding:2px 5px;flex-shrink:0;transition:.15s;border-radius:4px;}
.loop-layer .ll-del:hover{color:var(--studio-red);background:rgba(239,68,68,.1);}
#loop-rec-btn.recording{animation:lpPulse 1s ease-in-out infinite;}
@keyframes lpPulse{0%,100%{opacity:1;}50%{opacity:.5;}}
@keyframes recPulse{0%,100%{opacity:1;}50%{opacity:.55;}}
/* ── Audio track area ── */
#wave-area{display:flex;flex-direction:column;flex-shrink:0;border-bottom:1px solid rgba(255,255,255,.07);}
#wave-lanes{display:flex;flex-direction:column;}
.wave-lane{display:flex;height:64px;border-bottom:1px solid rgba(255,255,255,.06);flex-shrink:0;}
.wave-lane-ctrl{width:56px;flex-shrink:0;background:#161727;border-right:1px solid rgba(255,255,255,.07);display:flex;align-items:center;justify-content:center;}
.wl-color-strip{width:3px;height:38px;border-radius:2px;}
.wave-lane-cv-wrap{flex:1;position:relative;overflow:hidden;background:#191a2c;}
.wave-lane-cv{position:absolute;inset:0;width:100%;height:100%;}
.wave-lane-hdr{position:absolute;top:0;left:0;right:0;height:23px;display:flex;align-items:center;padding:0 7px;gap:5px;background:linear-gradient(to bottom,rgba(10,10,30,.65) 0%,transparent 100%);pointer-events:none;}
.wl-name{flex:1;font-size:10px;color:rgba(205,202,228,.75);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:text;pointer-events:all;}
.wl-name:hover{color:#dddaf5;}
.wl-hdr-btns{display:flex;align-items:center;gap:3px;pointer-events:all;}
.wl-hbtn{background:rgba(255,255,255,.09);border:1px solid rgba(255,255,255,.15);border-radius:3px;cursor:pointer;font-size:9px;color:#9496b8;padding:1px 5px;line-height:1.4;}
.wl-hbtn:hover{background:rgba(255,255,255,.18);color:#d8daf0;}
.wl-hbtn.muted{color:#ef4444;border-color:rgba(239,68,68,.5);}
.wl-hbtn.rec{color:#f87171;border-color:rgba(248,113,113,.5);}
.wl-hbtn.rec.armed{color:#fca5a5;border-color:#ef4444;background:rgba(239,68,68,.1);}
.wl-hbtn.rec.active{background:rgba(127,29,29,.8);color:#fecaca;border-color:#ef4444;animation:recPulse .9s ease-in-out infinite;}
.wl-hbtn.del:hover{background:rgba(239,68,68,.2);border-color:#ef4444;color:#fca5a5;}
.wl-vol-sm{width:46px;accent-color:#a78bfa;cursor:pointer;vertical-align:middle;}
#wave-add-bar{display:flex;align-items:center;gap:6px;padding:4px 8px;background:rgba(255,255,255,.025);border-top:1px solid rgba(255,255,255,.06);}
#wave-add-bar::before{content:'오디오 트랙';font-size:9px;color:#5a5a78;margin-right:2px;letter-spacing:.04em;}
.wave-add-btn{background:rgba(255,255,255,.05);border:1px dashed rgba(255,255,255,.18);border-radius:5px;color:#8888a8;font-size:10px;cursor:pointer;padding:3px 10px;}
.wave-add-btn:hover{background:rgba(167,139,250,.12);color:#c0b8f0;border-style:solid;border-color:rgba(167,139,250,.4);}
/* ── Drum box specific ── */
.drum-box-sb-btn{
background:rgba(147,51,234,.15);border:1px solid rgba(168,85,247,.4);
color:#c084fc;border-radius:9px;padding:7px 10px;font-size:12px;font-weight:600;
cursor:pointer;font-family:inherit;text-align:left;transition:.2s;margin-top:4px;
display:block;width:100%;letter-spacing:.02em;
}
.drum-box-sb-btn:hover{background:rgba(147,51,234,.3);border-color:var(--studio-accent2);color:#e9d5ff;box-shadow:0 0 10px var(--studio-glow2);}
.drum-back-btn{
background:rgba(147,51,234,.15);border:1px solid rgba(168,85,247,.4);
color:#c084fc;border-radius:7px;padding:5px 14px;font-size:12px;font-weight:600;
cursor:pointer;font-family:inherit;transition:.2s;
}
.drum-back-btn:hover{background:rgba(147,51,234,.3);border-color:var(--studio-accent2);color:#e9d5ff;}
/* ── Mobile ── */
@media(max-width:1024px){
.ms-hdr{padding:0 10px;gap:6px;}
.ms-hdr h1{font-size:13px;}
/* Sidebar → left overlay */
.ms-sb{
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:82vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 28px rgba(0,0,0,.5);
}
.ms-sb.mob-open{transform:translateX(0);}
/* Chat → right overlay */
.ms-chat{
position:fixed;top:0;right:0;bottom:52px;z-index:500;
width:92vw;max-width:360px;
transform:translateX(100%);transition:transform .25s ease;
box-shadow:-4px 0 28px rgba(0,0,0,.5);
}
.ms-chat.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--studio-panel);border-top:1px solid var(--studio-border);height:52px;}
.ms-layout{padding-bottom:52px;}
/* Master channel: wrap on small screens */
#master-channel{flex-wrap:wrap;gap:4px;}
input,textarea,select{font-size:16px !important;}
}
@media(min-width:1025px){
#mob-backdrop{display:none !important;}
#mob-tabbar{display:none !important;}
}
</style>
<!-- AudioMass built-in -->
<link rel="stylesheet" href="/audiomass/main.css">
<style>
/* AudioMass icon font override — must win over body font-family inheritance */
#audiomass-stage .pk_tb .pk_btn,
#audiomass-stage [class*="icon-"] {
font-family: 'icomoon' !important;
color: #eef2ff !important;
}
#audiomass-stage .pk_tb .pk_btn:hover {
color: #5af2ff !important;
}
#audiomass-stage .pk_tb .pk_btn.pk_act {
color: #5af2ff !important;
}
#audiomass-stage .pk_hdr,
#audiomass-stage .pk_hdr .pk_btn button {
color: #eef2ff !important;
}
</style>
</head>
<body>
<div class="ms-drop" id="drop-overlay">🎵 MIDI 파일을 드롭하세요</div>
<div id="mob-backdrop" onclick="mobCloseAll()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-files" onclick="mobToggle('files')"><span style="font-size:18px">📂</span>파일</button>
<button class="mob-tab active" id="mobtab-stage" onclick="mobCloseAll()"><span style="font-size:18px">🎵</span>스테이지</button>
<button class="mob-tab" id="mobtab-ai" onclick="mobToggle('ai')"><span style="font-size:18px">🤖</span>AI</button>
</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>
<button class="drum-box-sb-btn" onclick="showAudioMass(true)">✂️ 오디오 편집</button>
<button class="drum-box-sb-btn" onclick="openWavacity()">🎚 Wavacity</button>
<button class="drum-box-sb-btn" onclick="showYtPanel(true)">🎬 YouTube</button>
<button class="drum-box-sb-btn" onclick="showSepPanel(true)">🎙 보컬 분리</button>
<button class="drum-box-sb-btn" onclick="showStageDrums()">🥁 드럼박스</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>
<div class="ms-divider"></div>
<div class="ms-file-hdr">
<span>MIDI 다운로드</span>
</div>
<!-- MIDI download site picker -->
<div style="padding:4px 6px 2px;display:flex;gap:3px;">
<button id="mdl-site-midifind" onclick="mdlSetSite('midifind')" style="font-size:10px;padding:2px 6px;border:1px solid #a855f7;border-radius:4px;background:#a855f7;color:#fff;cursor:pointer;font-family:inherit;">midifind</button>
<button id="mdl-site-bitmidi" onclick="mdlSetSite('bitmidi')" style="font-size:10px;padding:2px 6px;border:1px solid var(--line);border-radius:4px;background:var(--panel);color:var(--muted);cursor:pointer;font-family:inherit;">bitmidi</button>
</div>
<div style="padding:2px 6px 4px;display:flex;gap:4px;">
<input id="mdl-search-input" type="text" placeholder="곡명/아티스트 검색..." onkeydown="if(event.key==='Enter')mdlSearch()"
style="flex:1;font-size:11px;padding:3px 7px;border:1px solid var(--line);border-radius:5px;background:var(--bg);color:var(--text);font-family:inherit;outline:none;">
<button onclick="mdlSearch()" style="font-size:11px;padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:var(--panel);color:var(--muted);cursor:pointer;">🔍</button>
</div>
<div class="ms-files" id="mdl-search-results" style="max-height:150px;overflow-y:auto;">
<div style="font-size:11px;color:var(--muted);padding:4px 8px">곡명 또는 아티스트로 검색</div>
</div>
<div class="ms-divider"></div>
<div class="ms-file-hdr">
<span>Guitar Pro</span>
</div>
<!-- GP 검색 -->
<div style="padding:4px 6px 2px;display:flex;gap:3px;">
<button id="gp-site-gptabs" onclick="gpSetSite('guitarprotabs')" style="font-size:10px;padding:2px 6px;border:1px solid #a855f7;border-radius:4px;background:#a855f7;color:#fff;cursor:pointer;font-family:inherit;">gptabs</button>
<button id="gp-site-gprotab" onclick="gpSetSite('gprotab')" style="font-size:10px;padding:2px 6px;border:1px solid var(--line);border-radius:4px;background:var(--panel);color:var(--muted);cursor:pointer;font-family:inherit;">gprotab</button>
</div>
<div style="padding:2px 6px 4px;display:flex;gap:4px;">
<input id="gp-search-input" type="text" placeholder="검색..." onkeydown="if(event.key==='Enter')gpSearch()"
style="flex:1;font-size:11px;padding:3px 7px;border:1px solid var(--line);border-radius:5px;background:var(--bg);color:var(--text);font-family:inherit;outline:none;">
<button onclick="gpSearch()" style="font-size:11px;padding:3px 8px;border:1px solid var(--line);border-radius:5px;background:var(--panel);color:var(--muted);cursor:pointer;">🔍</button>
</div>
<div class="ms-files" id="gp-search-results" style="flex:1;min-height:0;">
<div style="font-size:11px;color:var(--muted);padding:4px 8px">아티스트 또는 곡명으로 검색</div>
</div>
<div class="ms-files" id="gp-file-list" style="max-height:80px;">
</div>
<label class="ms-upload">
⬆ GP 파일 열기
<input type="file" accept=".gp,.gp3,.gp4,.gp5,.gp6,.gpx,.gp7" style="display:none" onchange="openGpFile(this.files[0]);this.value=''">
</label>
</aside>
<!-- ── Center stage ── -->
<section class="ms-stage" id="stage">
<!-- AudioMass panel -->
<div id="audiomass-panel" style="display:none;flex:1;min-height:0;overflow:hidden;flex-direction:column;">
<div style="flex-shrink:0;padding:4px 8px;background:#2c3152;border-bottom:1px solid rgba(90,242,255,.3);display:flex;align-items:center;gap:8px;">
<button onclick="showAudioMass(false)" style="padding:3px 10px;background:rgba(90,242,255,.08);border:1px solid rgba(90,242,255,.3);color:#eef2ff;border-radius:5px;font-size:11px;cursor:pointer;">← 뒤로</button>
<span style="font-size:11px;color:#8898c8;">AudioMass Audio Editor</span>
</div>
<div id="audiomass-stage" style="flex:1;min-height:0;overflow:hidden;"></div>
</div>
<!-- YouTube downloader panel -->
<div id="yt-panel" style="display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden;">
<div class="ms-stage-hdr">
<button class="ms-stage-btn" onclick="ytBack()" style="margin-right:6px">← 뒤로</button>
<span class="ms-stage-title">🎬 YouTube 다운로더</span>
</div>
<div style="flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:16px;align-items:center;">
<!-- Search / URL tabs -->
<div style="width:100%;max-width:520px;display:flex;flex-direction:column;gap:10px;">
<div style="display:flex;gap:6px;">
<button id="yt-tab-search" onclick="setYtTab('search')"
style="flex:1;padding:7px;background:rgba(168,85,247,.15);border:1.5px solid #a855f7;color:#a855f7;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;">🔍 검색</button>
<button id="yt-tab-url" onclick="setYtTab('url')"
style="flex:1;padding:7px;background:transparent;border:1.5px solid var(--studio-border2);color:var(--studio-muted);border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;">🔗 URL</button>
</div>
<!-- Search input -->
<div id="yt-search-box" style="display:flex;gap:8px;">
<input id="yt-search-input" type="text" placeholder="제목, 아티스트로 검색…"
style="flex:1;padding:10px 14px;background:var(--studio-panel2);border:1.5px solid var(--studio-border2);border-radius:10px;color:var(--studio-text);font-size:13px;outline:none;"
onfocus="this.style.borderColor='#a855f7'" onblur="this.style.borderColor=''"
onkeydown="if(event.key==='Enter')ytDoSearch()">
<button onclick="ytDoSearch()" id="yt-search-btn"
style="padding:10px 16px;background:#a855f7;color:#fff;border:none;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;">검색</button>
</div>
<!-- URL input -->
<div id="yt-url-box" style="display:none;display:flex;gap:8px;">
<input id="yt-url-input" type="url" placeholder="YouTube URL을 붙여넣으세요…"
style="flex:1;padding:10px 14px;background:var(--studio-panel2);border:1.5px solid var(--studio-border2);border-radius:10px;color:var(--studio-text);font-size:13px;outline:none;"
onfocus="this.style.borderColor='#a855f7'" onblur="this.style.borderColor=''"
onkeydown="if(event.key==='Enter')ytFetchInfo()">
<button onclick="ytFetchInfo()" id="yt-info-btn"
style="padding:10px 16px;background:#a855f7;color:#fff;border:none;border-radius:10px;font-size:13px;font-weight:700;cursor:pointer;white-space:nowrap;">확인</button>
</div>
</div>
<!-- Search results -->
<div id="yt-search-results" style="display:none;width:100%;max-width:520px;display:flex;flex-direction:column;gap:6px;"></div>
<!-- Video info preview -->
<div id="yt-info" style="display:none;width:100%;max-width:520px;background:var(--studio-panel2);border:1.5px solid var(--studio-border2);border-radius:12px;overflow:hidden;">
<!-- Embedded player -->
<div style="position:relative;width:100%;padding-top:56.25%;background:#000;">
<iframe id="yt-embed" src="" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen
style="position:absolute;top:0;left:0;width:100%;height:100%;border:none;"></iframe>
</div>
<div style="display:flex;gap:12px;padding:10px 12px;">
<div style="flex:1;min-width:0;">
<div id="yt-title" style="font-size:13px;font-weight:700;color:var(--studio-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"></div>
<div id="yt-meta" style="font-size:11px;color:var(--studio-muted);margin-top:3px;"></div>
</div>
</div>
<!-- Format selector -->
<div style="padding:0 12px 12px;display:flex;gap:6px;">
<label id="yt-fmt-mp3" onclick="setYtFmt('mp3')"
style="flex:1;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1.5px solid #a855f7;border-radius:9px;cursor:pointer;background:rgba(168,85,247,.1);">
<input type="radio" name="yt-fmt" value="mp3" checked style="accent-color:#a855f7">
<div>
<div style="font-size:12px;font-weight:700;color:var(--studio-text)">MP3 <span style="color:#a855f7;font-size:10px;">추천</span></div>
<div style="font-size:10px;color:var(--studio-muted)">320kbps · 보컬 분리 가능</div>
</div>
</label>
<label id="yt-fmt-best" onclick="setYtFmt('best')"
style="flex:1;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1.5px solid var(--studio-border2);border-radius:9px;cursor:pointer;">
<input type="radio" name="yt-fmt" value="best" style="accent-color:#a855f7">
<div>
<div style="font-size:12px;font-weight:700;color:var(--studio-text)">원본 최고음질</div>
<div style="font-size:10px;color:var(--studio-muted)">M4A/WebM · 무손실</div>
</div>
</label>
</div>
<div style="padding:0 12px 12px;">
<button id="yt-dl-btn" onclick="ytStartDownload()"
style="width:100%;padding:10px;background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;border:none;border-radius:9px;font-size:13px;font-weight:700;cursor:pointer;">
⬇ 다운로드 시작
</button>
</div>
</div>
<!-- Progress -->
<div id="yt-progress-wrap" style="display:none;width:100%;max-width:520px;">
<div style="display:flex;justify-content:space-between;margin-bottom:6px;">
<span id="yt-status-text" style="font-size:12px;color:var(--studio-muted)">다운로드 중…</span>
<span id="yt-pct-text" style="font-size:12px;font-weight:700;color:#a855f7">0%</span>
</div>
<div style="height:8px;background:var(--studio-border);border-radius:4px;overflow:hidden;">
<div id="yt-progress-bar" style="height:100%;background:linear-gradient(90deg,#7c3aed,#a855f7);border-radius:4px;width:0%;transition:width .4s ease;"></div>
</div>
</div>
<!-- Result -->
<div id="yt-result" style="display:none;width:100%;max-width:520px;background:var(--studio-panel2);border:1.5px solid var(--studio-border2);border-radius:12px;padding:14px;">
<div style="font-size:12px;font-weight:700;color:var(--studio-text);margin-bottom:10px;">✅ 다운로드 완료</div>
<div id="yt-result-body"></div>
</div>
</div>
</div>
<!-- Vocal separator panel -->
<div id="sep-panel" style="display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden;">
<div class="ms-stage-hdr">
<button class="ms-stage-btn" onclick="sepBack()" style="margin-right:6px">← 뒤로</button>
<span class="ms-stage-title">🎙 보컬 / 반주 분리</span>
<div style="display:flex;gap:4px;margin-left:auto;">
<button id="sep-tab-new" class="ms-stage-btn" onclick="setSepTab('new')" style="background:rgba(168,85,247,.15);border-color:#a855f7;color:#a855f7;">+ 새 작업</button>
<button id="sep-tab-files" class="ms-stage-btn" onclick="setSepTab('files')">📂 파일 목록</button>
</div>
</div>
<!-- New job view -->
<div id="sep-view-new" style="flex:1;overflow-y:auto;padding:20px;display:flex;flex-direction:column;gap:16px;align-items:center;">
<!-- Upload zone -->
<div id="sep-dropzone" style="width:100%;max-width:480px;border:2px dashed var(--studio-border2);border-radius:14px;padding:32px 20px;text-align:center;cursor:pointer;transition:.2s;background:var(--studio-panel2);"
onclick="document.getElementById('sep-file-input').click()"
ondragover="event.preventDefault();this.style.borderColor='#a855f7'"
ondragleave="this.style.borderColor=''"
ondrop="event.preventDefault();this.style.borderColor='';sepHandleFile(event.dataTransfer.files[0])">
<div style="font-size:40px;margin-bottom:8px">🎵</div>
<div style="font-size:13px;color:var(--studio-text);font-weight:600;margin-bottom:4px">MP3 / WAV / FLAC 파일을 드롭</div>
<div style="font-size:11px;color:var(--studio-muted)">또는 클릭하여 파일 선택 · 최대 200MB</div>
<input type="file" id="sep-file-input" accept=".mp3,.wav,.flac,.m4a,.ogg" style="display:none" onchange="sepHandleFile(this.files[0])">
</div>
<!-- Model selector -->
<div style="width:100%;max-width:480px;">
<div style="font-size:11px;color:var(--studio-muted);margin-bottom:6px;font-weight:600;letter-spacing:.04em;">모델 선택</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px;" id="sep-model-grid">
<label class="sep-model-opt sep-model-active" data-model="htdemucs" onclick="setSepModel('htdemucs')">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;font-weight:700">htdemucs</span>
<span style="font-size:9px;padding:1px 5px;background:#a855f7;color:#fff;border-radius:4px;">추천</span>
</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:2px">Hybrid Transformer · 균형</div>
</label>
<label class="sep-model-opt" data-model="htdemucs_ft" onclick="setSepModel('htdemucs_ft')">
<div style="font-size:12px;font-weight:700">htdemucs_ft</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:2px">Fine-tuned · 최고 품질 · 느림</div>
</label>
<label class="sep-model-opt" data-model="htdemucs_6s" onclick="setSepModel('htdemucs_6s')">
<div style="display:flex;align-items:center;justify-content:space-between;">
<span style="font-size:12px;font-weight:700">htdemucs_6s</span>
<span style="font-size:9px;padding:1px 5px;background:#0ea5e9;color:#fff;border-radius:4px;">6분리</span>
</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:2px">보컬+드럼+베이스+기타+피아노+기타2</div>
</label>
<label class="sep-model-opt" data-model="mdx_extra" onclick="setSepModel('mdx_extra')">
<div style="font-size:12px;font-weight:700">mdx_extra</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:2px">MDX-Net 고품질</div>
</label>
<label class="sep-model-opt" data-model="hdemucs_mmi" onclick="setSepModel('hdemucs_mmi')">
<div style="font-size:12px;font-weight:700">hdemucs_mmi</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:2px">구형 Hybrid · 안정적</div>
</label>
<label class="sep-model-opt" data-model="mdx" onclick="setSepModel('mdx')">
<div style="font-size:12px;font-weight:700">mdx</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:2px">MDX-Net 기본 · 빠름</div>
</label>
</div>
</div>
<!-- Stem mode selector -->
<div style="display:flex;gap:8px;width:100%;max-width:480px;" id="sep-stem-mode-wrap">
<label id="sep-opt-2" onclick="setSepMode('2')" style="flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid #a855f7;border-radius:10px;cursor:pointer;background:rgba(168,85,247,.1);">
<input type="radio" name="sep-mode" value="2" checked style="accent-color:#a855f7">
<div>
<div style="font-size:12px;font-weight:700;color:var(--studio-text)">2-stem <span style="color:#a855f7">추천</span></div>
<div style="font-size:10px;color:var(--studio-muted)">보컬 + 반주 · 빠름</div>
</div>
</label>
<label id="sep-opt-4" onclick="setSepMode('4')" style="flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid var(--studio-border2);border-radius:10px;cursor:pointer;">
<input type="radio" name="sep-mode" value="4" style="accent-color:#a855f7">
<div>
<div style="font-size:12px;font-weight:700;color:var(--studio-text)">4-stem</div>
<div style="font-size:10px;color:var(--studio-muted)">보컬+드럼+베이스+기타 · 느림</div>
</div>
</label>
</div>
<!-- Progress area (hidden by default) -->
<div id="sep-progress-wrap" style="display:none;width:100%;max-width:480px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px;">
<span id="sep-status-text" style="font-size:12px;color:var(--studio-muted)">분리 중… (CPU 모드, 수 분 소요)</span>
<span id="sep-pct-text" style="font-size:12px;font-weight:700;color:#a855f7">0%</span>
</div>
<div style="height:8px;background:var(--studio-border);border-radius:4px;overflow:hidden;">
<div id="sep-progress-bar" style="height:100%;background:linear-gradient(90deg,#7c3aed,#a855f7);border-radius:4px;width:0%;transition:width .4s ease;"></div>
</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:6px;text-align:center">htdemucs 모델 · 보컬/반주 2-stem 분리</div>
</div>
<!-- Results area (hidden by default) -->
<div id="sep-results" style="display:none;width:100%;max-width:480px;display:none;flex-direction:column;gap:10px;">
<div style="font-size:12px;font-weight:700;color:var(--studio-text);margin-bottom:2px">✅ 분리 완료</div>
<div id="sep-stem-list" style="display:flex;flex-direction:column;gap:8px;"></div>
</div>
</div>
<!-- File browser view -->
<div id="sep-view-files" style="display:none;flex:1;overflow-y:auto;padding:16px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px;">
<span style="font-size:11px;font-weight:700;color:var(--studio-muted);letter-spacing:.04em;">저장된 작업</span>
<button class="ms-stage-btn" onclick="loadSepJobs()">↺ 새로고침</button>
</div>
<div id="sep-job-list" style="display:flex;flex-direction:column;gap:10px;">
<div style="text-align:center;color:var(--studio-muted);font-size:12px;padding:40px 0;">불러오는 중…</div>
</div>
</div>
</div>
<!-- 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 (inline piano roll) -->
<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>
<button class="ms-editor-back" onclick="doSave()" style="margin-left:auto">💾 저장</button>
</div>
<div id="me-body">
<div id="toolbar">
<button class="tb-btn" onclick="openFileDialog()">📂 열기</button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="doUndo()" title="Ctrl+Z">↩</button>
<button class="tb-btn" onclick="doRedo()" title="Ctrl+Y">↪</button>
<div class="tb-sep"></div>
<button class="tb-btn" id="t-sel" onclick="setTool('sel')" title="V">▢ 선택</button>
<button class="tb-btn" id="t-draw" onclick="setTool('draw')" title="P">✏ 그리기</button>
<button class="tb-btn" id="t-erase" onclick="setTool('erase')" title="E">✗ 지우개</button>
<div class="tb-sep"></div>
<span class="tb-lbl">양자화</span>
<select class="tb-sel" id="q-sel" onchange="setQ(this.value)">
<option value="0">없음</option>
<option value="30">1/64</option>
<option value="60">1/32</option>
<option value="120" selected>1/16</option>
<option value="240">1/8</option>
<option value="480">1/4</option>
<option value="960">1/2</option>
<option value="1920">1/1</option>
</select>
<div class="tb-sep"></div>
<span class="tb-lbl">BPM</span>
<input class="tb-num" id="bpm-in" type="number" value="120" min="20" max="300" onchange="setBpm(+this.value)">
<div class="tb-sep"></div>
<button class="tb-btn" onclick="zoomX(1.3)">X+</button>
<button class="tb-btn" onclick="zoomX(1/1.3)">X−</button>
<button class="tb-btn" onclick="zoomY(1.3)">Y+</button>
<button class="tb-btn" onclick="zoomY(1/1.3)">Y−</button>
<div class="tb-sep"></div>
<button class="tb-btn" onclick="doAddTrack()">+ 트랙</button>
<div class="tb-sep"></div>
<span class="tb-lbl">MIDI 출력</span>
<select id="midi-out-sel"><option value="__sf2">내장 SF2</option><option value="">-- 없음 --</option></select>
<button class="tb-btn" onclick="meRefreshMidiOuts()">↻</button>
</div>
<div id="me-main">
<div id="track-panel">
<div id="tp-head"><span>트랙</span></div>
<div id="track-list"></div>
</div>
<div id="roll-wrap">
<div id="ruler-row">
<div id="corner"><span>bar</span></div>
<canvas id="ruler-canvas" height="24"></canvas>
</div>
<div id="wave-area">
<div id="wave-lanes"></div>
<div id="audio-settings-panel" style="display:none;flex-wrap:wrap;gap:10px;padding:6px 10px;background:rgba(255,255,255,.03);border-top:1px solid rgba(255,255,255,.07);align-items:center;font-size:11px;color:#c0bede;">
<span style="font-size:10px;color:#5a5878;letter-spacing:.04em;">입력 장치</span>
<select id="audio-input-sel" style="font-size:10px;background:#1f2032;color:#d8daf0;border:1px solid rgba(255,255,255,.14);border-radius:4px;padding:2px 4px;max-width:180px" onchange="meAudioSettings.deviceId=this.value"></select>
<button class="wave-add-btn" style="padding:2px 7px" onclick="meEnumerateDevices()">↻</button>
<div class="tb-sep" style="height:16px;align-self:center"></div>
<span style="font-size:10px;color:#5a5878;">레이턴시 보정</span>
<input type="number" id="audio-latency-ms" value="0" min="-500" max="500" step="5" style="width:54px;font-size:10px;background:#1f2032;color:#d8daf0;border:1px solid rgba(255,255,255,.14);border-radius:4px;padding:2px 4px;text-align:right" oninput="meAudioSettings.latencyMs=+this.value"> <span style="font-size:10px;color:#5a5878;">ms</span>
<div class="tb-sep" style="height:16px;align-self:center"></div>
<label style="display:inline-flex;align-items:center;gap:3px;font-size:10px;cursor:pointer"><input type="checkbox" id="au-echo" onchange="meAudioSettings.echoCancellation=this.checked"> 에코제거</label>
<label style="display:inline-flex;align-items:center;gap:3px;font-size:10px;cursor:pointer"><input type="checkbox" id="au-noise" onchange="meAudioSettings.noiseSuppression=this.checked"> 노이즈제거</label>
<label style="display:inline-flex;align-items:center;gap:3px;font-size:10px;cursor:pointer"><input type="checkbox" id="au-agc" onchange="meAudioSettings.autoGainControl=this.checked"> 자동게인</label>
<div class="tb-sep" style="height:16px;align-self:center"></div>
<span style="font-size:10px;color:#5a5878;">측정 레이턴시</span>
<span id="audio-latency-info" style="font-size:10px;color:#a78bfa;">—</span>
<button class="wave-add-btn" style="padding:2px 7px" onclick="meMeasureLatency()">측정</button>
</div>
<div id="wave-add-bar">
<button class="wave-add-btn" onclick="document.getElementById('audio-file-in').click()">+ 트랙</button>
<button class="wave-add-btn" onclick="meAddRecordTrack()">+ 녹음</button>
<button class="wave-add-btn" onclick="meToggleAudioSettings()" title="오디오 입력 설정">⚙</button>
</div>
</div>
<div id="roll-row">
<canvas id="keys-canvas" width="56"></canvas>
<canvas id="roll-canvas"></canvas>
</div>
<div id="vel-row">
<div id="vel-label">vel</div>
<canvas id="vel-canvas" height="70"></canvas>
</div>
</div>
</div>
<div id="master-channel">
<span class="mc-label">MASTER</span>
<div class="mc-sep"></div>
<span class="mc-lbl">볼륨</span>
<input class="mc-sl" type="range" min="0" max="200" value="100" oninput="setMasterVol(this.value);this.nextElementSibling.textContent=this.value+'%'">
<span class="mc-val">100%</span>
<div class="mc-sep"></div>
<label class="mc-lbl"><input type="checkbox" onchange="setFxOn('reverb',this.checked)"> 리버브</label>
<input class="mc-sl" type="range" min="0" max="100" value="30" oninput="setFxMix('reverb',this.value);this.nextElementSibling.textContent=this.value">
<span class="mc-val">30</span>
<div class="mc-sep"></div>
<label class="mc-lbl"><input type="checkbox" onchange="setFxOn('chorus',this.checked)"> 코러스</label>
<input class="mc-sl" type="range" min="0" max="100" value="30" oninput="setFxMix('chorus',this.value);this.nextElementSibling.textContent=this.value">
<span class="mc-val">30</span>
<div class="mc-sep"></div>
<label class="mc-lbl"><input type="checkbox" onchange="setFxOn('delay',this.checked)"> 딜레이</label>
<span class="mc-lbl" style="color:#5a5878">믹스</span>
<input class="mc-sl" type="range" min="0" max="100" value="25" oninput="setFxMix('delay',this.value);this.nextElementSibling.textContent=this.value">
<span class="mc-val">25</span>
<span class="mc-lbl" style="color:#5a5878">시간</span>
<input class="mc-sl" type="range" min="50" max="800" step="10" value="300" oninput="setFxTime(this.value);this.nextElementSibling.textContent=this.value+'ms'">
<span class="mc-val">300ms</span>
<div class="mc-sep"></div>
<label class="mc-lbl"><input type="checkbox" onchange="setFxOn('comp',this.checked)"> 컴프</label>
<input class="mc-sl" type="range" min="0" max="100" value="40" oninput="setFxComp(this.value);this.nextElementSibling.textContent=this.value">
<span class="mc-val">40</span>
</div>
<div id="transport">
<button class="tr-btn" onclick="doRewind()">⏮</button>
<button class="tr-btn" id="play-btn" onclick="doTogglePlay()">▶</button>
<button class="tr-btn" onclick="doStop()">■</button>
<span id="pos-disp">1:1:000</span>
<span class="tb-lbl">루프</span>
<input type="checkbox" id="loop-en">
<span class="tb-lbl">In</span><input class="tb-num" id="loop-in" type="number" value="1" min="1" style="width:42px">
<span class="tb-lbl">Out</span><input class="tb-num" id="loop-out" type="number" value="5" min="1" style="width:42px">
<span style="flex:1"></span>
<span id="status-txt"></span>
</div>
</div>
<input type="file" id="file-in" accept=".mid,.midi" style="display:none" onchange="handleOpen(this)">
<input type="file" id="audio-file-in" accept="audio/*,.wav,.mp3,.flac,.ogg,.aac" style="display:none" onchange="meLoadAudio(this.files[0]);this.value=''">
</div>
<!-- Guitar Pro viewer -->
<div id="stage-gp">
<div class="gp-bar">
<button class="gp-ctrl-btn" onclick="closeGpViewer()">← 스테이지로</button>
<span class="gp-bar-title" id="gp-title">Guitar Pro</span>
<button class="gp-ctrl-btn" id="gp-play-btn" onclick="gpTogglePlay()">▶ 재생</button>
<button class="gp-ctrl-btn" onclick="gpStop()">■ 정지</button>
</div>
<div class="gp-fx-bar">
<span class="gp-fx-label">📤 MIDI 출력</span>
<select id="gp-midi-out" class="gp-ctrl-btn" style="padding:2px 6px;font-size:11px;" onfocus="initMidiOut()" onchange="selectMidiOut(this.value)">
<option value="">-- 내장 SF2 --</option>
</select>
<span class="gp-fx-label" style="margin-left:12px;">♩=</span>
<input type="number" id="gp-tempo" min="20" max="400" step="1"
onchange="setGpTempo(this.value)" onkeydown="if(event.key==='Enter')setGpTempo(this.value)" title="템포 (BPM)">
<span style="font-size:10px;color:var(--muted);">BPM</span>
<span class="gp-fx-label" style="margin-left:12px;">🔊 마스터</span>
<input type="range" id="gp-master-vol" min="0" max="200" value="100" onchange="setGpMasterVol(this.value)" title="마스터 볼륨 (최대 200%)" style="width:80px;vertical-align:middle;">
<span id="gp-master-vol-val" style="font-size:10px;color:var(--muted);min-width:34px;">100%</span>
<button class="gp-ctrl-btn" style="padding:2px 8px;font-size:11px;margin-left:8px;" onclick="toggleGpFxPanel()" title="마스터 효과 (리버브·코러스·딜레이·컴프레션)">🎛 효과</button>
</div>
<div id="gp-fx-panel" style="display:none;flex-wrap:wrap;gap:14px;padding:8px 12px;border-bottom:1px solid var(--line);background:var(--panel);align-items:center;font-size:11px;">
<span class="gp-fx-label">마스터 효과 — GP·드럼·루퍼 모든 소리에 적용</span>
<label class="gp-fx-e"><input type="checkbox" onchange="setFxOn('reverb',this.checked)"> 리버브</label>
<input type="range" class="gp-vol" min="0" max="100" value="30" oninput="setFxMix('reverb',this.value);this.nextElementSibling.textContent=this.value"><span class="gp-fx-val">30</span>
<label class="gp-fx-e"><input type="checkbox" onchange="setFxOn('chorus',this.checked)"> 코러스</label>
<input type="range" class="gp-vol" min="0" max="100" value="30" oninput="setFxMix('chorus',this.value);this.nextElementSibling.textContent=this.value"><span class="gp-fx-val">30</span>
<label class="gp-fx-e"><input type="checkbox" onchange="setFxOn('delay',this.checked)"> 딜레이</label>
<span class="gp-fx-label">믹스</span>
<input type="range" class="gp-vol" min="0" max="100" value="25" oninput="setFxMix('delay',this.value);this.nextElementSibling.textContent=this.value"><span class="gp-fx-val">25</span>
<span class="gp-fx-label">시간ms</span>
<input type="range" class="gp-vol" min="50" max="800" step="10" value="300" oninput="setFxTime(this.value);this.nextElementSibling.textContent=this.value"><span class="gp-fx-val">300</span>
<label class="gp-fx-e"><input type="checkbox" onchange="setFxOn('comp',this.checked)"> 컴프레션</label>
<input type="range" class="gp-vol" min="0" max="100" value="40" oninput="setFxComp(this.value);this.nextElementSibling.textContent=this.value"><span class="gp-fx-val">40</span>
</div>
<div class="gp-track-list" id="gp-track-list"></div>
<div class="gp-viewport" id="gp-viewport">
<div class="gp-loading" id="gp-loading">Guitar Pro 파일을 로딩 중...</div>
<div id="gp-main"></div>
</div>
</div>
<!-- Drum box -->
<div id="stage-drums">
<div class="gp-bar">
<button class="drum-back-btn" onclick="closeDrums()">← 스테이지로</button>
<span class="gp-bar-title">🥁 드럼 박스</span>
<span id="drum-sample-status" class="gp-fx-label" style="opacity:.7">샘플 로딩 전</span>
<button class="gp-ctrl-btn" id="drum-play-btn" onclick="drumTogglePlay()" style="background:rgba(34,197,94,.15);border-color:#4ade80;color:#86efac;">▶ 재생</button>
<button class="gp-ctrl-btn" onclick="drumStop()" style="background:rgba(239,68,68,.15);border-color:#f87171;color:#fca5a5;">■ 정지</button>
<span class="gp-fx-label" style="margin-left:8px;">♩=</span>
<input type="number" id="drum-bpm" min="40" max="300" step="1" value="120"
onchange="drumSetBpm(this.value)" onkeydown="if(event.key==='Enter')drumSetBpm(this.value)" title="BPM" style="background:transparent;border:none;border-bottom:1px solid var(--studio-border2);color:var(--studio-text);font-size:13px;font-family:inherit;padding:1px 2px;text-align:center;outline:none;width:44px;">
<span style="font-size:10px;color:var(--studio-muted);">BPM</span>
<span class="gp-fx-label" style="margin-left:8px;">🔊</span>
<input type="range" id="drum-vol" min="0" max="100" value="80" onchange="drumSetVol(this.value)" style="width:80px;vertical-align:middle;">
<div style="flex:1"></div>
<select id="drum-steps" class="gp-ctrl-btn" style="padding:2px 6px;font-size:11px;" onchange="drumSetSteps(this.value)">
<option value="16">16 스텝</option>
<option value="32">32 스텝</option>
</select>
<button class="gp-ctrl-btn" onclick="drumClear()">지움</button>
<button class="gp-ctrl-btn" onclick="drumExportMidi()" style="border-color:#22c55e;color:#86efac;">⬇ MIDI</button>
</div>
<div class="gp-fx-bar" style="flex-wrap:wrap;gap:8px;">
<span class="gp-fx-label">리듬</span>
<select id="drum-rhythm" class="gp-ctrl-btn" style="padding:2px 6px;font-size:11px;" onchange="drumLoadRhythm(this.value)"></select>
<span class="gp-fx-label" style="margin-left:6px;">스윙</span>
<input type="range" id="drum-swing" min="0" max="70" value="0" onchange="drumSetSwing(this.value)" style="width:90px;vertical-align:middle;">
<span id="drum-swing-val" class="gp-fx-label" style="min-width:64px;">0% (8분)</span>
<div style="flex:1"></div>
<span class="gp-fx-label">저장된 패턴</span>
<select id="drum-saved" class="gp-ctrl-btn" style="padding:2px 6px;font-size:11px;min-width:120px;"><option value="">— 없음 —</option></select>
<button class="gp-ctrl-btn" onclick="drumLoadSavedSel()">불러오기</button>
<button class="gp-ctrl-btn" onclick="drumSavePattern()" style="border-color:#22c55e;color:#86efac;">💾 저장</button>
<button class="gp-ctrl-btn" onclick="drumDeleteSavedSel()" style="border-color:#ef4444;color:#fca5a5;">삭제</button>
</div>
<div id="drum-grid" class="drum-grid"></div>
<!-- Looper built into drum box (beat-synced) -->
<div class="drum-loop-panel">
<div class="loop-card">
<div class="loop-card-hdr">
<span class="loop-card-title">🎤 루퍼</span>
<span id="loop-status" class="loop-card-status">레이어 0 · 대기</span>
</div>
<div class="loop-meter-bar"><div id="loop-meter-fill" class="loop-meter-fill"></div></div>
<div class="loop-transport">
<button class="loop-btn rec" id="loop-rec-btn" onclick="loopToggleRecord()">
<span class="lb-icon">⏺</span>녹음
</button>
<button class="loop-btn" id="loop-play-btn" onclick="loopTogglePlay()">
<span class="lb-icon">▶</span>재생
</button>
<button class="loop-btn" onclick="loopStop()">
<span class="lb-icon">⏹</span>정지
</button>
</div>
<div class="loop-sec">
<span style="font-size:11px;color:var(--muted);">길이</span>
<select id="loop-bars" onchange="loopSetBars(this.value)">
<option value="1">1 마디</option>
<option value="2">2 마디</option>
<option value="4">4 마디</option>
<option value="8">8 마디</option>
</select>
<button onclick="loopUndo()">↶ 취소</button>
<button class="loop-del-all" onclick="loopClear()">🗑 전체 삭제</button>
</div>
<div id="loop-layers" class="loop-layers">
<div class="loop-empty">⏺ 녹음을 누르면 드럼 비트에 맞춰 녹음 시작<br>(1마디 카운트인 후 시작 · 재생 중 누르면 오버덥)</div>
</div>
</div>
</div>
</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; }
const d = await r.json();
if (!d.authenticated) { 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_music_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 APP_KEY = 'music';
let skillContext = '';
let abortCtrl = null;
const _renderedMidiIds = new Set();
let _sessionId = null;
function getSessionId() { return _sessionId || 'music_main'; }
async function initSessionId() {
try { const r = await fetch('/api/user/app-sessions', {headers: authH()}); if (r.ok) { const d = await r.json(); if (d[APP_KEY]) { _sessionId = d[APP_KEY]; return; } } } catch {}
_sessionId = 'music_' + Math.random().toString(36).slice(2,12);
try { await fetch('/api/user/app-sessions', {method:'PUT', headers: authH({'Content-Type':'application/json'}), body: JSON.stringify({appKey: APP_KEY, sessionId: _sessionId})}); } catch {}
}
async function loadHistory(){
const sid=getSessionId();
if(!sid||sid.endsWith('_main'))return;
try{
const r=await fetch('/api/chat/sessions/'+encodeURIComponent(sid),{headers:authH()});
if(!r.ok)return;
const d=await r.json();
const msgs=(d.history||[]).filter(m=>m.role==='user'||m.role==='assistant');
if(!msgs.length)return;
const recent=msgs.slice(-30);
const el=document.getElementById('chat-msgs');
const sep=document.createElement('div');sep.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep.textContent='─ 이전 대화 ─';el.appendChild(sep);
for(const m of recent){
if(m.role==='user')addChatMsg('user',escHtml(m.content||''));
else addChatMsg('assistant',renderMd(m.content||''));
}
const sep2=document.createElement('div');sep2.style.cssText='text-align:center;color:var(--muted,#888);font-size:11px;padding:6px 0;';sep2.textContent='─ 이어서 ─';el.appendChild(sep2);
el.scrollTop=el.scrollHeight;
}catch{}
}
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) {
// Stop any active playback
if (typeof playing !== 'undefined' && playing) doStop();
if (_midiPlaying) { _midiPlaying = false; try { _spessaSeq?.pause(); } catch {} }
// Hide other stages
document.getElementById('stage-player').style.display = 'none';
document.getElementById('stage-gp').style.display = 'none';
const ed = document.getElementById('stage-editor');
ed.classList.add('visible');
document.getElementById('editor-title').textContent = '🎹 ' + (filename || 'MIDI 편집기');
// Resize canvases after layout (they were hidden before)
requestAnimationFrame(() => { resize(); pushH(); updPos(); });
if (midiUrl) meLoadUrl(midiUrl);
}
function closeEditor() {
if (typeof playing !== 'undefined' && playing) doStop();
document.getElementById('stage-editor').classList.remove('visible');
const sp = document.getElementById('stage-player');
sp.style.display = 'flex'; sp.style.flexDirection = 'column';
sp.style.flex = '1'; sp.style.minHeight = '0'; sp.style.overflow = 'hidden';
}
// ── SpessaSynth SF2 MIDI 플레이어 ────────────────────────────────────────────
let _spessaCtx = null, _spessaSynth = null, _spessaSeq = null, _spessaSfBuf = null;
let _spessaLoading = false;
function midiSfGpStatus(msg) {
const el = document.getElementById('gp-loading');
if (el) { el.style.display = 'block'; el.textContent = msg; }
}
function _setMidiBtn(playing) {
const btn = document.getElementById('gp-play-btn');
const loading = document.getElementById('gp-loading');
if (playing) {
if (btn) btn.textContent = '⏸ 일시정지';
if (loading) loading.style.display = 'none';
} else {
if (btn) btn.textContent = '▶ 재생';
}
}
async function _ensureSpessa() {
if (_spessaSynth) return;
if (_spessaLoading) { await new Promise(r => setTimeout(r, 200)); return _ensureSpessa(); }
_spessaLoading = true;
try {
if (!window.SpessaSynth) {
await new Promise((res, rej) => {
const s = document.createElement('script');
s.src = '/spessasynth.js';
s.onload = res; s.onerror = () => rej(new Error('SpessaSynth 로드 실패'));
document.head.appendChild(s);
});
}
const AC = window.AudioContext || window.webkitAudioContext;
if (!_spessaCtx) _spessaCtx = new AC({ latencyHint: 'playback' });
if (_spessaCtx.state === 'suspended') await _spessaCtx.resume();
if (!_spessaSfBuf) {
midiSfGpStatus('사운드폰트 로딩 중...');
const r = await fetch('/GeneralUser-GS.sf2');
if (!r.ok) throw new Error('SF2 로드 실패: ' + r.status);
_spessaSfBuf = await r.arrayBuffer();
}
await _spessaCtx.audioWorklet.addModule('/spessasynth_processor.min.js');
const { WorkletSynthesizer, Sequencer } = window.SpessaSynth;
_spessaSynth = new WorkletSynthesizer(_spessaCtx);
_spessaSynth.connect(_spessaCtx.destination); // AudioWorkletNode → output (the global connect-patch redirects this through the master FX chain)
await _spessaSynth.soundBankManager.addSoundBank(_spessaSfBuf.slice(0), 'gs');
await _spessaSynth.isReady;
_spessaSeq = new Sequencer(_spessaSynth);
} finally {
_spessaLoading = false;
}
}
// ── Guitar Pro (AlphaTab) ─────────────────────────────────────────────────────
let gpApi = null, gpStageMode = 'gp';
const gpFiles = [];
function showStageGp() {
document.getElementById('stage-player').style.display = 'none';
document.getElementById('stage-editor').classList.remove('visible');
document.getElementById('stage-gp').style.display = 'flex';
document.getElementById('stage-gp').style.flexDirection = 'column';
document.getElementById('stage-gp').style.flex = '1';
// NOTE: MIDI access is NOT auto-initialized here. requestMIDIAccess() can
// crash the renderer via the OmniMIDI driver. It is only triggered when the
// user explicitly focuses the MIDI output dropdown (opt-in).
}
function closeGpViewer() {
if (gpStageMode === 'midi' && _spessaSeq) { try{_spessaSeq.pause();}catch{} _midiPlaying = false; }
gpStageMode = 'gp';
document.getElementById('stage-gp').style.display = 'none';
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';
}
function openGpFile(file) {
if (!file) return;
if (!window.alphaTab) { addChatMsg('system', '⚠️ AlphaTab 라이브러리 로딩 중... 잠시 후 다시 시도하세요.'); return; }
const reader = new FileReader();
reader.onload = (e) => {
const name = file.name;
if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf: e.target.result });
loadGpBuffer(e.target.result, name);
saveGpToServer(e.target.result.slice(0), name);
};
reader.readAsArrayBuffer(file);
}
async function saveGpToServer(buf, name) {
try {
await fetch('/api/music/gp', {
method: 'POST',
headers: { ...authH(), 'x-filename': encodeURIComponent(name), 'Content-Type': 'application/octet-stream' },
body: buf
});
loadGpFileList();
} catch(e) { console.warn('GP 저장 실패:', e.message); }
}
async function loadGpFileList() {
const el = document.getElementById('gp-file-list');
try {
const r = await fetch('/api/music/gp/list', { headers: authH() });
const data = await r.json();
if (!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-file-item" style="display:flex;align-items:center;padding:3px 6px;gap:2px;">
<div onclick="loadGpFromServer('${escAttr(f.path)}','${escAttr(f.name)}')"
style="flex:1;cursor:pointer;font-size:11px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)" title="${escHtml(f.name)}">
🎸 ${escHtml(f.name)}
</div>
<span onclick="deleteGpFile('${escAttr(f.name)}')" style="cursor:pointer;color:var(--muted);font-size:12px;padding:0 2px;flex-shrink:0" title="삭제">×</span>
</div>`).join('');
} catch { el.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 6px">로딩 실패</div>'; }
}
async function loadGpFromServer(path, name) {
try {
const r = await fetch(path, { headers: authH() });
const buf = await r.arrayBuffer();
if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf });
loadGpBuffer(buf, name);
} catch(e) { addChatMsg('system', '❌ GP 로드 실패: ' + escHtml(e.message)); }
}
async function deleteGpFile(name) {
if (!confirm(`"${name}" 삭제할까요?`)) return;
await fetch(`/api/music/gp-file/${encodeURIComponent(name)}`, { method: 'DELETE', headers: authH() });
loadGpFileList();
}
function renderGpFileList() {
loadGpFileList();
}
let gpSite = 'guitarprotabs';
function gpSetSite(site) {
gpSite = site;
document.getElementById('gp-site-gptabs').style.background = site === 'guitarprotabs' ? '#a855f7' : 'var(--panel)';
document.getElementById('gp-site-gptabs').style.color = site === 'guitarprotabs' ? '#fff' : 'var(--muted)';
document.getElementById('gp-site-gptabs').style.borderColor = site === 'guitarprotabs' ? '#a855f7' : 'var(--line)';
document.getElementById('gp-site-gprotab').style.background = site === 'gprotab' ? '#a855f7' : 'var(--panel)';
document.getElementById('gp-site-gprotab').style.color = site === 'gprotab' ? '#fff' : 'var(--muted)';
document.getElementById('gp-site-gprotab').style.borderColor = site === 'gprotab' ? '#a855f7' : 'var(--line)';
document.getElementById('gp-search-input').placeholder = site === 'gprotab' ? '곡명/아티스트 검색...' : '아티스트 검색...';
document.getElementById('gp-search-results').innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 8px">아티스트 또는 곡명으로 검색</div>';
}
async function gpSearch() {
const q = document.getElementById('gp-search-input').value.trim();
if (!q) return;
const el = document.getElementById('gp-search-results');
el.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:6px 8px">검색 중...</div>';
try {
const r = await fetch(`/api/gp-search?q=${encodeURIComponent(q)}&site=${gpSite}`, { headers: authH() });
const data = await r.json();
if (data.error && !data.results?.length) {
el.innerHTML = `<div style="font-size:11px;color:var(--muted);padding:6px 8px">아티스트를 찾을 수 없습니다</div>`; return;
}
if (!data.results?.length) {
el.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:6px 8px">결과 없음</div>'; return;
}
el.innerHTML = data.results.map(r => `
<div class="ms-file-item" onclick="gpDownloadAndOpen('${escAttr(r.url)}','${escAttr(r.title)}')"
style="cursor:pointer;padding:4px 8px;border-bottom:1px solid var(--line);" title="${escHtml(r.title)}">
<div style="font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">🎸 ${escHtml(r.title)}</div>
<div style="font-size:10px;color:var(--muted);">${escHtml(r.ext)} · ↓${escHtml(r.downloads)}</div>
</div>`).join('');
} catch(e) {
el.innerHTML = `<div style="font-size:11px;color:var(--muted);padding:6px 8px">오류: ${escHtml(e.message)}</div>`;
}
}
async function gpDownloadAndOpen(tabUrl, title) {
const el = document.getElementById('gp-search-results');
// highlight selected
el.querySelectorAll('.ms-file-item').forEach(d => d.style.background = '');
event?.currentTarget?.style && (event.currentTarget.style.background = 'var(--brand-dim, rgba(168,85,247,.15))');
addChatMsg('system', `🎸 ${escHtml(title)} 다운로드 중...`);
try {
const r = await fetch(`/api/gp-download?url=${encodeURIComponent(tabUrl)}`, { headers: authH() });
if (!r.ok) throw new Error(await r.text());
const buf = await r.arrayBuffer();
const name = title + '.gp';
if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf });
loadGpBuffer(buf, name);
saveGpToServer(buf.slice(0), name);
addChatMsg('system', `✅ ${escHtml(title)} 열림 · 저장됨`);
} catch(e) {
addChatMsg('system', `❌ 다운로드 실패: ${escHtml(e.message)}`);
}
}
// ── MIDI download site (midifind / bitmidi) ──────────────────────────────────
let mdlSite = 'midifind';
function mdlSetSite(site) {
mdlSite = site;
const a = document.getElementById('mdl-site-midifind');
const b = document.getElementById('mdl-site-bitmidi');
const apply = (el, on) => {
el.style.background = on ? '#a855f7' : 'var(--panel)';
el.style.color = on ? '#fff' : 'var(--muted)';
el.style.borderColor = on ? '#a855f7' : 'var(--line)';
};
apply(a, site === 'midifind');
apply(b, site === 'bitmidi');
document.getElementById('mdl-search-input').placeholder = site === 'bitmidi' ? '곡명 검색...' : '곡명/아티스트 검색...';
document.getElementById('mdl-search-results').innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 8px">곡명 또는 아티스트로 검색</div>';
}
async function mdlSearch() {
const q = document.getElementById('mdl-search-input').value.trim();
if (!q) return;
const el = document.getElementById('mdl-search-results');
el.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:6px 8px">검색 중...</div>';
try {
const r = await fetch(`/api/midi-search?q=${encodeURIComponent(q)}&site=${mdlSite}`, { headers: authH() });
const data = await r.json();
if (data.error && !data.results?.length) {
el.innerHTML = `<div style="font-size:11px;color:var(--muted);padding:6px 8px">${escHtml(data.error)}</div>`; return;
}
if (!data.results?.length) {
el.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:6px 8px">결과 없음</div>'; return;
}
el.innerHTML = data.results.map(r => `
<div class="ms-file-item" onclick="mdlDownload('${escAttr(r.url)}','${escAttr(r.title)}')"
style="cursor:pointer;padding:4px 8px;border-bottom:1px solid var(--line);" title="${escHtml(r.title)}">
<div style="font-size:11px;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">🎵 ${escHtml(r.title)}</div>
</div>`).join('');
} catch(e) {
el.innerHTML = `<div style="font-size:11px;color:var(--muted);padding:6px 8px">오류: ${escHtml(e.message)}</div>`;
}
}
async function mdlDownload(siteUrl, title) {
const el = document.getElementById('mdl-search-results');
el.querySelectorAll('.ms-file-item').forEach(d => d.style.background = '');
event?.currentTarget?.style && (event.currentTarget.style.background = 'var(--brand-dim, rgba(168,85,247,.15))');
const safeName = title.replace(/[/\\:*?"<>|]/g, '_').slice(0, 80);
addChatMsg('system', `🎵 ${escHtml(title)} 다운로드 중...`);
try {
const r = await fetch(`/api/midi-download?url=${encodeURIComponent(siteUrl)}&site=${mdlSite}&name=${encodeURIComponent(safeName + '.mid')}`, { headers: authH() });
if (!r.ok) throw new Error(await r.text());
const blob = await r.blob();
const filename = safeName + '.mid';
const file = new File([blob], filename, { type: 'audio/midi' });
const form = new FormData(); form.append('file', file);
const up = await fetch('/api/music/midi', { method:'POST', headers: authH(), body: form });
const data = await up.json();
if (!up.ok) throw new Error(data.error || '등록 실패');
const { html } = buildMidiPickerWidget(data, filename);
addToStage(html, filename);
addChatMsg('system', `✅ ${escHtml(title)} 다운로드 완료 · 스테이지에 추가`);
loadMidiFiles();
} catch(e) {
addChatMsg('system', `❌ 다운로드 실패: ${escHtml(e.message)}`);
}
}
// ── GP 오디오 효과 체인 ──────────────────────────────────────
let gpFx = null;
let gpMidiOut = null;
function patchAudioForGpFx() {
// Use large playback buffer to reduce audio underruns
const _OrigAC = window.AudioContext || window.webkitAudioContext;
if (!_OrigAC._patched) {
// Patch AudioNode.connect once (global) so any connect(ctx.destination)
// is redirected into our master FX chain input — applies reverb/chorus/
// delay/compression to every audio source (AlphaTab SF2, drums, looper).
if (!_origConnect) {
_origConnect = AudioNode.prototype.connect;
AudioNode.prototype.connect = function(dest, ...rest) {
const r = _destRedirect.get(dest);
if (r) return _origConnect.call(this, r, ...rest);
return _origConnect.call(this, dest, ...rest);
};
}
window.AudioContext = window.webkitAudioContext = function(opts) {
const ctx = new _OrigAC({ latencyHint: 'playback', ...opts });
try { buildAudioFxChain(ctx); } catch(e) { console.warn('fx chain', e); }
return ctx;
};
window.AudioContext.prototype = _OrigAC.prototype;
window.AudioContext._patched = true;
}
gpFx = {};
}
// ── Global master FX chain (reverb · chorus · delay · compression) ───────────
let gpFxChains = [];
const _destRedirect = new WeakMap();
let _origConnect = null;
let gpFxCtrl = {
reverb: { on: false, mix: 30 },
chorus: { on: false, mix: 30 },
delay: { on: false, mix: 25, time: 300 },
comp: { on: false, amt: 0 },
};
function makeImpulse(ctx, dur, decay) {
const rate = ctx.sampleRate, len = Math.max(1, Math.floor(rate * dur));
const buf = ctx.createBuffer(2, len, rate);
for (let c = 0; c < 2; c++) {
const d = buf.getChannelData(c);
for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay);
}
return buf;
}
function buildAudioFxChain(ctx) {
const input = ctx.createGain(); input.gain.value = 1;
const dry = ctx.createGain(); dry.gain.value = 1;
const mixer = ctx.createGain(); mixer.gain.value = 1;
// Reverb — convolver with synthetic impulse response
const revSend = ctx.createGain(); revSend.gain.value = 0;
const conv = ctx.createConvolver(); conv.buffer = makeImpulse(ctx, 2.2, 2.5);
const revWet = ctx.createGain(); revWet.gain.value = 1;
input.connect(revSend); revSend.connect(conv); conv.connect(revWet); revWet.connect(mixer);
// Chorus — short modulated delay (LFO on delayTime)
const choSend = ctx.createGain(); choSend.gain.value = 0;
const choDelay = ctx.createDelay(0.1); choDelay.delayTime.value = 0.025;
const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 1.2;
const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.003;
lfo.connect(lfoGain); lfoGain.connect(choDelay.delayTime); lfo.start();
const choWet = ctx.createGain(); choWet.gain.value = 1;
input.connect(choSend); choSend.connect(choDelay); choDelay.connect(choWet); choWet.connect(mixer);
// Delay — feedback echo
const dlySend = ctx.createGain(); dlySend.gain.value = 0;
const dlyDelay = ctx.createDelay(2.0); dlyDelay.delayTime.value = 0.3;
const fb = ctx.createGain(); fb.gain.value = 0;
const dlyWet = ctx.createGain(); dlyWet.gain.value = 1;
input.connect(dlySend); dlySend.connect(dlyDelay);
dlyDelay.connect(fb); fb.connect(dlyDelay);
dlyDelay.connect(dlyWet); dlyWet.connect(mixer);
// Dry path
input.connect(dry); dry.connect(mixer);
// Compressor — master bus, transparent when off (ratio 1, threshold 0)
const comp = ctx.createDynamicsCompressor();
comp.threshold.value = 0; comp.ratio.value = 1; comp.knee.value = 6;
comp.attack.value = 0.003; comp.release.value = 0.25;
const masterGain = ctx.createGain(); masterGain.gain.value = 1;
mixer.connect(comp);
_origConnect.call(comp, masterGain);
_origConnect.call(masterGain, ctx.destination); // bypass redirect → real output
// NOW redirect: every future connect(ctx.destination) feeds `input`
_destRedirect.set(ctx.destination, input);
const chain = { ctx, input, revSend, choSend, dlySend, dlyDelay, fb, comp, masterGain };
gpFxChains.push(chain);
applyFxCtrl(chain);
}
function applyFxCtrl(ch) {
const c = gpFxCtrl;
ch.revSend.gain.value = c.reverb.on ? (c.reverb.mix / 100) * 0.6 : 0;
ch.choSend.gain.value = c.chorus.on ? (c.chorus.mix / 100) * 0.7 : 0;
ch.dlySend.gain.value = c.delay.on ? (c.delay.mix / 100) * 0.5 : 0;
ch.dlyDelay.delayTime.value = Math.max(0.02, (c.delay.time || 300) / 1000);
ch.fb.gain.value = c.delay.on ? 0.38 : 0;
const a = c.comp.on ? c.comp.amt / 100 : 0;
ch.comp.threshold.value = -50 * a; // 0 → 0dB (transparent), 1 → -50dB
ch.comp.ratio.value = 1 + 11 * a; // 1 → 12:1
}
function applyFxCtrlAll() { gpFxChains.forEach(applyFxCtrl); }
function setMasterVol(v) { const g=v/100; gpFxChains.forEach(ch=>{if(ch.masterGain)ch.masterGain.gain.value=g;}); }
function setFxOn(key, on) { gpFxCtrl[key].on = on; applyFxCtrlAll(); }
function setFxMix(key, v) { gpFxCtrl[key].mix = parseInt(v, 10) || 0; applyFxCtrlAll(); }
function setFxTime(v) { gpFxCtrl.delay.time = parseInt(v, 10) || 0; applyFxCtrlAll(); }
function setFxComp(v) { gpFxCtrl.comp.amt = parseInt(v, 10) || 0; applyFxCtrlAll(); }
function toggleGpFxPanel() {
const p = document.getElementById('gp-fx-panel');
if (p) p.style.display = p.style.display === 'none' ? 'flex' : 'none';
}
// ── MIDI 외부 출력 ────────────────────────────────────────────
let _midiAccess = null;
let _midiInitPending = false;
async function initMidiOut() {
const sel = document.getElementById('gp-midi-out');
if (!navigator.requestMIDIAccess) {
sel.innerHTML = '<option value="">MIDI 미지원</option>';
return;
}
// Already initialized — just refresh the port list
if (_midiAccess) { refreshMidiPorts(); return; }
if (_midiInitPending) return;
_midiInitPending = true;
try {
// Wrap in a race with timeout — some MIDI drivers hang or crash on access.
// Note: a native driver crash (e.g. OmniMIDI) kills the renderer regardless
// of this timeout; this only guards against a JS-level hang.
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 3000));
_midiAccess = await Promise.race([
navigator.requestMIDIAccess({ sysex: false }),
timeout
]);
refreshMidiPorts();
_midiAccess.onstatechange = refreshMidiPorts;
} catch(e) {
sel.innerHTML = `<option value="">${e.message === 'timeout' ? 'MIDI 타임아웃' : 'MIDI 거부됨'}</option>`;
} finally {
_midiInitPending = false;
}
}
function refreshMidiPorts() {
const sel = document.getElementById('gp-midi-out');
const cur = sel.value;
const ports = [['', '-- 내장 SF2 --']];
_midiAccess?.outputs?.forEach(p => ports.push([p.id, p.name]));
sel.innerHTML = ports.map(([id, name]) =>
`<option value="${escAttr(id)}"${id===cur?' selected':''}>${escHtml(name)}</option>`
).join('');
}
function setGpTempo(val) {
const bpm = Math.max(20, Math.min(400, parseInt(val) || 0));
if (!bpm) return;
document.getElementById('gp-tempo').value = bpm;
const orig = gpApi?.score?.tempo;
if (gpApi && orig) gpApi.playbackSpeed = bpm / orig;
}
function selectMidiOut(portId) {
gpMidiOut = portId ? _midiAccess?.outputs?.get(portId) || null : null;
if (gpApi) gpApi.masterVolume = gpMidiOut ? 0 : 1;
const mv = document.getElementById('gp-master-vol');
if (mv) { mv.value = gpMidiOut ? 0 : 100; const l = document.getElementById('gp-master-vol-val'); if (l) l.textContent = (gpMidiOut ? 0 : 100) + '%'; }
if (gpMidiOut) {
addChatMsg('system', `🎹 MIDI 포트 "${escHtml(gpMidiOut.name)}" 선택됨 — SF2 음소거`);
sendMidiProgramChanges();
}
}
// Send GM program changes for all tracks using score data
function sendMidiProgramChanges() {
if (!gpMidiOut || !gpApi?.score?.tracks) return;
for (const track of gpApi.score.tracks) {
const info = track.playbackInfo;
if (!info) continue;
const pch = (info.primaryChannel ?? 0) & 0x0F;
const sch = (info.secondaryChannel ?? pch) & 0x0F;
const prog = (info.program ?? 0) & 0x7F;
// MIDI spec: Bank Select must come BEFORE Program Change
const vol = Math.min(127, Math.round((info.volume ?? 13) / 16 * 127));
const pan = Math.min(127, Math.round((info.balance ?? 8) / 16 * 127));
for (const ch of [...new Set([pch, sch])]) {
gpMidiOut.send([0xB0 | ch, 0, 0]); // Bank Select MSB
gpMidiOut.send([0xB0 | ch, 32, 0]); // Bank Select LSB
gpMidiOut.send([0xC0 | ch, prog]); // Program Change
gpMidiOut.send([0xB0 | ch, 7, vol]); // CC7 Volume
gpMidiOut.send([0xB0 | ch, 10, pan]); // CC10 Pan
}
}
}
function setupMidiForwarding() {
if (!gpApi) return;
// AlphaTab internal enum: NoteOn=0x80, NoteOff=0x90 (swapped vs MIDI standard)
// Filter uses these internal values
try { gpApi.midiEventsPlayedFilter = [0x80, 0x90, 0xB0, 0xC0, 0xE0]; } catch(e) {}
gpApi.midiEventsPlayed.on(args => {
if (!gpMidiOut) return;
for (const e of args.events) {
try {
const ch = (e.channel ?? 0) & 0x0F;
const t = e.type ?? 0;
if (e.noteKey !== undefined) {
// Swap bytes: AlphaTab 0x80=NoteOn / 0x90=NoteOff → MIDI 0x90=NoteOn / 0x80=NoteOff
const status = (t === 0x80 ? 0x90 : 0x80) | ch;
gpMidiOut.send([status, e.noteKey & 0x7F, (e.noteVelocity ?? 0) & 0x7F]);
} else if (e.controllerNumber !== undefined) {
gpMidiOut.send([0xB0 | ch, e.controllerNumber & 0x7F, (e.controllerValue ?? 0) & 0x7F]);
} else if (e.program !== undefined) {
gpMidiOut.send([0xC0 | ch, e.program & 0x7F]);
} else if (t === 0xE0 && e.value !== undefined) {
const v = e.value & 0x3FFF;
gpMidiOut.send([0xE0 | ch, v & 0x7F, (v >> 7) & 0x7F]);
}
} catch(err) { console.warn('MIDI send:', err.message); }
}
});
// Send program changes now (in case port already selected)
sendMidiProgramChanges();
}
// ─────────────────────────────────────────────────────────────
function loadGpBuffer(buf, name) {
showStageGp();
document.getElementById('gp-title').textContent = '🎸 ' + name;
document.getElementById('gp-loading').style.display = 'block';
document.getElementById('gp-main').innerHTML = '';
document.getElementById('gp-track-list').innerHTML = '';
document.getElementById('gp-tempo').value = '';
gpMuted.clear();
if (gpApi) { try { gpApi.destroy(); } catch(e){} gpApi = null; }
const container = document.getElementById('gp-main');
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
// ArrayBuffer → Blob URL (AlphaTab 1.8 requires URL or Uint8Array, not raw ArrayBuffer)
const blob = new Blob([new Uint8Array(buf)], { type: 'application/octet-stream' });
const blobUrl = URL.createObjectURL(blob);
const settings = {
file: blobUrl,
display: {
layoutMode: alphaTab.LayoutMode.Page,
scale: 0.9,
resources: isDark ? {
staffLineColor: new alphaTab.model.Color(107, 114, 128),
barSeparatorColor: new alphaTab.model.Color(107, 114, 128),
mainGlyphColor: new alphaTab.model.Color(229, 231, 235),
secondaryGlyphColor: new alphaTab.model.Color(156, 163, 175),
scoreInfoColor: new alphaTab.model.Color(229, 231, 235),
} : {}
},
player: {
enablePlayer: true,
enableCursor: true,
soundFont: '/GeneralUser-GS.sf2',
scrollElement: document.getElementById('gp-viewport')
}
};
try {
patchAudioForGpFx(); // intercept AudioNode→destination before AlphaTab creates its audio graph
gpApi = new alphaTab.AlphaTabApi(container, settings);
setGpMasterVol(100);
gpApi.renderFinished.on(() => {
URL.revokeObjectURL(blobUrl);
document.getElementById('gp-loading').style.display = 'none';
renderGpTracks();
const bpm = gpApi?.score?.tempo;
if (bpm) document.getElementById('gp-tempo').value = bpm;
});
gpApi.playerStateChanged.on((e) => {
const btn = document.getElementById('gp-play-btn');
if (btn) btn.textContent = e.state === alphaTab.synth.PlayerState.Playing ? '⏸ 일시정지' : '▶ 재생';
});
gpApi.soundFontLoaded.on(() => { setupMidiForwarding(); });
gpApi.error.on((e) => {
URL.revokeObjectURL(blobUrl);
document.getElementById('gp-loading').textContent = '⚠️ 로딩 실패: ' + (e.message || e);
});
} catch(err) {
URL.revokeObjectURL(blobUrl);
document.getElementById('gp-loading').textContent = '⚠️ 오류: ' + err.message;
}
}
function renderGpTracks() {
if (!gpApi || !gpApi.score) return;
const tracks = gpApi.score.tracks;
if (!tracks || !tracks.length) return;
const el = document.getElementById('gp-track-list');
const chips = tracks.map((t, i) => {
const muted = gpMuted.has(i);
const v = Math.round((t.playbackInfo?.volume ?? 11) / 16 * 100);
return `<span class="gp-track-chip on" id="gp-chip-${i}" title="단독 보기 (이름 클릭) / 음소거 (아이콘 클릭) / 볼륨 (슬라이더)">
<span onclick="gpSoloTrack(${i})">${escHtml(t.name || '트랙 ' + (i+1))}</span>
<span class="gp-mute-btn${muted ? ' muted' : ''}" onclick="event.stopPropagation();gpToggleMute(${i})" title="${muted ? '음소거 해제' : '음소거'}">${muted ? '🔇' : '🔊'}</span>
<input type="range" class="gp-vol" min="0" max="100" value="${v}" onclick="event.stopPropagation()" onchange="setGpTrackVol(${i},this.value)" oninput="gpVolLabel(${i},this.value)" title="트랙 볼륨">
<span class="gp-vol-val" id="gp-vol-val-${i}">${v}</span>
</span>`;
}).join('');
el.innerHTML = `<span class="gp-track-chip on" id="gp-chip-all" onclick="gpAllTracks()" style="opacity:.7">전체</span>${chips}`;
}
function setGpMasterVol(v) {
const val = Math.max(0, Math.min(200, parseInt(v, 10) || 0));
document.getElementById('gp-master-vol').value = val;
const lbl = document.getElementById('gp-master-vol-val'); if (lbl) lbl.textContent = val + '%';
if (gpApi) gpApi.masterVolume = val / 100;
}
function setGpTrackVol(i, v) {
if (!gpApi?.score?.tracks?.[i]) return;
const val = Math.max(0, Math.min(100, parseInt(v, 10) || 0)) / 100;
gpApi.changeTrackVolume([gpApi.score.tracks[i]], val);
gpVolLabel(i, Math.round(val * 100));
}
function gpVolLabel(i, v) {
const lbl = document.getElementById('gp-vol-val-' + i);
if (lbl) lbl.textContent = Math.round(v);
}
const gpMuted = new Set();
function gpToggleMute(idx) {
if (!gpApi || !gpApi.score) return;
const track = gpApi.score.tracks[idx];
if (!track) return;
const mute = !gpMuted.has(idx);
if (mute) gpMuted.add(idx); else gpMuted.delete(idx);
try { gpApi.changeTrackMute([track], mute); } catch(e) { console.warn('mute:', e.message); }
renderGpTracks();
}
function gpSoloTrack(idx) {
if (!gpApi || !gpApi.score) return;
// 이미 이 트랙만 선택된 상태면 전체로 돌아감
const allChips = document.querySelectorAll('.gp-track-chip:not(#gp-chip-all)');
const onChips = Array.from(allChips).filter(c => c.classList.contains('on'));
if (onChips.length === 1 && onChips[0].id === 'gp-chip-' + idx) {
gpAllTracks(); return;
}
allChips.forEach(c => c.classList.remove('on'));
document.getElementById('gp-chip-all').classList.remove('on');
document.getElementById('gp-chip-' + idx).classList.add('on');
gpApi.renderTracks([gpApi.score.tracks[idx]]);
}
function gpAllTracks() {
if (!gpApi || !gpApi.score) return;
document.querySelectorAll('.gp-track-chip').forEach(c => c.classList.add('on'));
gpApi.renderTracks(gpApi.score.tracks);
}
let _midiPlaying = false;
function gpTogglePlay() {
if (gpStageMode === 'midi') {
if (!_spessaSeq) return;
try {
if (_midiPlaying) {
_spessaSeq.pause(); _midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('일시정지');
} else {
if (_spessaCtx && _spessaCtx.state === 'suspended') _spessaCtx.resume();
_spessaSeq.play(); _midiPlaying = true; _setMidiBtn(true);
}
} catch(e) { console.error('gpTogglePlay midi:', e); }
return;
}
if (!gpApi) return;
gpApi.playPause();
}
function gpStop() {
if (gpStageMode === 'midi') {
if (_spessaSeq) try { _spessaSeq.pause(); } catch{}
_midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('정지');
return;
}
if (!gpApi) return;
gpApi.stop();
}
// ── Shared AudioContext (drum box + looper) ───────────────────────────────────
let _ac = null;
function getAudioCtx() {
if (!_ac) {
const AC = window.AudioContext || window.webkitAudioContext;
_ac = new AC({ latencyHint: 'playback' });
}
if (_ac.state === 'suspended') _ac.resume();
return _ac;
}
// ── Drum box ──────────────────────────────────────────────────────────────────
const DRUM_VOICES = [
{ name: '킥', note: 36, synth: synthKick },
{ name: '스네어', note: 38, synth: synthSnare },
{ name: '클로즈햇', note: 42, synth: (t,v)=>synthHat(t,v,false) },
{ name: '오픈햇', note: 46, synth: (t,v)=>synthHat(t,v,true ) },
{ name: '클랩', note: 39, synth: synthClap },
{ name: '로우탐', note: 45, synth: (t,v)=>synthTom(t,v,90,55) },
{ name: '하이탐', note: 48, synth: (t,v)=>synthTom(t,v,180,110) },
{ name: '심벌', note: 49, synth: synthCrash },
];
let drumPattern = []; // [voiceIdx][step] bool
let drumSteps = 16;
let drumBpm = 120;
let drumPlaying = false;
let drumStep = 0;
let drumStartTime = 0;
let drumNextTime = 0;
let drumTimer = null;
let drumRaf = null;
let drumMaster = null;
let drumSamples = {}; // voiceIdx -> [AudioBuffer] (round-robin variants)
let drumVarIdx = []; // per-voice variant counter
let drumSamplesLoaded = false;
let drumSampleLoading = false;
function drumNoiseBuf() {
const ctx = getAudioCtx();
if (!ctx._noiseBuf) {
const len = ctx.sampleRate; // 1s
const buf = ctx.createBuffer(1, len, ctx.sampleRate);
const d = buf.getChannelData(0);
for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1;
ctx._noiseBuf = buf;
}
return ctx._noiseBuf;
}
function drumMasterGain() {
if (!drumMaster) {
const ctx = getAudioCtx();
drumMaster = ctx.createGain();
drumMaster.gain.value = 0.8;
drumMaster.connect(ctx.destination);
}
return drumMaster;
}
async function drumLoadSamples() {
if (drumSamplesLoaded || drumSampleLoading) return;
drumSampleLoading = true;
const st = document.getElementById('drum-sample-status');
if (st) st.textContent = '샘플 로딩 중…';
try {
const ctx = getAudioCtx();
const r = await fetch('/drums/manifest.json', { headers: authH() });
if (!r.ok) throw new Error('manifest ' + r.status);
const map = await r.json();
let total = 0, ok = 0;
for (const k in map) total += map[k].length;
for (const k in map) {
const vi = parseInt(k, 10);
drumSamples[vi] = [];
for (const fname of map[k]) {
try {
const ar = await fetch('/drums/' + fname, { headers: authH() });
if (!ar.ok) continue;
const ab = await ar.arrayBuffer();
const buf = await ctx.decodeAudioData(ab.slice(0));
drumSamples[vi].push(buf);
ok++;
} catch (e) { console.warn('drum sample decode fail', fname, e.message); }
}
}
drumSamplesLoaded = true;
if (st) st.textContent = `샘플 ${ok}/${total} · 실제음`;
} catch (e) {
if (st) st.textContent = '샘플 로딩 실패 (합성 사용)';
console.warn('drum samples load fail', e.message);
} finally {
drumSampleLoading = false;
}
}
function drumTriggerVoice(vi, time, vel) {
const list = drumSamples[vi];
if (list && list.length) {
const ctx = getAudioCtx();
const idx = (drumVarIdx[vi] || 0) % list.length;
drumVarIdx[vi] = idx + 1;
const src = ctx.createBufferSource();
src.buffer = list[idx];
src.playbackRate.value = 1;
const g = ctx.createGain();
g.gain.value = (vel == null ? 1 : vel);
src.connect(g).connect(drumMasterGain());
src.start(time);
return;
}
DRUM_VOICES[vi].synth(time, vel == null ? 1 : vel);
}
function noiseSrc(t) {
const ctx = getAudioCtx();
const s = ctx.createBufferSource();
s.buffer = drumNoiseBuf();
s.start(t);
return s;
}
function synthKick(t, v) {
const ctx = getAudioCtx();
const osc = ctx.createOscillator(), g = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(150, t);
osc.frequency.exponentialRampToValueAtTime(50, t + 0.12);
g.gain.setValueAtTime(0.9 * v, t);
g.gain.exponentialRampToValueAtTime(0.0001, t + 0.18);
osc.connect(g).connect(drumMasterGain());
osc.start(t); osc.stop(t + 0.2);
}
function synthSnare(t, v) {
const ctx = getAudioCtx();
// noise body
const n = noiseSrc(t);
const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 0.7;
const ng = ctx.createGain(); ng.gain.setValueAtTime(0.7*v, t); ng.gain.exponentialRampToValueAtTime(0.0001, t+0.18);
n.connect(bp).connect(ng).connect(drumMasterGain());
// tone
const osc = ctx.createOscillator(), og = ctx.createGain();
osc.type = 'triangle'; osc.frequency.value = 180;
og.gain.setValueAtTime(0.4*v, t); og.gain.exponentialRampToValueAtTime(0.0001, t+0.1);
osc.connect(og).connect(drumMasterGain());
osc.start(t); osc.stop(t+0.12);
}
function synthHat(t, v, open) {
const ctx = getAudioCtx();
const n = noiseSrc(t);
const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 7000;
const g = ctx.createGain();
const dur = open ? 0.3 : 0.05;
g.gain.setValueAtTime(0.35*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
n.connect(hp).connect(g).connect(drumMasterGain());
}
function synthClap(t, v) {
const ctx = getAudioCtx();
const offsets = [0, 0.012, 0.024, 0.04];
for (const off of offsets) {
const n = noiseSrc(t+off);
const bp = ctx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=1500; bp.Q.value=0.5;
const g = ctx.createGain();
const dur = off === offsets[3] ? 0.12 : 0.03;
g.gain.setValueAtTime(0.5*v, t+off); g.gain.exponentialRampToValueAtTime(0.0001, t+off+dur);
n.connect(bp).connect(g).connect(drumMasterGain());
}
}
function synthTom(t, v, f0, f1) {
const ctx = getAudioCtx();
const osc = ctx.createOscillator(), g = ctx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(f0, t); osc.frequency.exponentialRampToValueAtTime(f1, t+0.18);
g.gain.setValueAtTime(0.6*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.25);
osc.connect(g).connect(drumMasterGain());
osc.start(t); osc.stop(t+0.3);
}
function synthCrash(t, v) {
const ctx = getAudioCtx();
const n = noiseSrc(t);
const hp = ctx.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=4000;
const g = ctx.createGain();
g.gain.setValueAtTime(0.35*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.6);
n.connect(hp).connect(g).connect(drumMasterGain());
}
function drumStepDur() { return 60 / drumBpm / 4; } // 16분음
function drumRenderGrid() {
const grid = document.getElementById('drum-grid');
let html = '';
for (let vi = 0; vi < DRUM_VOICES.length; vi++) {
const voice = DRUM_VOICES[vi];
let cells = '';
for (let s = 0; s < drumSteps; s++) {
const on = drumPattern[vi] && drumPattern[vi][s];
const beat = (s % 4 === 0) ? ' beat' : '';
cells += `<button class="drum-cell${beat}${on?' on':''}" id="dc-${vi}-${s}" onclick="drumToggle(${vi},${s})" oncontextmenu="drumToggle(${vi},${s},true);return false" title="${voice.name} ${s+1}"></button>`;
}
html += `<div class="drum-row"><span class="drum-label" onclick="drumPreview(${vi})" style="cursor:pointer" title="미리듣기">${voice.name}</span><div class="drum-cells">${cells}</div></div>`;
}
grid.innerHTML = html;
}
function drumToggle(vi, s, rightClick) {
drumPattern[vi] = drumPattern[vi] || new Array(drumSteps).fill(false);
if (rightClick) { // right-click = preview only, don't toggle
drumTriggerVoice(vi, getAudioCtx().currentTime, 1);
return;
}
drumPattern[vi][s] = !drumPattern[vi][s];
const cell = document.getElementById(`dc-${vi}-${s}`);
if (cell) cell.classList.toggle('on', drumPattern[vi][s]);
if (drumPattern[vi][s]) drumTriggerVoice(vi, getAudioCtx().currentTime, 1);
}
function drumPreview(vi) { drumTriggerVoice(vi, getAudioCtx().currentTime, 1); }
function drumSetSteps(n) {
drumStop();
if (loopRecording) loopStopRecord();
if (loopPlaying) loopStop();
const prev = drumPattern;
drumSteps = parseInt(n, 10) || 16;
drumPattern = DRUM_VOICES.map((_, vi) => {
const row = new Array(drumSteps).fill(false);
if (prev[vi]) for (let s = 0; s < Math.min(drumSteps, prev[vi].length); s++) row[s] = !!prev[vi][s];
return row;
});
drumRenderGrid();
}
function drumSetBpm(v) {
const nb = Math.max(40, Math.min(300, parseInt(v, 10) || 120));
const changed = nb !== drumBpm;
drumBpm = nb;
document.getElementById('drum-bpm').value = drumBpm;
if (changed) { if (loopRecording) loopStopRecord(); if (loopPlaying) loopStop(); loopRenderLayers(); }
}
function drumSetVol(v) { drumMasterGain().gain.value = (parseInt(v, 10) || 0) / 100; }
function drumClear() {
drumPattern = DRUM_VOICES.map(() => new Array(drumSteps).fill(false));
drumRenderGrid();
}
// ── Rhythm library (리듬별 패턴) ─────────────────────────────────────────────
// voice order: [0]킥 [1]스네어 [2]클로즈햇 [3]오픈햇 [4]클랩 [5]로우탐 [6]하이탐 [7]심벌
const DRUM_RHYTHMS = {
rock: { label: '🤘 록 (Rock)', bpm: 120, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } },
pop: { label: '🎤 팝 (Pop)', bpm: 100, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,1,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } },
funk: { label: '🪩 펑크 (Funk)', bpm: 100, swing: 56, swingDiv: '16', pat: { 0:[1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0] } },
jazz: { label: '🎷 재즈 (Jazz swing)', bpm: 140, swing: 62, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0] } },
blues: { label: '🎸 블루스 셔플', bpm: 90, swing: 66, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } },
trap: { label: '🔥 트랩 (Trap)', bpm: 70, swing: 60, swingDiv: '16', pat: { 0:[1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0], 1:[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0] } },
hiphop: { label: '🎧 힙합 붐밥', bpm: 90, swing: 54, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } },
reggae: { label: '🏝️ 레게 (One-drop)', bpm: 80, swing: 52, swingDiv: '8', pat: { 0:[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } },
disco: { label: '🕺 디스코 (4-on-floor)', bpm: 120, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1] } },
bossa: { label: '🌴 보사노바', bpm: 130, swing: 50, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0], 1:[0,0,0,1,0,0,1,0,0,0,0,1,0,0,1,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } },
samba: { label: '🇧🇷 삼바 (Samba)', bpm: 130, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,0,1,0,0,1,0,0,0,0,1,0,0], 1:[0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 4:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0] } },
metal: { label: '⚡ 헤비메탈 (Metal)', bpm: 165, swing: 0, swingDiv: '8', pat: { 0:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,1], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 7:[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0] } },
ballad: { label: '💿 발라드 (Ballad)', bpm: 68, swing: 50, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0], 4:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0] } },
house: { label: '🏠 하우스 (House)', bpm: 128, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 2:[0,0,1,0,0,0,1,0,0,0,1,0,0,0,1,0], 3:[0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1], 4:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0] } },
dnb: { label: '🥁 드럼앤베이스 (D&B)', bpm: 174, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,1,0,0,0,0,0,0,0,0,0], 1:[0,0,0,0,0,0,0,0,1,0,0,0,0,0,1,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,0,0,0,0,0,0,1,0,0,0,0,0,0,0,1] } },
afro: { label: '🌍 아프로비트 (Afro)', bpm: 105, swing: 52, swingDiv: '8', pat: { 0:[1,0,0,1,0,0,1,0,0,0,0,1,0,0,0,0], 1:[0,0,1,0,0,0,0,1,0,0,1,0,0,1,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 4:[0,0,0,0,0,1,0,0,0,0,0,0,0,1,0,0] } },
salsa: { label: '💃 살사 (Salsa)', bpm: 190, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0], 1:[0,0,1,0,0,0,0,0,0,0,1,0,0,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1] } },
country:{ label: '🤠 컨트리 (Country)', bpm: 96, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0], 4:[0,0,0,0,1,0,0,1,0,0,0,0,1,0,0,1] } },
};
let drumSwing = 0; // 0..70 % — swing amount
let drumSwingDiv = '8'; // '8' = 8th-note swing (jazz/blues/hiphop/reggae/bossa), '16' = 16th-note swing (funk/trap, MPC-style)
function drumInitRhythmSelect() {
const sel = document.getElementById('drum-rhythm');
if (!sel || sel.options.length) return;
sel.innerHTML = '<option value="">— 리듬 선택 —</option>' +
Object.entries(DRUM_RHYTHMS).map(([k, r]) => `<option value="${k}">${r.label}</option>`).join('');
}
function drumLoadRhythm(key) {
const r = DRUM_RHYTHMS[key]; if (!r) return;
drumSetSteps(16);
drumBpm = r.bpm; document.getElementById('drum-bpm').value = r.bpm;
drumSwing = r.swing; drumSwingDiv = r.swingDiv || '8';
document.getElementById('drum-swing').value = r.swing;
drumUpdateSwingLabel();
drumPattern = DRUM_VOICES.map((_, vi) => {
const row = new Array(drumSteps).fill(false);
const src = r.pat[vi];
if (src) for (let s = 0; s < Math.min(drumSteps, src.length); s++) row[s] = !!src[s];
return row;
});
drumRenderGrid();
}
function drumSetSwing(v) {
drumSwing = Math.max(0, Math.min(70, parseFloat(v) || 0));
drumUpdateSwingLabel();
}
function drumUpdateSwingLabel() {
const el = document.getElementById('drum-swing-val');
if (el) el.textContent = drumSwing + '%' + (drumSwingDiv === '16' ? ' (16분)' : ' (8분)');
}
// ── Saved patterns (server workspace) ─────────────────────────────────────────
async function drumRefreshSavedList() {
const sel = document.getElementById('drum-saved');
if (!sel) return;
try {
const r = await fetch('/api/music/drums/list', { headers: authH() });
const data = await r.json();
const cur = sel.value;
const files = data.files || [];
sel.innerHTML = (files.length ? files : [{name:'', mtime:0}]).map((f, i) =>
i === 0 && !files.length ? '<option value="">— 없음 —</option>' :
`<option value="${escAttr(f.name)}">${escHtml(f.name)}</option>`).join('');
if (cur && files.some(f => f.name === cur)) sel.value = cur;
} catch {}
}
async function drumSavePattern() {
let name = prompt('패턴 이름을 입력하세요:', '내 패턴');
if (!name) return;
name = name.trim().slice(0, 60);
if (!name) return;
const body = { name, steps: drumSteps, bpm: drumBpm, swing: drumSwing, swingDiv: drumSwingDiv, pattern: drumPattern };
try {
const r = await fetch('/api/music/drums/save', {
method: 'POST', headers: authH({ 'Content-Type': 'application/json' }), body: JSON.stringify(body) });
const data = await r.json();
if (!r.ok) throw new Error(data.error || '저장 실패');
addChatMsg('system', `💾 드럼 패턴 "${escHtml(name)}" 저장됨`);
await drumRefreshSavedList();
document.getElementById('drum-saved').value = name;
} catch (e) { addChatMsg('system', '❌ 저장 실패: ' + escHtml(e.message)); }
}
async function drumLoadSaved(name) {
try {
const r = await fetch('/api/music/drums/' + encodeURIComponent(name), { headers: authH() });
if (!r.ok) throw new Error('불러오기 실패 ' + r.status);
const d = await r.json();
drumSetSteps(d.steps || 16);
drumBpm = d.bpm || 120; document.getElementById('drum-bpm').value = drumBpm;
drumSwing = d.swing || 0; drumSwingDiv = (d.swingDiv === '16' ? '16' : '8');
document.getElementById('drum-swing').value = drumSwing; drumUpdateSwingLabel();
drumPattern = DRUM_VOICES.map((_, vi) => {
const row = new Array(drumSteps).fill(false);
const src = d.pattern && d.pattern[vi];
if (src) for (let s = 0; s < Math.min(drumSteps, src.length); s++) row[s] = !!src[s];
return row;
});
drumRenderGrid();
addChatMsg('system', `📂 패턴 "${escHtml(name)}" 불러옴 · 편집 후 다시 저장 가능`);
} catch (e) { addChatMsg('system', '❌ 불러오기 실패: ' + escHtml(e.message)); }
}
function drumLoadSavedSel() {
const name = document.getElementById('drum-saved').value;
if (name) drumLoadSaved(name);
}
async function drumDeleteSavedSel() {
const name = document.getElementById('drum-saved').value;
if (!name) return;
if (!confirm(`패턴 "${name}" 삭제할까요?`)) return;
try {
const r = await fetch('/api/music/drums/' + encodeURIComponent(name), { method: 'DELETE', headers: authH() });
if (!r.ok) throw new Error('삭제 실패');
await drumRefreshSavedList();
addChatMsg('system', `🗑 패턴 "${escHtml(name)}" 삭제됨`);
} catch (e) { addChatMsg('system', '❌ 삭제 실패: ' + escHtml(e.message)); }
}
function drumScheduleStep(step, time) {
for (let vi = 0; vi < DRUM_VOICES.length; vi++) {
if (drumPattern[vi] && drumPattern[vi][step]) drumTriggerVoice(vi, time, 1);
}
}
function drumScheduler() {
const ctx = getAudioCtx();
const AHEAD = 0.1;
const sd = drumStepDur();
// 8th-note swing: delay upbeat-8th group (step%4>=2) by swing/100 of a 16th. ~67% ≈ triplet.
// 16th-note swing (MPC): delay odd 16th steps (step%2===1) by swing/100 of a 32nd (sd/2).
const swingOff = drumSwing / 100 * (drumSwingDiv === '16' ? sd / 2 : sd);
const swingTest = drumSwingDiv === '16' ? (s => s % 2 === 1) : (s => s % 4 >= 2);
while (drumNextTime < ctx.currentTime + AHEAD) {
const t = drumNextTime + (swingTest(drumStep) ? swingOff : 0);
drumScheduleStep(drumStep, t);
drumNextTime += sd;
drumStep = (drumStep + 1) % drumSteps;
}
if (drumPlaying) drumTimer = setTimeout(drumScheduler, 25);
}
function drumTogglePlay() {
if (drumPlaying) drumStop();
else drumPlay();
}
function drumPlay() {
const ctx = getAudioCtx();
if (ctx.state === 'suspended') ctx.resume();
drumPlaying = true;
drumStep = 0;
drumStartTime = ctx.currentTime + 0.06;
drumNextTime = drumStartTime;
const pb = document.getElementById('drum-play-btn');
pb.textContent = '⏸ 일시정지';
pb.style.background = 'rgba(245,158,11,.15)'; pb.style.borderColor = '#fbbf24'; pb.style.color = '#fde68a';
drumScheduler();
drumDrawLoop();
}
function drumStop() {
drumPlaying = false;
if (drumTimer) { clearTimeout(drumTimer); drumTimer = null; }
if (drumRaf) { cancelAnimationFrame(drumRaf); drumRaf = null; }
const btn = document.getElementById('drum-play-btn');
if (btn) { btn.textContent = '▶ 재생'; btn.style.background = 'rgba(34,197,94,.15)'; btn.style.borderColor = '#4ade80'; btn.style.color = '#86efac'; }
document.querySelectorAll('.drum-cell.cur').forEach(c => c.classList.remove('cur'));
}
function drumDrawLoop() {
const ctx = getAudioCtx();
const elapsed = ctx.currentTime - drumStartTime;
let cur = Math.floor(elapsed / drumStepDur());
if (cur < 0) cur = 0;
cur = cur % drumSteps;
if (cur !== drumDrawLast) {
document.querySelectorAll('.drum-cell.cur').forEach(c => c.classList.remove('cur'));
for (let vi = 0; vi < DRUM_VOICES.length; vi++) {
const cell = document.getElementById(`dc-${vi}-${cur}`);
if (cell) cell.classList.add('cur');
}
drumDrawLast = cur;
}
if (drumPlaying) drumRaf = requestAnimationFrame(drumDrawLoop);
}
let drumDrawLast = -1;
// ── Drum pattern → MIDI export ────────────────────────────────────────────────
function vlen(n) {
const out = [];
let v = n;
while (true) {
out.unshift(v & 0x7F);
if (v < 0x80) break;
out[0] |= 0x80;
v >>= 7;
}
return out;
}
function drumBuildMidi() {
const TPQ = 480, STEP = 240, DUR = 120, CH = 9, VEL = 100;
const evs = []; // {tick, bytes}
for (let s = 0; s < drumSteps; s++) {
for (let vi = 0; vi < DRUM_VOICES.length; vi++) {
if (drumPattern[vi] && drumPattern[vi][s]) {
const note = DRUM_VOICES[vi].note;
const tick = s * STEP;
evs.push({ tick, bytes: [0x99, note, VEL] });
evs.push({ tick: tick + DUR, bytes: [0x89, note, 0] });
}
}
}
evs.sort((a, b) => a.tick - b.tick);
const body = [];
let lastTick = 0;
for (const e of evs) {
const delta = e.tick - lastTick; lastTick = e.tick;
for (const b of vlen(delta)) body.push(b);
for (const b of e.bytes) body.push(b);
}
// end of track
for (const b of vlen(0)) body.push(b);
body.push(0xFF, 0x2F, 0x00);
// tracks
const mtrk = [0x4D, 0x54, 0x72, 0x6B];
const len = body.length;
mtrk.push((len>>>24)&0xFF, (len>>>16)&0xFF, (len>>>8)&0xFF, len&0xFF);
const mthd = [0x4D,0x54,0x68,0x64, 0x00,0x00,0x00,0x06, 0x00,0x00, 0x00,0x01, (TPQ>>8)&0xFF, TPQ&0xFF];
return new Uint8Array([...mthd, ...mtrk, ...body]);
}
async function drumExportMidi() {
const hasAny = drumPattern.some(row => row && row.some(Boolean));
if (!hasAny) { addChatMsg('system', '패턴이 비어 있습니다. 셀을 클릭해 드럼을 채우세요.'); return; }
const bytes = drumBuildMidi();
const blob = new Blob([bytes], { type: 'audio/midi' });
const filename = '드럼패턴.mid';
const file = new File([blob], filename, { type: 'audio/midi' });
addChatMsg('system', '🥁 드럼 패턴 MIDI 내보내는 중...');
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, filename);
closeDrums();
addToStage(html, filename);
loadMidiFiles();
addChatMsg('system', '✅ 드럼 패턴을 MIDI로 내보냈습니다 · 스테이지에 추가됨');
} catch (e) { addChatMsg('system', '❌ 내보내기 실패: ' + escHtml(e.message)); }
}
// ── Looper (mic, beat-synced to drum box) ─────────────────────────────────────
let loopLayers = []; // {buffer, gain, source, muted, vol, dur, loopEnd}
let loopPlaying = false;
let loopRecording = false;
let loopBars = 1; // loop length in bars (1 bar = 16 steps)
let micStream = null, micSource = null, loopAnalyser = null, loopRecorder = null;
let loopRecChunks = [], loopRecTimer = null, loopRecStartTimer = null, loopMeterRaf = null, loopMasterGain = null;
function loopMaster() {
if (!loopMasterGain) {
const ctx = getAudioCtx();
loopMasterGain = ctx.createGain();
loopMasterGain.gain.value = 1;
loopMasterGain.connect(ctx.destination);
}
return loopMasterGain;
}
async function loopEnsureMic() {
if (micStream) return true;
try {
micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
const ctx = getAudioCtx();
micSource = ctx.createMediaStreamSource(micStream);
loopAnalyser = ctx.createAnalyser();
loopAnalyser.fftSize = 512;
micSource.connect(loopAnalyser);
// analyser is a sink (not connected to destination) — no feedback
loopStartMeter();
return true;
} catch (e) { addChatMsg('system', '❌ 마이크 접근 실패: ' + escHtml(e.message)); return false; }
}
function loopStartMeter() {
if (loopMeterRaf) return;
const fill = document.getElementById('loop-meter-fill');
const data = new Uint8Array(loopAnalyser.fftSize);
const tick = () => {
loopAnalyser.getByteTimeDomainData(data);
let sum = 0;
for (let i = 0; i < data.length; i++) { const v = (data[i]-128)/128; sum += v*v; }
const rms = Math.sqrt(sum / data.length);
if (fill) fill.style.width = Math.min(100, rms * 200) + '%';
loopMeterRaf = requestAnimationFrame(tick);
};
tick();
}
// ── Beat sync helpers ──
function loopBarDur() { return 16 * drumStepDur(); } // one 4/4 bar (sec)
function loopLen() { return loopBars * loopBarDur(); } // total loop length (sec)
// next drum bar-boundary time (multiples of 16 steps from drumStartTime). Falls back
// to ctx.currentTime+0.05 when drums aren't playing (standalone loop transport).
function drumNextBarTime() {
const ctx = getAudioCtx();
if (!drumPlaying) return ctx.currentTime + 0.05;
const barDur = loopBarDur();
const elapsed = ctx.currentTime - drumStartTime;
const k = Math.floor(elapsed / barDur);
let t = drumStartTime + (k + 1) * barDur;
if (t - ctx.currentTime < 0.03) t += barDur; // too close to boundary → next one
return t;
}
function loopSetBars(v) { loopBars = Math.max(1, Math.min(8, parseInt(v, 10) || 1)); loopRenderLayers(); }
function loopRenderLayers() {
const el = document.getElementById('loop-layers');
if (!el) return;
if (!loopLayers.length) {
el.innerHTML = '<div class="loop-empty">● 녹음을 누르면 드럼 비트에 맞춰 녹음 시작 (1마디 카운트인 후 녹음). 재생 중 누르면 오버덥(겹쳐 녹음).</div>';
} else {
el.innerHTML = loopLayers.map((L, i) => `
<div class="loop-layer" id="loop-layer-${i}">
<span class="ll-num">${i+1}</span>
<button class="ll-mute${L.muted?' muted':''}" onclick="loopMute(${i})" title="음소거">${L.muted?'🔇':'🔊'}</button>
<input type="range" min="0" max="100" value="${Math.round(L.vol*100)}" onchange="loopVol(${i},this.value)">
<span class="ll-dur">${loopLen().toFixed(1)}s</span>
<button class="ll-del" onclick="loopDel(${i})" title="삭제">✕</button>
</div>`).join('');
}
const st = document.getElementById('loop-status');
if (st) st.textContent = `레이어 ${loopLayers.length} · ${loopRecording?'녹음 중':loopPlaying?'재생 중':'대기'} · ${loopLen().toFixed(1)}s`;
}
function loopMute(i) {
const L = loopLayers[i]; if (!L) return;
L.muted = !L.muted; L.gain.gain.value = L.muted ? 0 : L.vol;
loopRenderLayers();
}
function loopVol(i, v) {
const L = loopLayers[i]; if (!L) return;
L.vol = (parseInt(v,10)||0)/100; if (!L.muted) L.gain.gain.value = L.vol;
}
function loopDel(i) {
const L = loopLayers[i]; if (!L) return;
if (L.source) { try { L.source.stop(); } catch{} }
try { L.gain.disconnect(); } catch{}
loopLayers.splice(i, 1);
if (!loopLayers.length) { loopPlaying = false; }
loopRenderLayers();
}
function loopUndo() {
if (!loopLayers.length) return;
loopDel(loopLayers.length - 1);
}
function loopClear() {
for (const L of loopLayers) { if (L.source) { try{L.source.stop();}catch{} } try{L.gain.disconnect();}catch{} }
loopLayers = []; loopPlaying = false;
const pb = document.getElementById('loop-play-btn'); if (pb) { pb.classList.remove('active'); pb.innerHTML = '<span class="lb-icon">▶</span>재생'; }
loopRenderLayers();
}
async function loopToggleRecord() {
if (loopRecording) loopStopRecord();
else await loopStartRecord();
}
async function loopStartRecord() {
const ctx = getAudioCtx();
if (ctx.state === 'suspended') ctx.resume();
if (!(await loopEnsureMic())) return;
// Beat-synced: drums are the click. Auto-start if stopped (gives 1-bar count-in).
const wasPlaying = drumPlaying;
if (!wasPlaying) drumPlay();
// play existing layers so you hear them while overdubbing
if (loopLayers.length && !loopPlaying) loopPlay();
const recLen = loopLen();
let startAt = drumNextBarTime();
if (!wasPlaying) startAt += loopBarDur(); // fresh start → wait 1 bar (count-in) before recording
const recBtn = document.getElementById('loop-rec-btn');
recBtn.classList.add('recording','active'); recBtn.innerHTML = '<span class="lb-icon">⏹</span>녹음중지';
loopRecording = true;
loopRenderLayers();
const delayMs = Math.max(0, (startAt - ctx.currentTime) * 1000);
loopRecStartTimer = setTimeout(() => {
if (!loopRecording) return; // cancelled during count-in
loopRecChunks = [];
const mime = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : '';
loopRecorder = mime ? new MediaRecorder(micStream, { mimeType: mime }) : new MediaRecorder(micStream);
loopRecorder.ondataavailable = e => { if (e.data && e.data.size) loopRecChunks.push(e.data); };
loopRecorder.onstop = async () => {
const blob = new Blob(loopRecChunks, { type: loopRecChunks[0]?.type || 'audio/webm' });
try {
const ab = await blob.arrayBuffer();
const buf = await ctx.decodeAudioData(ab.slice(0));
addLoopLayer(buf, recLen);
} catch (e) { addChatMsg('system', '❌ 녹음 디코딩 실패: ' + escHtml(e.message)); }
};
loopRecorder.start();
// stop after recLen + tail so buffer ≥ recLen (clean loopEnd, seamless loop)
loopRecTimer = setTimeout(() => { if (loopRecording) loopStopRecord(); }, (recLen + 0.15) * 1000);
}, delayMs);
}
function loopStopRecord() {
loopRecording = false;
if (loopRecStartTimer) { clearTimeout(loopRecStartTimer); loopRecStartTimer = null; }
if (loopRecTimer) { clearTimeout(loopRecTimer); loopRecTimer = null; }
if (loopRecorder && loopRecorder.state !== 'inactive') loopRecorder.stop();
const btn = document.getElementById('loop-rec-btn');
if (btn) { btn.classList.remove('recording','active'); btn.innerHTML = '<span class="lb-icon">⏺</span>녹음'; }
loopRenderLayers();
}
function addLoopLayer(buf, loopEnd) {
const ctx = getAudioCtx();
const g = ctx.createGain();
g.gain.value = 1;
g.connect(loopMaster());
const layer = { buffer: buf, gain: g, source: null, muted: false, vol: 1, dur: buf.duration,
loopEnd: loopEnd || buf.duration };
loopLayers.push(layer);
// immediately play in sync with the drum beat (phase-aligned, no gap)
if (loopPlaying) { const s = loopAlignedStart(); startLayerSource(layer, s.when, s.offset); }
else loopPlay();
loopRenderLayers();
}
// Returns {when, offset} so a layer starts phase-aligned to the drum bar grid:
// plays from the current position within the loop, locking to the beat with no wait.
function loopAlignedStart() {
const ctx = getAudioCtx();
if (!drumPlaying) return { when: ctx.currentTime + 0.05, offset: 0 };
const barDur = loopBarDur();
const len = loopLen();
const elapsed = ctx.currentTime - drumStartTime;
const k = Math.floor(elapsed / barDur);
const lastBoundary = drumStartTime + k * barDur;
const when = ctx.currentTime + 0.03;
let offset = (when - lastBoundary) % len;
if (offset < 0) offset += len;
return { when, offset };
}
function startLayerSource(L, when, offset) {
const ctx = getAudioCtx();
const src = ctx.createBufferSource();
src.buffer = L.buffer; src.loop = true; src.loopStart = 0;
src.loopEnd = Math.min(L.loopEnd, L.buffer.duration);
src.connect(L.gain);
src.start(when, offset || 0);
L.source = src;
}
function loopPlay() {
const ctx = getAudioCtx();
if (ctx.state === 'suspended') ctx.resume();
if (!loopLayers.length) return;
const s = loopAlignedStart();
for (const L of loopLayers) startLayerSource(L, s.when, s.offset);
loopPlaying = true;
const pb = document.getElementById('loop-play-btn'); if (pb) { pb.classList.add('active'); pb.innerHTML = '<span class="lb-icon">⏸</span>정지'; }
loopRenderLayers();
}
function loopTogglePlay() {
if (loopPlaying) loopStop();
else loopPlay();
}
function loopStop() {
for (const L of loopLayers) { if (L.source) { try{L.source.stop();}catch{} L.source = null; } }
loopPlaying = false;
const pb = document.getElementById('loop-play-btn'); if (pb) { pb.classList.remove('active'); pb.innerHTML = '<span class="lb-icon">▶</span>재생'; }
loopRenderLayers();
}
// ── Stage switching: drums (with built-in looper) ─────────────────────────────
function hideAllStages() {
document.getElementById('stage-player').style.display = 'none';
document.getElementById('stage-editor').classList.remove('visible');
document.getElementById('stage-gp').style.display = 'none';
document.getElementById('stage-drums').style.display = 'none';
}
function showStageDrums() {
hideAllStages();
const el = document.getElementById('stage-drums');
el.style.display = 'flex'; el.style.flexDirection = 'column'; el.style.flex = '1'; el.style.minHeight = '0';
if (!drumPattern.length) drumSetSteps(drumSteps);
drumInitRhythmSelect();
drumRefreshSavedList();
drumLoadSamples();
loopRenderLayers();
}
function closeDrums() {
drumStop();
if (loopRecording) loopStopRecord();
if (loopPlaying) loopStop();
document.getElementById('stage-drums').style.display = 'none';
restorePlayerStage();
}
function restorePlayerStage() {
const p = document.getElementById('stage-player');
p.style.display = 'flex'; p.style.flexDirection = 'column'; p.style.flex = '1'; p.style.minHeight = '0'; p.style.overflow = 'hidden';
}
function escHtml(s){return String(s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');}
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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')
.replace(/```([\w-]*)\n?([\s\S]*?)```/g, (_, lang, code) => {
if (lang === 'abc' || lang === 'lilypond') return buildAbcWidget(code.trim(), lang);
if (lang === 'midi-widget') {
try {
const d = JSON.parse(code.trim());
if (d.midi_url && d.id) {
const fname = d.filename || 'output.mid';
if (!_renderedMidiIds.has(d.id)) {
_renderedMidiIds.add(d.id);
setTimeout(() => { const {html:sh}=buildMidiPickerWidget(d,fname); addToStage(sh,fname); loadMidiFiles(); }, 0);
}
return '<span class="ms-stage-link" onclick="document.getElementById(\'stage-body\').scrollIntoView({behavior:\'smooth\'})">↑ 스테이지에서 보기</span>';
}
} catch {}
}
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(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/g,'"');
let data; try { data = JSON.parse(dec); } catch { return match; }
if (data && data.midi_url && data.id && !_renderedMidiIds.has(data.id)) {
_renderedMidiIds.add(data.id);
const filename = data.filename || 'output.mid';
setTimeout(() => { const {html:sh}=buildMidiPickerWidget(data,filename); addToStage(sh,filename); loadMidiFiles(); }, 0);
return '<span class="ms-stage-link" onclick="document.getElementById(\'stage-body\').scrollIntoView({behavior:\'smooth\'})">↑ 스테이지에서 보기</span>';
}
return match;
});
}
// ── AudioMass ────────────────────────────────────────────────────────────────
let _audiomassInited = false;
function showAudioMass(show) {
hideAllPanels();
document.getElementById('audiomass-panel').style.display = show ? 'flex' : 'none';
document.getElementById('stage-player').style.display = show ? 'none' : 'flex';
if (show && !_audiomassInited && typeof PKAudioEditor !== 'undefined') {
_audiomassInited = true;
PKAudioEditor.init('audiomass-stage');
// fire resize after layout is painted so MainHeight sees correct clientHeight
requestAnimationFrame(function() {
PKAudioEditor.fireEvent('RequestResize');
new ResizeObserver(function() {
PKAudioEditor.fireEvent('RequestResize');
}).observe(document.getElementById('audiomass-stage'));
});
} else if (show) {
requestAnimationFrame(function() { PKAudioEditor.fireEvent('RequestResize'); });
}
}
// ── Wavacity ─────────────────────────────────────────────────────────────────
function openWavacity(fileUrl) {
const url = fileUrl ? `/wavacity/?file=${encodeURIComponent(fileUrl)}` : '/wavacity/';
window.open(url, 'wavacity', 'width=1200,height=800,menubar=no,toolbar=no,location=no');
}
// ── YouTube downloader ───────────────────────────────────────────────────────
let _ytFmt = 'mp3';
let _ytCurrentUrl = '';
let _ytCurrentJobId = '';
let _ytTabMode = 'search';
function setYtTab(tab) {
_ytTabMode = tab;
const isSearch = tab === 'search';
document.getElementById('yt-search-box').style.display = isSearch ? 'flex' : 'none';
document.getElementById('yt-url-box').style.display = isSearch ? 'none' : 'flex';
document.getElementById('yt-tab-search').style.cssText = isSearch
? 'flex:1;padding:7px;background:rgba(168,85,247,.15);border:1.5px solid #a855f7;color:#a855f7;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;'
: 'flex:1;padding:7px;background:transparent;border:1.5px solid var(--studio-border2);color:var(--studio-muted);border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;';
document.getElementById('yt-tab-url').style.cssText = !isSearch
? 'flex:1;padding:7px;background:rgba(168,85,247,.15);border:1.5px solid #a855f7;color:#a855f7;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;'
: 'flex:1;padding:7px;background:transparent;border:1.5px solid var(--studio-border2);color:var(--studio-muted);border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;';
document.getElementById('yt-info').style.display = 'none';
document.getElementById('yt-search-results').style.display = 'none';
document.getElementById('yt-progress-wrap').style.display = 'none';
document.getElementById('yt-result').style.display = 'none';
setTimeout(() => {
const inp = isSearch ? document.getElementById('yt-search-input') : document.getElementById('yt-url-input');
inp && inp.focus();
}, 50);
}
async function ytDoSearch() {
const q = document.getElementById('yt-search-input').value.trim();
if (!q) return;
const btn = document.getElementById('yt-search-btn');
btn.textContent = '검색 중…'; btn.disabled = true;
document.getElementById('yt-search-results').style.display = 'flex';
document.getElementById('yt-search-results').innerHTML = '<div style="text-align:center;color:var(--studio-muted);font-size:12px;padding:20px 0;">검색 중…</div>';
document.getElementById('yt-info').style.display = 'none';
try {
const r = await fetch('/api/music/yt/search', {
method: 'POST', headers: { ...authH(), 'Content-Type': 'application/json' },
body: JSON.stringify({ q })
});
const results = await r.json();
if (!r.ok) { document.getElementById('yt-search-results').innerHTML = `<div style="color:#f87171;font-size:12px;padding:10px">${results.error}</div>`; return; }
if (!results.length) { document.getElementById('yt-search-results').innerHTML = '<div style="color:var(--studio-muted);font-size:12px;padding:10px">결과 없음</div>'; return; }
document.getElementById('yt-search-results').innerHTML = results.map(v => {
const dur = v.duration ? `${Math.floor(v.duration/60)}:${String(v.duration%60).padStart(2,'0')}` : '';
return `<div onclick="ytSelectResult('${escHtml(v.url)}','${escHtml(v.title)}','${escHtml(v.thumbnail||'')}','${escHtml(v.uploader||'')}','${dur}')"
style="display:flex;gap:10px;padding:8px 10px;background:var(--studio-panel2);border:1.5px solid var(--studio-border2);border-radius:10px;cursor:pointer;transition:.15s;"
onmouseover="this.style.borderColor='#a855f7'" onmouseout="this.style.borderColor='var(--studio-border2)'">
<img src="${escHtml(v.thumbnail||'')}" style="width:80px;height:45px;object-fit:cover;border-radius:6px;flex-shrink:0;" onerror="this.style.display='none'">
<div style="flex:1;min-width:0;">
<div style="font-size:12px;font-weight:700;color:var(--studio-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;">${escHtml(v.title)}</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:3px;">${escHtml(v.uploader||'')}${dur?' · '+dur:''}</div>
</div>
</div>`;
}).join('');
} catch(e) { document.getElementById('yt-search-results').innerHTML = `<div style="color:#f87171;font-size:12px;padding:10px">${e.message}</div>`; }
finally { btn.textContent = '검색'; btn.disabled = false; }
}
function ytSelectResult(url, title, thumbnail, uploader, dur) {
_ytCurrentUrl = url;
// Extract video ID for embed
const vidId = url.match(/[?&]v=([^&]+)/)?.[1] || url.match(/youtu\.be\/([^?]+)/)?.[1] || '';
document.getElementById('yt-embed').src = vidId ? `https://www.youtube.com/embed/${vidId}?autoplay=1` : '';
document.getElementById('yt-title').textContent = title;
document.getElementById('yt-meta').textContent = [uploader, dur].filter(Boolean).join(' · ');
document.getElementById('yt-search-results').style.display = 'none';
document.getElementById('yt-info').style.display = '';
document.getElementById('yt-result').style.display = 'none';
document.getElementById('yt-progress-wrap').style.display = 'none';
document.getElementById('yt-dl-btn').disabled = false;
document.getElementById('yt-dl-btn').textContent = '⬇ 다운로드 시작';
}
function hideAllPanels() {
['audiomass-panel','yt-panel','sep-panel','stage-drums','stage-editor'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
}
function ytBack() {
if (document.getElementById('yt-info').style.display !== 'none') {
// 영상 보는 중 → 검색 결과로
document.getElementById('yt-embed').src = '';
document.getElementById('yt-info').style.display = 'none';
document.getElementById('yt-progress-wrap').style.display = 'none';
document.getElementById('yt-result').style.display = 'none';
document.getElementById('yt-search-results').style.display = _ytTabMode === 'search' ? 'flex' : 'none';
} else {
// 검색 결과 또는 첫 화면 → 홈으로
showYtPanel(false);
}
}
function showYtPanel(show) {
hideAllPanels();
document.getElementById('yt-panel').style.display = show ? 'flex' : 'none';
document.getElementById('stage-player').style.display = show ? 'none' : 'flex';
if (!show) return;
document.getElementById('yt-embed').src = '';
document.getElementById('yt-info').style.display = 'none';
document.getElementById('yt-progress-wrap').style.display = 'none';
document.getElementById('yt-result').style.display = 'none';
document.getElementById('yt-search-results').style.display = 'none';
document.getElementById('yt-search-input').value = '';
document.getElementById('yt-url-input').value = '';
setYtFmt('mp3');
setYtTab('search');
}
function setYtFmt(fmt) {
_ytFmt = fmt;
const active = 'flex:1;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1.5px solid #a855f7;border-radius:9px;cursor:pointer;background:rgba(168,85,247,.1);';
const inactive = 'flex:1;display:flex;align-items:center;gap:8px;padding:8px 12px;border:1.5px solid var(--studio-border2);border-radius:9px;cursor:pointer;';
document.getElementById('yt-fmt-mp3').style.cssText = fmt === 'mp3' ? active : inactive;
document.getElementById('yt-fmt-best').style.cssText = fmt === 'best' ? active : inactive;
document.querySelector('input[name="yt-fmt"][value="' + fmt + '"]').checked = true;
}
async function ytFetchInfo() {
const url = document.getElementById('yt-url-input').value.trim();
if (!url) return;
_ytCurrentUrl = url;
const btn = document.getElementById('yt-info-btn');
btn.textContent = '조회 중…'; btn.disabled = true;
document.getElementById('yt-info').style.display = 'none';
document.getElementById('yt-progress-wrap').style.display = 'none';
document.getElementById('yt-result').style.display = 'none';
try {
const r = await fetch('/api/music/yt/info', {
method: 'POST', headers: { ...authH(), 'Content-Type': 'application/json' },
body: JSON.stringify({ url })
});
const d = await r.json();
if (!r.ok) { alert(d.error || '오류'); return; }
const vidId = url.match(/[?&]v=([^&]+)/)?.[1] || url.match(/youtu\.be\/([^?]+)/)?.[1] || '';
document.getElementById('yt-embed').src = vidId ? `https://www.youtube.com/embed/${vidId}?autoplay=1` : '';
document.getElementById('yt-title').textContent = d.title || '';
const dur = d.duration ? `${Math.floor(d.duration/60)}:${String(d.duration%60).padStart(2,'0')}` : '';
document.getElementById('yt-meta').textContent = [d.uploader, dur].filter(Boolean).join(' · ');
document.getElementById('yt-info').style.display = '';
} catch(e) { alert('오류: ' + e.message); }
finally { btn.textContent = '검색'; btn.disabled = false; }
}
async function ytStartDownload() {
const btn = document.getElementById('yt-dl-btn');
btn.disabled = true; btn.textContent = '시작 중…';
document.getElementById('yt-progress-wrap').style.display = '';
document.getElementById('yt-result').style.display = 'none';
document.getElementById('yt-progress-bar').style.width = '0%';
document.getElementById('yt-pct-text').textContent = '0%';
document.getElementById('yt-status-text').textContent = '다운로드 중…';
try {
const r = await fetch('/api/music/yt/download', {
method: 'POST', headers: { ...authH(), 'Content-Type': 'application/json' },
body: JSON.stringify({ url: _ytCurrentUrl, format: _ytFmt })
});
const d = await r.json();
if (!r.ok) { alert(d.error || '오류'); btn.disabled = false; btn.textContent = '⬇ 다운로드 시작'; return; }
_ytCurrentJobId = d.jobId;
const es = new EventSource(`/api/music/yt/${d.jobId}/stream`);
es.onmessage = e => {
const ev = JSON.parse(e.data);
if (ev.progress !== undefined) {
document.getElementById('yt-progress-bar').style.width = ev.progress + '%';
document.getElementById('yt-pct-text').textContent = ev.progress + '%';
}
if (ev.status === 'done') {
es.close();
document.getElementById('yt-progress-wrap').style.display = 'none';
document.getElementById('yt-result').style.display = '';
const dlUrl = `/api/music/yt/${d.jobId}/file`;
document.getElementById('yt-result-body').innerHTML = `
<audio controls src="${dlUrl}" style="width:100%;margin-bottom:10px;"></audio>
<div style="display:flex;gap:8px;">
<a href="${dlUrl}" download style="flex:1;text-align:center;padding:8px;background:var(--studio-surface);border:1px solid var(--studio-border2);color:var(--studio-text);border-radius:8px;font-size:12px;font-weight:700;text-decoration:none;">⬇ 저장</a>
<button onclick="ytSendToSep('${dlUrl}','${ev.fileName||''}')" style="flex:1;padding:8px;background:rgba(168,85,247,.15);border:1px solid #a855f7;color:#a855f7;border-radius:8px;font-size:12px;font-weight:700;cursor:pointer;">🎙 보컬 분리</button>
</div>`;
loadMidiFiles();
btn.disabled = false; btn.textContent = '⬇ 다운로드 시작';
}
if (ev.status === 'error') {
es.close();
document.getElementById('yt-status-text').textContent = '오류: ' + (ev.error || '알 수 없음');
document.getElementById('yt-pct-text').textContent = '❌';
btn.disabled = false; btn.textContent = '⬇ 다운로드 시작';
}
};
es.onerror = () => { es.close(); document.getElementById('yt-status-text').textContent = '연결 오류'; btn.disabled = false; btn.textContent = '⬇ 다운로드 시작'; };
} catch(e) { alert('오류: ' + e.message); btn.disabled = false; btn.textContent = '⬇ 다운로드 시작'; }
}
async function ytSendToSep(url, fileName) {
// Fetch the file and send it to the vocal separator
document.getElementById('yt-status-text') && (document.getElementById('yt-status-text').textContent = '보컬 분리로 전송 중…');
try {
const r = await fetch(url, { headers: authH() });
const blob = await r.blob();
const file = new File([blob], fileName || 'audio.mp3', { type: blob.type });
showSepPanel(true);
setTimeout(() => sepHandleFile(file), 100);
} catch(e) { alert('오류: ' + e.message); }
}
// ── Vocal separator ──────────────────────────────────────────────────────────
let _sepMode = '2';
let _sepModel = 'htdemucs';
function sepBack() {
if (document.getElementById('sep-view-files').style.display !== 'none') {
setSepTab('new');
} else {
showSepPanel(false);
}
}
function setSepTab(tab) {
const isNew = tab === 'new';
document.getElementById('sep-view-new').style.display = isNew ? 'flex' : 'none';
document.getElementById('sep-view-files').style.display = isNew ? 'none' : 'block';
const btnNew = document.getElementById('sep-tab-new');
const btnFiles = document.getElementById('sep-tab-files');
if (isNew) {
btnNew.style.cssText = 'background:rgba(168,85,247,.15);border-color:#a855f7;color:#a855f7;';
btnFiles.style.cssText = '';
} else {
btnFiles.style.cssText = 'background:rgba(168,85,247,.15);border-color:#a855f7;color:#a855f7;';
btnNew.style.cssText = '';
loadSepJobs();
}
}
const stemLabelsMap = { vocals:'🎤 보컬', no_vocals:'🎸 반주', drums:'🥁 드럼', bass:'🎸 베이스', other:'🎹 기타/건반', guitar:'🎸 기타', piano:'🎹 피아노' };
async function loadSepJobs() {
const list = document.getElementById('sep-job-list');
list.innerHTML = '<div style="text-align:center;color:var(--studio-muted);font-size:12px;padding:40px 0;">불러오는 중…</div>';
try {
const r = await fetch('/api/music/separate/jobs', { headers: authH() });
const jobs = await r.json();
if (!jobs.length) {
list.innerHTML = '<div style="text-align:center;color:var(--studio-muted);font-size:12px;padding:40px 0;">저장된 작업이 없습니다</div>';
return;
}
list.innerHTML = jobs.map(j => {
const date = j.createdAt ? new Date(j.createdAt).toLocaleString('ko-KR', {month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
const stemsHtml = (j.stems || []).map(s => {
const key = s.replace(/\.[^.]+$/, '');
const label = stemLabelsMap[key] || ('🎵 ' + key);
const url = `/api/music/separate/${j.jobId}/${encodeURIComponent(s)}`;
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--studio-border);">
<span style="font-size:11px;min-width:80px;color:var(--studio-muted)">${label}</span>
<audio controls src="${url}" style="height:28px;flex:1;min-width:0;"></audio>
<a href="${url}" download style="flex-shrink:0;background:var(--studio-surface);border:1px solid var(--studio-border2);color:var(--studio-text);border-radius:6px;padding:3px 8px;font-size:10px;font-weight:700;cursor:pointer;text-decoration:none;">⬇</a>
</div>`;
}).join('');
return `<div style="background:var(--studio-panel2);border:1px solid var(--studio-border2);border-radius:12px;padding:12px 14px;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px;">
<div>
<div style="font-size:13px;font-weight:700;color:var(--studio-text)">${escHtml(j.songName||'untitled')}</div>
<div style="font-size:10px;color:var(--studio-muted);margin-top:2px">${j.model||''} · ${date}</div>
</div>
<button onclick="deleteSepJob('${j.jobId}')" style="background:transparent;border:1px solid var(--studio-border2);color:var(--studio-muted);border-radius:6px;padding:3px 8px;font-size:11px;cursor:pointer;">🗑</button>
</div>
${stemsHtml}
</div>`;
}).join('');
} catch(e) {
list.innerHTML = `<div style="text-align:center;color:#f87171;font-size:12px;padding:20px">오류: ${e.message}</div>`;
}
}
async function deleteSepJob(jobId) {
if (!confirm('이 작업을 삭제할까요?')) return;
await fetch(`/api/music/separate/${jobId}`, { method:'DELETE', headers: authH() });
loadSepJobs();
}
function setSepModel(model) {
_sepModel = model;
document.querySelectorAll('.sep-model-opt').forEach(el => {
el.classList.toggle('sep-model-active', el.dataset.model === model);
});
// htdemucs_6s: 스템 모드 선택 숨기고 항상 multi-stem
const is6s = model === 'htdemucs_6s';
document.getElementById('sep-stem-mode-wrap').style.display = is6s ? 'none' : '';
if (is6s) _sepMode = '4';
}
function setSepMode(mode) {
_sepMode = mode;
document.getElementById('sep-opt-2').style.cssText = mode === '2'
? 'flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid #a855f7;border-radius:10px;cursor:pointer;background:rgba(168,85,247,.1);'
: 'flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid var(--studio-border2);border-radius:10px;cursor:pointer;';
document.getElementById('sep-opt-4').style.cssText = mode === '4'
? 'flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid #a855f7;border-radius:10px;cursor:pointer;background:rgba(168,85,247,.1);'
: 'flex:1;display:flex;align-items:center;gap:8px;padding:10px 14px;border:2px solid var(--studio-border2);border-radius:10px;cursor:pointer;';
document.querySelector(`input[name="sep-mode"][value="${mode}"]`).checked = true;
}
function showSepPanel(show) {
hideAllPanels();
document.getElementById('sep-panel').style.display = show ? 'flex' : 'none';
document.getElementById('stage-player').style.display = show ? 'none' : 'flex';
if (!show) return;
// reset UI
document.getElementById('sep-progress-wrap').style.display = 'none';
document.getElementById('sep-results').style.display = 'none';
document.getElementById('sep-dropzone').style.display = '';
document.getElementById('sep-file-input').value = '';
setSepMode('2');
setSepModel('htdemucs');
setSepTab('new');
}
function sepHandleFile(file) {
if (!file) return;
const allowed = /\.(mp3|wav|flac|m4a|ogg)$/i;
if (!allowed.test(file.name)) { alert('MP3/WAV/FLAC/M4A/OGG 파일만 지원합니다'); return; }
if (file.size > 200 * 1024 * 1024) { alert('파일이 200MB를 초과합니다'); return; }
document.getElementById('sep-dropzone').style.display = 'none';
document.getElementById('sep-progress-wrap').style.display = '';
document.getElementById('sep-results').style.display = 'none';
document.getElementById('sep-progress-bar').style.width = '0%';
document.getElementById('sep-pct-text').textContent = '0%';
const stemCount = _sepModel === 'htdemucs_6s' ? '6' : _sepMode === '4' ? '4' : '2';
const modeLabel = `${_sepModel} · ${stemCount}-stem`;
document.getElementById('sep-status-text').textContent = `"${file.name}" 업로드 중… [${modeLabel}]`;
const fd = new FormData();
fd.append('file', file);
fd.append('stems', _sepMode);
fd.append('model', _sepModel);
fetch('/api/music/separate', { method: 'POST', headers: authH(), body: fd })
.then(r => r.ok ? r.json() : r.json().then(e => Promise.reject(e.error || '오류')))
.then(({ jobId }) => {
const estTime = _sepMode === '4' ? '10~15분' : '3~5분';
document.getElementById('sep-status-text').textContent = `분리 중… [${modeLabel}] CPU 모드 약 ${estTime} 소요`;
const es = new EventSource(`/api/music/separate/${jobId}/stream`);
es.onmessage = e => {
const d = JSON.parse(e.data);
if (d.progress !== undefined) {
document.getElementById('sep-progress-bar').style.width = d.progress + '%';
document.getElementById('sep-pct-text').textContent = d.progress + '%';
}
if (d.status === 'done') {
es.close();
document.getElementById('sep-progress-wrap').style.display = 'none';
const res = document.getElementById('sep-results');
res.style.display = 'flex';
const list = document.getElementById('sep-stem-list');
list.innerHTML = '';
const stemLabels = { vocals:'🎤 보컬', no_vocals:'🎸 반주 (Instrumental)', drums:'🥁 드럼', bass:'🎸 베이스', other:'🎹 기타/건반', guitar:'🎸 기타', piano:'🎹 피아노' };
(d.stems || []).forEach(stem => {
const key = stem.replace(/\.[^.]+$/, '');
const label = stemLabels[key] || ('🎵 ' + key);
const url = `/api/music/separate/${jobId}/${encodeURIComponent(stem)}`;
list.innerHTML += `<div style="display:flex;align-items:center;gap:10px;padding:10px 14px;background:var(--studio-panel2);border:1px solid var(--studio-border2);border-radius:10px;">
<span style="font-size:13px;flex:1;color:var(--studio-text)">${label}</span>
<audio controls src="${url}" style="height:32px;min-width:0;flex:1;max-width:200px"></audio>
<a href="${url}" download style="background:var(--studio-accent2);color:#fff;border:none;border-radius:7px;padding:5px 12px;font-size:11px;font-weight:700;cursor:pointer;text-decoration:none;">⬇ 다운로드</a>
</div>`;
});
}
if (d.status === 'error') {
es.close();
document.getElementById('sep-status-text').textContent = '오류: ' + (d.error || '알 수 없는 오류');
document.getElementById('sep-pct-text').textContent = '❌';
}
};
es.onerror = () => { es.close(); document.getElementById('sep-status-text').textContent = '연결 오류'; };
})
.catch(err => {
document.getElementById('sep-status-text').textContent = '업로드 오류: ' + err;
document.getElementById('sep-pct-text').textContent = '❌';
});
}
// ── Mobile ───────────────────────────────────────────────────────────────────
function mobCloseAll(){
document.querySelector('.ms-sb').classList.remove('mob-open');
document.querySelector('.ms-chat').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
['files','ai'].forEach(t=>document.getElementById('mobtab-'+t)?.classList.remove('active'));
document.getElementById('mobtab-stage').classList.add('active');
}
function mobToggle(which){
if(window.innerWidth>1024)return;
const filesOpen=document.querySelector('.ms-sb').classList.contains('mob-open');
const aiOpen=document.querySelector('.ms-chat').classList.contains('mob-open');
if(which==='files'&&filesOpen){mobCloseAll();return;}
if(which==='ai'&&aiOpen){mobCloseAll();return;}
mobCloseAll();
if(which==='files'){
document.querySelector('.ms-sb').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
document.getElementById('mobtab-files').classList.add('active');
document.getElementById('mobtab-stage').classList.remove('active');
} else {
document.querySelector('.ms-chat').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
document.getElementById('mobtab-ai').classList.add('active');
document.getElementById('mobtab-stage').classList.remove('active');
}
}
async function sendMessage() {
const input = document.getElementById('chat-input');
const msg = input.value.trim();
if (!msg || document.getElementById('send-btn').disabled) return;
if(window.innerWidth<=1024)mobToggle('ai');
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;
}
}
async function clearChat() {
if (!confirm('대화를 지울까요?')) return;
document.getElementById('chat-msgs').innerHTML = '';
_renderedMidiIds.clear();
_sessionId = 'music_' + Math.random().toString(36).slice(2,12);
try { await fetch('/api/user/app-sessions', {method:'PUT', headers: authH({'Content-Type':'application/json'}), body: JSON.stringify({appKey: APP_KEY, sessionId: _sessionId})}); } 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);
const rv=p=>{let v=0,b;do{b=u8[p++];v=(v<<7)|(b&0x7F);}while(b&0x80);return{v,p};};
// Pass 1: collect events with absolute tick + global tempo map.
// (Type-1 MIDI: each track's tick counter is absolute from song start, so a
// single global tempo map keyed by tick is valid across all tracks.)
const ev=[],tmap=[{tk:0,tp:500000}];
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;
if(ty===0x90||ty===0x80){const n=u8[p++],vl=u8[p++];ev.push({tk,msg:[ty|ch,n,vl]});}
else if(ty===0xA0||ty===0xB0||ty===0xE0){ev.push({tk,msg:[b,u8[p],u8[p+1]]});p+=2;}
else if(ty===0xC0){ev.push({tk,msg:[0xC0|ch,u8[p++]]});}
else if(ty===0xD0){ev.push({tk,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){tmap.push({tk,tp:(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;
}
// Pass 2: integrate the tempo map to convert ticks → ms (handles tempo changes
// correctly; the old single-tempo formula broke songs with rit./accel.).
tmap.sort((a,b)=>a.tk-b.tk);
ev.sort((a,b)=>a.tk-b.tk);
let mi=0,ms=0,lastTk=0,cur=tmap[0].tp;
for(const e of ev){
while(mi+1<tmap.length&&tmap[mi+1].tk<=e.tk){
const nx=tmap[mi+1];ms+=(nx.tk-lastTk)*cur/tpq/1000;lastTk=nx.tk;cur=nx.tp;mi++;
}
ms+=(e.tk-lastTk)*cur/tpq/1000;lastTk=e.tk;e.ms=ms;
}
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="playMidiInternally(\''+url+'\',\''+escAttr(dl||'')+'\')">▶ 재생</button>';
h+='<button class="music-dl-btn" onclick="midiRouterToggle(\''+rid+'\')" title="외부 MIDI 하드웨어 신디">🎧 외부신디</button>';
h+='<button class="music-dl-btn btn-purple" onclick="openEditor(\''+url+'\',\''+escAttr(dl||'')+'\')" >🎹 편집</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;
}
async function playMidiInternally(url, filename) {
try {
addChatMsg('system', '🎵 "' + (filename || 'MIDI') + '" 로딩 중…');
gpStageMode = 'midi';
showStageGp();
document.getElementById('gp-title').textContent = '🎵 ' + (filename || 'MIDI');
document.getElementById('gp-track-list').innerHTML = '';
document.getElementById('gp-main').innerHTML = '';
document.getElementById('gp-play-btn').textContent = '▶ 재생';
midiSfGpStatus('초기화 중...');
const [, midiResp] = await Promise.all([
_ensureSpessa(),
fetch(url, { headers: authH() })
]);
if (!midiResp.ok) throw new Error('HTTP ' + midiResp.status);
const midiBuf = await midiResp.arrayBuffer();
midiSfGpStatus('MIDI 파싱 중...');
const { BasicMIDI } = window.SpessaSynth;
const midi = BasicMIDI.fromArrayBuffer(midiBuf, filename || 'track.mid');
if (_spessaCtx && _spessaCtx.state === 'suspended') await _spessaCtx.resume();
_spessaSeq.loadNewSongList([midi]);
_spessaSeq.eventHandler.addEvent('songEnded', 'ui', () => {
_midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('재생 완료');
});
_midiPlaying = true;
_spessaSeq.play();
_setMidiBtn(true);
} catch(e) {
addChatMsg('system', '⚠️ MIDI 재생 오류: ' + e.message);
console.error('playMidiInternally:', e);
}
}
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="playMidiInternally(\''+url+'\',\''+escAttr(filename)+'\')">▶ 재생</button><button class="music-dl-btn" onclick="midiRouterToggle(\''+rid+'\')" title="외부 MIDI 하드웨어 신디">🎧 외부신디</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();}
// ════════════════════════════════════════════════════════════════════════════
// Piano Roll (MIDI Editor — inline)
// ════════════════════════════════════════════════════════════════════════════
const KEY_W = 56, RULE_H = 24, VEL_H = 70;
const COLORS = ['#e06c75','#98c379','#61afef','#c678dd','#e5c07b','#56b6c2','#d19a66','#be5046','#a6e3a1','#89dceb','#cba6f7','#f9e2af','#74c7ec','#fab387'];
const GM_NAMES = ['Piano','Bright Piano','E.Grand','Honky-Tonk','E.Piano1','E.Piano2','Harpsichord','Clavinet','Celesta','Glockenspiel','Music Box','Vibraphone','Marimba','Xylophone','Tubular Bells','Dulcimer','Hammond Organ','Perc.Organ','Rock Organ','Church Organ','Reed Organ','Accordion','Harmonica','Bandoneon','Nylon Guitar','Steel Guitar','Jazz Guitar','Clean Guitar','Muted Guitar','Overd.Guitar','Dist.Guitar','Harmonics','Acoustic Bass','Finger Bass','Pick Bass','Fretless Bass','Slap Bass1','Slap Bass2','Synth Bass1','Synth Bass2','Violin','Viola','Cello','Contrabass','Tremolo Str','Pizzicato','Harp','Timpani','Str Ensemble1','Str Ensemble2','Synth Str1','Synth Str2','Choir Aahs','Voice Oohs','Synth Choir','Orch.Hit','Trumpet','Trombone','Tuba','Muted Trumpet','French Horn','Brass Section','Synth Brass1','Synth Brass2','Soprano Sax','Alto Sax','Tenor Sax','Baritone Sax','Oboe','English Horn','Bassoon','Clarinet','Piccolo','Flute','Recorder','Pan Flute','Bottle Blow','Shakuhachi','Whistle','Ocarina','Square Lead','Sawtooth Lead','Calliope Lead','Chiffer Lead','Charang Lead','Solo Vox','5th Sawtooth','Bass+Lead','Fantasia','Warm Pad','Polysynth','Choir Pad','Bowed Pad','Metallic Pad','Halo Pad','Sweep Pad','Rain','Soundtrack','Crystal','Atmosphere','Brightness','Goblins','Echoes','Sci-Fi','Sitar','Banjo','Shamisen','Koto','Kalimba','Bag Pipe','Fiddle','Shanai','Tinkle Bell','Agogo','Steel Drums','Woodblock','Taiko Drum','Melodic Tom','Synth Drum','Rev.Cymbal','Guitar Fret','Breath Noise','Seashore','Bird Tweet','Telephone','Helicopter','Applause','Gunshot'];
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
const BLACK_K = new Set([1,3,6,8,10]);
let midi = mkMidi();
let view = { scrollX:0, scrollY:57*8, pxPerTick:0.15, noteH:8 };
let tool = 'sel', qTicks = 120, activeTi = 0;
let sel = new Set(), hist = [], histIdx = -1, drag = null;
let ph = 0, playing = false;
let meMidiAccess = null, meMidiOut = null;
let sfMode = true;
let _sfLoopEn = false, _sfLoopIn = 0, _sfLoopOut = 0;
let animId = null, playT0 = 0, playTick0 = 0, nextSchedTick = 0;
const SCHED_AHEAD_MS = 300;
let velDrag = null, clip = [];
function mkMidi() {
return { format:1, tpq:480, tracks:[], tempos:[{tick:0,uspb:500000}], timeSigs:[{tick:0,num:4,den:4}] };
}
// Canvases — resolved lazily when the editor stage is first visible
let rulerC, keysC, rollC, velC, rCtx, kCtx, pCtx, vCtx;
function meInitCanvases() {
if (rulerC) return;
rulerC = document.getElementById('ruler-canvas');
keysC = document.getElementById('keys-canvas');
rollC = document.getElementById('roll-canvas');
velC = document.getElementById('vel-canvas');
rCtx = rulerC.getContext('2d');
kCtx = keysC.getContext('2d');
pCtx = rollC.getContext('2d');
vCtx = velC.getContext('2d');
meBindRollEvents();
}
function meZoomToFitBars(bars) {
if (!rollC || !rollC.width) return;
const barTicks = (midi.tpq || 480) * (midi.timeSigs[0]?.num || 4);
view.pxPerTick = Math.max(0.02, Math.min(4, rollC.width / (barTicks * bars)));
}
function resize() {
const isFirst = !rulerC;
meInitCanvases();
const wrap = document.getElementById('roll-wrap');
const rr = document.getElementById('roll-row');
const W = wrap.clientWidth - KEY_W;
const H = rr.clientHeight;
rulerC.width = W; rulerC.height = RULE_H;
keysC.height = H;
rollC.width = W; rollC.height = H;
velC.width = W; velC.height = VEL_H;
meResizeWave();
if (isFirst && W > 0) meZoomToFitBars(8);
renderAll();
}
window.addEventListener('resize', () => {
if (document.getElementById('stage-editor')?.classList.contains('visible')) resize();
});
// MIDI Parser
function parseMidi(buf) {
const u8=new Uint8Array(buf),dv=new DataView(buf);
if(dv.getUint32(0)!==0x4D546864) throw new Error('Not a MIDI file');
const format=dv.getUint16(8),ntrk=dv.getUint16(10),tpq=dv.getUint16(12);
const res={format,tpq,tracks:[],tempos:[],timeSigs:[]};
const readVL=(pos)=>{let val=0,b;do{b=u8[pos++];val=(val<<7)|(b&0x7F);}while(b&0x80);return{val,pos};};
let p=14;
for(let t=0;t<ntrk;t++){
if(p+8>u8.length||dv.getUint32(p)!==0x4D54726B) break;
const tlen=dv.getUint32(p+4);p+=8;const end=p+tlen;
let tick=0,rs=0;const open={},notes=[],pcs=[],ccs=[];let name=`Track ${t+1}`,ch=-1;
while(p<end){
const dl=readVL(p);p=dl.pos;tick+=dl.val;
let b=u8[p];if(b&0x80){rs=b;p++;}else{b=rs;}
const typ=b&0xF0,c=b&0x0F;
if(typ===0x90){const n=u8[p++],v=u8[p++];if(ch<0)ch=c;if(v>0)open[`${c}_${n}`]={tick,vel:v,ch:c};else{const o=open[`${c}_${n}`];if(o){notes.push({tick:o.tick,dur:Math.max(1,tick-o.tick),pitch:n,vel:o.vel,ch:o.ch});delete open[`${c}_${n}`];}}}
else if(typ===0x80){const n=u8[p++];p++;if(ch<0)ch=c;const o=open[`${c}_${n}`];if(o){notes.push({tick:o.tick,dur:Math.max(1,tick-o.tick),pitch:n,vel:o.vel,ch:o.ch});delete open[`${c}_${n}`];}}
else if(typ===0xA0){p+=2;}else if(typ===0xB0){const cc=u8[p++],val=u8[p++];ccs.push({tick,cc,val,ch:c});}
else if(typ===0xC0){pcs.push({tick,prog:u8[p++],ch:c});if(ch<0)ch=c;}
else if(typ===0xD0){p++;}else if(typ===0xE0){p+=2;}
else if(b===0xFF){const mt=u8[p++];const ll=readVL(p);p=ll.pos;if(mt===0x03)try{name=new TextDecoder().decode(u8.slice(p,p+ll.val));}catch{}else if(mt===0x51&&ll.val>=3)res.tempos.push({tick,uspb:(u8[p]<<16)|(u8[p+1]<<8)|u8[p+2]});else if(mt===0x58&&ll.val>=4)res.timeSigs.push({tick,num:u8[p],den:1<<u8[p+1]});p+=ll.val;}
else if(b===0xF0||b===0xF7){const ll=readVL(p);p=ll.pos+ll.val;}else{p++;}
p=Math.min(p,end);
}
for(const k in open){const[cc,np]=k.split('_').map(Number);const o=open[k];notes.push({tick:o.tick,dur:tpq,pitch:np,vel:o.vel,ch:cc});}
if(notes.length>0||pcs.length>0){
const chs=[...new Set(notes.map(n=>n.ch))].sort((a,b)=>a-b);
if(chs.length>1){for(const c of chs){const cN=notes.filter(n=>n.ch===c),cP=pcs.filter(pc=>pc.ch===c),cC=ccs.filter(cc=>cc.ch===c);if(cN.length||cP.length)res.tracks.push({name:`${name}/ch${c+1}`,channel:c,notes:cN,pcs:cP,ccs:cC,muted:false,solo:false,color:COLORS[res.tracks.length%COLORS.length]});}}
else res.tracks.push({name,channel:ch>=0?ch:t%16,notes,pcs,ccs,muted:false,solo:false,color:COLORS[res.tracks.length%COLORS.length]});
}
p=end;
}
if(!res.tempos.length)res.tempos.push({tick:0,uspb:500000});
if(!res.timeSigs.length)res.timeSigs.push({tick:0,num:4,den:4});
return res;
}
// MIDI Writer
function writeVL(n){if(n===0)return[0];const b=[];while(n>0){b.unshift(n&0x7F);n>>>=7;}for(let i=0;i<b.length-1;i++)b[i]|=0x80;return b;}
function writeMidi(state){
const trks=[];
const tevs=[];
for(const ts of [...state.timeSigs].sort((a,b)=>a.tick-b.tick))tevs.push({tick:ts.tick,bytes:[0xFF,0x58,0x04,ts.num,Math.round(Math.log2(ts.den)),24,8]});
for(const t of [...state.tempos].sort((a,b)=>a.tick-b.tick))tevs.push({tick:t.tick,bytes:[0xFF,0x51,0x03,(t.uspb>>16)&0xFF,(t.uspb>>8)&0xFF,t.uspb&0xFF]});
tevs.sort((a,b)=>a.tick-b.tick);
const tb=[];let pv=0;for(const e of tevs){tb.push(...writeVL(e.tick-pv),...e.bytes);pv=e.tick;}
tb.push(0x00,0xFF,0x2F,0x00);trks.push(tb);
for(const trk of state.tracks){
const evs=[];
for(const pc of(trk.pcs||[])){const pc_c=(pc.ch??trk.channel);evs.push({tick:pc.tick,bytes:[0xC0|pc_c,pc.prog]});}
for(const cc of(trk.ccs||[])){const cc_c=(cc.ch??trk.channel);evs.push({tick:cc.tick,bytes:[0xB0|cc_c,cc.cc,cc.val]});}
for(const n of trk.notes){const nc=(n.ch??trk.channel);evs.push({tick:n.tick,bytes:[0x90|nc,n.pitch,n.vel]});evs.push({tick:n.tick+n.dur,bytes:[0x80|nc,n.pitch,0]});}
evs.sort((a,b)=>a.tick-b.tick);
const bytes=[];pv=0;for(const e of evs){bytes.push(...writeVL(e.tick-pv),...e.bytes);pv=e.tick;}
bytes.push(0x00,0xFF,0x2F,0x00);trks.push(bytes);
}
let total=14;for(const t of trks)total+=8+t.length;
const buf=new ArrayBuffer(total),dv=new DataView(buf),u8=new Uint8Array(buf);let p=0;
dv.setUint32(p,0x4D546864);p+=4;dv.setUint32(p,6);p+=4;
dv.setUint16(p,1);p+=2;dv.setUint16(p,trks.length);p+=2;dv.setUint16(p,state.tpq);p+=2;
for(const t of trks){dv.setUint32(p,0x4D54726B);p+=4;dv.setUint32(p,t.length);p+=4;u8.set(t,p);p+=t.length;}
return buf;
}
// Coordinate helpers
const tX = tick => tick*view.pxPerTick - view.scrollX;
const pY = pitch => (127-pitch)*view.noteH - view.scrollY;
const xT = x => (x+view.scrollX)/view.pxPerTick;
const yP = y => 127-Math.floor((y+view.scrollY)/view.noteH);
const qz = tick => qTicks>0?Math.round(tick/qTicks)*qTicks:Math.round(tick);
const bpm = () => Math.round(60000000/(midi.tempos[0]?.uspb||500000));
const mspT= () => (midi.tempos[0]?.uspb||500000)/midi.tpq/1000;
// Rendering
function renderAll(){renderRuler();renderKeys();renderRoll();renderVel();meRenderWave();}
function renderRuler(){
if(!rulerC)return;
const W=rulerC.width,H=RULE_H;
rCtx.fillStyle='#1f2032';rCtx.fillRect(0,0,W,H);
const tpb=midi.tpq*(midi.timeSigs[0]?.num||4);
const startBar=Math.floor(view.scrollX/(tpb*view.pxPerTick));
const endBar=startBar+Math.ceil(W/(tpb*view.pxPerTick))+2;
rCtx.font='10px monospace';rCtx.textBaseline='middle';
for(let bar=startBar;bar<=endBar;bar++){
const x=bar*tpb*view.pxPerTick-view.scrollX;
rCtx.strokeStyle='#3e4060';rCtx.lineWidth=1;rCtx.beginPath();rCtx.moveTo(x+.5,0);rCtx.lineTo(x+.5,H);rCtx.stroke();
if(x>=0&&x<W){rCtx.fillStyle='#9497b8';rCtx.fillText(bar+1,x+3,H/2);}
if(tpb*view.pxPerTick>40){for(let b=1;b<(midi.timeSigs[0]?.num||4);b++){const bx=x+b*midi.tpq*view.pxPerTick;rCtx.strokeStyle='#2e3050';rCtx.lineWidth=.5;rCtx.beginPath();rCtx.moveTo(bx+.5,H/2);rCtx.lineTo(bx+.5,H);rCtx.stroke();}}
}
const phx=tX(ph);if(phx>=0&&phx<=W){rCtx.fillStyle='#f38ba8';rCtx.fillRect(phx-1,0,2,H);}
}
function renderKeys(){
if(!keysC)return;
const W=KEY_W,H=keysC.height;
kCtx.fillStyle='#1f2032';kCtx.fillRect(0,0,W,H);
for(let p=0;p<=127;p++){const y=pY(p);if(y+view.noteH<0||y>H)continue;const isB=BLACK_K.has(p%12),isC=p%12===0;kCtx.fillStyle=isB?'#282940':(isC?'#3a3c60':'#e8e8f2');kCtx.fillRect(0,y,W-(isB?14:0),view.noteH);kCtx.strokeStyle='#3e4060';kCtx.lineWidth=.5;kCtx.strokeRect(.5,y+.5,W-(isB?14:0)-1,view.noteH-1);if(!isB&&view.noteH>=7){kCtx.fillStyle=isC?'#a78bfa':'#6e7096';kCtx.font=`${Math.min(9,view.noteH-1)}px monospace`;kCtx.textAlign='right';kCtx.textBaseline='middle';kCtx.fillText(NOTE_NAMES[p%12]+(isC?Math.floor(p/12)-1:''),W-16,y+view.noteH/2);}}
}
function renderRoll(){
if(!rollC)return;
const W=rollC.width,H=rollC.height;
pCtx.fillStyle='#222338';pCtx.fillRect(0,0,W,H);
const nh=view.noteH;
for(let p=127;p>=0;p--){const y=pY(p);if(y+nh<0||y>H)continue;const isB=BLACK_K.has(p%12),isC=p%12===0;pCtx.fillStyle=isB?'#1c1d30':(isC?'#242448':'#222338');pCtx.fillRect(0,y,W,nh);if(isC){pCtx.strokeStyle='#303268';pCtx.lineWidth=.5;pCtx.beginPath();pCtx.moveTo(0,y+.5);pCtx.lineTo(W,y+.5);pCtx.stroke();}}
const tpb=midi.tpq*(midi.timeSigs[0]?.num||4);
const s0=Math.floor(view.scrollX/(tpb*view.pxPerTick));
const s1=s0+Math.ceil(W/(tpb*view.pxPerTick))+2;
for(let bar=s0;bar<=s1;bar++){const x=bar*tpb*view.pxPerTick-view.scrollX;pCtx.strokeStyle='#333558';pCtx.lineWidth=1;pCtx.beginPath();pCtx.moveTo(x+.5,0);pCtx.lineTo(x+.5,H);pCtx.stroke();if(view.pxPerTick*midi.tpq>16){for(let b=1;b<(midi.timeSigs[0]?.num||4);b++){const bx=x+b*midi.tpq*view.pxPerTick;pCtx.strokeStyle='#292a44';pCtx.lineWidth=.5;pCtx.beginPath();pCtx.moveTo(bx+.5,0);pCtx.lineTo(bx+.5,H);pCtx.stroke();}}}
if(view.pxPerTick*midi.tpq>60){const subT=midi.tpq/4;const subS=Math.floor(view.scrollX/(subT*view.pxPerTick));const subE=subS+Math.ceil(W/(subT*view.pxPerTick))+2;for(let i=subS;i<=subE;i++){if(i%(4*(midi.timeSigs[0]?.num||4))===0)continue;if(i%4===0)continue;const x=i*subT*view.pxPerTick-view.scrollX;pCtx.strokeStyle='#252640';pCtx.lineWidth=.5;pCtx.beginPath();pCtx.moveTo(x+.5,0);pCtx.lineTo(x+.5,H);pCtx.stroke();}}
const anySolo=midi.tracks.some(t=>t.solo);
for(let ti=0;ti<midi.tracks.length;ti++){const trk=midi.tracks[ti];if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){const x=tX(n.tick),w=Math.max(2,n.dur*view.pxPerTick-1),y=pY(n.pitch);if(x+w<0||x>W||y+nh<0||y>H)continue;const isSel=sel.has(n);pCtx.fillStyle=isSel?'#fff':trk.color;pCtx.fillRect(x,y+1,w,nh-2);if(isSel){pCtx.strokeStyle=trk.color;pCtx.lineWidth=1;pCtx.strokeRect(x+.5,y+1.5,w-1,nh-3);}else if(w>5){pCtx.fillStyle='rgba(0,0,0,.25)';pCtx.fillRect(x+w-3,y+1,3,nh-2);}}}
if(drag?.type==='box'){const{x0,y0,x1,y1}=drag;pCtx.strokeStyle='#89b4fa';pCtx.lineWidth=1;pCtx.setLineDash([4,2]);pCtx.strokeRect(Math.min(x0,x1),Math.min(y0,y1),Math.abs(x1-x0),Math.abs(y1-y0));pCtx.setLineDash([]);}
const phx=tX(ph);if(phx>=0&&phx<=W){pCtx.fillStyle='#f38ba8';pCtx.fillRect(phx-1,0,2,H);}
}
function renderVel(){
if(!velC)return;
const W=velC.width,H=VEL_H;
vCtx.fillStyle='#191a2c';vCtx.fillRect(0,0,W,H);
vCtx.strokeStyle='#2a2b44';vCtx.lineWidth=.5;
[.25,.5,.75].forEach(f=>{vCtx.beginPath();vCtx.moveTo(0,H*f);vCtx.lineTo(W,H*f);vCtx.stroke();});
const anySolo=midi.tracks.some(t=>t.solo);
for(const trk of midi.tracks){if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){const x=tX(n.tick);if(x<-4||x>W+4)continue;const bH=Math.max(2,Math.round((n.vel/127)*(H-4)));vCtx.fillStyle=sel.has(n)?'#fff':trk.color+'bb';vCtx.fillRect(x-1,H-bH-2,3,bH);}}
}
// Track Panel
function renderTracks(){
const list=document.getElementById('track-list');if(!list)return;
list.innerHTML='';
midi.tracks.forEach((trk,i)=>{
const prog=trk.pcs?.[0]?.prog,progName=prog!==undefined?(trk.channel===9?'Drums':GM_NAMES[prog]||`Prog ${prog}`):'';
const div=document.createElement('div');div.className='ti'+(i===activeTi?' sel':'');div.onclick=()=>{activeTi=i;renderTracks();};
div.innerHTML=`<div class="ti-top"><div class="ti-dot" style="background:${trk.color}" onclick="event.stopPropagation();cycleColor(${i})"></div><div class="ti-name" ondblclick="event.stopPropagation();meRenameMidiTrack(${i})" title="더블클릭으로 이름 변경">${trk.name}</div><div class="ti-ch">ch${trk.channel+1}</div></div><div class="ti-btns"><button class="ti-btn${trk.muted?' m-on':''}" onclick="event.stopPropagation();toggleMute(${i})">M</button><button class="ti-btn${trk.solo?' s-on':''}" onclick="event.stopPropagation();toggleSolo(${i})">S</button><select style="font-size:9px;background:#272840;color:#d8daf0;border:1px solid rgba(255,255,255,.14);border-radius:3px;padding:1px;max-width:70px" onchange="event.stopPropagation();setTrackProg(${i},+this.value)" onclick="event.stopPropagation()">${Array.from({length:128},(_,k)=>`<option value="${k}"${k===(prog??0)?' selected':''}>${k}:${GM_NAMES[k]||k}</option>`).join('')}</select><button class="ti-btn" style="margin-left:auto" onclick="event.stopPropagation();delTrack(${i})">✕</button></div>${progName?`<div class="ti-prog">${progName}</div>`:''}`;
list.appendChild(div);
});
}
function cycleColor(i){const idx=COLORS.indexOf(midi.tracks[i].color);midi.tracks[i].color=COLORS[(idx+1)%COLORS.length];renderAll();renderTracks();}
function toggleMute(i){pushH();midi.tracks[i].muted=!midi.tracks[i].muted;renderAll();renderTracks();sfReloadSong();}
function toggleSolo(i){pushH();midi.tracks[i].solo=!midi.tracks[i].solo;renderAll();renderTracks();sfReloadSong();}
function delTrack(i){pushH();midi.tracks.splice(i,1);if(activeTi>=midi.tracks.length)activeTi=Math.max(0,midi.tracks.length-1);renderAll();renderTracks();}
function setTrackProg(i,prog){pushH();if(!midi.tracks[i].pcs.length)midi.tracks[i].pcs.push({tick:0,prog});else midi.tracks[i].pcs[0].prog=prog;renderTracks();}
function doAddTrack(){pushH();const ch=midi.tracks.length%16;midi.tracks.push({name:`Track ${midi.tracks.length+1}`,channel:ch,notes:[],pcs:[{tick:0,prog:0}],muted:false,solo:false,color:COLORS[midi.tracks.length%COLORS.length]});activeTi=midi.tracks.length-1;renderAll();renderTracks();}
function meRenameMidiTrack(i){const trk=midi.tracks[i];if(!trk)return;const name=prompt('트랙 이름:',trk.name);if(name!==null&&name.trim()){pushH();trk.name=name.trim();renderTracks();}}
// Tools
function setTool(t){tool=t;['sel','draw','erase'].forEach(n=>document.getElementById('t-'+n)?.classList.toggle('active',n===t));}
function setQ(v){qTicks=+v;}
function setBpm(v){pushH();const b=Math.max(20,Math.min(300,v||120));midi.tempos=[{tick:0,uspb:Math.round(60000000/b)}];const el=document.getElementById('bpm-in');if(el)el.value=b;}
function zoomX(f){const mid=view.scrollX+(rollC?.width||0)/2;view.pxPerTick=Math.max(.02,Math.min(4,view.pxPerTick*f));view.scrollX=Math.max(0,mid-(rollC?.width||0)/2);renderAll();}
function zoomY(f){view.noteH=Math.max(3,Math.min(24,Math.round(view.noteH*f)));renderAll();}
// Hit Test
function hitNote(cx,cy){const tick=xT(cx),pitch=yP(cy);const anySolo=midi.tracks.some(t=>t.solo);for(let i=midi.tracks.length-1;i>=0;i--){const trk=midi.tracks[i];if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes)if(n.pitch===pitch&&tick>=n.tick&&tick<n.tick+n.dur)return{n,trk,ti:i};}return null;}
function hitResize(cx,cy){const RZ=6/view.pxPerTick,pitch=yP(cy);const anySolo=midi.tracks.some(t=>t.solo);for(let i=midi.tracks.length-1;i>=0;i--){const trk=midi.tracks[i];if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){if(n.pitch!==pitch)continue;if(Math.abs(xT(cx)-(n.tick+n.dur))<=RZ)return{n,trk,ti:i};}}return null;}
// Mouse Events — bound lazily after canvas init
let mdown=false;
function meBindRollEvents(){
rollC.addEventListener('contextmenu',e=>e.preventDefault());
rollC.addEventListener('mousedown',e=>{
if(e.button!==0&&e.button!==2)return;mdown=true;e.preventDefault();
const cx=e.offsetX,cy=e.offsetY;
if(e.button===2||tool==='erase'){const h=hitNote(cx,cy);if(h){pushH();h.trk.notes.splice(h.trk.notes.indexOf(h.n),1);sel.delete(h.n);renderAll();renderVel();}drag={type:'erase'};return;}
if(tool==='draw'){const pitch=yP(cy);if(pitch<0||pitch>127)return;if(activeTi>=midi.tracks.length)doAddTrack();pushH();const tick=Math.max(0,qz(xT(cx)));const dur=qTicks>0?qTicks:midi.tpq/4;const n={tick,dur,pitch,vel:80};midi.tracks[activeTi].notes.push(n);sel=new Set([n]);drag={type:'draw',n,startTick:tick};renderAll();return;}
const rh=hitResize(cx,cy);if(rh){if(!sel.has(rh.n)){if(!e.shiftKey)sel.clear();sel.add(rh.n);}pushH();drag={type:'resize',n:rh.n,startX:cx,origDur:rh.n.dur,origTick:rh.n.tick};return;}
const h=hitNote(cx,cy);if(h){if(!e.shiftKey&&!sel.has(h.n))sel.clear();sel.add(h.n);pushH();drag={type:'move',startX:cx,startY:cy,origNotes:[...sel].map(n=>({n,tick:n.tick,pitch:n.pitch}))};renderAll();return;}
if(!e.shiftKey)sel.clear();drag={type:'box',x0:cx,y0:cy,x1:cx,y1:cy};renderAll();
});
rollC.addEventListener('mousemove',e=>{
const cx=e.offsetX,cy=e.offsetY;
if(!mdown||!drag){if(tool==='sel'){const rh=hitResize(cx,cy);rollC.style.cursor=rh?'ew-resize':(hitNote(cx,cy)?'grab':'default');}else{rollC.style.cursor=tool==='draw'?'crosshair':'cell';}return;}
if(drag.type==='erase'){const h=hitNote(cx,cy);if(h){h.trk.notes.splice(h.trk.notes.indexOf(h.n),1);sel.delete(h.n);renderAll();renderVel();}return;}
if(drag.type==='draw'){const minDur=Math.max(1,qTicks>0?qTicks:1);const endTick=Math.max(drag.startTick+minDur,qz(xT(cx)));drag.n.dur=endTick-drag.startTick;renderAll();return;}
if(drag.type==='resize'){const minDur=Math.max(1,qTicks>0?qTicks:1);drag.n.dur=Math.max(minDur,qz(xT(cx))-drag.n.tick||minDur);renderAll();renderVel();return;}
if(drag.type==='move'){const dtick=qz(xT(cx))-qz(xT(drag.startX));const dpitch=yP(cy)-yP(drag.startY);for(const{n,tick:ot,pitch:op}of drag.origNotes){n.tick=Math.max(0,ot+dtick);n.pitch=Math.max(0,Math.min(127,op+dpitch));}renderAll();return;}
if(drag.type==='box'){drag.x1=cx;drag.y1=cy;renderRoll();return;}
});
rollC.addEventListener('mouseup',e=>{
mdown=false;
if(drag?.type==='box'){const{x0,y0,x1,y1}=drag;const t0=xT(Math.min(x0,x1)),t1=xT(Math.max(x0,x1));const p0=yP(Math.max(y0,y1)),p1=yP(Math.min(y0,y1));if(!e.shiftKey)sel.clear();for(const trk of midi.tracks)for(const n of trk.notes)if(n.tick<t1&&n.tick+n.dur>t0&&n.pitch>=p0&&n.pitch<=p1)sel.add(n);renderAll();}
drag=null;renderVel();
});
rollC.addEventListener('wheel',e=>{
e.preventDefault();
if(e.ctrlKey){const f=e.deltaY<0?1.2:.8,bt=xT(e.offsetX);view.pxPerTick=Math.max(.02,Math.min(4,view.pxPerTick*f));view.scrollX=Math.max(0,bt*view.pxPerTick-e.offsetX);}
else if(e.shiftKey){view.scrollX=Math.max(0,view.scrollX+e.deltaY);}
else{view.scrollY=Math.max(0,Math.min(127*view.noteH-rollC.height*.5,view.scrollY+e.deltaY));}
renderAll();
},{passive:false});
rulerC.addEventListener('click',e=>{ph=Math.max(0,qz(xT(e.offsetX)));renderRuler();renderRoll();meRenderWave();updPos();if(playing)meStartAllAudio(_tickToSec(ph));});
velC.addEventListener('mousedown',e=>{velDrag={startY:e.offsetY};e.preventDefault();});
velC.addEventListener('mousemove',e=>{
if(!velDrag)return;const x=e.offsetX;const anySolo=midi.tracks.some(t=>t.solo);
for(const trk of midi.tracks){if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){if(Math.abs(tX(n.tick)-x)<4)n.vel=Math.max(1,Math.min(127,Math.round((1-(e.offsetY/(VEL_H-4)))*127)));}}
renderVel();
});
velC.addEventListener('mouseup',()=>{if(velDrag){pushH();velDrag=null;}});
keysC.addEventListener('click',e=>{
const pitch=yP(e.offsetY);if(pitch<0||pitch>127)return;
const ch=midi.tracks[activeTi]?.channel??0;const prog=midi.tracks[activeTi]?.pcs?.[0]?.prog??0;
if(sfMode){sfPreviewNote(ch,prog,pitch,80);return;}
if(!meMidiOut)return;
try{meMidiOut.send(new Uint8Array([0x90|ch,pitch,80]));}catch{}
setTimeout(()=>{try{meMidiOut.send(new Uint8Array([0x80|ch,pitch,0]));}catch{}},300);
});
}
// Undo/Redo
function pushH(){const snap=JSON.stringify(midi);hist=hist.slice(0,histIdx+1);hist.push(snap);if(hist.length>60)hist.shift();histIdx=hist.length-1;}
function doUndo(){if(histIdx<=0)return;histIdx--;midi=JSON.parse(hist[histIdx]);sel.clear();const el=document.getElementById('bpm-in');if(el)el.value=bpm();renderAll();renderTracks();}
function doRedo(){if(histIdx>=hist.length-1)return;histIdx++;midi=JSON.parse(hist[histIdx]);sel.clear();const el=document.getElementById('bpm-in');if(el)el.value=bpm();renderAll();renderTracks();}
// File I/O
function openFileDialog(){document.getElementById('file-in')?.click();}
async function handleOpen(inp){
const f=inp.files[0];if(!f)return;
try{const buf=await f.arrayBuffer();midi=parseMidi(buf);sel.clear();hist=[];histIdx=-1;pushH();const el=document.getElementById('bpm-in');if(el)el.value=bpm();view.scrollX=0;view.scrollY=57*view.noteH;meZoomToFitBars(8);renderAll();renderTracks();meStatus('로드: '+f.name);}catch(e){alert('읽기 실패: '+e.message);}
inp.value='';
}
async function meLoadUrl(url){
try{
const r=await fetch(url,{headers:authH()});if(!r.ok)throw new Error(r.status);
const buf=await r.arrayBuffer();
midi=parseMidi(buf);sel.clear();hist=[];histIdx=-1;pushH();
const el=document.getElementById('bpm-in');if(el)el.value=bpm();
view.scrollX=0;view.scrollY=57*view.noteH;meZoomToFitBars(8);
renderAll();renderTracks();meStatus('로드 완료');
}catch(e){meStatus('로드 실패: '+e.message);}
}
function doSave(){
const buf=writeMidi(midi);
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([buf],{type:'audio/midi'}));
const titleEl=document.getElementById('editor-title');
a.download=((titleEl?.textContent||'').replace('🎹 ','').replace(/\.midi?$/,'')||'edited')+'.mid';
document.body.appendChild(a);a.click();setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000);
meStatus('저장 완료');
}
// MIDI Output
async function meInitMidiOut(){
if(!navigator.requestMIDIAccess)return;
try{meMidiAccess=await navigator.requestMIDIAccess();meMidiAccess.onstatechange=()=>meRefreshMidiOuts();meRefreshMidiOuts();}catch{}
}
function meRefreshMidiOuts(){
if(!meMidiAccess){meInitMidiOut();return;}
const sel=document.getElementById('midi-out-sel');if(!sel)return;
const cur=sel.value;
sel.innerHTML='<option value="__sf2">내장 SF2</option><option value="">-- 없음 --</option>';
meMidiAccess.outputs.forEach((out,id)=>{const o=document.createElement('option');o.value=id;o.textContent=out.name;if(id===cur)o.selected=true;sel.appendChild(o);});
if(cur&&cur!=='__sf2')meMidiOut=meMidiAccess.outputs.get(cur)||null;
meApplyOutSel(cur);
}
function meApplyOutSel(val){if(val==='__sf2'){sfMode=true;meMidiOut=null;}else{sfMode=false;meMidiOut=meMidiAccess?.outputs.get(val)||null;}}
document.addEventListener('change',e=>{if(e.target.id==='midi-out-sel')meApplyOutSel(e.target.value);});
document.addEventListener('focus',e=>{if(e.target.id==='midi-out-sel'&&!meMidiAccess)meInitMidiOut();},true);
// SpessaSynth — shared with music-app
function _tickToSec(tick){const tpq=midi.tpq||480;let sec=0,lastTick=0,uspb=(midi.tempos[0]?.uspb||500000);for(const t of(midi.tempos||[])){if(t.tick>=tick)break;sec+=(Math.min(t.tick,tick)-lastTick)/tpq*(uspb/1e6);lastTick=t.tick;uspb=t.uspb;}sec+=(tick-lastTick)/tpq*(uspb/1e6);return sec;}
function _secToTick(sec){const tpq=midi.tpq||480;let rem=sec,tick=0,uspb=(midi.tempos[0]?.uspb||500000);for(let i=1;i<(midi.tempos||[]).length;i++){const seg=(midi.tempos[i].tick-(midi.tempos[i-1]?.tick||0))/tpq*(uspb/1e6);if(rem<=seg)break;rem-=seg;tick=midi.tempos[i].tick;uspb=midi.tempos[i].uspb;}return tick+rem*tpq/(uspb/1e6);}
function buildSpessaMidi(){
const{BasicMIDI}=window.SpessaSynth;
const anySolo=midi.tracks.some(t=>t.solo);
// Use the editor's own writeMidi on a filtered copy — avoids MIDIBuilder API ambiguity
const filtered={...midi,tracks:midi.tracks.filter(t=>!t.muted&&!(anySolo&&!t.solo))};
return BasicMIDI.fromArrayBuffer(writeMidi(filtered));
}
function sfReloadSong(){
if(!_spessaSeq||!playing||!sfMode)return;
const t=_spessaSeq.currentTime;
_spessaSeq.loadNewSongList([buildSpessaMidi()]);
_spessaSeq.currentTime=t;
_spessaSeq.play();
}
async function sfPlay(){
if(!midi.tracks.length){meStatus('재생할 트랙이 없습니다');return;}
await _ensureSpessa();
if(_spessaCtx?.state==='suspended')await _spessaCtx.resume();
const tpb=midi.tpq*(midi.timeSigs[0]?.num||4);
_sfLoopEn=document.getElementById('loop-en')?.checked||false;
_sfLoopIn=(+(document.getElementById('loop-in')?.value||1)-1)*tpb;
_sfLoopOut=(+(document.getElementById('loop-out')?.value||5))*tpb;
const midiData=buildSpessaMidi();
_spessaSeq.loadNewSongList([midiData]);
_spessaSeq.currentTime=_tickToSec(ph);
if(_sfLoopEn)_spessaSeq.loopCount=-1;else _spessaSeq.loopCount=0;
_spessaSeq.eventHandler.addEvent('songEnded','editor',()=>{playing=false;const btn=document.getElementById('play-btn');if(btn)btn.textContent='▶';if(animId){cancelAnimationFrame(animId);animId=null;}});
_spessaSeq.play();
meStartAllAudio(_tickToSec(ph));
_meStartArmedTracks();
playing=true;
const btn=document.getElementById('play-btn');if(btn)btn.textContent='⏸';
if(animId)cancelAnimationFrame(animId);
animId=requestAnimationFrame(sfLoop);
}
function sfLoop(){
if(!playing||!_spessaSeq)return;
const sec=_spessaSeq.currentTime;
ph=Math.round(_secToTick(sec));
if(_sfLoopEn&&ph>=_sfLoopOut)_spessaSeq.currentTime=_tickToSec(_sfLoopIn);
updPos();
const phx=tX(ph);
if(rollC&&phx>rollC.width*.8)view.scrollX+=rollC.width*.4;
else if(phx<20&&view.scrollX>0)view.scrollX=Math.max(0,view.scrollX-rollC.width*.4);
renderRuler();renderRoll();meRenderWave();
animId=requestAnimationFrame(sfLoop);
}
function sfPreviewNote(ch,prog,pitch,vel){
if(!_spessaSynth){return sfOscPreview(ch,pitch);}
try{if(_spessaCtx?.state==='suspended')_spessaCtx.resume();_spessaSynth.programChange(ch,prog);_spessaSynth.noteOn(ch,pitch,vel);setTimeout(()=>{try{_spessaSynth.noteOff(ch,pitch,0);}catch{}},400);}catch(e){sfOscPreview(ch,pitch);}
}
function sfOscPreview(ch,pitch){
try{const AC=window.AudioContext||window.webkitAudioContext,ctx=new AC();const o=ctx.createOscillator(),g=ctx.createGain();o.type=(ch===9)?'square':'triangle';o.frequency.value=440*Math.pow(2,(pitch-69)/12);const t=ctx.currentTime;g.gain.setValueAtTime(0.0001,t);g.gain.exponentialRampToValueAtTime(0.25,t+0.01);g.gain.exponentialRampToValueAtTime(0.0001,t+0.4);o.connect(g).connect(ctx.destination);o.start(t);o.stop(t+0.42);setTimeout(()=>ctx.close(),700);}catch{}
}
function doTogglePlay(){playing?doStop():doPlay();}
function doRewind(){doStop();ph=0;renderRuler();renderRoll();updPos();}
function doStop(){
playing=false;
const btn=document.getElementById('play-btn');if(btn)btn.textContent='▶';
if(animId){cancelAnimationFrame(animId);animId=null;}
if(sfMode&&_spessaSeq){try{_spessaSeq.pause();}catch{}}
_meStopRecordOnly();
meStopAllAudio();
if(meMidiOut){try{meMidiOut.clear();}catch{}for(let c=0;c<16;c++)try{meMidiOut.send(new Uint8Array([0xB0|c,123,0]));}catch{}}
}
// ── Audio settings ────────────────────────────────────────────────────────────
const meAudioSettings = { deviceId:'', latencyMs:0, echoCancellation:false, noiseSuppression:false, autoGainControl:false };
function _meGetAudioConstraints() {
const a = { echoCancellation: meAudioSettings.echoCancellation, noiseSuppression: meAudioSettings.noiseSuppression, autoGainControl: meAudioSettings.autoGainControl };
if (meAudioSettings.deviceId) a.deviceId = { exact: meAudioSettings.deviceId };
return { audio: a, video: false };
}
function meToggleAudioSettings() {
const p = document.getElementById('audio-settings-panel');
if (!p) return;
const show = p.style.display === 'none';
p.style.display = show ? 'flex' : 'none';
if (show) meEnumerateDevices();
}
async function meMeasureLatency() {
const el = document.getElementById('audio-latency-info');
if (!el) return;
try {
await _ensureSpessa();
const base = Math.round((_spessaCtx.baseLatency || 0) * 1000);
const out = Math.round((_spessaCtx.outputLatency || 0) * 1000);
// 입력 레이턴시 측정: getUserMedia 스트림의 MediaStreamTrack settings
const stream = await navigator.mediaDevices.getUserMedia(_meGetAudioConstraints());
const track = stream.getAudioTracks()[0];
const settings = track.getSettings();
const inLat = Math.round((settings.latency || 0) * 1000);
stream.getTracks().forEach(t => t.stop());
const total = base + out + inLat;
el.textContent = `출력 ${out}ms + 입력 ${inLat}ms + 기본 ${base}ms = ~${total}ms`;
// 측정값을 보정 입력에 자동 입력
const inp = document.getElementById('audio-latency-ms');
if (inp && total > 0) { inp.value = total; meAudioSettings.latencyMs = total; }
} catch(e) { el.textContent = '측정 실패: ' + e.message; }
}
async function meEnumerateDevices() {
try {
const devices = await navigator.mediaDevices.enumerateDevices();
const inputs = devices.filter(d => d.kind === 'audioinput');
const sel = document.getElementById('audio-input-sel');
if (!sel) return;
sel.innerHTML = `<option value="">기본 장치</option>` +
inputs.map((d,i) => `<option value="${d.deviceId}"${d.deviceId===meAudioSettings.deviceId?' selected':''}>${d.label||'마이크 '+(i+1)}</option>`).join('');
} catch(e) { meStatus('장치 목록 오류: '+e.message); }
}
// ── Multi-track audio system ──────────────────────────────────────────────────
let meAudioTracks = [];
let meAudioIdCtr = 0;
const ME_PEAK_RES = 512;
const ME_TRACK_COLORS = ['#a78bfa','#34d399','#60a5fa','#fb923c','#f472b6','#facc15','#e879f9','#2dd4bf'];
let meRecStream = null, meRecorder = null, meRecChunks = [];
let meRecordingTrackId = null, meRecStartTick = 0;
function _meComputePeaks(audioBuf) {
const data = audioBuf.getChannelData(0);
const n = Math.ceil(data.length / ME_PEAK_RES);
const p = new Float32Array(n);
for (let i = 0; i < n; i++) {
let mx = 0, end = Math.min((i+1)*ME_PEAK_RES, data.length);
for (let j = i*ME_PEAK_RES; j < end; j++) { const v=Math.abs(data[j]); if(v>mx) mx=v; }
p[i] = mx;
}
return p;
}
function _meAddTrackObj(name, buf, peaks, startTick) {
const id = 'at' + (++meAudioIdCtr);
meAudioTracks.push({ id, name, buf: buf||null, peaks: peaks||null, startTick: startTick||0, muted:false, vol:0.8, src:null, gain:null, color: ME_TRACK_COLORS[(meAudioIdCtr-1)%ME_TRACK_COLORS.length], armed:false, armedStream:null });
return id;
}
function meRemoveAudioTrack(id) {
const t = meAudioTracks.find(t=>t.id===id);
if (!t) return;
try { t.src?.stop(); } catch{} t.src?.disconnect(); t.gain?.disconnect();
if (t.armedStream) { t.armedStream.getTracks().forEach(tr=>tr.stop()); t.armedStream=null; }
if (meRecordingTrackId === id) _meStopRecordOnly();
meAudioTracks = meAudioTracks.filter(t=>t.id!==id);
meRenderWaveLanes();
}
function meMuteAudioTrack(id) {
const t = meAudioTracks.find(t=>t.id===id);
if (!t) return;
t.muted = !t.muted;
if (t.gain) t.gain.gain.value = t.muted ? 0 : t.vol;
const btn = document.querySelector(`#wl_${id} .wl-hbtn.mute`);
if (btn) btn.classList.toggle('muted', t.muted);
}
function meSetAudioTrackVol(id, v) {
const t = meAudioTracks.find(t=>t.id===id);
if (!t) return;
t.vol = v/100;
if (t.gain && !t.muted) t.gain.gain.value = t.vol;
}
function meRenameAudioTrack(id) {
const t = meAudioTracks.find(t=>t.id===id);
if (!t) return;
const name = prompt('트랙 이름:', t.name);
if (name !== null && name.trim()) {
t.name = name.trim();
const el = document.querySelector(`#wl_${id} .wl-name`);
if (el) el.textContent = t.name;
}
}
// ── Per-track recording ───────────────────────────────────────────────────────
let meRecAnalyser = null, meRecLivePeaks = [], meRecAnimId = null, meRecCtxT0 = 0;
function meTrackRecord(id) {
const t = meAudioTracks.find(t=>t.id===id);
if (!t) return;
// 녹음 중 → 정지
if (meRecordingTrackId === id) { _meStopRecordOnly(); doStop(); return; }
if (meRecordingTrackId) return;
// arm 토글
t.armed = !t.armed;
const btn = document.querySelector(`#wl_${id} .wl-hbtn.rec`);
if (btn) btn.classList.toggle('armed', t.armed);
if (t.armed) {
// 마이크 미리 열기 (하드웨어 워밍업 + getUserMedia 지연 제거)
navigator.mediaDevices.getUserMedia(_meGetAudioConstraints())
.then(stream => {
if (!t.armed && meRecordingTrackId !== id) { stream.getTracks().forEach(tr=>tr.stop()); return; }
t.armedStream = stream;
})
.catch(e => { t.armed=false; if(btn)btn.classList.remove('armed'); meStatus('마이크 권한 오류: '+e.message); });
if (playing) {
// MIDI 재생 중 → arm 없이 즉시 시작
t.armed = false;
if (btn) btn.classList.remove('armed');
_meBeginRecording(id);
} else {
meStatus('⏺ 녹음 준비 — ▶ 재생 시 자동 시작');
}
} else {
// 해제 → 미리 연 스트림 정리
if (t.armedStream) { t.armedStream.getTracks().forEach(tr=>tr.stop()); t.armedStream=null; }
meStatus('녹음 준비 해제');
}
}
async function _meBeginRecording(id) {
const t = meAudioTracks.find(t=>t.id===id);
if (!t) return;
// arm 중 미리 열어둔 스트림 사용, 없으면 새로 요청
if (t?.armedStream) {
meRecStream = t.armedStream; t.armedStream = null;
} else {
try {
meRecStream = await navigator.mediaDevices.getUserMedia(_meGetAudioConstraints());
} catch(e) { meStatus('마이크 권한 오류: ' + e.message); return; }
}
meRecChunks = [];
meRecStartTick = ph - _secToTick(meAudioSettings.latencyMs / 1000);
meRecordingTrackId = id;
const opts = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? { mimeType:'audio/webm;codecs=opus' } : {};
meRecorder = new MediaRecorder(meRecStream, opts);
meRecorder.ondataavailable = e => { if (e.data.size>0) meRecChunks.push(e.data); };
const targetId = id;
meRecorder.onstop = async () => {
if (!meRecChunks.length) return;
meStatus('녹음 처리 중...');
try {
const blob = new Blob(meRecChunks, { type: meRecorder?.mimeType||'audio/webm' });
meRecChunks = [];
await _ensureSpessa();
const buf = await _spessaCtx.decodeAudioData(await blob.arrayBuffer());
const t = meAudioTracks.find(t=>t.id===targetId);
if (t) {
try { t.src?.stop(); } catch{} t.src?.disconnect(); t.gain?.disconnect(); t.src=null; t.gain=null;
t.buf=buf; t.peaks=_meComputePeaks(buf); t.startTick=meRecStartTick;
if (!t.name.startsWith('🎙')) t.name='🎙 '+t.name;
meRenderWaveLanes();
meStatus('녹음 완료 — '+buf.duration.toFixed(1)+'초');
}
} catch(e) { meStatus('녹음 처리 오류: '+e.message); }
};
meRecorder.start(100);
await _ensureSpessa();
meRecCtxT0 = _spessaCtx.currentTime;
meRecLivePeaks = [];
meRecAnalyser = _spessaCtx.createAnalyser();
meRecAnalyser.fftSize = 512;
_spessaCtx.createMediaStreamSource(meRecStream).connect(meRecAnalyser);
_meRecLiveLoop();
const btn = document.querySelector(`#wl_${id} .wl-hbtn.rec`);
if (btn) { btn.textContent='⏹'; btn.classList.add('active'); }
meStatus('🔴 녹음 중...');
}
function _meStartArmedTracks() {
for (const t of meAudioTracks) {
if (t.armed) { t.armed=false; _meBeginRecording(t.id); break; }
}
}
function _meRecLiveLoop() {
if (!meRecordingTrackId || !meRecAnalyser) return;
const data = new Float32Array(meRecAnalyser.frequencyBinCount);
meRecAnalyser.getFloatTimeDomainData(data);
let mx = 0;
for (let i=0; i<data.length; i++) { const v=Math.abs(data[i]); if(v>mx) mx=v; }
meRecLivePeaks.push({ amp: mx, sec: _spessaCtx.currentTime - meRecCtxT0 });
const cv = document.getElementById('wlcv_' + meRecordingTrackId);
const t = meAudioTracks.find(t=>t.id===meRecordingTrackId);
if (cv && t) _meDrawLiveLane(cv, t);
meRecAnimId = requestAnimationFrame(_meRecLiveLoop);
}
function _meDrawLiveLane(cv, t) {
const W=cv.width, H=cv.height, ctx2=cv.getContext('2d');
ctx2.fillStyle='#160d0d'; ctx2.fillRect(0,0,W,H);
ctx2.strokeStyle='rgba(255,255,255,.06)'; ctx2.lineWidth=1;
ctx2.beginPath(); ctx2.moveTo(0,H/2); ctx2.lineTo(W,H/2); ctx2.stroke();
const half=H/2-2;
for (const p of meRecLivePeaks) {
const x=tX(t.startTick + _secToTick(p.sec));
if (x<0||x>=W) continue;
const h=Math.max(1, p.amp*half*1.6);
ctx2.fillStyle='#f8717160'; ctx2.fillRect(x, H/2-h, 1, h*2);
}
const phx=tX(ph);
if (phx>=0&&phx<=W) { ctx2.fillStyle='#f38ba8bb'; ctx2.fillRect(phx-1,0,2,H); }
ctx2.fillStyle='#ef4444cc'; ctx2.font='bold 10px system-ui';
ctx2.fillText('● REC', W-42, 12);
}
function _meStopRecordOnly() {
const id = meRecordingTrackId;
meRecordingTrackId = null;
if (meRecAnimId) { cancelAnimationFrame(meRecAnimId); meRecAnimId=null; }
if (meRecAnalyser) { meRecAnalyser.disconnect(); meRecAnalyser=null; }
meRecLivePeaks = [];
if (meRecorder && meRecorder.state !== 'inactive') meRecorder.stop();
if (meRecStream) { meRecStream.getTracks().forEach(t=>t.stop()); meRecStream = null; }
if (id) {
const btn = document.querySelector(`#wl_${id} .wl-hbtn.rec`);
if (btn) { btn.textContent='⏺'; btn.classList.remove('active'); }
}
}
function meAddRecordTrack() {
_meAddTrackObj('🎙 녹음 트랙', null, null, ph);
meRenderWaveLanes();
}
function meStartAllAudio(playheadSec) {
if (!_spessaCtx) return;
for (const t of meAudioTracks) {
if (!t.buf) continue;
try { t.src?.stop(); } catch{} t.src?.disconnect(); t.gain?.disconnect();
t.src = _spessaCtx.createBufferSource(); t.src.buffer = t.buf;
t.gain = _spessaCtx.createGain(); t.gain.gain.value = t.muted ? 0 : t.vol;
t.src.connect(t.gain); t.gain.connect(_spessaCtx.destination);
const off = playheadSec - _tickToSec(t.startTick);
if (off >= 0) { t.src.start(0, Math.min(off, t.buf.duration - 0.001)); }
else { t.src.start(_spessaCtx.currentTime + (-off), 0); }
}
}
function meStopAllAudio() {
for (const t of meAudioTracks) {
try { t.src?.stop(); } catch{} t.src?.disconnect(); t.gain?.disconnect(); t.src=null; t.gain=null;
}
}
async function meLoadAudio(file) {
if (!file) return;
meStatus('오디오 로딩 중...');
try {
await _ensureSpessa();
const buf = await _spessaCtx.decodeAudioData(await file.arrayBuffer());
_meAddTrackObj(file.name, buf, _meComputePeaks(buf), 0);
meRenderWaveLanes();
meStatus('오디오 로드: ' + file.name);
} catch(e) { meStatus('오디오 오류: ' + e.message); }
}
// ── Wave lane DOM ─────────────────────────────────────────────────────────────
function meRenderWaveLanes() {
const lanes = document.getElementById('wave-lanes');
if (!lanes) return;
lanes.innerHTML = meAudioTracks.map(t => `
<div class="wave-lane" id="wl_${t.id}">
<div class="wave-lane-ctrl"><div class="wl-color-strip" style="background:${t.color}"></div></div>
<div class="wave-lane-cv-wrap">
<canvas class="wave-lane-cv" id="wlcv_${t.id}"></canvas>
<div class="wave-lane-hdr">
<span class="wl-name" ondblclick="meRenameAudioTrack('${t.id}')" title="더블클릭 이름 변경">${escHtml(t.name)}</span>
<span class="wl-hdr-btns">
<button class="wl-hbtn rec${meRecordingTrackId===t.id?' active':''}" onclick="meTrackRecord('${t.id}')" title="녹음">${meRecordingTrackId===t.id?'⏹':'⏺'}</button>
<button class="wl-hbtn mute${t.muted?' muted':''}" onclick="meMuteAudioTrack('${t.id}')" title="뮤트">M</button>
<input type="range" class="wl-vol-sm" min="0" max="100" value="${Math.round(t.vol*100)}" oninput="meSetAudioTrackVol('${t.id}',this.value)" title="볼륨">
<button class="wl-hbtn del" onclick="meRemoveAudioTrack('${t.id}')" title="삭제">✕</button>
</span>
</div>
</div>
</div>`).join('');
meResizeWave();
}
function meResizeWave() {
const W = Math.max(0, (document.getElementById('roll-wrap')?.clientWidth||56) - 56);
document.querySelectorAll('.wave-lane-cv').forEach(cv => {
cv.width = W; cv.height = 64;
const t = meAudioTracks.find(t=>t.id===cv.id.replace('wlcv_',''));
if (t) _meDrawLane(cv, t);
});
}
function _meDrawLane(cv, t) {
if (meRecordingTrackId === t.id) { _meDrawLiveLane(cv, t); return; }
const W=cv.width, H=cv.height, ctx2=cv.getContext('2d');
ctx2.fillStyle='#191a2c'; ctx2.fillRect(0,0,W,H);
ctx2.strokeStyle='rgba(255,255,255,.06)'; ctx2.lineWidth=1;
ctx2.beginPath(); ctx2.moveTo(0,H/2); ctx2.lineTo(W,H/2); ctx2.stroke();
if (t.peaks && t.buf) {
const startSec=_tickToSec(t.startTick), half=H/2-2;
const sx=tX(t.startTick);
if (sx>=0&&sx<=W) { ctx2.fillStyle=t.color+'33'; ctx2.fillRect(sx,0,1,H); }
for (let x=0; x<W; x++) {
const sec=_tickToSec((x+view.scrollX)/view.pxPerTick)-startSec;
if (sec<0||sec>=t.buf.duration) continue;
const amp=t.peaks[Math.min(Math.floor(sec*t.buf.sampleRate/ME_PEAK_RES),t.peaks.length-1)]||0;
const h=Math.max(1,amp*half*1.6);
ctx2.fillStyle=t.color+'66'; ctx2.fillRect(x,H/2-h,1,h*2);
}
} else if (!t.buf) {
ctx2.fillStyle='rgba(255,255,255,.1)'; ctx2.font='10px system-ui'; ctx2.fillText('비어있음 — ⏺ 녹음 또는 📂 파일 불러오기', 10, H/2+4);
}
const phx=tX(ph);
if (phx>=0&&phx<=W) { ctx2.fillStyle='#f38ba8bb'; ctx2.fillRect(phx-1,0,2,H); }
}
function meRenderWave() {
document.querySelectorAll('.wave-lane-cv').forEach(cv => {
const t=meAudioTracks.find(t=>t.id===cv.id.replace('wlcv_',''));
if (t) _meDrawLane(cv,t);
});
}
function scheduleChunk(mspt,loopEn,loopOut){
const now=performance.now();const schedUntilTick=playTick0+(now-playT0+SCHED_AHEAD_MS)/mspt;const anySolo=midi.tracks.some(t=>t.solo);
for(const trk of midi.tracks){if(trk.muted||(anySolo&&!trk.solo))continue;for(const n of trk.notes){if(n.tick<nextSchedTick)continue;if(n.tick>=schedUntilTick)continue;if(loopEn&&n.tick>=loopOut)continue;const on=playT0+(n.tick-playTick0)*mspt;const off=playT0+(n.tick+n.dur-playTick0)*mspt;try{meMidiOut.send(new Uint8Array([0x90|trk.channel,n.pitch,n.vel]),on);}catch{}try{meMidiOut.send(new Uint8Array([0x80|trk.channel,n.pitch,0]),off);}catch{}}}
nextSchedTick=schedUntilTick;
}
function doPlay(){
if(sfMode){sfPlay();return;}
if(!meMidiOut){alert('MIDI 출력 기기를 선택하세요.');return;}
playing=true;const btn=document.getElementById('play-btn');if(btn)btn.textContent='⏸';
meStartAllAudio(_tickToSec(ph));
_meStartArmedTracks();
playTick0=ph;playT0=performance.now();nextSchedTick=ph;
const mspt=mspT();const loopEn=document.getElementById('loop-en')?.checked||false;
const loopIn=(+(document.getElementById('loop-in')?.value||1)-1)*midi.tpq*(midi.timeSigs[0]?.num||4);
const loopOut=(+(document.getElementById('loop-out')?.value||5))*midi.tpq*(midi.timeSigs[0]?.num||4);
try{meMidiOut.send(new Uint8Array([0xF0,0x7E,0x7F,0x09,0x01,0xF7]));}catch{}
for(const trk of midi.tracks){const c=trk.channel;if(trk.pcs?.length){try{meMidiOut.send(new Uint8Array([0xB0|c,0,0]));}catch{}try{meMidiOut.send(new Uint8Array([0xB0|c,32,0]));}catch{}try{meMidiOut.send(new Uint8Array([0xC0|c,trk.pcs[0].prog]));}catch{}}for(const cc of(trk.ccs||[])){if(cc.tick===0)try{meMidiOut.send(new Uint8Array([0xB0|c,cc.cc,cc.val]));}catch{}}}
const maxTick=Math.max(0,...midi.tracks.flatMap(t=>t.notes.map(n=>n.tick+n.dur)));
function loop(){if(!playing)return;const now=performance.now();ph=playTick0+(now-playT0)/mspt;if(loopEn&&ph>=loopOut){doStop();ph=loopIn;doPlay();return;}if(ph>=maxTick){doStop();return;}scheduleChunk(mspt,loopEn,loopOut);updPos();const phx=tX(ph);if(rollC&&phx>rollC.width*.8)view.scrollX+=rollC.width*.4;else if(phx<20&&view.scrollX>0)view.scrollX=Math.max(0,view.scrollX-rollC.width*.4);renderRuler();renderRoll();animId=requestAnimationFrame(loop);}
animId=requestAnimationFrame(loop);
}
// Keyboard shortcuts (only active when editor stage is visible)
document.addEventListener('keydown',e=>{
if(!document.getElementById('stage-editor')?.classList.contains('visible'))return;
if(e.target.tagName==='INPUT'||e.target.tagName==='SELECT')return;
const ctrl=e.ctrlKey||e.metaKey;
if(ctrl&&e.key==='z'){e.preventDefault();doUndo();return;}
if(ctrl&&(e.key==='y'||(e.shiftKey&&e.key==='Z'))){e.preventDefault();doRedo();return;}
if(ctrl&&e.key==='a'){e.preventDefault();sel=new Set(midi.tracks.flatMap(t=>t.notes));renderAll();return;}
if(ctrl&&e.key==='c'){clip=[...sel].map(n=>({...n}));meStatus(`${clip.length}개 복사`);return;}
if(ctrl&&e.key==='d'){e.preventDefault();sel=new Set([...sel].map(n=>({...n})));if(activeTi<midi.tracks.length){pushH();midi.tracks[activeTi].notes.push(...sel);renderAll();renderVel();}return;}
if(ctrl&&e.key==='v'){if(!clip.length)return;e.preventDefault();pushH();const minT=Math.min(...clip.map(n=>n.tick));const ns=clip.map(n=>({...n,tick:n.tick-minT+ph}));if(activeTi<midi.tracks.length)midi.tracks[activeTi].notes.push(...ns);sel=new Set(ns);renderAll();renderVel();meStatus(`${ns.length}개 붙여넣기`);return;}
if((e.key==='Delete'||e.key==='Backspace')&&!ctrl){if(!sel.size)return;pushH();for(const trk of midi.tracks)trk.notes=trk.notes.filter(n=>!sel.has(n));sel.clear();renderAll();renderVel();return;}
if(e.key==='v'||e.key==='V')setTool('sel');
if(e.key==='p'||e.key==='P')setTool('draw');
if(e.key==='e'||e.key==='E')setTool('erase');
if(e.key===' '){e.preventDefault();doTogglePlay();}
if(e.key==='Home')doRewind();
if(e.key==='ArrowLeft'||e.key==='ArrowRight'||e.key==='ArrowUp'||e.key==='ArrowDown'){if(!sel.size)return;e.preventDefault();pushH();const dt=e.key==='ArrowLeft'?-(qTicks||midi.tpq/4):e.key==='ArrowRight'?(qTicks||midi.tpq/4):0;const dp=e.key==='ArrowUp'?1:e.key==='ArrowDown'?-1:0;for(const n of sel){n.tick=Math.max(0,n.tick+dt);n.pitch=Math.max(0,Math.min(127,n.pitch+dp));}renderAll();}
});
// Utilities
function updPos(){const tpb=midi.tpq*(midi.timeSigs[0]?.num||4);const bar=Math.floor(ph/tpb)+1,beat=Math.floor((ph%tpb)/midi.tpq)+1,tick=Math.floor(ph%midi.tpq);const el=document.getElementById('pos-disp');if(el)el.textContent=`${bar}:${beat}:${String(tick).padStart(3,'0')}`;}
function meStatus(msg){const el=document.getElementById('status-txt');if(!el)return;el.textContent=msg;setTimeout(()=>{if(el.textContent===msg)el.textContent='';},3000);}
// ── Init ──────────────────────────────────────────────────────────────────────
(async()=>{
patchAudioForGpFx(); // install FX chain + destination redirect before any AudioContext is created
const ok = await checkAuth();
if (!ok) return;
await Promise.all([initSessionId(), loadSkill(), loadModels(), loadMidiFiles(), loadGpFileList()]);
await loadHistory();
// 외부 진입: ?edit=<url> (또는 호환용 ?url=<url>) 로 에디터 스테이지 자동 오픈
const _params = new URLSearchParams(location.search);
const _editUrl = _params.get('edit') || _params.get('url');
if (_editUrl) {
const _name = decodeURIComponent(_editUrl.split('/').pop().replace(/\.midi?$/i, '')) || 'MIDI';
openEditor(_editUrl, _name);
} else {
addChatMsg('system', '🎵 음악 스튜디오에 오신 것을 환영합니다!');
document.getElementById('chat-input').focus();
}
})();
</script>
<!-- AudioMass scripts (loaded but not initialized until panel opens) -->
<script src="/audiomass/dist/wavesurfer.js"></script>
<script src="/audiomass/dist/plugin/wavesurfer.regions.js"></script>
<script src="/audiomass/oneup.js"></script>
<script src="/audiomass/app.js"></script>
<script src="/audiomass/keys.js"></script>
<script src="/audiomass/markers.js"></script>
<script src="/audiomass/contextmenu.js"></script>
<script src="/audiomass/lufs.js"></script>
<script src="/audiomass/ui-fx.js"></script>
<script src="/audiomass/ui.js"></script>
<script src="/audiomass/modal.js"></script>
<script src="/audiomass/state.js"></script>
<script src="/audiomass/engine.js"></script>
<script src="/audiomass/actions.js"></script>
<script src="/audiomass/drag.js"></script>
<script src="/audiomass/recorder.js"></script>
<!-- welcome.js omitted: shows unwanted popup when embedded -->
<script src="/audiomass/fx-pg-eq.js"></script>
<script src="/audiomass/fx-auto.js"></script>
<script src="/audiomass/local.js"></script>
<script src="/audiomass/id3.js"></script>
<script src="/audiomass/lzma.js"></script>
<script src="/audiomass/amss-format.js"></script>
<script src="/audiomass/multitrack.js"></script>
</body>
</html>