Add MIDI editor + fix external synth instrument mapping

- web-ui/midi-editor.html: full piano roll MIDI editor (new tab)
  - all tracks simultaneously, color-coded per channel
  - note add/delete/move/resize, multi-select, copy/paste
  - velocity lane, track panel (mute/solo/instrument), undo/redo
  - GM reset + bank select on playback, MIDI output device select
  - export to .mid, open via ?url= param or file picker

- web-ui/index.html: add ✏ 편집 button to MIDI widget (opens editor)
  - GM SysEx reset + bank select before note playback
  - 200ms note delay for synth init time
  - getRenderFile: prefer original.midi over LilyPond output.midi

- src/gateway/music-renderer.ts: getRenderFile returns original.midi first
  - LilyPond SVG render overwrites output.midi (no Program Changes)
  - original.midi preserved from upload, served via /api/music/file/:id/midi

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-21 13:54:15 +09:00
co-authored by Claude Sonnet 4.6
parent bc9fb4043b
commit 1bfadd6060
3 changed files with 1966 additions and 11 deletions
+615 -11
View File
@@ -2248,7 +2248,127 @@
.error-context-detail.visible {
display: block;
}
/* Music widget */
.music-widget {
border: 1px solid var(--line);
border-radius: 12px;
overflow: hidden;
margin: 10px 0;
background: var(--surface);
}
.music-widget-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--hover);
border-bottom: 1px solid var(--line);
}
.music-widget-label {
font-size: 12px;
font-weight: 600;
color: var(--muted);
}
.music-render-btn {
font-size: 12px;
padding: 4px 12px;
border-radius: 8px;
border: 1px solid var(--brand);
background: var(--brand);
color: #fff;
cursor: pointer;
font-family: inherit;
}
.music-render-btn:disabled {
opacity: 0.5;
cursor: default;
}
.music-widget-body {
overflow: hidden;
transition: max-height 0.2s ease;
}
.music-widget-body.collapsed {
max-height: 0 !important;
}
.music-output {
padding: 12px;
}
.music-output img {
width: 100%;
height: auto;
display: block;
}
midi-player {
display: block;
width: 100%;
max-width: 420px;
margin-top: 6px;
--player-row-height: 16px;
--player-font-size: 11px;
}
.music-actions {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
align-items: center;
}
.music-dl-btn {
font-size: 12px;
padding: 4px 12px;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
font-family: inherit;
}
.music-dl-btn:hover { background: var(--hover); }
.music-page-nav {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 12px;
color: var(--muted);
}
.music-page-btn {
padding: 2px 10px;
border-radius: 6px;
border: 1px solid var(--line);
background: var(--surface);
color: var(--text);
cursor: pointer;
font-size: 13px;
font-family: inherit;
line-height: 1.4;
}
.music-page-btn:disabled { opacity: 0.35; cursor: default; }
.music-page-btn:not(:disabled):hover { background: var(--hover); }
.midi-router-panel {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin-top: 4px;
width: 100%;
}
.midi-router-sel {
font-size: 11px;
padding: 2px 6px;
border: 1px solid var(--line);
border-radius: 6px;
background: var(--surface);
color: var(--text);
max-width: 200px;
flex: 1;
}
.midi-router-status { font-size: 11px; color: var(--muted); }
</style>
<script src="https://cdn.jsdelivr.net/npm/@tonejs/midi@2.0.28/build/Midi.js"></script>
<script src="https://cdn.jsdelivr.net/combine/npm/tone@14,npm/@magenta/music@1.23.1/es6/core.js,npm/html-midi-player@1.6.0" defer></script>
</head>
<body>
@@ -2335,6 +2455,12 @@
<span>Skills</span>
<button class="sidebar-action" onclick="openCreateSkillModal()">+ Create</button>
</div>
<div id="skills-user-selector" style="display:none;padding:5px 10px 6px;border-bottom:1px solid var(--line);background:var(--panel-2)">
<label style="font-size:10px;color:var(--muted);display:block;margin-bottom:3px">대상 유저</label>
<select id="skills-target-user" onchange="onSkillsUserChange()" style="width:100%;font-size:12px;padding:3px 6px;background:var(--bg2);color:var(--text);border:1px solid var(--line);border-radius:6px">
<option value="">글로벌 기본값</option>
</select>
</div>
<div id="skills-list" style="flex:1;min-height:0;overflow-y:auto;padding:10px">
<div class="empty-state" style="padding:16px">스킬 로딩 중...</div>
</div>
@@ -2381,6 +2507,12 @@
<input type="checkbox" id="tts-mode-toggle" onchange="updateTTSMode()">
<span class="toggle-track"></span>
</label>
<span style="color:var(--line);margin:0 6px;user-select:none">|</span>
<span class="agent-toggle-label">바로보내기</span>
<label class="toggle-switch" title="음성 인식 후 자동 전송">
<input type="checkbox" id="mic-autosend-toggle" onchange="updateMicAutosend()">
<span class="toggle-track"></span>
</label>
<div class="agent-toggle-right" style="display:flex;align-items:center;gap:6px">
<button class="quick-mode-btn" id="context-pin-btn" onclick="toggleContextPinMode()" title="메시지를 컨텍스트에 고정">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 17v5"/><path d="M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z"/></svg>
@@ -2429,7 +2561,7 @@
<!-- 첨부 파일 카드 영역 (전송 전 표시) -->
<div id="staged-attachments" style="display:none;flex-wrap:wrap;gap:8px;padding:8px 12px 0;border-top:1px solid var(--line)"></div>
<div class="chat-input-row">
<input type="file" id="file-upload" accept="image/*,.pdf,.txt,.md,.csv,.xlsx,.xls,.docx,.pptx" style="display:none" onchange="handleFileUpload(this)" />
<input type="file" id="file-upload" accept="image/*,.pdf,.txt,.md,.csv,.xlsx,.xls,.docx,.pptx,.mid,.midi" style="display:none" onchange="handleFileUpload(this)" />
<button class="send-btn" onclick="document.getElementById('file-upload').click()" title="파일 첨부 (이미지·PDF·Excel·txt 등)" style="font-size:18px;width:44px;height:44px" aria-label="파일 첨부">📎</button>
<button class="send-btn" id="mic-btn" onclick="toggleMicRecording()" title="음성 입력 (클릭: 시작/중지)" style="font-size:18px;width:44px;height:44px" aria-label="음성 입력">🎤</button>
<textarea
@@ -3123,6 +3255,7 @@
<option value="lm_studio">LM Studio (로컬)</option>
<option value="openai">OpenAI — API 키</option>
<option value="openai_codex">OpenAI — GPT Plus / Codex (OAuth)</option>
<option value="anthropic">Anthropic — Claude API 키</option>
</select>
</div>
@@ -3196,6 +3329,23 @@
<div id="provider-status-msg-openai" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
</div>
<!-- Anthropic -->
<div id="prov-fields-anthropic" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">Anthropic API 키</div>
<label style="display:block;font-size:12px;color:var(--muted);margin-bottom:6px">API 키</label>
<input id="settings-anthropic-key" type="password" placeholder="sk-ant-api03-..." style="width:100%;border:1px solid var(--line);border-radius:10px;padding:8px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
<label style="display:block;font-size:12px;color:var(--muted);margin:10px 0 6px">모델</label>
<select id="settings-anthropic-model" class="settings-input" style="font-size:13px">
<option value="claude-sonnet-4-6">claude-sonnet-4-6 (권장)</option>
<option value="claude-opus-4-7">claude-opus-4-7</option>
<option value="claude-haiku-4-5-20251001">claude-haiku-4-5-20251001 (빠름)</option>
</select>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-sm" onclick="testProviderConnection()" style="background:#fff;border:1px solid var(--line);color:var(--text)">연결 테스트</button>
</div>
<div id="provider-status-msg-anthropic" style="font-size:11px;color:var(--muted);margin-top:6px"></div>
</div>
<!-- OpenAI Codex OAuth -->
<div id="prov-fields-openai_codex" style="display:none">
<div class="right-section-title" style="margin-bottom:8px">OpenAI — GPT Plus / Codex</div>
@@ -3954,6 +4104,7 @@ let currentUser = null;
setAgentSessionId(generateSessionId()); // always fresh session on page load
checkAuth(); // Check authentication on page load
initTTSToggle();
initMicAutosendToggle();
// ── Server log panel ──────────────────────────────────────────────────────────
let logScrollLocked = true;
@@ -4461,7 +4612,7 @@ function setSidebarTab(tab) {
document.getElementById('tab-skills').classList.toggle('active', tab === 'skills');
document.getElementById('sidebar-jobs').style.display = tab === 'jobs' ? 'flex' : 'none';
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
if (tab === 'skills') loadInstalledSkills();
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
}
// ---- Agent mode toggle ----
@@ -4754,6 +4905,9 @@ function renderChatMessages() {
// Also show green for confirmed pins even outside pin mode
const confirmedClass = (!contextPinMode && confirmedPins.some(p => p.content === msg.content && p.role === (msg.role === 'user' ? 'user' : 'assistant'))) ? ' pin-selected' : '';
const clickHandler = contextPinMode ? ` onclick="togglePinMessage(${idx})"` : '';
if (msg.role === 'widget') {
return `<div class="msg widget${pinClass}${selectedClass}${confirmedClass}"${clickHandler}><div class="msg-content">${msg.content}</div></div>`;
}
return `
<div class="msg ${msg.role}${pinClass}${selectedClass}${confirmedClass}"${clickHandler}>
<div class="msg-avatar">${msg.role === 'user' ? 'U' : '<img src="cherry_logo.png" style="width:48px;height:48px;border-radius:6px">'}</div>
@@ -6267,7 +6421,7 @@ function toggleCredVis(inputId, btn) {
// --- Provider-aware Model Settings ------------------------------------------
const PROVIDER_IDS = ['ollama', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex'];
const PROVIDER_IDS = ['ollama', 'llama_cpp', 'lm_studio', 'openai', 'openai_codex', 'anthropic'];
function onProviderChange() {
const provider = document.getElementById('settings-llm-provider').value;
@@ -6307,6 +6461,8 @@ async function loadModelSettings() {
v('settings-lmstudio-endpoint', pc.lm_studio?.endpoint);
v('settings-lmstudio-model', pc.lm_studio?.model);
v('settings-openai-key', pc.openai?.api_key);
v('settings-anthropic-key', pc.anthropic?.api_key);
if (pc.anthropic?.model) { const s = document.getElementById('settings-anthropic-model'); if (s) s.value = pc.anthropic.model; }
// Set primary model from saved config before refreshing the list
const savedPrimaryModel = pc[prov]?.model || '';
@@ -6358,7 +6514,7 @@ async function refreshProviderModels() {
async function testProviderConnection() {
const provider = document.getElementById('settings-llm-provider').value;
const statusIds = { ollama: 'provider-status-msg', llama_cpp: 'provider-status-msg-llamacpp', lm_studio: 'provider-status-msg-lmstudio', openai: 'provider-status-msg-openai', openai_codex: 'codex-oauth-status' };
const statusIds = { ollama: 'provider-status-msg', llama_cpp: 'provider-status-msg-llamacpp', lm_studio: 'provider-status-msg-lmstudio', openai: 'provider-status-msg-openai', openai_codex: 'codex-oauth-status', anthropic: 'provider-status-msg-anthropic' };
const msgId = statusIds[provider] || 'provider-status-msg';
const msg = document.getElementById(msgId);
if (msg) msg.textContent = '테스트 중…';
@@ -6423,6 +6579,7 @@ function buildProviderPayload() {
providers.lm_studio = { endpoint: document.getElementById('settings-lmstudio-endpoint')?.value || 'http://localhost:1234', model: document.getElementById('settings-lmstudio-model')?.value || '' };
providers.openai = { api_key: document.getElementById('settings-openai-key')?.value || '', model: document.getElementById('settings-openai-model')?.value || 'gpt-4o' };
providers.openai_codex = { model: document.getElementById('settings-codex-model')?.value || 'gpt-5.3-codex' };
providers.anthropic = { api_key: document.getElementById('settings-anthropic-key')?.value || '', model: document.getElementById('settings-anthropic-model')?.value || 'claude-sonnet-4-6' };
return { provider, providers };
}
@@ -8843,6 +9000,17 @@ function initLuckyModalInteraction() {
// ── Voice: STT (Web Speech API — Chrome 내장 Google STT) ────────────────
var _speechRecog = null;
var _isRecording = false;
var _micAutosend = localStorage.getItem('micAutosend') !== 'false'; // default on
function updateMicAutosend() {
_micAutosend = document.getElementById('mic-autosend-toggle').checked;
localStorage.setItem('micAutosend', String(_micAutosend));
}
function initMicAutosendToggle() {
var toggle = document.getElementById('mic-autosend-toggle');
if (toggle) toggle.checked = _micAutosend;
}
function toggleMicRecording() {
if (_isRecording) stopMicRecording();
@@ -8874,7 +9042,7 @@ function startMicRecording() {
input.value = (input.value ? input.value + ' ' : '') + transcript;
input.focus();
input.dispatchEvent(new Event('input'));
setTimeout(function() { handleSendStop(); }, 50);
if (_micAutosend) setTimeout(function() { handleSendStop(); }, 50);
}
};
_speechRecog.onerror = function(e) {
@@ -9055,7 +9223,26 @@ function renderMarkdown(text, opts) {
// Code blocks (``` ... ```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, function(_, lang, code) {
return '<pre class="md-code-block"><code>' + code.trim() + '</code></pre>';
var trimmed = code.trim();
if (lang === 'abc' || lang === 'lilypond') {
var encoded = encodeURIComponent(trimmed);
var widgetId = 'music-' + Math.random().toString(36).slice(2, 9);
return '<div class="music-widget" id="' + widgetId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + (lang === 'abc' ? 'ABC Notation' : 'LilyPond') + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderMusicBlock(\'' + widgetId + '\', decodeURIComponent(\'' + encoded + '\'))">악보 렌더링</button>' +
'<button onclick="toggleMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + widgetId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body">' +
'<pre class="md-code-block music-source" style="margin:0;border-radius:0;border:none;border-bottom:1px solid var(--line);max-height:120px;overflow-y:auto;font-size:11px"><code>' + escHtml(trimmed) + '</code></pre>' +
'<div class="music-output" style="display:none"></div>' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
});
// Inline code
@@ -9088,6 +9275,376 @@ function renderMarkdown(text, opts) {
return html;
}
function toggleMusicWidget(widgetId) {
var widget = document.getElementById(widgetId);
if (!widget) return;
// widgetId may point to either .music-widget or .music-widget-body
var body = widget.classList.contains('music-widget-body')
? widget
: widget.querySelector('.music-widget-body');
var btn = widget.classList.contains('music-widget-body')
? widget.closest('.music-widget')?.querySelector('[title="접기/펼치기"]')
: widget.querySelector('[title="접기/펼치기"]');
if (!body) return;
var collapsed = body.classList.toggle('collapsed');
if (btn) btn.textContent = collapsed ? '▸' : '▾';
}
function closeMusicWidget(widgetId) {
var el = document.getElementById(widgetId);
if (!el) return;
var widget = el.closest('.music-widget') || el;
widget.remove();
}
// ── Web MIDI router ──────────────────────────────────────────────────────────
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 {} // All Notes Off
try { out.send([0xB0 | ch, 121, 0]); } catch {} // Reset All Controllers
}
});
}
},
// Parse raw MIDI bytes → scheduled events list
// Returns [{timeMs, msg:[...bytes]}, ...]
_parseMidiRaw(buf) {
const u8 = new Uint8Array(buf);
const dv = new DataView(buf);
if (dv.getUint32(0) !== 0x4D546864) return []; // not MThd
const tpq = dv.getUint16(12);
const ntrk = dv.getUint16(10);
let tempo = 500000;
const events = [];
const readVarLen = (pos) => {
let val = 0, b;
do { b = u8[pos++]; val = (val << 7) | (b & 0x7F); } while (b & 0x80);
return { val, pos };
};
const ticksToMs = (ticks) => (ticks / tpq) * (tempo / 1000);
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;
while (p < end) {
const dl = readVarLen(p); p = dl.pos; tick += dl.val;
let b = u8[p];
if (b & 0x80) { rs = b; p++; } else { b = rs; }
const typ = b & 0xF0, ch = b & 0x0F;
const ms = ticksToMs(tick);
if (typ === 0x90 || typ === 0x80) {
const note = u8[p++], vel = u8[p++];
events.push({ ms, msg: [typ | ch, note, vel] });
} else if (typ === 0xA0 || typ === 0xB0 || typ === 0xE0) {
events.push({ ms, msg: [b, u8[p], u8[p+1]] }); p += 2;
} else if (typ === 0xC0) {
events.push({ ms, msg: [0xC0 | ch, u8[p++]] });
} else if (typ === 0xD0) {
events.push({ ms, msg: [b, u8[p++]] });
} else if (b === 0xFF) {
const mt = u8[p++];
const ll = readVarLen(p); p = ll.pos;
if (mt === 0x51 && ll.val >= 3) {
// Tempo change: recalculate from current tick
tempo = (u8[p] << 16) | (u8[p+1] << 8) | u8[p+2];
}
p += ll.val;
} else if (b === 0xF0 || b === 0xF7) {
const ll = readVarLen(p); p = ll.pos + ll.val;
} else { p++; }
}
p = end;
}
events.sort((a, b) => a.ms - b.ms);
return events;
},
async play(midiUrl, outputId, onStatus) {
this.stop();
const output = this.access.outputs.get(outputId);
if (!output) throw new Error('기기를 찾을 수 없습니다');
if (onStatus) onStatus('로딩 중...');
const resp = await fetch(midiUrl);
const buf = await resp.arrayBuffer();
// Parse raw MIDI for exact Program Change + all events
const events = this._parseMidiRaw(buf);
if (!events.length) throw new Error('MIDI 파싱 실패');
// GM Reset SysEx → 신디를 GM 모드로 초기화
try { output.send(new Uint8Array([0xF0, 0x7E, 0x7F, 0x09, 0x01, 0xF7])); } catch {}
// Send all PC + Bank Select (CC#0=0, CC#32=0) events upfront
const initPc = new Set();
events.forEach(ev => {
if ((ev.msg[0] & 0xF0) === 0xC0 && !initPc.has(ev.msg[0] & 0x0F)) {
initPc.add(ev.msg[0] & 0x0F);
const ch = ev.msg[0] & 0x0F;
try { output.send(new Uint8Array([0xB0 | ch, 0, 0])); } catch {} // Bank Select MSB=0
try { output.send(new Uint8Array([0xB0 | ch, 32, 0])); } catch {} // Bank Select LSB=0
try { output.send(new Uint8Array(ev.msg)); } catch {}
}
});
const NOTE_DELAY = 200; // ms — give synth time to process GM reset + PCs
const totalMs = events[events.length - 1].ms + NOTE_DELAY + 500;
this.isPlaying = true;
if (onStatus) onStatus('재생 중...');
events.forEach(ev => {
this.timers.push(setTimeout(() => {
if (!this.isPlaying) return;
try { output.send(new Uint8Array(ev.msg)); } catch {}
}, ev.ms + NOTE_DELAY));
});
this.timers.push(setTimeout(() => {
this.isPlaying = false;
if (onStatus) onStatus('완료');
}, totalMs));
}
};
async function midiRouterToggle(routerId, btnId) {
const panel = document.getElementById(routerId);
if (!panel) { alert('패널을 찾을 수 없습니다: ' + routerId); return; }
const isHidden = panel.style.display === 'none' || panel.style.display === '';
if (isHidden) {
panel.style.display = 'flex';
panel.style.flexWrap = 'wrap';
panel.style.gap = '6px';
panel.style.alignItems = 'center';
const sel = document.getElementById(routerId + '-sel');
const st = document.getElementById(routerId + '-st');
try {
if (!navigator.requestMIDIAccess) throw new Error('Web MIDI 미지원 — Chrome/Edge 필요');
if (!midiRouter.access) {
midiRouter.access = await navigator.requestMIDIAccess();
}
if (sel) {
sel.innerHTML = '<option value="">-- MIDI 기기 선택 --</option>';
midiRouter.access.outputs.forEach((out, id) => {
const opt = document.createElement('option');
opt.value = id; opt.textContent = out.name; sel.appendChild(opt);
});
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(routerId, midiUrl) {
const sel = document.getElementById(routerId + '-sel');
const st = document.getElementById(routerId + '-st');
const outputId = sel ? sel.value : '';
if (!outputId) { if (st) st.textContent = '기기를 선택하세요'; return; }
try {
await midiRouter.play(midiUrl, outputId, msg => { if (st) st.textContent = msg; });
} catch (e) { if (st) st.textContent = '오류: ' + e.message; }
}
function midiRouterStop(routerId) {
midiRouter.stop();
const st = document.getElementById(routerId + '-st');
if (st) st.textContent = '정지';
}
function buildMusicActions(data, downloadName) {
var midiUrl = data.midi_url;
var pdfUrl = data.pdf_url;
var rid = 'mr-' + Math.random().toString(36).slice(2, 10);
var html = '<div class="music-actions">';
if (midiUrl) {
html += '<div class="midi-router-panel" id="' + rid + '" style="display:none;margin-bottom:4px">'
+ '<select class="midi-router-sel" id="' + rid + '-sel" style="font-size:11px;padding:2px 6px;border:1px solid var(--line);border-radius:6px"><option value="">-- MIDI 기기 선택 --</option></select>'
+ '<button class="music-page-btn" onclick="midiRouterPlay(\'' + rid + '\',\'' + midiUrl + '\')">▶</button>'
+ '<button class="music-page-btn" onclick="midiRouterStop(\'' + rid + '\')">■</button>'
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>';
html += '<button class="music-dl-btn" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">🎛 외부 신디</button>';
html += '<button class="music-dl-btn" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">✏ 편집</button>';
html += '<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(downloadName || 'score.midi') + '">⬇ MIDI</a>';
}
if (pdfUrl) html += '<a class="music-dl-btn" href="' + pdfUrl + '" download="score.pdf">⬇ PDF</a>';
html += '</div>';
return html;
}
// ─────────────────────────────────────────────────────────────────────────────
function buildSvgPagesHtml(data) {
var pages = data.page_count || 1;
var baseId = data.id;
// Use a random suffix so duplicate renders in chat history never share the same navId
var navId = 'music-nav-' + Math.random().toString(36).slice(2, 10);
var src1 = pages === 1 ? data.svg_url : '/api/music/file/' + baseId + '/svg/1';
var nav = '';
if (pages > 1) {
nav = '<div class="music-page-nav" id="' + navId + '" data-page="1" data-total="' + pages + '" data-id="' + baseId + '">' +
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',-1)" id="' + navId + '-prev" disabled>◀</button>' +
'<span id="' + navId + '-label">1 / ' + pages + '</span>' +
'<button class="music-page-btn" onclick="musicPageNav(\'' + navId + '\',1)" id="' + navId + '-next"' + (pages <= 1 ? ' disabled' : '') + '>▶</button>' +
'</div>';
}
return nav + '<img id="' + navId + '-img" src="' + src1 + '" alt="악보" style="width:100%;height:auto;background:#fff;border-radius:8px;padding:8px;display:block" />';
}
function musicPageNav(navId, delta) {
var nav = document.getElementById(navId);
if (!nav) return;
var cur = parseInt(nav.dataset.page, 10);
var total = parseInt(nav.dataset.total, 10);
var next = cur + delta;
if (next < 1 || next > total) return;
nav.dataset.page = String(next);
var baseId = nav.dataset.id;
var img = document.getElementById(navId + '-img');
var lbl = document.getElementById(navId + '-label');
var prev = document.getElementById(navId + '-prev');
var nxt = document.getElementById(navId + '-next');
var url = '/api/music/file/' + baseId + '/svg/' + next;
if (img) img.src = url;
if (lbl) lbl.textContent = next + ' / ' + total;
if (prev) prev.disabled = next <= 1;
if (nxt) nxt.disabled = next >= total;
}
async function renderMusicBlock(widgetId, source) {
var widget = document.getElementById(widgetId);
if (!widget) return;
var btn = widget.querySelector('.music-render-btn');
var output = widget.querySelector('.music-output');
if (btn) { btn.disabled = true; btn.textContent = '렌더링 중…'; }
try {
var data = await api('/api/music/render', {
method: 'POST',
body: JSON.stringify({ source: source }),
});
if (data.error) throw new Error(data.error);
var html = buildSvgPagesHtml(data);
html += buildMusicActions(data, 'score.midi');
if (output) {
output.innerHTML = html;
output.style.display = 'block';
// Update chatHistory so the rendered output survives page reload
persistActiveChat();
}
if (btn) { btn.textContent = '다시 렌더링'; btn.disabled = false; }
} catch (e) {
if (output) {
output.innerHTML = '<span style="color:var(--err);font-size:12px">렌더링 실패: ' + escHtml(e.message) + '</span>';
output.style.display = 'block';
}
if (btn) { btn.textContent = '악보 렌더링'; btn.disabled = false; }
}
}
async function uploadMidiAndRender(file) {
var msgId = 'midi-render-' + Date.now();
appendChatBubble('system',
'<div id="' + msgId + '" style="font-size:13px;color:var(--muted)">' +
'🎵 <strong>' + escHtml(file.name) + '</strong> 악보 변환 중...' +
'<br><span id="' + msgId + '-prog" style="font-size:11px;opacity:0.7">준비 중...</span>' +
'</div>'
);
try {
var form = new FormData();
form.append('file', file);
var resp = await fetch('/api/music/midi', { method: 'POST', headers: authHeaders(), body: form });
if (!resp.ok || !resp.body) throw new Error('서버 오류 ' + resp.status);
var reader = resp.body.getReader();
var decoder = new TextDecoder();
var buf = '';
while (true) {
var chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
var parts = buf.split('\n\n');
buf = parts.pop() || '';
for (var i = 0; i < parts.length; i++) {
var part = parts[i];
if (!part.startsWith('data: ')) continue;
var ev;
try { ev = JSON.parse(part.slice(6)); } catch (e) { continue; }
if (ev.type === 'progress') {
var progEl = document.getElementById(msgId + '-prog');
if (progEl) progEl.textContent = ev.message;
} else if (ev.type === 'done') {
var wid = msgId + '-widget';
var html = '<div class="music-widget" id="' + wid + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">🎵 ' + escHtml(file.name) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button onclick="toggleMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + wid + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body">' +
'<div class="music-output">';
if (ev.svg_url) html += buildSvgPagesHtml(ev);
html += buildMusicActions(ev, file.name);
html += '</div></div></div>';
var placeholder = document.getElementById(msgId);
if (placeholder) placeholder.outerHTML = html;
// Persist to chatHistory so it survives page reload
chatHistory.push({ role: 'widget', content: html });
persistActiveChat();
} else if (ev.type === 'error') {
var pl2 = document.getElementById(msgId);
if (pl2) pl2.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(ev.message) + '</span>';
}
}
}
} catch (e) {
var pl3 = document.getElementById(msgId);
if (pl3) pl3.outerHTML = '<span style="color:var(--err);font-size:12px">MIDI 변환 실패: ' + escHtml(e.message) + '</span>';
}
}
function appendChatBubble(role, html) {
var wrap = document.getElementById('chat-messages');
if (!wrap) return;
var div = document.createElement('div');
div.className = 'msg msg-' + role;
div.innerHTML = '<div class="msg-content">' + html + '</div>';
wrap.appendChild(div);
wrap.scrollTop = wrap.scrollHeight;
}
function openImageZoom(src) {
var modal = document.getElementById('image-zoom-modal');
var img = document.getElementById('image-zoom-img');
@@ -9262,6 +9819,12 @@ function handleFileUpload(input) {
input.value = '';
var name = file.name.toLowerCase();
// MIDI → 악보 변환
if (/\.(mid|midi)$/.test(name) || file.type === 'audio/midi' || file.type === 'audio/x-midi') {
uploadMidiAndRender(file);
return;
}
// 이미지
if (file.type.startsWith('image/')) {
var imgForm = new FormData();
@@ -9677,11 +10240,44 @@ function openOllamaInfo() { window.open('http://localhost:11434','_blank'); }
let skillsCache = [];
let editingSkillId = null;
let skillsTargetUser = ''; // '' = global (admin) or own username (regular user)
async function initSkillsUserSelector() {
const selector = document.getElementById('skills-user-selector');
if (!selector) return;
if (!currentUser) { selector.style.display = 'none'; return; }
if (currentUser.role !== 'admin') {
selector.style.display = 'none';
skillsTargetUser = currentUser.username || '';
return;
}
selector.style.display = 'block';
try {
const data = await api('/api/users');
const sel = document.getElementById('skills-target-user');
if (!sel || !data.users) return;
const opts = ['<option value="">글로벌 기본값</option>'];
for (const u of data.users) {
const selected = u.username === skillsTargetUser ? ' selected' : '';
opts.push(`<option value="${escHtml(u.username)}"${selected}>${escHtml(u.username)}</option>`);
}
sel.innerHTML = opts.join('');
} catch {}
}
function onSkillsUserChange() {
const sel = document.getElementById('skills-target-user');
skillsTargetUser = sel ? sel.value : '';
loadInstalledSkills();
}
async function loadInstalledSkills() {
const el = document.getElementById('skills-list');
el.innerHTML = '<div style="padding:12px;color:var(--muted);font-size:11px"><span class="spinner"></span> Loading...</div>';
try {
const data = await api('/api/skills');
const effectiveUser = skillsTargetUser || (currentUser?.role !== 'admin' ? currentUser?.username : null);
const url = effectiveUser ? `/api/skills?username=${encodeURIComponent(effectiveUser)}` : '/api/skills';
const data = await api(url);
const skills = data.skills || [];
skillsCache = skills;
if (skills.length === 0) {
@@ -9689,12 +10285,13 @@ async function loadInstalledSkills() {
} else {
el.innerHTML = skills.map(s => {
const skillEmoji = (s.emoji === '🧩' || s.emoji === '🦞' || s.emoji === '⚠️') ? s.emoji : '🧩';
const globalBadge = s.globalOnly ? '<span style="font-size:9px;opacity:.6;margin-left:4px">[전체]</span>' : '';
return `
<div class="skill-card ${s.enabled ? 'enabled' : ''}" data-skill-id="${escHtml(s.id)}" onclick="toggleSkill('${escHtml(s.id)}')">
<div class="skill-card-top">
<div class="skill-card-emoji">${escHtml(skillEmoji)}</div>
<div class="skill-card-info">
<div class="skill-card-name">${escHtml(s.name)}</div>
<div class="skill-card-name">${escHtml(s.name)}${globalBadge}</div>
<div class="skill-card-desc">${escHtml(s.description || 'No description')}</div>
</div>
<div class="skill-card-actions">
@@ -9715,18 +10312,25 @@ async function loadInstalledSkills() {
async function toggleSkill(id) {
const skillMeta = (skillsCache || []).find(s => s.id === id);
// Block only when trying to enable an ineligible skill.
if (skillMeta && skillMeta.eligible === false && !skillMeta.enabled) {
addProcessEntry('error', skillMeta.eligibleReason || 'This skill is currently unavailable.');
return;
}
// globalOnly 스킬은 per-user 모드에서 토글 불가
if (skillMeta?.globalOnly && (skillsTargetUser || currentUser?.role !== 'admin')) {
addProcessEntry('error', `"${skillMeta.name}" 스킬은 글로벌 전용입니다.`);
return;
}
try {
const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST' });
const effectiveUser = skillsTargetUser || (currentUser?.role !== 'admin' ? currentUser?.username : null);
const body = effectiveUser ? JSON.stringify({ username: effectiveUser }) : undefined;
const res = await api(`/api/skills/${encodeURIComponent(id)}/toggle`, { method: 'POST', body });
if (!res.success) {
addProcessEntry('error', res.error || 'Failed to toggle skill.');
return;
}
addProcessEntry('info', `Skill "${res.skill.name}": ${res.skill.enabled ? 'ENABLED' : 'DISABLED'}`);
const label = res.targetUser ? ` (${res.targetUser})` : '';
addProcessEntry('info', `Skill "${res.skill.name}"${label}: ${res.skill.enabled ? 'ENABLED' : 'DISABLED'}`);
loadInstalledSkills();
} catch (err) {
addProcessEntry('error', `Failed to toggle skill: ${err.message}`);
+901
View File
@@ -0,0 +1,901 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MIDI Editor — Homeclaw</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #1e1e2e; color: #cdd6f4; font-family: 'Segoe UI', system-ui, sans-serif; font-size: 13px; height: 100vh; display: flex; flex-direction: column; overflow: hidden; user-select: none; }
/* ── Toolbar ── */
#toolbar { display: flex; align-items: center; gap: 3px; padding: 4px 8px; background: #181825; border-bottom: 1px solid #313244; flex-shrink: 0; flex-wrap: wrap; min-height: 36px; }
.tb-sep { width: 1px; height: 22px; background: #45475a; margin: 0 4px; }
.tb-btn { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 3px 8px; cursor: pointer; font-size: 12px; white-space: nowrap; }
.tb-btn:hover { background: #45475a; }
.tb-btn.active { background: #89b4fa; color: #1e1e2e; border-color: #89b4fa; }
.tb-lbl { color: #6c7086; font-size: 11px; }
select.tb-sel { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 2px 4px; font-size: 12px; }
input.tb-num { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 2px 6px; width: 58px; font-size: 12px; text-align: center; }
#midi-out-sel { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 2px 4px; font-size: 11px; max-width: 160px; }
/* ── Main ── */
#main { display: flex; flex: 1; overflow: hidden; }
/* ── Track Panel ── */
#track-panel { width: 162px; flex-shrink: 0; background: #181825; border-right: 1px solid #313244; display: flex; flex-direction: column; overflow: hidden; }
#tp-head { padding: 4px 8px; color: #6c7086; font-size: 11px; border-bottom: 1px solid #313244; display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
#track-list { flex: 1; overflow-y: auto; }
.ti { padding: 4px 6px 3px; border-bottom: 1px solid #252535; cursor: pointer; }
.ti:hover { background: #252535; }
.ti.sel { background: #313244; }
.ti-top { display: flex; align-items: center; gap: 5px; }
.ti-dot { width: 10px; height: 10px; border-radius: 2px; flex-shrink: 0; cursor: pointer; }
.ti-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; }
.ti-ch { color: #6c7086; font-size: 10px; flex-shrink: 0; }
.ti-btns { display: flex; gap: 2px; margin-top: 3px; }
.ti-btn { background: #252535; border: 1px solid #45475a; color: #6c7086; border-radius: 3px; padding: 1px 5px; cursor: pointer; font-size: 10px; }
.ti-btn:hover { background: #45475a; color: #cdd6f4; }
.ti-btn.m-on { background: #f38ba8; color: #1e1e2e; border-color: #f38ba8; }
.ti-btn.s-on { background: #a6e3a1; color: #1e1e2e; border-color: #a6e3a1; }
.ti-prog { font-size: 9px; color: #585b70; margin-top: 1px; }
/* ── Roll Container ── */
#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 #313244; }
#corner { width: 56px; flex-shrink: 0; background: #1a1a2a; border-right: 1px solid #313244; height: 24px; display: flex; align-items: center; justify-content: center; }
#corner span { color: #45475a; font-size: 9px; }
#ruler-canvas { flex: 1; display: block; background: #1a1a2a; 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: #1a1a2a; border-right: 1px solid #313244; cursor: pointer; }
#roll-canvas { display: block; flex: 1; min-width: 0; cursor: default; }
#vel-row { height: 70px; flex-shrink: 0; display: flex; border-top: 1px solid #313244; }
#vel-label { width: 56px; flex-shrink: 0; background: #1a1a2a; border-right: 1px solid #313244; display: flex; align-items: center; justify-content: center; color: #45475a; font-size: 9px; }
#vel-canvas { display: block; flex: 1; min-width: 0; cursor: ns-resize; background: #16161e; }
/* ── Transport ── */
#transport { display: flex; align-items: center; gap: 6px; padding: 4px 10px; background: #181825; border-top: 1px solid #313244; flex-shrink: 0; }
.tr-btn { background: #313244; border: 1px solid #45475a; color: #cdd6f4; border-radius: 4px; padding: 2px 10px; cursor: pointer; font-size: 14px; }
.tr-btn:hover { background: #45475a; }
#pos-disp { font-family: monospace; color: #a6e3a1; font-size: 12px; min-width: 90px; }
</style>
</head>
<body>
<div id="toolbar">
<button class="tb-btn" onclick="openFileDialog()">📂 열기</button>
<button class="tb-btn" onclick="doSave()">💾 저장</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="">-- 없음 --</option></select>
<button class="tb-btn" onclick="refreshMidiOuts()">↻</button>
</div>
<div id="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="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="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 class="tb-lbl" id="status-txt"></span>
</div>
<input type="file" id="file-in" accept=".mid,.midi" style="display:none" onchange="handleOpen(this)">
<script>
// ═══════════════════════════════════════════════════════════════════
// Constants
// ═══════════════════════════════════════════════════════════════════
const KEY_W = 56;
const RULE_H = 24;
const 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]);
// ═══════════════════════════════════════════════════════════════════
// State
// ═══════════════════════════════════════════════════════════════════
let midi = mkMidi();
let view = { scrollX: 0, scrollY: 57 * 8, pxPerTick: 0.15, noteH: 8 };
let tool = 'sel';
let qTicks = 120;
let activeTi = 0;
let sel = new Set();
let hist = [], histIdx = -1;
let drag = null;
let ph = 0; // playhead ticks
let playing = false;
let midiAccess = null, midiOut = null;
let animId = null;
let playT0 = 0, playTick0 = 0;
let velDrag = null;
let clip = [];
function mkMidi() {
return { format:1, tpq:480, tracks:[], tempos:[{tick:0,uspb:500000}], timeSigs:[{tick:0,num:4,den:4}] };
}
// ═══════════════════════════════════════════════════════════════════
// Canvases
// ═══════════════════════════════════════════════════════════════════
const rulerC = document.getElementById('ruler-canvas');
const keysC = document.getElementById('keys-canvas');
const rollC = document.getElementById('roll-canvas');
const velC = document.getElementById('vel-canvas');
const rCtx = rulerC.getContext('2d');
const kCtx = keysC.getContext('2d');
const pCtx = rollC.getContext('2d');
const vCtx = velC.getContext('2d');
function resize() {
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;
renderAll();
}
window.addEventListener('resize', resize);
// ═══════════════════════════════════════════════════════════════════
// MIDI Parser (tick-based)
// ═══════════════════════════════════════════════════════════════════
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=[];
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};
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});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});delete open[`${c}_${n}`];}
} else if (typ===0xA0) { p+=2; }
else if (typ===0xB0) { p+=2; }
else if (typ===0xC0) { pcs.push({tick,prog:u8[p++]}); 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);
}
// Close orphaned notes
for(const k in open){const[,np]=k.split('_').map(Number);const o=open[k];notes.push({tick:o.tick,dur:tpq,pitch:np,vel:o.vel});}
if(notes.length>0||pcs.length>0) {
res.tracks.push({name,channel:ch>=0?ch:t%16,notes,pcs,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=[];
// Tempo track
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||[])) evs.push({tick:pc.tick,bytes:[0xC0|trk.channel,pc.prog]});
for(const n of trk.notes){
evs.push({tick:n.tick, bytes:[0x90|trk.channel,n.pitch,n.vel]});
evs.push({tick:n.tick+n.dur,bytes:[0x80|trk.channel,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(); }
function renderRuler() {
const W=rulerC.width, H=RULE_H;
rCtx.fillStyle='#1a1a2a'; 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='#45475a'; rCtx.lineWidth=1;
rCtx.beginPath(); rCtx.moveTo(x+.5,0); rCtx.lineTo(x+.5,H); rCtx.stroke();
if(x>=0&&x<W){rCtx.fillStyle='#a6adc8'; 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='#313244'; 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() {
const W=KEY_W, H=keysC.height;
kCtx.fillStyle='#1a1a2a'; 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?'#2a2a3a':(isC?'#3a3a5a':'#e8e8f0');
kCtx.fillRect(0,y,W-(isB?14:0),view.noteH);
kCtx.strokeStyle='#45475a'; kCtx.lineWidth=.5;
kCtx.strokeRect(.5,y+.5,W-(isB?14:0)-1,view.noteH-1);
if(!isB&&view.noteH>=7){
kCtx.fillStyle=isC?'#89b4fa':'#6c7086';
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() {
const W=rollC.width, H=rollC.height;
pCtx.fillStyle='#1e1e2e'; pCtx.fillRect(0,0,W,H);
const nh=view.noteH;
// Pitch rows
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?'#191926':(isC?'#1e1e38':'#1e1e2e');
pCtx.fillRect(0,y,W,nh);
if(isC){pCtx.strokeStyle='#2e2e50';pCtx.lineWidth=.5;pCtx.beginPath();pCtx.moveTo(0,y+.5);pCtx.lineTo(W,y+.5);pCtx.stroke();}
}
// Bar/beat grid
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='#313244'; 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='#252535'; pCtx.lineWidth=.5;
pCtx.beginPath(); pCtx.moveTo(bx+.5,0); pCtx.lineTo(bx+.5,H); pCtx.stroke();
}
}
}
// Sub-beat grid (1/16) when zoomed in
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='#232333'; 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);
// Notes
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);}
}
}
// Drag select box
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([]);
}
// Playhead
const phx=tX(ph); if(phx>=0&&phx<=W){pCtx.fillStyle='#f38ba8';pCtx.fillRect(phx-1,0,2,H);}
}
function renderVel() {
const W=velC.width, H=VEL_H;
vCtx.fillStyle='#16161e'; vCtx.fillRect(0,0,W,H);
vCtx.strokeStyle='#252535'; 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');
list.innerHTML='';
midi.tracks.forEach((trk,i)=>{
const prog=trk.pcs?.[0]?.prog;
const 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}" title="색상변경" onclick="event.stopPropagation();cycleColor(${i})"></div>
<div class="ti-name" title="${trk.name}">${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:#252535;color:#cdd6f4;border:1px solid #45475a;border-radius:2px;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(); }
function toggleSolo(i) { pushH(); midi.tracks[i].solo=!midi.tracks[i].solo; renderAll(); renderTracks(); }
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();
}
// ═══════════════════════════════════════════════════════════════════
// Tools
// ═══════════════════════════════════════════════════════════════════
function setTool(t) {
tool=t;
['sel','draw','erase'].forEach(n=>document.getElementById('t-'+n).classList.toggle('active',n===t));
}
setTool('sel');
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)}]; document.getElementById('bpm-in').value=b; }
function zoomX(f) { const mid=view.scrollX+rollC.width/2; view.pxPerTick=Math.max(.02,Math.min(4,view.pxPerTick*f)); view.scrollX=Math.max(0,mid-rollC.width/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;
const endTick=n.tick+n.dur;
if(Math.abs(xT(cx)-endTick)<=RZ) return {n,trk,ti:i};
}
}
return null;
}
// ═══════════════════════════════════════════════════════════════════
// Mouse Events (Roll)
// ═══════════════════════════════════════════════════════════════════
let mdown=false;
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;
}
// Select tool
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(); updPos();
});
// Velocity lane drag
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;}});
// Keys: click to preview note
keysC.addEventListener('click',e=>{
const pitch=yP(e.offsetY);
if(pitch<0||pitch>127||!midiOut) return;
const ch=midi.tracks[activeTi]?.channel??0;
try{midiOut.send(new Uint8Array([0x90|ch,pitch,80]));}catch{}
setTimeout(()=>{try{midiOut.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();
document.getElementById('bpm-in').value=bpm();
renderAll(); renderTracks();
}
function doRedo() {
if(histIdx>=hist.length-1) return;
histIdx++; midi=JSON.parse(hist[histIdx]); sel.clear();
document.getElementById('bpm-in').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();
document.getElementById('bpm-in').value=bpm();
view.scrollX=0; view.scrollY=57*view.noteH;
document.title=`MIDI Editor — ${f.name}`;
renderAll(); renderTracks(); status('로드: '+f.name);
} catch(e) { alert('읽기 실패: '+e.message); }
inp.value='';
}
async function loadUrl(url) {
try {
const r=await fetch(url); if(!r.ok) throw new Error(r.status);
const buf=await r.arrayBuffer();
midi=parseMidi(buf); sel.clear(); hist=[]; histIdx=-1; pushH();
document.getElementById('bpm-in').value=bpm();
view.scrollX=0; view.scrollY=57*view.noteH;
const name=decodeURIComponent(url.split('/').pop());
document.title=`MIDI Editor — ${name}`;
renderAll(); renderTracks(); status('로드 완료');
} catch(e) { alert('로드 실패: '+e.message); }
}
function doSave() {
const buf=writeMidi(midi);
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([buf],{type:'audio/midi'}));
a.download=(document.title.replace('MIDI Editor — ','').replace(/\.midi?$/,'')||'edited')+'.mid';
document.body.appendChild(a); a.click();
setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000);
status('저장 완료');
}
// ═══════════════════════════════════════════════════════════════════
// MIDI Output / Playback
// ═══════════════════════════════════════════════════════════════════
async function initMidiOut() {
if(!navigator.requestMIDIAccess) return;
try {
midiAccess=await navigator.requestMIDIAccess();
midiAccess.onstatechange=()=>refreshMidiOuts();
refreshMidiOuts();
} catch {}
}
function refreshMidiOuts() {
if(!midiAccess) { initMidiOut(); return; }
const sel=document.getElementById('midi-out-sel');
const cur=sel.value;
sel.innerHTML='<option value="">-- 없음 --</option>';
midiAccess.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);
});
midiOut=cur?midiAccess.outputs.get(cur):null;
if(!midiOut&&midiAccess.outputs.size>0){
const first=midiAccess.outputs.values().next().value;
midiOut=first; sel.value=first.id;
}
}
document.getElementById('midi-out-sel').addEventListener('change',function(){
midiOut=midiAccess?.outputs.get(this.value)||null;
});
function doTogglePlay() { playing?doStop():doPlay(); }
function doRewind() { doStop(); ph=0; renderRuler(); renderRoll(); updPos(); }
function doStop() {
playing=false;
document.getElementById('play-btn').textContent='▶';
if(animId){cancelAnimationFrame(animId);animId=null;}
if(midiOut) for(let c=0;c<16;c++) try{midiOut.send(new Uint8Array([0xB0|c,123,0]));}catch{}
}
function doPlay() {
if(!midiOut){alert('MIDI 출력 기기를 선택하세요.');return;}
playing=true;
document.getElementById('play-btn').textContent='⏸';
playTick0=ph; playT0=performance.now();
const mspt=mspT();
const anySolo=midi.tracks.some(t=>t.solo);
const loopEn=document.getElementById('loop-en').checked;
const loopIn=(+document.getElementById('loop-in').value-1)*midi.tpq*(midi.timeSigs[0]?.num||4);
const loopOut=(+document.getElementById('loop-out').value)*midi.tpq*(midi.timeSigs[0]?.num||4);
// GM Reset + Program Changes
try{midiOut.send(new Uint8Array([0xF0,0x7E,0x7F,0x09,0x01,0xF7]));}catch{}
for(const trk of midi.tracks){
if(trk.pcs?.length){const c=trk.channel;
try{midiOut.send(new Uint8Array([0xB0|c,0,0]));}catch{}
try{midiOut.send(new Uint8Array([0xB0|c,32,0]));}catch{}
try{midiOut.send(new Uint8Array([0xC0|c,trk.pcs[0].prog]));}catch{}
}
}
// Schedule notes
const t0=performance.now()+200;
for(const trk of midi.tracks){
if(trk.muted||(anySolo&&!trk.solo)) continue;
for(const n of trk.notes){
if(n.tick<playTick0) continue;
if(loopEn&&n.tick>=loopOut) continue;
const on =(n.tick-playTick0)*mspt+t0;
const off=(n.tick+n.dur-playTick0)*mspt+t0;
try{midiOut.send(new Uint8Array([0x90|trk.channel,n.pitch,n.vel]),on);}catch{}
try{midiOut.send(new Uint8Array([0x80|trk.channel,n.pitch,0]),off);}catch{}
}
}
const maxTick=Math.max(0,...midi.tracks.flatMap(t=>t.notes.map(n=>n.tick+n.dur)));
function loop(){
if(!playing) return;
const elapsed=(performance.now()-playT0-200);
ph=playTick0+elapsed/mspt;
if(loopEn&&ph>=loopOut){ doStop(); ph=loopIn; doPlay(); return; }
if(ph>=maxTick){ doStop(); return; }
updPos();
// Auto-scroll playhead into view
const phx=tX(ph);
if(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
// ═══════════════════════════════════════════════════════════════════
document.addEventListener('keydown',e=>{
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}));status(`${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(); status(`${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();}
// Arrow keys: move selected notes
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);
document.getElementById('pos-disp').textContent=`${bar}:${beat}:${String(tick).padStart(3,'0')}`;
}
function status(msg) { document.getElementById('status-txt').textContent=msg; setTimeout(()=>document.getElementById('status-txt').textContent='',3000); }
// ═══════════════════════════════════════════════════════════════════
// Init
// ═══════════════════════════════════════════════════════════════════
initMidiOut();
window.addEventListener('load',()=>{
resize(); pushH();
const url=new URLSearchParams(location.search).get('url');
if(url) loadUrl(url);
updPos();
});
</script>
</body>
</html>