/* ── State ───────────────────────────────────────────────────────── */ const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; class App_State { constructor() { this.patterns = new Map(); this.selected_id = null; this.backend_connected = false; this.playing = false; this.play_pattern_id = null; this.current_step = null; this.bpm = 120; /* Pending edits — only sent on explicit save */ this.pending_notes = null; /* Array or null */ this.is_dirty = false; } get selected_pattern() { return this.selected_id ? this.patterns.get(this.selected_id) ?? null : null; } } const state = new App_State(); /* ── API helpers ─────────────────────────────────────────────────── */ async function api(method, path, body) { const opts = { method, headers: { 'Content-Type': 'application/json' } }; if (body !== undefined) opts.body = JSON.stringify(body); const res = await fetch(path, opts); if (!res.ok) throw new Error(`${method} ${path} → ${res.status}`); if (res.status === 204) return null; return res.json(); } const GET = (path) => api('GET', path); const POST = (path, body) => api('POST', path, body); const PUT = (path, body) => api('PUT', path, body); const DELETE = (path) => api('DELETE', path); /* ── MIDI note helpers ───────────────────────────────────────────── */ function note_name(midi) { const oct = Math.floor(midi / 12) - 1; const name = NOTE_NAMES[midi % 12]; return `${name}${oct}`; } /* Rows to display in the step grid: a range of MIDI notes */ const DISPLAY_NOTES = (() => { const rows = []; for (let n = 83; n >= 36; n--) rows.push(n); /* B5 down to C2 */ return rows; })(); /* ── Render ──────────────────────────────────────────────────────── */ function render_pattern_list() { const list = document.getElementById('pattern-list'); list.innerHTML = ''; for (const p of state.patterns.values()) { const div = document.createElement('div'); div.className = 'pattern-item' + (p.id === state.selected_id ? ' active' : ''); div.innerHTML = ` ${escape_html(p.name)} ${p.steps}st `; div.addEventListener('click', () => select_pattern(p.id)); list.appendChild(div); } } function render_content() { const content = document.getElementById('content'); const pattern = state.selected_pattern; if (!pattern) { content.innerHTML = '
Select or create a pattern to get started.
'; return; } const notes = state.pending_notes ?? pattern.notes; content.innerHTML = `

Pattern Settings

${state.is_dirty ? '● unsaved changes' : ''}

Step Grid

Click cells to toggle notes — Save to apply

Sub-patterns

`; render_step_grid(pattern, notes); render_sub_refs(pattern); bind_content_events(pattern); } function render_step_grid(pattern, notes) { const grid = document.getElementById('step-grid'); if (!grid) return; /* Build a set for quick lookup: "note:step" */ const note_set = new Map(); /* "note:step" → { velocity, duration_steps } */ for (const ev of notes) { note_set.set(`${ev.note}:${ev.step}`, ev); } grid.style.gridTemplateColumns = `36px repeat(${pattern.steps}, 28px)`; let html = ''; for (const midi of DISPLAY_NOTES) { html += `
`; html += `
${note_name(midi)}
`; for (let s = 0; s < pattern.steps; s++) { const key = `${midi}:${s}`; const ev = note_set.get(key); const on = ev ? 'on' : ''; const cur = (state.play_pattern_id === pattern.id && state.current_step === s) ? 'current' : ''; const vel_pct = ev ? Math.round((ev.velocity / 127) * 100) : 0; html += ``; } html += `
`; } grid.innerHTML = html; /* Delegate clicks */ grid.addEventListener('click', (e) => { const btn = e.target.closest('.step-btn'); if (!btn) return; toggle_note(pattern, parseInt(btn.dataset.note, 10), parseInt(btn.dataset.step, 10)); }); } function render_sub_refs(pattern) { const list = document.getElementById('sub-ref-list'); if (!list) return; if (pattern.sub_refs.length === 0) { list.innerHTML = '
No sub-patterns
'; return; } list.innerHTML = pattern.sub_refs.map(ref => `
Pattern ${ref.sub_pattern_id} at step ${ref.step}
`).join(''); } /* ── Interaction ─────────────────────────────────────────────────── */ function toggle_note(pattern, note, step) { if (!state.pending_notes) { state.pending_notes = (state.selected_pattern?.notes ?? []).map(n => ({ ...n })); } const idx = state.pending_notes.findIndex(n => n.note === note && n.step === step); if (idx >= 0) { state.pending_notes.splice(idx, 1); } else { state.pending_notes.push({ step, note, velocity: 100, duration_steps: 1 }); } state.is_dirty = true; render_content(); } function select_pattern(id) { state.selected_id = id; state.pending_notes = null; state.is_dirty = false; render_pattern_list(); render_content(); } function bind_content_events(pattern) { /* Save settings */ document.getElementById('save-settings-btn')?.addEventListener('click', async () => { const name = document.getElementById('pat-name').value.trim(); const steps = parseInt(document.getElementById('pat-steps').value, 10); const channel = parseInt(document.getElementById('pat-channel').value, 10); try { const updated = await PUT(`/api/patterns/${pattern.id}`, { name, steps, channel }); state.patterns.set(updated.id, updated); state.pending_notes = null; state.is_dirty = false; render_pattern_list(); render_content(); } catch (err) { console.error(err); } }); /* Save notes */ document.getElementById('save-notes-btn')?.addEventListener('click', async () => { if (!state.pending_notes) return; try { const updated = await PUT(`/api/patterns/${pattern.id}/notes`, state.pending_notes); state.patterns.set(updated.id, updated); state.pending_notes = null; state.is_dirty = false; render_content(); } catch (err) { console.error(err); } }); /* Clear notes */ document.getElementById('clear-notes-btn')?.addEventListener('click', () => { state.pending_notes = []; state.is_dirty = true; render_content(); }); /* Delete pattern */ document.getElementById('delete-pat-btn')?.addEventListener('click', async () => { if (!confirm(`Delete "${pattern.name}"?`)) return; try { await DELETE(`/api/patterns/${pattern.id}`); state.patterns.delete(pattern.id); state.selected_id = null; state.pending_notes = null; state.is_dirty = false; render_pattern_list(); render_content(); } catch (err) { console.error(err); } }); /* Add sub-pattern */ document.getElementById('add-sub-btn')?.addEventListener('click', async () => { const step = parseInt(document.getElementById('sub-step').value, 10); const sub_pattern_id = parseInt(document.getElementById('sub-pat-id').value, 10); if (isNaN(sub_pattern_id)) return; try { await POST(`/api/patterns/${pattern.id}/sub-refs`, { step, sub_pattern_id }); } catch (err) { console.error(err); } }); } /* ── Transport ───────────────────────────────────────────────────── */ document.getElementById('play-btn').addEventListener('click', async () => { const pattern = state.selected_pattern; if (!pattern) return; try { await POST('/api/play', { pattern_id: pattern.id }); } catch (err) { console.error(err); } }); document.getElementById('stop-btn').addEventListener('click', async () => { try { await POST('/api/stop'); } catch (err) { console.error(err); } }); document.getElementById('new-pattern-btn').addEventListener('click', async () => { try { const p = await POST('/api/patterns', { steps: 16, channel: 0 }); state.patterns.set(p.id, p); render_pattern_list(); select_pattern(p.id); } catch (err) { console.error(err); } }); document.getElementById('set-tempo-btn').addEventListener('click', async () => { const bpm = parseFloat(document.getElementById('bpm-input').value); if (isNaN(bpm) || bpm <= 0) return; try { const result = await PUT('/api/tempo', { bpm }); state.bpm = result.bpm; document.getElementById('bpm-input').value = result.bpm; } catch (err) { console.error(err); } }); /* ── SSE event handling ──────────────────────────────────────────── */ function update_status_ui() { const dot = document.getElementById('backend-dot'); const label = document.getElementById('status-label'); const pdot = document.getElementById('play-dot'); dot.classList.toggle('connected', state.backend_connected); pdot.classList.toggle('playing', state.playing); label.textContent = state.backend_connected ? (state.playing ? 'playing' : 'connected') : 'disconnected'; } function update_step_highlight(step, pattern_id) { if (state.selected_pattern?.id !== pattern_id) return; document.querySelectorAll('.step-btn.current').forEach(b => b.classList.remove('current')); document.querySelectorAll(`.step-btn[data-step="${step}"]`).forEach(b => b.classList.add('current')); } const es = new EventSource('/api/events'); es.addEventListener('state', (e) => { const data = JSON.parse(e.data); state.bpm = data.bpm; document.getElementById('bpm-input').value = data.bpm; state.patterns.clear(); for (const p of data.patterns) state.patterns.set(p.id, p); render_pattern_list(); render_content(); }); es.addEventListener('backend_connect', () => { state.backend_connected = true; update_status_ui(); }); es.addEventListener('backend_disconnect', () => { state.backend_connected = false; update_status_ui(); }); es.addEventListener('backend_status', (e) => { const { connected } = JSON.parse(e.data); state.backend_connected = connected; update_status_ui(); }); es.addEventListener('beat_tick', (e) => { const { pattern_id, step } = JSON.parse(e.data); state.current_step = step; state.play_pattern_id = pattern_id; state.playing = true; update_status_ui(); update_step_highlight(step, pattern_id); }); es.addEventListener('play', (e) => { const { pattern_id } = JSON.parse(e.data); state.playing = true; state.play_pattern_id = pattern_id; update_status_ui(); }); es.addEventListener('stop', () => { state.playing = false; state.current_step = null; update_status_ui(); document.querySelectorAll('.step-btn.current').forEach(b => b.classList.remove('current')); }); es.addEventListener('pattern_created', (e) => { const p = JSON.parse(e.data); state.patterns.set(p.id, p); render_pattern_list(); }); es.addEventListener('pattern_updated', (e) => { const p = JSON.parse(e.data); state.patterns.set(p.id, p); if (state.selected_id === p.id && !state.is_dirty) { render_content(); } render_pattern_list(); }); es.addEventListener('pattern_deleted', (e) => { const { id } = JSON.parse(e.data); state.patterns.delete(id); if (state.selected_id === id) { state.selected_id = null; render_content(); } render_pattern_list(); }); es.addEventListener('tempo', (e) => { state.bpm = JSON.parse(e.data).bpm; document.getElementById('bpm-input').value = state.bpm; }); /* ── Utilities ───────────────────────────────────────────────────── */ function escape_html(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); }