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,