From 9aba8057a8bdb509677109ce29ed4503d5d9598c Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sat, 25 Apr 2026 00:54:38 +0000 Subject: [PATCH] Initial scaffold: ALSA MIDI C backend + Node ESM sequencer web app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - protocol.yaml: SSoT for the binary framing protocol (12 record types) - codegen/gen.mjs: generates C header/source and Node ESM from protocol.yaml - c-backend: ALSA sequencer with drift-free clock_nanosleep tick thread, pattern store (hierarchical sub-patterns), Unix socket server - node-server: Express 5 web app — REST API, SSE for real-time beat events, step-sequencer frontend with pending-edit / explicit-save flow Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + Makefile | 30 ++ c-backend/Makefile | 20 ++ c-backend/generated/protocol.c | 215 +++++++++++++ c-backend/generated/protocol.h | 116 ++++++++ c-backend/src/main.c | 176 +++++++++++ c-backend/src/pattern_store.c | 102 +++++++ c-backend/src/pattern_store.h | 52 ++++ c-backend/src/sequencer.c | 301 +++++++++++++++++++ c-backend/src/sequencer.h | 67 +++++ c-backend/src/socket_server.c | 153 ++++++++++ c-backend/src/socket_server.h | 35 +++ codegen/gen.mjs | 313 +++++++++++++++++++ codegen/package.json | 9 + node-server/Makefile | 21 ++ node-server/package.json | 10 + node-server/public/app.mjs | 397 +++++++++++++++++++++++++ node-server/public/index.html | 237 +++++++++++++++ node-server/server.mjs | 194 ++++++++++++ node-server/src/backend_client.mjs | 107 +++++++ node-server/src/generated/protocol.mjs | 210 +++++++++++++ node-server/src/pattern_state.mjs | 121 ++++++++ protocol.yaml | 147 +++++++++ 23 files changed, 3037 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile create mode 100644 c-backend/Makefile create mode 100644 c-backend/generated/protocol.c create mode 100644 c-backend/generated/protocol.h create mode 100644 c-backend/src/main.c create mode 100644 c-backend/src/pattern_store.c create mode 100644 c-backend/src/pattern_store.h create mode 100644 c-backend/src/sequencer.c create mode 100644 c-backend/src/sequencer.h create mode 100644 c-backend/src/socket_server.c create mode 100644 c-backend/src/socket_server.h create mode 100644 codegen/gen.mjs create mode 100644 codegen/package.json create mode 100644 node-server/Makefile create mode 100644 node-server/package.json create mode 100644 node-server/public/app.mjs create mode 100644 node-server/public/index.html create mode 100644 node-server/server.mjs create mode 100644 node-server/src/backend_client.mjs create mode 100644 node-server/src/generated/protocol.mjs create mode 100644 node-server/src/pattern_state.mjs create mode 100644 protocol.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d9848f --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +c-backend/midi-sequencer +c-backend/obj/ +*.o diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..5a00a98 --- /dev/null +++ b/Makefile @@ -0,0 +1,30 @@ +CODEGEN_INPUTS = protocol.yaml codegen/gen.mjs +CODEGEN_OUTPUTS = c-backend/generated/protocol.h \ + c-backend/generated/protocol.c \ + node-server/src/generated/protocol.mjs + +.PHONY: all generate c-backend node-install clean + +all: generate c-backend node-install + +generate: $(CODEGEN_OUTPUTS) + +$(CODEGEN_OUTPUTS): $(CODEGEN_INPUTS) | codegen/node_modules + node codegen/gen.mjs + +codegen/node_modules: codegen/package.json + cd codegen && npm install + @touch codegen/node_modules + +c-backend: generate + $(MAKE) -C c-backend + +node-install: node-server/node_modules + +node-server/node_modules: node-server/package.json + cd node-server && npm install + @touch node-server/node_modules + +clean: + $(MAKE) -C c-backend clean + rm -f $(CODEGEN_OUTPUTS) diff --git a/c-backend/Makefile b/c-backend/Makefile new file mode 100644 index 0000000..437ef54 --- /dev/null +++ b/c-backend/Makefile @@ -0,0 +1,20 @@ +CC = gcc +CFLAGS = -std=c11 -Wall -Wextra -D_GNU_SOURCE -Isrc -Igenerated -O2 +LDFLAGS = -lasound -lpthread +TARGET = midi-sequencer + +SRCS = src/main.c \ + src/socket_server.c \ + src/pattern_store.c \ + src/sequencer.c \ + generated/protocol.c + +.PHONY: all clean + +all: $(TARGET) + +$(TARGET): $(SRCS) src/socket_server.h src/pattern_store.h src/sequencer.h generated/protocol.h + $(CC) $(CFLAGS) -o $@ $(SRCS) $(LDFLAGS) + +clean: + rm -f $(TARGET) diff --git a/c-backend/generated/protocol.c b/c-backend/generated/protocol.c new file mode 100644 index 0000000..79852a6 --- /dev/null +++ b/c-backend/generated/protocol.c @@ -0,0 +1,215 @@ +/* AUTO-GENERATED by codegen/gen.mjs — DO NOT EDIT */ +#include "protocol.h" + +/* ── serialization helpers ───────────────────────────────────── */ + +static void put_u8(uint8_t *buf, int *off, uint8_t v) { + buf[(*off)++] = v; +} +static void put_u16(uint8_t *buf, int *off, uint16_t v) { + buf[(*off)++] = (uint8_t)(v & 0xFF); + buf[(*off)++] = (uint8_t)(v >> 8); +} +static uint8_t get_u8(const uint8_t *buf, int *off) { + return buf[(*off)++]; +} +static uint16_t get_u16(const uint8_t *buf, int *off) { + uint16_t v = (uint16_t)((uint16_t)buf[*off] | ((uint16_t)buf[*off + 1] << 8)); + *off += 2; + return v; +} +static int write_frame(uint8_t *buf, uint8_t record_type, int payload_len) { + buf[0] = record_type; + buf[1] = (uint8_t)(payload_len & 0xFF); + buf[2] = (uint8_t)(payload_len >> 8); + return FRAME_HEADER_SIZE + payload_len; +} + +/* ── encode ──────────────────────────────────────────────────── */ + +int proto_encode_hello(uint8_t *buf, const Msg_Hello *r) { + int off = FRAME_HEADER_SIZE; + put_u8(buf, &off, r->version); + return write_frame(buf, RT_HELLO, 1); +} + +int proto_encode_define_pattern(uint8_t *buf, const Msg_Define_Pattern *r) { + int off = FRAME_HEADER_SIZE; + put_u16(buf, &off, r->pattern_id); + put_u8(buf, &off, r->steps); + put_u8(buf, &off, r->channel); + return write_frame(buf, RT_DEFINE_PATTERN, 4); +} + +int proto_encode_clear_pattern(uint8_t *buf, const Msg_Clear_Pattern *r) { + int off = FRAME_HEADER_SIZE; + put_u16(buf, &off, r->pattern_id); + return write_frame(buf, RT_CLEAR_PATTERN, 2); +} + +int proto_encode_add_note(uint8_t *buf, const Msg_Add_Note *r) { + int off = FRAME_HEADER_SIZE; + put_u16(buf, &off, r->pattern_id); + put_u8(buf, &off, r->step); + put_u8(buf, &off, r->note); + put_u8(buf, &off, r->velocity); + put_u8(buf, &off, r->duration_steps); + return write_frame(buf, RT_ADD_NOTE, 6); +} + +int proto_encode_add_sub_pattern(uint8_t *buf, const Msg_Add_Sub_Pattern *r) { + int off = FRAME_HEADER_SIZE; + put_u16(buf, &off, r->pattern_id); + put_u8(buf, &off, r->step); + put_u16(buf, &off, r->sub_pattern_id); + return write_frame(buf, RT_ADD_SUB_PATTERN, 5); +} + +int proto_encode_play(uint8_t *buf, const Msg_Play *r) { + int off = FRAME_HEADER_SIZE; + put_u16(buf, &off, r->pattern_id); + return write_frame(buf, RT_PLAY, 2); +} + +int proto_encode_stop(uint8_t *buf) { + return write_frame(buf, RT_STOP, 0); +} + +int proto_encode_set_tempo(uint8_t *buf, const Msg_Set_Tempo *r) { + int off = FRAME_HEADER_SIZE; + put_u16(buf, &off, r->bpm_x10); + return write_frame(buf, RT_SET_TEMPO, 2); +} + +int proto_encode_ack(uint8_t *buf, const Msg_Ack *r) { + int off = FRAME_HEADER_SIZE; + put_u8(buf, &off, r->acked_type); + return write_frame(buf, RT_ACK, 1); +} + +int proto_encode_error(uint8_t *buf, const Msg_Error *r) { + int off = FRAME_HEADER_SIZE; + put_u8(buf, &off, r->code); + put_u8(buf, &off, r->context_type); + return write_frame(buf, RT_ERROR, 2); +} + +int proto_encode_beat_tick(uint8_t *buf, const Msg_Beat_Tick *r) { + int off = FRAME_HEADER_SIZE; + put_u16(buf, &off, r->pattern_id); + put_u8(buf, &off, r->step); + put_u8(buf, &off, r->beat); + return write_frame(buf, RT_BEAT_TICK, 4); +} + +int proto_encode_pattern_end(uint8_t *buf, const Msg_Pattern_End *r) { + int off = FRAME_HEADER_SIZE; + put_u16(buf, &off, r->pattern_id); + return write_frame(buf, RT_PATTERN_END, 2); +} + +/* ── decode ──────────────────────────────────────────────────── */ + +int proto_decode_hello(const uint8_t *p, uint16_t len, Msg_Hello *r) { + if (len < 1) return -1; + int off = 0; + r->version = get_u8(p, &off); + (void)off; + return 0; +} + +int proto_decode_define_pattern(const uint8_t *p, uint16_t len, Msg_Define_Pattern *r) { + if (len < 4) return -1; + int off = 0; + r->pattern_id = get_u16(p, &off); + r->steps = get_u8(p, &off); + r->channel = get_u8(p, &off); + (void)off; + return 0; +} + +int proto_decode_clear_pattern(const uint8_t *p, uint16_t len, Msg_Clear_Pattern *r) { + if (len < 2) return -1; + int off = 0; + r->pattern_id = get_u16(p, &off); + (void)off; + return 0; +} + +int proto_decode_add_note(const uint8_t *p, uint16_t len, Msg_Add_Note *r) { + if (len < 6) return -1; + int off = 0; + r->pattern_id = get_u16(p, &off); + r->step = get_u8(p, &off); + r->note = get_u8(p, &off); + r->velocity = get_u8(p, &off); + r->duration_steps = get_u8(p, &off); + (void)off; + return 0; +} + +int proto_decode_add_sub_pattern(const uint8_t *p, uint16_t len, Msg_Add_Sub_Pattern *r) { + if (len < 5) return -1; + int off = 0; + r->pattern_id = get_u16(p, &off); + r->step = get_u8(p, &off); + r->sub_pattern_id = get_u16(p, &off); + (void)off; + return 0; +} + +int proto_decode_play(const uint8_t *p, uint16_t len, Msg_Play *r) { + if (len < 2) return -1; + int off = 0; + r->pattern_id = get_u16(p, &off); + (void)off; + return 0; +} + +int proto_decode_stop(const uint8_t *p, uint16_t len, Msg_Stop *r) { + (void)p; (void)len; (void)r; + return 0; +} + +int proto_decode_set_tempo(const uint8_t *p, uint16_t len, Msg_Set_Tempo *r) { + if (len < 2) return -1; + int off = 0; + r->bpm_x10 = get_u16(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; + r->acked_type = get_u8(p, &off); + (void)off; + return 0; +} + +int proto_decode_error(const uint8_t *p, uint16_t len, Msg_Error *r) { + if (len < 2) return -1; + int off = 0; + r->code = get_u8(p, &off); + r->context_type = get_u8(p, &off); + (void)off; + return 0; +} + +int proto_decode_beat_tick(const uint8_t *p, uint16_t len, Msg_Beat_Tick *r) { + if (len < 4) return -1; + int off = 0; + r->pattern_id = get_u16(p, &off); + r->step = get_u8(p, &off); + r->beat = get_u8(p, &off); + (void)off; + return 0; +} + +int proto_decode_pattern_end(const uint8_t *p, uint16_t len, Msg_Pattern_End *r) { + if (len < 2) return -1; + int off = 0; + r->pattern_id = get_u16(p, &off); + (void)off; + return 0; +} diff --git a/c-backend/generated/protocol.h b/c-backend/generated/protocol.h new file mode 100644 index 0000000..253ce5a --- /dev/null +++ b/c-backend/generated/protocol.h @@ -0,0 +1,116 @@ +/* AUTO-GENERATED by codegen/gen.mjs — DO NOT EDIT */ +/* Source: protocol.yaml version 1 */ +#pragma once +#include +#include + +#define PROTOCOL_VERSION 1 +#define FRAME_HEADER_SIZE 3 +#define DIRECTION_C_TO_NODE(t) ((t) >= 0x80) + +/* Record types */ +typedef enum { + RT_HELLO = 0x01, + RT_DEFINE_PATTERN = 0x02, + RT_CLEAR_PATTERN = 0x03, + RT_ADD_NOTE = 0x04, + RT_ADD_SUB_PATTERN = 0x05, + RT_PLAY = 0x06, + RT_STOP = 0x07, + RT_SET_TEMPO = 0x08, + RT_ACK = 0x81, + RT_ERROR = 0x82, + RT_BEAT_TICK = 0x83, + RT_PATTERN_END = 0x84, +} Record_Type; + +/* Payload sizes (bytes, not including 3-byte frame header) */ +#define PAYLOAD_SIZE_HELLO 1 +#define PAYLOAD_SIZE_DEFINE_PATTERN 4 +#define PAYLOAD_SIZE_CLEAR_PATTERN 2 +#define PAYLOAD_SIZE_ADD_NOTE 6 +#define PAYLOAD_SIZE_ADD_SUB_PATTERN 5 +#define PAYLOAD_SIZE_PLAY 2 +#define PAYLOAD_SIZE_STOP 0 +#define PAYLOAD_SIZE_SET_TEMPO 2 +#define PAYLOAD_SIZE_ACK 1 +#define PAYLOAD_SIZE_ERROR 2 +#define PAYLOAD_SIZE_BEAT_TICK 4 +#define PAYLOAD_SIZE_PATTERN_END 2 + +/* Record payload structs */ +typedef struct { + uint8_t version; +} Msg_Hello; +typedef struct { + uint16_t pattern_id; + uint8_t steps; /* Total step count e.g. 16 for a one-bar pattern at 16th-note resolution */ + uint8_t channel; /* MIDI channel 0-15 */ +} Msg_Define_Pattern; +typedef struct { + uint16_t pattern_id; +} Msg_Clear_Pattern; +typedef struct { + uint16_t pattern_id; + uint8_t step; /* 0-based step index within the pattern */ + uint8_t note; /* MIDI note number 0-127 (middle C = 60) */ + uint8_t velocity; /* 0-127 */ + uint8_t duration_steps; /* Duration in steps (1 = one step) */ +} Msg_Add_Note; +typedef struct { + uint16_t pattern_id; /* Parent pattern ID */ + uint8_t step; /* Step within parent at which the sub-pattern begins playing */ + uint16_t sub_pattern_id; +} Msg_Add_Sub_Pattern; +typedef struct { + uint16_t pattern_id; +} Msg_Play; +typedef struct { + char _empty; +} Msg_Stop; +typedef struct { + uint16_t bpm_x10; /* BPM × 10 for 0.1 BPM resolution — e.g. 1200 = 120.0 BPM */ +} Msg_Set_Tempo; +typedef struct { + uint8_t acked_type; /* Record type being acknowledged */ +} Msg_Ack; +typedef struct { + uint8_t code; + uint8_t context_type; /* Record type that triggered this error */ +} Msg_Error; +typedef struct { + uint16_t pattern_id; + uint8_t step; /* Current step within the pattern (0-based) */ + uint8_t beat; /* Current beat number within the current cycle (wraps at 255) */ +} Msg_Beat_Tick; +typedef struct { + uint16_t pattern_id; +} Msg_Pattern_End; + +/* Encode: write complete frame to buf, return total bytes written */ +int proto_encode_hello(uint8_t *buf, const Msg_Hello *r); +int proto_encode_define_pattern(uint8_t *buf, const Msg_Define_Pattern *r); +int proto_encode_clear_pattern(uint8_t *buf, const Msg_Clear_Pattern *r); +int proto_encode_add_note(uint8_t *buf, const Msg_Add_Note *r); +int proto_encode_add_sub_pattern(uint8_t *buf, const Msg_Add_Sub_Pattern *r); +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_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); +int proto_encode_pattern_end(uint8_t *buf, const Msg_Pattern_End *r); + +/* Decode: parse payload into struct, return 0 on success, -1 on error */ +int proto_decode_hello(const uint8_t *payload, uint16_t len, Msg_Hello *r); +int proto_decode_define_pattern(const uint8_t *payload, uint16_t len, Msg_Define_Pattern *r); +int proto_decode_clear_pattern(const uint8_t *payload, uint16_t len, Msg_Clear_Pattern *r); +int proto_decode_add_note(const uint8_t *payload, uint16_t len, Msg_Add_Note *r); +int proto_decode_add_sub_pattern(const uint8_t *payload, uint16_t len, Msg_Add_Sub_Pattern *r); +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_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); +int proto_decode_pattern_end(const uint8_t *payload, uint16_t len, Msg_Pattern_End *r); diff --git a/c-backend/src/main.c b/c-backend/src/main.c new file mode 100644 index 0000000..ac78311 --- /dev/null +++ b/c-backend/src/main.c @@ -0,0 +1,176 @@ +#include +#include +#include +#include +#include + +#include "pattern_store.h" +#include "sequencer.h" +#include "socket_server.h" +#include "../generated/protocol.h" + +/* ── Application context ──────────────────────────────────────────── */ + +typedef struct { + Pattern_Store ps; + Sequencer seq; + Socket_Server srv; +} App; + +static App g_app; + +/* ── Send callback for sequencer ─────────────────────────────────── */ + +static void seq_send(void *ctx, const uint8_t *buf, int len) { + App *app = (App *)ctx; + socket_server_send(&app->srv, buf, len); +} + +/* ── Frame handler ────────────────────────────────────────────────── */ + +static void send_ack(App *app, uint8_t acked_type) { + uint8_t buf[16]; + Msg_Ack ack = { .acked_type = acked_type }; + int len = proto_encode_ack(buf, &ack); + socket_server_send(&app->srv, buf, len); +} + +static void send_error(App *app, uint8_t code, uint8_t context_type) { + uint8_t buf[16]; + Msg_Error err = { .code = code, .context_type = context_type }; + int len = proto_encode_error(buf, &err); + socket_server_send(&app->srv, buf, len); +} + +static void on_frame(uint8_t rt, const uint8_t *payload, uint16_t payload_len, void *ctx) { + App *app = (App *)ctx; + + switch (rt) { + case RT_HELLO: { + Msg_Hello msg; + if (proto_decode_hello(payload, payload_len, &msg) < 0) goto bad_payload; + fprintf(stderr, "main: HELLO from client, version=%u\n", msg.version); + /* Reply with our HELLO */ + uint8_t buf[16]; + Msg_Hello reply = { .version = PROTOCOL_VERSION }; + int len = proto_encode_hello(buf, &reply); + socket_server_send(&app->srv, buf, len); + return; + } + case RT_DEFINE_PATTERN: { + Msg_Define_Pattern msg; + if (proto_decode_define_pattern(payload, payload_len, &msg) < 0) goto bad_payload; + pattern_store_define(&app->ps, msg.pattern_id, msg.steps, msg.channel); + break; + } + case RT_CLEAR_PATTERN: { + Msg_Clear_Pattern msg; + if (proto_decode_clear_pattern(payload, payload_len, &msg) < 0) goto bad_payload; + pattern_store_clear(&app->ps, msg.pattern_id); + break; + } + case RT_ADD_NOTE: { + Msg_Add_Note msg; + if (proto_decode_add_note(payload, payload_len, &msg) < 0) goto bad_payload; + if (pattern_store_add_note(&app->ps, msg.pattern_id, msg.step, + msg.note, msg.velocity, msg.duration_steps) < 0) { + send_error(app, 0x01, rt); + return; + } + break; + } + case RT_ADD_SUB_PATTERN: { + Msg_Add_Sub_Pattern msg; + if (proto_decode_add_sub_pattern(payload, payload_len, &msg) < 0) goto bad_payload; + if (pattern_store_add_sub_ref(&app->ps, msg.pattern_id, msg.step, + msg.sub_pattern_id) < 0) { + send_error(app, 0x02, rt); + return; + } + break; + } + 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); + break; + } + case RT_STOP: { + sequencer_stop(&app->seq); + break; + } + case RT_SET_TEMPO: { + Msg_Set_Tempo msg; + if (proto_decode_set_tempo(payload, payload_len, &msg) < 0) goto bad_payload; + sequencer_set_tempo(&app->seq, msg.bpm_x10); + break; + } + default: + fprintf(stderr, "main: unknown record type 0x%02x\n", rt); + return; + } + + send_ack(app, rt); + return; + +bad_payload: + fprintf(stderr, "main: bad payload for record type 0x%02x\n", rt); + send_error(app, 0xFF, rt); +} + +/* ── Connect / disconnect callbacks ──────────────────────────────── */ + +static void on_connect(int fd, void *ctx) { + (void)fd; + (void)ctx; + fprintf(stderr, "main: node client connected\n"); +} + +static void on_disconnect(void *ctx) { + App *app = (App *)ctx; + sequencer_stop(&app->seq); + fprintf(stderr, "main: node client disconnected, playback stopped\n"); +} + +/* ── Signal handler ───────────────────────────────────────────────── */ + +static volatile int g_running = 1; +static void on_signal(int sig) { (void)sig; g_running = 0; } + +/* ── Entry point ──────────────────────────────────────────────────── */ + +int main(int argc, char *argv[]) { + const char *socket_path = argc > 1 ? argv[1] : "/tmp/midi-sequencer.sock"; + const char *alsa_name = argc > 2 ? argv[2] : "midi-sequencer"; + + fprintf(stderr, "midi-sequencer starting\n"); + fprintf(stderr, " socket : %s\n", socket_path); + fprintf(stderr, " ALSA : %s\n", alsa_name); + + pattern_store_init(&g_app.ps); + + if (sequencer_init(&g_app.seq, &g_app.ps, alsa_name, seq_send, &g_app) < 0) { + fprintf(stderr, "main: sequencer init failed\n"); + return 1; + } + + if (socket_server_start(&g_app.srv, socket_path, + on_frame, on_connect, on_disconnect, &g_app) < 0) { + fprintf(stderr, "main: socket server failed to start\n"); + return 1; + } + + signal(SIGINT, on_signal); + signal(SIGTERM, on_signal); + + fprintf(stderr, "midi-sequencer ready, waiting for node client\n"); + while (g_running) { + sleep(1); + } + + fprintf(stderr, "midi-sequencer shutting down\n"); + socket_server_stop(&g_app.srv); + sequencer_destroy(&g_app.seq); + pattern_store_destroy(&g_app.ps); + return 0; +} diff --git a/c-backend/src/pattern_store.c b/c-backend/src/pattern_store.c new file mode 100644 index 0000000..65f0403 --- /dev/null +++ b/c-backend/src/pattern_store.c @@ -0,0 +1,102 @@ +#include "pattern_store.h" +#include +#include + +void pattern_store_init(Pattern_Store *ps) { + memset(ps, 0, sizeof(*ps)); + pthread_mutex_init(&ps->mutex, NULL); +} + +void pattern_store_destroy(Pattern_Store *ps) { + pthread_mutex_destroy(&ps->mutex); +} + +void pattern_store_lock(Pattern_Store *ps) { + pthread_mutex_lock(&ps->mutex); +} + +void pattern_store_unlock(Pattern_Store *ps) { + pthread_mutex_unlock(&ps->mutex); +} + +/* Linear search — fine for PATTERN_STORE_SIZE = 256 */ +Pattern *pattern_store_get(Pattern_Store *ps, uint16_t id) { + for (int i = 0; i < ps->count; i++) { + if (ps->entries[i].id == id && ps->entries[i].defined) { + return &ps->entries[i]; + } + } + return NULL; +} + +static Pattern *get_or_create(Pattern_Store *ps, uint16_t id) { + Pattern *p = pattern_store_get(ps, id); + if (p) return p; + if (ps->count >= PATTERN_STORE_SIZE) { + fprintf(stderr, "pattern_store: capacity reached\n"); + return NULL; + } + p = &ps->entries[ps->count++]; + memset(p, 0, sizeof(*p)); + p->id = id; + p->defined = 1; + return p; +} + +void pattern_store_define(Pattern_Store *ps, uint16_t id, uint8_t steps, uint8_t channel) { + pattern_store_lock(ps); + Pattern *p = get_or_create(ps, id); + if (p) { + p->steps = steps; + p->channel = channel; + p->defined = 1; + } + pattern_store_unlock(ps); +} + +void pattern_store_clear(Pattern_Store *ps, uint16_t id) { + pattern_store_lock(ps); + Pattern *p = pattern_store_get(ps, id); + if (p) { + p->note_count = 0; + p->sub_ref_count = 0; + } + pattern_store_unlock(ps); +} + +int pattern_store_add_note(Pattern_Store *ps, uint16_t id, uint8_t step, + uint8_t note, uint8_t velocity, uint8_t duration_steps) { + int result = 0; + pattern_store_lock(ps); + Pattern *p = pattern_store_get(ps, id); + if (!p) { + result = -1; + } else if (p->note_count >= MAX_NOTES_PER_PAT) { + result = -2; + } else { + Note_Event *ev = &p->notes[p->note_count++]; + ev->step = step; + ev->note = note; + ev->velocity = velocity; + ev->duration_steps = duration_steps; + } + pattern_store_unlock(ps); + return result; +} + +int pattern_store_add_sub_ref(Pattern_Store *ps, uint16_t id, uint8_t step, uint16_t sub_id) { + int result = 0; + pattern_store_lock(ps); + Pattern *p = pattern_store_get(ps, id); + if (!p) { + result = -1; + } else if (p->sub_ref_count >= MAX_SUB_REFS_PER_PAT) { + result = -2; + } else { + Sub_Ref *ref = &p->sub_refs[p->sub_ref_count++]; + ref->step = step; + ref->sub_pattern_id = sub_id; + } + pattern_store_unlock(ps); + return result; +} diff --git a/c-backend/src/pattern_store.h b/c-backend/src/pattern_store.h new file mode 100644 index 0000000..5d1ef36 --- /dev/null +++ b/c-backend/src/pattern_store.h @@ -0,0 +1,52 @@ +#pragma once +#include +#include + +#define PATTERN_STORE_SIZE 256 +#define MAX_NOTES_PER_PAT 64 +#define MAX_SUB_REFS_PER_PAT 16 + +typedef struct { + uint8_t step; + uint8_t note; + uint8_t velocity; + uint8_t duration_steps; +} Note_Event; + +typedef struct { + uint8_t step; + uint16_t sub_pattern_id; +} Sub_Ref; + +typedef struct { + uint16_t id; + uint8_t steps; + uint8_t channel; + int defined; + Note_Event notes[MAX_NOTES_PER_PAT]; + int note_count; + Sub_Ref sub_refs[MAX_SUB_REFS_PER_PAT]; + int sub_ref_count; +} Pattern; + +typedef struct { + Pattern entries[PATTERN_STORE_SIZE]; + int count; + pthread_mutex_t mutex; +} Pattern_Store; + +/* Lifecycle */ +void pattern_store_init(Pattern_Store *ps); +void pattern_store_destroy(Pattern_Store *ps); + +/* Thread-safe operations */ +void pattern_store_define(Pattern_Store *ps, uint16_t id, uint8_t steps, uint8_t channel); +void pattern_store_clear(Pattern_Store *ps, uint16_t id); +int pattern_store_add_note(Pattern_Store *ps, uint16_t id, uint8_t step, + uint8_t note, uint8_t velocity, uint8_t duration_steps); +int pattern_store_add_sub_ref(Pattern_Store *ps, uint16_t id, uint8_t step, uint16_t sub_id); + +/* NOT thread-safe: caller must hold the lock */ +Pattern *pattern_store_get(Pattern_Store *ps, uint16_t id); +void pattern_store_lock(Pattern_Store *ps); +void pattern_store_unlock(Pattern_Store *ps); diff --git a/c-backend/src/sequencer.c b/c-backend/src/sequencer.c new file mode 100644 index 0000000..faded83 --- /dev/null +++ b/c-backend/src/sequencer.c @@ -0,0 +1,301 @@ +#include "sequencer.h" +#include "../generated/protocol.h" +#include +#include +#include +#include +#include + +/* ── ALSA helpers ─────────────────────────────────────────────────── */ + +static void alsa_note_on(Sequencer *seq, uint8_t ch, uint8_t note, uint8_t vel) { + snd_seq_event_t ev; + snd_seq_ev_clear(&ev); + snd_seq_ev_set_source(&ev, seq->port_id); + snd_seq_ev_set_subs(&ev); + snd_seq_ev_set_direct(&ev); + snd_seq_ev_set_noteon(&ev, ch, note, vel); + snd_seq_event_output_direct(seq->seq, &ev); +} + +static void alsa_note_off(Sequencer *seq, uint8_t ch, uint8_t note) { + snd_seq_event_t ev; + snd_seq_ev_clear(&ev); + snd_seq_ev_set_source(&ev, seq->port_id); + snd_seq_ev_set_subs(&ev); + snd_seq_ev_set_direct(&ev); + snd_seq_ev_set_noteoff(&ev, ch, note, 0); + snd_seq_event_output_direct(seq->seq, &ev); +} + +static void alsa_all_notes_off(Sequencer *seq) { + for (int ch = 0; ch < 16; ch++) { + for (int n = 0; n < 128; n++) { + alsa_note_off(seq, (uint8_t)ch, (uint8_t)n); + } + } +} + +/* ── Note-off queue ───────────────────────────────────────────────── */ + +static void enqueue_note_off(Sequencer *seq, uint8_t ch, uint8_t note, uint64_t delay_ns) { + if (seq->note_off_count >= NOTE_OFF_QUEUE_SIZE) { + fprintf(stderr, "sequencer: note-off queue full\n"); + return; + } + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + uint64_t fire_ns = (uint64_t)now.tv_sec * 1000000000ULL + (uint64_t)now.tv_nsec + delay_ns; + + Pending_Note_Off *pno = &seq->note_offs[seq->note_off_count++]; + pno->channel = ch; + pno->note = note; + pno->fire_at.tv_sec = (time_t)(fire_ns / 1000000000ULL); + 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); + uint64_t now_ns = (uint64_t)now.tv_sec * 1000000000ULL + (uint64_t)now.tv_nsec; + + int write_idx = 0; + for (int i = 0; i < seq->note_off_count; i++) { + Pending_Note_Off *pno = &seq->note_offs[i]; + uint64_t fire_ns = (uint64_t)pno->fire_at.tv_sec * 1000000000ULL + + (uint64_t)pno->fire_at.tv_nsec; + if (fire_ns <= now_ns) { + alsa_note_off(seq, pno->channel, pno->note); + } else { + seq->note_offs[write_idx++] = *pno; + } + } + seq->note_off_count = write_idx; +} + +/* ── 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; + pthread_mutex_unlock(&seq->tempo_mutex); + return 150000000000ULL / (uint64_t)bpm_x10; +} + +/* ── 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 */ + for (int i = 0; i < pat->note_count; i++) { + Note_Event *ev = &pat->notes[i]; + if (ev->step != step) continue; + alsa_note_on(seq, pat->channel, ev->note, ev->velocity); + enqueue_note_off(seq, pat->channel, ev->note, + 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; + } + } + } + } +} + +/* ── Tick thread ──────────────────────────────────────────────────── */ + +static void *tick_fn(void *arg) { + Sequencer *seq = (Sequencer *)arg; + + struct timespec next; + clock_gettime(CLOCK_MONOTONIC, &next); + + 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); + + 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; + } + + 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; + continue; + } + + process_step(seq, subpat, ps->local_step, sn, 1); + 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); + 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; + } + } + } + + /* 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) { + 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); + } + + /* Advance absolute deadline — drift-free */ + uint64_t next_abs = (uint64_t)next.tv_sec * 1000000000ULL + + (uint64_t)next.tv_nsec + sn; + next.tv_sec = (time_t)(next_abs / 1000000000ULL); + next.tv_nsec = (long)(next_abs % 1000000000ULL); + clock_nanosleep(CLOCK_MONOTONIC, TIMER_ABSTIME, &next, NULL); + } + + return NULL; +} + +/* ── Public API ───────────────────────────────────────────────────── */ + +int sequencer_init(Sequencer *seq, Pattern_Store *ps, const char *alsa_name, + Send_Fn send_fn, void *send_ctx) { + memset(seq, 0, sizeof(*seq)); + seq->ps = ps; + seq->send_fn = send_fn; + seq->send_ctx = send_ctx; + seq->bpm_x10 = 1200; /* 120.0 BPM */ + atomic_store(&seq->running, 0); + pthread_mutex_init(&seq->tempo_mutex, NULL); + + if (snd_seq_open(&seq->seq, "default", SND_SEQ_OPEN_OUTPUT, 0) < 0) { + fprintf(stderr, "sequencer: failed to open ALSA sequencer\n"); + return -1; + } + snd_seq_set_client_name(seq->seq, alsa_name ? alsa_name : "midi-sequencer"); + seq->client_id = snd_seq_client_id(seq->seq); + + seq->port_id = snd_seq_create_simple_port(seq->seq, "output", + SND_SEQ_PORT_CAP_READ | SND_SEQ_PORT_CAP_SUBS_READ, + SND_SEQ_PORT_TYPE_APPLICATION | SND_SEQ_PORT_TYPE_MIDI_GENERIC); + if (seq->port_id < 0) { + fprintf(stderr, "sequencer: failed to create ALSA port\n"); + snd_seq_close(seq->seq); + return -1; + } + + fprintf(stderr, "sequencer: ALSA client %d port %d\n", seq->client_id, seq->port_id); + return 0; +} + +void sequencer_destroy(Sequencer *seq) { + sequencer_stop(seq); + snd_seq_close(seq->seq); + pthread_mutex_destroy(&seq->tempo_mutex); +} + +void sequencer_play(Sequencer *seq, uint16_t pattern_id) { + sequencer_stop(seq); + + 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)); + + 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; + atomic_store(&seq->running, 0); + pthread_join(seq->tick_thread, NULL); + alsa_all_notes_off(seq); + drain_note_offs(seq); +} + +void sequencer_set_tempo(Sequencer *seq, uint16_t bpm_x10) { + pthread_mutex_lock(&seq->tempo_mutex); + seq->bpm_x10 = bpm_x10 > 0 ? bpm_x10 : 1; + pthread_mutex_unlock(&seq->tempo_mutex); +} diff --git a/c-backend/src/sequencer.h b/c-backend/src/sequencer.h new file mode 100644 index 0000000..c5cc6e7 --- /dev/null +++ b/c-backend/src/sequencer.h @@ -0,0 +1,67 @@ +#pragma once +#include +#include +#include +#include +#include +#include "pattern_store.h" + +#define NOTE_OFF_QUEUE_SIZE 256 +#define MAX_PENDING_SUBS 64 + +typedef struct { + struct timespec fire_at; + uint8_t channel; + uint8_t note; +} Pending_Note_Off; + +typedef struct { + uint16_t pattern_id; + uint8_t local_step; + int active; +} Pending_Sub; + +/* Function pointer used to write frames back to the Node client */ +typedef void (*Send_Fn)(void *ctx, const uint8_t *buf, int len); + +typedef struct { + /* ALSA */ + snd_seq_t *seq; + int port_id; + int client_id; + + /* Pattern store (shared with socket thread) */ + Pattern_Store *ps; + + /* Callback for sending frames to Node */ + 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; + + /* 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; + + /* Thread control */ + atomic_int running; + pthread_t tick_thread; +} Sequencer; + +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_stop(Sequencer *seq); +void sequencer_set_tempo(Sequencer *seq, uint16_t bpm_x10); diff --git a/c-backend/src/socket_server.c b/c-backend/src/socket_server.c new file mode 100644 index 0000000..8e7dfdd --- /dev/null +++ b/c-backend/src/socket_server.c @@ -0,0 +1,153 @@ +#include "socket_server.h" +#include "../generated/protocol.h" +#include +#include +#include +#include +#include +#include +#include + +/* Read exactly len bytes, return 0 on success, -1 on EOF/error */ +static int read_exact(int fd, uint8_t *buf, size_t len) { + size_t done = 0; + while (done < len) { + ssize_t n = read(fd, buf + done, len - done); + if (n <= 0) return -1; + done += (size_t)n; + } + return 0; +} + +/* Read loop for the connected client */ +static void *read_thread_fn(void *arg) { + Socket_Server *srv = (Socket_Server *)arg; + uint8_t hdr[FRAME_HEADER_SIZE]; + uint8_t payload[65535]; + + while (atomic_load(&srv->running)) { + int fd = atomic_load(&srv->client_fd); + if (fd < 0) { + /* No client yet, busy-wait briefly */ + struct timespec ts = { .tv_sec = 0, .tv_nsec = 10000000 }; + nanosleep(&ts, NULL); + continue; + } + + if (read_exact(fd, hdr, FRAME_HEADER_SIZE) < 0) goto disconnected; + + uint8_t record_type = hdr[0]; + uint16_t payload_len = (uint16_t)(hdr[1] | ((uint16_t)hdr[2] << 8)); + + if (payload_len > 0) { + if (read_exact(fd, payload, payload_len) < 0) goto disconnected; + } + + if (srv->on_frame) { + srv->on_frame(record_type, payload, payload_len, srv->ctx); + } + continue; + +disconnected: + close(fd); + atomic_store(&srv->client_fd, -1); + if (srv->on_disconnect) { + srv->on_disconnect(srv->ctx); + } + fprintf(stderr, "socket_server: client disconnected\n"); + } + + return NULL; +} + +/* Accept loop */ +static void *accept_thread_fn(void *arg) { + Socket_Server *srv = (Socket_Server *)arg; + + while (atomic_load(&srv->running)) { + int fd = accept(srv->sock_fd, NULL, NULL); + if (fd < 0) { + if (errno == EINTR || errno == EBADF) break; + perror("socket_server: accept"); + continue; + } + + /* Close any existing client */ + int old_fd = atomic_exchange(&srv->client_fd, fd); + if (old_fd >= 0) { + close(old_fd); + if (srv->on_disconnect) srv->on_disconnect(srv->ctx); + } + + fprintf(stderr, "socket_server: client connected\n"); + if (srv->on_connect) { + srv->on_connect(fd, srv->ctx); + } + } + + return NULL; +} + +int socket_server_start(Socket_Server *srv, const char *path, + Frame_Handler on_frame, + Connect_Handler on_connect, + Disconnect_Handler on_disconnect, + void *ctx) { + memset(srv, 0, sizeof(*srv)); + strncpy(srv->sock_path, path, sizeof(srv->sock_path) - 1); + srv->on_frame = on_frame; + srv->on_connect = on_connect; + srv->on_disconnect = on_disconnect; + srv->ctx = ctx; + atomic_store(&srv->client_fd, -1); + atomic_store(&srv->running, 1); + pthread_mutex_init(&srv->write_mutex, NULL); + + unlink(path); /* Remove stale socket */ + + srv->sock_fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (srv->sock_fd < 0) { perror("socket"); return -1; } + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1); + + if (bind(srv->sock_fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) { + perror("bind"); close(srv->sock_fd); return -1; + } + if (listen(srv->sock_fd, 1) < 0) { + perror("listen"); close(srv->sock_fd); return -1; + } + + pthread_create(&srv->read_thread, NULL, read_thread_fn, srv); + pthread_create(&srv->accept_thread, NULL, accept_thread_fn, srv); + return 0; +} + +void socket_server_stop(Socket_Server *srv) { + atomic_store(&srv->running, 0); + close(srv->sock_fd); + int fd = atomic_exchange(&srv->client_fd, -1); + if (fd >= 0) close(fd); + pthread_join(srv->accept_thread, NULL); + pthread_join(srv->read_thread, NULL); + pthread_mutex_destroy(&srv->write_mutex); + unlink(srv->sock_path); +} + +void socket_server_send(Socket_Server *srv, const uint8_t *buf, int len) { + int fd = atomic_load(&srv->client_fd); + if (fd < 0) return; + pthread_mutex_lock(&srv->write_mutex); + ssize_t done = 0; + while (done < len) { + ssize_t n = write(fd, buf + done, (size_t)(len - done)); + if (n <= 0) { + atomic_store(&srv->client_fd, -1); + break; + } + done += n; + } + pthread_mutex_unlock(&srv->write_mutex); +} diff --git a/c-backend/src/socket_server.h b/c-backend/src/socket_server.h new file mode 100644 index 0000000..18fad2a --- /dev/null +++ b/c-backend/src/socket_server.h @@ -0,0 +1,35 @@ +#pragma once +#include +#include +#include + +typedef void (*Frame_Handler)(uint8_t record_type, const uint8_t *payload, + uint16_t payload_len, void *ctx); +typedef void (*Connect_Handler)(int client_fd, void *ctx); +typedef void (*Disconnect_Handler)(void *ctx); + +typedef struct { + int sock_fd; + atomic_int client_fd; + pthread_t accept_thread; + pthread_t read_thread; + pthread_mutex_t write_mutex; + + Frame_Handler on_frame; + Connect_Handler on_connect; + Disconnect_Handler on_disconnect; + void *ctx; + + atomic_int running; + char sock_path[256]; +} Socket_Server; + +int socket_server_start(Socket_Server *srv, const char *path, + Frame_Handler on_frame, + Connect_Handler on_connect, + Disconnect_Handler on_disconnect, + void *ctx); +void socket_server_stop(Socket_Server *srv); + +/* Thread-safe write to current client; no-op if no client connected */ +void socket_server_send(Socket_Server *srv, const uint8_t *buf, int len); diff --git a/codegen/gen.mjs b/codegen/gen.mjs new file mode 100644 index 0000000..43fc276 --- /dev/null +++ b/codegen/gen.mjs @@ -0,0 +1,313 @@ +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { fileURLToPath } from 'url'; +import { dirname, join, resolve } from 'path'; +import yaml from 'js-yaml'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT = resolve(__dirname, '..'); + +const proto = yaml.load(readFileSync(join(ROOT, 'protocol.yaml'), 'utf8')); + +const RECORDS = Object.entries(proto.records); +const TYPES = proto.types; +const C_GEN_DIR = join(ROOT, 'c-backend', 'generated'); +const NODE_GEN_DIR = join(ROOT, 'node-server', 'src', 'generated'); + +mkdirSync(C_GEN_DIR, { recursive: true }); +mkdirSync(NODE_GEN_DIR, { recursive: true }); + +/* ── helpers ──────────────────────────────────────────────────────── */ + +function payload_size(record) { + let size = 0; + for (const f of (record.fields || [])) { + size += TYPES[f.type].size; + } + return size; +} + +function struct_name(id) { + return 'Msg_' + id.split('_').map(w => w[0].toUpperCase() + w.slice(1).toLowerCase()).join('_'); +} + +function rt_const(id) { + return 'RT_' + id.toUpperCase(); +} + +function fn_name(id) { + return id.toLowerCase(); +} + +/* ── C header ─────────────────────────────────────────────────────── */ + +function gen_c_header() { + const L = []; + + L.push(`/* AUTO-GENERATED by codegen/gen.mjs — DO NOT EDIT */`); + L.push(`/* Source: protocol.yaml version ${proto.version} */`); + L.push(`#pragma once`); + L.push(`#include `); + L.push(`#include `); + L.push(``); + L.push(`#define PROTOCOL_VERSION ${proto.version}`); + L.push(`#define FRAME_HEADER_SIZE 3`); + L.push(`#define DIRECTION_C_TO_NODE(t) ((t) >= 0x80)`); + L.push(``); + + /* enum */ + L.push(`/* Record types */`); + L.push(`typedef enum {`); + for (const [id, rec] of RECORDS) { + L.push(`\t${rt_const(id).padEnd(24)} = 0x${rec.id.toString(16).padStart(2, '0').toUpperCase()},`); + } + L.push(`} Record_Type;`); + L.push(``); + + /* payload size constants */ + L.push(`/* Payload sizes (bytes, not including 3-byte frame header) */`); + for (const [id, rec] of RECORDS) { + const name = `PAYLOAD_SIZE_${id.toUpperCase()}`; + L.push(`#define ${name.padEnd(32)} ${payload_size(rec)}`); + } + L.push(``); + + /* structs */ + L.push(`/* Record payload structs */`); + for (const [id, rec] of RECORDS) { + const sn = struct_name(id); + L.push(`typedef struct {`); + if (!rec.fields || rec.fields.length === 0) { + L.push(`\tchar _empty;`); + } else { + for (const f of rec.fields) { + const ct = TYPES[f.type].c_type; + const note = f.note ? ` /* ${f.note} */` : ''; + L.push(`\t${ct.padEnd(10)} ${f.name};${note}`); + } + } + L.push(`} ${sn};`); + } + L.push(``); + + /* function declarations */ + L.push(`/* Encode: write complete frame to buf, return total bytes written */`); + for (const [id, rec] of RECORDS) { + const sn = struct_name(id); + if (!rec.fields || rec.fields.length === 0) { + L.push(`int proto_encode_${fn_name(id)}(uint8_t *buf);`); + } else { + L.push(`int proto_encode_${fn_name(id)}(uint8_t *buf, const ${sn} *r);`); + } + } + L.push(``); + L.push(`/* Decode: parse payload into struct, return 0 on success, -1 on error */`); + for (const [id, rec] of RECORDS) { + const sn = struct_name(id); + L.push(`int proto_decode_${fn_name(id)}(const uint8_t *payload, uint16_t len, ${sn} *r);`); + } + + return L.join('\n') + '\n'; +} + +/* ── C source ─────────────────────────────────────────────────────── */ + +function gen_c_source() { + const L = []; + + L.push(`/* AUTO-GENERATED by codegen/gen.mjs — DO NOT EDIT */`); + L.push(`#include "protocol.h"`); + L.push(``); + L.push(`/* ── serialization helpers ───────────────────────────────────── */`); + L.push(``); + L.push(`static void put_u8(uint8_t *buf, int *off, uint8_t v) {`); + L.push(`\tbuf[(*off)++] = v;`); + L.push(`}`); + L.push(`static void put_u16(uint8_t *buf, int *off, uint16_t v) {`); + L.push(`\tbuf[(*off)++] = (uint8_t)(v & 0xFF);`); + L.push(`\tbuf[(*off)++] = (uint8_t)(v >> 8);`); + L.push(`}`); + L.push(`static uint8_t get_u8(const uint8_t *buf, int *off) {`); + L.push(`\treturn buf[(*off)++];`); + L.push(`}`); + L.push(`static uint16_t get_u16(const uint8_t *buf, int *off) {`); + L.push(`\tuint16_t v = (uint16_t)((uint16_t)buf[*off] | ((uint16_t)buf[*off + 1] << 8));`); + L.push(`\t*off += 2;`); + L.push(`\treturn v;`); + L.push(`}`); + L.push(`static int write_frame(uint8_t *buf, uint8_t record_type, int payload_len) {`); + L.push(`\tbuf[0] = record_type;`); + L.push(`\tbuf[1] = (uint8_t)(payload_len & 0xFF);`); + L.push(`\tbuf[2] = (uint8_t)(payload_len >> 8);`); + L.push(`\treturn FRAME_HEADER_SIZE + payload_len;`); + L.push(`}`); + L.push(``); + + /* encode functions */ + L.push(`/* ── encode ──────────────────────────────────────────────────── */`); + L.push(``); + for (const [id, rec] of RECORDS) { + const sn = struct_name(id); + const ps = payload_size(rec); + const no_fields = !rec.fields || rec.fields.length === 0; + + if (no_fields) { + L.push(`int proto_encode_${fn_name(id)}(uint8_t *buf) {`); + L.push(`\treturn write_frame(buf, ${rt_const(id)}, 0);`); + } else { + L.push(`int proto_encode_${fn_name(id)}(uint8_t *buf, const ${sn} *r) {`); + L.push(`\tint off = FRAME_HEADER_SIZE;`); + for (const f of rec.fields) { + L.push(`\t${TYPES[f.type].c_put}(buf, &off, r->${f.name});`); + } + L.push(`\treturn write_frame(buf, ${rt_const(id)}, ${ps});`); + } + L.push(`}`); + L.push(``); + } + + /* decode functions */ + L.push(`/* ── decode ──────────────────────────────────────────────────── */`); + L.push(``); + for (const [id, rec] of RECORDS) { + const sn = struct_name(id); + const ps = payload_size(rec); + const no_fields = !rec.fields || rec.fields.length === 0; + + L.push(`int proto_decode_${fn_name(id)}(const uint8_t *p, uint16_t len, ${sn} *r) {`); + if (no_fields) { + L.push(`\t(void)p; (void)len; (void)r;`); + L.push(`\treturn 0;`); + } else { + L.push(`\tif (len < ${ps}) return -1;`); + L.push(`\tint off = 0;`); + for (const f of rec.fields) { + L.push(`\tr->${f.name} = ${TYPES[f.type].c_get}(p, &off);`); + } + L.push(`\t(void)off;`); + L.push(`\treturn 0;`); + } + L.push(`}`); + L.push(``); + } + + return L.join('\n'); +} + +/* ── Node ESM module ──────────────────────────────────────────────── */ + +function gen_node_esm() { + const L = []; + + L.push(`/* AUTO-GENERATED by codegen/gen.mjs — DO NOT EDIT */`); + L.push(`/* Source: protocol.yaml version ${proto.version} */`); + L.push(``); + L.push(`export const PROTOCOL_VERSION = ${proto.version};`); + L.push(`export const FRAME_HEADER_SIZE = 3;`); + L.push(``); + + /* Record_Type enum */ + L.push(`export const RT = Object.freeze({`); + for (const [id, rec] of RECORDS) { + L.push(`\t${id.padEnd(20)}: 0x${rec.id.toString(16).padStart(2, '0').toUpperCase()},`); + } + L.push(`});`); + L.push(``); + + /* Reverse map */ + L.push(`export const RT_NAME = Object.freeze(`); + L.push(`\tObject.fromEntries(Object.entries(RT).map(([k, v]) => [v, k]))`); + L.push(`);`); + L.push(``); + + /* Payload sizes */ + L.push(`export const PAYLOAD_SIZE = Object.freeze({`); + for (const [id, rec] of RECORDS) { + L.push(`\t${id.padEnd(20)}: ${payload_size(rec)},`); + } + L.push(`});`); + L.push(``); + + /* write_frame helper */ + L.push(`function write_frame(record_type, payload_buf) {`); + L.push(`\tconst frame = Buffer.alloc(FRAME_HEADER_SIZE + payload_buf.length);`); + L.push(`\tframe.writeUInt8(record_type, 0);`); + L.push(`\tframe.writeUInt16LE(payload_buf.length, 1);`); + L.push(`\tpayload_buf.copy(frame, FRAME_HEADER_SIZE);`); + L.push(`\treturn frame;`); + L.push(`}`); + L.push(``); + + /* encode functions */ + L.push(`/* ── encode ──────────────────────────────────────────────────── */`); + L.push(``); + for (const [id, rec] of RECORDS) { + const ps = payload_size(rec); + const no_fields = !rec.fields || rec.fields.length === 0; + const params = no_fields ? '' : `{ ${(rec.fields || []).map(f => f.name).join(', ')} }`; + + L.push(`export function encode_${fn_name(id)}(${params}) {`); + if (no_fields) { + L.push(`\treturn write_frame(RT.${id}, Buffer.alloc(0));`); + } else { + L.push(`\tconst buf = Buffer.alloc(${ps});`); + let offset = 0; + for (const f of rec.fields) { + const t = TYPES[f.type]; + L.push(`\tbuf.${t.node_write}(${f.name}, ${offset});`); + offset += t.size; + } + L.push(`\treturn write_frame(RT.${id}, buf);`); + } + L.push(`}`); + L.push(``); + } + + /* decode functions */ + L.push(`/* ── decode ──────────────────────────────────────────────────── */`); + L.push(``); + for (const [id, rec] of RECORDS) { + const ps = payload_size(rec); + const no_fields = !rec.fields || rec.fields.length === 0; + + L.push(`export function decode_${fn_name(id)}(payload) {`); + if (no_fields) { + L.push(`\treturn {};`); + } else { + L.push(`\tif (payload.length < ${ps}) throw new Error('${id} payload too short');`); + const obj_fields = []; + let offset = 0; + for (const f of rec.fields) { + const t = TYPES[f.type]; + obj_fields.push(`${f.name}: payload.${t.node_read}(${offset})`); + offset += t.size; + } + L.push(`\treturn { ${obj_fields.join(', ')} };`); + } + L.push(`}`); + L.push(``); + } + + /* Generic decode dispatcher */ + L.push(`/* Dispatch: decode any payload by record type */`); + L.push(`export function decode(record_type, payload) {`); + L.push(`\tswitch (record_type) {`); + for (const [id] of RECORDS) { + L.push(`\t\tcase RT.${id}: return decode_${fn_name(id)}(payload);`); + } + L.push(`\t\tdefault: throw new Error(\`Unknown record type 0x\${record_type.toString(16)}\`);`); + L.push(`\t}`); + L.push(`}`); + + return L.join('\n') + '\n'; +} + +/* ── write outputs ────────────────────────────────────────────────── */ + +writeFileSync(join(C_GEN_DIR, 'protocol.h'), gen_c_header()); +writeFileSync(join(C_GEN_DIR, 'protocol.c'), gen_c_source()); +writeFileSync(join(NODE_GEN_DIR, 'protocol.mjs'), gen_node_esm()); + +console.log('Generated:'); +console.log(' c-backend/generated/protocol.h'); +console.log(' c-backend/generated/protocol.c'); +console.log(' node-server/src/generated/protocol.mjs'); diff --git a/codegen/package.json b/codegen/package.json new file mode 100644 index 0000000..9576689 --- /dev/null +++ b/codegen/package.json @@ -0,0 +1,9 @@ +{ + "name": "midi-sequencer-codegen", + "version": "1.0.0", + "type": "module", + "private": true, + "dependencies": { + "js-yaml": "^4.1.0" + } +} diff --git a/node-server/Makefile b/node-server/Makefile new file mode 100644 index 0000000..fd184ed --- /dev/null +++ b/node-server/Makefile @@ -0,0 +1,21 @@ +PORT ?= 3000 +SOCKET_PATH ?= /tmp/midi-sequencer.sock + +.PHONY: all install start generate + +all: install + +install: node_modules + +node_modules: package.json + npm install + @touch node_modules + +generate: + node ../codegen/gen.mjs + +start: node_modules + PORT=$(PORT) SOCKET_PATH=$(SOCKET_PATH) node server.mjs + +dev: node_modules + PORT=$(PORT) SOCKET_PATH=$(SOCKET_PATH) node --watch server.mjs diff --git a/node-server/package.json b/node-server/package.json new file mode 100644 index 0000000..0172aee --- /dev/null +++ b/node-server/package.json @@ -0,0 +1,10 @@ +{ + "name": "midi-sequencer-server", + "version": "1.0.0", + "type": "module", + "private": true, + "main": "server.mjs", + "dependencies": { + "express": "^5.2.1" + } +} diff --git a/node-server/public/app.mjs b/node-server/public/app.mjs new file mode 100644 index 0000000..e4a8fa3 --- /dev/null +++ b/node-server/public/app.mjs @@ -0,0 +1,397 @@ +/* ── State ───────────────────────────────────────────────────────── */ + +const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B']; + +class App_State { + constructor() { + this.patterns = new Map(); + this.selected_id = null; + this.backend_connected = false; + this.playing = false; + this.play_pattern_id = null; + this.current_step = null; + this.bpm = 120; + + /* Pending edits — only sent on explicit save */ + this.pending_notes = null; /* Array or null */ + this.is_dirty = false; + } + + get selected_pattern() { + return this.selected_id ? this.patterns.get(this.selected_id) ?? null : null; + } +} + +const state = new App_State(); + +/* ── API helpers ─────────────────────────────────────────────────── */ + +async function api(method, path, body) { + const opts = { method, headers: { 'Content-Type': 'application/json' } }; + if (body !== undefined) opts.body = JSON.stringify(body); + const res = await fetch(path, opts); + if (!res.ok) throw new Error(`${method} ${path} → ${res.status}`); + if (res.status === 204) return null; + return res.json(); +} + +const GET = (path) => api('GET', path); +const POST = (path, body) => api('POST', path, body); +const PUT = (path, body) => api('PUT', path, body); +const DELETE = (path) => api('DELETE', path); + +/* ── MIDI note helpers ───────────────────────────────────────────── */ + +function note_name(midi) { + const oct = Math.floor(midi / 12) - 1; + const name = NOTE_NAMES[midi % 12]; + return `${name}${oct}`; +} + +/* Rows to display in the step grid: a range of MIDI notes */ +const DISPLAY_NOTES = (() => { + const rows = []; + for (let n = 83; n >= 36; n--) rows.push(n); /* B5 down to C2 */ + return rows; +})(); + +/* ── Render ──────────────────────────────────────────────────────── */ + +function render_pattern_list() { + const list = document.getElementById('pattern-list'); + list.innerHTML = ''; + for (const p of state.patterns.values()) { + const div = document.createElement('div'); + div.className = 'pattern-item' + (p.id === state.selected_id ? ' active' : ''); + div.innerHTML = ` + ${escape_html(p.name)} + ${p.steps}st + `; + div.addEventListener('click', () => select_pattern(p.id)); + list.appendChild(div); + } +} + +function render_content() { + const content = document.getElementById('content'); + const pattern = state.selected_pattern; + + if (!pattern) { + content.innerHTML = '
Select or create a pattern to get started.
'; + return; + } + + const notes = state.pending_notes ?? pattern.notes; + + content.innerHTML = ` +
+
+

