';
});
// 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 = '';
+ 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 = '
';
+ if (midiUrl) {
+ html += '
'
+ + ''
+ + '▶'
+ + '■'
+ + ''
+ + '
';
+ html += '🎛 외부 신디';
+ html += '✏ 편집';
+ html += '⬇ MIDI';
+ }
+ if (pdfUrl) html += '⬇ PDF';
+ html += '
';
+ 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 = '
' +
+ '◀' +
+ '1 / ' + pages + '' +
+ '▶' +
+ '
';
+ }
+ return nav + '';
+}
+
+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 = '렌더링 실패: ' + escHtml(e.message) + '';
+ output.style.display = 'block';
+ }
+ if (btn) { btn.textContent = '악보 렌더링'; btn.disabled = false; }
+ }
+}
+
+async function uploadMidiAndRender(file) {
+ var msgId = 'midi-render-' + Date.now();
+ appendChatBubble('system',
+ '
' +
+ '🎵 ' + escHtml(file.name) + ' 악보 변환 중...' +
+ ' 준비 중...' +
+ '
'
+ );
+
+ 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 = '
' +
+ '
' +
+ '🎵 ' + escHtml(file.name) + '' +
+ '
' +
+ '▾' +
+ '✕' +
+ '
' +
+ '
' +
+ '
' +
+ '
';
+ if (ev.svg_url) html += buildSvgPagesHtml(ev);
+ html += buildMusicActions(ev, file.name);
+ html += '
';
+
+ 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 = 'MIDI 변환 실패: ' + escHtml(ev.message) + '';
+ }
+ }
+ }
+ } catch (e) {
+ var pl3 = document.getElementById(msgId);
+ if (pl3) pl3.outerHTML = 'MIDI 변환 실패: ' + escHtml(e.message) + '';
+ }
+}
+
+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 = '
' + html + '
';
+ 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 = [''];
+ for (const u of data.users) {
+ const selected = u.username === skillsTargetUser ? ' selected' : '';
+ opts.push(``);
+ }
+ 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 = '