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:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,25 +95,19 @@ 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tick thread ──────────────────────────────────────────────────── */
|
||||
@@ -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 */
|
||||
pthread_mutex_lock(&seq->tracks_mutex);
|
||||
|
||||
for (int ti = 0; ti < MAX_TRACKS; ti++) {
|
||||
Track *t = &seq->tracks[ti];
|
||||
if (!t->active) continue;
|
||||
|
||||
pattern_store_lock(seq->ps);
|
||||
|
||||
Pattern *root = pattern_store_get(seq->ps, seq->root_pattern_id);
|
||||
if (!root) {
|
||||
Pattern *pat = pattern_store_get(seq->ps, t->pattern_id);
|
||||
if (!pat) {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
t->active = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
process_step(seq, subpat, ps->local_step, sn, 1);
|
||||
ps->local_step++;
|
||||
uint8_t step = t->current_step;
|
||||
|
||||
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;
|
||||
/* Notify sub-pattern end */
|
||||
uint8_t buf[16];
|
||||
Msg_Pattern_End msg = { .pattern_id = ps->pattern_id };
|
||||
int len = proto_encode_pattern_end(buf, &msg);
|
||||
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, len);
|
||||
seq->send_fn(seq->send_ctx, buf, elen);
|
||||
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; break; }
|
||||
}
|
||||
}
|
||||
|
||||
/* Send BEAT_TICK */
|
||||
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_Beat_Tick tick = {
|
||||
.pattern_id = seq->root_pattern_id,
|
||||
.pattern_id = t->pattern_id,
|
||||
.step = step,
|
||||
.beat = seq->current_beat,
|
||||
.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);
|
||||
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; }
|
||||
}
|
||||
|
||||
/* Advance step */
|
||||
seq->current_step++;
|
||||
if (seq->current_step >= root->steps) {
|
||||
seq->current_step = 0;
|
||||
seq->current_beat = (uint8_t)(seq->current_beat + 1);
|
||||
/* 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 = seq->root_pattern_id };
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
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);
|
||||
|
||||
@@ -81,10 +81,44 @@
|
||||
gap: 8px;
|
||||
}
|
||||
.pattern-item:hover { background: var(--surface2); }
|
||||
.pattern-item.active { border-color: var(--accent); 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 {
|
||||
display: flex; gap: 8px; flex-wrap: wrap;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -17,6 +17,9 @@ export class Pattern_State {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user