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);
|
||||
}
|
||||
|
||||
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 off = FRAME_HEADER_SIZE;
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
if (len < 1) return -1;
|
||||
int off = 0;
|
||||
|
||||
@@ -18,6 +18,7 @@ typedef enum {
|
||||
RT_PLAY = 0x06,
|
||||
RT_STOP = 0x07,
|
||||
RT_SET_TEMPO = 0x08,
|
||||
RT_PREVIEW_NOTE = 0x09,
|
||||
RT_ACK = 0x81,
|
||||
RT_ERROR = 0x82,
|
||||
RT_BEAT_TICK = 0x83,
|
||||
@@ -33,6 +34,7 @@ typedef enum {
|
||||
#define PAYLOAD_SIZE_PLAY 2
|
||||
#define PAYLOAD_SIZE_STOP 0
|
||||
#define PAYLOAD_SIZE_SET_TEMPO 2
|
||||
#define PAYLOAD_SIZE_PREVIEW_NOTE 5
|
||||
#define PAYLOAD_SIZE_ACK 1
|
||||
#define PAYLOAD_SIZE_ERROR 2
|
||||
#define PAYLOAD_SIZE_BEAT_TICK 4
|
||||
@@ -71,6 +73,12 @@ typedef struct {
|
||||
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 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 {
|
||||
uint8_t acked_type; /* Record type being acknowledged */
|
||||
} 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_stop(uint8_t *buf);
|
||||
int proto_encode_set_tempo(uint8_t *buf, const Msg_Set_Tempo *r);
|
||||
int proto_encode_preview_note(uint8_t *buf, const Msg_Preview_Note *r);
|
||||
int proto_encode_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);
|
||||
@@ -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_stop(const uint8_t *payload, uint16_t len, Msg_Stop *r);
|
||||
int proto_decode_set_tempo(const uint8_t *payload, uint16_t len, Msg_Set_Tempo *r);
|
||||
int proto_decode_preview_note(const uint8_t *payload, uint16_t len, Msg_Preview_Note *r);
|
||||
int proto_decode_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);
|
||||
|
||||
@@ -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);
|
||||
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:
|
||||
fprintf(stderr, "main: unknown record type 0x%02x\n", rt);
|
||||
return;
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <time.h>
|
||||
#include <pthread.h>
|
||||
|
||||
/* ── 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;
|
||||
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_stop(Sequencer *seq);
|
||||
void sequencer_set_tempo(Sequencer *seq, uint16_t bpm_x10);
|
||||
void sequencer_preview_note(Sequencer *seq, uint8_t channel, uint8_t note,
|
||||
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 ───────────────────────────────────────────────────────── */
|
||||
|
||||
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.current_step = null;
|
||||
this.bpm = 120;
|
||||
|
||||
/* Pending edits — only sent on explicit save */
|
||||
this.pending_notes = null; /* Array or null */
|
||||
this.is_dirty = false;
|
||||
this.custom_labels = new Map(); /* key: "patternId:note" → string */
|
||||
}
|
||||
|
||||
get selected_pattern() {
|
||||
@@ -48,6 +97,13 @@ function note_name(midi) {
|
||||
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 */
|
||||
const DISPLAY_NOTES = (() => {
|
||||
const rows = [];
|
||||
@@ -81,13 +137,10 @@ function render_content() {
|
||||
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>
|
||||
@@ -104,12 +157,13 @@ function render_content() {
|
||||
<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>
|
||||
<span style="font-size:11px;color:var(--text-dim)">Click cells to toggle — saves immediately</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 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 class="panel">
|
||||
@@ -125,7 +179,7 @@ function render_content() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
render_step_grid(pattern, notes);
|
||||
render_step_grid(pattern, pattern.notes);
|
||||
render_sub_refs(pattern);
|
||||
bind_content_events(pattern);
|
||||
}
|
||||
@@ -134,38 +188,49 @@ 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 } */
|
||||
const is_perc = pattern.channel === 9;
|
||||
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) {
|
||||
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 = '';
|
||||
for (const midi of DISPLAY_NOTES) {
|
||||
html += `<div class="note-row" data-note="${midi}">`;
|
||||
html += `<div class="note-label">${note_name(midi)}</div>`;
|
||||
|
||||
/* Playhead indicator row — spacer aligns with play-btn + label */
|
||||
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++) {
|
||||
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}">`;
|
||||
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>`;
|
||||
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) {
|
||||
@@ -184,25 +249,68 @@ function render_sub_refs(pattern) {
|
||||
|
||||
/* ── Interaction ─────────────────────────────────────────────────── */
|
||||
|
||||
function toggle_note(pattern, note, step) {
|
||||
if (!state.pending_notes) {
|
||||
state.pending_notes = (state.selected_pattern?.notes ?? []).map(n => ({ ...n }));
|
||||
async function toggle_note(pattern, note, step) {
|
||||
const notes = 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);
|
||||
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();
|
||||
input.addEventListener('blur', commit);
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
input.blur();
|
||||
} else if (e.key === 'Escape') {
|
||||
input.removeEventListener('blur', commit);
|
||||
label_el.textContent = current;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function select_pattern(id) {
|
||||
state.selected_id = id;
|
||||
state.pending_notes = null;
|
||||
state.is_dirty = false;
|
||||
state.selected_id = id;
|
||||
render_pattern_list();
|
||||
render_content();
|
||||
}
|
||||
@@ -216,41 +324,27 @@ function bind_content_events(pattern) {
|
||||
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;
|
||||
/* Clear notes — auto-save */
|
||||
document.getElementById('clear-notes-btn')?.addEventListener('click', async () => {
|
||||
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.pending_notes = null;
|
||||
state.is_dirty = false;
|
||||
render_content();
|
||||
render_step_grid(updated, updated.notes);
|
||||
} 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;
|
||||
state.selected_id = null;
|
||||
render_pattern_list();
|
||||
render_content();
|
||||
} 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 ───────────────────────────────────────────────────── */
|
||||
|
||||
document.getElementById('play-btn').addEventListener('click', async () => {
|
||||
@@ -292,6 +411,15 @@ document.getElementById('new-pattern-btn').addEventListener('click', async () =>
|
||||
} 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 () => {
|
||||
const bpm = parseFloat(document.getElementById('bpm-input').value);
|
||||
if (isNaN(bpm) || bpm <= 0) return;
|
||||
@@ -318,8 +446,9 @@ function update_status_ui() {
|
||||
|
||||
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'));
|
||||
document.querySelectorAll('.step-indicator.current').forEach(b => b.classList.remove('current'));
|
||||
const indicator = document.querySelector(`.step-indicator[data-step="${step}"]`);
|
||||
if (indicator) indicator.classList.add('current');
|
||||
}
|
||||
|
||||
const es = new EventSource('/api/events');
|
||||
@@ -360,7 +489,7 @@ es.addEventListener('play', (e) => {
|
||||
es.addEventListener('stop', () => {
|
||||
state.playing = false; state.current_step = null;
|
||||
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) => {
|
||||
@@ -372,7 +501,7 @@ es.addEventListener('pattern_created', (e) => {
|
||||
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) {
|
||||
if (state.selected_id === p.id) {
|
||||
render_content();
|
||||
}
|
||||
render_pattern_list();
|
||||
|
||||
@@ -152,9 +152,22 @@
|
||||
.field-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
|
||||
/* 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 {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
width: fit-content;
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.note-row {
|
||||
@@ -163,13 +176,86 @@
|
||||
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 {
|
||||
width: 36px;
|
||||
text-align: right;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
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 {
|
||||
width: 28px;
|
||||
@@ -185,7 +271,6 @@
|
||||
}
|
||||
.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 {
|
||||
@@ -226,7 +311,10 @@
|
||||
<button id="stop-btn">■ Stop</button>
|
||||
</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>
|
||||
<div class="content" id="content">
|
||||
<div class="empty-state">Select or create a pattern to get started.</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
encode_play,
|
||||
encode_stop,
|
||||
encode_set_tempo,
|
||||
encode_preview_note,
|
||||
} from './src/generated/protocol.mjs';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
@@ -185,6 +186,15 @@ app.get('/api/tempo', (_req, res) => {
|
||||
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 ────────────────────────────────────────────────────────── */
|
||||
|
||||
app.listen(PORT, () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ export const RT = Object.freeze({
|
||||
PLAY : 0x06,
|
||||
STOP : 0x07,
|
||||
SET_TEMPO : 0x08,
|
||||
PREVIEW_NOTE : 0x09,
|
||||
ACK : 0x81,
|
||||
ERROR : 0x82,
|
||||
BEAT_TICK : 0x83,
|
||||
@@ -32,6 +33,7 @@ export const PAYLOAD_SIZE = Object.freeze({
|
||||
PLAY : 2,
|
||||
STOP : 0,
|
||||
SET_TEMPO : 2,
|
||||
PREVIEW_NOTE : 5,
|
||||
ACK : 1,
|
||||
ERROR : 2,
|
||||
BEAT_TICK : 4,
|
||||
@@ -102,6 +104,15 @@ export function encode_set_tempo({ bpm_x10 }) {
|
||||
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 }) {
|
||||
const buf = Buffer.alloc(1);
|
||||
buf.writeUInt8(acked_type, 0);
|
||||
@@ -170,6 +181,11 @@ export function decode_set_tempo(payload) {
|
||||
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) {
|
||||
if (payload.length < 1) throw new Error('ACK payload too short');
|
||||
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.STOP: return decode_stop(payload);
|
||||
case RT.SET_TEMPO: return decode_set_tempo(payload);
|
||||
case RT.PREVIEW_NOTE: return decode_preview_note(payload);
|
||||
case RT.ACK: return decode_ack(payload);
|
||||
case RT.ERROR: return decode_error(payload);
|
||||
case RT.BEAT_TICK: return decode_beat_tick(payload);
|
||||
|
||||
@@ -104,6 +104,21 @@ records:
|
||||
type: uint16
|
||||
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:
|
||||
id: 0x81
|
||||
direction: c_to_node
|
||||
|
||||
Reference in New Issue
Block a user