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

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

195 lines
7.1 KiB
JavaScript

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