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