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:
2026-04-25 04:16:06 +00:00
parent a452477825
commit 9b45905d80
11 changed files with 599 additions and 141 deletions

View File

@@ -90,6 +90,29 @@ int proto_encode_preview_note(uint8_t *buf, const Msg_Preview_Note *r) {
return write_frame(buf, RT_PREVIEW_NOTE, 5);
}
int proto_encode_add_track(uint8_t *buf, const Msg_Add_Track *r) {
int off = FRAME_HEADER_SIZE;
put_u16(buf, &off, r->pattern_id);
return write_frame(buf, RT_ADD_TRACK, 2);
}
int proto_encode_remove_track(uint8_t *buf, const Msg_Remove_Track *r) {
int off = FRAME_HEADER_SIZE;
put_u16(buf, &off, r->pattern_id);
return write_frame(buf, RT_REMOVE_TRACK, 2);
}
int proto_encode_play_tracks(uint8_t *buf) {
return write_frame(buf, RT_PLAY_TRACKS, 0);
}
int proto_encode_set_track_mute(uint8_t *buf, const Msg_Set_Track_Mute *r) {
int off = FRAME_HEADER_SIZE;
put_u16(buf, &off, r->pattern_id);
put_u8(buf, &off, r->muted);
return write_frame(buf, RT_SET_TRACK_MUTE, 3);
}
int proto_encode_ack(uint8_t *buf, const Msg_Ack *r) {
int off = FRAME_HEADER_SIZE;
put_u8(buf, &off, r->acked_type);
@@ -199,6 +222,36 @@ int proto_decode_preview_note(const uint8_t *p, uint16_t len, Msg_Preview_Note *
return 0;
}
int proto_decode_add_track(const uint8_t *p, uint16_t len, Msg_Add_Track *r) {
if (len < 2) return -1;
int off = 0;
r->pattern_id = get_u16(p, &off);
(void)off;
return 0;
}
int proto_decode_remove_track(const uint8_t *p, uint16_t len, Msg_Remove_Track *r) {
if (len < 2) return -1;
int off = 0;
r->pattern_id = get_u16(p, &off);
(void)off;
return 0;
}
int proto_decode_play_tracks(const uint8_t *p, uint16_t len, Msg_Play_Tracks *r) {
(void)p; (void)len; (void)r;
return 0;
}
int proto_decode_set_track_mute(const uint8_t *p, uint16_t len, Msg_Set_Track_Mute *r) {
if (len < 3) return -1;
int off = 0;
r->pattern_id = get_u16(p, &off);
r->muted = get_u8(p, &off);
(void)off;
return 0;
}
int proto_decode_ack(const uint8_t *p, uint16_t len, Msg_Ack *r) {
if (len < 1) return -1;
int off = 0;

View File

@@ -19,6 +19,10 @@ typedef enum {
RT_STOP = 0x07,
RT_SET_TEMPO = 0x08,
RT_PREVIEW_NOTE = 0x09,
RT_ADD_TRACK = 0x0A,
RT_REMOVE_TRACK = 0x0B,
RT_PLAY_TRACKS = 0x0C,
RT_SET_TRACK_MUTE = 0x0D,
RT_ACK = 0x81,
RT_ERROR = 0x82,
RT_BEAT_TICK = 0x83,
@@ -35,6 +39,10 @@ typedef enum {
#define PAYLOAD_SIZE_STOP 0
#define PAYLOAD_SIZE_SET_TEMPO 2
#define PAYLOAD_SIZE_PREVIEW_NOTE 5
#define PAYLOAD_SIZE_ADD_TRACK 2
#define PAYLOAD_SIZE_REMOVE_TRACK 2
#define PAYLOAD_SIZE_PLAY_TRACKS 0
#define PAYLOAD_SIZE_SET_TRACK_MUTE 3
#define PAYLOAD_SIZE_ACK 1
#define PAYLOAD_SIZE_ERROR 2
#define PAYLOAD_SIZE_BEAT_TICK 4
@@ -79,6 +87,19 @@ typedef struct {
uint8_t velocity;
uint16_t duration_ms; /* Note-off will be sent after this many milliseconds */
} Msg_Preview_Note;
typedef struct {
uint16_t pattern_id;
} Msg_Add_Track;
typedef struct {
uint16_t pattern_id;
} Msg_Remove_Track;
typedef struct {
char _empty;
} Msg_Play_Tracks;
typedef struct {
uint16_t pattern_id;
uint8_t muted;
} Msg_Set_Track_Mute;
typedef struct {
uint8_t acked_type; /* Record type being acknowledged */
} Msg_Ack;
@@ -105,6 +126,10 @@ int proto_encode_play(uint8_t *buf, const Msg_Play *r);
int proto_encode_stop(uint8_t *buf);
int proto_encode_set_tempo(uint8_t *buf, const Msg_Set_Tempo *r);
int proto_encode_preview_note(uint8_t *buf, const Msg_Preview_Note *r);
int proto_encode_add_track(uint8_t *buf, const Msg_Add_Track *r);
int proto_encode_remove_track(uint8_t *buf, const Msg_Remove_Track *r);
int proto_encode_play_tracks(uint8_t *buf);
int proto_encode_set_track_mute(uint8_t *buf, const Msg_Set_Track_Mute *r);
int proto_encode_ack(uint8_t *buf, const Msg_Ack *r);
int proto_encode_error(uint8_t *buf, const Msg_Error *r);
int proto_encode_beat_tick(uint8_t *buf, const Msg_Beat_Tick *r);
@@ -120,6 +145,10 @@ int proto_decode_play(const uint8_t *payload, uint16_t len, Msg_Play *r);
int proto_decode_stop(const uint8_t *payload, uint16_t len, Msg_Stop *r);
int proto_decode_set_tempo(const uint8_t *payload, uint16_t len, Msg_Set_Tempo *r);
int proto_decode_preview_note(const uint8_t *payload, uint16_t len, Msg_Preview_Note *r);
int proto_decode_add_track(const uint8_t *payload, uint16_t len, Msg_Add_Track *r);
int proto_decode_remove_track(const uint8_t *payload, uint16_t len, Msg_Remove_Track *r);
int proto_decode_play_tracks(const uint8_t *payload, uint16_t len, Msg_Play_Tracks *r);
int proto_decode_set_track_mute(const uint8_t *payload, uint16_t len, Msg_Set_Track_Mute *r);
int proto_decode_ack(const uint8_t *payload, uint16_t len, Msg_Ack *r);
int proto_decode_error(const uint8_t *payload, uint16_t len, Msg_Error *r);
int proto_decode_beat_tick(const uint8_t *payload, uint16_t len, Msg_Beat_Tick *r);

View File

@@ -92,7 +92,9 @@ static void on_frame(uint8_t rt, const uint8_t *payload, uint16_t payload_len, v
case RT_PLAY: {
Msg_Play msg;
if (proto_decode_play(payload, payload_len, &msg) < 0) goto bad_payload;
sequencer_play(&app->seq, msg.pattern_id);
sequencer_stop(&app->seq);
sequencer_add_track(&app->seq, msg.pattern_id);
sequencer_play(&app->seq);
break;
}
case RT_STOP: {
@@ -111,6 +113,28 @@ static void on_frame(uint8_t rt, const uint8_t *payload, uint16_t payload_len, v
sequencer_preview_note(&app->seq, msg.channel, msg.note, msg.velocity, msg.duration_ms);
break;
}
case RT_ADD_TRACK: {
Msg_Add_Track msg;
if (proto_decode_add_track(payload, payload_len, &msg) < 0) goto bad_payload;
sequencer_add_track(&app->seq, msg.pattern_id);
break;
}
case RT_REMOVE_TRACK: {
Msg_Remove_Track msg;
if (proto_decode_remove_track(payload, payload_len, &msg) < 0) goto bad_payload;
sequencer_remove_track(&app->seq, msg.pattern_id);
break;
}
case RT_PLAY_TRACKS: {
sequencer_play(&app->seq);
break;
}
case RT_SET_TRACK_MUTE: {
Msg_Set_Track_Mute msg;
if (proto_decode_set_track_mute(payload, payload_len, &msg) < 0) goto bad_payload;
sequencer_set_track_mute(&app->seq, msg.pattern_id, msg.muted);
break;
}
default:
fprintf(stderr, "main: unknown record type 0x%02x\n", rt);
return;

View File

@@ -55,7 +55,6 @@ static void enqueue_note_off(Sequencer *seq, uint8_t ch, uint8_t note, uint64_t
pno->fire_at.tv_nsec = (long)(fire_ns % 1000000000ULL);
}
/* Fire any due note-offs; compact the queue. */
static void drain_note_offs(Sequencer *seq) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
@@ -77,11 +76,6 @@ static void drain_note_offs(Sequencer *seq) {
/* ── Tempo helpers ────────────────────────────────────────────────── */
/* Returns step duration in nanoseconds.
* One step = one 16th note.
* step_ns = 60,000,000,000 / (bpm_x10/10) / 4
* = 150,000,000,000 / bpm_x10
* At 120.0 BPM (bpm_x10=1200): 125,000,000 ns = 125 ms */
static uint64_t get_step_ns(Sequencer *seq) {
pthread_mutex_lock(&seq->tempo_mutex);
uint16_t bpm_x10 = seq->bpm_x10;
@@ -91,11 +85,8 @@ static uint64_t get_step_ns(Sequencer *seq) {
/* ── Step processing ──────────────────────────────────────────────── */
/* Process notes and sub-refs at a given step within a pattern.
* is_sub: non-zero means this is a sub-pattern call (don't recurse sub-refs). */
static void process_step(Sequencer *seq, Pattern *pat, uint8_t step,
uint64_t step_ns, int is_sub) {
/* Fire notes */
/* Process notes at a given step within a pattern; sub-ref activation uses track's pending_subs. */
static void process_step(Sequencer *seq, Track *t, Pattern *pat, uint8_t step, uint64_t step_ns) {
for (int i = 0; i < pat->note_count; i++) {
Note_Event *ev = &pat->notes[i];
if (ev->step != step) continue;
@@ -104,22 +95,16 @@ static void process_step(Sequencer *seq, Pattern *pat, uint8_t step,
step_ns * (uint64_t)ev->duration_steps);
}
/* Start sub-patterns (root level only; subs don't nest further in this pass) */
if (!is_sub) {
for (int i = 0; i < pat->sub_ref_count; i++) {
Sub_Ref *ref = &pat->sub_refs[i];
if (ref->step != step) continue;
/* Find an empty slot */
for (int j = 0; j < MAX_PENDING_SUBS; j++) {
if (!seq->pending_subs[j].active) {
seq->pending_subs[j].pattern_id = ref->sub_pattern_id;
seq->pending_subs[j].local_step = 0;
seq->pending_subs[j].active = 1;
if (j >= seq->pending_sub_count) {
seq->pending_sub_count = j + 1;
}
break;
}
for (int i = 0; i < pat->sub_ref_count; i++) {
Sub_Ref *ref = &pat->sub_refs[i];
if (ref->step != step) continue;
for (int j = 0; j < MAX_SUBS_PER_TRACK; j++) {
if (!t->pending_subs[j].active) {
t->pending_subs[j].pattern_id = ref->sub_pattern_id;
t->pending_subs[j].local_step = 0;
t->pending_subs[j].active = 1;
if (j >= t->pending_sub_count) t->pending_sub_count = j + 1;
break;
}
}
}
@@ -136,93 +121,98 @@ static void *tick_fn(void *arg) {
while (atomic_load(&seq->running)) {
uint64_t sn = get_step_ns(seq);
/* Fire due note-offs */
drain_note_offs(seq);
/* Lock pattern store for the whole tick */
pattern_store_lock(seq->ps);
pthread_mutex_lock(&seq->tracks_mutex);
Pattern *root = pattern_store_get(seq->ps, seq->root_pattern_id);
if (!root) {
pattern_store_unlock(seq->ps);
fprintf(stderr, "sequencer: root pattern %u not found, stopping\n",
seq->root_pattern_id);
atomic_store(&seq->running, 0);
break;
}
for (int ti = 0; ti < MAX_TRACKS; ti++) {
Track *t = &seq->tracks[ti];
if (!t->active) continue;
uint8_t step = seq->current_step;
/* Process root */
process_step(seq, root, step, sn, 0);
/* Process active sub-patterns */
for (int i = 0; i < seq->pending_sub_count; i++) {
Pending_Sub *ps = &seq->pending_subs[i];
if (!ps->active) continue;
Pattern *subpat = pattern_store_get(seq->ps, ps->pattern_id);
if (!subpat) {
ps->active = 0;
pattern_store_lock(seq->ps);
Pattern *pat = pattern_store_get(seq->ps, t->pattern_id);
if (!pat) {
pattern_store_unlock(seq->ps);
t->active = 0;
continue;
}
process_step(seq, subpat, ps->local_step, sn, 1);
ps->local_step++;
uint8_t step = t->current_step;
if (ps->local_step >= subpat->steps) {
ps->active = 0;
/* Notify sub-pattern end */
if (!t->muted) {
process_step(seq, t, pat, step, sn);
/* Process sub-patterns for this track */
for (int si = 0; si < t->pending_sub_count; si++) {
Pending_Sub *ps = &t->pending_subs[si];
if (!ps->active) continue;
Pattern *subpat = pattern_store_get(seq->ps, ps->pattern_id);
if (!subpat) { ps->active = 0; continue; }
/* Fire sub-step notes (no further recursion) */
for (int ni = 0; ni < subpat->note_count; ni++) {
Note_Event *ev = &subpat->notes[ni];
if (ev->step != ps->local_step) continue;
alsa_note_on(seq, subpat->channel, ev->note, ev->velocity);
enqueue_note_off(seq, subpat->channel, ev->note,
sn * (uint64_t)ev->duration_steps);
}
ps->local_step++;
if (ps->local_step >= subpat->steps) {
ps->active = 0;
uint8_t buf[16];
Msg_Pattern_End end_msg = { .pattern_id = ps->pattern_id };
int elen = proto_encode_pattern_end(buf, &end_msg);
pattern_store_unlock(seq->ps);
seq->send_fn(seq->send_ctx, buf, elen);
pattern_store_lock(seq->ps);
pat = pattern_store_get(seq->ps, t->pattern_id);
if (!pat) { t->active = 0; break; }
}
}
if (!t->active) {
/* pat is NULL and ps is locked after failed re-fetch */
pattern_store_unlock(seq->ps);
continue;
}
}
/* Send BEAT_TICK for this track */
{
uint8_t buf[16];
Msg_Pattern_End msg = { .pattern_id = ps->pattern_id };
int len = proto_encode_pattern_end(buf, &msg);
Msg_Beat_Tick tick = {
.pattern_id = t->pattern_id,
.step = step,
.beat = t->current_beat,
};
int len = proto_encode_beat_tick(buf, &tick);
pattern_store_unlock(seq->ps);
seq->send_fn(seq->send_ctx, buf, len);
pattern_store_lock(seq->ps);
/* Re-fetch root after unlock */
root = pattern_store_get(seq->ps, seq->root_pattern_id);
if (!root) {
pattern_store_unlock(seq->ps);
atomic_store(&seq->running, 0);
return NULL;
}
pat = pattern_store_get(seq->ps, t->pattern_id);
if (!pat) { t->active = 0; continue; }
}
}
/* Send BEAT_TICK */
{
uint8_t buf[16];
Msg_Beat_Tick tick = {
.pattern_id = seq->root_pattern_id,
.step = step,
.beat = seq->current_beat,
};
int len = proto_encode_beat_tick(buf, &tick);
pattern_store_unlock(seq->ps);
seq->send_fn(seq->send_ctx, buf, len);
pattern_store_lock(seq->ps);
root = pattern_store_get(seq->ps, seq->root_pattern_id);
if (!root) {
/* Advance step, wrap independently per track */
t->current_step++;
if (t->current_step >= pat->steps) {
t->current_step = 0;
t->current_beat++;
uint8_t buf[16];
Msg_Pattern_End msg = { .pattern_id = t->pattern_id };
int len = proto_encode_pattern_end(buf, &msg);
pattern_store_unlock(seq->ps);
seq->send_fn(seq->send_ctx, buf, len);
} else {
pattern_store_unlock(seq->ps);
atomic_store(&seq->running, 0);
return NULL;
}
}
/* Advance step */
seq->current_step++;
if (seq->current_step >= root->steps) {
seq->current_step = 0;
seq->current_beat = (uint8_t)(seq->current_beat + 1);
uint8_t buf[16];
Msg_Pattern_End msg = { .pattern_id = seq->root_pattern_id };
int len = proto_encode_pattern_end(buf, &msg);
pattern_store_unlock(seq->ps);
seq->send_fn(seq->send_ctx, buf, len);
} else {
pattern_store_unlock(seq->ps);
}
pthread_mutex_unlock(&seq->tracks_mutex);
/* Advance absolute deadline — drift-free */
uint64_t next_abs = (uint64_t)next.tv_sec * 1000000000ULL
@@ -243,9 +233,10 @@ int sequencer_init(Sequencer *seq, Pattern_Store *ps, const char *alsa_name,
seq->ps = ps;
seq->send_fn = send_fn;
seq->send_ctx = send_ctx;
seq->bpm_x10 = 1200; /* 120.0 BPM */
seq->bpm_x10 = 1200;
atomic_store(&seq->running, 0);
pthread_mutex_init(&seq->tempo_mutex, NULL);
pthread_mutex_init(&seq->tracks_mutex, NULL);
if (snd_seq_open(&seq->seq, "default", SND_SEQ_OPEN_OUTPUT, 0) < 0) {
fprintf(stderr, "sequencer: failed to open ALSA sequencer\n");
@@ -271,28 +262,76 @@ void sequencer_destroy(Sequencer *seq) {
sequencer_stop(seq);
snd_seq_close(seq->seq);
pthread_mutex_destroy(&seq->tempo_mutex);
pthread_mutex_destroy(&seq->tracks_mutex);
}
void sequencer_play(Sequencer *seq, uint16_t pattern_id) {
sequencer_stop(seq);
void sequencer_add_track(Sequencer *seq, uint16_t pattern_id) {
pthread_mutex_lock(&seq->tracks_mutex);
/* Check if already active */
for (int i = 0; i < MAX_TRACKS; i++) {
if (seq->tracks[i].active && seq->tracks[i].pattern_id == pattern_id) {
pthread_mutex_unlock(&seq->tracks_mutex);
return;
}
}
/* Find first empty slot */
for (int i = 0; i < MAX_TRACKS; i++) {
if (!seq->tracks[i].active) {
memset(&seq->tracks[i], 0, sizeof(Track));
seq->tracks[i].pattern_id = pattern_id;
seq->tracks[i].active = 1;
pthread_mutex_unlock(&seq->tracks_mutex);
return;
}
}
pthread_mutex_unlock(&seq->tracks_mutex);
fprintf(stderr, "sequencer: MAX_TRACKS reached, cannot add pattern %u\n", pattern_id);
}
seq->root_pattern_id = pattern_id;
seq->current_step = 0;
seq->current_beat = 0;
seq->note_off_count = 0;
seq->pending_sub_count = 0;
memset(seq->pending_subs, 0, sizeof(seq->pending_subs));
void sequencer_remove_track(Sequencer *seq, uint16_t pattern_id) {
pthread_mutex_lock(&seq->tracks_mutex);
for (int i = 0; i < MAX_TRACKS; i++) {
if (seq->tracks[i].active && seq->tracks[i].pattern_id == pattern_id) {
seq->tracks[i].active = 0;
break;
}
}
pthread_mutex_unlock(&seq->tracks_mutex);
}
void sequencer_set_track_mute(Sequencer *seq, uint16_t pattern_id, uint8_t muted) {
pthread_mutex_lock(&seq->tracks_mutex);
for (int i = 0; i < MAX_TRACKS; i++) {
if (seq->tracks[i].active && seq->tracks[i].pattern_id == pattern_id) {
seq->tracks[i].muted = muted;
break;
}
}
pthread_mutex_unlock(&seq->tracks_mutex);
}
void sequencer_clear_tracks(Sequencer *seq) {
pthread_mutex_lock(&seq->tracks_mutex);
memset(seq->tracks, 0, sizeof(seq->tracks));
pthread_mutex_unlock(&seq->tracks_mutex);
}
void sequencer_play(Sequencer *seq) {
if (atomic_load(&seq->running)) return;
atomic_store(&seq->running, 1);
pthread_create(&seq->tick_thread, NULL, tick_fn, seq);
}
void sequencer_stop(Sequencer *seq) {
if (!atomic_load(&seq->running)) return;
if (!atomic_load(&seq->running)) {
sequencer_clear_tracks(seq);
return;
}
atomic_store(&seq->running, 0);
pthread_join(seq->tick_thread, NULL);
alsa_all_notes_off(seq);
drain_note_offs(seq);
sequencer_clear_tracks(seq);
}
void sequencer_set_tempo(Sequencer *seq, uint16_t bpm_x10) {

View File

@@ -7,7 +7,8 @@
#include "pattern_store.h"
#define NOTE_OFF_QUEUE_SIZE 256
#define MAX_PENDING_SUBS 64
#define MAX_SUBS_PER_TRACK 16
#define MAX_TRACKS 16
typedef struct {
struct timespec fire_at;
@@ -21,6 +22,16 @@ typedef struct {
int active;
} Pending_Sub;
typedef struct {
uint16_t pattern_id;
uint8_t current_step;
uint8_t current_beat;
uint8_t muted;
int active;
Pending_Sub pending_subs[MAX_SUBS_PER_TRACK];
int pending_sub_count;
} Track;
/* Function pointer used to write frames back to the Node client */
typedef void (*Send_Fn)(void *ctx, const uint8_t *buf, int len);
@@ -37,19 +48,14 @@ typedef struct {
Send_Fn send_fn;
void *send_ctx;
/* Playback state — owned exclusively by tick thread when running */
uint16_t root_pattern_id;
uint8_t current_step;
uint8_t current_beat;
/* Tracks — protected by tracks_mutex */
Track tracks[MAX_TRACKS];
pthread_mutex_t tracks_mutex;
/* Note-off queue — tick thread only, no mutex needed */
Pending_Note_Off note_offs[NOTE_OFF_QUEUE_SIZE];
int note_off_count;
/* Pending sub-patterns — tick thread only */
Pending_Sub pending_subs[MAX_PENDING_SUBS];
int pending_sub_count;
/* Tempo — protected by tempo_mutex */
uint16_t bpm_x10;
pthread_mutex_t tempo_mutex;
@@ -62,7 +68,11 @@ typedef struct {
int sequencer_init(Sequencer *seq, Pattern_Store *ps, const char *alsa_name,
Send_Fn send_fn, void *send_ctx);
void sequencer_destroy(Sequencer *seq);
void sequencer_play(Sequencer *seq, uint16_t pattern_id);
void sequencer_add_track(Sequencer *seq, uint16_t pattern_id);
void sequencer_remove_track(Sequencer *seq, uint16_t pattern_id);
void sequencer_set_track_mute(Sequencer *seq, uint16_t pattern_id, uint8_t muted);
void sequencer_clear_tracks(Sequencer *seq);
void sequencer_play(Sequencer *seq);
void sequencer_stop(Sequencer *seq);
void sequencer_set_tempo(Sequencer *seq, uint16_t bpm_x10);
void sequencer_preview_note(Sequencer *seq, uint8_t channel, uint8_t note,

View File

@@ -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'}">&#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>
`;
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);

View File

@@ -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 {

View File

@@ -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) => {

View File

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

View File

@@ -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(),
};
}
}

View File

@@ -119,6 +119,38 @@ records:
type: uint16
note: Note-off will be sent after this many milliseconds
ADD_TRACK:
id: 0x0A
direction: node_to_c
description: Add a pattern as an independently-looping track
fields:
- name: pattern_id
type: uint16
REMOVE_TRACK:
id: 0x0B
direction: node_to_c
description: Remove a track and stop its playback
fields:
- name: pattern_id
type: uint16
PLAY_TRACKS:
id: 0x0C
direction: node_to_c
description: Start the multi-track engine with all added tracks
fields: []
SET_TRACK_MUTE:
id: 0x0D
direction: node_to_c
description: Mute or unmute a track without resetting its position
fields:
- name: pattern_id
type: uint16
- name: muted
type: uint8
ACK:
id: 0x81
direction: c_to_node