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
+43
View File
@@ -266,5 +266,48 @@
},
"pubmed": {
"api_key": "vault:pubmed.api_key"
},
"email": {
"accounts": [
{
"id": "applecherry",
"imap_host": "121.183.65.201",
"imap_port": 993,
"imap_tls": true,
"imap_reject_unauthorized": false,
"smtp_host": "121.183.65.201",
"smtp_port": 587,
"smtp_secure": false,
"smtp_reject_unauthorized": false,
"username": "kim@applecherry.net",
"password": "vault:email.accounts.applecherry.password"
},
{
"id": "evangel",
"imap_host": "mail.evangel.xyz",
"imap_port": 993,
"imap_tls": true,
"imap_reject_unauthorized": false,
"smtp_host": "mail.evangel.xyz",
"smtp_port": 587,
"smtp_secure": false,
"smtp_reject_unauthorized": false,
"username": "kim@evangel.xyz",
"password": "vault:email.accounts.evangel.password"
},
{
"id": "daum",
"imap_host": "imap.daum.net",
"imap_port": 993,
"imap_tls": true,
"imap_reject_unauthorized": true,
"smtp_host": "smtp.daum.net",
"smtp_port": 465,
"smtp_secure": true,
"smtp_reject_unauthorized": true,
"username": "kimsgms@hanmail.net",
"password": "vault:email.accounts.daum.password"
}
]
}
}
+14
View File
@@ -20,3 +20,17 @@
[16:22:07] **SmallClaw**: HEARTBEAT_OK
[16:52:07] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention.
[16:52:08] **SmallClaw**: HEARTBEAT_OK
[18:26:17] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention.
[18:26:19] **SmallClaw**: HEARTBEAT_OK
[19:42:01] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention.
[19:42:02] **SmallClaw**: HEARTBEAT_OK
[20:12:02] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention.
[20:12:05] **SmallClaw**: HEARTBEAT_OK
[21:22:20] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention.
[21:22:23] **SmallClaw**: HEARTBEAT_OK
[21:52:23] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention.
[21:52:24] **SmallClaw**: HEARTBEAT_OK
[08:14:25] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention.
[08:14:27] **SmallClaw**: HEARTBEAT_OK
[08:44:27] **User**: Check for anything important and reply HEARTBEAT_OK if nothing needs attention.
[08:44:29] **SmallClaw**: HEARTBEAT_OK
+134
View File
@@ -274,4 +274,138 @@ app.delete('/api/music/workspace-file/:dir', (req, res) => {
}
});
// POST /api/music/gp — save GP file to workspace/music/gp/<name>
app.post('/api/music/gp', express.raw({ type: '*/*', limit: '50mb' }), (req: any, res: any) => {
const user = req.user;
if (!user?.workspace) { res.status(403).json({ error: 'No workspace' }); return; }
try {
const buf: Buffer = req.body;
const rawName = decodeURIComponent((req.headers['x-filename'] || 'tab.gp') as string);
const safeName = rawName.replace(/[/\\:*?"<>|]/g, '_').trim() || 'tab.gp';
const songName = safeName.replace(/\.[^.]+$/, '');
const gpDir = path.join(user.workspace, 'music', 'gp', songName);
fs.mkdirSync(gpDir, { recursive: true });
const destPath = path.join(gpDir, safeName);
fs.writeFileSync(destPath, buf);
res.json({ success: true, path: `/api/music/gp-file/${encodeURIComponent(songName)}/${encodeURIComponent(safeName)}` });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// GET /api/music/gp/list — list GP files from workspace/music/gp/
app.get('/api/music/gp/list', (req: any, res: any) => {
const user = req.user;
if (!user?.workspace) { res.json({ files: [] }); return; }
const gpDir = path.join(user.workspace, 'music', 'gp');
try {
if (!fs.existsSync(gpDir)) { res.json({ files: [] }); return; }
const entries: { name: string; file: string; path: string; mtime: number }[] = [];
for (const d of fs.readdirSync(gpDir, { withFileTypes: true })) {
if (!d.isDirectory()) continue;
const sub = path.join(gpDir, d.name);
for (const f of fs.readdirSync(sub).filter(f => /\.(gp[0-9x]?|gpx)$/i.test(f))) {
const fp = path.join(sub, f);
entries.push({ name: d.name, file: f, path: `/api/music/gp-file/${encodeURIComponent(d.name)}/${encodeURIComponent(f)}`, mtime: fs.statSync(fp).mtimeMs });
}
}
entries.sort((a, b) => b.mtime - a.mtime);
res.json({ files: entries });
} catch { res.json({ files: [] }); }
});
// GET /api/music/gp-file/:dir/:file — serve GP file from workspace
app.get('/api/music/gp-file/:dir/:file', (req: any, res: any) => {
const user = req.user;
if (!user?.workspace) { res.status(403).json({ error: 'No workspace' }); return; }
const filePath = path.join(user.workspace, 'music', 'gp', req.params.dir, req.params.file);
if (!filePath.startsWith(path.join(user.workspace, 'music', 'gp'))) { res.status(403).json({ error: 'Forbidden' }); return; }
if (!fs.existsSync(filePath)) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(req.params.file)}`);
fs.createReadStream(filePath).pipe(res);
});
// DELETE /api/music/gp-file/:dir — delete GP folder
app.delete('/api/music/gp-file/:dir', (req: any, res: any) => {
const user = req.user;
if (!user?.workspace) { res.status(403).json({ error: 'No workspace' }); return; }
const dirPath = path.join(user.workspace, 'music', 'gp', req.params.dir);
if (!dirPath.startsWith(path.join(user.workspace, 'music', 'gp') + path.sep)) { res.status(403).json({ error: 'Forbidden' }); return; }
if (!fs.existsSync(dirPath)) { res.status(404).json({ error: 'Not found' }); return; }
fs.rmSync(dirPath, { recursive: true, force: true });
res.json({ success: true });
});
// ── Drum patterns (save / list / load / delete) — workspace/drums/<name>.json ─
function drumDir(user: any): string | null {
if (!user?.workspace) return null;
return path.join(user.workspace, 'drums');
}
function drumSafeName(raw: string): string {
return (raw || '').replace(/[/\\:*?"<>|]/g, '_').trim().slice(0, 60) || '패턴';
}
// GET /api/music/drums/list
app.get('/api/music/drums/list', (req: any, res: any) => {
const dir = drumDir(req.user);
if (!dir) { res.json({ files: [] }); return; }
try {
if (!fs.existsSync(dir)) { res.json({ files: [] }); return; }
const files = fs.readdirSync(dir)
.filter(f => f.endsWith('.json'))
.map(f => {
const fp = path.join(dir, f);
const st = fs.statSync(fp);
return { name: f.replace(/\.json$/, ''), mtime: st.mtimeMs };
})
.sort((a, b) => b.mtime - a.mtime);
res.json({ files });
} catch { res.json({ files: [] }); }
});
// GET /api/music/drums/:name
app.get('/api/music/drums/:name', (req: any, res: any) => {
const dir = drumDir(req.user);
if (!dir) { res.status(403).json({ error: 'No workspace' }); return; }
const fp = path.join(dir, drumSafeName(req.params.name) + '.json');
if (!fp.startsWith(dir)) { res.status(403).json({ error: 'Forbidden' }); return; }
if (!fs.existsSync(fp)) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'application/json; charset=utf-8');
fs.createReadStream(fp).pipe(res);
});
// POST /api/music/drums/save body: { name, steps, bpm, swing, pattern }
app.post('/api/music/drums/save', (req: any, res: any) => {
const dir = drumDir(req.user);
if (!dir) { res.status(403).json({ error: 'No workspace' }); return; }
try {
const body = req.body || {};
const name = drumSafeName(body.name);
const steps = Math.max(4, Math.min(32, parseInt(body.steps, 10) || 16));
const bpm = Math.max(40, Math.min(300, parseInt(body.bpm, 10) || 120));
const swing = Math.max(0, Math.min(70, parseFloat(body.swing) || 0));
const swingDiv = body.swingDiv === '16' ? '16' : '8';
const pattern = Array.isArray(body.pattern) ? body.pattern : [];
fs.mkdirSync(dir, { recursive: true });
const fp = path.join(dir, name + '.json');
if (!fp.startsWith(dir)) { res.status(403).json({ error: 'Forbidden' }); return; }
fs.writeFileSync(fp, JSON.stringify({ name, steps, bpm, swing, swingDiv, pattern }, null, 2));
res.json({ success: true, name });
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// DELETE /api/music/drums/:name
app.delete('/api/music/drums/:name', (req: any, res: any) => {
const dir = drumDir(req.user);
if (!dir) { res.status(403).json({ error: 'No workspace' }); return; }
const fp = path.join(dir, drumSafeName(req.params.name) + '.json');
if (!fp.startsWith(dir)) { res.status(403).json({ error: 'Forbidden' }); return; }
if (!fs.existsSync(fp)) { res.status(404).json({ error: 'Not found' }); return; }
fs.rmSync(fp, { force: true });
res.json({ success: true });
});
} // registerMusicRoutes
+267
View File
@@ -10584,6 +10584,29 @@ app.get('/api/bg-tasks', (_req, res) => {
res.json({ success: true, tasks, heartbeatConfig });
});
app.post('/api/bg-tasks', async (req: any, res: any) => {
const user = getSessionUser(req);
const { title, prompt } = req.body || {};
if (!title || !prompt) { res.status(400).json({ success: false, error: 'title과 prompt가 필요합니다' }); return; }
try {
const ownerUsername = user?.username;
const sessionId = `bgt_manual_${Date.now()}`;
if (ownerUsername) {
try { ensureUserWorkspace(ownerUsername); } catch { /* non-fatal */ }
getSession(sessionId, ownerUsername);
}
const plan = [{ index: 0, id: 'step_1', title: '작업 실행', status: 'pending' as const, description: prompt }];
const task = createTask({ title, prompt, sessionId, channel: 'web', plan, ownerUsername });
appendJournal(task.id, { type: 'status_push', content: `수동 태스크 "${title}" 시작` });
const runner = new BackgroundTaskRunner(task.id, handleChat, makeBroadcastForTask(task.id), telegramChannel);
runner.start().catch((err: any) => console.error(`[BgTask] Manual task ${task.id} error:`, err.message));
broadcastWS({ type: 'bg_task_created', taskId: task.id, title });
res.json({ success: true, taskId: task.id });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get('/api/bg-tasks/:id', (req, res) => {
const task = loadTask(req.params.id);
if (!task) { res.status(404).json({ success: false, error: 'Task not found' }); return; }
@@ -12521,6 +12544,250 @@ app.post('/api/models/test', async (req, res) => {
});
registerMusicRoutes(app);
// ── Guitar Pro tab search/download (guitarprotabs.org proxy) ─────────────────
function gpNormalizeArtist(name: string): { letter: string; slug: string } {
let n = name.trim().toLowerCase();
// "The Eagles" → "eagles_(the)"
if (n.startsWith('the ')) { n = n.slice(4) + '_(the)'; }
const slug = n.replace(/[&]/g, 'and').replace(/[^a-z0-9_()]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
const letter = slug[0] || 'a';
return { letter, slug };
}
async function gpFetch(url: string, referer?: string): Promise<{ ok: boolean; status: number; text?: string; buffer?: Buffer; contentType?: string }> {
return new Promise((resolve) => {
const https = require('https');
const urlObj = new URL(url);
const opts = {
hostname: urlObj.hostname,
path: urlObj.pathname + urlObj.search,
method: 'GET',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
'Accept': '*/*',
...(referer ? { 'Referer': referer } : {})
}
};
const req = https.request(opts, (res: any) => {
const chunks: Buffer[] = [];
res.on('data', (c: Buffer) => chunks.push(c));
res.on('end', () => {
const buf = Buffer.concat(chunks);
const ct = res.headers['content-type'] || '';
if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
const next = new URL(res.headers.location, url).href;
gpFetch(next, referer).then(resolve);
} else {
resolve({ ok: res.statusCode < 400, status: res.statusCode, buffer: buf, contentType: ct, text: ct.includes('text') ? buf.toString('utf8') : undefined });
}
});
});
req.on('error', () => resolve({ ok: false, status: 0 }));
req.end();
});
}
async function gpSearchGptabs(q: string): Promise<{ results: any[]; artistUrl: string; error?: string }> {
const searchR = await gpFetch(`https://guitarprotabs.org/?s=${encodeURIComponent(q)}`);
let baseUrl = '';
if (searchR.ok && searchR.text) {
const qLower = q.toLowerCase().replace(/\s+/g, '');
const re = /href="(https:\/\/guitarprotabs\.org\/[a-z]\/([^/]+)\/\d+\/)"/g;
let am;
while ((am = re.exec(searchR.text)) !== null) {
const slug = am[2].replace(/_/g, '').replace(/[^a-z0-9]/gi, '').toLowerCase();
if (slug.includes(qLower) || qLower.includes(slug)) { baseUrl = am[1]; break; }
}
}
if (!baseUrl) {
const { letter, slug } = gpNormalizeArtist(q);
baseUrl = `https://guitarprotabs.org/${letter}/${slug}/1/`;
}
// Strip trailing page number to rebuild per-page URL
const urlBase = baseUrl.replace(/\/\d+\/$/, '/');
const artistUrl = urlBase + '1/';
const results: any[] = [];
const rowRe = /<tr[^>]*itemprop[^>]*>([\s\S]*?)<\/tr>/g;
const MAX_PAGES = 10;
for (let page = 1; page <= MAX_PAGES; page++) {
const r = await gpFetch(urlBase + page + '/');
if (!r.ok || !r.text) break;
let m;
let pageCount = 0;
rowRe.lastIndex = 0;
while ((m = rowRe.exec(r.text)) !== null) {
const row = m[1];
const href = row.match(/href="(https:\/\/guitarprotabs\.org\/[^"]+)"/);
const nameM = row.match(/itemprop="name">([^<]+)/);
const extM = row.match(/<\/td>\s*<td>(\.[a-z0-9]+)<\/td>/i);
const dlM = row.match(/badge">([^<]+)</);
if (href && nameM) {
results.push({ title: nameM[1].trim(), url: href[1], ext: extM?.[1].trim() || '', downloads: dlM?.[1].trim() || '', site: 'guitarprotabs' });
pageCount++;
}
}
if (pageCount === 0) break; // 더 이상 결과 없음
}
if (!results.length) return { results: [], artistUrl, error: '아티스트를 찾을 수 없습니다' };
return { results, artistUrl };
}
async function gpSearchGprotab(q: string): Promise<{ results: any[]; searchUrl: string; error?: string }> {
const searchUrl = `https://www.gprotab.net/en/search/?q=${encodeURIComponent(q)}`;
const r = await gpFetch(searchUrl);
if (!r.ok || !r.text) return { results: [], searchUrl, error: '검색 실패' };
// Match each tab link directly — more robust than block regex
const hrefRe = /href="(\/en\/tabs\/[^"]+)" class="tab-name"[^>]*>([^<]+)<\/a>/g;
const bandRe = /class="tab-band"[^>]*>([^<]+)<\/a>/g;
const results: any[] = [];
// Collect bands first (parallel structure to hrefs)
const bands: string[] = [];
let bm;
while ((bm = bandRe.exec(r.text)) !== null) bands.push(bm[1].trim());
let hm;
let i = 0;
while ((hm = hrefRe.exec(r.text)) !== null) {
const band = bands[i] || '';
const name = hm[2].trim();
results.push({
title: band ? `${band} - ${name}` : name,
url: 'https://www.gprotab.net' + hm[1],
ext: '', downloads: '', site: 'gprotab'
});
i++;
}
return { results, searchUrl };
}
app.get('/api/gp-search', async (req: any, res: any) => {
const q = (req.query.q || req.query.artist || '').toString().trim();
const site = (req.query.site || 'guitarprotabs').toString();
if (!q) return res.status(400).json({ error: '검색어가 필요합니다' });
try {
if (site === 'gprotab') {
const r = await gpSearchGprotab(q);
res.json(r);
} else {
const r = await gpSearchGptabs(q);
res.json(r);
}
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/gp-download', async (req: any, res: any) => {
const tabUrl = (req.query.url || '').toString();
const isGptabs = tabUrl.startsWith('https://guitarprotabs.org/');
const isGprotab = tabUrl.startsWith('https://www.gprotab.net/') || tabUrl.startsWith('https://gprotab.net/');
if (!isGptabs && !isGprotab) return res.status(400).json({ error: '잘못된 URL' });
let dlUrl = tabUrl;
let referer = tabUrl;
if (isGptabs) {
dlUrl = tabUrl.endsWith('/') ? tabUrl + 'download/' : tabUrl + '/download/';
} else {
dlUrl = tabUrl.includes('?') ? tabUrl + '&download' : tabUrl + '?download';
}
try {
const r = await gpFetch(dlUrl, referer);
if (!r.ok || !r.buffer) return res.status(502).json({ error: '다운로드 실패' });
const isGp = r.buffer[1] === 0x46 && r.buffer[2] === 0x49; // .FICH magic
if (!isGp) return res.status(502).json({ error: '유효하지 않은 GP 파일' });
const titleM = isGptabs
? tabUrl.match(/\/([^/]+)_\d+\/?$/)
: tabUrl.match(/\/([^/]+)\/?$/);
const filename = (titleM ? titleM[1].replace(/[-_]/g, ' ') : 'tab') + '.gp';
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.send(r.buffer);
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
// ── MIDI download site search/download (midifind.com, bitmidi.com proxy) ──────
async function midiSearchMidifind(q: string): Promise<{ results: any[]; error?: string }> {
const r = await gpFetch(`https://midifind.com/search/?q=${encodeURIComponent(q)}&t=0`);
if (!r.ok || !r.text) return { results: [], error: '검색 실패' };
const results: any[] = [];
// <a href="/files/<letter>/<artist>/<slug>/<nums>-<nums>-<nums>-<id>" class="item">…title…</a>
const re = /<a href="(\/files\/[a-z]\/[^"]+\/\d+-\d+-\d+-(\d+))"[^>]*class="item"[^>]*>([\s\S]*?)<\/a>/g;
let m;
while ((m = re.exec(r.text)) !== null) {
const title = m[3].replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
if (title) results.push({ title, url: 'https://midifind.com' + m[1], site: 'midifind' });
}
return { results };
}
async function midiSearchBitmidi(q: string): Promise<{ results: any[]; error?: string }> {
const r = await gpFetch(`https://bitmidi.com/search?q=${encodeURIComponent(q)}`);
if (!r.ok || !r.text) return { results: [], error: '검색 실패' };
const results: any[] = [];
// Result anchors: <a ... href="/slug-mid" ... title="filename.mid" ...> (attribute order varies)
const re = /<a\b([^>]*)>/g;
let m;
while ((m = re.exec(r.text)) !== null) {
const attrs = m[1];
const hrefM = attrs.match(/\bhref="(\/[^"]+-mid)"/);
const titleM = attrs.match(/\btitle="([^"]+\.mid)"/i);
if (hrefM && titleM) results.push({ title: titleM[1], url: 'https://bitmidi.com' + hrefM[1], site: 'bitmidi' });
}
return { results };
}
app.get('/api/midi-search', async (req: any, res: any) => {
const q = (req.query.q || '').toString().trim();
const site = (req.query.site || 'midifind').toString();
if (!q) return res.status(400).json({ error: '검색어가 필요합니다' });
try {
const r = site === 'bitmidi' ? await midiSearchBitmidi(q) : await midiSearchMidifind(q);
res.json(r);
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
app.get('/api/midi-download', async (req: any, res: any) => {
const url = (req.query.url || '').toString();
const site = (req.query.site || 'midifind').toString();
let dlUrl = '';
let referer = url;
if (site === 'midifind') {
const idM = url.match(/(\d+)$/);
if (!idM) return res.status(400).json({ error: '잘못된 URL' });
dlUrl = `https://midifind.com/files/0-0-1-${idM[1]}-20`;
} else if (site === 'bitmidi') {
// detail page → find /uploads/{id}.mid
const page = await gpFetch(url);
if (!page.ok || !page.text) return res.status(502).json({ error: '다운로드 실패' });
const upM = page.text.match(/href="(\/uploads\/\d+\.mid)"/);
if (!upM) return res.status(502).json({ error: '다운로드 링크 없음' });
dlUrl = 'https://bitmidi.com' + upM[1];
} else {
return res.status(400).json({ error: '잘못된 사이트' });
}
try {
const r = await gpFetch(dlUrl, referer);
if (!r.ok || !r.buffer) return res.status(502).json({ error: '다운로드 실패' });
const isMidi = r.buffer.length > 4 && r.buffer[0] === 0x4D && r.buffer[1] === 0x54
&& r.buffer[2] === 0x68 && r.buffer[3] === 0x64; // "MThd"
if (!isMidi) return res.status(502).json({ error: '유효하지 않은 MIDI 파일' });
const fname = (req.query.name || 'download.mid').toString().replace(/[/\\:*?"<>|]/g, '_');
res.setHeader('Content-Type', 'audio/midi');
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`);
res.send(r.buffer);
} catch (e: any) {
res.status(500).json({ error: e.message });
}
});
registerLanguageRoutes(app);
registerDentalRoutes(app);
+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; }