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:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
node_modules/
|
||||
c-backend/midi-sequencer
|
||||
c-backend/obj/
|
||||
*.o
|
||||
30
Makefile
Normal file
30
Makefile
Normal 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
20
c-backend/Makefile
Normal 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)
|
||||
215
c-backend/generated/protocol.c
Normal file
215
c-backend/generated/protocol.c
Normal 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;
|
||||
}
|
||||
116
c-backend/generated/protocol.h
Normal file
116
c-backend/generated/protocol.h
Normal 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
176
c-backend/src/main.c
Normal 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;
|
||||
}
|
||||
102
c-backend/src/pattern_store.c
Normal file
102
c-backend/src/pattern_store.c
Normal 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;
|
||||
}
|
||||
52
c-backend/src/pattern_store.h
Normal file
52
c-backend/src/pattern_store.h
Normal 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
301
c-backend/src/sequencer.c
Normal 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
67
c-backend/src/sequencer.h
Normal 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);
|
||||
153
c-backend/src/socket_server.c
Normal file
153
c-backend/src/socket_server.c
Normal 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);
|
||||
}
|
||||
35
c-backend/src/socket_server.h
Normal file
35
c-backend/src/socket_server.h
Normal 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
313
codegen/gen.mjs
Normal 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
9
codegen/package.json
Normal 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
21
node-server/Makefile
Normal 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
10
node-server/package.json
Normal 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
397
node-server/public/app.mjs
Normal 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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
237
node-server/public/index.html
Normal file
237
node-server/public/index.html
Normal 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
194
node-server/server.mjs
Normal 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);
|
||||
});
|
||||
107
node-server/src/backend_client.mjs
Normal file
107
node-server/src/backend_client.mjs
Normal 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;
|
||||
}
|
||||
}
|
||||
210
node-server/src/generated/protocol.mjs
Normal file
210
node-server/src/generated/protocol.mjs
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
121
node-server/src/pattern_state.mjs
Normal file
121
node-server/src/pattern_state.mjs
Normal 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
147
protocol.yaml
Normal 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
|
||||
Reference in New Issue
Block a user