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}`);
|
||||
|
||||
Reference in New Issue
Block a user