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:
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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)">
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+31
-1
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user