- 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>
398 lines
14 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|