Add PREVIEW_NOTE, auto-save, per-row play button, and click-to-rename
Protocol: - Add PREVIEW_NOTE record (id 0x09, node→C): channel, note, velocity, duration_ms — plays a note immediately without touching sequencer state C backend: - sequencer_preview_note(): fires ALSA note-on, spawns detached thread for note-off after duration_ms - RT_PREVIEW_NOTE handler in on_frame() Node server: - encode_preview_note imported from generated protocol - POST /api/preview route forwards to backend Frontend (app.mjs): - Remove pending_notes / is_dirty / Save Notes button; every step-button toggle now calls PUT /api/patterns/:id/notes immediately (auto-save) - Single delegated click handler on #content — no per-render listener accumulation - Row-level ▶ play button per note row → POST /api/preview - Click-to-rename on note/percussion labels: inline <input>, saves to state.custom_labels (Map keyed by "patternId:note"), Escape to cancel - pattern_updated SSE no longer guarded by is_dirty CSS (index.html): - .note-label: cursor pointer + hover highlight - .note-label-input for inline rename field - .row-play-btn and .row-play-spacer for per-row preview buttons Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -81,6 +81,15 @@ int proto_encode_set_tempo(uint8_t *buf, const Msg_Set_Tempo *r) {
|
|||||||
return write_frame(buf, RT_SET_TEMPO, 2);
|
return write_frame(buf, RT_SET_TEMPO, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int proto_encode_preview_note(uint8_t *buf, const Msg_Preview_Note *r) {
|
||||||
|
int off = FRAME_HEADER_SIZE;
|
||||||
|
put_u8(buf, &off, r->channel);
|
||||||
|
put_u8(buf, &off, r->note);
|
||||||
|
put_u8(buf, &off, r->velocity);
|
||||||
|
put_u16(buf, &off, r->duration_ms);
|
||||||
|
return write_frame(buf, RT_PREVIEW_NOTE, 5);
|
||||||
|
}
|
||||||
|
|
||||||
int proto_encode_ack(uint8_t *buf, const Msg_Ack *r) {
|
int proto_encode_ack(uint8_t *buf, const Msg_Ack *r) {
|
||||||
int off = FRAME_HEADER_SIZE;
|
int off = FRAME_HEADER_SIZE;
|
||||||
put_u8(buf, &off, r->acked_type);
|
put_u8(buf, &off, r->acked_type);
|
||||||
@@ -179,6 +188,17 @@ int proto_decode_set_tempo(const uint8_t *p, uint16_t len, Msg_Set_Tempo *r) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int proto_decode_preview_note(const uint8_t *p, uint16_t len, Msg_Preview_Note *r) {
|
||||||
|
if (len < 5) return -1;
|
||||||
|
int off = 0;
|
||||||
|
r->channel = get_u8(p, &off);
|
||||||
|
r->note = get_u8(p, &off);
|
||||||
|
r->velocity = get_u8(p, &off);
|
||||||
|
r->duration_ms = get_u16(p, &off);
|
||||||
|
(void)off;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
int proto_decode_ack(const uint8_t *p, uint16_t len, Msg_Ack *r) {
|
int proto_decode_ack(const uint8_t *p, uint16_t len, Msg_Ack *r) {
|
||||||
if (len < 1) return -1;
|
if (len < 1) return -1;
|
||||||
int off = 0;
|
int off = 0;
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ typedef enum {
|
|||||||
RT_PLAY = 0x06,
|
RT_PLAY = 0x06,
|
||||||
RT_STOP = 0x07,
|
RT_STOP = 0x07,
|
||||||
RT_SET_TEMPO = 0x08,
|
RT_SET_TEMPO = 0x08,
|
||||||
|
RT_PREVIEW_NOTE = 0x09,
|
||||||
RT_ACK = 0x81,
|
RT_ACK = 0x81,
|
||||||
RT_ERROR = 0x82,
|
RT_ERROR = 0x82,
|
||||||
RT_BEAT_TICK = 0x83,
|
RT_BEAT_TICK = 0x83,
|
||||||
@@ -33,6 +34,7 @@ typedef enum {
|
|||||||
#define PAYLOAD_SIZE_PLAY 2
|
#define PAYLOAD_SIZE_PLAY 2
|
||||||
#define PAYLOAD_SIZE_STOP 0
|
#define PAYLOAD_SIZE_STOP 0
|
||||||
#define PAYLOAD_SIZE_SET_TEMPO 2
|
#define PAYLOAD_SIZE_SET_TEMPO 2
|
||||||
|
#define PAYLOAD_SIZE_PREVIEW_NOTE 5
|
||||||
#define PAYLOAD_SIZE_ACK 1
|
#define PAYLOAD_SIZE_ACK 1
|
||||||
#define PAYLOAD_SIZE_ERROR 2
|
#define PAYLOAD_SIZE_ERROR 2
|
||||||
#define PAYLOAD_SIZE_BEAT_TICK 4
|
#define PAYLOAD_SIZE_BEAT_TICK 4
|
||||||
@@ -71,6 +73,12 @@ typedef struct {
|
|||||||
typedef struct {
|
typedef struct {
|
||||||
uint16_t bpm_x10; /* BPM × 10 for 0.1 BPM resolution — e.g. 1200 = 120.0 BPM */
|
uint16_t bpm_x10; /* BPM × 10 for 0.1 BPM resolution — e.g. 1200 = 120.0 BPM */
|
||||||
} Msg_Set_Tempo;
|
} Msg_Set_Tempo;
|
||||||
|
typedef struct {
|
||||||
|
uint8_t channel;
|
||||||
|
uint8_t note;
|
||||||
|
uint8_t velocity;
|
||||||
|
uint16_t duration_ms; /* Note-off will be sent after this many milliseconds */
|
||||||
|
} Msg_Preview_Note;
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint8_t acked_type; /* Record type being acknowledged */
|
uint8_t acked_type; /* Record type being acknowledged */
|
||||||
} Msg_Ack;
|
} Msg_Ack;
|
||||||
@@ -96,6 +104,7 @@ 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_play(uint8_t *buf, const Msg_Play *r);
|
||||||
int proto_encode_stop(uint8_t *buf);
|
int proto_encode_stop(uint8_t *buf);
|
||||||
int proto_encode_set_tempo(uint8_t *buf, const Msg_Set_Tempo *r);
|
int proto_encode_set_tempo(uint8_t *buf, const Msg_Set_Tempo *r);
|
||||||
|
int proto_encode_preview_note(uint8_t *buf, const Msg_Preview_Note *r);
|
||||||
int proto_encode_ack(uint8_t *buf, const Msg_Ack *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_error(uint8_t *buf, const Msg_Error *r);
|
||||||
int proto_encode_beat_tick(uint8_t *buf, const Msg_Beat_Tick *r);
|
int proto_encode_beat_tick(uint8_t *buf, const Msg_Beat_Tick *r);
|
||||||
@@ -110,6 +119,7 @@ int proto_decode_add_sub_pattern(const uint8_t *payload, uint16_t len, Msg_Add_S
|
|||||||
int proto_decode_play(const uint8_t *payload, uint16_t len, Msg_Play *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_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_set_tempo(const uint8_t *payload, uint16_t len, Msg_Set_Tempo *r);
|
||||||
|
int proto_decode_preview_note(const uint8_t *payload, uint16_t len, Msg_Preview_Note *r);
|
||||||
int proto_decode_ack(const uint8_t *payload, uint16_t len, Msg_Ack *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_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_beat_tick(const uint8_t *payload, uint16_t len, Msg_Beat_Tick *r);
|
||||||
|
|||||||
@@ -105,6 +105,12 @@ static void on_frame(uint8_t rt, const uint8_t *payload, uint16_t payload_len, v
|
|||||||
sequencer_set_tempo(&app->seq, msg.bpm_x10);
|
sequencer_set_tempo(&app->seq, msg.bpm_x10);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case RT_PREVIEW_NOTE: {
|
||||||
|
Msg_Preview_Note msg;
|
||||||
|
if (proto_decode_preview_note(payload, payload_len, &msg) < 0) goto bad_payload;
|
||||||
|
sequencer_preview_note(&app->seq, msg.channel, msg.note, msg.velocity, msg.duration_ms);
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
fprintf(stderr, "main: unknown record type 0x%02x\n", rt);
|
fprintf(stderr, "main: unknown record type 0x%02x\n", rt);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <time.h>
|
#include <time.h>
|
||||||
|
#include <pthread.h>
|
||||||
|
|
||||||
/* ── ALSA helpers ─────────────────────────────────────────────────── */
|
/* ── ALSA helpers ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
@@ -299,3 +300,41 @@ void sequencer_set_tempo(Sequencer *seq, uint16_t bpm_x10) {
|
|||||||
seq->bpm_x10 = bpm_x10 > 0 ? bpm_x10 : 1;
|
seq->bpm_x10 = bpm_x10 > 0 ? bpm_x10 : 1;
|
||||||
pthread_mutex_unlock(&seq->tempo_mutex);
|
pthread_mutex_unlock(&seq->tempo_mutex);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Preview note ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
Sequencer *seq;
|
||||||
|
uint8_t channel;
|
||||||
|
uint8_t note;
|
||||||
|
uint32_t delay_ms;
|
||||||
|
} Preview_Off_Arg;
|
||||||
|
|
||||||
|
static void *preview_off_fn(void *arg) {
|
||||||
|
Preview_Off_Arg *a = (Preview_Off_Arg *)arg;
|
||||||
|
struct timespec ts = {
|
||||||
|
.tv_sec = (time_t)(a->delay_ms / 1000),
|
||||||
|
.tv_nsec = (long)(a->delay_ms % 1000) * 1000000L,
|
||||||
|
};
|
||||||
|
nanosleep(&ts, NULL);
|
||||||
|
alsa_note_off(a->seq, a->channel, a->note);
|
||||||
|
free(a);
|
||||||
|
return NULL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void sequencer_preview_note(Sequencer *seq, uint8_t channel, uint8_t note,
|
||||||
|
uint8_t velocity, uint16_t duration_ms) {
|
||||||
|
alsa_note_on(seq, channel, note, velocity);
|
||||||
|
Preview_Off_Arg *arg = malloc(sizeof(*arg));
|
||||||
|
if (!arg) { alsa_note_off(seq, channel, note); return; }
|
||||||
|
arg->seq = seq;
|
||||||
|
arg->channel = channel;
|
||||||
|
arg->note = note;
|
||||||
|
arg->delay_ms = duration_ms ? duration_ms : 500;
|
||||||
|
pthread_t t;
|
||||||
|
pthread_attr_t attr;
|
||||||
|
pthread_attr_init(&attr);
|
||||||
|
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
|
||||||
|
pthread_create(&t, &attr, preview_off_fn, arg);
|
||||||
|
pthread_attr_destroy(&attr);
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,3 +65,5 @@ void sequencer_destroy(Sequencer *seq);
|
|||||||
void sequencer_play(Sequencer *seq, uint16_t pattern_id);
|
void sequencer_play(Sequencer *seq, uint16_t pattern_id);
|
||||||
void sequencer_stop(Sequencer *seq);
|
void sequencer_stop(Sequencer *seq);
|
||||||
void sequencer_set_tempo(Sequencer *seq, uint16_t bpm_x10);
|
void sequencer_set_tempo(Sequencer *seq, uint16_t bpm_x10);
|
||||||
|
void sequencer_preview_note(Sequencer *seq, uint8_t channel, uint8_t note,
|
||||||
|
uint8_t velocity, uint16_t duration_ms);
|
||||||
|
|||||||
@@ -1,3 +1,55 @@
|
|||||||
|
/* ── GM Percussion ───────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
const GM_PERC = {
|
||||||
|
35: 'Bass Drum 2', 36: 'Bass Drum 1',
|
||||||
|
37: 'Side Stick', 38: 'Snare 1',
|
||||||
|
39: 'Hand Clap', 40: 'Snare 2',
|
||||||
|
41: 'Low Floor Tom', 42: 'Closed Hi-Hat',
|
||||||
|
43: 'Hi Floor Tom', 44: 'Pedal Hi-Hat',
|
||||||
|
45: 'Low Tom', 46: 'Open Hi-Hat',
|
||||||
|
47: 'Low-Mid Tom', 48: 'Hi-Mid Tom',
|
||||||
|
49: 'Crash Cym 1', 50: 'High Tom',
|
||||||
|
51: 'Ride Cym 1', 52: 'Chinese Cym',
|
||||||
|
53: 'Ride Bell', 54: 'Tambourine',
|
||||||
|
55: 'Splash Cym', 56: 'Cowbell',
|
||||||
|
57: 'Crash Cym 2', 58: 'Vibraslap',
|
||||||
|
59: 'Ride Cym 2', 60: 'Hi Bongo',
|
||||||
|
61: 'Low Bongo', 62: 'Mute Hi Conga',
|
||||||
|
63: 'Open Hi Conga', 64: 'Low Conga',
|
||||||
|
65: 'Hi Timbale', 66: 'Low Timbale',
|
||||||
|
67: 'Hi Agogo', 68: 'Low Agogo',
|
||||||
|
69: 'Cabasa', 70: 'Maracas',
|
||||||
|
71: 'Short Whistle', 72: 'Long Whistle',
|
||||||
|
73: 'Short Guiro', 74: 'Long Guiro',
|
||||||
|
75: 'Claves', 76: 'Hi Wood Block',
|
||||||
|
77: 'Low Wood Block', 78: 'Mute Cuica',
|
||||||
|
79: 'Open Cuica', 80: 'Mute Triangle',
|
||||||
|
81: 'Open Triangle',
|
||||||
|
};
|
||||||
|
|
||||||
|
/* Drum-machine order: cymbals → hi-hats → toms → snares → kicks → latin */
|
||||||
|
const PERC_NOTES = [
|
||||||
|
49, 57, 51, 59, 52, 53, 55, 56, 57, /* crash, ride, chinese, bell, splash, cowbell */
|
||||||
|
46, 44, 42, 54, /* open/pedal/closed hi-hat, tambourine */
|
||||||
|
50, 48, 47, 45, 43, 41, /* toms high→low */
|
||||||
|
38, 40, 37, 39, 58, /* snares, clap, vibraslap */
|
||||||
|
36, 35, /* bass drums */
|
||||||
|
60, 61, 62, 63, 64, /* bongos, congas */
|
||||||
|
65, 66, 67, 68, /* timbales, agogo */
|
||||||
|
69, 70, 71, 72, 73, 74, /* cabasa, maracas, whistles, guiro */
|
||||||
|
75, 76, 77, 78, 79, 80, 81, /* claves, wood blocks, cuica, triangle */
|
||||||
|
].filter((n, i, a) => GM_PERC[n] && a.indexOf(n) === i); /* dedupe */
|
||||||
|
|
||||||
|
function perc_family(note) {
|
||||||
|
if (note === 42 || note === 44 || note === 46) return 'perc-hihat';
|
||||||
|
if (note >= 49 && note <= 59 || note === 53 || note === 55) return 'perc-cymbal';
|
||||||
|
if (note === 56 || note === 54) return 'perc-cymbal';
|
||||||
|
if (note >= 41 && note <= 50 && note !== 42 && note !== 44 && note !== 46) return 'perc-tom';
|
||||||
|
if (note >= 37 && note <= 40 || note === 58) return 'perc-snare';
|
||||||
|
if (note === 35 || note === 36) return 'perc-bass';
|
||||||
|
return 'perc-latin';
|
||||||
|
}
|
||||||
|
|
||||||
/* ── State ───────────────────────────────────────────────────────── */
|
/* ── State ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
const NOTE_NAMES = ['C','C#','D','D#','E','F','F#','G','G#','A','A#','B'];
|
||||||
@@ -11,10 +63,7 @@ class App_State {
|
|||||||
this.play_pattern_id = null;
|
this.play_pattern_id = null;
|
||||||
this.current_step = null;
|
this.current_step = null;
|
||||||
this.bpm = 120;
|
this.bpm = 120;
|
||||||
|
this.custom_labels = new Map(); /* key: "patternId:note" → string */
|
||||||
/* Pending edits — only sent on explicit save */
|
|
||||||
this.pending_notes = null; /* Array or null */
|
|
||||||
this.is_dirty = false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get selected_pattern() {
|
get selected_pattern() {
|
||||||
@@ -48,6 +97,13 @@ function note_name(midi) {
|
|||||||
return `${name}${oct}`;
|
return `${name}${oct}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function row_label(pattern, midi) {
|
||||||
|
const label_key = `${pattern.id}:${midi}`;
|
||||||
|
const is_perc = pattern.channel === 9;
|
||||||
|
const default_label = is_perc ? (GM_PERC[midi] ?? `Note ${midi}`) : note_name(midi);
|
||||||
|
return state.custom_labels.get(label_key) ?? default_label;
|
||||||
|
}
|
||||||
|
|
||||||
/* Rows to display in the step grid: a range of MIDI notes */
|
/* Rows to display in the step grid: a range of MIDI notes */
|
||||||
const DISPLAY_NOTES = (() => {
|
const DISPLAY_NOTES = (() => {
|
||||||
const rows = [];
|
const rows = [];
|
||||||
@@ -81,13 +137,10 @@ function render_content() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const notes = state.pending_notes ?? pattern.notes;
|
|
||||||
|
|
||||||
content.innerHTML = `
|
content.innerHTML = `
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>Pattern Settings</h2>
|
<h2>Pattern Settings</h2>
|
||||||
${state.is_dirty ? '<span style="color:var(--accent);font-size:11px">● unsaved changes</span>' : ''}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="field-row">
|
<div class="field-row">
|
||||||
<label>Name</label>
|
<label>Name</label>
|
||||||
@@ -104,12 +157,13 @@ function render_content() {
|
|||||||
<div class="panel">
|
<div class="panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<h2>Step Grid</h2>
|
<h2>Step Grid</h2>
|
||||||
<span style="font-size:11px;color:var(--text-dim)">Click cells to toggle notes — Save to apply</span>
|
<span style="font-size:11px;color:var(--text-dim)">Click cells to toggle — saves immediately</span>
|
||||||
<div style="flex:1"></div>
|
<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>
|
<button id="clear-notes-btn">Clear</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="step-grid" id="step-grid"></div>
|
<div class="step-grid-scroll">
|
||||||
|
<div class="step-grid" id="step-grid" style="display:flex;flex-direction:column;gap:2px;width:fit-content;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -125,7 +179,7 @@ function render_content() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
render_step_grid(pattern, notes);
|
render_step_grid(pattern, pattern.notes);
|
||||||
render_sub_refs(pattern);
|
render_sub_refs(pattern);
|
||||||
bind_content_events(pattern);
|
bind_content_events(pattern);
|
||||||
}
|
}
|
||||||
@@ -134,38 +188,49 @@ function render_step_grid(pattern, notes) {
|
|||||||
const grid = document.getElementById('step-grid');
|
const grid = document.getElementById('step-grid');
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
|
|
||||||
/* Build a set for quick lookup: "note:step" */
|
const is_perc = pattern.channel === 9;
|
||||||
const note_set = new Map(); /* "note:step" → { velocity, duration_steps } */
|
const rows = is_perc ? PERC_NOTES : DISPLAY_NOTES;
|
||||||
|
|
||||||
|
grid.className = is_perc ? 'step-grid step-grid--perc' : 'step-grid';
|
||||||
|
|
||||||
|
/* Build note lookup */
|
||||||
|
const note_set = new Map();
|
||||||
for (const ev of notes) {
|
for (const ev of notes) {
|
||||||
note_set.set(`${ev.note}:${ev.step}`, ev);
|
note_set.set(`${ev.note}:${ev.step}`, ev);
|
||||||
}
|
}
|
||||||
|
|
||||||
grid.style.gridTemplateColumns = `36px repeat(${pattern.steps}, 28px)`;
|
const active_step = (state.play_pattern_id === pattern.id) ? state.current_step : null;
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
for (const midi of DISPLAY_NOTES) {
|
|
||||||
html += `<div class="note-row" data-note="${midi}">`;
|
/* Playhead indicator row — spacer aligns with play-btn + label */
|
||||||
html += `<div class="note-label">${note_name(midi)}</div>`;
|
html += '<div class="note-row playhead-row">';
|
||||||
|
html += '<div class="row-play-spacer"></div>';
|
||||||
|
html += '<div class="note-label"></div>';
|
||||||
|
for (let s = 0; s < pattern.steps; s++) {
|
||||||
|
const cur = active_step === s ? 'current' : '';
|
||||||
|
html += `<div class="step-indicator ${cur}" data-step="${s}"></div>`;
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
for (const midi of rows) {
|
||||||
|
const label = row_label(pattern, midi);
|
||||||
|
const row_cls = is_perc ? `note-row ${perc_family(midi)}` : 'note-row';
|
||||||
|
html += `<div class="${row_cls}" data-note="${midi}">`;
|
||||||
|
html += `<button class="row-play-btn" data-note="${midi}" data-channel="${pattern.channel}" title="Preview note">▶</button>`;
|
||||||
|
html += `<div class="note-label" data-note="${midi}" title="${escape_html(label)}">${escape_html(label)}</div>`;
|
||||||
for (let s = 0; s < pattern.steps; s++) {
|
for (let s = 0; s < pattern.steps; s++) {
|
||||||
const key = `${midi}:${s}`;
|
const key = `${midi}:${s}`;
|
||||||
const ev = note_set.get(key);
|
const ev = note_set.get(key);
|
||||||
const on = ev ? 'on' : '';
|
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;
|
const vel_pct = ev ? Math.round((ev.velocity / 127) * 100) : 0;
|
||||||
html += `<button class="step-btn ${on} ${cur}" data-note="${midi}" data-step="${s}">`;
|
html += `<button class="step-btn ${on}" data-note="${midi}" data-step="${s}">`;
|
||||||
if (ev) html += `<div class="vel-bar" style="height:${Math.max(2, vel_pct * 0.28)}px"></div>`;
|
if (ev) html += `<div class="vel-bar" style="height:${Math.max(2, vel_pct * 0.28)}px"></div>`;
|
||||||
html += `</button>`;
|
html += `</button>`;
|
||||||
}
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
}
|
}
|
||||||
grid.innerHTML = html;
|
grid.innerHTML = html;
|
||||||
|
|
||||||
/* Delegate clicks */
|
|
||||||
grid.addEventListener('click', (e) => {
|
|
||||||
const btn = e.target.closest('.step-btn');
|
|
||||||
if (!btn) return;
|
|
||||||
toggle_note(pattern, parseInt(btn.dataset.note, 10), parseInt(btn.dataset.step, 10));
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function render_sub_refs(pattern) {
|
function render_sub_refs(pattern) {
|
||||||
@@ -184,25 +249,68 @@ function render_sub_refs(pattern) {
|
|||||||
|
|
||||||
/* ── Interaction ─────────────────────────────────────────────────── */
|
/* ── Interaction ─────────────────────────────────────────────────── */
|
||||||
|
|
||||||
function toggle_note(pattern, note, step) {
|
async function toggle_note(pattern, note, step) {
|
||||||
if (!state.pending_notes) {
|
const notes = pattern.notes.map(n => ({ ...n }));
|
||||||
state.pending_notes = (state.selected_pattern?.notes ?? []).map(n => ({ ...n }));
|
const idx = notes.findIndex(n => n.note === note && n.step === step);
|
||||||
|
if (idx >= 0) {
|
||||||
|
notes.splice(idx, 1);
|
||||||
|
} else {
|
||||||
|
notes.push({ step, note, velocity: 100, duration_steps: 1 });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const updated = await PUT(`/api/patterns/${pattern.id}/notes`, notes);
|
||||||
|
state.patterns.set(updated.id, updated);
|
||||||
|
render_step_grid(updated, updated.notes);
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function preview_note(note, channel) {
|
||||||
|
try {
|
||||||
|
await POST('/api/preview', { channel, note, velocity: 100, duration_ms: 500 });
|
||||||
|
} catch (err) { console.error(err); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function start_rename(pattern, note_num, label_el) {
|
||||||
|
if (label_el.querySelector('input')) return; /* already editing */
|
||||||
|
|
||||||
|
const label_key = `${pattern.id}:${note_num}`;
|
||||||
|
const is_perc = pattern.channel === 9;
|
||||||
|
const default_label = is_perc ? (GM_PERC[note_num] ?? `Note ${note_num}`) : note_name(note_num);
|
||||||
|
const current = state.custom_labels.get(label_key) ?? default_label;
|
||||||
|
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'text';
|
||||||
|
input.value = current;
|
||||||
|
input.className = 'note-label-input';
|
||||||
|
|
||||||
|
label_el.textContent = '';
|
||||||
|
label_el.appendChild(input);
|
||||||
|
input.focus();
|
||||||
|
input.select();
|
||||||
|
|
||||||
|
function commit() {
|
||||||
|
const val = input.value.trim();
|
||||||
|
if (val && val !== default_label) {
|
||||||
|
state.custom_labels.set(label_key, val);
|
||||||
|
} else {
|
||||||
|
state.custom_labels.delete(label_key);
|
||||||
|
}
|
||||||
|
label_el.textContent = state.custom_labels.get(label_key) ?? default_label;
|
||||||
}
|
}
|
||||||
|
|
||||||
const idx = state.pending_notes.findIndex(n => n.note === note && n.step === step);
|
input.addEventListener('blur', commit);
|
||||||
if (idx >= 0) {
|
input.addEventListener('keydown', (e) => {
|
||||||
state.pending_notes.splice(idx, 1);
|
if (e.key === 'Enter') {
|
||||||
} else {
|
input.blur();
|
||||||
state.pending_notes.push({ step, note, velocity: 100, duration_steps: 1 });
|
} else if (e.key === 'Escape') {
|
||||||
}
|
input.removeEventListener('blur', commit);
|
||||||
state.is_dirty = true;
|
label_el.textContent = current;
|
||||||
render_content();
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function select_pattern(id) {
|
function select_pattern(id) {
|
||||||
state.selected_id = id;
|
state.selected_id = id;
|
||||||
state.pending_notes = null;
|
|
||||||
state.is_dirty = false;
|
|
||||||
render_pattern_list();
|
render_pattern_list();
|
||||||
render_content();
|
render_content();
|
||||||
}
|
}
|
||||||
@@ -216,41 +324,27 @@ function bind_content_events(pattern) {
|
|||||||
try {
|
try {
|
||||||
const updated = await PUT(`/api/patterns/${pattern.id}`, { name, steps, channel });
|
const updated = await PUT(`/api/patterns/${pattern.id}`, { name, steps, channel });
|
||||||
state.patterns.set(updated.id, updated);
|
state.patterns.set(updated.id, updated);
|
||||||
state.pending_notes = null;
|
|
||||||
state.is_dirty = false;
|
|
||||||
render_pattern_list();
|
render_pattern_list();
|
||||||
render_content();
|
render_content();
|
||||||
} catch (err) { console.error(err); }
|
} catch (err) { console.error(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
/* Save notes */
|
/* Clear notes — auto-save */
|
||||||
document.getElementById('save-notes-btn')?.addEventListener('click', async () => {
|
document.getElementById('clear-notes-btn')?.addEventListener('click', async () => {
|
||||||
if (!state.pending_notes) return;
|
|
||||||
try {
|
try {
|
||||||
const updated = await PUT(`/api/patterns/${pattern.id}/notes`, state.pending_notes);
|
const updated = await PUT(`/api/patterns/${pattern.id}/notes`, []);
|
||||||
state.patterns.set(updated.id, updated);
|
state.patterns.set(updated.id, updated);
|
||||||
state.pending_notes = null;
|
render_step_grid(updated, updated.notes);
|
||||||
state.is_dirty = false;
|
|
||||||
render_content();
|
|
||||||
} catch (err) { console.error(err); }
|
} 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 */
|
/* Delete pattern */
|
||||||
document.getElementById('delete-pat-btn')?.addEventListener('click', async () => {
|
document.getElementById('delete-pat-btn')?.addEventListener('click', async () => {
|
||||||
if (!confirm(`Delete "${pattern.name}"?`)) return;
|
if (!confirm(`Delete "${pattern.name}"?`)) return;
|
||||||
try {
|
try {
|
||||||
await DELETE(`/api/patterns/${pattern.id}`);
|
await DELETE(`/api/patterns/${pattern.id}`);
|
||||||
state.patterns.delete(pattern.id);
|
state.patterns.delete(pattern.id);
|
||||||
state.selected_id = null;
|
state.selected_id = null;
|
||||||
state.pending_notes = null;
|
|
||||||
state.is_dirty = false;
|
|
||||||
render_pattern_list();
|
render_pattern_list();
|
||||||
render_content();
|
render_content();
|
||||||
} catch (err) { console.error(err); }
|
} catch (err) { console.error(err); }
|
||||||
@@ -267,6 +361,31 @@ function bind_content_events(pattern) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Content-level click delegation ─────────────────────────────── */
|
||||||
|
|
||||||
|
document.getElementById('content').addEventListener('click', (e) => {
|
||||||
|
const pattern = state.selected_pattern;
|
||||||
|
if (!pattern) return;
|
||||||
|
|
||||||
|
const step_btn = e.target.closest('.step-btn');
|
||||||
|
if (step_btn) {
|
||||||
|
toggle_note(pattern, parseInt(step_btn.dataset.note, 10), parseInt(step_btn.dataset.step, 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const play_btn = e.target.closest('.row-play-btn');
|
||||||
|
if (play_btn) {
|
||||||
|
preview_note(parseInt(play_btn.dataset.note, 10), parseInt(play_btn.dataset.channel, 10));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const label = e.target.closest('.note-label');
|
||||||
|
if (label && label.dataset.note && !e.target.matches('input')) {
|
||||||
|
start_rename(pattern, parseInt(label.dataset.note, 10), label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Transport ───────────────────────────────────────────────────── */
|
/* ── Transport ───────────────────────────────────────────────────── */
|
||||||
|
|
||||||
document.getElementById('play-btn').addEventListener('click', async () => {
|
document.getElementById('play-btn').addEventListener('click', async () => {
|
||||||
@@ -292,6 +411,15 @@ document.getElementById('new-pattern-btn').addEventListener('click', async () =>
|
|||||||
} catch (err) { console.error(err); }
|
} catch (err) { console.error(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('new-perc-btn').addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
const p = await POST('/api/patterns', { name: 'Drums', steps: 16, channel: 9 });
|
||||||
|
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 () => {
|
document.getElementById('set-tempo-btn').addEventListener('click', async () => {
|
||||||
const bpm = parseFloat(document.getElementById('bpm-input').value);
|
const bpm = parseFloat(document.getElementById('bpm-input').value);
|
||||||
if (isNaN(bpm) || bpm <= 0) return;
|
if (isNaN(bpm) || bpm <= 0) return;
|
||||||
@@ -318,8 +446,9 @@ function update_status_ui() {
|
|||||||
|
|
||||||
function update_step_highlight(step, pattern_id) {
|
function update_step_highlight(step, pattern_id) {
|
||||||
if (state.selected_pattern?.id !== pattern_id) return;
|
if (state.selected_pattern?.id !== pattern_id) return;
|
||||||
document.querySelectorAll('.step-btn.current').forEach(b => b.classList.remove('current'));
|
document.querySelectorAll('.step-indicator.current').forEach(b => b.classList.remove('current'));
|
||||||
document.querySelectorAll(`.step-btn[data-step="${step}"]`).forEach(b => b.classList.add('current'));
|
const indicator = document.querySelector(`.step-indicator[data-step="${step}"]`);
|
||||||
|
if (indicator) indicator.classList.add('current');
|
||||||
}
|
}
|
||||||
|
|
||||||
const es = new EventSource('/api/events');
|
const es = new EventSource('/api/events');
|
||||||
@@ -360,7 +489,7 @@ es.addEventListener('play', (e) => {
|
|||||||
es.addEventListener('stop', () => {
|
es.addEventListener('stop', () => {
|
||||||
state.playing = false; state.current_step = null;
|
state.playing = false; state.current_step = null;
|
||||||
update_status_ui();
|
update_status_ui();
|
||||||
document.querySelectorAll('.step-btn.current').forEach(b => b.classList.remove('current'));
|
document.querySelectorAll('.step-indicator.current').forEach(b => b.classList.remove('current'));
|
||||||
});
|
});
|
||||||
|
|
||||||
es.addEventListener('pattern_created', (e) => {
|
es.addEventListener('pattern_created', (e) => {
|
||||||
@@ -372,7 +501,7 @@ es.addEventListener('pattern_created', (e) => {
|
|||||||
es.addEventListener('pattern_updated', (e) => {
|
es.addEventListener('pattern_updated', (e) => {
|
||||||
const p = JSON.parse(e.data);
|
const p = JSON.parse(e.data);
|
||||||
state.patterns.set(p.id, p);
|
state.patterns.set(p.id, p);
|
||||||
if (state.selected_id === p.id && !state.is_dirty) {
|
if (state.selected_id === p.id) {
|
||||||
render_content();
|
render_content();
|
||||||
}
|
}
|
||||||
render_pattern_list();
|
render_pattern_list();
|
||||||
|
|||||||
@@ -152,9 +152,22 @@
|
|||||||
.field-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
.field-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||||
|
|
||||||
/* Step grid */
|
/* Step grid */
|
||||||
|
.step-grid-scroll {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 4px;
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
.step-grid {
|
.step-grid {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 4px;
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
width: fit-content;
|
||||||
|
min-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-row {
|
.note-row {
|
||||||
@@ -163,13 +176,86 @@
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Percussion mode — wider labels */
|
||||||
|
.step-grid--perc .note-label {
|
||||||
|
width: 110px;
|
||||||
|
font-size: 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Percussion family row tints */
|
||||||
|
.perc-bass { background: rgba(220, 60, 60, 0.06); }
|
||||||
|
.perc-snare { background: rgba(220, 140, 40, 0.06); }
|
||||||
|
.perc-tom { background: rgba(60, 180, 80, 0.06); }
|
||||||
|
.perc-hihat { background: rgba(220, 200, 40, 0.06); }
|
||||||
|
.perc-cymbal { background: rgba(60, 140, 220, 0.06); }
|
||||||
|
.perc-latin { background: rgba(160, 60, 220, 0.06); }
|
||||||
|
|
||||||
|
/* Percussion on-button colors per family */
|
||||||
|
.perc-bass .step-btn.on { background: #c43c3c; border-color: #d04d4d; }
|
||||||
|
.perc-snare .step-btn.on { background: #c48020; border-color: #d09030; }
|
||||||
|
.perc-tom .step-btn.on { background: #2a9040; border-color: #3aa050; }
|
||||||
|
.perc-hihat .step-btn.on { background: #b09a10; border-color: #c0aa20; }
|
||||||
|
.perc-cymbal .step-btn.on { background: #2866b0; border-color: #3876c0; }
|
||||||
|
.perc-latin .step-btn.on { background: #7030a0; border-color: #8040b0; }
|
||||||
|
|
||||||
|
/* Playhead indicator row */
|
||||||
|
.playhead-row { margin-bottom: 2px; }
|
||||||
|
.step-indicator {
|
||||||
|
width: 28px;
|
||||||
|
height: 4px;
|
||||||
|
border-radius: 2px;
|
||||||
|
background: var(--border);
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: background 0.04s;
|
||||||
|
}
|
||||||
|
.step-indicator.current { background: var(--step-active); }
|
||||||
|
|
||||||
.note-label {
|
.note-label {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.1s;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.note-label:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.note-label-input {
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: 3px;
|
||||||
|
padding: 1px 4px;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 10px;
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.row-play-btn {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
padding: 0;
|
||||||
|
font-size: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 3px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
.row-play-btn:hover { background: var(--surface2); color: var(--text); border-color: var(--accent2); }
|
||||||
|
|
||||||
|
.row-play-spacer { width: 18px; flex-shrink: 0; }
|
||||||
|
|
||||||
.step-btn {
|
.step-btn {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
@@ -185,7 +271,6 @@
|
|||||||
}
|
}
|
||||||
.step-btn.on { background: var(--step-on); border-color: var(--accent); }
|
.step-btn.on { background: var(--step-on); border-color: var(--accent); }
|
||||||
.step-btn.pending { background: var(--step-pend); border-color: #7a2233; }
|
.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 */
|
/* Velocity bar inside step button */
|
||||||
.step-btn .vel-bar {
|
.step-btn .vel-bar {
|
||||||
@@ -226,7 +311,10 @@
|
|||||||
<button id="stop-btn">■ Stop</button>
|
<button id="stop-btn">■ Stop</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="pattern-list" id="pattern-list"></div>
|
<div class="pattern-list" id="pattern-list"></div>
|
||||||
<button id="new-pattern-btn">+ New Pattern</button>
|
<div style="display:flex;gap:6px;">
|
||||||
|
<button id="new-pattern-btn" style="flex:1">+ Melodic</button>
|
||||||
|
<button id="new-perc-btn" style="flex:1">🥁 Drums</button>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="content" id="content">
|
<div class="content" id="content">
|
||||||
<div class="empty-state">Select or create a pattern to get started.</div>
|
<div class="empty-state">Select or create a pattern to get started.</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
encode_play,
|
encode_play,
|
||||||
encode_stop,
|
encode_stop,
|
||||||
encode_set_tempo,
|
encode_set_tempo,
|
||||||
|
encode_preview_note,
|
||||||
} from './src/generated/protocol.mjs';
|
} from './src/generated/protocol.mjs';
|
||||||
|
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
@@ -185,6 +186,15 @@ app.get('/api/tempo', (_req, res) => {
|
|||||||
res.json({ bpm: state.bpm });
|
res.json({ bpm: state.bpm });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* ── Preview route ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
app.post('/api/preview', (req, res) => {
|
||||||
|
const { channel, note, velocity = 100, duration_ms = 500 } = req.body;
|
||||||
|
if (typeof note !== 'number') return res.status(400).json({ error: 'note required' });
|
||||||
|
backend.send(encode_preview_note({ channel: channel ?? 0, note, velocity, duration_ms }));
|
||||||
|
res.json({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
/* ── Start ────────────────────────────────────────────────────────── */
|
/* ── Start ────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
app.listen(PORT, () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const RT = Object.freeze({
|
|||||||
PLAY : 0x06,
|
PLAY : 0x06,
|
||||||
STOP : 0x07,
|
STOP : 0x07,
|
||||||
SET_TEMPO : 0x08,
|
SET_TEMPO : 0x08,
|
||||||
|
PREVIEW_NOTE : 0x09,
|
||||||
ACK : 0x81,
|
ACK : 0x81,
|
||||||
ERROR : 0x82,
|
ERROR : 0x82,
|
||||||
BEAT_TICK : 0x83,
|
BEAT_TICK : 0x83,
|
||||||
@@ -32,6 +33,7 @@ export const PAYLOAD_SIZE = Object.freeze({
|
|||||||
PLAY : 2,
|
PLAY : 2,
|
||||||
STOP : 0,
|
STOP : 0,
|
||||||
SET_TEMPO : 2,
|
SET_TEMPO : 2,
|
||||||
|
PREVIEW_NOTE : 5,
|
||||||
ACK : 1,
|
ACK : 1,
|
||||||
ERROR : 2,
|
ERROR : 2,
|
||||||
BEAT_TICK : 4,
|
BEAT_TICK : 4,
|
||||||
@@ -102,6 +104,15 @@ export function encode_set_tempo({ bpm_x10 }) {
|
|||||||
return write_frame(RT.SET_TEMPO, buf);
|
return write_frame(RT.SET_TEMPO, buf);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function encode_preview_note({ channel, note, velocity, duration_ms }) {
|
||||||
|
const buf = Buffer.alloc(5);
|
||||||
|
buf.writeUInt8(channel, 0);
|
||||||
|
buf.writeUInt8(note, 1);
|
||||||
|
buf.writeUInt8(velocity, 2);
|
||||||
|
buf.writeUInt16LE(duration_ms, 3);
|
||||||
|
return write_frame(RT.PREVIEW_NOTE, buf);
|
||||||
|
}
|
||||||
|
|
||||||
export function encode_ack({ acked_type }) {
|
export function encode_ack({ acked_type }) {
|
||||||
const buf = Buffer.alloc(1);
|
const buf = Buffer.alloc(1);
|
||||||
buf.writeUInt8(acked_type, 0);
|
buf.writeUInt8(acked_type, 0);
|
||||||
@@ -170,6 +181,11 @@ export function decode_set_tempo(payload) {
|
|||||||
return { bpm_x10: payload.readUInt16LE(0) };
|
return { bpm_x10: payload.readUInt16LE(0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function decode_preview_note(payload) {
|
||||||
|
if (payload.length < 5) throw new Error('PREVIEW_NOTE payload too short');
|
||||||
|
return { channel: payload.readUInt8(0), note: payload.readUInt8(1), velocity: payload.readUInt8(2), duration_ms: payload.readUInt16LE(3) };
|
||||||
|
}
|
||||||
|
|
||||||
export function decode_ack(payload) {
|
export function decode_ack(payload) {
|
||||||
if (payload.length < 1) throw new Error('ACK payload too short');
|
if (payload.length < 1) throw new Error('ACK payload too short');
|
||||||
return { acked_type: payload.readUInt8(0) };
|
return { acked_type: payload.readUInt8(0) };
|
||||||
@@ -201,6 +217,7 @@ export function decode(record_type, payload) {
|
|||||||
case RT.PLAY: return decode_play(payload);
|
case RT.PLAY: return decode_play(payload);
|
||||||
case RT.STOP: return decode_stop(payload);
|
case RT.STOP: return decode_stop(payload);
|
||||||
case RT.SET_TEMPO: return decode_set_tempo(payload);
|
case RT.SET_TEMPO: return decode_set_tempo(payload);
|
||||||
|
case RT.PREVIEW_NOTE: return decode_preview_note(payload);
|
||||||
case RT.ACK: return decode_ack(payload);
|
case RT.ACK: return decode_ack(payload);
|
||||||
case RT.ERROR: return decode_error(payload);
|
case RT.ERROR: return decode_error(payload);
|
||||||
case RT.BEAT_TICK: return decode_beat_tick(payload);
|
case RT.BEAT_TICK: return decode_beat_tick(payload);
|
||||||
|
|||||||
@@ -104,6 +104,21 @@ records:
|
|||||||
type: uint16
|
type: uint16
|
||||||
note: "BPM × 10 for 0.1 BPM resolution — e.g. 1200 = 120.0 BPM"
|
note: "BPM × 10 for 0.1 BPM resolution — e.g. 1200 = 120.0 BPM"
|
||||||
|
|
||||||
|
PREVIEW_NOTE:
|
||||||
|
id: 0x09
|
||||||
|
direction: node_to_c
|
||||||
|
description: Play a single note immediately without affecting sequencer state
|
||||||
|
fields:
|
||||||
|
- name: channel
|
||||||
|
type: uint8
|
||||||
|
- name: note
|
||||||
|
type: uint8
|
||||||
|
- name: velocity
|
||||||
|
type: uint8
|
||||||
|
- name: duration_ms
|
||||||
|
type: uint16
|
||||||
|
note: Note-off will be sent after this many milliseconds
|
||||||
|
|
||||||
ACK:
|
ACK:
|
||||||
id: 0x81
|
id: 0x81
|
||||||
direction: c_to_node
|
direction: c_to_node
|
||||||
|
|||||||
Reference in New Issue
Block a user