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>
This commit is contained in:
21
node-server/Makefile
Normal file
21
node-server/Makefile
Normal file
@@ -0,0 +1,21 @@
|
||||
PORT ?= 3000
|
||||
SOCKET_PATH ?= /tmp/midi-sequencer.sock
|
||||
|
||||
.PHONY: all install start generate
|
||||
|
||||
all: install
|
||||
|
||||
install: node_modules
|
||||
|
||||
node_modules: package.json
|
||||
npm install
|
||||
@touch node_modules
|
||||
|
||||
generate:
|
||||
node ../codegen/gen.mjs
|
||||
|
||||
start: node_modules
|
||||
PORT=$(PORT) SOCKET_PATH=$(SOCKET_PATH) node server.mjs
|
||||
|
||||
dev: node_modules
|
||||
PORT=$(PORT) SOCKET_PATH=$(SOCKET_PATH) node --watch server.mjs
|
||||
10
node-server/package.json
Normal file
10
node-server/package.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"name": "midi-sequencer-server",
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"main": "server.mjs",
|
||||
"dependencies": {
|
||||
"express": "^5.2.1"
|
||||
}
|
||||
}
|
||||
397
node-server/public/app.mjs
Normal file
397
node-server/public/app.mjs
Normal file
@@ -0,0 +1,397 @@
|
||||
/* ── 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,'"');
|
||||
}
|
||||
237
node-server/public/index.html
Normal file
237
node-server/public/index.html
Normal file
@@ -0,0 +1,237 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>MIDI Sequencer</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
:root {
|
||||
--bg: #1a1a2e;
|
||||
--surface: #16213e;
|
||||
--surface2: #0f3460;
|
||||
--accent: #e94560;
|
||||
--accent2: #533483;
|
||||
--text: #eaeaea;
|
||||
--text-dim: #888;
|
||||
--step-off: #1e2a42;
|
||||
--step-on: #e94560;
|
||||
--step-pend: #7a2233;
|
||||
--step-active: #f0a500;
|
||||
--border: #2a3a5e;
|
||||
--radius: 6px;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
header {
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 12px 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
header h1 { font-size: 18px; letter-spacing: 2px; color: var(--accent); }
|
||||
|
||||
.status-dot {
|
||||
width: 8px; height: 8px; border-radius: 50%;
|
||||
background: #444;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
.status-dot.connected { background: #2ecc71; }
|
||||
.status-dot.playing { background: var(--accent); animation: pulse 0.5s infinite alternate; }
|
||||
|
||||
@keyframes pulse { from { opacity: 1; } to { opacity: 0.4; } }
|
||||
|
||||
main { flex: 1; display: flex; gap: 0; }
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 240px;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar h2 { font-size: 11px; letter-spacing: 2px; color: var(--text-dim); text-transform: uppercase; }
|
||||
|
||||
.pattern-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
|
||||
|
||||
.pattern-item {
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pattern-item:hover { background: var(--surface2); }
|
||||
.pattern-item.active { border-color: var(--accent); background: var(--surface2); }
|
||||
.pattern-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pattern-item .steps { font-size: 11px; color: var(--text-dim); }
|
||||
|
||||
/* Transport */
|
||||
.transport {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* Content area */
|
||||
.content {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.panel-header h2 { font-size: 12px; letter-spacing: 2px; color: var(--text-dim); text-transform: uppercase; }
|
||||
|
||||
/* Buttons */
|
||||
button {
|
||||
background: var(--surface2);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 14px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
button:hover { background: var(--accent2); border-color: var(--accent2); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
button.primary:hover { background: #c73452; }
|
||||
button.danger { background: #7a1a2e; border-color: #9b2a3e; }
|
||||
button.danger:hover { background: #9b2a3e; }
|
||||
button:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
/* Inputs */
|
||||
input, select {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 5px 10px;
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
}
|
||||
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||
input[type="number"] { width: 80px; }
|
||||
label { font-size: 12px; color: var(--text-dim); }
|
||||
|
||||
.field-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* Step grid */
|
||||
.step-grid {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.note-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.note-label {
|
||||
width: 36px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 3px;
|
||||
background: var(--step-off);
|
||||
border: 1px solid var(--border);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
transition: background 0.05s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.step-btn.on { background: var(--step-on); border-color: var(--accent); }
|
||||
.step-btn.pending { background: var(--step-pend); border-color: #7a2233; }
|
||||
.step-btn.current { box-shadow: 0 0 0 2px var(--step-active); }
|
||||
|
||||
/* Velocity bar inside step button */
|
||||
.step-btn .vel-bar {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 3px;
|
||||
background: rgba(255,255,255,0.4);
|
||||
border-radius: 0 0 3px 3px;
|
||||
}
|
||||
|
||||
.empty-state { color: var(--text-dim); text-align: center; padding: 40px; }
|
||||
|
||||
.sub-ref-list { display: flex; flex-direction: column; gap: 4px; }
|
||||
.sub-ref-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
background: var(--surface2); border-radius: var(--radius); padding: 6px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.sub-ref-item .label { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>MIDI SEQUENCER</h1>
|
||||
<div class="status-dot" id="backend-dot" title="Backend connection"></div>
|
||||
<div class="status-dot" id="play-dot" title="Playing"></div>
|
||||
<span id="status-label" style="font-size:12px;color:var(--text-dim)">disconnected</span>
|
||||
<div style="flex:1"></div>
|
||||
<label>BPM</label>
|
||||
<input type="number" id="bpm-input" value="120" min="1" max="300" step="0.1" style="width:70px">
|
||||
<button id="set-tempo-btn">Set Tempo</button>
|
||||
</header>
|
||||
<main>
|
||||
<aside class="sidebar">
|
||||
<h2>Patterns</h2>
|
||||
<div class="transport">
|
||||
<button id="play-btn" class="primary">▶ Play</button>
|
||||
<button id="stop-btn">■ Stop</button>
|
||||
</div>
|
||||
<div class="pattern-list" id="pattern-list"></div>
|
||||
<button id="new-pattern-btn">+ New Pattern</button>
|
||||
</aside>
|
||||
<div class="content" id="content">
|
||||
<div class="empty-state">Select or create a pattern to get started.</div>
|
||||
</div>
|
||||
</main>
|
||||
<script type="module" src="app.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
194
node-server/server.mjs
Normal file
194
node-server/server.mjs
Normal file
@@ -0,0 +1,194 @@
|
||||
import express from 'express';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname, join } from 'path';
|
||||
import { Backend_Client } from './src/backend_client.mjs';
|
||||
import { Pattern_State } from './src/pattern_state.mjs';
|
||||
import {
|
||||
encode_define_pattern,
|
||||
encode_clear_pattern,
|
||||
encode_add_note,
|
||||
encode_add_sub_pattern,
|
||||
encode_play,
|
||||
encode_stop,
|
||||
encode_set_tempo,
|
||||
} from './src/generated/protocol.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
||||
const SOCK_PATH = process.env.SOCKET_PATH || '/tmp/midi-sequencer.sock';
|
||||
|
||||
/* ── Core objects ─────────────────────────────────────────────────── */
|
||||
|
||||
const backend = new Backend_Client();
|
||||
const state = new Pattern_State();
|
||||
const sse_set = new Set(); /* active SSE response objects */
|
||||
|
||||
/* ── SSE broadcast ────────────────────────────────────────────────── */
|
||||
|
||||
function broadcast(event, data) {
|
||||
const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
for (const res of sse_set) {
|
||||
try { res.write(msg); } catch { sse_set.delete(res); }
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Backend event forwarding ─────────────────────────────────────── */
|
||||
|
||||
backend.on('beat_tick', data => broadcast('beat_tick', data));
|
||||
backend.on('pattern_end', data => broadcast('pattern_end', data));
|
||||
backend.on('backend_error', data => broadcast('backend_error', data));
|
||||
backend.on('connect', () => broadcast('backend_connect', {}));
|
||||
backend.on('disconnect', () => broadcast('backend_disconnect', {}));
|
||||
|
||||
/* ── Sync a single pattern to backend ────────────────────────────── */
|
||||
|
||||
function sync_pattern(pattern) {
|
||||
backend.send(encode_define_pattern({
|
||||
pattern_id: pattern.id,
|
||||
steps: pattern.steps,
|
||||
channel: pattern.channel,
|
||||
}));
|
||||
backend.send(encode_clear_pattern({ pattern_id: pattern.id }));
|
||||
|
||||
for (const note of pattern.notes) {
|
||||
backend.send(encode_add_note({
|
||||
pattern_id: pattern.id,
|
||||
step: note.step,
|
||||
note: note.note,
|
||||
velocity: note.velocity,
|
||||
duration_steps: note.duration_steps,
|
||||
}));
|
||||
}
|
||||
|
||||
for (const ref of pattern.sub_refs) {
|
||||
backend.send(encode_add_sub_pattern({
|
||||
pattern_id: pattern.id,
|
||||
step: ref.step,
|
||||
sub_pattern_id: ref.sub_pattern_id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Express app ──────────────────────────────────────────────────── */
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use(express.static(join(__dirname, 'public')));
|
||||
|
||||
/* SSE stream */
|
||||
app.get('/api/events', (req, res) => {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
res.flushHeaders();
|
||||
sse_set.add(res);
|
||||
|
||||
/* Send current state snapshot on connect */
|
||||
res.write(`event: state\ndata: ${JSON.stringify(state.to_json())}\n\n`);
|
||||
res.write(`event: backend_status\ndata: ${JSON.stringify({ connected: backend.is_connected })}\n\n`);
|
||||
|
||||
req.on('close', () => sse_set.delete(res));
|
||||
});
|
||||
|
||||
/* ── Pattern routes ───────────────────────────────────────────────── */
|
||||
|
||||
app.get('/api/patterns', (_req, res) => {
|
||||
res.json(state.list_patterns());
|
||||
});
|
||||
|
||||
app.post('/api/patterns', (req, res) => {
|
||||
const { name, steps = 16, channel = 0 } = req.body;
|
||||
const pattern = state.create_pattern({ name, steps, channel });
|
||||
sync_pattern(pattern);
|
||||
broadcast('pattern_created', pattern);
|
||||
res.status(201).json(pattern);
|
||||
});
|
||||
|
||||
app.get('/api/patterns/:id', (req, res) => {
|
||||
const pattern = state.get_pattern(parseInt(req.params.id, 10));
|
||||
if (!pattern) return res.status(404).json({ error: 'not found' });
|
||||
res.json(pattern);
|
||||
});
|
||||
|
||||
app.put('/api/patterns/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const { name, steps, channel } = req.body;
|
||||
const pattern = state.update_pattern(id, { name, steps, channel });
|
||||
if (!pattern) return res.status(404).json({ error: 'not found' });
|
||||
sync_pattern(pattern);
|
||||
broadcast('pattern_updated', pattern);
|
||||
res.json(pattern);
|
||||
});
|
||||
|
||||
app.delete('/api/patterns/:id', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
if (!state.delete_pattern(id)) return res.status(404).json({ error: 'not found' });
|
||||
broadcast('pattern_deleted', { id });
|
||||
res.status(204).end();
|
||||
});
|
||||
|
||||
/* ── Notes routes ─────────────────────────────────────────────────── */
|
||||
|
||||
/* Replace all notes for a pattern and sync */
|
||||
app.put('/api/patterns/:id/notes', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const pattern = state.set_notes(id, req.body);
|
||||
if (!pattern) return res.status(404).json({ error: 'not found' });
|
||||
sync_pattern(pattern);
|
||||
broadcast('pattern_updated', pattern);
|
||||
res.json(pattern);
|
||||
});
|
||||
|
||||
/* ── Sub-ref routes ───────────────────────────────────────────────── */
|
||||
|
||||
app.post('/api/patterns/:id/sub-refs', (req, res) => {
|
||||
const id = parseInt(req.params.id, 10);
|
||||
const { step, sub_pattern_id } = req.body;
|
||||
const ref = state.add_sub_ref(id, { step, sub_pattern_id });
|
||||
if (!ref) return res.status(404).json({ error: 'not found' });
|
||||
const pattern = state.get_pattern(id);
|
||||
sync_pattern(pattern);
|
||||
broadcast('pattern_updated', pattern);
|
||||
res.status(201).json(ref);
|
||||
});
|
||||
|
||||
/* ── Transport routes ─────────────────────────────────────────────── */
|
||||
|
||||
app.post('/api/play', (req, res) => {
|
||||
const { pattern_id } = req.body;
|
||||
if (!state.get_pattern(pattern_id)) {
|
||||
return res.status(404).json({ error: 'pattern not found' });
|
||||
}
|
||||
backend.send(encode_play({ pattern_id }));
|
||||
broadcast('play', { pattern_id });
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/stop', (_req, res) => {
|
||||
backend.send(encode_stop());
|
||||
broadcast('stop', {});
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.put('/api/tempo', (req, res) => {
|
||||
const { bpm } = req.body;
|
||||
if (typeof bpm !== 'number' || bpm <= 0) {
|
||||
return res.status(400).json({ error: 'bpm must be a positive number' });
|
||||
}
|
||||
state.bpm = bpm;
|
||||
backend.send(encode_set_tempo({ bpm_x10: state.bpm_x10 }));
|
||||
broadcast('tempo', { bpm: state.bpm });
|
||||
res.json({ bpm: state.bpm });
|
||||
});
|
||||
|
||||
app.get('/api/tempo', (_req, res) => {
|
||||
res.json({ bpm: state.bpm });
|
||||
});
|
||||
|
||||
/* ── Start ────────────────────────────────────────────────────────── */
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`midi-sequencer server listening on http://localhost:${PORT}`);
|
||||
console.log(`connecting to backend socket: ${SOCK_PATH}`);
|
||||
backend.connect(SOCK_PATH);
|
||||
});
|
||||
107
node-server/src/backend_client.mjs
Normal file
107
node-server/src/backend_client.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
import net from 'net';
|
||||
import { EventEmitter } from 'events';
|
||||
import {
|
||||
FRAME_HEADER_SIZE, RT, RT_NAME, PROTOCOL_VERSION,
|
||||
encode_hello, decode,
|
||||
} from './generated/protocol.mjs';
|
||||
|
||||
export class Backend_Client extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this._socket = null;
|
||||
this._buf = Buffer.alloc(0);
|
||||
this._connected = false;
|
||||
}
|
||||
|
||||
connect(sock_path) {
|
||||
if (this._socket) return;
|
||||
|
||||
const socket = net.createConnection({ path: sock_path });
|
||||
this._socket = socket;
|
||||
|
||||
socket.on('connect', () => {
|
||||
this._connected = true;
|
||||
console.log(`[backend] connected to ${sock_path}`);
|
||||
/* Send HELLO */
|
||||
socket.write(encode_hello({ version: PROTOCOL_VERSION }));
|
||||
this.emit('connect');
|
||||
});
|
||||
|
||||
socket.on('data', (chunk) => {
|
||||
this._buf = Buffer.concat([this._buf, chunk]);
|
||||
this._drain();
|
||||
});
|
||||
|
||||
socket.on('close', () => {
|
||||
this._connected = false;
|
||||
this._socket = null;
|
||||
this._buf = Buffer.alloc(0);
|
||||
console.log('[backend] disconnected');
|
||||
this.emit('disconnect');
|
||||
/* Auto-reconnect after 2s */
|
||||
setTimeout(() => this.connect(sock_path), 2000);
|
||||
});
|
||||
|
||||
socket.on('error', (err) => {
|
||||
if (err.code !== 'ECONNREFUSED' && err.code !== 'ENOENT') {
|
||||
console.error('[backend] socket error:', err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_drain() {
|
||||
for (;;) {
|
||||
if (this._buf.length < FRAME_HEADER_SIZE) return;
|
||||
const record_type = this._buf.readUInt8(0);
|
||||
const payload_len = this._buf.readUInt16LE(1);
|
||||
const total = FRAME_HEADER_SIZE + payload_len;
|
||||
if (this._buf.length < total) return;
|
||||
|
||||
const payload = this._buf.slice(FRAME_HEADER_SIZE, total);
|
||||
this._buf = this._buf.slice(total);
|
||||
|
||||
this._dispatch(record_type, payload);
|
||||
}
|
||||
}
|
||||
|
||||
_dispatch(record_type, payload) {
|
||||
try {
|
||||
const data = decode(record_type, payload);
|
||||
const name = RT_NAME[record_type] || `0x${record_type.toString(16)}`;
|
||||
|
||||
switch (record_type) {
|
||||
case RT.HELLO:
|
||||
console.log(`[backend] HELLO version=${data.version}`);
|
||||
this.emit('hello', data);
|
||||
break;
|
||||
case RT.BEAT_TICK:
|
||||
this.emit('beat_tick', data);
|
||||
break;
|
||||
case RT.PATTERN_END:
|
||||
this.emit('pattern_end', data);
|
||||
break;
|
||||
case RT.ACK:
|
||||
this.emit('ack', data);
|
||||
break;
|
||||
case RT.ERROR:
|
||||
console.error(`[backend] ERROR code=${data.code} context=0x${data.context_type.toString(16)}`);
|
||||
this.emit('backend_error', data);
|
||||
break;
|
||||
default:
|
||||
console.warn(`[backend] unhandled record type: ${name}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[backend] dispatch error:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
send(frame_buf) {
|
||||
if (!this._connected || !this._socket) return false;
|
||||
this._socket.write(frame_buf);
|
||||
return true;
|
||||
}
|
||||
|
||||
get is_connected() {
|
||||
return this._connected;
|
||||
}
|
||||
}
|
||||
210
node-server/src/generated/protocol.mjs
Normal file
210
node-server/src/generated/protocol.mjs
Normal file
@@ -0,0 +1,210 @@
|
||||
/* AUTO-GENERATED by codegen/gen.mjs — DO NOT EDIT */
|
||||
/* Source: protocol.yaml version 1 */
|
||||
|
||||
export const PROTOCOL_VERSION = 1;
|
||||
export const FRAME_HEADER_SIZE = 3;
|
||||
|
||||
export const RT = Object.freeze({
|
||||
HELLO : 0x01,
|
||||
DEFINE_PATTERN : 0x02,
|
||||
CLEAR_PATTERN : 0x03,
|
||||
ADD_NOTE : 0x04,
|
||||
ADD_SUB_PATTERN : 0x05,
|
||||
PLAY : 0x06,
|
||||
STOP : 0x07,
|
||||
SET_TEMPO : 0x08,
|
||||
ACK : 0x81,
|
||||
ERROR : 0x82,
|
||||
BEAT_TICK : 0x83,
|
||||
PATTERN_END : 0x84,
|
||||
});
|
||||
|
||||
export const RT_NAME = Object.freeze(
|
||||
Object.fromEntries(Object.entries(RT).map(([k, v]) => [v, k]))
|
||||
);
|
||||
|
||||
export const PAYLOAD_SIZE = Object.freeze({
|
||||
HELLO : 1,
|
||||
DEFINE_PATTERN : 4,
|
||||
CLEAR_PATTERN : 2,
|
||||
ADD_NOTE : 6,
|
||||
ADD_SUB_PATTERN : 5,
|
||||
PLAY : 2,
|
||||
STOP : 0,
|
||||
SET_TEMPO : 2,
|
||||
ACK : 1,
|
||||
ERROR : 2,
|
||||
BEAT_TICK : 4,
|
||||
PATTERN_END : 2,
|
||||
});
|
||||
|
||||
function write_frame(record_type, payload_buf) {
|
||||
const frame = Buffer.alloc(FRAME_HEADER_SIZE + payload_buf.length);
|
||||
frame.writeUInt8(record_type, 0);
|
||||
frame.writeUInt16LE(payload_buf.length, 1);
|
||||
payload_buf.copy(frame, FRAME_HEADER_SIZE);
|
||||
return frame;
|
||||
}
|
||||
|
||||
/* ── encode ──────────────────────────────────────────────────── */
|
||||
|
||||
export function encode_hello({ version }) {
|
||||
const buf = Buffer.alloc(1);
|
||||
buf.writeUInt8(version, 0);
|
||||
return write_frame(RT.HELLO, buf);
|
||||
}
|
||||
|
||||
export function encode_define_pattern({ pattern_id, steps, channel }) {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
buf.writeUInt8(steps, 2);
|
||||
buf.writeUInt8(channel, 3);
|
||||
return write_frame(RT.DEFINE_PATTERN, buf);
|
||||
}
|
||||
|
||||
export function encode_clear_pattern({ pattern_id }) {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
return write_frame(RT.CLEAR_PATTERN, buf);
|
||||
}
|
||||
|
||||
export function encode_add_note({ pattern_id, step, note, velocity, duration_steps }) {
|
||||
const buf = Buffer.alloc(6);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
buf.writeUInt8(step, 2);
|
||||
buf.writeUInt8(note, 3);
|
||||
buf.writeUInt8(velocity, 4);
|
||||
buf.writeUInt8(duration_steps, 5);
|
||||
return write_frame(RT.ADD_NOTE, buf);
|
||||
}
|
||||
|
||||
export function encode_add_sub_pattern({ pattern_id, step, sub_pattern_id }) {
|
||||
const buf = Buffer.alloc(5);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
buf.writeUInt8(step, 2);
|
||||
buf.writeUInt16LE(sub_pattern_id, 3);
|
||||
return write_frame(RT.ADD_SUB_PATTERN, buf);
|
||||
}
|
||||
|
||||
export function encode_play({ pattern_id }) {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
return write_frame(RT.PLAY, buf);
|
||||
}
|
||||
|
||||
export function encode_stop() {
|
||||
return write_frame(RT.STOP, Buffer.alloc(0));
|
||||
}
|
||||
|
||||
export function encode_set_tempo({ bpm_x10 }) {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16LE(bpm_x10, 0);
|
||||
return write_frame(RT.SET_TEMPO, buf);
|
||||
}
|
||||
|
||||
export function encode_ack({ acked_type }) {
|
||||
const buf = Buffer.alloc(1);
|
||||
buf.writeUInt8(acked_type, 0);
|
||||
return write_frame(RT.ACK, buf);
|
||||
}
|
||||
|
||||
export function encode_error({ code, context_type }) {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt8(code, 0);
|
||||
buf.writeUInt8(context_type, 1);
|
||||
return write_frame(RT.ERROR, buf);
|
||||
}
|
||||
|
||||
export function encode_beat_tick({ pattern_id, step, beat }) {
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
buf.writeUInt8(step, 2);
|
||||
buf.writeUInt8(beat, 3);
|
||||
return write_frame(RT.BEAT_TICK, buf);
|
||||
}
|
||||
|
||||
export function encode_pattern_end({ pattern_id }) {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
return write_frame(RT.PATTERN_END, buf);
|
||||
}
|
||||
|
||||
/* ── decode ──────────────────────────────────────────────────── */
|
||||
|
||||
export function decode_hello(payload) {
|
||||
if (payload.length < 1) throw new Error('HELLO payload too short');
|
||||
return { version: payload.readUInt8(0) };
|
||||
}
|
||||
|
||||
export function decode_define_pattern(payload) {
|
||||
if (payload.length < 4) throw new Error('DEFINE_PATTERN payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0), steps: payload.readUInt8(2), channel: payload.readUInt8(3) };
|
||||
}
|
||||
|
||||
export function decode_clear_pattern(payload) {
|
||||
if (payload.length < 2) throw new Error('CLEAR_PATTERN payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0) };
|
||||
}
|
||||
|
||||
export function decode_add_note(payload) {
|
||||
if (payload.length < 6) throw new Error('ADD_NOTE payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0), step: payload.readUInt8(2), note: payload.readUInt8(3), velocity: payload.readUInt8(4), duration_steps: payload.readUInt8(5) };
|
||||
}
|
||||
|
||||
export function decode_add_sub_pattern(payload) {
|
||||
if (payload.length < 5) throw new Error('ADD_SUB_PATTERN payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0), step: payload.readUInt8(2), sub_pattern_id: payload.readUInt16LE(3) };
|
||||
}
|
||||
|
||||
export function decode_play(payload) {
|
||||
if (payload.length < 2) throw new Error('PLAY payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0) };
|
||||
}
|
||||
|
||||
export function decode_stop(payload) {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function decode_set_tempo(payload) {
|
||||
if (payload.length < 2) throw new Error('SET_TEMPO payload too short');
|
||||
return { bpm_x10: payload.readUInt16LE(0) };
|
||||
}
|
||||
|
||||
export function decode_ack(payload) {
|
||||
if (payload.length < 1) throw new Error('ACK payload too short');
|
||||
return { acked_type: payload.readUInt8(0) };
|
||||
}
|
||||
|
||||
export function decode_error(payload) {
|
||||
if (payload.length < 2) throw new Error('ERROR payload too short');
|
||||
return { code: payload.readUInt8(0), context_type: payload.readUInt8(1) };
|
||||
}
|
||||
|
||||
export function decode_beat_tick(payload) {
|
||||
if (payload.length < 4) throw new Error('BEAT_TICK payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0), step: payload.readUInt8(2), beat: payload.readUInt8(3) };
|
||||
}
|
||||
|
||||
export function decode_pattern_end(payload) {
|
||||
if (payload.length < 2) throw new Error('PATTERN_END payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0) };
|
||||
}
|
||||
|
||||
/* Dispatch: decode any payload by record type */
|
||||
export function decode(record_type, payload) {
|
||||
switch (record_type) {
|
||||
case RT.HELLO: return decode_hello(payload);
|
||||
case RT.DEFINE_PATTERN: return decode_define_pattern(payload);
|
||||
case RT.CLEAR_PATTERN: return decode_clear_pattern(payload);
|
||||
case RT.ADD_NOTE: return decode_add_note(payload);
|
||||
case RT.ADD_SUB_PATTERN: return decode_add_sub_pattern(payload);
|
||||
case RT.PLAY: return decode_play(payload);
|
||||
case RT.STOP: return decode_stop(payload);
|
||||
case RT.SET_TEMPO: return decode_set_tempo(payload);
|
||||
case RT.ACK: return decode_ack(payload);
|
||||
case RT.ERROR: return decode_error(payload);
|
||||
case RT.BEAT_TICK: return decode_beat_tick(payload);
|
||||
case RT.PATTERN_END: return decode_pattern_end(payload);
|
||||
default: throw new Error(`Unknown record type 0x${record_type.toString(16)}`);
|
||||
}
|
||||
}
|
||||
121
node-server/src/pattern_state.mjs
Normal file
121
node-server/src/pattern_state.mjs
Normal file
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Pattern_State — authoritative JS-side store for sequence patterns.
|
||||
*
|
||||
* A pattern has:
|
||||
* id : sequential integer (1-based)
|
||||
* name : string label for the UI
|
||||
* steps : number of steps (e.g. 16)
|
||||
* channel : MIDI channel 0-15
|
||||
* notes : Array<{ step, note, velocity, duration_steps }>
|
||||
* sub_refs : Array<{ step, sub_pattern_id }>
|
||||
*
|
||||
* Patterns are hierarchical: a pattern may reference sub-patterns by ID,
|
||||
* which the C backend will play as nested sequences.
|
||||
*/
|
||||
export class Pattern_State {
|
||||
constructor() {
|
||||
this._patterns = new Map(); /* id → pattern object */
|
||||
this._next_id = 1;
|
||||
this._bpm_x10 = 1200; /* 120.0 BPM */
|
||||
}
|
||||
|
||||
/* ── Pattern CRUD ─────────────────────────────────────────────── */
|
||||
|
||||
create_pattern({ name = '', steps = 16, channel = 0 } = {}) {
|
||||
const id = this._next_id++;
|
||||
const pattern = {
|
||||
id,
|
||||
name: name || `Pattern ${id}`,
|
||||
steps,
|
||||
channel,
|
||||
notes: [],
|
||||
sub_refs: [],
|
||||
};
|
||||
this._patterns.set(id, pattern);
|
||||
return pattern;
|
||||
}
|
||||
|
||||
get_pattern(id) {
|
||||
return this._patterns.get(id) ?? null;
|
||||
}
|
||||
|
||||
list_patterns() {
|
||||
return [...this._patterns.values()];
|
||||
}
|
||||
|
||||
delete_pattern(id) {
|
||||
return this._patterns.delete(id);
|
||||
}
|
||||
|
||||
update_pattern(id, { name, steps, channel }) {
|
||||
const p = this.get_pattern(id);
|
||||
if (!p) return null;
|
||||
if (name !== undefined) p.name = name;
|
||||
if (steps !== undefined) p.steps = steps;
|
||||
if (channel !== undefined) p.channel = channel;
|
||||
return p;
|
||||
}
|
||||
|
||||
/* ── Notes ────────────────────────────────────────────────────── */
|
||||
|
||||
add_note(pattern_id, { step, note, velocity = 100, duration_steps = 1 }) {
|
||||
const p = this.get_pattern(pattern_id);
|
||||
if (!p) return null;
|
||||
const ev = { step, note, velocity, duration_steps };
|
||||
p.notes.push(ev);
|
||||
return ev;
|
||||
}
|
||||
|
||||
clear_notes(pattern_id) {
|
||||
const p = this.get_pattern(pattern_id);
|
||||
if (!p) return;
|
||||
p.notes = [];
|
||||
}
|
||||
|
||||
set_notes(pattern_id, notes) {
|
||||
const p = this.get_pattern(pattern_id);
|
||||
if (!p) return null;
|
||||
p.notes = notes.map(({ step, note, velocity = 100, duration_steps = 1 }) =>
|
||||
({ step, note, velocity, duration_steps }));
|
||||
return p;
|
||||
}
|
||||
|
||||
/* ── Sub-pattern references ───────────────────────────────────── */
|
||||
|
||||
add_sub_ref(pattern_id, { step, sub_pattern_id }) {
|
||||
const p = this.get_pattern(pattern_id);
|
||||
if (!p) return null;
|
||||
const ref = { step, sub_pattern_id };
|
||||
p.sub_refs.push(ref);
|
||||
return ref;
|
||||
}
|
||||
|
||||
clear_sub_refs(pattern_id) {
|
||||
const p = this.get_pattern(pattern_id);
|
||||
if (!p) return;
|
||||
p.sub_refs = [];
|
||||
}
|
||||
|
||||
/* ── Tempo ────────────────────────────────────────────────────── */
|
||||
|
||||
get bpm() {
|
||||
return this._bpm_x10 / 10;
|
||||
}
|
||||
|
||||
set bpm(value) {
|
||||
this._bpm_x10 = Math.round(Math.max(1, Math.min(6553.5, value)) * 10);
|
||||
}
|
||||
|
||||
get bpm_x10() {
|
||||
return this._bpm_x10;
|
||||
}
|
||||
|
||||
/* ── Serialization (for API responses) ───────────────────────── */
|
||||
|
||||
to_json() {
|
||||
return {
|
||||
bpm: this.bpm,
|
||||
patterns: this.list_patterns(),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user