Protocol: - Add PREVIEW_NOTE record (id 0x09, node→C): channel, note, velocity, duration_ms — plays a note immediately without touching sequencer state C backend: - sequencer_preview_note(): fires ALSA note-on, spawns detached thread for note-off after duration_ms - RT_PREVIEW_NOTE handler in on_frame() Node server: - encode_preview_note imported from generated protocol - POST /api/preview route forwards to backend Frontend (app.mjs): - Remove pending_notes / is_dirty / Save Notes button; every step-button toggle now calls PUT /api/patterns/:id/notes immediately (auto-save) - Single delegated click handler on #content — no per-render listener accumulation - Row-level ▶ play button per note row → POST /api/preview - Click-to-rename on note/percussion labels: inline <input>, saves to state.custom_labels (Map keyed by "patternId:note"), Escape to cancel - pattern_updated SSE no longer guarded by is_dirty CSS (index.html): - .note-label: cursor pointer + hover highlight - .note-label-input for inline rename field - .row-play-btn and .row-play-spacer for per-row preview buttons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
205 lines
7.6 KiB
JavaScript
205 lines
7.6 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,
|
|
} 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 });
|
|
});
|
|
|
|
/* ── 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);
|
|
});
|