Replaces the single-root-pattern sequencer with a Track[] array that allows multiple patterns to loop independently. Adds ADD_TRACK (0x0A), REMOVE_TRACK (0x0B), PLAY_TRACKS (0x0C), and SET_TRACK_MUTE (0x0D) protocol records. The C backend gains per-track pending_subs and a tracks_mutex. The Node server gains track-state APIs (/api/tracks/:id/ active, mute, solo) and the frontend shows per-pattern track/mute/solo buttons in the sidebar list. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
601 lines
21 KiB
JavaScript
601 lines
21 KiB
JavaScript
/* ── 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'];
|
|
|
|
class App_State {
|
|
constructor() {
|
|
this.patterns = new Map();
|
|
this.selected_id = null;
|
|
this.backend_connected = false;
|
|
this.playing = false;
|
|
this.play_pattern_id = null;
|
|
this.current_step = null;
|
|
this.bpm = 120;
|
|
this.custom_labels = new Map(); /* key: "patternId:note" → string */
|
|
this.active_tracks = new Set(); /* pattern IDs */
|
|
this.muted_tracks = new Set(); /* pattern IDs */
|
|
this.solo_id = null;
|
|
}
|
|
|
|
get selected_pattern() {
|
|
return this.selected_id ? this.patterns.get(this.selected_id) ?? null : null;
|
|
}
|
|
}
|
|
|
|
const state = new App_State();
|
|
|
|
/* ── API helpers ─────────────────────────────────────────────────── */
|
|
|
|
async function api(method, path, body) {
|
|
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
|
if (body !== undefined) opts.body = JSON.stringify(body);
|
|
const res = await fetch(path, opts);
|
|
if (!res.ok) throw new Error(`${method} ${path} → ${res.status}`);
|
|
if (res.status === 204) return null;
|
|
return res.json();
|
|
}
|
|
|
|
const GET = (path) => api('GET', path);
|
|
const POST = (path, body) => api('POST', path, body);
|
|
const PUT = (path, body) => api('PUT', path, body);
|
|
const DELETE = (path) => api('DELETE', path);
|
|
|
|
/* ── MIDI note helpers ───────────────────────────────────────────── */
|
|
|
|
function note_name(midi) {
|
|
const oct = Math.floor(midi / 12) - 1;
|
|
const name = NOTE_NAMES[midi % 12];
|
|
return `${name}${oct}`;
|
|
}
|
|
|
|
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 = [];
|
|
for (let n = 83; n >= 36; n--) rows.push(n); /* B5 down to C2 */
|
|
return rows;
|
|
})();
|
|
|
|
/* ── Render ──────────────────────────────────────────────────────── */
|
|
|
|
function render_pattern_list() {
|
|
const list = document.getElementById('pattern-list');
|
|
list.innerHTML = '';
|
|
for (const p of state.patterns.values()) {
|
|
const is_active = state.active_tracks.has(p.id);
|
|
const is_muted = state.muted_tracks.has(p.id);
|
|
const is_solo = state.solo_id === p.id;
|
|
const div = document.createElement('div');
|
|
div.className = 'pattern-item' + (p.id === state.selected_id ? ' selected' : '');
|
|
div.dataset.id = p.id;
|
|
div.innerHTML = `
|
|
<button class="track-btn${is_active ? ' on' : ''}" title="${is_active ? 'Remove from playback' : 'Add to playback'}">⬤</button>
|
|
<span class="name">${escape_html(p.name)}</span>
|
|
<span class="steps">${p.steps}st</span>
|
|
<span class="track-ctl">
|
|
<button class="mute-btn${is_muted && !is_solo ? ' on' : ''}${!is_active ? ' dim' : ''}" title="Mute">M</button>
|
|
<button class="solo-btn${is_solo ? ' on' : ''}${!is_active ? ' dim' : ''}" title="Solo">S</button>
|
|
</span>
|
|
`;
|
|
list.appendChild(div);
|
|
}
|
|
}
|
|
|
|
function render_content() {
|
|
const content = document.getElementById('content');
|
|
const pattern = state.selected_pattern;
|
|
|
|
if (!pattern) {
|
|
content.innerHTML = '<div class="empty-state">Select or create a pattern to get started.</div>';
|
|
return;
|
|
}
|
|
|
|
content.innerHTML = `
|
|
<div class="panel">
|
|
<div class="panel-header">
|
|
<h2>Pattern Settings</h2>
|
|
</div>
|
|
<div class="field-row">
|
|
<label>Name</label>
|
|
<input type="text" id="pat-name" value="${escape_html(pattern.name)}" style="width:160px">
|
|
<label>Steps</label>
|
|
<input type="number" id="pat-steps" value="${pattern.steps}" min="1" max="64" style="width:60px">
|
|
<label>Channel</label>
|
|
<input type="number" id="pat-channel" value="${pattern.channel}" min="0" max="15" style="width:55px">
|
|
<button id="save-settings-btn" class="primary">Save</button>
|
|
<button id="delete-pat-btn" class="danger">Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="panel-header">
|
|
<h2>Step Grid</h2>
|
|
<span style="font-size:11px;color:var(--text-dim)">Click cells to toggle — saves immediately</span>
|
|
<div style="flex:1"></div>
|
|
<button id="clear-notes-btn">Clear</button>
|
|
</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">
|
|
<div class="panel-header"><h2>Sub-patterns</h2></div>
|
|
<div class="sub-ref-list" id="sub-ref-list"></div>
|
|
<div class="field-row" style="margin-top:10px">
|
|
<label>At step</label>
|
|
<input type="number" id="sub-step" value="0" min="0" style="width:60px">
|
|
<label>Pattern ID</label>
|
|
<input type="number" id="sub-pat-id" value="" min="1" style="width:60px">
|
|
<button id="add-sub-btn">Add Sub-pattern</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
render_step_grid(pattern, pattern.notes);
|
|
render_sub_refs(pattern);
|
|
bind_content_events(pattern);
|
|
}
|
|
|
|
function render_step_grid(pattern, notes) {
|
|
const grid = document.getElementById('step-grid');
|
|
if (!grid) return;
|
|
|
|
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);
|
|
}
|
|
|
|
const active_step = (state.play_pattern_id === pattern.id) ? state.current_step : null;
|
|
|
|
let html = '';
|
|
|
|
/* 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 vel_pct = ev ? Math.round((ev.velocity / 127) * 100) : 0;
|
|
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;
|
|
}
|
|
|
|
function render_sub_refs(pattern) {
|
|
const list = document.getElementById('sub-ref-list');
|
|
if (!list) return;
|
|
if (pattern.sub_refs.length === 0) {
|
|
list.innerHTML = '<div style="color:var(--text-dim);font-size:12px">No sub-patterns</div>';
|
|
return;
|
|
}
|
|
list.innerHTML = pattern.sub_refs.map(ref => `
|
|
<div class="sub-ref-item">
|
|
<span class="label">Pattern ${ref.sub_pattern_id} at step ${ref.step}</span>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
/* ── Interaction ─────────────────────────────────────────────────── */
|
|
|
|
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;
|
|
}
|
|
|
|
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;
|
|
render_pattern_list();
|
|
render_content();
|
|
}
|
|
|
|
/* ── Track interactions ──────────────────────────────────────────── */
|
|
|
|
async function toggle_track_active(id) {
|
|
const active = !state.active_tracks.has(id);
|
|
if (active) {
|
|
state.active_tracks.add(id);
|
|
} else {
|
|
state.active_tracks.delete(id);
|
|
state.muted_tracks.delete(id);
|
|
if (state.solo_id === id) state.solo_id = null;
|
|
}
|
|
try {
|
|
await PUT(`/api/tracks/${id}/active`, { active });
|
|
} catch (err) { console.error(err); }
|
|
render_pattern_list();
|
|
}
|
|
|
|
async function toggle_mute(id) {
|
|
if (!state.active_tracks.has(id)) return;
|
|
const muted = !state.muted_tracks.has(id);
|
|
if (muted) state.muted_tracks.add(id);
|
|
else state.muted_tracks.delete(id);
|
|
try {
|
|
await PUT(`/api/tracks/${id}/mute`, { muted });
|
|
} catch (err) { console.error(err); }
|
|
render_pattern_list();
|
|
}
|
|
|
|
async function toggle_solo(id) {
|
|
if (!state.active_tracks.has(id)) return;
|
|
const new_solo = state.solo_id === id ? null : id;
|
|
state.solo_id = new_solo;
|
|
try {
|
|
await PUT('/api/solo', { id: new_solo });
|
|
} catch (err) { console.error(err); }
|
|
render_pattern_list();
|
|
}
|
|
|
|
/* Pattern list click delegation */
|
|
document.getElementById('pattern-list').addEventListener('click', (e) => {
|
|
const item = e.target.closest('.pattern-item');
|
|
if (!item) return;
|
|
const id = parseInt(item.dataset.id, 10);
|
|
|
|
if (e.target.closest('.track-btn')) { toggle_track_active(id); return; }
|
|
if (e.target.closest('.mute-btn')) { toggle_mute(id); return; }
|
|
if (e.target.closest('.solo-btn')) { toggle_solo(id); return; }
|
|
|
|
select_pattern(id);
|
|
});
|
|
|
|
function bind_content_events(pattern) {
|
|
/* Save settings */
|
|
document.getElementById('save-settings-btn')?.addEventListener('click', async () => {
|
|
const name = document.getElementById('pat-name').value.trim();
|
|
const steps = parseInt(document.getElementById('pat-steps').value, 10);
|
|
const channel = parseInt(document.getElementById('pat-channel').value, 10);
|
|
try {
|
|
const updated = await PUT(`/api/patterns/${pattern.id}`, { name, steps, channel });
|
|
state.patterns.set(updated.id, updated);
|
|
render_pattern_list();
|
|
render_content();
|
|
} catch (err) { console.error(err); }
|
|
});
|
|
|
|
/* Clear notes — auto-save */
|
|
document.getElementById('clear-notes-btn')?.addEventListener('click', async () => {
|
|
try {
|
|
const updated = await PUT(`/api/patterns/${pattern.id}/notes`, []);
|
|
state.patterns.set(updated.id, updated);
|
|
render_step_grid(updated, updated.notes);
|
|
} catch (err) { console.error(err); }
|
|
});
|
|
|
|
/* 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;
|
|
render_pattern_list();
|
|
render_content();
|
|
} catch (err) { console.error(err); }
|
|
});
|
|
|
|
/* Add sub-pattern */
|
|
document.getElementById('add-sub-btn')?.addEventListener('click', async () => {
|
|
const step = parseInt(document.getElementById('sub-step').value, 10);
|
|
const sub_pattern_id = parseInt(document.getElementById('sub-pat-id').value, 10);
|
|
if (isNaN(sub_pattern_id)) return;
|
|
try {
|
|
await POST(`/api/patterns/${pattern.id}/sub-refs`, { step, sub_pattern_id });
|
|
} catch (err) { console.error(err); }
|
|
});
|
|
}
|
|
|
|
/* ── 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 () => {
|
|
try {
|
|
await POST('/api/play');
|
|
} catch (err) { console.error(err); }
|
|
});
|
|
|
|
document.getElementById('stop-btn').addEventListener('click', async () => {
|
|
try {
|
|
await POST('/api/stop');
|
|
} catch (err) { console.error(err); }
|
|
});
|
|
|
|
document.getElementById('new-pattern-btn').addEventListener('click', async () => {
|
|
try {
|
|
const p = await POST('/api/patterns', { steps: 16, channel: 0 });
|
|
state.patterns.set(p.id, p);
|
|
render_pattern_list();
|
|
select_pattern(p.id);
|
|
} catch (err) { console.error(err); }
|
|
});
|
|
|
|
document.getElementById('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;
|
|
try {
|
|
const result = await PUT('/api/tempo', { bpm });
|
|
state.bpm = result.bpm;
|
|
document.getElementById('bpm-input').value = result.bpm;
|
|
} catch (err) { console.error(err); }
|
|
});
|
|
|
|
/* ── SSE event handling ──────────────────────────────────────────── */
|
|
|
|
function update_status_ui() {
|
|
const dot = document.getElementById('backend-dot');
|
|
const label = document.getElementById('status-label');
|
|
const pdot = document.getElementById('play-dot');
|
|
|
|
dot.classList.toggle('connected', state.backend_connected);
|
|
pdot.classList.toggle('playing', state.playing);
|
|
label.textContent = state.backend_connected
|
|
? (state.playing ? 'playing' : 'connected')
|
|
: 'disconnected';
|
|
}
|
|
|
|
function update_step_highlight(step, pattern_id) {
|
|
if (state.selected_pattern?.id !== pattern_id) return;
|
|
document.querySelectorAll('.step-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');
|
|
|
|
es.addEventListener('state', (e) => {
|
|
const data = JSON.parse(e.data);
|
|
state.bpm = data.bpm;
|
|
document.getElementById('bpm-input').value = data.bpm;
|
|
state.patterns.clear();
|
|
for (const p of data.patterns) state.patterns.set(p.id, p);
|
|
state.active_tracks = new Set(data.tracks?.active ?? []);
|
|
state.muted_tracks = new Set(data.tracks?.muted ?? []);
|
|
state.solo_id = data.tracks?.solo_id ?? null;
|
|
state.playing = data.is_playing ?? false;
|
|
render_pattern_list();
|
|
render_content();
|
|
update_status_ui();
|
|
});
|
|
|
|
es.addEventListener('backend_connect', () => { state.backend_connected = true; update_status_ui(); });
|
|
es.addEventListener('backend_disconnect', () => { state.backend_connected = false; update_status_ui(); });
|
|
es.addEventListener('backend_status', (e) => {
|
|
const { connected } = JSON.parse(e.data);
|
|
state.backend_connected = connected;
|
|
update_status_ui();
|
|
});
|
|
|
|
es.addEventListener('beat_tick', (e) => {
|
|
const { pattern_id, step } = JSON.parse(e.data);
|
|
state.current_step = step;
|
|
state.play_pattern_id = pattern_id;
|
|
state.playing = true;
|
|
update_status_ui();
|
|
update_step_highlight(step, pattern_id);
|
|
});
|
|
|
|
es.addEventListener('play', () => {
|
|
state.playing = true;
|
|
update_status_ui();
|
|
});
|
|
|
|
es.addEventListener('stop', () => {
|
|
state.playing = false;
|
|
state.current_step = null;
|
|
update_status_ui();
|
|
document.querySelectorAll('.step-indicator.current').forEach(b => b.classList.remove('current'));
|
|
});
|
|
|
|
es.addEventListener('tracks_updated', (e) => {
|
|
const { active, muted, solo_id } = JSON.parse(e.data);
|
|
state.active_tracks = new Set(active);
|
|
state.muted_tracks = new Set(muted);
|
|
state.solo_id = solo_id;
|
|
render_pattern_list();
|
|
});
|
|
|
|
es.addEventListener('pattern_created', (e) => {
|
|
const p = JSON.parse(e.data);
|
|
state.patterns.set(p.id, p);
|
|
render_pattern_list();
|
|
});
|
|
|
|
es.addEventListener('pattern_updated', (e) => {
|
|
const p = JSON.parse(e.data);
|
|
state.patterns.set(p.id, p);
|
|
if (state.selected_id === p.id) {
|
|
/* Only refresh the grid — full render_content() resets scroll position */
|
|
render_step_grid(p, p.notes);
|
|
}
|
|
render_pattern_list();
|
|
});
|
|
|
|
es.addEventListener('pattern_deleted', (e) => {
|
|
const { id } = JSON.parse(e.data);
|
|
state.patterns.delete(id);
|
|
if (state.selected_id === id) { state.selected_id = null; render_content(); }
|
|
render_pattern_list();
|
|
});
|
|
|
|
es.addEventListener('tempo', (e) => {
|
|
state.bpm = JSON.parse(e.data).bpm;
|
|
document.getElementById('bpm-input').value = state.bpm;
|
|
});
|
|
|
|
/* ── Utilities ───────────────────────────────────────────────────── */
|
|
|
|
function escape_html(str) {
|
|
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
}
|