Files
midi-sequencer/node-server/server.mjs
mikael-lovqvists-claude-agent 9b45905d80 Add multi-track playback with mute/solo support
Replaces the single-root-pattern sequencer with a Track[] array that
allows multiple patterns to loop independently. Adds ADD_TRACK (0x0A),
REMOVE_TRACK (0x0B), PLAY_TRACKS (0x0C), and SET_TRACK_MUTE (0x0D)
protocol records. The C backend gains per-track pending_subs and a
tracks_mutex. The Node server gains track-state APIs (/api/tracks/:id/
active, mute, solo) and the frontend shows per-pattern track/mute/solo
buttons in the sidebar list.

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

265 lines
9.5 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,
encode_preview_note,
encode_add_track,
encode_remove_track,
encode_play_tracks,
encode_set_track_mute,
} 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 */
let is_playing = false;
/* ── 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', {}));
/* Push current effective mutes to backend for all active tracks */
function sync_mutes_to_backend() {
for (const id of state.active_tracks) {
const muted = state.effective_mute(id) ? 1 : 0;
backend.send(encode_set_track_mute({ pattern_id: id, muted }));
}
}
/* ── 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(), is_playing })}\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) => {
backend.send(encode_stop());
for (const id of state.active_tracks) {
backend.send(encode_add_track({ pattern_id: id }));
}
sync_mutes_to_backend();
backend.send(encode_play_tracks());
is_playing = true;
broadcast('play', { active_tracks: state.active_tracks });
res.json({ ok: true });
});
app.post('/api/stop', (_req, res) => {
backend.send(encode_stop());
is_playing = false;
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 });
});
/* ── Track routes ─────────────────────────────────────────────────── */
app.put('/api/tracks/:id/active', (req, res) => {
const id = parseInt(req.params.id, 10);
const { active } = req.body;
if (!state.get_pattern(id)) return res.status(404).json({ error: 'not found' });
state.set_track_active(id, !!active);
if (is_playing) {
if (active) {
backend.send(encode_add_track({ pattern_id: id }));
const muted = state.effective_mute(id) ? 1 : 0;
backend.send(encode_set_track_mute({ pattern_id: id, muted }));
} else {
backend.send(encode_remove_track({ pattern_id: id }));
}
}
broadcast('tracks_updated', state.tracks_json());
res.json({ ok: true });
});
app.put('/api/tracks/:id/mute', (req, res) => {
const id = parseInt(req.params.id, 10);
const { muted } = req.body;
if (!state.get_pattern(id)) return res.status(404).json({ error: 'not found' });
state.set_track_muted(id, !!muted);
if (is_playing && state.is_track_active(id)) {
sync_mutes_to_backend();
}
broadcast('tracks_updated', state.tracks_json());
res.json({ ok: true });
});
app.put('/api/solo', (req, res) => {
const { id } = req.body; /* null to clear */
if (id !== null && id !== undefined) {
state.set_solo(id);
} else {
state.clear_solo();
}
if (is_playing) sync_mutes_to_backend();
broadcast('tracks_updated', state.tracks_json());
res.json({ ok: true });
});
/* ── Preview route ────────────────────────────────────────────────── */
app.post('/api/preview', (req, res) => {
const { channel, note, velocity = 100, duration_ms = 500 } = req.body;
if (typeof note !== 'number') return res.status(400).json({ error: 'note required' });
backend.send(encode_preview_note({ channel: channel ?? 0, note, velocity, duration_ms }));
res.json({ ok: true });
});
/* ── 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);
});