Pattern Settings

+ ${state.is_dirty ? '● unsaved changes' : ''} +
+
+ + + + + + + + +
+
+ +
+
+

Step Grid

+ Click cells to toggle notes — Save to apply +
+ + +
+
+
+ +
+

Sub-patterns

+
+
+ + + + + +
+
+ `; + + render_step_grid(pattern, notes); + render_sub_refs(pattern); + bind_content_events(pattern); +} + +function render_step_grid(pattern, notes) { + const grid = document.getElementById('step-grid'); + if (!grid) return; + + /* Build a set for quick lookup: "note:step" */ + const note_set = new Map(); /* "note:step" → { velocity, duration_steps } */ + for (const ev of notes) { + note_set.set(`${ev.note}:${ev.step}`, ev); + } + + grid.style.gridTemplateColumns = `36px repeat(${pattern.steps}, 28px)`; + + let html = ''; + for (const midi of DISPLAY_NOTES) { + html += `
`; + html += `
${note_name(midi)}
`; + for (let s = 0; s < pattern.steps; s++) { + const key = `${midi}:${s}`; + const ev = note_set.get(key); + const on = ev ? 'on' : ''; + const cur = (state.play_pattern_id === pattern.id && state.current_step === s) ? 'current' : ''; + const vel_pct = ev ? Math.round((ev.velocity / 127) * 100) : 0; + html += ``; + } + html += `
`; + } + grid.innerHTML = html; + + /* Delegate clicks */ + grid.addEventListener('click', (e) => { + const btn = e.target.closest('.step-btn'); + if (!btn) return; + toggle_note(pattern, parseInt(btn.dataset.note, 10), parseInt(btn.dataset.step, 10)); + }); +} + +function render_sub_refs(pattern) { + const list = document.getElementById('sub-ref-list'); + if (!list) return; + if (pattern.sub_refs.length === 0) { + list.innerHTML = '
No sub-patterns
'; + return; + } + list.innerHTML = pattern.sub_refs.map(ref => ` +
+ Pattern ${ref.sub_pattern_id} at step ${ref.step} +
+ `).join(''); +} + +/* ── Interaction ─────────────────────────────────────────────────── */ + +function toggle_note(pattern, note, step) { + if (!state.pending_notes) { + state.pending_notes = (state.selected_pattern?.notes ?? []).map(n => ({ ...n })); + } + + const idx = state.pending_notes.findIndex(n => n.note === note && n.step === step); + if (idx >= 0) { + state.pending_notes.splice(idx, 1); + } else { + state.pending_notes.push({ step, note, velocity: 100, duration_steps: 1 }); + } + state.is_dirty = true; + render_content(); +} + +function select_pattern(id) { + state.selected_id = id; + state.pending_notes = null; + state.is_dirty = false; + render_pattern_list(); + render_content(); +} + +function bind_content_events(pattern) { + /* Save settings */ + document.getElementById('save-settings-btn')?.addEventListener('click', async () => { + const name = document.getElementById('pat-name').value.trim(); + const steps = parseInt(document.getElementById('pat-steps').value, 10); + const channel = parseInt(document.getElementById('pat-channel').value, 10); + try { + const updated = await PUT(`/api/patterns/${pattern.id}`, { name, steps, channel }); + state.patterns.set(updated.id, updated); + state.pending_notes = null; + state.is_dirty = false; + render_pattern_list(); + render_content(); + } catch (err) { console.error(err); } + }); + + /* Save notes */ + document.getElementById('save-notes-btn')?.addEventListener('click', async () => { + if (!state.pending_notes) return; + try { + const updated = await PUT(`/api/patterns/${pattern.id}/notes`, state.pending_notes); + state.patterns.set(updated.id, updated); + state.pending_notes = null; + state.is_dirty = false; + render_content(); + } catch (err) { console.error(err); } + }); + + /* Clear notes */ + document.getElementById('clear-notes-btn')?.addEventListener('click', () => { + state.pending_notes = []; + state.is_dirty = true; + render_content(); + }); + + /* Delete pattern */ + document.getElementById('delete-pat-btn')?.addEventListener('click', async () => { + if (!confirm(`Delete "${pattern.name}"?`)) return; + try { + await DELETE(`/api/patterns/${pattern.id}`); + state.patterns.delete(pattern.id); + state.selected_id = null; + state.pending_notes = null; + state.is_dirty = false; + render_pattern_list(); + render_content(); + } catch (err) { console.error(err); } + }); + + /* Add sub-pattern */ + document.getElementById('add-sub-btn')?.addEventListener('click', async () => { + const step = parseInt(document.getElementById('sub-step').value, 10); + const sub_pattern_id = parseInt(document.getElementById('sub-pat-id').value, 10); + if (isNaN(sub_pattern_id)) return; + try { + await POST(`/api/patterns/${pattern.id}/sub-refs`, { step, sub_pattern_id }); + } catch (err) { console.error(err); } + }); +} + +/* ── 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 }); + } catch (err) { console.error(err); } +}); + +document.getElementById('stop-btn').addEventListener('click', async () => { + try { + await POST('/api/stop'); + } catch (err) { console.error(err); } +}); + +document.getElementById('new-pattern-btn').addEventListener('click', async () => { + try { + const p = await POST('/api/patterns', { steps: 16, channel: 0 }); + state.patterns.set(p.id, p); + render_pattern_list(); + select_pattern(p.id); + } catch (err) { console.error(err); } +}); + +document.getElementById('set-tempo-btn').addEventListener('click', async () => { + const bpm = parseFloat(document.getElementById('bpm-input').value); + if (isNaN(bpm) || bpm <= 0) return; + try { + const result = await PUT('/api/tempo', { bpm }); + state.bpm = result.bpm; + document.getElementById('bpm-input').value = result.bpm; + } catch (err) { console.error(err); } +}); + +/* ── SSE event handling ──────────────────────────────────────────── */ + +function update_status_ui() { + const dot = document.getElementById('backend-dot'); + const label = document.getElementById('status-label'); + const pdot = document.getElementById('play-dot'); + + dot.classList.toggle('connected', state.backend_connected); + pdot.classList.toggle('playing', state.playing); + label.textContent = state.backend_connected + ? (state.playing ? 'playing' : 'connected') + : 'disconnected'; +} + +function update_step_highlight(step, pattern_id) { + if (state.selected_pattern?.id !== pattern_id) return; + document.querySelectorAll('.step-btn.current').forEach(b => b.classList.remove('current')); + document.querySelectorAll(`.step-btn[data-step="${step}"]`).forEach(b => b.classList.add('current')); +} + +const es = new EventSource('/api/events'); + +es.addEventListener('state', (e) => { + const data = JSON.parse(e.data); + state.bpm = data.bpm; + document.getElementById('bpm-input').value = data.bpm; + state.patterns.clear(); + for (const p of data.patterns) state.patterns.set(p.id, p); + render_pattern_list(); + render_content(); +}); + +es.addEventListener('backend_connect', () => { state.backend_connected = true; update_status_ui(); }); +es.addEventListener('backend_disconnect', () => { state.backend_connected = false; update_status_ui(); }); +es.addEventListener('backend_status', (e) => { + const { connected } = JSON.parse(e.data); + state.backend_connected = connected; + update_status_ui(); +}); + +es.addEventListener('beat_tick', (e) => { + const { pattern_id, step } = JSON.parse(e.data); + state.current_step = step; + state.play_pattern_id = pattern_id; + state.playing = true; + update_status_ui(); + 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; + update_status_ui(); +}); + +es.addEventListener('stop', () => { + state.playing = false; state.current_step = null; + update_status_ui(); + document.querySelectorAll('.step-btn.current').forEach(b => b.classList.remove('current')); +}); + +es.addEventListener('pattern_created', (e) => { + const p = JSON.parse(e.data); + state.patterns.set(p.id, p); + render_pattern_list(); +}); + +es.addEventListener('pattern_updated', (e) => { + const p = JSON.parse(e.data); + state.patterns.set(p.id, p); + if (state.selected_id === p.id && !state.is_dirty) { + render_content(); + } + render_pattern_list(); +}); + +es.addEventListener('pattern_deleted', (e) => { + const { id } = JSON.parse(e.data); + state.patterns.delete(id); + if (state.selected_id === id) { state.selected_id = null; render_content(); } + render_pattern_list(); +}); + +es.addEventListener('tempo', (e) => { + state.bpm = JSON.parse(e.data).bpm; + document.getElementById('bpm-input').value = state.bpm; +}); + +/* ── Utilities ───────────────────────────────────────────────────── */ + +function escape_html(str) { + return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} diff --git a/node-server/public/index.html b/node-server/public/index.html new file mode 100644 index 0000000..6bdb554 --- /dev/null +++ b/node-server/public/index.html @@ -0,0 +1,237 @@ + + + + + + MIDI Sequencer + + + +
+

MIDI SEQUENCER

+
+
+ disconnected +
+ + + +
+
+ +
+
Select or create a pattern to get started.
+
+
+ + + diff --git a/node-server/server.mjs b/node-server/server.mjs new file mode 100644 index 0000000..b532c53 --- /dev/null +++ b/node-server/server.mjs @@ -0,0 +1,194 @@ +import express from 'express'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; +import { Backend_Client } from './src/backend_client.mjs'; +import { Pattern_State } from './src/pattern_state.mjs'; +import { + encode_define_pattern, + encode_clear_pattern, + encode_add_note, + encode_add_sub_pattern, + encode_play, + encode_stop, + encode_set_tempo, +} from './src/generated/protocol.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const PORT = parseInt(process.env.PORT || '3000', 10); +const SOCK_PATH = process.env.SOCKET_PATH || '/tmp/midi-sequencer.sock'; + +/* ── Core objects ─────────────────────────────────────────────────── */ + +const backend = new Backend_Client(); +const state = new Pattern_State(); +const sse_set = new Set(); /* active SSE response objects */ + +/* ── SSE broadcast ────────────────────────────────────────────────── */ + +function broadcast(event, data) { + const msg = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; + for (const res of sse_set) { + try { res.write(msg); } catch { sse_set.delete(res); } + } +} + +/* ── Backend event forwarding ─────────────────────────────────────── */ + +backend.on('beat_tick', data => broadcast('beat_tick', data)); +backend.on('pattern_end', data => broadcast('pattern_end', data)); +backend.on('backend_error', data => broadcast('backend_error', data)); +backend.on('connect', () => broadcast('backend_connect', {})); +backend.on('disconnect', () => broadcast('backend_disconnect', {})); + +/* ── Sync a single pattern to backend ────────────────────────────── */ + +function sync_pattern(pattern) { + backend.send(encode_define_pattern({ + pattern_id: pattern.id, + steps: pattern.steps, + channel: pattern.channel, + })); + backend.send(encode_clear_pattern({ pattern_id: pattern.id })); + + for (const note of pattern.notes) { + backend.send(encode_add_note({ + pattern_id: pattern.id, + step: note.step, + note: note.note, + velocity: note.velocity, + duration_steps: note.duration_steps, + })); + } + + for (const ref of pattern.sub_refs) { + backend.send(encode_add_sub_pattern({ + pattern_id: pattern.id, + step: ref.step, + sub_pattern_id: ref.sub_pattern_id, + })); + } +} + +/* ── Express app ──────────────────────────────────────────────────── */ + +const app = express(); +app.use(express.json()); +app.use(express.static(join(__dirname, 'public'))); + +/* SSE stream */ +app.get('/api/events', (req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.setHeader('Cache-Control', 'no-cache'); + res.setHeader('Connection', 'keep-alive'); + res.flushHeaders(); + 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: backend_status\ndata: ${JSON.stringify({ connected: backend.is_connected })}\n\n`); + + req.on('close', () => sse_set.delete(res)); +}); + +/* ── Pattern routes ───────────────────────────────────────────────── */ + +app.get('/api/patterns', (_req, res) => { + res.json(state.list_patterns()); +}); + +app.post('/api/patterns', (req, res) => { + const { name, steps = 16, channel = 0 } = req.body; + const pattern = state.create_pattern({ name, steps, channel }); + sync_pattern(pattern); + broadcast('pattern_created', pattern); + res.status(201).json(pattern); +}); + +app.get('/api/patterns/:id', (req, res) => { + const pattern = state.get_pattern(parseInt(req.params.id, 10)); + if (!pattern) return res.status(404).json({ error: 'not found' }); + res.json(pattern); +}); + +app.put('/api/patterns/:id', (req, res) => { + const id = parseInt(req.params.id, 10); + const { name, steps, channel } = req.body; + const pattern = state.update_pattern(id, { name, steps, channel }); + if (!pattern) return res.status(404).json({ error: 'not found' }); + sync_pattern(pattern); + broadcast('pattern_updated', pattern); + res.json(pattern); +}); + +app.delete('/api/patterns/:id', (req, res) => { + const id = parseInt(req.params.id, 10); + if (!state.delete_pattern(id)) return res.status(404).json({ error: 'not found' }); + broadcast('pattern_deleted', { id }); + res.status(204).end(); +}); + +/* ── Notes routes ─────────────────────────────────────────────────── */ + +/* Replace all notes for a pattern and sync */ +app.put('/api/patterns/:id/notes', (req, res) => { + const id = parseInt(req.params.id, 10); + const pattern = state.set_notes(id, req.body); + if (!pattern) return res.status(404).json({ error: 'not found' }); + sync_pattern(pattern); + broadcast('pattern_updated', pattern); + res.json(pattern); +}); + +/* ── Sub-ref routes ───────────────────────────────────────────────── */ + +app.post('/api/patterns/:id/sub-refs', (req, res) => { + const id = parseInt(req.params.id, 10); + const { step, sub_pattern_id } = req.body; + const ref = state.add_sub_ref(id, { step, sub_pattern_id }); + if (!ref) return res.status(404).json({ error: 'not found' }); + const pattern = state.get_pattern(id); + sync_pattern(pattern); + broadcast('pattern_updated', pattern); + res.status(201).json(ref); +}); + +/* ── 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' }); + } + backend.send(encode_play({ pattern_id })); + broadcast('play', { pattern_id }); + res.json({ ok: true }); +}); + +app.post('/api/stop', (_req, res) => { + backend.send(encode_stop()); + broadcast('stop', {}); + res.json({ ok: true }); +}); + +app.put('/api/tempo', (req, res) => { + const { bpm } = req.body; + if (typeof bpm !== 'number' || bpm <= 0) { + return res.status(400).json({ error: 'bpm must be a positive number' }); + } + state.bpm = bpm; + backend.send(encode_set_tempo({ bpm_x10: state.bpm_x10 })); + broadcast('tempo', { bpm: state.bpm }); + res.json({ bpm: state.bpm }); +}); + +app.get('/api/tempo', (_req, res) => { + res.json({ bpm: state.bpm }); +}); + +/* ── Start ────────────────────────────────────────────────────────── */ + +app.listen(PORT, () => { + console.log(`midi-sequencer server listening on http://localhost:${PORT}`); + console.log(`connecting to backend socket: ${SOCK_PATH}`); + backend.connect(SOCK_PATH); +}); diff --git a/node-server/src/backend_client.mjs b/node-server/src/backend_client.mjs new file mode 100644 index 0000000..5c506c7 --- /dev/null +++ b/node-server/src/backend_client.mjs @@ -0,0 +1,107 @@ +import net from 'net'; +import { EventEmitter } from 'events'; +import { + FRAME_HEADER_SIZE, RT, RT_NAME, PROTOCOL_VERSION, + encode_hello, decode, +} from './generated/protocol.mjs'; + +export class Backend_Client extends EventEmitter { + constructor() { + super(); + this._socket = null; + this._buf = Buffer.alloc(0); + this._connected = false; + } + + connect(sock_path) { + if (this._socket) return; + + const socket = net.createConnection({ path: sock_path }); + this._socket = socket; + + socket.on('connect', () => { + this._connected = true; + console.log(`[backend] connected to ${sock_path}`); + /* Send HELLO */ + socket.write(encode_hello({ version: PROTOCOL_VERSION })); + this.emit('connect'); + }); + + socket.on('data', (chunk) => { + this._buf = Buffer.concat([this._buf, chunk]); + this._drain(); + }); + + socket.on('close', () => { + this._connected = false; + this._socket = null; + this._buf = Buffer.alloc(0); + console.log('[backend] disconnected'); + this.emit('disconnect'); + /* Auto-reconnect after 2s */ + setTimeout(() => this.connect(sock_path), 2000); + }); + + socket.on('error', (err) => { + if (err.code !== 'ECONNREFUSED' && err.code !== 'ENOENT') { + console.error('[backend] socket error:', err.message); + } + }); + } + + _drain() { + for (;;) { + if (this._buf.length < FRAME_HEADER_SIZE) return; + const record_type = this._buf.readUInt8(0); + const payload_len = this._buf.readUInt16LE(1); + const total = FRAME_HEADER_SIZE + payload_len; + if (this._buf.length < total) return; + + const payload = this._buf.slice(FRAME_HEADER_SIZE, total); + this._buf = this._buf.slice(total); + + this._dispatch(record_type, payload); + } + } + + _dispatch(record_type, payload) { + try { + const data = decode(record_type, payload); + const name = RT_NAME[record_type] || `0x${record_type.toString(16)}`; + + switch (record_type) { + case RT.HELLO: + console.log(`[backend] HELLO version=${data.version}`); + this.emit('hello', data); + break; + case RT.BEAT_TICK: + this.emit('beat_tick', data); + break; + case RT.PATTERN_END: + this.emit('pattern_end', data); + break; + case RT.ACK: + this.emit('ack', data); + break; + case RT.ERROR: + console.error(`[backend] ERROR code=${data.code} context=0x${data.context_type.toString(16)}`); + this.emit('backend_error', data); + break; + default: + console.warn(`[backend] unhandled record type: ${name}`); + } + } catch (err) { + console.error('[backend] dispatch error:', err.message); + } + } + + send(frame_buf) { + if (!this._connected || !this._socket) return false; + this._socket.write(frame_buf); + return true; + } + + get is_connected() { + return this._connected; + } +} diff --git a/node-server/src/generated/protocol.mjs b/node-server/src/generated/protocol.mjs new file mode 100644 index 0000000..3f517c6 --- /dev/null +++ b/node-server/src/generated/protocol.mjs @@ -0,0 +1,210 @@ +/* AUTO-GENERATED by codegen/gen.mjs — DO NOT EDIT */ +/* Source: protocol.yaml version 1 */ + +export const PROTOCOL_VERSION = 1; +export const FRAME_HEADER_SIZE = 3; + +export const RT = Object.freeze({ + HELLO : 0x01, + DEFINE_PATTERN : 0x02, + CLEAR_PATTERN : 0x03, + ADD_NOTE : 0x04, + ADD_SUB_PATTERN : 0x05, + PLAY : 0x06, + STOP : 0x07, + SET_TEMPO : 0x08, + ACK : 0x81, + ERROR : 0x82, + BEAT_TICK : 0x83, + PATTERN_END : 0x84, +}); + +export const RT_NAME = Object.freeze( + Object.fromEntries(Object.entries(RT).map(([k, v]) => [v, k])) +); + +export const PAYLOAD_SIZE = Object.freeze({ + HELLO : 1, + DEFINE_PATTERN : 4, + CLEAR_PATTERN : 2, + ADD_NOTE : 6, + ADD_SUB_PATTERN : 5, + PLAY : 2, + STOP : 0, + SET_TEMPO : 2, + ACK : 1, + ERROR : 2, + BEAT_TICK : 4, + PATTERN_END : 2, +}); + +function write_frame(record_type, payload_buf) { + const frame = Buffer.alloc(FRAME_HEADER_SIZE + payload_buf.length); + frame.writeUInt8(record_type, 0); + frame.writeUInt16LE(payload_buf.length, 1); + payload_buf.copy(frame, FRAME_HEADER_SIZE); + return frame; +} + +/* ── encode ──────────────────────────────────────────────────── */ + +export function encode_hello({ version }) { + const buf = Buffer.alloc(1); + buf.writeUInt8(version, 0); + return write_frame(RT.HELLO, buf); +} + +export function encode_define_pattern({ pattern_id, steps, channel }) { + const buf = Buffer.alloc(4); + buf.writeUInt16LE(pattern_id, 0); + buf.writeUInt8(steps, 2); + buf.writeUInt8(channel, 3); + return write_frame(RT.DEFINE_PATTERN, buf); +} + +export function encode_clear_pattern({ pattern_id }) { + const buf = Buffer.alloc(2); + buf.writeUInt16LE(pattern_id, 0); + return write_frame(RT.CLEAR_PATTERN, buf); +} + +export function encode_add_note({ pattern_id, step, note, velocity, duration_steps }) { + const buf = Buffer.alloc(6); + buf.writeUInt16LE(pattern_id, 0); + buf.writeUInt8(step, 2); + buf.writeUInt8(note, 3); + buf.writeUInt8(velocity, 4); + buf.writeUInt8(duration_steps, 5); + return write_frame(RT.ADD_NOTE, buf); +} + +export function encode_add_sub_pattern({ pattern_id, step, sub_pattern_id }) { + const buf = Buffer.alloc(5); + buf.writeUInt16LE(pattern_id, 0); + buf.writeUInt8(step, 2); + buf.writeUInt16LE(sub_pattern_id, 3); + return write_frame(RT.ADD_SUB_PATTERN, buf); +} + +export function encode_play({ pattern_id }) { + const buf = Buffer.alloc(2); + buf.writeUInt16LE(pattern_id, 0); + return write_frame(RT.PLAY, buf); +} + +export function encode_stop() { + return write_frame(RT.STOP, Buffer.alloc(0)); +} + +export function encode_set_tempo({ bpm_x10 }) { + const buf = Buffer.alloc(2); + buf.writeUInt16LE(bpm_x10, 0); + return write_frame(RT.SET_TEMPO, buf); +} + +export function encode_ack({ acked_type }) { + const buf = Buffer.alloc(1); + buf.writeUInt8(acked_type, 0); + return write_frame(RT.ACK, buf); +} + +export function encode_error({ code, context_type }) { + const buf = Buffer.alloc(2); + buf.writeUInt8(code, 0); + buf.writeUInt8(context_type, 1); + return write_frame(RT.ERROR, buf); +} + +export function encode_beat_tick({ pattern_id, step, beat }) { + const buf = Buffer.alloc(4); + buf.writeUInt16LE(pattern_id, 0); + buf.writeUInt8(step, 2); + buf.writeUInt8(beat, 3); + return write_frame(RT.BEAT_TICK, buf); +} + +export function encode_pattern_end({ pattern_id }) { + const buf = Buffer.alloc(2); + buf.writeUInt16LE(pattern_id, 0); + return write_frame(RT.PATTERN_END, buf); +} + +/* ── decode ──────────────────────────────────────────────────── */ + +export function decode_hello(payload) { + if (payload.length < 1) throw new Error('HELLO payload too short'); + return { version: payload.readUInt8(0) }; +} + +export function decode_define_pattern(payload) { + if (payload.length < 4) throw new Error('DEFINE_PATTERN payload too short'); + return { pattern_id: payload.readUInt16LE(0), steps: payload.readUInt8(2), channel: payload.readUInt8(3) }; +} + +export function decode_clear_pattern(payload) { + if (payload.length < 2) throw new Error('CLEAR_PATTERN payload too short'); + return { pattern_id: payload.readUInt16LE(0) }; +} + +export function decode_add_note(payload) { + if (payload.length < 6) throw new Error('ADD_NOTE payload too short'); + return { pattern_id: payload.readUInt16LE(0), step: payload.readUInt8(2), note: payload.readUInt8(3), velocity: payload.readUInt8(4), duration_steps: payload.readUInt8(5) }; +} + +export function decode_add_sub_pattern(payload) { + if (payload.length < 5) throw new Error('ADD_SUB_PATTERN payload too short'); + return { pattern_id: payload.readUInt16LE(0), step: payload.readUInt8(2), sub_pattern_id: payload.readUInt16LE(3) }; +} + +export function decode_play(payload) { + if (payload.length < 2) throw new Error('PLAY payload too short'); + return { pattern_id: payload.readUInt16LE(0) }; +} + +export function decode_stop(payload) { + return {}; +} + +export function decode_set_tempo(payload) { + if (payload.length < 2) throw new Error('SET_TEMPO payload too short'); + return { bpm_x10: payload.readUInt16LE(0) }; +} + +export function decode_ack(payload) { + if (payload.length < 1) throw new Error('ACK payload too short'); + return { acked_type: payload.readUInt8(0) }; +} + +export function decode_error(payload) { + if (payload.length < 2) throw new Error('ERROR payload too short'); + return { code: payload.readUInt8(0), context_type: payload.readUInt8(1) }; +} + +export function decode_beat_tick(payload) { + if (payload.length < 4) throw new Error('BEAT_TICK payload too short'); + return { pattern_id: payload.readUInt16LE(0), step: payload.readUInt8(2), beat: payload.readUInt8(3) }; +} + +export function decode_pattern_end(payload) { + if (payload.length < 2) throw new Error('PATTERN_END payload too short'); + return { pattern_id: payload.readUInt16LE(0) }; +} + +/* Dispatch: decode any payload by record type */ +export function decode(record_type, payload) { + switch (record_type) { + case RT.HELLO: return decode_hello(payload); + case RT.DEFINE_PATTERN: return decode_define_pattern(payload); + case RT.CLEAR_PATTERN: return decode_clear_pattern(payload); + case RT.ADD_NOTE: return decode_add_note(payload); + case RT.ADD_SUB_PATTERN: return decode_add_sub_pattern(payload); + case RT.PLAY: return decode_play(payload); + case RT.STOP: return decode_stop(payload); + case RT.SET_TEMPO: return decode_set_tempo(payload); + case RT.ACK: return decode_ack(payload); + case RT.ERROR: return decode_error(payload); + case RT.BEAT_TICK: return decode_beat_tick(payload); + case RT.PATTERN_END: return decode_pattern_end(payload); + default: throw new Error(`Unknown record type 0x${record_type.toString(16)}`); + } +} diff --git a/node-server/src/pattern_state.mjs b/node-server/src/pattern_state.mjs new file mode 100644 index 0000000..168c77b --- /dev/null +++ b/node-server/src/pattern_state.mjs @@ -0,0 +1,121 @@ +/** + * Pattern_State — authoritative JS-side store for sequence patterns. + * + * A pattern has: + * id : sequential integer (1-based) + * name : string label for the UI + * steps : number of steps (e.g. 16) + * channel : MIDI channel 0-15 + * notes : Array<{ step, note, velocity, duration_steps }> + * sub_refs : Array<{ step, sub_pattern_id }> + * + * Patterns are hierarchical: a pattern may reference sub-patterns by ID, + * which the C backend will play as nested sequences. + */ +export class Pattern_State { + constructor() { + this._patterns = new Map(); /* id → pattern object */ + this._next_id = 1; + this._bpm_x10 = 1200; /* 120.0 BPM */ + } + + /* ── Pattern CRUD ─────────────────────────────────────────────── */ + + create_pattern({ name = '', steps = 16, channel = 0 } = {}) { + const id = this._next_id++; + const pattern = { + id, + name: name || `Pattern ${id}`, + steps, + channel, + notes: [], + sub_refs: [], + }; + this._patterns.set(id, pattern); + return pattern; + } + + get_pattern(id) { + return this._patterns.get(id) ?? null; + } + + list_patterns() { + return [...this._patterns.values()]; + } + + delete_pattern(id) { + return this._patterns.delete(id); + } + + update_pattern(id, { name, steps, channel }) { + const p = this.get_pattern(id); + if (!p) return null; + if (name !== undefined) p.name = name; + if (steps !== undefined) p.steps = steps; + if (channel !== undefined) p.channel = channel; + return p; + } + + /* ── Notes ────────────────────────────────────────────────────── */ + + add_note(pattern_id, { step, note, velocity = 100, duration_steps = 1 }) { + const p = this.get_pattern(pattern_id); + if (!p) return null; + const ev = { step, note, velocity, duration_steps }; + p.notes.push(ev); + return ev; + } + + clear_notes(pattern_id) { + const p = this.get_pattern(pattern_id); + if (!p) return; + p.notes = []; + } + + set_notes(pattern_id, notes) { + const p = this.get_pattern(pattern_id); + if (!p) return null; + p.notes = notes.map(({ step, note, velocity = 100, duration_steps = 1 }) => + ({ step, note, velocity, duration_steps })); + return p; + } + + /* ── Sub-pattern references ───────────────────────────────────── */ + + add_sub_ref(pattern_id, { step, sub_pattern_id }) { + const p = this.get_pattern(pattern_id); + if (!p) return null; + const ref = { step, sub_pattern_id }; + p.sub_refs.push(ref); + return ref; + } + + clear_sub_refs(pattern_id) { + const p = this.get_pattern(pattern_id); + if (!p) return; + p.sub_refs = []; + } + + /* ── Tempo ────────────────────────────────────────────────────── */ + + get bpm() { + return this._bpm_x10 / 10; + } + + set bpm(value) { + this._bpm_x10 = Math.round(Math.max(1, Math.min(6553.5, value)) * 10); + } + + get bpm_x10() { + return this._bpm_x10; + } + + /* ── Serialization (for API responses) ───────────────────────── */ + + to_json() { + return { + bpm: this.bpm, + patterns: this.list_patterns(), + }; + } +} diff --git a/protocol.yaml b/protocol.yaml new file mode 100644 index 0000000..e061976 --- /dev/null +++ b/protocol.yaml @@ -0,0 +1,147 @@ +version: 1 +description: Binary protocol between Node sequencer and C ALSA MIDI backend + +# Frame format: [record_type: uint8][payload_length: uint16le][payload: bytes...] +# Header size: 3 bytes +# High bit set (0x80+) means C → Node direction + +frame: + - name: record_type + type: uint8 + - name: payload_length + type: uint16 + +# Type mapping used by codegen +types: + uint8: { c_type: uint8_t, c_put: put_u8, c_get: get_u8, node_write: writeUInt8, node_read: readUInt8, size: 1 } + uint16: { c_type: uint16_t, c_put: put_u16, c_get: get_u16, node_write: writeUInt16LE, node_read: readUInt16LE, size: 2 } + +records: + HELLO: + id: 0x01 + direction: node_to_c + description: Protocol version handshake + fields: + - name: version + type: uint8 + + DEFINE_PATTERN: + id: 0x02 + direction: node_to_c + description: Define or redefine a pattern (clears existing data) + fields: + - name: pattern_id + type: uint16 + - name: steps + type: uint8 + note: Total step count e.g. 16 for a one-bar pattern at 16th-note resolution + - name: channel + type: uint8 + note: MIDI channel 0-15 + + CLEAR_PATTERN: + id: 0x03 + direction: node_to_c + description: Remove all notes and sub-pattern references from a pattern + fields: + - name: pattern_id + type: uint16 + + ADD_NOTE: + id: 0x04 + direction: node_to_c + description: Add a note event to a pattern at a specific step + fields: + - name: pattern_id + type: uint16 + - name: step + type: uint8 + note: 0-based step index within the pattern + - name: note + type: uint8 + note: MIDI note number 0-127 (middle C = 60) + - name: velocity + type: uint8 + note: 0-127 + - name: duration_steps + type: uint8 + note: Duration in steps (1 = one step) + + ADD_SUB_PATTERN: + id: 0x05 + direction: node_to_c + description: Schedule a sub-pattern to start at a given step within a parent pattern + fields: + - name: pattern_id + type: uint16 + note: Parent pattern ID + - name: step + type: uint8 + note: Step within parent at which the sub-pattern begins playing + - name: sub_pattern_id + type: uint16 + + PLAY: + id: 0x06 + direction: node_to_c + description: Start playing a pattern from step 0 + fields: + - name: pattern_id + type: uint16 + + STOP: + id: 0x07 + direction: node_to_c + description: Stop all playback and send MIDI all-notes-off + fields: [] + + SET_TEMPO: + id: 0x08 + direction: node_to_c + description: Set global tempo (applies immediately, even mid-sequence) + fields: + - name: bpm_x10 + type: uint16 + note: "BPM × 10 for 0.1 BPM resolution — e.g. 1200 = 120.0 BPM" + + ACK: + id: 0x81 + direction: c_to_node + description: Acknowledge a received command + fields: + - name: acked_type + type: uint8 + note: Record type being acknowledged + + ERROR: + id: 0x82 + direction: c_to_node + description: Report an error condition + fields: + - name: code + type: uint8 + - name: context_type + type: uint8 + note: Record type that triggered this error + + BEAT_TICK: + id: 0x83 + direction: c_to_node + description: Timing notification sent on every sequencer step + fields: + - name: pattern_id + type: uint16 + - name: step + type: uint8 + note: Current step within the pattern (0-based) + - name: beat + type: uint8 + note: Current beat number within the current cycle (wraps at 255) + + PATTERN_END: + id: 0x84 + direction: c_to_node + description: Notification that a pattern has completed one full cycle + fields: + - name: pattern_id + type: uint16