Files
midi-sequencer/node-server/public/app.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

601 lines
21 KiB
JavaScript

/* ── GM Percussion ───────────────────────────────────────────────── */
const GM_PERC = {
35: 'Bass Drum 2', 36: 'Bass Drum 1',
37: 'Side Stick', 38: 'Snare 1',
39: 'Hand Clap', 40: 'Snare 2',
41: 'Low Floor Tom', 42: 'Closed Hi-Hat',
43: 'Hi Floor Tom', 44: 'Pedal Hi-Hat',
45: 'Low Tom', 46: 'Open Hi-Hat',
47: 'Low-Mid Tom', 48: 'Hi-Mid Tom',
49: 'Crash Cym 1', 50: 'High Tom',
51: 'Ride Cym 1', 52: 'Chinese Cym',
53: 'Ride Bell', 54: 'Tambourine',
55: 'Splash Cym', 56: 'Cowbell',
57: 'Crash Cym 2', 58: 'Vibraslap',
59: 'Ride Cym 2', 60: 'Hi Bongo',
61: 'Low Bongo', 62: 'Mute Hi Conga',
63: 'Open Hi Conga', 64: 'Low Conga',
65: 'Hi Timbale', 66: 'Low Timbale',
67: 'Hi Agogo', 68: 'Low Agogo',
69: 'Cabasa', 70: 'Maracas',
71: 'Short Whistle', 72: 'Long Whistle',
73: 'Short Guiro', 74: 'Long Guiro',
75: 'Claves', 76: 'Hi Wood Block',
77: 'Low Wood Block', 78: 'Mute Cuica',
79: 'Open Cuica', 80: 'Mute Triangle',
81: 'Open Triangle',
};
/* Drum-machine order: cymbals → hi-hats → toms → snares → kicks → latin */
const PERC_NOTES = [
49, 57, 51, 59, 52, 53, 55, 56, 57, /* crash, ride, chinese, bell, splash, cowbell */
46, 44, 42, 54, /* open/pedal/closed hi-hat, tambourine */
50, 48, 47, 45, 43, 41, /* toms high→low */
38, 40, 37, 39, 58, /* snares, clap, vibraslap */
36, 35, /* bass drums */
60, 61, 62, 63, 64, /* bongos, congas */
65, 66, 67, 68, /* timbales, agogo */
69, 70, 71, 72, 73, 74, /* cabasa, maracas, whistles, guiro */
75, 76, 77, 78, 79, 80, 81, /* claves, wood blocks, cuica, triangle */
].filter((n, i, a) => GM_PERC[n] && a.indexOf(n) === i); /* dedupe */
function perc_family(note) {
if (note === 42 || note === 44 || note === 46) return 'perc-hihat';
if (note >= 49 && note <= 59 || note === 53 || note === 55) return 'perc-cymbal';
if (note === 56 || note === 54) return 'perc-cymbal';
if (note >= 41 && note <= 50 && note !== 42 && note !== 44 && note !== 46) return 'perc-tom';
if (note >= 37 && note <= 40 || note === 58) return 'perc-snare';
if (note === 35 || note === 36) return 'perc-bass';
return 'perc-latin';
}
/* ── 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;
this.custom_labels = new Map(); /* key: "patternId:note" → string */
this.active_tracks = new Set(); /* pattern IDs */
this.muted_tracks = new Set(); /* pattern IDs */
this.solo_id = null;
}
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}`;
}
function row_label(pattern, midi) {
const label_key = `${pattern.id}:${midi}`;
const is_perc = pattern.channel === 9;
const default_label = is_perc ? (GM_PERC[midi] ?? `Note ${midi}`) : note_name(midi);
return state.custom_labels.get(label_key) ?? default_label;
}
/* 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 is_active = state.active_tracks.has(p.id);
const is_muted = state.muted_tracks.has(p.id);
const is_solo = state.solo_id === p.id;
const div = document.createElement('div');
div.className = 'pattern-item' + (p.id === state.selected_id ? ' selected' : '');
div.dataset.id = p.id;
div.innerHTML = `
<button class="track-btn${is_active ? ' on' : ''}" title="${is_active ? 'Remove from playback' : 'Add to playback'}">&#11044;</button>
<span class="name">${escape_html(p.name)}</span>
<span class="steps">${p.steps}st</span>
<span class="track-ctl">
<button class="mute-btn${is_muted && !is_solo ? ' on' : ''}${!is_active ? ' dim' : ''}" title="Mute">M</button>
<button class="solo-btn${is_solo ? ' on' : ''}${!is_active ? ' dim' : ''}" title="Solo">S</button>
</span>
`;
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;
}
content.innerHTML = `
<div class="panel">
<div class="panel-header">
<h2>Pattern Settings</h2>
</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 &mdash; saves immediately</span>
<div style="flex:1"></div>
<button id="clear-notes-btn">Clear</button>
</div>
<div class="step-grid-scroll">
<div class="step-grid" id="step-grid" style="display:flex;flex-direction:column;gap:2px;width:fit-content;"></div>
</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, 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;
const is_perc = pattern.channel === 9;
const rows = is_perc ? PERC_NOTES : DISPLAY_NOTES;
grid.className = is_perc ? 'step-grid step-grid--perc' : 'step-grid';
/* Build note lookup */
const note_set = new Map();
for (const ev of notes) {
note_set.set(`${ev.note}:${ev.step}`, ev);
}
const active_step = (state.play_pattern_id === pattern.id) ? state.current_step : null;
let html = '';
/* Playhead indicator row — spacer aligns with play-btn + label */
html += '<div class="note-row playhead-row">';
html += '<div class="row-play-spacer"></div>';
html += '<div class="note-label"></div>';
for (let s = 0; s < pattern.steps; s++) {
const cur = active_step === s ? 'current' : '';
html += `<div class="step-indicator ${cur}" data-step="${s}"></div>`;
}
html += '</div>';
for (const midi of rows) {
const label = row_label(pattern, midi);
const row_cls = is_perc ? `note-row ${perc_family(midi)}` : 'note-row';
html += `<div class="${row_cls}" data-note="${midi}">`;
html += `<button class="row-play-btn" data-note="${midi}" data-channel="${pattern.channel}" title="Preview note">&#9654;</button>`;
html += `<div class="note-label" data-note="${midi}" title="${escape_html(label)}">${escape_html(label)}</div>`;
for (let s = 0; s < pattern.steps; s++) {
const key = `${midi}:${s}`;
const ev = note_set.get(key);
const on = ev ? 'on' : '';
const vel_pct = ev ? Math.round((ev.velocity / 127) * 100) : 0;
html += `<button class="step-btn ${on}" 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;
}
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 ─────────────────────────────────────────────────── */
async function toggle_note(pattern, note, step) {
const notes = pattern.notes.map(n => ({ ...n }));
const idx = notes.findIndex(n => n.note === note && n.step === step);
if (idx >= 0) {
notes.splice(idx, 1);
} else {
notes.push({ step, note, velocity: 100, duration_steps: 1 });
}
try {
const updated = await PUT(`/api/patterns/${pattern.id}/notes`, notes);
state.patterns.set(updated.id, updated);
render_step_grid(updated, updated.notes);
} catch (err) { console.error(err); }
}
async function preview_note(note, channel) {
try {
await POST('/api/preview', { channel, note, velocity: 100, duration_ms: 500 });
} catch (err) { console.error(err); }
}
function start_rename(pattern, note_num, label_el) {
if (label_el.querySelector('input')) return; /* already editing */
const label_key = `${pattern.id}:${note_num}`;
const is_perc = pattern.channel === 9;
const default_label = is_perc ? (GM_PERC[note_num] ?? `Note ${note_num}`) : note_name(note_num);
const current = state.custom_labels.get(label_key) ?? default_label;
const input = document.createElement('input');
input.type = 'text';
input.value = current;
input.className = 'note-label-input';
label_el.textContent = '';
label_el.appendChild(input);
input.focus();
input.select();
function commit() {
const val = input.value.trim();
if (val && val !== default_label) {
state.custom_labels.set(label_key, val);
} else {
state.custom_labels.delete(label_key);
}
label_el.textContent = state.custom_labels.get(label_key) ?? default_label;
}
input.addEventListener('blur', commit);
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
input.blur();
} else if (e.key === 'Escape') {
input.removeEventListener('blur', commit);
label_el.textContent = current;
}
});
}
function select_pattern(id) {
state.selected_id = id;
render_pattern_list();
render_content();
}
/* ── Track interactions ──────────────────────────────────────────── */
async function toggle_track_active(id) {
const active = !state.active_tracks.has(id);
if (active) {
state.active_tracks.add(id);
} else {
state.active_tracks.delete(id);
state.muted_tracks.delete(id);
if (state.solo_id === id) state.solo_id = null;
}
try {
await PUT(`/api/tracks/${id}/active`, { active });
} catch (err) { console.error(err); }
render_pattern_list();
}
async function toggle_mute(id) {
if (!state.active_tracks.has(id)) return;
const muted = !state.muted_tracks.has(id);
if (muted) state.muted_tracks.add(id);
else state.muted_tracks.delete(id);
try {
await PUT(`/api/tracks/${id}/mute`, { muted });
} catch (err) { console.error(err); }
render_pattern_list();
}
async function toggle_solo(id) {
if (!state.active_tracks.has(id)) return;
const new_solo = state.solo_id === id ? null : id;
state.solo_id = new_solo;
try {
await PUT('/api/solo', { id: new_solo });
} catch (err) { console.error(err); }
render_pattern_list();
}
/* Pattern list click delegation */
document.getElementById('pattern-list').addEventListener('click', (e) => {
const item = e.target.closest('.pattern-item');
if (!item) return;
const id = parseInt(item.dataset.id, 10);
if (e.target.closest('.track-btn')) { toggle_track_active(id); return; }
if (e.target.closest('.mute-btn')) { toggle_mute(id); return; }
if (e.target.closest('.solo-btn')) { toggle_solo(id); return; }
select_pattern(id);
});
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);
render_pattern_list();
render_content();
} catch (err) { console.error(err); }
});
/* Clear notes — auto-save */
document.getElementById('clear-notes-btn')?.addEventListener('click', async () => {
try {
const updated = await PUT(`/api/patterns/${pattern.id}/notes`, []);
state.patterns.set(updated.id, updated);
render_step_grid(updated, updated.notes);
} catch (err) { console.error(err); }
});
/* 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;
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); }
});
}
/* ── Content-level click delegation ─────────────────────────────── */
document.getElementById('content').addEventListener('click', (e) => {
const pattern = state.selected_pattern;
if (!pattern) return;
const step_btn = e.target.closest('.step-btn');
if (step_btn) {
toggle_note(pattern, parseInt(step_btn.dataset.note, 10), parseInt(step_btn.dataset.step, 10));
return;
}
const play_btn = e.target.closest('.row-play-btn');
if (play_btn) {
preview_note(parseInt(play_btn.dataset.note, 10), parseInt(play_btn.dataset.channel, 10));
return;
}
const label = e.target.closest('.note-label');
if (label && label.dataset.note && !e.target.matches('input')) {
start_rename(pattern, parseInt(label.dataset.note, 10), label);
return;
}
});
/* ── Transport ───────────────────────────────────────────────────── */
document.getElementById('play-btn').addEventListener('click', async () => {
try {
await POST('/api/play');
} 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('new-perc-btn').addEventListener('click', async () => {
try {
const p = await POST('/api/patterns', { name: 'Drums', steps: 16, channel: 9 });
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-indicator.current').forEach(b => b.classList.remove('current'));
const indicator = document.querySelector(`.step-indicator[data-step="${step}"]`);
if (indicator) indicator.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);
state.active_tracks = new Set(data.tracks?.active ?? []);
state.muted_tracks = new Set(data.tracks?.muted ?? []);
state.solo_id = data.tracks?.solo_id ?? null;
state.playing = data.is_playing ?? false;
render_pattern_list();
render_content();
update_status_ui();
});
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', () => {
state.playing = true;
update_status_ui();
});
es.addEventListener('stop', () => {
state.playing = false;
state.current_step = null;
update_status_ui();
document.querySelectorAll('.step-indicator.current').forEach(b => b.classList.remove('current'));
});
es.addEventListener('tracks_updated', (e) => {
const { active, muted, solo_id } = JSON.parse(e.data);
state.active_tracks = new Set(active);
state.muted_tracks = new Set(muted);
state.solo_id = solo_id;
render_pattern_list();
});
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) {
/* Only refresh the grid — full render_content() resets scroll position */
render_step_grid(p, p.notes);
}
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;');
}