diff --git a/c-backend/generated/protocol.c b/c-backend/generated/protocol.c index c09d2df..ee0bc1c 100644 --- a/c-backend/generated/protocol.c +++ b/c-backend/generated/protocol.c @@ -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; diff --git a/c-backend/generated/protocol.h b/c-backend/generated/protocol.h index 97ad3f8..c995b02 100644 --- a/c-backend/generated/protocol.h +++ b/c-backend/generated/protocol.h @@ -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); diff --git a/c-backend/src/main.c b/c-backend/src/main.c index 77d0ca8..6809c7f 100644 --- a/c-backend/src/main.c +++ b/c-backend/src/main.c @@ -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; diff --git a/c-backend/src/sequencer.c b/c-backend/src/sequencer.c index 208a224..6371c86 100644 --- a/c-backend/src/sequencer.c +++ b/c-backend/src/sequencer.c @@ -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) { diff --git a/c-backend/src/sequencer.h b/c-backend/src/sequencer.h index 4cbcb8b..7c3d7c2 100644 --- a/c-backend/src/sequencer.h +++ b/c-backend/src/sequencer.h @@ -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, diff --git a/node-server/public/app.mjs b/node-server/public/app.mjs index c0ad156..d8cac0c 100644 --- a/node-server/public/app.mjs +++ b/node-server/public/app.mjs @@ -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 = ` + ${escape_html(p.name)} ${p.steps}st + + + + `; - 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); diff --git a/node-server/public/index.html b/node-server/public/index.html index b4eb5b3..b6193da 100644 --- a/node-server/public/index.html +++ b/node-server/public/index.html @@ -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 { diff --git a/node-server/server.mjs b/node-server/server.mjs index 17a922a..d681ffc 100644 --- a/node-server/server.mjs +++ b/node-server/server.mjs @@ -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) => { diff --git a/node-server/src/generated/protocol.mjs b/node-server/src/generated/protocol.mjs index 865049d..e1b79a0 100644 --- a/node-server/src/generated/protocol.mjs +++ b/node-server/src/generated/protocol.mjs @@ -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); diff --git a/node-server/src/pattern_state.mjs b/node-server/src/pattern_state.mjs index 168c77b..c130849 100644 --- a/node-server/src/pattern_state.mjs +++ b/node-server/src/pattern_state.mjs @@ -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(), }; } } diff --git a/protocol.yaml b/protocol.yaml index f34d287..6c955a2 100644 --- a/protocol.yaml +++ b/protocol.yaml @@ -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