v3.0.9: 미디 에디터 대폭 개선 — 솔로/뮤트/CC/매핑 수정

미디 에디터:
- 솔로/뮤트 실시간 반영: writeMidi 기반으로 buildSpessaMidi 재설계, 토글 시 sfReloadSong()으로 즉시 시퀀서 리로드
- MIDI 파서 CC 이벤트 보존: 기존에 모든 CC(볼륨·팬·expression·리버브 등) 버리던 것 수정, ccs[] 배열로 저장·재출력
- 멀티채널 트랙 분리: 포맷0 등 한 트랙에 채널 혼합 시 채널별로 자동 분리, 드럼 채널 보호
- 노트에 ch 필드 추가: 파서에서 각 노트에 실제 MIDI 채널 저장, writeMidi에서 활용
- 롤에디터 기본 8마디 보기, 마스터 FX 패널 추가
- 기타프로 마스터 볼륨 200%까지 확장

기타프로 MIDI 포트 매핑:
- sendMidiProgramChanges 순서 수정: PC→Bank 오류를 Bank→PC로 교정
- 세컨더리 채널(팜뮤트·하모닉스) 초기화 추가
- CC7 볼륨·CC10 팬 초기값 전송 추가

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-22 17:44:32 +09:00
co-authored by Claude Sonnet 4.6
parent 9ad63ec8b1
commit 04e8f8ebe8
19 changed files with 3130 additions and 1008 deletions
+46 -12
View File
@@ -525,24 +525,18 @@ function markSessionUnread(sessionId) {
// ---- Mode switching ----
function setMode(mode) {
if (!['chat','bgtasks','schedule'].includes(mode)) mode = 'chat';
if (!['chat','bgtasks'].includes(mode)) mode = 'chat';
currentMode = mode;
document.getElementById('btn-chat').classList.toggle('active', mode === 'chat');
const bgtBtn = document.getElementById('btn-bgtasks');
if (bgtBtn) bgtBtn.classList.toggle('active', mode === 'bgtasks');
const schedBtn = document.getElementById('btn-schedule');
if (schedBtn) schedBtn.classList.toggle('active', mode === 'schedule');
document.getElementById('chat-view').style.display = mode === 'chat' ? 'flex' : 'none';
const bgtView = document.getElementById('bgtasks-view');
if (bgtView) bgtView.style.display = mode === 'bgtasks' ? 'flex' : 'none';
const schedView = document.getElementById('schedule-view');
if (schedView) schedView.style.display = mode === 'schedule' ? 'flex' : 'none';
const aside = document.querySelector('aside');
const rightPanel = document.getElementById('right-panel');
if (aside) aside.style.display = '';
if (rightPanel) rightPanel.style.display = mode === 'chat' ? '' : 'none';
// sidebar tab 하이라이트 동기화
document.getElementById('tab-bgtasks')?.classList.toggle('active', mode === 'bgtasks');
if (mode === 'bgtasks') refreshBgTasks();
if (mode === 'schedule') refreshSchedules();
}
// --- Heartbeat / Tasks System -----------------------------------------------
@@ -739,12 +733,20 @@ function setSidebarTab(tab) {
sidebarTab = tab;
const toggle = (id, active) => document.getElementById(id)?.classList.toggle('active', active);
toggle('tab-jobs', tab === 'jobs');
toggle('tab-bgtasks', tab === 'bgtasks');
toggle('tab-skills', tab === 'skills');
toggle('tab-pptx', tab === 'pptx');
document.getElementById('sidebar-jobs').style.display = tab === 'jobs' ? 'flex' : 'none';
toggle('tab-schedule', tab === 'schedule');
document.getElementById('sidebar-jobs').style.display = (tab === 'jobs' || tab === 'bgtasks') ? 'flex' : 'none';
document.getElementById('sidebar-skills').style.display = tab === 'skills' ? 'flex' : 'none';
document.getElementById('sidebar-pptx').style.display = tab === 'pptx' ? 'flex' : 'none';
const schedEl = document.getElementById('sidebar-schedule');
if (schedEl) schedEl.style.display = tab === 'schedule' ? 'flex' : 'none';
// 메인 뷰 전환
if (tab === 'bgtasks') { setMode('bgtasks'); }
else if (currentMode === 'bgtasks') { setMode('chat'); }
if (tab === 'skills') { initSkillsUserSelector(); loadInstalledSkills(); }
if (tab === 'schedule') refreshSchedules();
}
// ---- Agent mode toggle ----
@@ -6220,7 +6222,7 @@ function buildMusicActions(data, downloadName) {
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>';
html += '<button class="music-dl-btn btn-green" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">▶ 외부 신디</button>';
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>';
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/music-app.html?edit=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</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>';
@@ -6341,7 +6343,7 @@ function buildMidiPickerWidget(data, filename) {
savedLine +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '\')">▶ 재생</button>' +
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>' +
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/music-app.html?edit=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>' +
'<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' +
'</div>' +
playerPanel +
@@ -7651,6 +7653,7 @@ setInterval(checkStatus, 10000);
setInterval(loadSystemStats, 3000);
setInterval(loadApprovals, 5000);
setMode('chat'); // Start in chat mode
setSidebarTab('jobs');
// ---------------------------------------------------------------------------
// BACKGROUND TASKS KANBAN SYSTEM
@@ -7660,6 +7663,37 @@ let bgtTasks = []; // all task records from server
let bgtOpenTaskId = null; // currently open panel task id
let bgtHeartbeatConfig = { enabled: true, interval_minutes: 10 };
function openNewBgTaskModal() {
const modal = document.getElementById('bgt-new-task-modal');
if (modal) { modal.style.display = 'flex'; document.getElementById('bgt-new-title').focus(); }
}
function closeNewBgTaskModal() {
const modal = document.getElementById('bgt-new-task-modal');
if (modal) modal.style.display = 'none';
document.getElementById('bgt-new-title').value = '';
document.getElementById('bgt-new-prompt').value = '';
}
async function submitNewBgTask() {
const title = document.getElementById('bgt-new-title').value.trim();
const prompt = document.getElementById('bgt-new-prompt').value.trim();
if (!title || !prompt) { alert('제목과 작업 내용을 입력해주세요.'); return; }
const btn = document.getElementById('bgt-new-submit-btn');
btn.textContent = '시작 중...'; btn.disabled = true;
try {
const data = await api('/api/bg-tasks', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title, prompt }) });
if (data.success) {
closeNewBgTaskModal();
await refreshBgTasks();
} else {
alert('태스크 생성 실패: ' + (data.error || 'unknown'));
}
} catch (err) {
alert('오류: ' + err.message);
} finally {
btn.textContent = '▶ 시작'; btn.disabled = false;
}
}
function openBgtHeartbeatModal() {
const modal = document.getElementById('bgt-heartbeat-modal');
const enabledEl = document.getElementById('bgt-hb-enabled');
+45 -36
View File
@@ -53,14 +53,7 @@
<img src="cherry_logo.png" alt="🍒"> CherryClaw
</div>
<div class="mode-toggle">
<button class="mode-btn active" id="btn-chat" onclick="setMode('chat')">채팅</button>
<button class="mode-btn" id="btn-bgtasks" onclick="setMode('bgtasks')">작업</button>
<button class="mode-btn" id="btn-schedule" onclick="setMode('schedule')">일정</button>
</div>
<div class="mode-toggle" style="margin-left:8px">
<button class="mode-btn" onclick="setSidebarTab('skills')" title="스킬 관리">🧩 스킬</button>
<button class="mode-btn" onclick="window.open('/code.html','_blank')" title="코드 에디터">💻 코드</button>
<button class="mode-btn" onclick="window.open('/pptx-wizard.html','_blank')" title="슬라이드 제작">📊 슬라이드</button>
<button class="mode-btn" onclick="window.open('/language-app.html','_blank')" title="우즈베크어 학습">🇺🇿 언어</button>
@@ -115,6 +108,9 @@
<button id="mob-menu-close" onclick="mobTab('chat')" style="display:none;position:absolute;top:12px;right:12px;z-index:10;border:1px solid var(--line);background:var(--panel-2);border-radius:999px;padding:6px 14px;font-size:13px;font-weight:700;color:var(--muted);cursor:pointer;font-family:'Manrope',sans-serif">✕ 닫기</button>
<div id="sidebar-tabs">
<button class="sidebar-tab active" id="tab-jobs" onclick="setSidebarTab('jobs')">세션</button>
<button class="sidebar-tab" id="tab-schedule" onclick="setSidebarTab('schedule')">일정</button>
<button class="sidebar-tab" id="tab-skills" onclick="setSidebarTab('skills')">스킬</button>
<button class="sidebar-tab" id="tab-bgtasks" onclick="setSidebarTab('bgtasks')">작업</button>
</div>
<!-- SESSIONS TAB -->
@@ -154,16 +150,6 @@
<div class="sys-meter-track"><i id="sb-sys-vram-meter"></i></div>
<span id="sb-sys-vram" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:36px;text-align:right;flex-shrink:0">--%</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">↓</span>
<div class="sys-meter-track"><i id="sb-sys-rx-meter" style="background:linear-gradient(90deg,#059669,#10b981)"></i></div>
<span id="sb-sys-rx" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:52px;text-align:right;flex-shrink:0">0 B/s</span>
</div>
<div style="display:flex;align-items:center;gap:8px">
<span style="font-size:11px;font-weight:700;color:var(--muted);width:32px;flex-shrink:0">↑</span>
<div class="sys-meter-track"><i id="sb-sys-tx-meter" style="background:linear-gradient(90deg,#d97706,#f59e0b)"></i></div>
<span id="sb-sys-tx" style="font-size:11px;font-weight:700;font-family:'IBM Plex Mono',monospace;color:var(--text);width:52px;text-align:right;flex-shrink:0">0 B/s</span>
</div>
</div>
</div>
@@ -187,6 +173,23 @@
</div>
</div>
<!-- SCHEDULE TAB -->
<div id="sidebar-schedule" style="display:none;flex-direction:column;flex:1;min-height:0;overflow:hidden">
<div class="sidebar-section" style="border-top:none;flex-shrink:0">
<div style="display:flex;align-items:center;gap:8px">
<span style="font-weight:700">일정</span>
<span id="schedule-count" style="font-size:11px;background:#eaf2ff;color:#0d4faf;border-radius:999px;padding:2px 8px;font-weight:700">0</span>
</div>
<div style="display:flex;gap:6px">
<button class="sidebar-action" onclick="openScheduleCreateModal()">+ 새 일정</button>
<button class="sidebar-action" onclick="refreshSchedules()" title="새로고침">↺</button>
</div>
</div>
<div id="schedule-list" style="flex:1;min-height:0;overflow-y:auto;padding:10px;display:flex;flex-direction:column;gap:8px">
<div class="empty-state" style="text-align:center;color:var(--muted);padding:40px 20px">No schedules yet.<br><strong>+ 새 일정</strong>으로 시작하세요.</div>
</div>
</div>
<!-- CODE SIDEBAR -->
<!-- PPTX SIDEBAR -->
@@ -423,6 +426,7 @@
style="border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:8px;padding:5px 10px;font-size:11px;font-weight:600;cursor:pointer"
title="작업 하트비트 설정"
>? heartbeat: 10min</button>
<button onclick="openNewBgTaskModal()" style="border:1px solid var(--brand);background:var(--brand);color:#fff;border-radius:8px;padding:5px 14px;font-size:12px;font-weight:700;cursor:pointer">+ 태스크</button>
<button onclick="refreshBgTasks()" style="border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:8px;padding:5px 12px;font-size:12px;font-weight:600;cursor:pointer">새로고침</button>
</div>
</div>
@@ -455,25 +459,6 @@
</div>
</div>
<!-- SCHEDULE VIEW -->
<div id="schedule-view" style="display:none;flex-direction:column;gap:0;position:absolute;top:90px;bottom:12px;left:12px;right:12px;background:var(--bg);padding:16px;overflow:hidden;border:1px solid var(--line);border-radius:var(--radius-lg);box-shadow:var(--shadow-sm);z-index:5;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:16px;flex-shrink:0">
<div style="display:flex;align-items:center;gap:12px">
<span style="font-size:16px;font-weight:800;letter-spacing:-0.01em">일정</span>
<span id="schedule-count" style="font-size:11px;background:#eaf2ff;color:#0d4faf;border-radius:999px;padding:2px 10px;font-weight:700">0 schedules</span>
</div>
<div style="display:flex;gap:8px;align-items:center">
<button onclick="openScheduleCreateModal()" style="border:1px solid var(--brand);background:var(--brand);color:white;border-radius:8px;padding:6px 14px;font-size:12px;font-weight:600;cursor:pointer">+ 새 일정</button>
<button onclick="refreshSchedules()" style="border:1px solid var(--line);background:var(--panel);color:var(--muted);border-radius:8px;padding:6px 12px;font-size:12px;font-weight:600;cursor:pointer">새로고침</button>
</div>
</div>
<!-- Schedule list -->
<div id="schedule-list" style="flex:1;overflow-y:auto;display:flex;flex-direction:column;gap:10px;padding-right:8px">
<div class="empty-state" style="text-align:center;color:var(--muted);padding:40px 20px">No schedules yet. <strong>+ 새 일정</strong> to get started.</div>
</div>
</div>
<!-- Schedule Create/Edit Modal -->
<div id="schedule-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.35);z-index:9999;align-items:center;justify-content:center;padding:18px">
<div style="width:min(480px,96vw);max-height:90vh;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md)">
@@ -527,6 +512,30 @@
</div>
</div>
<!-- New Background Task Modal -->
<div id="bgt-new-task-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.35);z-index:9999;align-items:center;justify-content:center;padding:18px">
<div style="width:min(460px,96vw);background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md);display:flex;flex-direction:column">
<div style="display:flex;align-items:center;justify-content:space-between;padding:14px 16px;border-bottom:1px solid var(--line)">
<span style="font-weight:800">새 백그라운드 태스크</span>
<button onclick="closeNewBgTaskModal()" style="border:0;background:none;font-size:18px;cursor:pointer;color:var(--muted);line-height:1">✕</button>
</div>
<div style="padding:16px;display:flex;flex-direction:column;gap:12px">
<div>
<label style="display:block;font-size:12px;font-weight:700;color:var(--muted);margin-bottom:5px">태스크 제목</label>
<input id="bgt-new-title" type="text" placeholder="예: 오늘의 뉴스 요약" style="width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;background:var(--panel-2);color:var(--text)" />
</div>
<div>
<label style="display:block;font-size:12px;font-weight:700;color:var(--muted);margin-bottom:5px">작업 내용</label>
<textarea id="bgt-new-prompt" rows="4" placeholder="에이전트가 수행할 작업을 입력하세요..." style="width:100%;box-sizing:border-box;border:1px solid var(--line);border-radius:8px;padding:8px 10px;font-size:13px;background:var(--panel-2);color:var(--text);resize:vertical;font-family:inherit" onkeydown="if(event.key==='Enter'&&event.ctrlKey)submitNewBgTask()"></textarea>
</div>
</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding:12px 16px;border-top:1px solid var(--line)">
<button onclick="closeNewBgTaskModal()" style="border:1px solid var(--line);background:var(--panel-2);color:var(--muted);border-radius:8px;padding:7px 16px;font-size:12px;font-weight:600;cursor:pointer">취소</button>
<button id="bgt-new-submit-btn" onclick="submitNewBgTask()" style="border:0;background:var(--brand);color:#fff;border-radius:8px;padding:7px 18px;font-size:12px;font-weight:700;cursor:pointer">▶ 시작</button>
</div>
</div>
</div>
<!-- Background Task Heartbeat Modal -->
<div id="bgt-heartbeat-modal" style="display:none;position:fixed;inset:0;background:rgba(10,20,40,0.35);z-index:9999;align-items:center;justify-content:center;padding:18px">
<div style="width:min(420px,96vw);max-height:90vh;overflow:auto;background:var(--panel);border:1px solid var(--line);border-radius:14px;box-shadow:var(--shadow-md)">
-915
View File
@@ -1,915 +0,0 @@
<!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, nextSchedTick = 0;
const SCHED_AHEAD_MS = 300;
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();
if(playing){ try{midiOut.clear();}catch{} for(let c=0;c<16;c++) try{midiOut.send(new Uint8Array([0xB0|c,123,0]));}catch{} nextSchedTick=ph; }
}
function toggleSolo(i) {
pushH(); midi.tracks[i].solo=!midi.tracks[i].solo; renderAll(); renderTracks();
if(playing){ try{midiOut.clear();}catch{} for(let c=0;c<16;c++) try{midiOut.send(new Uint8Array([0xB0|c,123,0]));}catch{} nextSchedTick=ph; }
}
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){
try{midiOut.clear();}catch{} // cancel future-scheduled notes
for(let c=0;c<16;c++) try{midiOut.send(new Uint8Array([0xB0|c,123,0]));}catch{}
}
}
function scheduleChunk(mspt, loopEn, loopOut) {
const now = performance.now();
const schedUntilTick = playTick0 + (now - playT0 + SCHED_AHEAD_MS) / mspt;
const anySolo = midi.tracks.some(t=>t.solo);
for(const trk of midi.tracks){
if(trk.muted||(anySolo&&!trk.solo)) continue;
for(const n of trk.notes){
if(n.tick < nextSchedTick) continue;
if(n.tick >= schedUntilTick) continue;
if(loopEn && n.tick >= loopOut) continue;
const on = playT0 + (n.tick - playTick0) * mspt;
const off = playT0 + (n.tick+n.dur - playTick0) * mspt;
try{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{}
}
}
nextSchedTick = schedUntilTick;
}
function doPlay() {
if(!midiOut){alert('MIDI 출력 기기를 선택하세요.');return;}
playing=true;
document.getElementById('play-btn').textContent='⏸';
playTick0=ph; playT0=performance.now(); nextSchedTick=ph;
const mspt=mspT();
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{}
}
}
const maxTick=Math.max(0,...midi.tracks.flatMap(t=>t.notes.map(n=>n.tick+n.dur)));
function loop(){
if(!playing) return;
const now=performance.now();
ph=playTick0+(now-playT0)/mspt;
if(loopEn&&ph>=loopOut){ doStop(); ph=loopIn; doPlay(); return; }
if(ph>=maxTick){ doStop(); return; }
scheduleChunk(mspt, loopEn, loopOut);
updPos();
const phx=tX(ph);
if(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>
+261 -17
View File
@@ -30,7 +30,7 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
/* ── Sidebar ── */
.mn-sb{width:200px;flex-shrink:0;display:flex;flex-direction:column;border-right:1px solid var(--line);background:var(--panel);min-height:0;}
.mn-sb-sec{font-size:10px;font-weight:700;color:var(--muted);text-transform:uppercase;letter-spacing:.05em;padding:8px 10px 4px;}
.mn-tools{padding:0 6px 6px;display:flex;flex-direction:column;gap:3px;}
.mn-tools{padding:0 6px 6px;display:flex;flex-direction:column;gap:3px;flex:1;overflow-y:auto;min-height:0;}
.mn-tool{background:none;border:1px solid var(--line);border-radius:7px;padding:7px 10px;font-size:12px;color:var(--muted);cursor:pointer;font-family:inherit;text-align:left;transition:.15s;display:flex;align-items:center;gap:7px;}
.mn-tool:hover{border-color:#8b5cf6;color:#8b5cf6;background:rgba(139,92,246,.05);}
.mn-tool.active{border-color:#8b5cf6;background:#1e1b4b;color:#a78bfa;}
@@ -90,6 +90,23 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
.result-card .r-label{font-size:14px;font-weight:700;margin-bottom:4px;}
.result-card .r-desc{font-size:11px;color:var(--muted);}
/* ── Result detail (subscores + item table) ── */
.q-subscores{display:flex;flex-direction:column;gap:10px;margin:14px 0;}
.q-sub{font-size:11px;}
.q-sub .q-sub-hdr{display:flex;justify-content:space-between;margin-bottom:4px;color:var(--text);}
.q-sub .q-sub-hdr span:last-child{color:var(--muted);}
.q-sub .q-sub-bar{height:6px;background:var(--line);border-radius:3px;overflow:hidden;}
.q-sub .q-sub-fill{height:100%;background:#7c3aed;border-radius:3px;transition:width .4s;}
.q-result-detail{margin:14px 0;font-size:11px;overflow-x:auto;}
.q-result-detail table{width:100%;border-collapse:collapse;}
.q-result-detail th,.q-result-detail td{border:1px solid var(--line);padding:5px 8px;text-align:left;vertical-align:top;}
.q-result-detail th{background:var(--panel);color:var(--muted);font-weight:700;font-size:10px;text-transform:uppercase;letter-spacing:.03em;}
.q-result-detail td.q-num{color:var(--muted);width:26px;text-align:center;white-space:nowrap;}
.q-result-detail td.q-sc{text-align:center;width:46px;white-space:nowrap;color:#a78bfa;font-weight:700;}
.q-disclaimer{font-size:10px;color:var(--muted);line-height:1.6;margin-top:12px;padding:10px 12px;border:1px dashed var(--line);border-radius:7px;}
.q-disclaimer strong{display:block;color:var(--text);margin-bottom:3px;font-size:10px;}
.q-refs{font-size:10px;color:var(--muted);margin-top:6px;font-style:italic;opacity:.85;}
/* ── Breathing widget ── */
.breath-container{display:flex;flex-direction:column;align-items:center;gap:24px;padding:20px 0;}
.breath-circle-wrap{position:relative;width:200px;height:200px;}
@@ -211,6 +228,26 @@ body{background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;dis
<span>📋</span>
<span class="t-lbl">GAD-7<span class="t-sub">범불안장애 검사</span></span>
</button>
<button class="mn-tool" id="tool-pss10" onclick="showTool('pss10')">
<span>📋</span>
<span class="t-lbl">PSS-10<span class="t-sub">지각된 스트레스</span></span>
</button>
<button class="mn-tool" id="tool-isi" onclick="showTool('isi')">
<span>📋</span>
<span class="t-lbl">ISI<span class="t-sub">불면증 심각도</span></span>
</button>
<button class="mn-tool" id="tool-who5" onclick="showTool('who5')">
<span>📋</span>
<span class="t-lbl">WHO-5<span class="t-sub">웰빙 지수</span></span>
</button>
<button class="mn-tool" id="tool-rosenberg" onclick="showTool('rosenberg')">
<span>📋</span>
<span class="t-lbl">Rosenberg<span class="t-sub">자존감 척도</span></span>
</button>
<button class="mn-tool" id="tool-audit" onclick="showTool('audit')">
<span>📋</span>
<span class="t-lbl">AUDIT<span class="t-sub">알코올 선별</span></span>
</button>
<button class="mn-tool" id="tool-cbt" onclick="showTool('cbt')">
<span>✍️</span>
<span class="t-lbl">생각 기록지<span class="t-sub">CBT 인지 재구성</span></span>
@@ -361,6 +398,11 @@ const toolTitles={
emotion:'😊 감정 체크인',
phq9:'📋 PHQ-9 우울증 선별 검사',
gad7:'📋 GAD-7 범불안장애 검사',
pss10:'📋 PSS-10 지각된 스트레스 척도',
isi:'📋 ISI 불면증 심각도 척도',
who5:'📋 WHO-5 웰빙 지수',
rosenberg:'📋 Rosenberg 자존감 척도',
audit:'📋 AUDIT 알코올 사용장애 선별검사',
cbt:'✍️ 생각 기록지 (CBT)',
breath:'🌬️ 호흡 명상',
};
@@ -379,7 +421,7 @@ function showTool(name){
const widget=document.getElementById('center-widget');
widget.style.display='';
// render
const renders={emotion:renderEmotion,phq9:()=>renderQuestionnaire('phq9'),gad7:()=>renderQuestionnaire('gad7'),cbt:renderCBT,breath:renderBreath};
const renders={emotion:renderEmotion,phq9:()=>renderQuestionnaire('phq9'),gad7:()=>renderQuestionnaire('gad7'),pss10:()=>renderQuestionnaire('pss10'),isi:()=>renderQuestionnaire('isi'),who5:()=>renderQuestionnaire('who5'),rosenberg:()=>renderQuestionnaire('rosenberg'),audit:()=>renderQuestionnaire('audit'),cbt:renderCBT,breath:renderBreath};
(renders[name]||function(){widget.innerHTML='<div class="widget"><p>준비 중입니다.</p></div>';})();
}
@@ -449,6 +491,8 @@ const TESTS={
{max:19,cls:'moderate',label:'중등도-중증 우울',desc:'중증에 가까운 우울 증상입니다. 전문가 상담이 필요합니다.'},
{max:27,cls:'severe',label:'심각한 우울',desc:'심각한 우울 증상입니다. 즉시 전문가 상담을 받으시길 권합니다.'},
],
refs:'Kroenke, Spitzer & Williams(2001); 한국어판 Cho et al.(2007)',
note:'9번 문항(자해/자살 생각)에 1점 이상 응답 시, 총점과 관계없이 전문가 상담이 권장됩니다.',
},
gad7:{
title:'GAD-7 범불안장애 검사',
@@ -469,10 +513,162 @@ const TESTS={
{max:14,cls:'moderate',label:'중등도 불안',desc:'중간 정도의 불안 증상입니다. 전문가 상담을 권장합니다.'},
{max:21,cls:'severe',label:'심각한 불안',desc:'심각한 불안 증상입니다. 전문가 상담이 필요합니다.'},
],
refs:'Spitzer et al.(2006); 한국어판 Cho et al.(2011)',
},
pss10:{
title:'PSS-10 지각된 스트레스 척도',
intro:'지난 한 달 동안, 각 상황을 겪은 빈도에 해당하는 답을 선택해주세요. *표시는 역채점 문항입니다.',
scale:['전혀 없었다(0)','거의 없었다(1)','때때로 있었다(2)','자주 있었다(3)','매우 자주 있었다(4)'],
reverse:[3,4,6,7],
questions:[
'예기치 못한 일 때문에 당황한 적이 있었나?',
'중요한 일을 통제할 수 없다고 느낀 적이 있었나?',
'신경이 곤두서고 스트레스를 받는다고 느낀 적이 있었나?',
'* 자신의 개인적인 문제들을 성공적으로 처리했다고 자신 있게 느낀 적이 있었나?',
'* 중요한 일들을 처리하는 방법에 대해 통제력이 있다고 느낀 적이 있었나?',
'화가 나는 일 때문에 분노를 억누르기 힘들었나?',
'* 자신이 원하는 대로 일을 처리할 수 있다고 느낀 적이 있었나?',
'일이 너무 많아 통제할 수 없다고 느낀 적이 있었나?',
'일이 내 통제 밖의 일로 인해 화가 난 적이 있었나?',
'어려운 일들이 너무 많이 쌓여 극복할 수 없다고 느낀 적이 있었나?',
],
levels:[
{max:13,cls:'minimal',label:'낮은 스트레스',desc:'지각된 스트레스 수준이 낮습니다.'},
{max:26,cls:'mild',label:'보통 스트레스',desc:'보통 수준의 스트레스를 경험하고 있습니다. 대처 자원 점검이 도움될 수 있습니다.'},
{max:40,cls:'moderate',label:'높은 스트레스',desc:'스트레스 수준이 높습니다. 스트레스 관리와 전문가 상담을 권장합니다.'},
],
subscores:[
{name:'무력감 (Perceived Helplessness)',items:[0,1,2,5,8,9]},
{name:'자기효능감 (Perceived Self-Efficacy)',items:[3,4,6,7]},
],
refs:'Cohen & Williamson(1988); 한국어판 Park & Seo(2015)',
},
isi:{
title:'ISI 불면증 심각도 척도',
intro:'지난 2주 동안의 수면 문제를 기준으로 답해주세요.',
scale:['없음(0)','경미함(1)','중간(2)','상당함(3)','심각함(4)'],
questions:[
'잠들기 어려움의 심각성',
'수면을 유지하기 어려움의 심각성',
'너무 일찍 깨는 문제의 심각성',
'현재 수면 패턴에 대한 만족/불만족도',
'수면 문제가 일상 기능(낮 동안 피로·집중력 등)에 미치는 영향',
'수면 문제가 타인에게 눈에 띄는 정도',
'현재 수면 문제로 인해 느끼는 고통/걱정',
],
levels:[
{max:7,cls:'minimal',label:'정상',desc:'임상적 의미의 불면 증상이 없습니다.'},
{max:14,cls:'mild',label:'경미한 불면',desc:'아임상적 불면 가능성. 수면 위생 개선이 도움될 수 있습니다.'},
{max:21,cls:'moderate',label:'중등도 불면',desc:'중간 정도의 불면. 전문가 평가를 권장합니다.'},
{max:28,cls:'severe',label:'중증 불면',desc:'심각한 불면. 인지행동치료(CBT-I) 등 전문가 치료가 권장됩니다.'},
],
refs:'Morin(1993); 한국어판 Cho et al.(2014)',
},
who5:{
title:'WHO-5 웰빙 지수',
intro:'지난 2주 동안 가장 잘 맞는 답을 선택해주세요.',
scale:['전혀 없었다(0)','드물게(1)','절반 미만(2)','절반 이상(3)','대부분(4)','항상(5)'],
questions:[
'기분이 명랑하고 쾌활했다',
'차분하고 휴식을 취할 수 있었다',
'활기차고 에너지가 넘쳤다',
'관심사에 흥미가 일어나고 일어나는 일들을 좋아했다',
'매일의 일들이 즐거웠다',
],
levels:[
{max:7,cls:'severe',label:'우울 선별 권장',desc:'원시점수 7 이하(백분위 28 이하)로, 우울 선별을 위한 추가 평가가 권장됩니다.'},
{max:12,cls:'moderate',label:'낮은 웰빙',desc:'웰빙 수준이 낮습니다. 스트레스·기분 추가 평가가 도움될 수 있습니다.'},
{max:25,cls:'minimal',label:'양호한 웰빙',desc:'웰빙 수준이 양호합니다.'},
],
refs:'WHO(1998); 한국어판 Bae et al.(2017). 원시점수 ×4 = 백분위 점수.',
displayScore:(raw)=>`${raw}점 (백분위 ${raw*4})`,
},
rosenberg:{
title:'Rosenberg 자존감 척도',
intro:'각 문항에 대해 자신에게 가장 잘 맞는 답을 선택해주세요. *표시는 역채점 문항입니다.',
scale:['매우 그렇다(4)','그렇다(3)','아니다(2)','매우 아니다(1)'],
descending:true,
scoreBase:1,
reverse:[1,4,5,8],
questions:[
'나는 적어도 다른 사람들과 같은 정도의 가치 있는 사람이라고 느낀다.',
'* 때때로 나는 전혀 쓸모없는 사람이라고 생각한다.',
'나에게는 대부분의 사람들이 가진 것과 같은 좋은 점들이 있다고 생각한다.',
'나는 내가 할 수 있는 것만큼은 할 수 있다고 생각한다.',
'* 나는 자신감이 없는 사람이라고 느낀다.',
'* 때때로 나는 다른 사람들보다 못하다고 느낀다.',
'나는 자신에 대해 긍정적인 태도를 가지고 있다고 생각한다.',
'전반적으로 나는 스스로에게 만족한다.',
'* 때때로 나는 나 자신이 별로 잘난 것이 없다고 느낀다.',
'나는 나 자신에게 긍정적인 가치를 느낀다.',
],
levels:[
{max:14,cls:'moderate',label:'낮은 자존감',desc:'자존감이 낮은 편입니다. 자존감 강화를 위한 접근이 도움될 수 있습니다.'},
{max:24,cls:'mild',label:'보통 자존감',desc:'보통 수준의 자존감입니다.'},
{max:40,cls:'minimal',label:'높은 자존감',desc:'자존감이 높고 안정적입니다.'},
],
refs:'Rosenberg(1965); 한국어판 Jeon(1974). 총점 범위 10-40.',
},
audit:{
title:'AUDIT 알코올 사용장애 선별검사',
intro:'지난 1년 동안의 음주를 기준으로 답해주세요.',
scale:['없다(0)','한달 1회 미만(1)','한달 1회(2)','일주일 1회(3)','거의 매일(4)'],
questions:[
'술을 마신 빈도는?',
'술을 마시는 날 한 번에 마시는 잔 수는?',
'한 번에 5~6잔(여성 4~5잔) 이상 마신 날이 얼마나 되나요?',
'한 번 마시기 시작하면 멈출 수 없었던 적이?',
'음주 때문에 계획된 일을 실패한 적이?',
'술 마신 후 아침에 정신을 차리기 위해 다시 마신 적이?',
'술 마신 후 죄책감이나 후회를 느낀 적이?',
'전날 밤 술 때문에 기억이 나지 않은 적이?',
'음주로 인해 본인이나 타인이 다친 적이?',
'친척·친구·의사가 음주를 걱정하거나 줄이라고 한 적이?',
],
itemOpts:{
0:[{label:'안 마신다(0)',score:0},{label:'한달 1회 이하(1)',score:1},{label:'한달 2-4회(2)',score:2},{label:'일주일 2-3회(3)',score:3},{label:'일주일 4회 이상(4)',score:4}],
1:[{label:'1-2잔(0)',score:0},{label:'3-4잔(1)',score:1},{label:'5-6잔(2)',score:2},{label:'7-9잔(3)',score:3},{label:'10잔 이상(4)',score:4}],
8:[{label:'없다(0)',score:0},{label:'있으나 최근 1년 내 없음(2)',score:2},{label:'최근 1년 내 있음(4)',score:4}],
9:[{label:'없다(0)',score:0},{label:'있으나 최근 1년 내 없음(2)',score:2},{label:'최근 1년 내 있음(4)',score:4}],
},
levels:[
{max:7,cls:'minimal',label:'낮은 위험',desc:'알코올 사용으로 인한 위험이 낮습니다.'},
{max:15,cls:'mild',label:'위험 음주',desc:'위험 음주 수준입니다. 음주량 감량과 상담이 권장됩니다.'},
{max:19,cls:'moderate',label:'유해 음주',desc:'유해 음주 수준입니다. 간단한 중재와 상담이 권장됩니다.'},
{max:40,cls:'severe',label:'알코올 의존 가능성',desc:'알코올 의존 가능성이 있습니다. 전문가 진료가 권장됩니다.'},
],
subscores:[
{name:'음주 (AUDIT-C, Q1-3)',items:[0,1,2],bands:[{max:3,label:'저위험'},{max:12,label:'위험 음주'}]},
{name:'의존 (Q4-6)',items:[3,4,5]},
{name:'위해 (Q7-10)',items:[6,7,8,9]},
],
refs:'Saunders et al.(1993); 한국어판 Lee et al.(2000). AUDIT-C 절단점 ≥4(남)/≥3(여).',
},
};
let testAnswers={};
let lastResult=null;
// ── Scoring engine ──
function scoreFor(test,i,optIdx){
if(test.itemOpts&&test.itemOpts[i])return test.itemOpts[i][optIdx].score;
const n=test.scale.length,b=test.scoreBase||0;
let raw=test.descending?(n-1-optIdx):optIdx;
if(test.reverse&&test.reverse.includes(i))raw=(n-1)-raw;
return b+raw;
}
function optsFor(test,i){
if(test.itemOpts&&test.itemOpts[i])return test.itemOpts[i];
return test.scale.map((label,idx)=>({label,score:scoreFor(test,i,idx)}));
}
function scoreRange(test){
let min=0,max=0;
for(let i=0;i<test.questions.length;i++){
const sc=optsFor(test,i).map(o=>o.score);
min+=Math.min(...sc);max+=Math.max(...sc);
}
return {min,max};
}
function renderQuestionnaire(testId){
const test=TESTS[testId];
@@ -483,13 +679,15 @@ function renderQuestionnaire(testId){
<h2>${test.title}</h2>
<p class="w-desc">${test.intro}</p>
<div class="q-progress"><div class="q-progress-bar" id="qprog" style="width:0%"></div></div>
${test.questions.map((q,i)=>`
<div class="q-item">
${test.questions.map((q,i)=>{
const opts=optsFor(test,i);
return `<div class="q-item">
<div class="q-text">${i+1}. ${q}</div>
<div class="q-opts">
${test.scale.map((s,v)=>`<button class="q-opt" id="q${testId}_${i}_${v}" onclick="selectAnswer('${testId}',${i},${v})">${s}</button>`).join('')}
${opts.map((o,v)=>`<button class="q-opt" onclick="selectAnswer('${testId}',${i},${v})">${escHtml(o.label)}</button>`).join('')}
</div>
</div>`).join('')}
</div>`;
}).join('')}
<div class="w-footer">
<button class="w-btn" id="submit-q-btn" onclick="submitQuestionnaire('${testId}')" disabled>결과 확인</button>
<span style="font-size:11px;color:var(--muted)">0/${test.questions.length} 완료</span>
@@ -498,15 +696,13 @@ function renderQuestionnaire(testId){
}
function selectAnswer(testId,qIdx,val){
const test=TESTS[testId];
testAnswers[qIdx]=val;
// highlight
for(let v=0;v<4;v++){const b=document.getElementById(`q${testId}_${qIdx}_${v}`);if(b){b.classList.toggle('selected',v===val);}}
// progress
const item=document.querySelectorAll('#center-widget .q-item')[qIdx];
if(item)item.querySelectorAll('.q-opt').forEach((b,v)=>b.classList.toggle('selected',v===val));
const done=Object.keys(testAnswers).length;
const total=test.questions.length;
document.getElementById('qprog').style.width=(done/total*100)+'%';
const footer=document.querySelector('.w-footer span');if(footer)footer.textContent=`${done}/${total} 완료`;
const total=TESTS[testId].questions.length;
const prog=document.getElementById('qprog');if(prog)prog.style.width=(done/total*100)+'%';
const footer=document.querySelector('#center-widget .w-footer span');if(footer)footer.textContent=`${done}/${total} 완료`;
const btn=document.getElementById('submit-q-btn');if(btn)btn.disabled=done<total;
}
@@ -514,25 +710,73 @@ function submitQuestionnaire(testId){
const test=TESTS[testId];
const n=test.questions.length;
for(let i=0;i<n;i++)if(testAnswers[i]===undefined){alert(`${i+1}번 문항을 선택해주세요.`);return;}
const score=Object.values(testAnswers).reduce((a,b)=>a+b,0);
let score=0;const items=[];
for(let i=0;i<n;i++){
const opts=optsFor(test,i);
const sc=opts[testAnswers[i]].score;
score+=sc;
items.push({n:i+1,q:test.questions[i],label:opts[testAnswers[i]].label,score:sc});
}
const level=test.levels.find(l=>score<=l.max)||test.levels[test.levels.length-1];
const range=scoreRange(test);
// subscores
const subscores=[];
if(test.subscores){
for(const s of test.subscores){
let ss=0,smax=0,interp='';
for(const idx of s.items){
const sc=optsFor(test,idx).map(o=>o.score);
ss+=items[idx].score;smax+=Math.max(...sc);
}
if(s.bands){const b=s.bands.find(x=>ss<=x.max)||s.bands[s.bands.length-1];interp=b.label;}
subscores.push({name:s.name,score:ss,max:smax,interp});
}
}
const scoreDisplay=test.displayScore?test.displayScore(score):`${score}점`;
const subsHtml=subscores.length?`<div class="q-subscores">${subscores.map(s=>`
<div class="q-sub">
<div class="q-sub-hdr"><span>${escHtml(s.name)}</span><span>${s.score}/${s.max}${s.interp?' · '+escHtml(s.interp):''}</span></div>
<div class="q-sub-bar"><div class="q-sub-fill" style="width:${Math.round(s.score/s.max*100)}%"></div></div>
</div>`).join('')}</div>`:'';
const itemTable=`<div class="q-result-detail"><table><thead><tr><th>#</th><th>문항</th><th>응답</th><th>점수</th></tr></thead><tbody>${items.map(it=>`<tr><td class="q-num">${it.n}</td><td>${escHtml(it.q)}</td><td>${escHtml(it.label)}</td><td class="q-sc">${it.score}</td></tr>`).join('')}</tbody></table></div>`;
const disclaimer=`<div class="q-disclaimer"><strong>안내</strong>본 검사는 자기보고식 선별도구로 임상 진단을 대체하지 않습니다. 결과는 참고용이며, 정확한 평가는 임상심리사 또는 정신건강의학과 전문의를 통해 받으세요.${test.note?'<br>'+escHtml(test.note):''}${test.refs?`<div class="q-refs">참고: ${escHtml(test.refs)}</div>`:''}</div>`;
lastResult={title:test.title,score,level,subscores,items,refs:test.refs,minScore:range.min,maxScore:range.max};
const w=document.getElementById('center-widget');
const answers=test.questions.map((q,i)=>`${i+1}. ${q}: ${test.scale[testAnswers[i]]}`).join('\n');
w.innerHTML=`
<div class="widget">
<h2>${test.title} 결과</h2>
<div class="result-card ${level.cls}">
<div class="r-score">${score}점</div>
<div class="r-score">${scoreDisplay}</div>
<div class="r-label">${level.label}</div>
<div class="r-desc">${level.desc}</div>
</div>
${subsHtml}
${itemTable}
${disclaimer}
<div class="w-footer">
<button class="w-btn" onclick="sendToChat('[${test.title} 결과]\\n총점: ${score}점 — ${level.label}\\n\\n${level.desc}\\n\\n[각 문항 응답]\\n${answers}\\n\\n이 결과에 대한 설명과 다음 단계를 조언해주세요.')">AI에게 해석 요청</button>
<button class="w-btn" onclick="sendResultToChat()">AI에게 해석 요청</button>
<button class="w-btn secondary" onclick="renderQuestionnaire('${testId}')">다시 검사하기</button>
</div>
</div>`;
}
function sendResultToChat(){
if(!lastResult)return;
const r=lastResult;
let msg=`[${r.title} 결과]\n총점: ${r.score}점 (범위 ${r.minScore}-${r.maxScore}) — ${r.level.label}\n\n${r.level.desc}`;
if(r.subscores&&r.subscores.length){
msg+='\n\n[하위척도]';
for(const s of r.subscores)msg+=`\n- ${s.name}: ${s.score}/${s.max}${s.interp?' ('+s.interp+')':''}`;
}
msg+='\n\n[문항별 응답]';
for(const it of r.items)msg+=`\n${it.n}. ${it.q}: ${it.label} (${it.score}점)`;
if(r.refs)msg+=`\n\n참고문헌: ${r.refs}`;
msg+='\n\n이 결과에 대한 임상적 해석과 권장 다음 단계를 조언해주세요.';
sendToChat(msg,r.title+' 결과를 공유했습니다. AI가 해석 중...');
}
// ── CBT Thought Record ────────────────────────────────────────────────────────
const CBT_EMOTIONS=['불안','슬픔','분노','두려움','수치심','죄책감','외로움','좌절','우울','무력감'];
+2289 -27
View File
File diff suppressed because it is too large Load Diff
+31 -1
View File
@@ -201,6 +201,8 @@
grid-area: sidebar;
display: flex;
flex-direction: column;
position: relative;
z-index: 10;
}
#sidebar-tabs {
@@ -1870,7 +1872,7 @@
body {
padding: 0;
gap: 0;
grid-template-rows: 52px 1fr 56px;
grid-template-rows: auto 1fr 56px;
grid-template-columns: 1fr;
grid-template-areas:
"header"
@@ -1882,6 +1884,34 @@
border-left: none;
border-right: none;
border-top: none;
flex-wrap: wrap;
padding: 8px 12px 0;
gap: 6px;
height: auto;
}
/* 앱 버튼 스트립 — 두 번째 행, 가로 스크롤 */
header .mode-toggle {
flex: 0 0 100%;
order: 10;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
border: none;
background: transparent;
border-radius: 0;
padding: 4px 0 8px;
margin-left: 0 !important;
gap: 6px;
/* 오른쪽 페이드 힌트 */
-webkit-mask-image: linear-gradient(to right, black 80%, transparent 100%);
mask-image: linear-gradient(to right, black 80%, transparent 100%);
}
header .mode-toggle::-webkit-scrollbar { display: none; }
header .mode-toggle .mode-btn {
flex-shrink: 0;
white-space: nowrap;
padding: 6px 12px;
font-size: 12px;
}
aside { display: none; }
.status-bar { display: none; }