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:
+615
-11
@@ -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}`);
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user