Files
midi-sequencer/node-server/public/app.mjs
mikael-lovqvists-claude-agent 9aba8057a8 Initial scaffold: ALSA MIDI C backend + Node ESM sequencer web app
- protocol.yaml: SSoT for the binary framing protocol (12 record types)
- codegen/gen.mjs: generates C header/source and Node ESM from protocol.yaml
- c-backend: ALSA sequencer with drift-free clock_nanosleep tick thread,
  pattern store (hierarchical sub-patterns), Unix socket server
- node-server: Express 5 web app — REST API, SSE for real-time beat events,
  step-sequencer frontend with pending-edit / explicit-save flow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 00:54:38 +00:00

398 lines
14 KiB
JavaScript

/* ── 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 = `
<span class="name">${escape_html(p.name)}</span>
<span class="steps">${p.steps}st</span>
`;
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 = '<div class="empty-state">Select or create a pattern to get started.</div>';
return;
}
const notes = state.pending_notes ?? pattern.notes;
content.innerHTML = `
<div class="panel">
<div class="panel-header">
<h2>Pattern Settings</h2>
${state.is_dirty ? '<span style="color:var(--accent);font-size:11px">● unsaved changes</span>' : ''}
</div>
<div class="field-row">
<label>Name</label>
<input type="text" id="pat-name" value="${escape_html(pattern.name)}" style="width:160px">
<label>Steps</label>
<input type="number" id="pat-steps" value="${pattern.steps}" min="1" max="64" style="width:60px">
<label>Channel</label>
<input type="number" id="pat-channel" value="${pattern.channel}" min="0" max="15" style="width:55px">
<button id="save-settings-btn" class="primary">Save</button>
<button id="delete-pat-btn" class="danger">Delete</button>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Step Grid</h2>
<span style="font-size:11px;color:var(--text-dim)">Click cells to toggle notes — Save to apply</span>
<div style="flex:1"></div>
<button id="save-notes-btn" class="primary" ${state.is_dirty ? '' : 'disabled'}>Save Notes</button>
<button id="clear-notes-btn">Clear</button>
</div>
<div class="step-grid" id="step-grid"></div>
</div>
<div class="panel">
<div class="panel-header"><h2>Sub-patterns</h2></div>
<div class="sub-ref-list" id="sub-ref-list"></div>
<div class="field-row" style="margin-top:10px">
<label>At step</label>
<input type="number" id="sub-step" value="0" min="0" style="width:60px">
<label>Pattern ID</label>
<input type="number" id="sub-pat-id" value="" min="1" style="width:60px">
<button id="add-sub-btn">Add Sub-pattern</button>
</div>
</div>
`;
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 += `<div class="note-row" data-note="${midi}">`;
html += `<div class="note-label">${note_name(midi)}</div>`;
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 += `<button class="step-btn ${on} ${cur}" data-note="${midi}" data-step="${s}">`;
if (ev) html += `<div class="vel-bar" style="height:${Math.max(2, vel_pct * 0.28)}px"></div>`;
html += `</button>`;
}
html += `</div>`;
}
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 = '<div style="color:var(--text-dim);font-size:12px">No sub-patterns</div>';
return;
}
list.innerHTML = pattern.sub_refs.map(ref => `
<div class="sub-ref-item">
<span class="label">Pattern ${ref.sub_pattern_id} at step ${ref.step}</span>
</div>
`).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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}