Initial scaffold: ALSA MIDI C backend + Node ESM sequencer web app

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:54:38 +00:00
commit 9aba8057a8
23 changed files with 3037 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
c-backend/midi-sequencer
c-backend/obj/
*.o

30
Makefile Normal file
View File

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

20
c-backend/Makefile Normal file
View File

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

View File

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

View File

@@ -0,0 +1,116 @@
/* AUTO-GENERATED by codegen/gen.mjs — DO NOT EDIT */
/* Source: protocol.yaml version 1 */
#pragma once
#include <stdint.h>
#include <string.h>
#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);

176
c-backend/src/main.c Normal file
View File

@@ -0,0 +1,176 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <signal.h>
#include <unistd.h>
#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;
}

View File

@@ -0,0 +1,102 @@
#include "pattern_store.h"
#include <string.h>
#include <stdio.h>
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;
}

View File

@@ -0,0 +1,52 @@
#pragma once
#include <stdint.h>
#include <pthread.h>
#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);

301
c-backend/src/sequencer.c Normal file
View File

@@ -0,0 +1,301 @@
#include "sequencer.h"
#include "../generated/protocol.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <time.h>
/* ── 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);
}

67
c-backend/src/sequencer.h Normal file
View File

@@ -0,0 +1,67 @@
#pragma once
#include <stdint.h>
#include <pthread.h>
#include <stdatomic.h>
#include <time.h>
#include <alsa/asoundlib.h>
#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);

View File

@@ -0,0 +1,153 @@
#include "socket_server.h"
#include "../generated/protocol.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <errno.h>
/* 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);
}

View File

@@ -0,0 +1,35 @@
#pragma once
#include <stdint.h>
#include <stdatomic.h>
#include <pthread.h>
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);

313
codegen/gen.mjs Normal file
View File

@@ -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 <stdint.h>`);
L.push(`#include <string.h>`);
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');

9
codegen/package.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "midi-sequencer-codegen",
"version": "1.0.0",
"type": "module",
"private": true,
"dependencies": {
"js-yaml": "^4.1.0"
}
}

21
node-server/Makefile Normal file
View File

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

10
node-server/package.json Normal file
View File

@@ -0,0 +1,10 @@
{
"name": "midi-sequencer-server",
"version": "1.0.0",
"type": "module",
"private": true,
"main": "server.mjs",
"dependencies": {
"express": "^5.2.1"
}
}

397
node-server/public/app.mjs Normal file
View File

@@ -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 = `
<span class="name">${escape_html(p.name)}</span>
<span class="steps">${p.steps}st</span>
`;
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 = '<div class="empty-state">Select or create a pattern to get started.</div>';
return;
}
const notes = state.pending_notes ?? pattern.notes;
content.innerHTML = `
<div class="panel">
<div class="panel-header">
<h2>Pattern Settings</h2>
${state.is_dirty ? '<span style="color:var(--accent);font-size:11px">● unsaved changes</span>' : ''}
</div>
<div class="field-row">
<label>Name</label>
<input type="text" id="pat-name" value="${escape_html(pattern.name)}" style="width:160px">
<label>Steps</label>
<input type="number" id="pat-steps" value="${pattern.steps}" min="1" max="64" style="width:60px">
<label>Channel</label>
<input type="number" id="pat-channel" value="${pattern.channel}" min="0" max="15" style="width:55px">
<button id="save-settings-btn" class="primary">Save</button>
<button id="delete-pat-btn" class="danger">Delete</button>
</div>
</div>
<div class="panel">
<div class="panel-header">
<h2>Step Grid</h2>
<span style="font-size:11px;color:var(--text-dim)">Click cells to toggle notes — Save to apply</span>
<div style="flex:1"></div>
<button id="save-notes-btn" class="primary" ${state.is_dirty ? '' : 'disabled'}>Save Notes</button>
<button id="clear-notes-btn">Clear</button>
</div>
<div class="step-grid" id="step-grid"></div>
</div>
<div class="panel">
<div class="panel-header"><h2>Sub-patterns</h2></div>
<div class="sub-ref-list" id="sub-ref-list"></div>
<div class="field-row" style="margin-top:10px">
<label>At step</label>
<input type="number" id="sub-step" value="0" min="0" style="width:60px">
<label>Pattern ID</label>
<input type="number" id="sub-pat-id" value="" min="1" style="width:60px">
<button id="add-sub-btn">Add Sub-pattern</button>
</div>
</div>
`;
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 += `<div class="note-row" data-note="${midi}">`;
html += `<div class="note-label">${note_name(midi)}</div>`;
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 += `<button class="step-btn ${on} ${cur}" data-note="${midi}" data-step="${s}">`;
if (ev) html += `<div class="vel-bar" style="height:${Math.max(2, vel_pct * 0.28)}px"></div>`;
html += `</button>`;
}
html += `</div>`;
}
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 = '<div style="color:var(--text-dim);font-size:12px">No sub-patterns</div>';
return;
}
list.innerHTML = pattern.sub_refs.map(ref => `
<div class="sub-ref-item">
<span class="label">Pattern ${ref.sub_pattern_id} at step ${ref.step}</span>
</div>
`).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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

View File

@@ -0,0 +1,237 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MIDI Sequencer</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #1a1a2e;
--surface: #16213e;
--surface2: #0f3460;
--accent: #e94560;
--accent2: #533483;
--text: #eaeaea;
--text-dim: #888;
--step-off: #1e2a42;
--step-on: #e94560;
--step-pend: #7a2233;
--step-active: #f0a500;
--border: #2a3a5e;
--radius: 6px;
}
body {
background: var(--bg);
color: var(--text);
font-family: 'Courier New', monospace;
font-size: 14px;
min-height: 100vh;
display: flex;
flex-direction: column;
}
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 12px 20px;
display: flex;
align-items: center;
gap: 16px;
}
header h1 { font-size: 18px; letter-spacing: 2px; color: var(--accent); }
.status-dot {
width: 8px; height: 8px; border-radius: 50%;
background: #444;
transition: background 0.3s;
}
.status-dot.connected { background: #2ecc71; }
.status-dot.playing { background: var(--accent); animation: pulse 0.5s infinite alternate; }
@keyframes pulse { from { opacity: 1; } to { opacity: 0.4; } }
main { flex: 1; display: flex; gap: 0; }
/* Sidebar */
.sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
padding: 12px;
gap: 12px;
}
.sidebar h2 { font-size: 11px; letter-spacing: 2px; color: var(--text-dim); text-transform: uppercase; }
.pattern-list { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; }
.pattern-item {
padding: 8px 10px;
border-radius: var(--radius);
cursor: pointer;
border: 1px solid transparent;
display: flex;
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); }
/* Transport */
.transport {
display: flex; gap: 8px; flex-wrap: wrap;
}
/* Content area */
.content {
flex: 1;
padding: 20px;
display: flex;
flex-direction: column;
gap: 16px;
overflow-y: auto;
}
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
}
.panel-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 14px;
}
.panel-header h2 { font-size: 12px; letter-spacing: 2px; color: var(--text-dim); text-transform: uppercase; }
/* Buttons */
button {
background: var(--surface2);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 14px;
cursor: pointer;
font-family: inherit;
font-size: 13px;
transition: background 0.15s;
}
button:hover { background: var(--accent2); border-color: var(--accent2); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { background: #c73452; }
button.danger { background: #7a1a2e; border-color: #9b2a3e; }
button.danger:hover { background: #9b2a3e; }
button:disabled { opacity: 0.4; cursor: not-allowed; }
/* Inputs */
input, select {
background: var(--bg);
color: var(--text);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 5px 10px;
font-family: inherit;
font-size: 13px;
}
input:focus, select:focus { outline: none; border-color: var(--accent); }
input[type="number"] { width: 80px; }
label { font-size: 12px; color: var(--text-dim); }
.field-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* Step grid */
.step-grid {
display: grid;
gap: 4px;
}
.note-row {
display: flex;
align-items: center;
gap: 4px;
}
.note-label {
width: 36px;
text-align: right;
font-size: 11px;
color: var(--text-dim);
flex-shrink: 0;
}
.step-btn {
width: 28px;
height: 28px;
border-radius: 3px;
background: var(--step-off);
border: 1px solid var(--border);
cursor: pointer;
padding: 0;
position: relative;
transition: background 0.05s;
flex-shrink: 0;
}
.step-btn.on { background: var(--step-on); border-color: var(--accent); }
.step-btn.pending { background: var(--step-pend); border-color: #7a2233; }
.step-btn.current { box-shadow: 0 0 0 2px var(--step-active); }
/* Velocity bar inside step button */
.step-btn .vel-bar {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 3px;
background: rgba(255,255,255,0.4);
border-radius: 0 0 3px 3px;
}
.empty-state { color: var(--text-dim); text-align: center; padding: 40px; }
.sub-ref-list { display: flex; flex-direction: column; gap: 4px; }
.sub-ref-item {
display: flex; align-items: center; gap: 8px;
background: var(--surface2); border-radius: var(--radius); padding: 6px 10px;
font-size: 12px;
}
.sub-ref-item .label { flex: 1; }
</style>
</head>
<body>
<header>
<h1>MIDI SEQUENCER</h1>
<div class="status-dot" id="backend-dot" title="Backend connection"></div>
<div class="status-dot" id="play-dot" title="Playing"></div>
<span id="status-label" style="font-size:12px;color:var(--text-dim)">disconnected</span>
<div style="flex:1"></div>
<label>BPM</label>
<input type="number" id="bpm-input" value="120" min="1" max="300" step="0.1" style="width:70px">
<button id="set-tempo-btn">Set Tempo</button>
</header>
<main>
<aside class="sidebar">
<h2>Patterns</h2>
<div class="transport">
<button id="play-btn" class="primary">▶ Play</button>
<button id="stop-btn">■ Stop</button>
</div>
<div class="pattern-list" id="pattern-list"></div>
<button id="new-pattern-btn">+ New Pattern</button>
</aside>
<div class="content" id="content">
<div class="empty-state">Select or create a pattern to get started.</div>
</div>
</main>
<script type="module" src="app.mjs"></script>
</body>
</html>

194
node-server/server.mjs Normal file
View File

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

View File

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

View File

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

View File

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

147
protocol.yaml Normal file
View File

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