diff --git a/.smallclaw/config.json b/.smallclaw/config.json index ffd32c8..01dc39b 100644 --- a/.smallclaw/config.json +++ b/.smallclaw/config.json @@ -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" + } + ] } } \ No newline at end of file diff --git a/.smallclaw/users/papa/workspace/music/Deep Purple — Black Night (5) [MIDIfind.com]/Deep Purple — Black Night (5) [MIDIfind.com].mid b/.smallclaw/users/papa/workspace/music/Deep Purple — Black Night (5) [MIDIfind.com]/Deep Purple — Black Night (5) [MIDIfind.com].mid deleted file mode 100644 index a85e296..0000000 Binary files a/.smallclaw/users/papa/workspace/music/Deep Purple — Black Night (5) [MIDIfind.com]/Deep Purple — Black Night (5) [MIDIfind.com].mid and /dev/null differ diff --git a/.smallclaw/users/papa/workspace/music/Midnight Blue Claw/Midnight Blue Claw.mid b/.smallclaw/users/papa/workspace/music/Midnight Blue Claw/Midnight Blue Claw.mid deleted file mode 100644 index 2594876..0000000 Binary files a/.smallclaw/users/papa/workspace/music/Midnight Blue Claw/Midnight Blue Claw.mid and /dev/null differ diff --git a/.smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.MID b/.smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.MID deleted file mode 100644 index c156377..0000000 Binary files a/.smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.MID and /dev/null differ diff --git a/.smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.mid b/.smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.mid deleted file mode 100644 index c156377..0000000 Binary files a/.smallclaw/users/papa/workspace/music/SMOK_WAT/SMOK_WAT.mid and /dev/null differ diff --git a/.smallclaw/users/papa/workspace/music/Super Exciting Jazz/Super Exciting Jazz.mid b/.smallclaw/users/papa/workspace/music/Super Exciting Jazz/Super Exciting Jazz.mid deleted file mode 100644 index ba4322c..0000000 Binary files a/.smallclaw/users/papa/workspace/music/Super Exciting Jazz/Super Exciting Jazz.mid and /dev/null differ diff --git a/.smallclaw/users/papa/workspace/music/smallclaw_morning/smallclaw_morning.mid b/.smallclaw/users/papa/workspace/music/smallclaw_morning/smallclaw_morning.mid deleted file mode 100644 index c999be8..0000000 Binary files a/.smallclaw/users/papa/workspace/music/smallclaw_morning/smallclaw_morning.mid and /dev/null differ diff --git a/.smallclaw/users/papa/workspace/music/smallclaw_morning_full/smallclaw_morning_full.mid b/.smallclaw/users/papa/workspace/music/smallclaw_morning_full/smallclaw_morning_full.mid deleted file mode 100644 index 4c0f6e7..0000000 Binary files a/.smallclaw/users/papa/workspace/music/smallclaw_morning_full/smallclaw_morning_full.mid and /dev/null differ diff --git a/.smallclaw/users/papa/workspace/music/soldieroffortune/soldieroffortune.mid b/.smallclaw/users/papa/workspace/music/soldieroffortune/soldieroffortune.mid deleted file mode 100644 index 5638c67..0000000 Binary files a/.smallclaw/users/papa/workspace/music/soldieroffortune/soldieroffortune.mid and /dev/null differ diff --git a/.smallclaw/users/papa/workspace/music/신나는_재즈_잼/신나는_재즈_잼.mid b/.smallclaw/users/papa/workspace/music/신나는_재즈_잼/신나는_재즈_잼.mid deleted file mode 100644 index f8c4da7..0000000 Binary files a/.smallclaw/users/papa/workspace/music/신나는_재즈_잼/신나는_재즈_잼.mid and /dev/null differ diff --git a/.smallclaw/workspace/memory/2026-06-21.md b/.smallclaw/workspace/memory/2026-06-21.md index 566b78d..2f4eddd 100644 --- a/.smallclaw/workspace/memory/2026-06-21.md +++ b/.smallclaw/workspace/memory/2026-06-21.md @@ -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 diff --git a/src/gateway/routes-music.ts b/src/gateway/routes-music.ts index 5f3fa19..1ba4e34 100644 --- a/src/gateway/routes-music.ts +++ b/src/gateway/routes-music.ts @@ -274,4 +274,138 @@ app.delete('/api/music/workspace-file/:dir', (req, res) => { } }); +// POST /api/music/gp — save GP file to workspace/music/gp/ +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/.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 diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 413e9bc..8d69e5c 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -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 = /]*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*(\.[a-z0-9]+)<\/td>/i); + const dlM = row.match(/badge">([^<]+) { + 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[] = []; + // …title… + const re = /]*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: (attribute order varies) + const re = /]*)>/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); diff --git a/web-ui/app.js b/web-ui/app.js index c5a2928..f808675 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -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) { + '' + ''; html += ''; - html += ''; + html += ''; html += '⬇ MIDI'; } if (pdfUrl) html += '⬇ PDF'; @@ -6341,7 +6343,7 @@ function buildMidiPickerWidget(data, filename) { savedLine + '
' + '' + - '' + + '' + '' + '
' + 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'); diff --git a/web-ui/index.html b/web-ui/index.html index 1db4459..f130683 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -53,14 +53,7 @@ 🍒 CherryClaw -
- - - -
-
- @@ -115,6 +108,9 @@ @@ -154,16 +150,6 @@
--%
-
- ↓ -
- 0 B/s -
-
- ↑ -
- 0 B/s -
@@ -187,6 +173,23 @@ + + + @@ -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 + @@ -455,25 +459,6 @@ - - - + + + `).join('')} + `; + }).join('')} - +
MIDI 편집기 + +
+
+
+ +
+ + +
+ + + +
+ 양자화 + +
+ BPM + +
+ + + + +
+ +
+ MIDI 출력 + + +
+ +
+ +
+
+
트랙
+
+
+
+
+
bar
+ +
+
+ + +
+
+
vel
+ +
+
+
+
+ + + + 1:1:000 + 루프 + + In + Out + + +
+
+ +
+ +
+
+ + Guitar Pro + + +
+
+ 📤 MIDI 출력 + + ♩= + + BPM + 🔊 마스터 + + 100% + +
+ +
+
+
Guitar Pro 파일을 로딩 중...
+
+
+
+ + +
+
+ + 🥁 드럼 박스 + 샘플 로딩 전 + + + ♩= + + BPM + 🔊 + +
+ + + +
+
+ 리듬 + + 스윙 + + 0% (8분) +
+ 저장된 패턴 + + + + +
+
+ +
+
+ 🎤 루퍼 + + + + + + 길이 + +
+
+ 레이어 0 · 대기 +
+
+
● 녹음을 누르면 드럼 비트에 맞춰 녹음 시작 (1마디 카운트인 후 녹음). 재생 중 누르면 오버덥(겹쳐 녹음).
+
-
@@ -240,7 +589,7 @@ function toggleTheme() { const d=document.documentElement; const n=d.getAttribute('data-theme')==='dark'?'light':'dark'; d.setAttribute('data-theme',n); - try{localStorage.setItem('cherryclaw_theme',n);}catch{} + try{localStorage.setItem('cherryclaw_music_theme',n);}catch{} } // ── Resize handle ───────────────────────────────────────────────────────────── @@ -312,6 +661,7 @@ function saveModel() { const SID_KEY = 'music_app_session'; let skillContext = ''; let abortCtrl = null; +const _renderedMidiIds = new Set(); function getSessionId() { try { @@ -345,16 +695,107 @@ function clearStage() { } function openEditor(midiUrl, filename) { + // Stop any active playback + if (typeof playing !== 'undefined' && playing) doStop(); + if (_midiPlaying) { _midiPlaying = false; try { _spessaSeq?.pause(); } catch {} } + + // Hide other stages document.getElementById('stage-player').style.display = 'none'; + document.getElementById('stage-gp').style.display = 'none'; + const ed = document.getElementById('stage-editor'); ed.classList.add('visible'); document.getElementById('editor-title').textContent = '🎹 ' + (filename || 'MIDI 편집기'); - document.getElementById('editor-frame').src = '/midi-editor.html?url=' + encodeURIComponent(midiUrl); + + // Resize canvases after layout (they were hidden before) + requestAnimationFrame(() => { resize(); pushH(); updPos(); }); + + if (midiUrl) meLoadUrl(midiUrl); } function closeEditor() { + if (typeof playing !== 'undefined' && playing) doStop(); document.getElementById('stage-editor').classList.remove('visible'); - document.getElementById('editor-frame').src = ''; + const sp = document.getElementById('stage-player'); + sp.style.display = 'flex'; sp.style.flexDirection = 'column'; + sp.style.flex = '1'; sp.style.minHeight = '0'; sp.style.overflow = 'hidden'; +} + +// ── SpessaSynth SF2 MIDI 플레이어 ──────────────────────────────────────────── +let _spessaCtx = null, _spessaSynth = null, _spessaSeq = null, _spessaSfBuf = null; +let _spessaLoading = false; + +function midiSfGpStatus(msg) { + const el = document.getElementById('gp-loading'); + if (el) { el.style.display = 'block'; el.textContent = msg; } +} + +function _setMidiBtn(playing) { + const btn = document.getElementById('gp-play-btn'); + const loading = document.getElementById('gp-loading'); + if (playing) { + if (btn) btn.textContent = '⏸ 일시정지'; + if (loading) loading.style.display = 'none'; + } else { + if (btn) btn.textContent = '▶ 재생'; + } +} + +async function _ensureSpessa() { + if (_spessaSynth) return; + if (_spessaLoading) { await new Promise(r => setTimeout(r, 200)); return _ensureSpessa(); } + _spessaLoading = true; + try { + if (!window.SpessaSynth) { + await new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = '/spessasynth.js'; + s.onload = res; s.onerror = () => rej(new Error('SpessaSynth 로드 실패')); + document.head.appendChild(s); + }); + } + const AC = window.AudioContext || window.webkitAudioContext; + if (!_spessaCtx) _spessaCtx = new AC({ latencyHint: 'playback' }); + if (_spessaCtx.state === 'suspended') await _spessaCtx.resume(); + + if (!_spessaSfBuf) { + midiSfGpStatus('사운드폰트 로딩 중...'); + const r = await fetch('/GeneralUser-GS.sf2'); + if (!r.ok) throw new Error('SF2 로드 실패: ' + r.status); + _spessaSfBuf = await r.arrayBuffer(); + } + + await _spessaCtx.audioWorklet.addModule('/spessasynth_processor.min.js'); + const { WorkletSynthesizer, Sequencer } = window.SpessaSynth; + _spessaSynth = new WorkletSynthesizer(_spessaCtx); + _spessaSynth.connect(_spessaCtx.destination); // AudioWorkletNode → output (the global connect-patch redirects this through the master FX chain) + await _spessaSynth.soundBankManager.addSoundBank(_spessaSfBuf.slice(0), 'gs'); + await _spessaSynth.isReady; + _spessaSeq = new Sequencer(_spessaSynth); + } finally { + _spessaLoading = false; + } +} + +// ── Guitar Pro (AlphaTab) ───────────────────────────────────────────────────── +let gpApi = null, gpStageMode = 'gp'; +const gpFiles = []; + +function showStageGp() { + document.getElementById('stage-player').style.display = 'none'; + document.getElementById('stage-editor').classList.remove('visible'); + document.getElementById('stage-gp').style.display = 'flex'; + document.getElementById('stage-gp').style.flexDirection = 'column'; + document.getElementById('stage-gp').style.flex = '1'; + // NOTE: MIDI access is NOT auto-initialized here. requestMIDIAccess() can + // crash the renderer via the OmniMIDI driver. It is only triggered when the + // user explicitly focuses the MIDI output dropdown (opt-in). +} + +function closeGpViewer() { + if (gpStageMode === 'midi' && _spessaSeq) { try{_spessaSeq.pause();}catch{} _midiPlaying = false; } + gpStageMode = 'gp'; + document.getElementById('stage-gp').style.display = 'none'; document.getElementById('stage-player').style.display = 'flex'; document.getElementById('stage-player').style.flexDirection = 'column'; document.getElementById('stage-player').style.flex = '1'; @@ -362,7 +803,1318 @@ function closeEditor() { document.getElementById('stage-player').style.overflow = 'hidden'; } -// ── MIDI file list ──────────────────────────────────────────────────────────── +function openGpFile(file) { + if (!file) return; + if (!window.alphaTab) { addChatMsg('system', '⚠️ AlphaTab 라이브러리 로딩 중... 잠시 후 다시 시도하세요.'); return; } + const reader = new FileReader(); + reader.onload = (e) => { + const name = file.name; + if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf: e.target.result }); + loadGpBuffer(e.target.result, name); + saveGpToServer(e.target.result.slice(0), name); + }; + reader.readAsArrayBuffer(file); +} + +async function saveGpToServer(buf, name) { + try { + await fetch('/api/music/gp', { + method: 'POST', + headers: { ...authH(), 'x-filename': encodeURIComponent(name), 'Content-Type': 'application/octet-stream' }, + body: buf + }); + loadGpFileList(); + } catch(e) { console.warn('GP 저장 실패:', e.message); } +} + +async function loadGpFileList() { + const el = document.getElementById('gp-file-list'); + try { + const r = await fetch('/api/music/gp/list', { headers: authH() }); + const data = await r.json(); + if (!data.files?.length) { el.innerHTML = '
저장된 파일 없음
'; return; } + el.innerHTML = data.files.map(f => ` +
+
+ 🎸 ${escHtml(f.name)} +
+ × +
`).join(''); + } catch { el.innerHTML = '
로딩 실패
'; } +} + +async function loadGpFromServer(path, name) { + try { + const r = await fetch(path, { headers: authH() }); + const buf = await r.arrayBuffer(); + if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf }); + loadGpBuffer(buf, name); + } catch(e) { addChatMsg('system', '❌ GP 로드 실패: ' + escHtml(e.message)); } +} + +async function deleteGpFile(name) { + if (!confirm(`"${name}" 삭제할까요?`)) return; + await fetch(`/api/music/gp-file/${encodeURIComponent(name)}`, { method: 'DELETE', headers: authH() }); + loadGpFileList(); +} + +function renderGpFileList() { + loadGpFileList(); +} + +let gpSite = 'guitarprotabs'; +function gpSetSite(site) { + gpSite = site; + document.getElementById('gp-site-gptabs').style.background = site === 'guitarprotabs' ? '#a855f7' : 'var(--panel)'; + document.getElementById('gp-site-gptabs').style.color = site === 'guitarprotabs' ? '#fff' : 'var(--muted)'; + document.getElementById('gp-site-gptabs').style.borderColor = site === 'guitarprotabs' ? '#a855f7' : 'var(--line)'; + document.getElementById('gp-site-gprotab').style.background = site === 'gprotab' ? '#a855f7' : 'var(--panel)'; + document.getElementById('gp-site-gprotab').style.color = site === 'gprotab' ? '#fff' : 'var(--muted)'; + document.getElementById('gp-site-gprotab').style.borderColor = site === 'gprotab' ? '#a855f7' : 'var(--line)'; + document.getElementById('gp-search-input').placeholder = site === 'gprotab' ? '곡명/아티스트 검색...' : '아티스트 검색...'; + document.getElementById('gp-search-results').innerHTML = '
아티스트 또는 곡명으로 검색
'; +} + +async function gpSearch() { + const q = document.getElementById('gp-search-input').value.trim(); + if (!q) return; + const el = document.getElementById('gp-search-results'); + el.innerHTML = '
검색 중...
'; + try { + const r = await fetch(`/api/gp-search?q=${encodeURIComponent(q)}&site=${gpSite}`, { headers: authH() }); + const data = await r.json(); + if (data.error && !data.results?.length) { + el.innerHTML = `
아티스트를 찾을 수 없습니다
`; return; + } + if (!data.results?.length) { + el.innerHTML = '
결과 없음
'; return; + } + el.innerHTML = data.results.map(r => ` +
+
🎸 ${escHtml(r.title)}
+
${escHtml(r.ext)} · ↓${escHtml(r.downloads)}
+
`).join(''); + } catch(e) { + el.innerHTML = `
오류: ${escHtml(e.message)}
`; + } +} + +async function gpDownloadAndOpen(tabUrl, title) { + const el = document.getElementById('gp-search-results'); + // highlight selected + el.querySelectorAll('.ms-file-item').forEach(d => d.style.background = ''); + event?.currentTarget?.style && (event.currentTarget.style.background = 'var(--brand-dim, rgba(168,85,247,.15))'); + addChatMsg('system', `🎸 ${escHtml(title)} 다운로드 중...`); + try { + const r = await fetch(`/api/gp-download?url=${encodeURIComponent(tabUrl)}`, { headers: authH() }); + if (!r.ok) throw new Error(await r.text()); + const buf = await r.arrayBuffer(); + const name = title + '.gp'; + if (!gpFiles.find(f => f.name === name)) gpFiles.push({ name, buf }); + loadGpBuffer(buf, name); + saveGpToServer(buf.slice(0), name); + addChatMsg('system', `✅ ${escHtml(title)} 열림 · 저장됨`); + } catch(e) { + addChatMsg('system', `❌ 다운로드 실패: ${escHtml(e.message)}`); + } +} + +// ── MIDI download site (midifind / bitmidi) ────────────────────────────────── +let mdlSite = 'midifind'; +function mdlSetSite(site) { + mdlSite = site; + const a = document.getElementById('mdl-site-midifind'); + const b = document.getElementById('mdl-site-bitmidi'); + const apply = (el, on) => { + el.style.background = on ? '#a855f7' : 'var(--panel)'; + el.style.color = on ? '#fff' : 'var(--muted)'; + el.style.borderColor = on ? '#a855f7' : 'var(--line)'; + }; + apply(a, site === 'midifind'); + apply(b, site === 'bitmidi'); + document.getElementById('mdl-search-input').placeholder = site === 'bitmidi' ? '곡명 검색...' : '곡명/아티스트 검색...'; + document.getElementById('mdl-search-results').innerHTML = '
곡명 또는 아티스트로 검색
'; +} + +async function mdlSearch() { + const q = document.getElementById('mdl-search-input').value.trim(); + if (!q) return; + const el = document.getElementById('mdl-search-results'); + el.innerHTML = '
검색 중...
'; + try { + const r = await fetch(`/api/midi-search?q=${encodeURIComponent(q)}&site=${mdlSite}`, { headers: authH() }); + const data = await r.json(); + if (data.error && !data.results?.length) { + el.innerHTML = `
${escHtml(data.error)}
`; return; + } + if (!data.results?.length) { + el.innerHTML = '
결과 없음
'; return; + } + el.innerHTML = data.results.map(r => ` +
+
🎵 ${escHtml(r.title)}
+
`).join(''); + } catch(e) { + el.innerHTML = `
오류: ${escHtml(e.message)}
`; + } +} + +async function mdlDownload(siteUrl, title) { + const el = document.getElementById('mdl-search-results'); + el.querySelectorAll('.ms-file-item').forEach(d => d.style.background = ''); + event?.currentTarget?.style && (event.currentTarget.style.background = 'var(--brand-dim, rgba(168,85,247,.15))'); + const safeName = title.replace(/[/\\:*?"<>|]/g, '_').slice(0, 80); + addChatMsg('system', `🎵 ${escHtml(title)} 다운로드 중...`); + try { + const r = await fetch(`/api/midi-download?url=${encodeURIComponent(siteUrl)}&site=${mdlSite}&name=${encodeURIComponent(safeName + '.mid')}`, { headers: authH() }); + if (!r.ok) throw new Error(await r.text()); + const blob = await r.blob(); + const filename = safeName + '.mid'; + const file = new File([blob], filename, { type: 'audio/midi' }); + const form = new FormData(); form.append('file', file); + const up = await fetch('/api/music/midi', { method:'POST', headers: authH(), body: form }); + const data = await up.json(); + if (!up.ok) throw new Error(data.error || '등록 실패'); + const { html } = buildMidiPickerWidget(data, filename); + addToStage(html, filename); + addChatMsg('system', `✅ ${escHtml(title)} 다운로드 완료 · 스테이지에 추가`); + loadMidiFiles(); + } catch(e) { + addChatMsg('system', `❌ 다운로드 실패: ${escHtml(e.message)}`); + } +} + +// ── GP 오디오 효과 체인 ────────────────────────────────────── +let gpFx = null; +let gpMidiOut = null; + +function patchAudioForGpFx() { + // Use large playback buffer to reduce audio underruns + const _OrigAC = window.AudioContext || window.webkitAudioContext; + if (!_OrigAC._patched) { + // Patch AudioNode.connect once (global) so any connect(ctx.destination) + // is redirected into our master FX chain input — applies reverb/chorus/ + // delay/compression to every audio source (AlphaTab SF2, drums, looper). + if (!_origConnect) { + _origConnect = AudioNode.prototype.connect; + AudioNode.prototype.connect = function(dest, ...rest) { + const r = _destRedirect.get(dest); + if (r) return _origConnect.call(this, r, ...rest); + return _origConnect.call(this, dest, ...rest); + }; + } + window.AudioContext = window.webkitAudioContext = function(opts) { + const ctx = new _OrigAC({ latencyHint: 'playback', ...opts }); + try { buildAudioFxChain(ctx); } catch(e) { console.warn('fx chain', e); } + return ctx; + }; + window.AudioContext.prototype = _OrigAC.prototype; + window.AudioContext._patched = true; + } + gpFx = {}; +} + +// ── Global master FX chain (reverb · chorus · delay · compression) ─────────── +let gpFxChains = []; +const _destRedirect = new WeakMap(); +let _origConnect = null; +let gpFxCtrl = { + reverb: { on: false, mix: 30 }, + chorus: { on: false, mix: 30 }, + delay: { on: false, mix: 25, time: 300 }, + comp: { on: false, amt: 0 }, +}; + +function makeImpulse(ctx, dur, decay) { + const rate = ctx.sampleRate, len = Math.max(1, Math.floor(rate * dur)); + const buf = ctx.createBuffer(2, len, rate); + for (let c = 0; c < 2; c++) { + const d = buf.getChannelData(c); + for (let i = 0; i < len; i++) d[i] = (Math.random() * 2 - 1) * Math.pow(1 - i / len, decay); + } + return buf; +} + +function buildAudioFxChain(ctx) { + const input = ctx.createGain(); input.gain.value = 1; + const dry = ctx.createGain(); dry.gain.value = 1; + const mixer = ctx.createGain(); mixer.gain.value = 1; + + // Reverb — convolver with synthetic impulse response + const revSend = ctx.createGain(); revSend.gain.value = 0; + const conv = ctx.createConvolver(); conv.buffer = makeImpulse(ctx, 2.2, 2.5); + const revWet = ctx.createGain(); revWet.gain.value = 1; + input.connect(revSend); revSend.connect(conv); conv.connect(revWet); revWet.connect(mixer); + + // Chorus — short modulated delay (LFO on delayTime) + const choSend = ctx.createGain(); choSend.gain.value = 0; + const choDelay = ctx.createDelay(0.1); choDelay.delayTime.value = 0.025; + const lfo = ctx.createOscillator(); lfo.type = 'sine'; lfo.frequency.value = 1.2; + const lfoGain = ctx.createGain(); lfoGain.gain.value = 0.003; + lfo.connect(lfoGain); lfoGain.connect(choDelay.delayTime); lfo.start(); + const choWet = ctx.createGain(); choWet.gain.value = 1; + input.connect(choSend); choSend.connect(choDelay); choDelay.connect(choWet); choWet.connect(mixer); + + // Delay — feedback echo + const dlySend = ctx.createGain(); dlySend.gain.value = 0; + const dlyDelay = ctx.createDelay(2.0); dlyDelay.delayTime.value = 0.3; + const fb = ctx.createGain(); fb.gain.value = 0; + const dlyWet = ctx.createGain(); dlyWet.gain.value = 1; + input.connect(dlySend); dlySend.connect(dlyDelay); + dlyDelay.connect(fb); fb.connect(dlyDelay); + dlyDelay.connect(dlyWet); dlyWet.connect(mixer); + + // Dry path + input.connect(dry); dry.connect(mixer); + + // Compressor — master bus, transparent when off (ratio 1, threshold 0) + const comp = ctx.createDynamicsCompressor(); + comp.threshold.value = 0; comp.ratio.value = 1; comp.knee.value = 6; + comp.attack.value = 0.003; comp.release.value = 0.25; + mixer.connect(comp); + _origConnect.call(comp, ctx.destination); // bypass redirect → real output + + // NOW redirect: every future connect(ctx.destination) feeds `input` + _destRedirect.set(ctx.destination, input); + + const chain = { ctx, input, revSend, choSend, dlySend, dlyDelay, fb, comp }; + gpFxChains.push(chain); + applyFxCtrl(chain); +} + +function applyFxCtrl(ch) { + const c = gpFxCtrl; + ch.revSend.gain.value = c.reverb.on ? (c.reverb.mix / 100) * 0.6 : 0; + ch.choSend.gain.value = c.chorus.on ? (c.chorus.mix / 100) * 0.7 : 0; + ch.dlySend.gain.value = c.delay.on ? (c.delay.mix / 100) * 0.5 : 0; + ch.dlyDelay.delayTime.value = Math.max(0.02, (c.delay.time || 300) / 1000); + ch.fb.gain.value = c.delay.on ? 0.38 : 0; + const a = c.comp.on ? c.comp.amt / 100 : 0; + ch.comp.threshold.value = -50 * a; // 0 → 0dB (transparent), 1 → -50dB + ch.comp.ratio.value = 1 + 11 * a; // 1 → 12:1 +} +function applyFxCtrlAll() { gpFxChains.forEach(applyFxCtrl); } + +function setFxOn(key, on) { gpFxCtrl[key].on = on; applyFxCtrlAll(); } +function setFxMix(key, v) { gpFxCtrl[key].mix = parseInt(v, 10) || 0; applyFxCtrlAll(); } +function setFxTime(v) { gpFxCtrl.delay.time = parseInt(v, 10) || 0; applyFxCtrlAll(); } +function setFxComp(v) { gpFxCtrl.comp.amt = parseInt(v, 10) || 0; applyFxCtrlAll(); } +function toggleGpFxPanel() { + const p = document.getElementById('gp-fx-panel'); + if (p) p.style.display = p.style.display === 'none' ? 'flex' : 'none'; +} +function toggleMeFxPanel() { + const p = document.getElementById('me-fx-panel'); + if (p) p.style.display = p.style.display === 'none' ? 'flex' : 'none'; +} + + +// ── MIDI 외부 출력 ──────────────────────────────────────────── +let _midiAccess = null; +let _midiInitPending = false; + +async function initMidiOut() { + const sel = document.getElementById('gp-midi-out'); + if (!navigator.requestMIDIAccess) { + sel.innerHTML = ''; + return; + } + // Already initialized — just refresh the port list + if (_midiAccess) { refreshMidiPorts(); return; } + if (_midiInitPending) return; + _midiInitPending = true; + try { + // Wrap in a race with timeout — some MIDI drivers hang or crash on access. + // Note: a native driver crash (e.g. OmniMIDI) kills the renderer regardless + // of this timeout; this only guards against a JS-level hang. + const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error('timeout')), 3000)); + _midiAccess = await Promise.race([ + navigator.requestMIDIAccess({ sysex: false }), + timeout + ]); + refreshMidiPorts(); + _midiAccess.onstatechange = refreshMidiPorts; + } catch(e) { + sel.innerHTML = ``; + } finally { + _midiInitPending = false; + } +} + +function refreshMidiPorts() { + const sel = document.getElementById('gp-midi-out'); + const cur = sel.value; + const ports = [['', '-- 내장 SF2 --']]; + _midiAccess?.outputs?.forEach(p => ports.push([p.id, p.name])); + sel.innerHTML = ports.map(([id, name]) => + `` + ).join(''); +} + +function setGpTempo(val) { + const bpm = Math.max(20, Math.min(400, parseInt(val) || 0)); + if (!bpm) return; + document.getElementById('gp-tempo').value = bpm; + const orig = gpApi?.score?.tempo; + if (gpApi && orig) gpApi.playbackSpeed = bpm / orig; +} + +function selectMidiOut(portId) { + gpMidiOut = portId ? _midiAccess?.outputs?.get(portId) || null : null; + if (gpApi) gpApi.masterVolume = gpMidiOut ? 0 : 1; + const mv = document.getElementById('gp-master-vol'); + if (mv) { mv.value = gpMidiOut ? 0 : 100; const l = document.getElementById('gp-master-vol-val'); if (l) l.textContent = (gpMidiOut ? 0 : 100) + '%'; } + if (gpMidiOut) { + addChatMsg('system', `🎹 MIDI 포트 "${escHtml(gpMidiOut.name)}" 선택됨 — SF2 음소거`); + sendMidiProgramChanges(); + } +} + +// Send GM program changes for all tracks using score data +function sendMidiProgramChanges() { + if (!gpMidiOut || !gpApi?.score?.tracks) return; + for (const track of gpApi.score.tracks) { + const info = track.playbackInfo; + if (!info) continue; + const pch = (info.primaryChannel ?? 0) & 0x0F; + const sch = (info.secondaryChannel ?? pch) & 0x0F; + const prog = (info.program ?? 0) & 0x7F; + // MIDI spec: Bank Select must come BEFORE Program Change + const vol = Math.min(127, Math.round((info.volume ?? 13) / 16 * 127)); + const pan = Math.min(127, Math.round((info.balance ?? 8) / 16 * 127)); + for (const ch of [...new Set([pch, sch])]) { + gpMidiOut.send([0xB0 | ch, 0, 0]); // Bank Select MSB + gpMidiOut.send([0xB0 | ch, 32, 0]); // Bank Select LSB + gpMidiOut.send([0xC0 | ch, prog]); // Program Change + gpMidiOut.send([0xB0 | ch, 7, vol]); // CC7 Volume + gpMidiOut.send([0xB0 | ch, 10, pan]); // CC10 Pan + } + } +} + +function setupMidiForwarding() { + if (!gpApi) return; + + // AlphaTab internal enum: NoteOn=0x80, NoteOff=0x90 (swapped vs MIDI standard) + // Filter uses these internal values + try { gpApi.midiEventsPlayedFilter = [0x80, 0x90, 0xB0, 0xC0, 0xE0]; } catch(e) {} + + gpApi.midiEventsPlayed.on(args => { + if (!gpMidiOut) return; + for (const e of args.events) { + try { + const ch = (e.channel ?? 0) & 0x0F; + const t = e.type ?? 0; + + if (e.noteKey !== undefined) { + // Swap bytes: AlphaTab 0x80=NoteOn / 0x90=NoteOff → MIDI 0x90=NoteOn / 0x80=NoteOff + const status = (t === 0x80 ? 0x90 : 0x80) | ch; + gpMidiOut.send([status, e.noteKey & 0x7F, (e.noteVelocity ?? 0) & 0x7F]); + } else if (e.controllerNumber !== undefined) { + gpMidiOut.send([0xB0 | ch, e.controllerNumber & 0x7F, (e.controllerValue ?? 0) & 0x7F]); + } else if (e.program !== undefined) { + gpMidiOut.send([0xC0 | ch, e.program & 0x7F]); + } else if (t === 0xE0 && e.value !== undefined) { + const v = e.value & 0x3FFF; + gpMidiOut.send([0xE0 | ch, v & 0x7F, (v >> 7) & 0x7F]); + } + } catch(err) { console.warn('MIDI send:', err.message); } + } + }); + + // Send program changes now (in case port already selected) + sendMidiProgramChanges(); +} + +// ───────────────────────────────────────────────────────────── + +function loadGpBuffer(buf, name) { + showStageGp(); + document.getElementById('gp-title').textContent = '🎸 ' + name; + document.getElementById('gp-loading').style.display = 'block'; + document.getElementById('gp-main').innerHTML = ''; + document.getElementById('gp-track-list').innerHTML = ''; + document.getElementById('gp-tempo').value = ''; + gpMuted.clear(); + if (gpApi) { try { gpApi.destroy(); } catch(e){} gpApi = null; } + + const container = document.getElementById('gp-main'); + const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; + + // ArrayBuffer → Blob URL (AlphaTab 1.8 requires URL or Uint8Array, not raw ArrayBuffer) + const blob = new Blob([new Uint8Array(buf)], { type: 'application/octet-stream' }); + const blobUrl = URL.createObjectURL(blob); + + const settings = { + file: blobUrl, + display: { + layoutMode: alphaTab.LayoutMode.Page, + scale: 0.9, + resources: isDark ? { + staffLineColor: new alphaTab.model.Color(107, 114, 128), + barSeparatorColor: new alphaTab.model.Color(107, 114, 128), + mainGlyphColor: new alphaTab.model.Color(229, 231, 235), + secondaryGlyphColor: new alphaTab.model.Color(156, 163, 175), + scoreInfoColor: new alphaTab.model.Color(229, 231, 235), + } : {} + }, + player: { + enablePlayer: true, + enableCursor: true, + soundFont: '/GeneralUser-GS.sf2', + scrollElement: document.getElementById('gp-viewport') + } + }; + + try { + patchAudioForGpFx(); // intercept AudioNode→destination before AlphaTab creates its audio graph + gpApi = new alphaTab.AlphaTabApi(container, settings); + setGpMasterVol(100); + gpApi.renderFinished.on(() => { + URL.revokeObjectURL(blobUrl); + document.getElementById('gp-loading').style.display = 'none'; + renderGpTracks(); + const bpm = gpApi?.score?.tempo; + if (bpm) document.getElementById('gp-tempo').value = bpm; + }); + gpApi.playerStateChanged.on((e) => { + const btn = document.getElementById('gp-play-btn'); + if (btn) btn.textContent = e.state === alphaTab.synth.PlayerState.Playing ? '⏸ 일시정지' : '▶ 재생'; + }); + gpApi.soundFontLoaded.on(() => { setupMidiForwarding(); }); + gpApi.error.on((e) => { + URL.revokeObjectURL(blobUrl); + document.getElementById('gp-loading').textContent = '⚠️ 로딩 실패: ' + (e.message || e); + }); + } catch(err) { + URL.revokeObjectURL(blobUrl); + document.getElementById('gp-loading').textContent = '⚠️ 오류: ' + err.message; + } +} + +function renderGpTracks() { + if (!gpApi || !gpApi.score) return; + const tracks = gpApi.score.tracks; + if (!tracks || !tracks.length) return; + const el = document.getElementById('gp-track-list'); + const chips = tracks.map((t, i) => { + const muted = gpMuted.has(i); + const v = Math.round((t.playbackInfo?.volume ?? 11) / 16 * 100); + return ` + ${escHtml(t.name || '트랙 ' + (i+1))} + ${muted ? '🔇' : '🔊'} + + ${v} + `; + }).join(''); + el.innerHTML = `전체${chips}`; +} +function setGpMasterVol(v) { + const val = Math.max(0, Math.min(200, parseInt(v, 10) || 0)); + document.getElementById('gp-master-vol').value = val; + const lbl = document.getElementById('gp-master-vol-val'); if (lbl) lbl.textContent = val + '%'; + if (gpApi) gpApi.masterVolume = val / 100; +} +function setGpTrackVol(i, v) { + if (!gpApi?.score?.tracks?.[i]) return; + const val = Math.max(0, Math.min(100, parseInt(v, 10) || 0)) / 100; + gpApi.changeTrackVolume([gpApi.score.tracks[i]], val); + gpVolLabel(i, Math.round(val * 100)); +} +function gpVolLabel(i, v) { + const lbl = document.getElementById('gp-vol-val-' + i); + if (lbl) lbl.textContent = Math.round(v); +} + +const gpMuted = new Set(); +function gpToggleMute(idx) { + if (!gpApi || !gpApi.score) return; + const track = gpApi.score.tracks[idx]; + if (!track) return; + const mute = !gpMuted.has(idx); + if (mute) gpMuted.add(idx); else gpMuted.delete(idx); + try { gpApi.changeTrackMute([track], mute); } catch(e) { console.warn('mute:', e.message); } + renderGpTracks(); +} + +function gpSoloTrack(idx) { + if (!gpApi || !gpApi.score) return; + // 이미 이 트랙만 선택된 상태면 전체로 돌아감 + const allChips = document.querySelectorAll('.gp-track-chip:not(#gp-chip-all)'); + const onChips = Array.from(allChips).filter(c => c.classList.contains('on')); + if (onChips.length === 1 && onChips[0].id === 'gp-chip-' + idx) { + gpAllTracks(); return; + } + allChips.forEach(c => c.classList.remove('on')); + document.getElementById('gp-chip-all').classList.remove('on'); + document.getElementById('gp-chip-' + idx).classList.add('on'); + gpApi.renderTracks([gpApi.score.tracks[idx]]); +} + +function gpAllTracks() { + if (!gpApi || !gpApi.score) return; + document.querySelectorAll('.gp-track-chip').forEach(c => c.classList.add('on')); + gpApi.renderTracks(gpApi.score.tracks); +} + +let _midiPlaying = false; +function gpTogglePlay() { + if (gpStageMode === 'midi') { + if (!_spessaSeq) return; + try { + if (_midiPlaying) { + _spessaSeq.pause(); _midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('일시정지'); + } else { + if (_spessaCtx && _spessaCtx.state === 'suspended') _spessaCtx.resume(); + _spessaSeq.play(); _midiPlaying = true; _setMidiBtn(true); + } + } catch(e) { console.error('gpTogglePlay midi:', e); } + return; + } + if (!gpApi) return; + gpApi.playPause(); +} + +function gpStop() { + if (gpStageMode === 'midi') { + if (_spessaSeq) try { _spessaSeq.pause(); } catch{} + _midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('정지'); + return; + } + if (!gpApi) return; + gpApi.stop(); +} + +// ── Shared AudioContext (drum box + looper) ─────────────────────────────────── +let _ac = null; +function getAudioCtx() { + if (!_ac) { + const AC = window.AudioContext || window.webkitAudioContext; + _ac = new AC({ latencyHint: 'playback' }); + } + if (_ac.state === 'suspended') _ac.resume(); + return _ac; +} + +// ── Drum box ────────────────────────────────────────────────────────────────── +const DRUM_VOICES = [ + { name: '킥', note: 36, synth: synthKick }, + { name: '스네어', note: 38, synth: synthSnare }, + { name: '클로즈햇', note: 42, synth: (t,v)=>synthHat(t,v,false) }, + { name: '오픈햇', note: 46, synth: (t,v)=>synthHat(t,v,true ) }, + { name: '클랩', note: 39, synth: synthClap }, + { name: '로우탐', note: 45, synth: (t,v)=>synthTom(t,v,90,55) }, + { name: '하이탐', note: 48, synth: (t,v)=>synthTom(t,v,180,110) }, + { name: '심벌', note: 49, synth: synthCrash }, +]; +let drumPattern = []; // [voiceIdx][step] bool +let drumSteps = 16; +let drumBpm = 120; +let drumPlaying = false; +let drumStep = 0; +let drumStartTime = 0; +let drumNextTime = 0; +let drumTimer = null; +let drumRaf = null; +let drumMaster = null; +let drumSamples = {}; // voiceIdx -> [AudioBuffer] (round-robin variants) +let drumVarIdx = []; // per-voice variant counter +let drumSamplesLoaded = false; +let drumSampleLoading = false; + +function drumNoiseBuf() { + const ctx = getAudioCtx(); + if (!ctx._noiseBuf) { + const len = ctx.sampleRate; // 1s + const buf = ctx.createBuffer(1, len, ctx.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + ctx._noiseBuf = buf; + } + return ctx._noiseBuf; +} +function drumMasterGain() { + if (!drumMaster) { + const ctx = getAudioCtx(); + drumMaster = ctx.createGain(); + drumMaster.gain.value = 0.8; + drumMaster.connect(ctx.destination); + } + return drumMaster; +} +async function drumLoadSamples() { + if (drumSamplesLoaded || drumSampleLoading) return; + drumSampleLoading = true; + const st = document.getElementById('drum-sample-status'); + if (st) st.textContent = '샘플 로딩 중…'; + try { + const ctx = getAudioCtx(); + const r = await fetch('/drums/manifest.json', { headers: authH() }); + if (!r.ok) throw new Error('manifest ' + r.status); + const map = await r.json(); + let total = 0, ok = 0; + for (const k in map) total += map[k].length; + for (const k in map) { + const vi = parseInt(k, 10); + drumSamples[vi] = []; + for (const fname of map[k]) { + try { + const ar = await fetch('/drums/' + fname, { headers: authH() }); + if (!ar.ok) continue; + const ab = await ar.arrayBuffer(); + const buf = await ctx.decodeAudioData(ab.slice(0)); + drumSamples[vi].push(buf); + ok++; + } catch (e) { console.warn('drum sample decode fail', fname, e.message); } + } + } + drumSamplesLoaded = true; + if (st) st.textContent = `샘플 ${ok}/${total} · 실제음`; + } catch (e) { + if (st) st.textContent = '샘플 로딩 실패 (합성 사용)'; + console.warn('drum samples load fail', e.message); + } finally { + drumSampleLoading = false; + } +} +function drumTriggerVoice(vi, time, vel) { + const list = drumSamples[vi]; + if (list && list.length) { + const ctx = getAudioCtx(); + const idx = (drumVarIdx[vi] || 0) % list.length; + drumVarIdx[vi] = idx + 1; + const src = ctx.createBufferSource(); + src.buffer = list[idx]; + src.playbackRate.value = 1; + const g = ctx.createGain(); + g.gain.value = (vel == null ? 1 : vel); + src.connect(g).connect(drumMasterGain()); + src.start(time); + return; + } + DRUM_VOICES[vi].synth(time, vel == null ? 1 : vel); +} +function noiseSrc(t) { + const ctx = getAudioCtx(); + const s = ctx.createBufferSource(); + s.buffer = drumNoiseBuf(); + s.start(t); + return s; +} +function synthKick(t, v) { + const ctx = getAudioCtx(); + const osc = ctx.createOscillator(), g = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(150, t); + osc.frequency.exponentialRampToValueAtTime(50, t + 0.12); + g.gain.setValueAtTime(0.9 * v, t); + g.gain.exponentialRampToValueAtTime(0.0001, t + 0.18); + osc.connect(g).connect(drumMasterGain()); + osc.start(t); osc.stop(t + 0.2); +} +function synthSnare(t, v) { + const ctx = getAudioCtx(); + // noise body + const n = noiseSrc(t); + const bp = ctx.createBiquadFilter(); bp.type = 'bandpass'; bp.frequency.value = 1800; bp.Q.value = 0.7; + const ng = ctx.createGain(); ng.gain.setValueAtTime(0.7*v, t); ng.gain.exponentialRampToValueAtTime(0.0001, t+0.18); + n.connect(bp).connect(ng).connect(drumMasterGain()); + // tone + const osc = ctx.createOscillator(), og = ctx.createGain(); + osc.type = 'triangle'; osc.frequency.value = 180; + og.gain.setValueAtTime(0.4*v, t); og.gain.exponentialRampToValueAtTime(0.0001, t+0.1); + osc.connect(og).connect(drumMasterGain()); + osc.start(t); osc.stop(t+0.12); +} +function synthHat(t, v, open) { + const ctx = getAudioCtx(); + const n = noiseSrc(t); + const hp = ctx.createBiquadFilter(); hp.type = 'highpass'; hp.frequency.value = 7000; + const g = ctx.createGain(); + const dur = open ? 0.3 : 0.05; + g.gain.setValueAtTime(0.35*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+dur); + n.connect(hp).connect(g).connect(drumMasterGain()); +} +function synthClap(t, v) { + const ctx = getAudioCtx(); + const offsets = [0, 0.012, 0.024, 0.04]; + for (const off of offsets) { + const n = noiseSrc(t+off); + const bp = ctx.createBiquadFilter(); bp.type='bandpass'; bp.frequency.value=1500; bp.Q.value=0.5; + const g = ctx.createGain(); + const dur = off === offsets[3] ? 0.12 : 0.03; + g.gain.setValueAtTime(0.5*v, t+off); g.gain.exponentialRampToValueAtTime(0.0001, t+off+dur); + n.connect(bp).connect(g).connect(drumMasterGain()); + } +} +function synthTom(t, v, f0, f1) { + const ctx = getAudioCtx(); + const osc = ctx.createOscillator(), g = ctx.createGain(); + osc.type = 'sine'; + osc.frequency.setValueAtTime(f0, t); osc.frequency.exponentialRampToValueAtTime(f1, t+0.18); + g.gain.setValueAtTime(0.6*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.25); + osc.connect(g).connect(drumMasterGain()); + osc.start(t); osc.stop(t+0.3); +} +function synthCrash(t, v) { + const ctx = getAudioCtx(); + const n = noiseSrc(t); + const hp = ctx.createBiquadFilter(); hp.type='highpass'; hp.frequency.value=4000; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.35*v, t); g.gain.exponentialRampToValueAtTime(0.0001, t+0.6); + n.connect(hp).connect(g).connect(drumMasterGain()); +} + +function drumStepDur() { return 60 / drumBpm / 4; } // 16분음 + +function drumRenderGrid() { + const grid = document.getElementById('drum-grid'); + let html = ''; + for (let vi = 0; vi < DRUM_VOICES.length; vi++) { + const voice = DRUM_VOICES[vi]; + let cells = ''; + for (let s = 0; s < drumSteps; s++) { + const on = drumPattern[vi] && drumPattern[vi][s]; + const beat = (s % 4 === 0) ? ' beat' : ''; + cells += ``; + } + html += `
${voice.name}
${cells}
`; + } + grid.innerHTML = html; +} + +function drumToggle(vi, s, rightClick) { + drumPattern[vi] = drumPattern[vi] || new Array(drumSteps).fill(false); + if (rightClick) { // right-click = preview only, don't toggle + drumTriggerVoice(vi, getAudioCtx().currentTime, 1); + return; + } + drumPattern[vi][s] = !drumPattern[vi][s]; + const cell = document.getElementById(`dc-${vi}-${s}`); + if (cell) cell.classList.toggle('on', drumPattern[vi][s]); + if (drumPattern[vi][s]) drumTriggerVoice(vi, getAudioCtx().currentTime, 1); +} + +function drumPreview(vi) { drumTriggerVoice(vi, getAudioCtx().currentTime, 1); } + +function drumSetSteps(n) { + drumStop(); + if (loopRecording) loopStopRecord(); + if (loopPlaying) loopStop(); + const prev = drumPattern; + drumSteps = parseInt(n, 10) || 16; + drumPattern = DRUM_VOICES.map((_, vi) => { + const row = new Array(drumSteps).fill(false); + if (prev[vi]) for (let s = 0; s < Math.min(drumSteps, prev[vi].length); s++) row[s] = !!prev[vi][s]; + return row; + }); + drumRenderGrid(); +} + +function drumSetBpm(v) { + const nb = Math.max(40, Math.min(300, parseInt(v, 10) || 120)); + const changed = nb !== drumBpm; + drumBpm = nb; + document.getElementById('drum-bpm').value = drumBpm; + if (changed) { if (loopRecording) loopStopRecord(); if (loopPlaying) loopStop(); loopRenderLayers(); } +} + +function drumSetVol(v) { drumMasterGain().gain.value = (parseInt(v, 10) || 0) / 100; } + +function drumClear() { + drumPattern = DRUM_VOICES.map(() => new Array(drumSteps).fill(false)); + drumRenderGrid(); +} + +// ── Rhythm library (리듬별 패턴) ───────────────────────────────────────────── +// voice order: [0]킥 [1]스네어 [2]클로즈햇 [3]오픈햇 [4]클랩 [5]로우탐 [6]하이탐 [7]심벌 +const DRUM_RHYTHMS = { + rock: { label: '🤘 록 (Rock)', bpm: 120, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + pop: { label: '🎤 팝 (Pop)', bpm: 100, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,1,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + funk: { label: '🪩 펑크 (Funk)', bpm: 100, swing: 56, swingDiv: '16', pat: { 0:[1,0,0,1,0,0,1,0,0,0,1,0,0,1,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0] } }, + jazz: { label: '🎷 재즈 (Jazz swing)', bpm: 140, swing: 62, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,0,1,0,0,1,0,1,0,0,1,0,0,1,0] } }, + blues: { label: '🎸 블루스 셔플', bpm: 90, swing: 66, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + trap: { label: '🔥 트랩 (Trap)', bpm: 70, swing: 60, swingDiv: '16', pat: { 0:[1,0,0,1,0,0,0,0,0,0,1,0,0,0,0,0], 1:[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,0,0,0,0,0,0,0,0,0,0,0,0,0,1,0] } }, + hiphop: { label: '🎧 힙합 붐밥', bpm: 90, swing: 54, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,1,0,0,0,1,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + reggae: { label: '🏝️ 레게 (One-drop)', bpm: 80, swing: 52, swingDiv: '8', pat: { 0:[0,0,0,0,0,0,0,0,1,0,0,0,0,0,0,0], 1:[0,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, + disco: { label: '🕺 디스코 (4-on-floor)', bpm: 120, swing: 0, swingDiv: '8', pat: { 0:[1,0,0,0,1,0,0,0,1,0,0,0,1,0,0,0], 1:[0,0,0,0,1,0,0,0,0,0,0,0,1,0,0,0], 2:[1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,1], 3:[0,1,0,1,0,1,0,1,0,1,0,1,0,1,0,1] } }, + bossa: { label: '🌴 보사노바', bpm: 130, swing: 50, swingDiv: '8', pat: { 0:[1,0,0,0,0,0,1,0,1,0,0,0,0,0,0,0], 1:[0,0,0,1,0,0,1,0,0,0,0,1,0,0,1,0], 2:[1,0,1,0,1,0,1,0,1,0,1,0,1,0,1,0] } }, +}; +let drumSwing = 0; // 0..70 % — swing amount +let drumSwingDiv = '8'; // '8' = 8th-note swing (jazz/blues/hiphop/reggae/bossa), '16' = 16th-note swing (funk/trap, MPC-style) +function drumInitRhythmSelect() { + const sel = document.getElementById('drum-rhythm'); + if (!sel || sel.options.length) return; + sel.innerHTML = '' + + Object.entries(DRUM_RHYTHMS).map(([k, r]) => ``).join(''); +} +function drumLoadRhythm(key) { + const r = DRUM_RHYTHMS[key]; if (!r) return; + drumSetSteps(16); + drumBpm = r.bpm; document.getElementById('drum-bpm').value = r.bpm; + drumSwing = r.swing; drumSwingDiv = r.swingDiv || '8'; + document.getElementById('drum-swing').value = r.swing; + drumUpdateSwingLabel(); + drumPattern = DRUM_VOICES.map((_, vi) => { + const row = new Array(drumSteps).fill(false); + const src = r.pat[vi]; + if (src) for (let s = 0; s < Math.min(drumSteps, src.length); s++) row[s] = !!src[s]; + return row; + }); + drumRenderGrid(); +} +function drumSetSwing(v) { + drumSwing = Math.max(0, Math.min(70, parseFloat(v) || 0)); + drumUpdateSwingLabel(); +} +function drumUpdateSwingLabel() { + const el = document.getElementById('drum-swing-val'); + if (el) el.textContent = drumSwing + '%' + (drumSwingDiv === '16' ? ' (16분)' : ' (8분)'); +} + +// ── Saved patterns (server workspace) ───────────────────────────────────────── +async function drumRefreshSavedList() { + const sel = document.getElementById('drum-saved'); + if (!sel) return; + try { + const r = await fetch('/api/music/drums/list', { headers: authH() }); + const data = await r.json(); + const cur = sel.value; + const files = data.files || []; + sel.innerHTML = (files.length ? files : [{name:'', mtime:0}]).map((f, i) => + i === 0 && !files.length ? '' : + ``).join(''); + if (cur && files.some(f => f.name === cur)) sel.value = cur; + } catch {} +} +async function drumSavePattern() { + let name = prompt('패턴 이름을 입력하세요:', '내 패턴'); + if (!name) return; + name = name.trim().slice(0, 60); + if (!name) return; + const body = { name, steps: drumSteps, bpm: drumBpm, swing: drumSwing, swingDiv: drumSwingDiv, pattern: drumPattern }; + try { + const r = await fetch('/api/music/drums/save', { + method: 'POST', headers: authH({ 'Content-Type': 'application/json' }), body: JSON.stringify(body) }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || '저장 실패'); + addChatMsg('system', `💾 드럼 패턴 "${escHtml(name)}" 저장됨`); + await drumRefreshSavedList(); + document.getElementById('drum-saved').value = name; + } catch (e) { addChatMsg('system', '❌ 저장 실패: ' + escHtml(e.message)); } +} +async function drumLoadSaved(name) { + try { + const r = await fetch('/api/music/drums/' + encodeURIComponent(name), { headers: authH() }); + if (!r.ok) throw new Error('불러오기 실패 ' + r.status); + const d = await r.json(); + drumSetSteps(d.steps || 16); + drumBpm = d.bpm || 120; document.getElementById('drum-bpm').value = drumBpm; + drumSwing = d.swing || 0; drumSwingDiv = (d.swingDiv === '16' ? '16' : '8'); + document.getElementById('drum-swing').value = drumSwing; drumUpdateSwingLabel(); + drumPattern = DRUM_VOICES.map((_, vi) => { + const row = new Array(drumSteps).fill(false); + const src = d.pattern && d.pattern[vi]; + if (src) for (let s = 0; s < Math.min(drumSteps, src.length); s++) row[s] = !!src[s]; + return row; + }); + drumRenderGrid(); + addChatMsg('system', `📂 패턴 "${escHtml(name)}" 불러옴 · 편집 후 다시 저장 가능`); + } catch (e) { addChatMsg('system', '❌ 불러오기 실패: ' + escHtml(e.message)); } +} +function drumLoadSavedSel() { + const name = document.getElementById('drum-saved').value; + if (name) drumLoadSaved(name); +} +async function drumDeleteSavedSel() { + const name = document.getElementById('drum-saved').value; + if (!name) return; + if (!confirm(`패턴 "${name}" 삭제할까요?`)) return; + try { + const r = await fetch('/api/music/drums/' + encodeURIComponent(name), { method: 'DELETE', headers: authH() }); + if (!r.ok) throw new Error('삭제 실패'); + await drumRefreshSavedList(); + addChatMsg('system', `🗑 패턴 "${escHtml(name)}" 삭제됨`); + } catch (e) { addChatMsg('system', '❌ 삭제 실패: ' + escHtml(e.message)); } +} + +function drumScheduleStep(step, time) { + for (let vi = 0; vi < DRUM_VOICES.length; vi++) { + if (drumPattern[vi] && drumPattern[vi][step]) drumTriggerVoice(vi, time, 1); + } +} + +function drumScheduler() { + const ctx = getAudioCtx(); + const AHEAD = 0.1; + const sd = drumStepDur(); + // 8th-note swing: delay upbeat-8th group (step%4>=2) by swing/100 of a 16th. ~67% ≈ triplet. + // 16th-note swing (MPC): delay odd 16th steps (step%2===1) by swing/100 of a 32nd (sd/2). + const swingOff = drumSwing / 100 * (drumSwingDiv === '16' ? sd / 2 : sd); + const swingTest = drumSwingDiv === '16' ? (s => s % 2 === 1) : (s => s % 4 >= 2); + while (drumNextTime < ctx.currentTime + AHEAD) { + const t = drumNextTime + (swingTest(drumStep) ? swingOff : 0); + drumScheduleStep(drumStep, t); + drumNextTime += sd; + drumStep = (drumStep + 1) % drumSteps; + } + if (drumPlaying) drumTimer = setTimeout(drumScheduler, 25); +} + +function drumTogglePlay() { + if (drumPlaying) drumStop(); + else drumPlay(); +} +function drumPlay() { + const ctx = getAudioCtx(); + if (ctx.state === 'suspended') ctx.resume(); + drumPlaying = true; + drumStep = 0; + drumStartTime = ctx.currentTime + 0.06; + drumNextTime = drumStartTime; + document.getElementById('drum-play-btn').textContent = '⏸ 일시정지'; + drumScheduler(); + drumDrawLoop(); +} +function drumStop() { + drumPlaying = false; + if (drumTimer) { clearTimeout(drumTimer); drumTimer = null; } + if (drumRaf) { cancelAnimationFrame(drumRaf); drumRaf = null; } + const btn = document.getElementById('drum-play-btn'); + if (btn) btn.textContent = '▶ 재생'; + document.querySelectorAll('.drum-cell.cur').forEach(c => c.classList.remove('cur')); +} +function drumDrawLoop() { + const ctx = getAudioCtx(); + const elapsed = ctx.currentTime - drumStartTime; + let cur = Math.floor(elapsed / drumStepDur()); + if (cur < 0) cur = 0; + cur = cur % drumSteps; + if (cur !== drumDrawLast) { + document.querySelectorAll('.drum-cell.cur').forEach(c => c.classList.remove('cur')); + for (let vi = 0; vi < DRUM_VOICES.length; vi++) { + const cell = document.getElementById(`dc-${vi}-${cur}`); + if (cell) cell.classList.add('cur'); + } + drumDrawLast = cur; + } + if (drumPlaying) drumRaf = requestAnimationFrame(drumDrawLoop); +} +let drumDrawLast = -1; + +// ── Drum pattern → MIDI export ──────────────────────────────────────────────── +function vlen(n) { + const out = []; + let v = n; + while (true) { + out.unshift(v & 0x7F); + if (v < 0x80) break; + out[0] |= 0x80; + v >>= 7; + } + return out; +} +function drumBuildMidi() { + const TPQ = 480, STEP = 240, DUR = 120, CH = 9, VEL = 100; + const evs = []; // {tick, bytes} + for (let s = 0; s < drumSteps; s++) { + for (let vi = 0; vi < DRUM_VOICES.length; vi++) { + if (drumPattern[vi] && drumPattern[vi][s]) { + const note = DRUM_VOICES[vi].note; + const tick = s * STEP; + evs.push({ tick, bytes: [0x99, note, VEL] }); + evs.push({ tick: tick + DUR, bytes: [0x89, note, 0] }); + } + } + } + evs.sort((a, b) => a.tick - b.tick); + const body = []; + let lastTick = 0; + for (const e of evs) { + const delta = e.tick - lastTick; lastTick = e.tick; + for (const b of vlen(delta)) body.push(b); + for (const b of e.bytes) body.push(b); + } + // end of track + for (const b of vlen(0)) body.push(b); + body.push(0xFF, 0x2F, 0x00); + // tracks + const mtrk = [0x4D, 0x54, 0x72, 0x6B]; + const len = body.length; + mtrk.push((len>>>24)&0xFF, (len>>>16)&0xFF, (len>>>8)&0xFF, len&0xFF); + const mthd = [0x4D,0x54,0x68,0x64, 0x00,0x00,0x00,0x06, 0x00,0x00, 0x00,0x01, (TPQ>>8)&0xFF, TPQ&0xFF]; + return new Uint8Array([...mthd, ...mtrk, ...body]); +} + +async function drumExportMidi() { + const hasAny = drumPattern.some(row => row && row.some(Boolean)); + if (!hasAny) { addChatMsg('system', '패턴이 비어 있습니다. 셀을 클릭해 드럼을 채우세요.'); return; } + const bytes = drumBuildMidi(); + const blob = new Blob([bytes], { type: 'audio/midi' }); + const filename = '드럼패턴.mid'; + const file = new File([blob], filename, { type: 'audio/midi' }); + addChatMsg('system', '🥁 드럼 패턴 MIDI 내보내는 중...'); + try { + const form = new FormData(); form.append('file', file); + const r = await fetch('/api/music/midi', { method: 'POST', headers: authH(), body: form }); + const data = await r.json(); + if (!r.ok) throw new Error(data.error || '등록 실패'); + const { html } = buildMidiPickerWidget(data, filename); + closeDrums(); + addToStage(html, filename); + loadMidiFiles(); + addChatMsg('system', '✅ 드럼 패턴을 MIDI로 내보냈습니다 · 스테이지에 추가됨'); + } catch (e) { addChatMsg('system', '❌ 내보내기 실패: ' + escHtml(e.message)); } +} + +// ── Looper (mic, beat-synced to drum box) ───────────────────────────────────── +let loopLayers = []; // {buffer, gain, source, muted, vol, dur, loopEnd} +let loopPlaying = false; +let loopRecording = false; +let loopBars = 1; // loop length in bars (1 bar = 16 steps) +let micStream = null, micSource = null, loopAnalyser = null, loopRecorder = null; +let loopRecChunks = [], loopRecTimer = null, loopRecStartTimer = null, loopMeterRaf = null, loopMasterGain = null; + +function loopMaster() { + if (!loopMasterGain) { + const ctx = getAudioCtx(); + loopMasterGain = ctx.createGain(); + loopMasterGain.gain.value = 1; + loopMasterGain.connect(ctx.destination); + } + return loopMasterGain; +} +async function loopEnsureMic() { + if (micStream) return true; + try { + micStream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const ctx = getAudioCtx(); + micSource = ctx.createMediaStreamSource(micStream); + loopAnalyser = ctx.createAnalyser(); + loopAnalyser.fftSize = 512; + micSource.connect(loopAnalyser); + // analyser is a sink (not connected to destination) — no feedback + loopStartMeter(); + return true; + } catch (e) { addChatMsg('system', '❌ 마이크 접근 실패: ' + escHtml(e.message)); return false; } +} +function loopStartMeter() { + if (loopMeterRaf) return; + const fill = document.getElementById('loop-meter-fill'); + const data = new Uint8Array(loopAnalyser.fftSize); + const tick = () => { + loopAnalyser.getByteTimeDomainData(data); + let sum = 0; + for (let i = 0; i < data.length; i++) { const v = (data[i]-128)/128; sum += v*v; } + const rms = Math.sqrt(sum / data.length); + if (fill) fill.style.width = Math.min(100, rms * 200) + '%'; + loopMeterRaf = requestAnimationFrame(tick); + }; + tick(); +} + +// ── Beat sync helpers ── +function loopBarDur() { return 16 * drumStepDur(); } // one 4/4 bar (sec) +function loopLen() { return loopBars * loopBarDur(); } // total loop length (sec) +// next drum bar-boundary time (multiples of 16 steps from drumStartTime). Falls back +// to ctx.currentTime+0.05 when drums aren't playing (standalone loop transport). +function drumNextBarTime() { + const ctx = getAudioCtx(); + if (!drumPlaying) return ctx.currentTime + 0.05; + const barDur = loopBarDur(); + const elapsed = ctx.currentTime - drumStartTime; + const k = Math.floor(elapsed / barDur); + let t = drumStartTime + (k + 1) * barDur; + if (t - ctx.currentTime < 0.03) t += barDur; // too close to boundary → next one + return t; +} +function loopSetBars(v) { loopBars = Math.max(1, Math.min(8, parseInt(v, 10) || 1)); loopRenderLayers(); } + +function loopRenderLayers() { + const el = document.getElementById('loop-layers'); + if (!el) return; + if (!loopLayers.length) { + el.innerHTML = '
● 녹음을 누르면 드럼 비트에 맞춰 녹음 시작 (1마디 카운트인 후 녹음). 재생 중 누르면 오버덥(겹쳐 녹음).
'; + } else { + el.innerHTML = loopLayers.map((L, i) => ` +
+ 레이어 ${i+1} + + + ${loopLen().toFixed(1)}s + +
`).join(''); + } + const st = document.getElementById('loop-status'); + if (st) st.textContent = `레이어 ${loopLayers.length} · ${loopRecording?'녹음 중':loopPlaying?'재생 중':'대기'} · ${loopLen().toFixed(1)}s`; +} +function loopMute(i) { + const L = loopLayers[i]; if (!L) return; + L.muted = !L.muted; L.gain.gain.value = L.muted ? 0 : L.vol; + loopRenderLayers(); +} +function loopVol(i, v) { + const L = loopLayers[i]; if (!L) return; + L.vol = (parseInt(v,10)||0)/100; if (!L.muted) L.gain.gain.value = L.vol; +} +function loopDel(i) { + const L = loopLayers[i]; if (!L) return; + if (L.source) { try { L.source.stop(); } catch{} } + try { L.gain.disconnect(); } catch{} + loopLayers.splice(i, 1); + if (!loopLayers.length) { loopPlaying = false; } + loopRenderLayers(); +} +function loopUndo() { + if (!loopLayers.length) return; + loopDel(loopLayers.length - 1); +} +function loopClear() { + for (const L of loopLayers) { if (L.source) { try{L.source.stop();}catch{} } try{L.gain.disconnect();}catch{} } + loopLayers = []; loopPlaying = false; + const pb = document.getElementById('loop-play-btn'); if (pb) pb.textContent = '▶ 재생'; + loopRenderLayers(); +} + +async function loopToggleRecord() { + if (loopRecording) loopStopRecord(); + else await loopStartRecord(); +} +async function loopStartRecord() { + const ctx = getAudioCtx(); + if (ctx.state === 'suspended') ctx.resume(); + if (!(await loopEnsureMic())) return; + // Beat-synced: drums are the click. Auto-start if stopped (gives 1-bar count-in). + const wasPlaying = drumPlaying; + if (!wasPlaying) drumPlay(); + // play existing layers so you hear them while overdubbing + if (loopLayers.length && !loopPlaying) loopPlay(); + const recLen = loopLen(); + let startAt = drumNextBarTime(); + if (!wasPlaying) startAt += loopBarDur(); // fresh start → wait 1 bar (count-in) before recording + const recBtn = document.getElementById('loop-rec-btn'); + recBtn.classList.add('recording'); recBtn.textContent = '■ 녹음중지'; + loopRecording = true; + loopRenderLayers(); + const delayMs = Math.max(0, (startAt - ctx.currentTime) * 1000); + loopRecStartTimer = setTimeout(() => { + if (!loopRecording) return; // cancelled during count-in + loopRecChunks = []; + const mime = MediaRecorder.isTypeSupported('audio/webm') ? 'audio/webm' : ''; + loopRecorder = mime ? new MediaRecorder(micStream, { mimeType: mime }) : new MediaRecorder(micStream); + loopRecorder.ondataavailable = e => { if (e.data && e.data.size) loopRecChunks.push(e.data); }; + loopRecorder.onstop = async () => { + const blob = new Blob(loopRecChunks, { type: loopRecChunks[0]?.type || 'audio/webm' }); + try { + const ab = await blob.arrayBuffer(); + const buf = await ctx.decodeAudioData(ab.slice(0)); + addLoopLayer(buf, recLen); + } catch (e) { addChatMsg('system', '❌ 녹음 디코딩 실패: ' + escHtml(e.message)); } + }; + loopRecorder.start(); + // stop after recLen + tail so buffer ≥ recLen (clean loopEnd, seamless loop) + loopRecTimer = setTimeout(() => { if (loopRecording) loopStopRecord(); }, (recLen + 0.15) * 1000); + }, delayMs); +} +function loopStopRecord() { + loopRecording = false; + if (loopRecStartTimer) { clearTimeout(loopRecStartTimer); loopRecStartTimer = null; } + if (loopRecTimer) { clearTimeout(loopRecTimer); loopRecTimer = null; } + if (loopRecorder && loopRecorder.state !== 'inactive') loopRecorder.stop(); + const btn = document.getElementById('loop-rec-btn'); + if (btn) { btn.classList.remove('recording'); btn.textContent = '● 녹음'; } + loopRenderLayers(); +} +function addLoopLayer(buf, loopEnd) { + const ctx = getAudioCtx(); + const g = ctx.createGain(); + g.gain.value = 1; + g.connect(loopMaster()); + const layer = { buffer: buf, gain: g, source: null, muted: false, vol: 1, dur: buf.duration, + loopEnd: loopEnd || buf.duration }; + loopLayers.push(layer); + // immediately play in sync with the drum beat (phase-aligned, no gap) + if (loopPlaying) { const s = loopAlignedStart(); startLayerSource(layer, s.when, s.offset); } + else loopPlay(); + loopRenderLayers(); +} +// Returns {when, offset} so a layer starts phase-aligned to the drum bar grid: +// plays from the current position within the loop, locking to the beat with no wait. +function loopAlignedStart() { + const ctx = getAudioCtx(); + if (!drumPlaying) return { when: ctx.currentTime + 0.05, offset: 0 }; + const barDur = loopBarDur(); + const len = loopLen(); + const elapsed = ctx.currentTime - drumStartTime; + const k = Math.floor(elapsed / barDur); + const lastBoundary = drumStartTime + k * barDur; + const when = ctx.currentTime + 0.03; + let offset = (when - lastBoundary) % len; + if (offset < 0) offset += len; + return { when, offset }; +} +function startLayerSource(L, when, offset) { + const ctx = getAudioCtx(); + const src = ctx.createBufferSource(); + src.buffer = L.buffer; src.loop = true; src.loopStart = 0; + src.loopEnd = Math.min(L.loopEnd, L.buffer.duration); + src.connect(L.gain); + src.start(when, offset || 0); + L.source = src; +} +function loopPlay() { + const ctx = getAudioCtx(); + if (ctx.state === 'suspended') ctx.resume(); + if (!loopLayers.length) return; + const s = loopAlignedStart(); + for (const L of loopLayers) startLayerSource(L, s.when, s.offset); + loopPlaying = true; + const pb = document.getElementById('loop-play-btn'); if (pb) pb.textContent = '⏸ 정지'; + loopRenderLayers(); +} +function loopTogglePlay() { + if (loopPlaying) loopStop(); + else loopPlay(); +} +function loopStop() { + for (const L of loopLayers) { if (L.source) { try{L.source.stop();}catch{} L.source = null; } } + loopPlaying = false; + const pb = document.getElementById('loop-play-btn'); if (pb) pb.textContent = '▶ 재생'; + loopRenderLayers(); +} + +// ── Stage switching: drums (with built-in looper) ───────────────────────────── +function hideAllStages() { + document.getElementById('stage-player').style.display = 'none'; + document.getElementById('stage-editor').classList.remove('visible'); + document.getElementById('stage-gp').style.display = 'none'; + document.getElementById('stage-drums').style.display = 'none'; +} +function showStageDrums() { + hideAllStages(); + const el = document.getElementById('stage-drums'); + el.style.display = 'flex'; el.style.flexDirection = 'column'; el.style.flex = '1'; el.style.minHeight = '0'; + if (!drumPattern.length) drumSetSteps(drumSteps); + drumInitRhythmSelect(); + drumRefreshSavedList(); + drumLoadSamples(); + loopRenderLayers(); +} +function closeDrums() { + drumStop(); + if (loopRecording) loopStopRecord(); + if (loopPlaying) loopStop(); + document.getElementById('stage-drums').style.display = 'none'; + restorePlayerStage(); +} +function restorePlayerStage() { + const p = document.getElementById('stage-player'); + p.style.display = 'flex'; p.style.flexDirection = 'column'; p.style.flex = '1'; p.style.minHeight = '0'; p.style.overflow = 'hidden'; +} + function escHtml(s){return String(s||'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"');} function escAttr(s){return String(s||'').replace(/'/g,"\\'");} @@ -447,8 +2199,21 @@ function addChatMsg(role, html, raw) { function renderMd(text) { return text .replace(/&/g,'&').replace(//g,'>') - .replace(/```(\w*)\n?([\s\S]*?)```/g, (_, lang, code) => { + .replace(/```([\w-]*)\n?([\s\S]*?)```/g, (_, lang, code) => { if (lang === 'abc' || lang === 'lilypond') return buildAbcWidget(code.trim(), lang); + if (lang === 'midi-widget') { + try { + const d = JSON.parse(code.trim()); + if (d.midi_url && d.id) { + const fname = d.filename || 'output.mid'; + if (!_renderedMidiIds.has(d.id)) { + _renderedMidiIds.add(d.id); + setTimeout(() => { const {html:sh}=buildMidiPickerWidget(d,fname); addToStage(sh,fname); loadMidiFiles(); }, 0); + } + return '↑ 스테이지에서 보기'; + } + } catch {} + } return `
${code}
`; }) .replace(/`([^`]+)`/g,'$1') @@ -466,11 +2231,10 @@ function processMidiWidgets(html) { return html.replace(/
([\s\S]*?)<\/code><\/pre>/g, (match, inner) => {
     const dec = inner.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
     let data; try { data = JSON.parse(dec); } catch { return match; }
-    if (data && data.type === 'midi-widget' && data.midi_url) {
+    if (data && data.midi_url && data.id && !_renderedMidiIds.has(data.id)) {
+      _renderedMidiIds.add(data.id);
       const filename = data.filename || 'output.mid';
-      const { html: whtml } = buildMidiPickerWidget(data, filename);
-      // Also add to stage
-      setTimeout(() => addToStage(whtml.replace(/id="(midi-[^"]+)"/g, (m,id) => `id="${id}-s"`).replace(/onclick="([^"]*)'(midi-[^']+)'/g, (m,fn,id) => `onclick="${fn}'${id}-s'`), data.filename || 'output.mid'), 0);
+      setTimeout(() => { const {html:sh}=buildMidiPickerWidget(data,filename); addToStage(sh,filename); loadMidiFiles(); }, 0);
       return '↑ 스테이지에서 보기';
     }
     return match;
@@ -566,6 +2330,7 @@ async function callChat(message) {
 function clearChat() {
   if (!confirm('대화를 지울까요?')) return;
   document.getElementById('chat-msgs').innerHTML = '';
+  _renderedMidiIds.clear();
   try { sessionStorage.removeItem(SID_KEY); } catch {}
   addChatMsg('system', '🎵 새 대화가 시작되었습니다.');
 }
@@ -600,23 +2365,41 @@ const midiRouter = {
   _parse(buf) {
     const u8=new Uint8Array(buf),dv=new DataView(buf);
     if(dv.getUint32(0)!==0x4D546864)return[];
-    const tpq=dv.getUint16(12),ntrk=dv.getUint16(10);let tempo=500000;const ev=[];
+    const tpq=dv.getUint16(12),ntrk=dv.getUint16(10);
     const rv=p=>{let v=0,b;do{b=u8[p++];v=(v<<7)|(b&0x7F);}while(b&0x80);return{v,p};};
+    // Pass 1: collect events with absolute tick + global tempo map.
+    // (Type-1 MIDI: each track's tick counter is absolute from song start, so a
+    // single global tempo map keyed by tick is valid across all tracks.)
+    const ev=[],tmap=[{tk:0,tp:500000}];
     let p=14;
     for(let t=0;tu8.length||dv.getUint32(p)!==0x4D54726B)break;
       const tl=dv.getUint32(p+4);p+=8;const e=p+tl;let tk=0,rs=0;
       while(p=3)tempo=(u8[p]<<16)|(u8[p+1]<<8)|u8[p+2];p+=l.v;}
+        const ty=b&0xF0,ch=b&0x0F;
+        if(ty===0x90||ty===0x80){const n=u8[p++],vl=u8[p++];ev.push({tk,msg:[ty|ch,n,vl]});}
+        else if(ty===0xA0||ty===0xB0||ty===0xE0){ev.push({tk,msg:[b,u8[p],u8[p+1]]});p+=2;}
+        else if(ty===0xC0){ev.push({tk,msg:[0xC0|ch,u8[p++]]});}
+        else if(ty===0xD0){ev.push({tk,msg:[b,u8[p++]]});}
+        else if(b===0xFF){const mt=u8[p++];const l=rv(p);p=l.p;
+          if(mt===0x51&&l.v>=3){tmap.push({tk,tp:(u8[p]<<16)|(u8[p+1]<<8)|u8[p+2]});}
+          p+=l.v;
+        }
         else if(b===0xF0||b===0xF7){const l=rv(p);p=l.p+l.v;}else p++;
       }p=e;
     }
+    // Pass 2: integrate the tempo map to convert ticks → ms (handles tempo changes
+    // correctly; the old single-tempo formula broke songs with rit./accel.).
+    tmap.sort((a,b)=>a.tk-b.tk);
+    ev.sort((a,b)=>a.tk-b.tk);
+    let mi=0,ms=0,lastTk=0,cur=tmap[0].tp;
+    for(const e of ev){
+      while(mi+1a.ms-b.ms);return ev;
   },
   async play(url,outId,cb){
@@ -655,8 +2438,9 @@ function buildMusicActions(data,dl){
   if(data.saved_to)h+='
💾 '+escHtml(data.saved_to)+'
'; if(url){ h+=''; - h+=''; - h+=''; + h+=''; + h+=''; + h+=''; h+='⬇ MIDI'; } if(pdf)h+='⬇ PDF'; @@ -679,13 +2463,51 @@ function musicPageNav(nid,d){ if(lbl)lbl.textContent=nx+'/'+tot;if(pv)pv.disabled=nx<=1;if(nt)nt.disabled=nx>=tot; } +async function playMidiInternally(url, filename) { + try { + addChatMsg('system', '🎵 "' + (filename || 'MIDI') + '" 로딩 중…'); + + gpStageMode = 'midi'; + showStageGp(); + document.getElementById('gp-title').textContent = '🎵 ' + (filename || 'MIDI'); + document.getElementById('gp-track-list').innerHTML = ''; + document.getElementById('gp-main').innerHTML = ''; + document.getElementById('gp-play-btn').textContent = '▶ 재생'; + midiSfGpStatus('초기화 중...'); + + const [, midiResp] = await Promise.all([ + _ensureSpessa(), + fetch(url, { headers: authH() }) + ]); + if (!midiResp.ok) throw new Error('HTTP ' + midiResp.status); + const midiBuf = await midiResp.arrayBuffer(); + + midiSfGpStatus('MIDI 파싱 중...'); + const { BasicMIDI } = window.SpessaSynth; + const midi = BasicMIDI.fromArrayBuffer(midiBuf, filename || 'track.mid'); + + if (_spessaCtx && _spessaCtx.state === 'suspended') await _spessaCtx.resume(); + _spessaSeq.loadNewSongList([midi]); + _spessaSeq.eventHandler.addEvent('songEnded', 'ui', () => { + _midiPlaying = false; _setMidiBtn(false); midiSfGpStatus('재생 완료'); + }); + + _midiPlaying = true; + _spessaSeq.play(); + _setMidiBtn(true); + } catch(e) { + addChatMsg('system', '⚠️ MIDI 재생 오류: ' + e.message); + console.error('playMidiInternally:', e); + } +} + function buildMidiPickerWidget(data,filename){ const wid='midi-'+Math.random().toString(36).slice(2,10),url=data.midi_url,rid='mr-'+Math.random().toString(36).slice(2,10); const saved=data.saved_to?'
💾 '+escHtml(data.saved_to)+'
':''; const pp=''; const si=(data.has_svg&&data.svg_url)?buildSvgPagesHtml(data)+buildMusicActions(data,filename):''; const sa=''; - const html='
🎵 '+escHtml(filename)+'
'+saved+'
'+pp+sa+'
'; + const html='
🎵 '+escHtml(filename)+'
'+saved+'
'+pp+sa+'
'; return {wid,html}; } @@ -733,13 +2555,453 @@ function toggleMusicWidget(id){ } function closeMusicWidget(id){const el=document.getElementById(id);if(!el)return;(el.closest('.music-widget')||el).remove();} +// ════════════════════════════════════════════════════════════════════════════ +// Piano Roll (MIDI Editor — inline) +// ════════════════════════════════════════════════════════════════════════════ +const KEY_W = 56, RULE_H = 24, 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]); + +let midi = mkMidi(); +let view = { scrollX:0, scrollY:57*8, pxPerTick:0.15, noteH:8 }; +let tool = 'sel', qTicks = 120, activeTi = 0; +let sel = new Set(), hist = [], histIdx = -1, drag = null; +let ph = 0, playing = false; +let meMidiAccess = null, meMidiOut = null; +let sfMode = true; +let _sfLoopEn = false, _sfLoopIn = 0, _sfLoopOut = 0; +let animId = null, playT0 = 0, playTick0 = 0, nextSchedTick = 0; +const SCHED_AHEAD_MS = 300; +let velDrag = null, clip = []; + +function mkMidi() { + return { format:1, tpq:480, tracks:[], tempos:[{tick:0,uspb:500000}], timeSigs:[{tick:0,num:4,den:4}] }; +} + +// Canvases — resolved lazily when the editor stage is first visible +let rulerC, keysC, rollC, velC, rCtx, kCtx, pCtx, vCtx; +function meInitCanvases() { + if (rulerC) return; + rulerC = document.getElementById('ruler-canvas'); + keysC = document.getElementById('keys-canvas'); + rollC = document.getElementById('roll-canvas'); + velC = document.getElementById('vel-canvas'); + rCtx = rulerC.getContext('2d'); + kCtx = keysC.getContext('2d'); + pCtx = rollC.getContext('2d'); + vCtx = velC.getContext('2d'); + meBindRollEvents(); +} + +function meZoomToFitBars(bars) { + if (!rollC || !rollC.width) return; + const barTicks = (midi.tpq || 480) * (midi.timeSigs[0]?.num || 4); + view.pxPerTick = Math.max(0.02, Math.min(4, rollC.width / (barTicks * bars))); +} + +function resize() { + const isFirst = !rulerC; + meInitCanvases(); + 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; + if (isFirst && W > 0) meZoomToFitBars(8); + renderAll(); +} +window.addEventListener('resize', () => { + if (document.getElementById('stage-editor')?.classList.contains('visible')) resize(); +}); + +// MIDI Parser +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;tu8.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=[],ccs=[];let name=`Track ${t+1}`,ch=-1; + while(p0)open[`${c}_${n}`]={tick,vel:v,ch:c};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,ch:o.ch});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,ch:o.ch});delete open[`${c}_${n}`];}} + else if(typ===0xA0){p+=2;}else if(typ===0xB0){const cc=u8[p++],val=u8[p++];ccs.push({tick,cc,val,ch:c});} + else if(typ===0xC0){pcs.push({tick,prog:u8[p++],ch:c});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<0||pcs.length>0){ + const chs=[...new Set(notes.map(n=>n.ch))].sort((a,b)=>a-b); + if(chs.length>1){for(const c of chs){const cN=notes.filter(n=>n.ch===c),cP=pcs.filter(pc=>pc.ch===c),cC=ccs.filter(cc=>cc.ch===c);if(cN.length||cP.length)res.tracks.push({name:`${name}/ch${c+1}`,channel:c,notes:cN,pcs:cP,ccs:cC,muted:false,solo:false,color:COLORS[res.tracks.length%COLORS.length]});}} + else res.tracks.push({name,channel:ch>=0?ch:t%16,notes,pcs,ccs,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;ia.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||[])){const pc_c=(pc.ch??trk.channel);evs.push({tick:pc.tick,bytes:[0xC0|pc_c,pc.prog]});} + for(const cc of(trk.ccs||[])){const cc_c=(cc.ch??trk.channel);evs.push({tick:cc.tick,bytes:[0xB0|cc_c,cc.cc,cc.val]});} + for(const n of trk.notes){const nc=(n.ch??trk.channel);evs.push({tick:n.tick,bytes:[0x90|nc,n.pitch,n.vel]});evs.push({tick:n.tick+n.dur,bytes:[0x80|nc,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(){ + if(!rulerC)return; + 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&&x40){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(){ + if(!keysC)return; + 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(){ + if(!rollC)return; + const W=rollC.width,H=rollC.height; + pCtx.fillStyle='#1e1e2e';pCtx.fillRect(0,0,W,H); + const nh=view.noteH; + 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();}} + 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();}}} + 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); + for(let ti=0;tiW||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);}}} + 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([]);} + const phx=tX(ph);if(phx>=0&&phx<=W){pCtx.fillStyle='#f38ba8';pCtx.fillRect(phx-1,0,2,H);} +} +function renderVel(){ + if(!velC)return; + 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');if(!list)return; + list.innerHTML=''; + midi.tracks.forEach((trk,i)=>{ + const prog=trk.pcs?.[0]?.prog,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=`
${trk.name}
ch${trk.channel+1}
${progName?`
${progName}
`:''}`; + 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();sfReloadSong();} +function toggleSolo(i){pushH();midi.tracks[i].solo=!midi.tracks[i].solo;renderAll();renderTracks();sfReloadSong();} +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));} +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)}];const el=document.getElementById('bpm-in');if(el)el.value=b;} +function zoomX(f){const mid=view.scrollX+(rollC?.width||0)/2;view.pxPerTick=Math.max(.02,Math.min(4,view.pxPerTick*f));view.scrollX=Math.max(0,mid-(rollC?.width||0)/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&&tickt.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;if(Math.abs(xT(cx)-(n.tick+n.dur))<=RZ)return{n,trk,ti:i};}}return null;} + +// Mouse Events — bound lazily after canvas init +let mdown=false; +function meBindRollEvents(){ + 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;} + 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.tickt0&&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();}); + 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;}}); + keysC.addEventListener('click',e=>{ + const pitch=yP(e.offsetY);if(pitch<0||pitch>127)return; + const ch=midi.tracks[activeTi]?.channel??0;const prog=midi.tracks[activeTi]?.pcs?.[0]?.prog??0; + if(sfMode){sfPreviewNote(ch,prog,pitch,80);return;} + if(!meMidiOut)return; + try{meMidiOut.send(new Uint8Array([0x90|ch,pitch,80]));}catch{} + setTimeout(()=>{try{meMidiOut.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();const el=document.getElementById('bpm-in');if(el)el.value=bpm();renderAll();renderTracks();} +function doRedo(){if(histIdx>=hist.length-1)return;histIdx++;midi=JSON.parse(hist[histIdx]);sel.clear();const el=document.getElementById('bpm-in');if(el)el.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();const el=document.getElementById('bpm-in');if(el)el.value=bpm();view.scrollX=0;view.scrollY=57*view.noteH;meZoomToFitBars(8);renderAll();renderTracks();meStatus('로드: '+f.name);}catch(e){alert('읽기 실패: '+e.message);} + inp.value=''; +} +async function meLoadUrl(url){ + try{ + const r=await fetch(url,{headers:authH()});if(!r.ok)throw new Error(r.status); + const buf=await r.arrayBuffer(); + midi=parseMidi(buf);sel.clear();hist=[];histIdx=-1;pushH(); + const el=document.getElementById('bpm-in');if(el)el.value=bpm(); + view.scrollX=0;view.scrollY=57*view.noteH;meZoomToFitBars(8); + renderAll();renderTracks();meStatus('로드 완료'); + }catch(e){meStatus('로드 실패: '+e.message);} +} +function doSave(){ + const buf=writeMidi(midi); + const a=document.createElement('a'); + a.href=URL.createObjectURL(new Blob([buf],{type:'audio/midi'})); + const titleEl=document.getElementById('editor-title'); + a.download=((titleEl?.textContent||'').replace('🎹 ','').replace(/\.midi?$/,'')||'edited')+'.mid'; + document.body.appendChild(a);a.click();setTimeout(()=>{URL.revokeObjectURL(a.href);a.remove();},1000); + meStatus('저장 완료'); +} + +// MIDI Output +async function meInitMidiOut(){ + if(!navigator.requestMIDIAccess)return; + try{meMidiAccess=await navigator.requestMIDIAccess();meMidiAccess.onstatechange=()=>meRefreshMidiOuts();meRefreshMidiOuts();}catch{} +} +function meRefreshMidiOuts(){ + if(!meMidiAccess){meInitMidiOut();return;} + const sel=document.getElementById('midi-out-sel');if(!sel)return; + const cur=sel.value; + sel.innerHTML=''; + meMidiAccess.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);}); + if(cur&&cur!=='__sf2')meMidiOut=meMidiAccess.outputs.get(cur)||null; + meApplyOutSel(cur); +} +function meApplyOutSel(val){if(val==='__sf2'){sfMode=true;meMidiOut=null;}else{sfMode=false;meMidiOut=meMidiAccess?.outputs.get(val)||null;}} +document.addEventListener('change',e=>{if(e.target.id==='midi-out-sel')meApplyOutSel(e.target.value);}); +document.addEventListener('focus',e=>{if(e.target.id==='midi-out-sel'&&!meMidiAccess)meInitMidiOut();},true); + +// SpessaSynth — shared with music-app +function _tickToSec(tick){const tpq=midi.tpq||480;let sec=0,lastTick=0,uspb=(midi.tempos[0]?.uspb||500000);for(const t of(midi.tempos||[])){if(t.tick>=tick)break;sec+=(Math.min(t.tick,tick)-lastTick)/tpq*(uspb/1e6);lastTick=t.tick;uspb=t.uspb;}sec+=(tick-lastTick)/tpq*(uspb/1e6);return sec;} +function _secToTick(sec){const tpq=midi.tpq||480;let rem=sec,tick=0,uspb=(midi.tempos[0]?.uspb||500000);for(let i=1;i<(midi.tempos||[]).length;i++){const seg=(midi.tempos[i].tick-(midi.tempos[i-1]?.tick||0))/tpq*(uspb/1e6);if(rem<=seg)break;rem-=seg;tick=midi.tempos[i].tick;uspb=midi.tempos[i].uspb;}return tick+rem*tpq/(uspb/1e6);} + +function buildSpessaMidi(){ + const{BasicMIDI}=window.SpessaSynth; + const anySolo=midi.tracks.some(t=>t.solo); + // Use the editor's own writeMidi on a filtered copy — avoids MIDIBuilder API ambiguity + const filtered={...midi,tracks:midi.tracks.filter(t=>!t.muted&&!(anySolo&&!t.solo))}; + return BasicMIDI.fromArrayBuffer(writeMidi(filtered)); +} + +function sfReloadSong(){ + if(!_spessaSeq||!playing||!sfMode)return; + const t=_spessaSeq.currentTime; + _spessaSeq.loadNewSongList([buildSpessaMidi()]); + _spessaSeq.currentTime=t; + _spessaSeq.play(); +} + +async function sfPlay(){ + if(!midi.tracks.length){meStatus('재생할 트랙이 없습니다');return;} + await _ensureSpessa(); + if(_spessaCtx?.state==='suspended')await _spessaCtx.resume(); + const tpb=midi.tpq*(midi.timeSigs[0]?.num||4); + _sfLoopEn=document.getElementById('loop-en')?.checked||false; + _sfLoopIn=(+(document.getElementById('loop-in')?.value||1)-1)*tpb; + _sfLoopOut=(+(document.getElementById('loop-out')?.value||5))*tpb; + const midiData=buildSpessaMidi(); + _spessaSeq.loadNewSongList([midiData]); + _spessaSeq.currentTime=_tickToSec(ph); + if(_sfLoopEn)_spessaSeq.loopCount=-1;else _spessaSeq.loopCount=0; + _spessaSeq.eventHandler.addEvent('songEnded','editor',()=>{playing=false;const btn=document.getElementById('play-btn');if(btn)btn.textContent='▶';if(animId){cancelAnimationFrame(animId);animId=null;}}); + _spessaSeq.play(); + playing=true; + const btn=document.getElementById('play-btn');if(btn)btn.textContent='⏸'; + if(animId)cancelAnimationFrame(animId); + animId=requestAnimationFrame(sfLoop); +} +function sfLoop(){ + if(!playing||!_spessaSeq)return; + const sec=_spessaSeq.currentTime; + ph=Math.round(_secToTick(sec)); + if(_sfLoopEn&&ph>=_sfLoopOut)_spessaSeq.currentTime=_tickToSec(_sfLoopIn); + updPos(); + const phx=tX(ph); + if(rollC&&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(sfLoop); +} +function sfPreviewNote(ch,prog,pitch,vel){ + if(!_spessaSynth){return sfOscPreview(ch,pitch);} + try{if(_spessaCtx?.state==='suspended')_spessaCtx.resume();_spessaSynth.programChange(ch,prog);_spessaSynth.noteOn(ch,pitch,vel);setTimeout(()=>{try{_spessaSynth.noteOff(ch,pitch,0);}catch{}},400);}catch(e){sfOscPreview(ch,pitch);} +} +function sfOscPreview(ch,pitch){ + try{const AC=window.AudioContext||window.webkitAudioContext,ctx=new AC();const o=ctx.createOscillator(),g=ctx.createGain();o.type=(ch===9)?'square':'triangle';o.frequency.value=440*Math.pow(2,(pitch-69)/12);const t=ctx.currentTime;g.gain.setValueAtTime(0.0001,t);g.gain.exponentialRampToValueAtTime(0.25,t+0.01);g.gain.exponentialRampToValueAtTime(0.0001,t+0.4);o.connect(g).connect(ctx.destination);o.start(t);o.stop(t+0.42);setTimeout(()=>ctx.close(),700);}catch{} +} + +function doTogglePlay(){playing?doStop():doPlay();} +function doRewind(){doStop();ph=0;renderRuler();renderRoll();updPos();} +function doStop(){ + playing=false; + const btn=document.getElementById('play-btn');if(btn)btn.textContent='▶'; + if(animId){cancelAnimationFrame(animId);animId=null;} + if(sfMode&&_spessaSeq){try{_spessaSeq.pause();}catch{}} + if(meMidiOut){try{meMidiOut.clear();}catch{}for(let c=0;c<16;c++)try{meMidiOut.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=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{meMidiOut.send(new Uint8Array([0x90|trk.channel,n.pitch,n.vel]),on);}catch{}try{meMidiOut.send(new Uint8Array([0x80|trk.channel,n.pitch,0]),off);}catch{}}} + nextSchedTick=schedUntilTick; +} +function doPlay(){ + if(sfMode){sfPlay();return;} + if(!meMidiOut){alert('MIDI 출력 기기를 선택하세요.');return;} + playing=true;const btn=document.getElementById('play-btn');if(btn)btn.textContent='⏸'; + playTick0=ph;playT0=performance.now();nextSchedTick=ph; + const mspt=mspT();const loopEn=document.getElementById('loop-en')?.checked||false; + const loopIn=(+(document.getElementById('loop-in')?.value||1)-1)*midi.tpq*(midi.timeSigs[0]?.num||4); + const loopOut=(+(document.getElementById('loop-out')?.value||5))*midi.tpq*(midi.timeSigs[0]?.num||4); + try{meMidiOut.send(new Uint8Array([0xF0,0x7E,0x7F,0x09,0x01,0xF7]));}catch{} + for(const trk of midi.tracks){const c=trk.channel;if(trk.pcs?.length){try{meMidiOut.send(new Uint8Array([0xB0|c,0,0]));}catch{}try{meMidiOut.send(new Uint8Array([0xB0|c,32,0]));}catch{}try{meMidiOut.send(new Uint8Array([0xC0|c,trk.pcs[0].prog]));}catch{}}for(const cc of(trk.ccs||[])){if(cc.tick===0)try{meMidiOut.send(new Uint8Array([0xB0|c,cc.cc,cc.val]));}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(rollC&&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 (only active when editor stage is visible) +document.addEventListener('keydown',e=>{ + if(!document.getElementById('stage-editor')?.classList.contains('visible'))return; + 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}));meStatus(`${clip.length}개 복사`);return;} + if(ctrl&&e.key==='d'){e.preventDefault();sel=new Set([...sel].map(n=>({...n})));if(activeTin.tick));const ns=clip.map(n=>({...n,tick:n.tick-minT+ph}));if(activeTi!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(); + 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);const el=document.getElementById('pos-disp');if(el)el.textContent=`${bar}:${beat}:${String(tick).padStart(3,'0')}`;} +function meStatus(msg){const el=document.getElementById('status-txt');if(!el)return;el.textContent=msg;setTimeout(()=>{if(el.textContent===msg)el.textContent='';},3000);} + // ── Init ────────────────────────────────────────────────────────────────────── (async()=>{ + patchAudioForGpFx(); // install FX chain + destination redirect before any AudioContext is created const ok = await checkAuth(); if (!ok) return; - await Promise.all([loadSkill(), loadModels(), loadMidiFiles()]); - addChatMsg('system', '🎵 음악 스튜디오에 오신 것을 환영합니다!'); - document.getElementById('chat-input').focus(); + await Promise.all([loadSkill(), loadModels(), loadMidiFiles(), loadGpFileList()]); + // 외부 진입: ?edit= (또는 호환용 ?url=) 로 에디터 스테이지 자동 오픈 + const _params = new URLSearchParams(location.search); + const _editUrl = _params.get('edit') || _params.get('url'); + if (_editUrl) { + const _name = decodeURIComponent(_editUrl.split('/').pop().replace(/\.midi?$/i, '')) || 'MIDI'; + openEditor(_editUrl, _name); + } else { + addChatMsg('system', '🎵 음악 스튜디오에 오신 것을 환영합니다!'); + document.getElementById('chat-input').focus(); + } })(); diff --git a/web-ui/styles.css b/web-ui/styles.css index 3faf454..c4111b4 100644 --- a/web-ui/styles.css +++ b/web-ui/styles.css @@ -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; }