- Blue Pill SVG: 핀 서클(r=3), 폰트 4px bold, 온보드 LED/PWR/RST 버튼 확대 - GPIO 텔레메트리: _RN_PNAME()+Serial.write(STX/ETX)로 digitalWrite() 후 핀 상태 전송 - PA0 등 아날로그 핀 번호(0xC0=192) → digitalPinToPinName()으로 런타임 복원 - PC13 toggle 시 핀 서클 + 온보드 LED 동시 업데이트 (active LOW) - PA9(TX)/PA10(RX) 시리얼 신호 깜빡임 - 코드 에디터 AI에 선택된 보드 이름(boardContext) 전달 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
4549 lines
265 KiB
HTML
4549 lines
265 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>🎵 음악 스튜디오</title>
|
||
<link rel="icon" type="image/png" sizes="64x64" href="cherry_logo.png">
|
||
<link rel="stylesheet" href="styles.css">
|
||
<script 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;}
|
||
|
||
/* ── Folder popup ── */
|
||
#fp-overlay{display:none;position:fixed;inset:0;z-index:950;background:rgba(0,0,0,.6);backdrop-filter:blur(3px);}
|
||
#fp-overlay.open{display:block;}
|
||
#fp-sheet{
|
||
position:absolute;bottom:0;left:0;right:0;
|
||
max-height:72vh;background:var(--studio-panel);
|
||
border-radius:16px 16px 0 0;
|
||
border-top:1px solid var(--studio-border2);
|
||
display:flex;flex-direction:column;overflow:hidden;
|
||
box-shadow:0 -8px 40px rgba(0,0,0,.5);
|
||
transform:translateY(100%);transition:transform .25s ease;
|
||
}
|
||
#fp-overlay.open #fp-sheet{transform:translateY(0);}
|
||
@media(min-width:1025px){
|
||
#fp-sheet{
|
||
position:absolute;top:50%;left:50%;bottom:auto;right:auto;
|
||
width:400px;max-height:65vh;
|
||
border-radius:14px;border:1px solid var(--studio-border2);
|
||
box-shadow:0 20px 60px rgba(0,0,0,.6);
|
||
transform:translate(-50%,-46%);transition:none;
|
||
}
|
||
#fp-overlay.open #fp-sheet{transform:translate(-50%,-50%);}
|
||
}
|
||
.fp-hdr{display:flex;align-items:center;padding:13px 16px 10px;flex-shrink:0;border-bottom:1px solid var(--studio-border);gap:6px;}
|
||
.fp-title{font-size:13px;font-weight:700;color:var(--studio-text);flex:1;}
|
||
.fp-icon-btn{background:none;border:none;color:var(--studio-muted);cursor:pointer;font-size:14px;padding:3px 6px;border-radius:6px;transition:.15s;font-family:inherit;}
|
||
.fp-icon-btn:hover{color:var(--studio-accent2);background:var(--studio-glow2);}
|
||
.fp-tabs{display:flex;gap:4px;padding:8px 12px 6px;flex-shrink:0;}
|
||
.fp-tab{flex:1;padding:6px;font-size:11px;font-weight:700;border:1px solid var(--studio-border2);border-radius:8px;background:transparent;color:var(--studio-muted);cursor:pointer;font-family:inherit;transition:.2s;}
|
||
.fp-tab.active{background:var(--studio-accent);border-color:var(--studio-accent);color:#fff;box-shadow:0 0 10px var(--studio-glow);}
|
||
.fp-body{flex:1;overflow-y:auto;padding:4px 8px 14px;min-height:0;}
|
||
.fp-item{display:flex;align-items:center;gap:6px;padding:7px 8px;border-radius:8px;transition:.15s;}
|
||
.fp-item:hover{background:var(--studio-glow2);}
|
||
.fp-item-icon{font-size:15px;flex-shrink:0;}
|
||
.fp-item-name{flex:1;font-size:12px;color:var(--studio-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;cursor:pointer;}
|
||
.fp-item-del{background:none;border:none;color:var(--studio-muted);cursor:pointer;font-size:13px;padding:2px 5px;border-radius:4px;flex-shrink:0;transition:.15s;line-height:1;}
|
||
.fp-item-del:hover{color:var(--studio-red);}
|
||
.fp-empty{font-size:12px;color:var(--studio-muted);padding:24px 12px;text-align:center;}
|
||
|
||
/* ── 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>
|
||
<button onclick="openFolderPopup('midi')" title="저장된 파일 보기" style="font-size:13px;padding:1px 6px;border:1px solid var(--studio-border2);border-radius:6px;background:var(--studio-panel2);color:var(--studio-muted);cursor:pointer;transition:.15s;" onmouseover="this.style.borderColor='var(--studio-accent2)';this.style.color='var(--studio-accent2)'" onmouseout="this.style.borderColor='var(--studio-border2)';this.style.color='var(--studio-muted)'">📂</button>
|
||
</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>
|
||
<button onclick="openFolderPopup('gp')" title="저장된 파일 보기" style="font-size:13px;padding:1px 6px;border:1px solid var(--studio-border2);border-radius:6px;background:var(--studio-panel2);color:var(--studio-muted);cursor:pointer;transition:.15s;" onmouseover="this.style.borderColor='var(--studio-accent2)';this.style.color='var(--studio-accent2)'" onmouseout="this.style.borderColor='var(--studio-border2)';this.style.color='var(--studio-muted)'">📂</button>
|
||
</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');}
|
||
function escAttr(s){return String(s||'').replace(/'/g,"\\'");}
|
||
|
||
async function loadMidiFiles() {
|
||
const el = document.getElementById('midi-file-list');
|
||
try {
|
||
const r = await fetch('/api/music/list', { headers: authH() });
|
||
const data = await r.json();
|
||
if (!data.files || !data.files.length) {
|
||
el.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 6px">파일 없음</div>';
|
||
return;
|
||
}
|
||
el.innerHTML = data.files.map(f => `
|
||
<div class="ms-fi">
|
||
<span class="ms-fi-name" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
|
||
<button class="ms-fi-btn play" onclick="loadMidiToStage('${escAttr(f.path)}','${escAttr(f.name)}')" title="스테이지에 불러오기">▶</button>
|
||
<button class="ms-fi-btn del" onclick="deleteMidiFile('${escAttr(f.name)}',this)" title="삭제">✕</button>
|
||
</div>`).join('');
|
||
} catch {
|
||
el.innerHTML = '<div style="font-size:11px;color:var(--err);padding:4px 6px">로드 실패</div>';
|
||
}
|
||
}
|
||
|
||
async function loadMidiToStage(filePath, name) {
|
||
try {
|
||
const blob = await fetch(filePath, { headers: authH() }).then(r => {
|
||
if (!r.ok) throw new Error('파일 로드 실패 ' + r.status);
|
||
return r.blob();
|
||
});
|
||
const filename = name.match(/\.(mid|midi)$/i) ? name : name + '.mid';
|
||
const file = new File([blob], filename, { type: 'audio/midi' });
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const r = await fetch('/api/music/midi', { method:'POST', headers: authH(), body: form });
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || '등록 실패');
|
||
const { html } = buildMidiPickerWidget(data, filename);
|
||
addToStage(html, filename);
|
||
} catch (e) { alert('MIDI 로드 실패: ' + e.message); }
|
||
}
|
||
|
||
async function deleteMidiFile(name, btn) {
|
||
if (!confirm(`"${name}" 을(를) 삭제할까요?`)) return;
|
||
try {
|
||
const r = await fetch('/api/music/workspace-file/' + encodeURIComponent(name), { method:'DELETE', headers: authH() });
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || '삭제 실패');
|
||
btn.closest('.ms-fi').remove();
|
||
} catch (e) { alert('삭제 실패: ' + e.message); }
|
||
}
|
||
|
||
async function uploadMidiFile(file) {
|
||
if (!file) return;
|
||
addChatMsg('system', '🎵 ' + escHtml(file.name) + ' 업로드 중...');
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
const r = await fetch('/api/music/midi', { method:'POST', headers: authH(), body: form });
|
||
const data = await r.json();
|
||
if (!r.ok) throw new Error(data.error || '업로드 실패');
|
||
const { html } = buildMidiPickerWidget(data, file.name);
|
||
addToStage(html, file.name);
|
||
addChatMsg('system', '✅ ' + escHtml(file.name) + ' 스테이지에 추가됨');
|
||
loadMidiFiles();
|
||
} catch (e) { addChatMsg('system', '업로드 실패: ' + escHtml(e.message)); }
|
||
}
|
||
|
||
// ── Chat ──────────────────────────────────────────────────────────────────────
|
||
function addChatMsg(role, html, raw) {
|
||
const msgs = document.getElementById('chat-msgs');
|
||
const div = document.createElement('div');
|
||
div.className = 'ms-msg ' + role;
|
||
const bubble = document.createElement('div');
|
||
bubble.className = 'ms-bubble';
|
||
bubble.innerHTML = html;
|
||
div.appendChild(bubble);
|
||
msgs.appendChild(div);
|
||
msgs.scrollTop = msgs.scrollHeight;
|
||
return bubble;
|
||
}
|
||
|
||
function renderMd(text) {
|
||
return text
|
||
.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>')
|
||
.replace(/```([\w-]*)\n?([\s\S]*?)```/g, (_, lang, code) => {
|
||
if (lang === 'abc' || lang === 'lilypond') return buildAbcWidget(code.trim(), lang);
|
||
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(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/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();
|
||
}
|
||
})();
|
||
|
||
// ── Folder popup ──────────────────────────────────────────────────────────────
|
||
let _fpTab = 'midi';
|
||
|
||
function openFolderPopup(tab = 'midi') {
|
||
_fpTab = tab;
|
||
document.getElementById('fp-overlay').classList.add('open');
|
||
fpSetTab(tab);
|
||
fpLoadMidi();
|
||
fpLoadGp();
|
||
}
|
||
|
||
function closeFolderPopup() {
|
||
document.getElementById('fp-overlay').classList.remove('open');
|
||
}
|
||
|
||
function fpSetTab(tab) {
|
||
_fpTab = tab;
|
||
document.getElementById('fp-tab-midi').classList.toggle('active', tab === 'midi');
|
||
document.getElementById('fp-tab-gp').classList.toggle('active', tab === 'gp');
|
||
document.getElementById('fp-midi-body').style.display = tab === 'midi' ? '' : 'none';
|
||
document.getElementById('fp-gp-body').style.display = tab === 'gp' ? '' : 'none';
|
||
}
|
||
|
||
function fpReload() { fpLoadMidi(); fpLoadGp(); }
|
||
|
||
async function fpLoadMidi() {
|
||
const el = document.getElementById('fp-midi-body');
|
||
el.innerHTML = '<div class="fp-empty">로딩 중...</div>';
|
||
try {
|
||
const r = await fetch('/api/music/list', { headers: authH() });
|
||
const data = await r.json();
|
||
if (!data.files?.length) { el.innerHTML = '<div class="fp-empty">저장된 MIDI 파일 없음</div>'; return; }
|
||
el.innerHTML = data.files.map(f => `
|
||
<div class="fp-item">
|
||
<span class="fp-item-icon">🎵</span>
|
||
<span class="fp-item-name" onclick="fpOpenMidi('${escAttr(f.path)}','${escAttr(f.name)}')" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
|
||
<button class="fp-item-del" onclick="fpDeleteMidi('${escAttr(f.name)}',this)" title="삭제">✕</button>
|
||
</div>`).join('');
|
||
} catch { el.innerHTML = '<div class="fp-empty" style="color:var(--studio-red)">로드 실패</div>'; }
|
||
}
|
||
|
||
async function fpLoadGp() {
|
||
const el = document.getElementById('fp-gp-body');
|
||
el.innerHTML = '<div class="fp-empty">로딩 중...</div>';
|
||
try {
|
||
const r = await fetch('/api/music/gp/list', { headers: authH() });
|
||
const data = await r.json();
|
||
if (!data.files?.length) { el.innerHTML = '<div class="fp-empty">저장된 GP 파일 없음</div>'; return; }
|
||
el.innerHTML = data.files.map(f => `
|
||
<div class="fp-item">
|
||
<span class="fp-item-icon">🎸</span>
|
||
<span class="fp-item-name" onclick="fpOpenGp('${escAttr(f.path)}','${escAttr(f.name)}')" title="${escHtml(f.name)}">${escHtml(f.name)}</span>
|
||
<button class="fp-item-del" onclick="fpDeleteGp('${escAttr(f.name)}',this)" title="삭제">✕</button>
|
||
</div>`).join('');
|
||
} catch { el.innerHTML = '<div class="fp-empty" style="color:var(--studio-red)">로드 실패</div>'; }
|
||
}
|
||
|
||
async function fpOpenMidi(path, name) { closeFolderPopup(); await loadMidiToStage(path, name); }
|
||
async function fpOpenGp(path, name) { closeFolderPopup(); await loadGpFromServer(path, name); }
|
||
|
||
async function fpDeleteMidi(name, btn) {
|
||
if (!confirm(`"${name}" 삭제할까요?`)) return;
|
||
try {
|
||
const r = await fetch('/api/music/workspace-file/' + encodeURIComponent(name), { method: 'DELETE', headers: authH() });
|
||
if (!r.ok) throw new Error((await r.json()).error || '삭제 실패');
|
||
btn.closest('.fp-item').remove();
|
||
loadMidiFiles();
|
||
} catch(e) { alert('삭제 실패: ' + e.message); }
|
||
}
|
||
|
||
async function fpDeleteGp(name, btn) {
|
||
if (!confirm(`"${name}" 삭제할까요?`)) return;
|
||
try {
|
||
const r = await fetch(`/api/music/gp-file/${encodeURIComponent(name)}`, { method: 'DELETE', headers: authH() });
|
||
if (!r.ok) throw new Error((await r.json()).error || '삭제 실패');
|
||
btn.closest('.fp-item').remove();
|
||
loadGpFileList();
|
||
} catch(e) { alert('삭제 실패: ' + e.message); }
|
||
}
|
||
</script>
|
||
<!-- ── Folder popup ── -->
|
||
<div id="fp-overlay" onclick="if(event.target===this)closeFolderPopup()">
|
||
<div id="fp-sheet">
|
||
<div class="fp-hdr">
|
||
<span class="fp-title">📂 저장된 파일</span>
|
||
<button class="fp-icon-btn" onclick="fpReload()" title="새로고침">↺</button>
|
||
<button class="fp-icon-btn" onclick="closeFolderPopup()" style="font-size:16px;">✕</button>
|
||
</div>
|
||
<div class="fp-tabs">
|
||
<button class="fp-tab active" id="fp-tab-midi" onclick="fpSetTab('midi')">🎵 MIDI</button>
|
||
<button class="fp-tab" id="fp-tab-gp" onclick="fpSetTab('gp')">🎸 Guitar Pro</button>
|
||
</div>
|
||
<div class="fp-body">
|
||
<div id="fp-midi-body"><div class="fp-empty">로딩 중...</div></div>
|
||
<div id="fp-gp-body" style="display:none"><div class="fp-empty">로딩 중...</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 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>
|