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:
2026-04-25 00:54:38 +00:00
commit 9aba8057a8
23 changed files with 3037 additions and 0 deletions

21
node-server/Makefile Normal file
View 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
View 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
View 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

View 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
View 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);
});

View 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;
}
}

View 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)}`);
}
}

View 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(),
};
}
}