From 515cc8738299d29515a54cb5f696863535e632c4 Mon Sep 17 00:00:00 2001 From: mikael-lovqvists-claude-agent Date: Sat, 25 Apr 2026 03:33:05 +0000 Subject: [PATCH] Add PREVIEW_NOTE, auto-save, per-row play button, and click-to-rename MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 , 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 --- c-backend/generated/protocol.c | 20 ++ c-backend/generated/protocol.h | 10 + c-backend/src/main.c | 6 + c-backend/src/sequencer.c | 39 ++++ c-backend/src/sequencer.h | 2 + node-server/public/app.mjs | 255 +++++++++++++++++++------ node-server/public/index.html | 96 +++++++++- node-server/server.mjs | 10 + node-server/src/generated/protocol.mjs | 17 ++ protocol.yaml | 15 ++ 10 files changed, 403 insertions(+), 67 deletions(-) diff --git a/c-backend/generated/protocol.c b/c-backend/generated/protocol.c index 79852a6..c09d2df 100644 --- a/c-backend/generated/protocol.c +++ b/c-backend/generated/protocol.c @@ -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; diff --git a/c-backend/generated/protocol.h b/c-backend/generated/protocol.h index 253ce5a..97ad3f8 100644 --- a/c-backend/generated/protocol.h +++ b/c-backend/generated/protocol.h @@ -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); diff --git a/c-backend/src/main.c b/c-backend/src/main.c index ac78311..77d0ca8 100644 --- a/c-backend/src/main.c +++ b/c-backend/src/main.c @@ -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; diff --git a/c-backend/src/sequencer.c b/c-backend/src/sequencer.c index faded83..208a224 100644 --- a/c-backend/src/sequencer.c +++ b/c-backend/src/sequencer.c @@ -5,6 +5,7 @@ #include #include #include +#include /* ── 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); +} diff --git a/c-backend/src/sequencer.h b/c-backend/src/sequencer.h index c5cc6e7..4cbcb8b 100644 --- a/c-backend/src/sequencer.h +++ b/c-backend/src/sequencer.h @@ -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); diff --git a/node-server/public/app.mjs b/node-server/public/app.mjs index e4a8fa3..0d1d57b 100644 --- a/node-server/public/app.mjs +++ b/node-server/public/app.mjs @@ -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 = `

Pattern Settings

- ${state.is_dirty ? '● unsaved changes' : ''}
@@ -104,12 +157,13 @@ function render_content() {

Step Grid

- Click cells to toggle notes — Save to apply + Click cells to toggle — saves immediately
-
-
+
+
+
@@ -125,7 +179,7 @@ function render_content() {
`; - 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 += `
`; - html += `
${note_name(midi)}
`; + + /* Playhead indicator row — spacer aligns with play-btn + label */ + html += '
'; + html += '
'; + html += '
'; + for (let s = 0; s < pattern.steps; s++) { + const cur = active_step === s ? 'current' : ''; + html += `
`; + } + html += '
'; + + for (const midi of rows) { + const label = row_label(pattern, midi); + const row_cls = is_perc ? `note-row ${perc_family(midi)}` : 'note-row'; + html += `
`; + html += ``; + html += `
${escape_html(label)}
`; 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 += ``; } 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) { @@ -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(); diff --git a/node-server/public/index.html b/node-server/public/index.html index 6bdb554..b4eb5b3 100644 --- a/node-server/public/index.html +++ b/node-server/public/index.html @@ -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 @@
- +
+ + +
Select or create a pattern to get started.
diff --git a/node-server/server.mjs b/node-server/server.mjs index b532c53..17a922a 100644 --- a/node-server/server.mjs +++ b/node-server/server.mjs @@ -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, () => { diff --git a/node-server/src/generated/protocol.mjs b/node-server/src/generated/protocol.mjs index 3f517c6..865049d 100644 --- a/node-server/src/generated/protocol.mjs +++ b/node-server/src/generated/protocol.mjs @@ -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); diff --git a/protocol.yaml b/protocol.yaml index e061976..f34d287 100644 --- a/protocol.yaml +++ b/protocol.yaml @@ -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