Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
says so), a checkpoint dropped by the undo cap or a session reset just falls
back to that, and a refused undo can never spin.

- **Select and delete multiple barlines at once** in Tempo Map mode. Shift-click
a second barline to select the contiguous range, drag a box on empty grid to
rubber-band-select, or Ctrl+A to select every barline; the selection washes
amber. Delete (or right-click ▸ "Delete N barlines") demotes them all in one
undoable step — the first and last barline are always kept (they bound the
map). Escape clears the selection. The single focused barline (BPM / tap /
lock / modulate / suggest) is unchanged; the multi-selection is separate and
is dropped on any grid-topology change.

- **Pitched GM guide voices** (DAW workspace 1.2/1.5). The guide can now play
the charted notes as a real General-MIDI instrument instead of the clap:
Transport ▸ Guide voice ▸ Instrument (GM), with a per-part-kind instrument
Expand Down
37 changes: 29 additions & 8 deletions src/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { _editorCommandById, _editorEffectiveRightClickBehaviorPure, _editorEofC
import { SNAP_VALUES, _editorEffectiveSnapValuePure, _editorSnapSubdivisionsPure } from './snap.js';
import { S } from './state.js';
import { _editorShowTabPreview, _tabPreviewKeyPolicyPure } from './tab-preview.js';
import { TempoGridCmd, _editorModulateTempoAtSelection, _editorTapTempoAtSelection, _editorToggleSyncLock, _editorToggleTempoMapMode, _tapTempoHandleKey, _tempoDeleteSyncPoint, _tempoInsertSyncPoint, _tempoMapOnContextMenu, _tempoMeasureBeatCount, _tempoMeasureDenominator, _tempoPromptMeasureBpm, _tempoSetBeatsPerMeasure, _tempoSetDenominatorOnBeatsPure, _tempoPromptPickup } from './tempo.js';
import { TempoGridCmd, _editorModulateTempoAtSelection, _editorTapTempoAtSelection, _editorToggleSyncLock, _editorToggleTempoMapMode, _tapTempoHandleKey, _tempoDeleteSelection, _tempoInsertSyncPoint, _tempoMapOnContextMenu, _tempoMeasureBeatCount, _tempoMeasureDenominator, _tempoPromptMeasureBpm, _tempoSetBeatsPerMeasure, _tempoSetDenominatorOnBeatsPure, _tempoPromptPickup } from './tempo.js';
import { _editorPromptText, setStatus } from './ui.js';
import { host } from './host.js';

Expand Down Expand Up @@ -647,11 +647,13 @@ function _editorInsertTempoSyncAtCursor() {
}

function _editorDeleteTempoSyncSelection() {
if (!S.tempoMapMode || S.tempoSel < 0) {
// Bulk-delete the multi-selection when there is one (PR 5a), else the single
// focus — _tempoDeleteSelection covers both in one undoable command.
if (!S.tempoMapMode || (S.tempoSel < 0 && !(S.tempoSelMulti && S.tempoSelMulti.size))) {
setStatus('Select a Tempo Map barline first.');
return true;
}
_tempoDeleteSyncPoint(S.tempoSel);
_tempoDeleteSelection();
return true;
}

Expand Down Expand Up @@ -1154,6 +1156,16 @@ export function onKeyDown(e) {
setStatus('Suggestions dismissed');
return;
}
// Escape clears a barline multi-selection (PR 5a) — layered UNDER the
// suggest-dismiss above, so ghosts always own Escape first.
if (e.key === 'Escape' && S.tempoMapMode && S.tempoSelMulti && S.tempoSelMulti.size
&& !e.target.matches('input, select, textarea')) {
e.preventDefault();
S.tempoSelMulti.clear();
host.draw();
setStatus('Selection cleared');
return;
}

if (_editorDispatchFeedbackShortcut(e)) return;
if (_editorDispatchEofShortcut(e)) return;
Expand All @@ -1167,11 +1179,12 @@ export function onKeyDown(e) {
}

if (e.key === 'Delete' || e.key === 'Backspace') {
// Tempo-map mode: delete the selected barline.
if (S.tempoMapMode && S.tempoSel >= 0 &&
// Tempo-map mode: delete the selected barline(s) — bulk when a
// multi-selection exists (PR 5a), else the single focus.
if (S.tempoMapMode && (S.tempoSel >= 0 || (S.tempoSelMulti && S.tempoSelMulti.size)) &&
!e.target.matches('input, select, textarea')) {
e.preventDefault();
_tempoDeleteSyncPoint(S.tempoSel);
_tempoDeleteSelection();
return;
}
// Anchor-lane: delete the selected anchor. Same focus / mode
Expand Down Expand Up @@ -1277,8 +1290,16 @@ export function onKeyDown(e) {
host.draw();
return;
}
// Tempo-map mode has no note selection — Ctrl+A is inert.
if (S.tempoMapMode) return;
// Tempo-map mode: Ctrl+A selects every downbeat (PR 5a).
if (S.tempoMapMode) {
if (!S.tempoSelMulti) S.tempoSelMulti = new Set();
S.tempoSelMulti.clear();
const beats = S.beats || [];
for (let i = 0; i < beats.length; i++) if (beats[i] && beats[i].measure > 0) S.tempoSelMulti.add(i);
host.draw();
setStatus(`${S.tempoSelMulti.size} barline${S.tempoSelMulti.size === 1 ? '' : 's'} selected.`);
return;
}
const nn = notes();
for (let i = 0; i < nn.length; i++) S.sel.add(i);
host.draw();
Expand Down
18 changes: 17 additions & 1 deletion src/mouse.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import { _recState } from './midi-record.js';
import { _resizeSustainsForDeltaPure, _resizeTargetIndicesPure, notes } from './notes.js';
import { _rulerZonePure, rulerOnMouseDown, rulerOnMouseMove, rulerOnMouseUp } from './ruler.js';
import { S } from './state.js';
import { _tempoBeatOnDragMove, _tempoMapOnDragEnd, _tempoMapOnDragMove, _tempoMapOnMouseDown, _tempoSyncAtX } from './tempo.js';
import { _tempoBeatOnDragMove, _tempoMapOnDragEnd, _tempoMapOnDragMove, _tempoMapOnMouseDown, _tempoMarqueeOnEnd, _tempoSyncAtX } from './tempo.js';
import { setStatus } from './ui.js';
import { host } from './host.js';

Expand Down Expand Up @@ -360,6 +360,17 @@ function _onMouseMoveBody(e, x, y, L) {
return;
}

// Tempo-map barline marquee (PR 5a): rubber-band box-select of downbeats.
// Same deferred 3px `moved` idiom as the drum-editor marquee above.
if (S.drag.type === 'tempo-marquee') {
S.drag.curX = x;
S.drag.curY = y;
const ddx = x - S.drag.startX, ddy = y - S.drag.startY;
if (ddx * ddx + ddy * ddy > 9) S.drag.moved = true;
host.draw();
return;
}

if (S.drag.type === 'select') {
S.drag.curX = x;
S.drag.curY = y;
Expand Down Expand Up @@ -466,6 +477,11 @@ export function onMouseUp(e) {
return;
}

if (S.drag.type === 'tempo-marquee') {
_tempoMarqueeOnEnd();
return;
}

if (S.drag.type === 'tone') {
onToneLaneMouseUp();
return;
Expand Down
6 changes: 6 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export const S = {
tempoMapMode: false,
tempoSel: -1,
tempoHover: -1,
// Multi-selected barlines (PR 5a) — a Set of downbeat indices into S.beats,
// the S.drumSel pattern. SEPARATE from tempoSel (the single focus that
// inspector/tap/lock/modulate/suggest key on): Shift+click a range, marquee
// on empty grid, or Ctrl+A add here. Index-based, so it is CLEARED (never
// remapped) on any topology change (TempoGridCmd) and on mode exit.
tempoSelMulti: new Set(),

// View
scrollX: 0, // seconds
Expand Down
151 changes: 145 additions & 6 deletions src/tempo.js
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,32 @@ export function _tempoMapDraw(w, h) {
// to line up with them (and the waveform).
_tempoDrawReferenceNotes(w, gridBottom, visibleStart, visibleEnd);

// Multi-selected barlines (PR 5a): a light amber wash spanning the range
// between the outermost selected downbeats (the existing halo grammar).
if (S.tempoSelMulti && S.tempoSelMulti.size) {
let minT = Infinity, maxT = -Infinity;
for (const i of S.tempoSelMulti) {
const b = S.beats[i];
if (b && b.measure > 0) { if (b.time < minT) minT = b.time; if (b.time > maxT) maxT = b.time; }
}
if (minT <= maxT) {
const xa = Math.max(LABEL_W, timeToX(minT)), xb = Math.min(w, timeToX(maxT));
if (xb > xa) {
ctx.fillStyle = 'rgba(251,191,36,0.10)';
ctx.fillRect(xa, (TIMELINE_TOP + WAVEFORM_H), xb - xa, gridBottom - (TIMELINE_TOP + WAVEFORM_H));
}
}
Comment on lines +276 to +290

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Render disjoint selections as separate washes.

Shift-marquee unions can create disjoint selections, but this min/max fill paints the gaps as selected. Split the wash by contiguous downbeat runs so deletion feedback matches the actual selected barlines.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/tempo.js` around lines 276 - 290, Update the tempoSelMulti rendering
block to group selected barlines into contiguous downbeat runs instead of using
one global minT/maxT range. Render a separate amber wash for each run,
preserving the existing timeToX bounds, fill styling, and canvas region so gaps
between disjoint selections remain unpainted.

}
// Marquee rubber-band while box-selecting downbeats on empty grid.
if (S.drag && S.drag.type === 'tempo-marquee' && S.drag.moved) {
const xa = Math.min(S.drag.startX, S.drag.curX), xb = Math.max(S.drag.startX, S.drag.curX);
ctx.fillStyle = 'rgba(251,191,36,0.08)';
ctx.fillRect(xa, (TIMELINE_TOP + WAVEFORM_H), xb - xa, gridBottom - (TIMELINE_TOP + WAVEFORM_H));
ctx.strokeStyle = 'rgba(251,191,36,0.5)';
ctx.lineWidth = 1;
ctx.strokeRect(xa + 0.5, (TIMELINE_TOP + WAVEFORM_H) + 0.5, xb - xa - 1, gridBottom - (TIMELINE_TOP + WAVEFORM_H) - 1);
}

// Measures: per-measure labels + draggable sync-point poles.
const measures = _tempoMeasures();
// Pickup display shift (D3): with a partial first bar, the first FULL
Expand Down Expand Up @@ -304,6 +330,10 @@ export function _tempoMapDraw(w, h) {
if (x >= LABEL_W && x <= w) {
const sel = (m.i === S.tempoSel);
const hov = (m.i === S.tempoHover);
// A multi-selected barline (PR 5a) reads amber like the focus, but
// without the thick focus halo (that stays unique to tempoSel).
const inMulti = !!(S.tempoSelMulti && S.tempoSelMulti.has(m.i));
const amber = sel || inMulti;
// Beat-lock: a locked sync point renders EMERALD — its time is held
// by global tempo re-fits (detect / modulate / re-space). The
// selection halo still shows through, so lock ≠ selection.
Expand All @@ -316,13 +346,13 @@ export function _tempoMapDraw(w, h) {
ctx.lineTo(x, gridBottom);
ctx.stroke();
}
ctx.strokeStyle = locked ? '#34d399' : sel ? '#fbbf24' : hov ? '#93c5fd' : '#64748b';
ctx.strokeStyle = locked ? '#34d399' : amber ? '#fbbf24' : hov ? '#93c5fd' : '#64748b';
ctx.lineWidth = sel ? 3 : 2;
ctx.beginPath();
ctx.moveTo(x, (TIMELINE_TOP + WAVEFORM_H));
ctx.lineTo(x, gridBottom);
ctx.stroke();
ctx.fillStyle = locked ? '#34d399' : sel ? '#fbbf24' : hov ? '#93c5fd' : '#94a3b8';
ctx.fillStyle = locked ? '#34d399' : amber ? '#fbbf24' : hov ? '#93c5fd' : '#94a3b8';
ctx.fillRect(x - TEMPO_POLE_HALF, (TIMELINE_TOP + WAVEFORM_H), TEMPO_POLE_HALF * 2, 13);
ctx.fillStyle = '#0c0c1c';
ctx.font = 'bold 9px monospace';
Expand Down Expand Up @@ -595,6 +625,7 @@ export function _editorToggleTempoMapMode() {
S.tempoMapMode = !S.tempoMapMode;
S.tempoSel = -1;
S.tempoHover = -1;
if (S.tempoSelMulti) S.tempoSelMulti.clear(); // multi-select is mode-scoped (PR 5a)
_tapTempo = null; // abandon any pending tap run on mode change
_suggestDismiss(); // proposals are mode-scoped — never survive an exit
if (S.tempoMapMode) {
Expand Down Expand Up @@ -823,9 +854,20 @@ export function _tempoMapOnMouseDown(e, x, y) {

// Click a sync-point pole to select it and start a drag.
const hit = _tempoSyncAtX(x, y);
if (hit !== S.tempoSel) _tapTempo = null; // selection moved — drop stale tap run
S.tempoSel = hit;
if (hit >= 0) {
// Shift+click extends a contiguous range of downbeats from the current
// focus to the clicked pole into the multi-selection (PR 5a). No drag.
if (e.shiftKey && S.tempoSel >= 0 && S.beats[S.tempoSel] && S.beats[S.tempoSel].measure > 0) {
_tempoSelectDownbeatRange(S.tempoSel, hit);
S.tempoSel = hit;
_tapTempo = null;
host.draw();
setStatus(`${S.tempoSelMulti.size} barline${S.tempoSelMulti.size === 1 ? '' : 's'} selected.`);
return;
}
if (hit !== S.tempoSel) _tapTempo = null; // selection moved — drop stale tap run
S.tempoSel = hit;
if (S.tempoSelMulti) S.tempoSelMulti.clear(); // plain pole click = single focus
S.drag = {
type: 'tempo-sync',
beatIdx: hit,
Expand All @@ -836,19 +878,26 @@ export function _tempoMapOnMouseDown(e, x, y) {
host.draw();
return;
}
S.tempoSel = -1;
// No pole under the cursor — try an individual (sub-)beat tick for a
// rubato drag: re-time one beat inside its measure without touching
// the downbeats. Essential for hand-syncing accel/rit within a bar.
const beatHit = _tempoSubBeatAtX(x, y);
if (beatHit >= 0) {
_tapTempo = null;
S.drag = {
type: 'tempo-beat',
beatIdx: beatHit,
startX: x,
origBeats: S.beats.map(b => ({ ...b })),
moved: false,
};
host.draw();
return;
}
// Empty grid → marquee box-select of downbeats (PR 5a). Deferred 3px like the
// drum editor: a stationary press just clears (below), a drag rubber-bands.
S.drag = { type: 'tempo-marquee', startX: x, startY: y, curX: x, curY: y, shift: e.shiftKey, moved: false };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
host.draw();
}

Expand Down Expand Up @@ -878,7 +927,11 @@ export function _tempoMapOnContextMenu(e) {
html += mkBtn('togglelock',
(S.beats[onPole] && S.beats[onPole].locked) ? 'Unlock barline' : 'Lock barline',
'', LOCK_TOOLTIP);
html += mkBtn('delete', 'Delete barline', 'text-red-400');
// With a multi-selection, offer the bulk delete (PR 5a); else the single.
const nMulti = S.tempoSelMulti ? S.tempoSelMulti.size : 0;
html += (nMulti > 1)
? mkBtn('delete-multi', `Delete ${nMulti} barlines`, 'text-red-400')
: mkBtn('delete', 'Delete barline', 'text-red-400');
} else {
html += mkBtn('insert', 'Mark barline here');
}
Expand All @@ -888,7 +941,8 @@ export function _tempoMapOnContextMenu(e) {
host.hideContextMenu();
const a = btn.dataset.action;
if (a === 'pickup') { _tempoPromptPickup(); return; }
if (a === 'delete') _tempoDeleteSyncPoint(onPole);
if (a === 'delete-multi') _tempoDeleteSelection();
else if (a === 'delete') _tempoDeleteSyncPoint(onPole);
else if (a === 'togglelock') { S.tempoSel = onPole; _editorToggleSyncLock(); }
else if (a === 'insert') _tempoInsertSyncPoint(xToTime(x));
else if (a === 'bpmedit') _tempoPromptMeasureBpm(onPole);
Expand Down Expand Up @@ -1031,6 +1085,87 @@ export function _tempoDeleteSyncPoint(beatIdx) {
host.draw();
}

// ── Barline multi-select: range / marquee / bulk delete (PR 5a) ──────

// Add the contiguous downbeat range [a,b] to the multi-selection (Shift+click).
export function _tempoSelectDownbeatRange(a, b) {
if (!S.tempoSelMulti) S.tempoSelMulti = new Set();
const beats = S.beats || [];
const lo = Math.min(a, b), hi = Math.max(a, b);
for (let i = lo; i <= hi; i++) {
if (beats[i] && beats[i].measure > 0) S.tempoSelMulti.add(i);
}
}

// Downbeat indices whose time falls in [tLo, tHi] — the marquee hit math. Pure.
export function _tempoMarqueeDownbeatsPure(beats, tLo, tHi) {
const out = [];
if (!Array.isArray(beats)) return out;
const lo = Math.min(tLo, tHi), hi = Math.max(tLo, tHi);
for (let i = 0; i < beats.length; i++) {
const b = beats[i];
if (b && b.measure > 0 && b.time >= lo && b.time <= hi) out.push(i);
}
return out;
}

// Finalize the marquee drag: box-select the downbeats within the swept X range.
// Plain replaces the selection, Shift unions; a press that never moved clears
// (a click-away) — the drum-editor marquee idiom.
export function _tempoMarqueeOnEnd() {
const dg = S.drag;
S.drag = null;
if (!dg || dg.type !== 'tempo-marquee') { host.draw(); return; }
if (!S.tempoSelMulti) S.tempoSelMulti = new Set();
if (!dg.moved) {
if (!dg.shift) { S.tempoSelMulti.clear(); S.tempoSel = -1; }
host.draw();
return;
}
if (!dg.shift) S.tempoSelMulti.clear();
for (const i of _tempoMarqueeDownbeatsPure(S.beats, xToTime(dg.startX), xToTime(dg.curX))) {
S.tempoSelMulti.add(i);
}
host.draw();
setStatus(`${S.tempoSelMulti.size} barline${S.tempoSelMulti.size === 1 ? '' : 's'} selected.`);
}

// Demote the given interior downbeats to sub-beats + renumber — the bulk
// delete's grid transform. Pure; returns { beats, count } or null. Never the
// first/last downbeat (they bound the mapped range), matching
// _tempoDeleteSyncPoint's guard generalized to a set.
export function _tempoDeleteBarlinesPure(beats, indices) {
if (!Array.isArray(beats)) return null;
const dbIdx = [];
for (let i = 0; i < beats.length; i++) if (beats[i] && beats[i].measure > 0) dbIdx.push(i);
if (dbIdx.length < 3) return null; // need at least one interior downbeat
const first = dbIdx[0], last = dbIdx[dbIdx.length - 1];
const del = new Set([...(indices || [])].filter(
i => beats[i] && beats[i].measure > 0 && i !== first && i !== last));
if (!del.size) return null;
const out = beats.map(b => ({ ...b }));
for (const i of del) out[i].measure = -1;
_tempoRenumberMeasures(out);
return { beats: out, count: del.size };
}

// Del / right-click "Delete N barlines": bulk-demote the multi-selection (or the
// single focus when nothing is multi-selected) in ONE TempoGridCmd.
export function _tempoDeleteSelection() {
const beats = S.beats || [];
const sel = (S.tempoSelMulti && S.tempoSelMulti.size)
? [...S.tempoSelMulti]
: (S.tempoSel >= 0 ? [S.tempoSel] : []);
const res = _tempoDeleteBarlinesPure(beats, sel);
if (!res) { setStatus("Select interior barlines to delete — the first and last can't be removed."); return; }
S.history.exec(new TempoGridCmd(beats.map(b => ({ ...b })), res.beats,
res.count > 1 ? 'delete-barlines' : 'delete'));
S.tempoSel = -1;
if (S.tempoSelMulti) S.tempoSelMulti.clear();
host.draw();
setStatus(res.count > 1 ? `Deleted ${res.count} barlines.` : 'Barline deleted.');
}

// ── Time signature ──────────────────────────────────────────────────
//
// Re-subdivide the measure starting at downbeat `d` to `newCount`
Expand Down Expand Up @@ -1242,6 +1377,9 @@ export class TempoGridCmd {
exec() {
S.beats = this.newBeats.map(b => ({ ...b }));
if (Number.isInteger(this.newSelection)) S.tempoSel = this.newSelection;
// Topology changed: barline indices shifted, so the multi-selection (PR
// 5a) can't be remapped safely — drop it rather than point at stale beats.
if (S.tempoSelMulti) S.tempoSelMulti.clear();
// Grid re-INDEXES (insert/delete sync-point, time-sig): note SECONDS
// stay put, but a note's beat coordinate changed (a beat was added /
// removed before it), so re-lift beats from the unchanged seconds
Expand All @@ -1257,6 +1395,7 @@ export class TempoGridCmd {
rollback() {
S.beats = this.oldBeats.map(b => ({ ...b }));
if (Number.isInteger(this.oldSelection)) S.tempoSel = this.oldSelection;
if (S.tempoSelMulti) S.tempoSelMulti.clear(); // topology reverted — drop the stale set
// Seconds are unchanged; re-lift beats back onto the old indexing.
_liftAllBeats(S.beats);
host.loopReliftBeats(S.beats);
Expand Down
Loading
Loading