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>
This commit is contained in:
@@ -64,6 +64,9 @@ class App_State {
|
||||
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() {
|
||||
@@ -117,13 +120,21 @@ 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 ? ' active' : '');
|
||||
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'}">⬤</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>
|
||||
`;
|
||||
div.addEventListener('click', () => select_pattern(p.id));
|
||||
list.appendChild(div);
|
||||
}
|
||||
}
|
||||
@@ -315,6 +326,57 @@ function select_pattern(id) {
|
||||
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 () => {
|
||||
@@ -389,10 +451,8 @@ document.getElementById('content').addEventListener('click', (e) => {
|
||||
/* ── Transport ───────────────────────────────────────────────────── */
|
||||
|
||||
document.getElementById('play-btn').addEventListener('click', async () => {
|
||||
const pattern = state.selected_pattern;
|
||||
if (!pattern) return;
|
||||
try {
|
||||
await POST('/api/play', { pattern_id: pattern.id });
|
||||
await POST('/api/play');
|
||||
} catch (err) { console.error(err); }
|
||||
});
|
||||
|
||||
@@ -459,8 +519,13 @@ es.addEventListener('state', (e) => {
|
||||
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(); });
|
||||
@@ -480,18 +545,26 @@ es.addEventListener('beat_tick', (e) => {
|
||||
update_step_highlight(step, pattern_id);
|
||||
});
|
||||
|
||||
es.addEventListener('play', (e) => {
|
||||
const { pattern_id } = JSON.parse(e.data);
|
||||
state.playing = true; state.play_pattern_id = pattern_id;
|
||||
es.addEventListener('play', () => {
|
||||
state.playing = true;
|
||||
update_status_ui();
|
||||
});
|
||||
|
||||
es.addEventListener('stop', () => {
|
||||
state.playing = false; state.current_step = null;
|
||||
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);
|
||||
|
||||
@@ -80,10 +80,44 @@
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.pattern-item:hover { background: var(--surface2); }
|
||||
.pattern-item.active { border-color: var(--accent); background: var(--surface2); }
|
||||
.pattern-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pattern-item .steps { font-size: 11px; color: var(--text-dim); }
|
||||
.pattern-item:hover { background: var(--surface2); }
|
||||
.pattern-item.selected { border-color: var(--accent); background: var(--surface2); }
|
||||
.pattern-item .name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.pattern-item .steps { font-size: 11px; color: var(--text-dim); }
|
||||
|
||||
.track-btn {
|
||||
width: 16px; height: 16px;
|
||||
padding: 0;
|
||||
font-size: 8px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.track-btn:hover { color: var(--text); }
|
||||
.track-btn.on { color: #2ecc71; }
|
||||
|
||||
.track-ctl { display: flex; gap: 3px; margin-left: auto; }
|
||||
|
||||
.mute-btn, .solo-btn {
|
||||
width: 18px; height: 18px;
|
||||
padding: 0;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
background: var(--surface2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: var(--text-dim);
|
||||
flex-shrink: 0;
|
||||
font-family: inherit;
|
||||
}
|
||||
.mute-btn:hover, .solo-btn:hover { border-color: var(--accent2); color: var(--text); }
|
||||
.mute-btn.on { background: #7a3030; border-color: #c04040; color: #f08080; }
|
||||
.solo-btn.on { background: #7a6000; border-color: #c0a000; color: #f0d060; }
|
||||
.mute-btn.dim, .solo-btn.dim { opacity: 0.3; cursor: default; }
|
||||
|
||||
/* Transport */
|
||||
.transport {
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
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));
|
||||
@@ -23,6 +27,7 @@ const SOCK_PATH = process.env.SOCKET_PATH || '/tmp/midi-sequencer.sock';
|
||||
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 ────────────────────────────────────────────────── */
|
||||
|
||||
@@ -41,6 +46,14 @@ 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) {
|
||||
@@ -85,7 +98,7 @@ app.get('/api/events', (req, res) => {
|
||||
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: 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));
|
||||
@@ -155,18 +168,21 @@ app.post('/api/patterns/:id/sub-refs', (req, res) => {
|
||||
|
||||
/* ── 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' });
|
||||
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 }));
|
||||
}
|
||||
backend.send(encode_play({ pattern_id }));
|
||||
broadcast('play', { pattern_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 });
|
||||
});
|
||||
@@ -186,6 +202,50 @@ 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) => {
|
||||
|
||||
@@ -14,6 +14,10 @@ export const RT = Object.freeze({
|
||||
STOP : 0x07,
|
||||
SET_TEMPO : 0x08,
|
||||
PREVIEW_NOTE : 0x09,
|
||||
ADD_TRACK : 0x0A,
|
||||
REMOVE_TRACK : 0x0B,
|
||||
PLAY_TRACKS : 0x0C,
|
||||
SET_TRACK_MUTE : 0x0D,
|
||||
ACK : 0x81,
|
||||
ERROR : 0x82,
|
||||
BEAT_TICK : 0x83,
|
||||
@@ -34,6 +38,10 @@ export const PAYLOAD_SIZE = Object.freeze({
|
||||
STOP : 0,
|
||||
SET_TEMPO : 2,
|
||||
PREVIEW_NOTE : 5,
|
||||
ADD_TRACK : 2,
|
||||
REMOVE_TRACK : 2,
|
||||
PLAY_TRACKS : 0,
|
||||
SET_TRACK_MUTE : 3,
|
||||
ACK : 1,
|
||||
ERROR : 2,
|
||||
BEAT_TICK : 4,
|
||||
@@ -113,6 +121,29 @@ export function encode_preview_note({ channel, note, velocity, duration_ms }) {
|
||||
return write_frame(RT.PREVIEW_NOTE, buf);
|
||||
}
|
||||
|
||||
export function encode_add_track({ pattern_id }) {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
return write_frame(RT.ADD_TRACK, buf);
|
||||
}
|
||||
|
||||
export function encode_remove_track({ pattern_id }) {
|
||||
const buf = Buffer.alloc(2);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
return write_frame(RT.REMOVE_TRACK, buf);
|
||||
}
|
||||
|
||||
export function encode_play_tracks() {
|
||||
return write_frame(RT.PLAY_TRACKS, Buffer.alloc(0));
|
||||
}
|
||||
|
||||
export function encode_set_track_mute({ pattern_id, muted }) {
|
||||
const buf = Buffer.alloc(3);
|
||||
buf.writeUInt16LE(pattern_id, 0);
|
||||
buf.writeUInt8(muted, 2);
|
||||
return write_frame(RT.SET_TRACK_MUTE, buf);
|
||||
}
|
||||
|
||||
export function encode_ack({ acked_type }) {
|
||||
const buf = Buffer.alloc(1);
|
||||
buf.writeUInt8(acked_type, 0);
|
||||
@@ -186,6 +217,25 @@ export function decode_preview_note(payload) {
|
||||
return { channel: payload.readUInt8(0), note: payload.readUInt8(1), velocity: payload.readUInt8(2), duration_ms: payload.readUInt16LE(3) };
|
||||
}
|
||||
|
||||
export function decode_add_track(payload) {
|
||||
if (payload.length < 2) throw new Error('ADD_TRACK payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0) };
|
||||
}
|
||||
|
||||
export function decode_remove_track(payload) {
|
||||
if (payload.length < 2) throw new Error('REMOVE_TRACK payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0) };
|
||||
}
|
||||
|
||||
export function decode_play_tracks(payload) {
|
||||
return {};
|
||||
}
|
||||
|
||||
export function decode_set_track_mute(payload) {
|
||||
if (payload.length < 3) throw new Error('SET_TRACK_MUTE payload too short');
|
||||
return { pattern_id: payload.readUInt16LE(0), muted: payload.readUInt8(2) };
|
||||
}
|
||||
|
||||
export function decode_ack(payload) {
|
||||
if (payload.length < 1) throw new Error('ACK payload too short');
|
||||
return { acked_type: payload.readUInt8(0) };
|
||||
@@ -218,6 +268,10 @@ export function decode(record_type, payload) {
|
||||
case RT.STOP: return decode_stop(payload);
|
||||
case RT.SET_TEMPO: return decode_set_tempo(payload);
|
||||
case RT.PREVIEW_NOTE: return decode_preview_note(payload);
|
||||
case RT.ADD_TRACK: return decode_add_track(payload);
|
||||
case RT.REMOVE_TRACK: return decode_remove_track(payload);
|
||||
case RT.PLAY_TRACKS: return decode_play_tracks(payload);
|
||||
case RT.SET_TRACK_MUTE: return decode_set_track_mute(payload);
|
||||
case RT.ACK: return decode_ack(payload);
|
||||
case RT.ERROR: return decode_error(payload);
|
||||
case RT.BEAT_TICK: return decode_beat_tick(payload);
|
||||
|
||||
@@ -14,9 +14,12 @@
|
||||
*/
|
||||
export class Pattern_State {
|
||||
constructor() {
|
||||
this._patterns = new Map(); /* id → pattern object */
|
||||
this._next_id = 1;
|
||||
this._bpm_x10 = 1200; /* 120.0 BPM */
|
||||
this._patterns = new Map(); /* id → pattern object */
|
||||
this._next_id = 1;
|
||||
this._bpm_x10 = 1200; /* 120.0 BPM */
|
||||
this._active_tracks = new Set(); /* pattern_id Set */
|
||||
this._muted_tracks = new Set(); /* pattern_id Set */
|
||||
this._solo_id = null; /* pattern_id or null */
|
||||
}
|
||||
|
||||
/* ── Pattern CRUD ─────────────────────────────────────────────── */
|
||||
@@ -110,12 +113,59 @@ export class Pattern_State {
|
||||
return this._bpm_x10;
|
||||
}
|
||||
|
||||
/* ── Track state ────────────────────────────────────────────────── */
|
||||
|
||||
set_track_active(id, active) {
|
||||
if (active) {
|
||||
this._active_tracks.add(id);
|
||||
} else {
|
||||
this._active_tracks.delete(id);
|
||||
this._muted_tracks.delete(id);
|
||||
if (this._solo_id === id) this._solo_id = null;
|
||||
}
|
||||
}
|
||||
|
||||
set_track_muted(id, muted) {
|
||||
if (muted) this._muted_tracks.add(id);
|
||||
else this._muted_tracks.delete(id);
|
||||
}
|
||||
|
||||
set_solo(id) {
|
||||
this._solo_id = id;
|
||||
}
|
||||
|
||||
clear_solo() {
|
||||
this._solo_id = null;
|
||||
}
|
||||
|
||||
get active_tracks() { return [...this._active_tracks]; }
|
||||
get muted_tracks() { return [...this._muted_tracks]; }
|
||||
get solo_id() { return this._solo_id; }
|
||||
|
||||
is_track_active(id) { return this._active_tracks.has(id); }
|
||||
is_track_muted(id) { return this._muted_tracks.has(id); }
|
||||
|
||||
/* Effective mute accounting for solo */
|
||||
effective_mute(id) {
|
||||
if (this._solo_id !== null) return id !== this._solo_id;
|
||||
return this._muted_tracks.has(id);
|
||||
}
|
||||
|
||||
tracks_json() {
|
||||
return {
|
||||
active: [...this._active_tracks],
|
||||
muted: [...this._muted_tracks],
|
||||
solo_id: this._solo_id,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Serialization (for API responses) ───────────────────────── */
|
||||
|
||||
to_json() {
|
||||
return {
|
||||
bpm: this.bpm,
|
||||
patterns: this.list_patterns(),
|
||||
tracks: this.tracks_json(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user