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:
2026-04-25 03:33:05 +00:00
parent 9aba8057a8
commit 515cc87382
10 changed files with 403 additions and 67 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 &mdash; 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">&#9654;</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();

View File

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

View File

@@ -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, () => {

View File

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

View File

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