diff --git a/CHANGELOG.md b/CHANGELOG.md
index 01536c9..995b17a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -23,6 +23,22 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
+- **Track regions — import a drum part into an existing project as a placed,
+ movable region.** The Add-Drums import (New Track ▸ Drums ▸ from a file, or
+ Track ▸ Drums…) now lands the imported part in the Tracks view **selected as
+ a region**, ready to drag — and a new **"Place at"** choice in the dialog
+ says where: **Keep source timing** (the default — the file's own timing, no
+ content motion), **Bar 1**, or **Playhead** (the whole part slides there as
+ one undoable step, snapped to the bar, beats preserved across tempo
+ changes). **Delete/Backspace** on a selected region block removes the block
+ and the notes it owns as one undoable step. Under the hood:
+ `PlaceRegionCmd` / `DeleteRegionCmd` (bounded window + verbatim-snapshot
+ undo, mirroring the move command) and a `placeImportedPartAsRegion`
+ orchestrator the import flows reach through the host table. Also fixes the region commands and the Tracks drag-arming for
+ **multi-drum songs**: a region on a drum part's track now always acts on
+ that part's own tab — never whichever part happens to be active in the drum
+ grid (dragging an extra part's block used to move nothing at all).
+
- **Multiple drum parts now work when creating a new song too.** The several-
drum-parts feature previously covered only songs you re-open and Save; a
*create-mode* session (New Song from a Guitar Pro / MIDI import) was capped at
diff --git a/screen.html b/screen.html
index a79df0d..cb5e744 100644
--- a/screen.html
+++ b/screen.html
@@ -953,6 +953,20 @@
Select Drum Track
+
+
+ Place at
+
+ Keep source timing
+ Bar 1
+ Playhead
+
+
Import Drum Track
diff --git a/src/arrangement.js b/src/arrangement.js
index 2d69211..27ab15e 100644
--- a/src/arrangement.js
+++ b/src/arrangement.js
@@ -12,7 +12,7 @@ import { _editorEscHtml, _editorPromptText, setStatus } from './ui.js';
import { flattenChords } from './chords.js';
import { KEYS_PATTERN } from './keys.js';
import { _arrTypeKind, _typeKind } from './instrument.js';
-import { addDrumArrangement, clampAwayFromDrums, findDrumArrangement, isDrumArrangement, pitchedArrangementCount, pitchedIndexOf, syncDrumArrangement } from './drum-arrangement.js';
+import { activeDrumArrangementIndex, addDrumArrangement, clampAwayFromDrums, findDrumArrangement, isDrumArrangement, pitchedArrangementCount, pitchedIndexOf, syncDrumArrangement } from './drum-arrangement.js';
import { _recState } from './midi-record.js';
import { _maybeOfferMidiTempoMap, _showDrumImportUnmappedModal } from './import.js';
import { host } from './host.js';
@@ -313,6 +313,14 @@ export function editorShowAddDrumsModal() {
: 'This song already has a drum track — importing will replace it.';
}
}
+ // "Place at": default to the file's own timing on every open. Hidden in
+ // create mode — a create session can hold several drum parts now (#343),
+ // but region PLACEMENT there stays deferred until the create build path
+ // proves it round-trips regions[]; the import still lands selected.
+ const placeRow = document.getElementById('editor-add-drums-place-row');
+ if (placeRow) placeRow.classList.toggle('hidden', !!S.createMode);
+ const placeSel = document.getElementById('editor-add-drums-place');
+ if (placeSel) placeSel.value = 'keep';
}
// Can a SECOND (third, …) drum part be added — i.e. does a drum import ADD
@@ -500,6 +508,20 @@ export async function editorDoAddDrums() {
// part only (a drums-only session must not put drums at index 0).
if (pitchedArrangementCount(S.arrangements) > 0) syncDrumArrangement(S);
+ // Land the fresh part in the Tracks view as a selected region — placed
+ // at bar 1 / the playhead when the dialog said so, else left at the
+ // source file's own timing (R3b import-into-existing). Create mode
+ // forces 'keep' (its Place-at row is hidden — placement is deferred
+ // until the create build path proves it round-trips regions[]), so
+ // there the import lands selected at source timing.
+ const placeSel = document.getElementById('editor-add-drums-place');
+ host.placeImportedPartAsRegion({
+ kind: 'drums',
+ arrIdx: activeDrumArrangementIndex(S.arrangements, tab),
+ placeAt: (placeSel && !S.createMode) ? placeSel.value : 'keep',
+ items: Array.isArray(tab.hits) ? tab.hits.slice() : [],
+ });
+
editorHideAddDrumsModal();
const hitCount = Array.isArray(data.drum_tab.hits)
? data.drum_tab.hits.length : 0;
diff --git a/src/host.js b/src/host.js
index daa66fa..b2d057c 100644
--- a/src/host.js
+++ b/src/host.js
@@ -224,6 +224,19 @@ export const host = {
partMixChanged: () => {},
/** The visible audio-source roster changed (remove/restore/import/rename). */
audioSourcesChanged: () => {},
+ /**
+ * Land a freshly-imported part in the Tracks view as a selected region,
+ * optionally placed at bar 1 / the playhead (R3b). Owned by
+ * src/track-session.js; the import flows (arrangement.js) reach it here
+ * because track-session imports arrangement — the usual seam inversion.
+ */
+ placeImportedPartAsRegion: () => false,
+ /**
+ * Del/Backspace in the Tracks view: delete the selected region block
+ * (content + window, one undoable step). Owned by src/parts-view.js;
+ * input.js's Delete ladder asks here. False = key not consumed.
+ */
+ partsViewRegionDelete: () => false,
stripUiChanged: () => {},
/** The persisted band-mode pref, read by the panel's header toggle. */
playAllTracksEnabled: () => false,
diff --git a/src/input.js b/src/input.js
index 3ee16da..a32911a 100644
--- a/src/input.js
+++ b/src/input.js
@@ -1769,6 +1769,17 @@ export function onKeyDown(e) {
if (S.partsViewMode
&& _editorFeedbackCommandForKeyPure(e, 'note') !== 'togglePartsView'
&& _editorEofCommandForKeyPure(e, 'note') !== 'togglePartsView') {
+ // The Tracks overview owns exactly one editing key: Delete/Backspace
+ // removes the SELECTED REGION BLOCK (its window AND the content it
+ // owns, one undoable step) — a region op on this surface, not a
+ // note-edit on the hidden chart. parts-view owns the resolution
+ // (which part, which kind) through the host table; false = no region
+ // selected, and the key stays ignored like everything else here.
+ if ((e.key === 'Delete' || e.key === 'Backspace')
+ && !e.target.matches('input, select, textarea')
+ && host.partsViewRegionDelete()) {
+ e.preventDefault();
+ }
return;
}
@@ -1889,6 +1900,8 @@ export function onKeyDown(e) {
}
if (e.key === 'Delete' || e.key === 'Backspace') {
+ // (Region-block delete lives in the partsViewMode gate above — this
+ // ladder is only reachable outside the Tracks overview.)
// 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)) &&
diff --git a/src/main.js b/src/main.js
index 9b63d79..28a792b 100644
--- a/src/main.js
+++ b/src/main.js
@@ -109,8 +109,8 @@ import {
editorShowNewTrackModal
} from './new-track.js';
import {
- _editorTogglePartsView, _partsViewDraw, _partsViewOnDblClick,
- _partsViewOnMouseDown, _partsViewRegionDrag, _partsViewRegionDrop, _refreshPartsViewButton
+ _editorTogglePartsView, _partsViewDraw, _partsViewOnDblClick, _partsViewOnMouseDown,
+ _partsViewRegionDelete, _partsViewRegionDrag, _partsViewRegionDrop, _refreshPartsViewButton
} from './parts-view.js';
import { drawWaveform } from './waveform.js';
import {
@@ -132,7 +132,7 @@ import { _tabViewHideIfShown, _tabViewPing, editorToggleTabView, teardownTabView
import { initToolbars } from './toolbars.js';
import { _applyToolCursor, editorToolPaletteClick } from './tools.js';
import { editorStartTour, editorTourEscape, editorTourSkip, _tourAdvance, _tourNoteAction } from './tour.js';
-import { _trackSessionTargetsPure, initTrackSession, installCreatedTrackSession, refreshTrackSession, scrollTrackSessionBy, trackSessionOrderedMixKeys } from './track-session.js';
+import { _trackSessionTargetsPure, initTrackSession, installCreatedTrackSession, placeImportedPartAsRegion, refreshTrackSession, scrollTrackSessionBy, trackSessionOrderedMixKeys } from './track-session.js';
import { editorDismissSignpost } from './signposts.js';
import { _editorSongFit } from './song-fit.js';
import { _transportBarTick, initTransportBar } from './transport-bar.js';
@@ -554,6 +554,7 @@ setHostHooks({
partsViewOnDblClick: (...a) => _partsViewOnDblClick(...a),
partsViewRegionDrag: (...a) => _partsViewRegionDrag(...a),
partsViewRegionDrop: (...a) => _partsViewRegionDrop(...a),
+ partsViewRegionDelete: () => _partsViewRegionDelete(),
resizeCanvas: (...a) => resizeCanvas(...a),
editorCycleViewMode: (...a) => _editorCycleViewMode(...a),
editorMovePart: (...a) => _editorMovePart(...a),
@@ -578,6 +579,9 @@ setHostHooks({
},
playAllTracksEnabled: () => editorPlayAllTracksEnabled(),
stripUiChanged: () => _mixerPanelRefresh(),
+ // Import-into-existing (R3b): the import flows land a fresh part in the
+ // Tracks view as a selected (optionally placed) region.
+ placeImportedPartAsRegion: (opts) => placeImportedPartAsRegion(opts),
// The stem-mixer capability signal: its PRESENCE flips stemMixerAvailable()
// true (lighting up Solo-my-source and the audio-row strips). Re-ramps the
// stem gains off S.partMix and repaints the surfaces.
diff --git a/src/parts-view.js b/src/parts-view.js
index 8d7c95d..3070ed0 100644
--- a/src/parts-view.js
+++ b/src/parts-view.js
@@ -11,7 +11,7 @@ import { DRUM_PIECE_META, _refreshDrumEditButton } from './drum.js';
import { beatOf, timeOf } from './beats.js';
import { LABEL_W, TIMELINE_TOP, timeToX, xToTime } from './geometry.js';
import { _regionBlockRectPure, _regionHitPure, _regionSnapStartPure, _regionTimeSpanPure, _trackRegionsResolvePure } from './region.js';
-import { MoveRegionCmd } from './region-commands.js';
+import { DeleteRegionCmd, MoveRegionCmd } from './region-commands.js';
import { isDrumArrangement } from './drum-arrangement.js';
import { arrKind } from './instrument.js';
import { _stringCountFor } from './lanes.js';
@@ -104,6 +104,9 @@ function _arrIndexForTarget(targetId) {
.find(item => item.id === targetId);
return target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1;
}
+function _isDrumRow(arrIdx, targetId) {
+ return (arrIdx >= 0 && isDrumArrangement(S.arrangements[arrIdx])) || targetId === 'drums';
+}
// An audio lane's waveform, when the host has one cached for the source
// (today: the master mix via S.waveformPeaks; stems light up with the
@@ -388,7 +391,7 @@ export function _partsViewOnMouseDown(e, x, y) {
host.selectTrackSessionSource(row.sourceId);
} else {
const idx = _arrIndexForTarget(row.targetId);
- if ((idx >= 0 && isDrumArrangement(S.arrangements[idx])) || row.targetId === 'drums') {
+ if (_isDrumRow(idx, row.targetId)) {
// A drum part (any of them) — arming is a no-op (currentArr never
// moves onto a drums arrangement); double-click opens its grid.
setStatus('Drum transcription selected — double-click to open the drum editor');
@@ -415,7 +418,11 @@ export function _partsViewOnMouseDown(e, x, y) {
trackId: row.id,
regionId: hitRegion,
region,
- kind: row.targetId === 'drums' ? 'drums' : 'notation',
+ // Any drum part's row (its own arrangement, or the legacy
+ // unmaterialized 'drums' target) moves DRUM content — the
+ // command resolves the part's own tab from arrIdx, so dragging
+ // a non-active part never shifts the active grid's hits.
+ kind: _isDrumRow(rArrIdx, row.targetId) ? 'drums' : 'notation',
arrIdx: rArrIdx,
origStart: span.t0,
spanW: Math.max(0, span.t1 - span.t0),
@@ -458,6 +465,28 @@ export function _partsViewRegionDrop() {
setStatus(`Moved “${d.region.name || d.region.id}” ${dBeat > 0 ? 'later' : 'earlier'}`);
}
+// Delete the selected region block — the Del/Backspace surface for the Tracks
+// view (input.js routes here through the host table). Removes the window AND
+// the content it owns as one undoable DeleteRegionCmd. Transcription rows only
+// — an audio region carries a two-clock media pointer and stays select-only
+// until the audio-region PR. Same kind/arrIdx resolution as the drag arming,
+// so a drum part's region always deletes ITS OWN hits. Returns true when it
+// consumed the key (a caller must then preventDefault).
+export function _partsViewRegionDelete() {
+ if (!S.partsViewMode || !S.selectedTrackId || !S.selectedRegionId || !S.history) return false;
+ const row = _unifiedRows().find(r => r.id === S.selectedTrackId);
+ if (!row || row.type !== 'transcription') return false;
+ const region = _trackRegionsResolvePure(row.regions).find(r => r.id === S.selectedRegionId);
+ if (!region) return false;
+ const arrIdx = _arrIndexForTarget(row.targetId);
+ const kind = _isDrumRow(arrIdx, row.targetId) ? 'drums' : 'notation';
+ S.history.exec(new DeleteRegionCmd({ kind, arrIdx, trackId: row.id, region }));
+ host.draw();
+ host.updateStatus();
+ setStatus(`Deleted region “${region.name || region.id}” and its notes — Undo restores it`);
+ return true;
+}
+
export function _partsViewOnDblClick(e) {
const { x, y } = getMousePos(e);
_partsViewOnMouseDown(e, x, y);
diff --git a/src/region-commands.js b/src/region-commands.js
index f4e480b..d76fdea 100644
--- a/src/region-commands.js
+++ b/src/region-commands.js
@@ -3,9 +3,9 @@
//
// A region is a WINDOW over content, never a copy (src/region.js). "Move
// region" therefore shifts the CONTENT the window covers — a notation region
-// shifts the arrangement's contained notes, a drum region shifts S.drumTab's
-// contained hits — as one undoable edit, with the window's own placement riding
-// along for a BOUNDED region. It goes through S.history.exec (NOT the
+// shifts the arrangement's contained notes, a drum region shifts its own
+// part's tab hits (see _drumTabFor) — as one undoable edit, with the window's
+// own placement riding along for a BOUNDED region. It goes through S.history.exec (NOT the
// track-session commit() path): commit() only marks the session dirty, so an
// in-place time shift would leave the coverage/chord/lint memos stale — exec()
// bumps editGen, which is what forces them to recompute.
@@ -22,15 +22,62 @@
// ════════════════════════════════════════════════════════════════════
import { beatOf, timeOf } from './beats.js';
import {
- _regionContainsBeatPure, _regionRemapPure, _trackRegionsNormalizePure, _trackRegionsResolvePure,
+ DEFAULT_REGION_ID, _nextRegionIdPure, _regionContainsBeatPure, _regionRemapPure,
+ _trackRegionsNormalizePure, _trackRegionsResolvePure,
} from './region.js';
import { S } from './state.js';
+// A placed region's window must strictly CONTAIN the last onset it owns
+// (membership is [startBeat, startBeat+lenBeat) — end-exclusive). When every
+// contained note has zero sustain the content end equals the last onset, so the
+// naive span would put that onset exactly on the excluded edge; pad the length
+// by this sub-cent-of-a-beat guard so the region keeps owning its own notes.
+const REGION_LEN_GUARD = 1e-4;
+
+// ── Content/track access shared by all region commands ────────────────
+// A song can hold SEVERAL drum parts (each type:"drums" arrangement OWNS its
+// `.drumTab`; `S.drumTab` is only the ACTIVE grid target) — so a drums region
+// command must act on the tab of the part whose TRACK carries the region, not
+// whichever part happens to be active. `arrIdx >= 0` names that part;
+// `arrIdx < 0` is the legacy unmaterialized tab (create-mode compose), which
+// IS `S.drumTab`. Same resolution the lane silhouette paints by.
+function _drumTabFor(arrIdx) {
+ const arr = Number.isInteger(arrIdx) && arrIdx >= 0 && S.arrangements ? S.arrangements[arrIdx] : null;
+ return (arr && arr.drumTab) || S.drumTab;
+}
+function _contentList(kind, arrIdx) {
+ if (kind === 'drums') {
+ const tab = _drumTabFor(arrIdx);
+ return (tab && Array.isArray(tab.hits)) ? tab.hits : null;
+ }
+ const arr = S.arrangements && S.arrangements[arrIdx];
+ return arr && Array.isArray(arr.notes) ? arr.notes : null;
+}
+function _timeOfItem(kind, item) { return kind === 'drums' ? item.t : item.time; }
+function _findTrack(trackId) {
+ const tracks = S.trackSession && Array.isArray(S.trackSession.tracks) ? S.trackSession.tracks : null;
+ return tracks ? tracks.find(t => t && t.id === trackId) : null;
+}
+// Snapshot a track's raw `regions` (absent or an array) so rollback restores it
+// EXACTLY — including deleting a key that was never there.
+function _snapRegions(track) {
+ return {
+ taken: true,
+ hadKey: Object.prototype.hasOwnProperty.call(track, 'regions'),
+ value: track.regions,
+ };
+}
+function _restoreRegions(track, snap) {
+ if (!snap.taken || !track) return;
+ if (snap.hadKey) track.regions = snap.value; else delete track.regions;
+}
+
export class MoveRegionCmd {
- // `kind`: 'notation' shifts S.arrangements[arrIdx].notes; 'drums' shifts
- // S.drumTab.hits. `region` is the resolved region object being moved (its id
- // locates it in the track's regions[]); `dBeat` is the snapped bar/beat
- // distance of the drag.
+ // `kind`: 'notation' shifts S.arrangements[arrIdx].notes; 'drums' shifts the
+ // hits of the drum part `arrIdx` names (its own `.drumTab`; arrIdx < 0 = the
+ // legacy unmaterialized S.drumTab — see _drumTabFor). `region` is the
+ // resolved region object being moved (its id locates it in the track's
+ // regions[]); `dBeat` is the snapped bar/beat distance of the drag.
constructor({ kind, arrIdx, trackId, region, dBeat }) {
this.kind = kind;
this.arrIdx = arrIdx;
@@ -58,13 +105,7 @@ export class MoveRegionCmd {
return r.lenBeat != null || (Number(r.startBeat) || 0) > 0 || r.srcIn != null;
}
- _list() {
- if (this.kind === 'drums') {
- return (S.drumTab && Array.isArray(S.drumTab.hits)) ? S.drumTab.hits : null;
- }
- const arr = S.arrangements && S.arrangements[this.arrIdx];
- return arr && Array.isArray(arr.notes) ? arr.notes : null;
- }
+ _list() { return _contentList(this.kind, this.arrIdx); }
_timeOf(item) { return this.kind === 'drums' ? item.t : item.time; }
@@ -155,3 +196,181 @@ export class MoveRegionCmd {
else delete track.regions;
}
}
+
+// ════════════════════════════════════════════════════════════════════
+// PlaceRegionCmd — drop freshly-added track content onto the timeline as a
+// BOUNDED, selectable, draggable block (the "Add Track from File" driver).
+//
+// The import adopts the new part at its source timing (typically beat 0); this
+// command then, as ONE undoable edit: (1) slides all of that content so its
+// first onset lands on `startBeat` (a MUSICAL move — mirrors MoveRegionCmd, so
+// a varying grid preserves beats, not seconds), and (2) writes a bounded region
+// covering it onto the track and selects it. Placing at bar 1 (startBeat 0)
+// still yields a bounded region (explicit lenBeat) — never the implicit default
+// — so the block is a distinct, addressable object you can drag/delete, unlike
+// a whole-track default slide.
+//
+// Undo restores a verbatim content snapshot (the beat round trip isn't bit-
+// reversible), the track's raw `regions` (incl. deleting a key that wasn't
+// there), and the prior selection. Browser surface: NONE (node-runnable).
+// ════════════════════════════════════════════════════════════════════
+export class PlaceRegionCmd {
+ // `kind`: 'notation' places S.arrangements[arrIdx].notes; 'drums' places
+ // S.drumTab.hits. `startBeat` is the snapped bar/beat the block lands on.
+ // `regionId`/`name` are optional (id defaults to the track's next free one).
+ constructor({ kind, arrIdx, trackId, startBeat, regionId, name, items } = {}) {
+ this.kind = kind;
+ this.arrIdx = arrIdx;
+ this.trackId = trackId;
+ this.startBeat = Math.max(0, Number(startBeat) || 0);
+ this.regionId = (typeof regionId === 'string' && regionId.trim()) ? regionId.trim() : null;
+ this.name = (typeof name === 'string' && name.trim()) ? name.trim().slice(0, 120) : null;
+ this.items = Array.isArray(items) ? items.slice() : null;
+ // A placement changes only WHEN content plays, never pitch — passes the
+ // read-only-roll lock like MoveRegionCmd. Drum content is song-level.
+ this.pitchPreserving = true;
+ this.songScope = kind === 'drums';
+ this._before = null; // ref-order snapshot of the content array
+ this._snap = null; // [{ item, time, sustain }] verbatim pre-move
+ this._regionBefore = { taken: false, hadKey: false, value: undefined };
+ this._sel = { taken: false, track: '', region: '' };
+ this._placedId = '';
+ }
+
+ exec() {
+ const track = _findTrack(this.trackId);
+ if (!track) return;
+ const list = _contentList(this.kind, this.arrIdx);
+ if (!list || !list.length) return; // nothing to place
+ const itemSet = this.items ? new Set(this.items) : null;
+ const targets = itemSet
+ ? list.filter(it => itemSet.has(it))
+ : list;
+ if (!targets.length) return;
+ this._before = list.slice();
+ const isDrums = this.kind === 'drums';
+ const times = targets.map(it => Number(_timeOfItem(this.kind, it)) || 0);
+ const sustains = targets.map(it => (isDrums ? 0 : Math.max(0, Number(it.sustain) || 0)));
+ // Beat extent of the content as imported (independent of the shift — a
+ // musical move preserves beat span, so lenBeat is computed once here).
+ let minBeat = Infinity; let maxOnset = -Infinity; let maxEnd = -Infinity;
+ for (let i = 0; i < times.length; i++) {
+ const ob = beatOf(S.beats, times[i]);
+ const eb = sustains[i] > 0 ? beatOf(S.beats, times[i] + sustains[i]) : ob;
+ if (ob < minBeat) minBeat = ob;
+ if (ob > maxOnset) maxOnset = ob;
+ if (eb > maxEnd) maxEnd = eb;
+ }
+ if (!Number.isFinite(minBeat)) return;
+ const dBeat = this.startBeat - minBeat;
+ const onsetSpan = Math.max(0, maxOnset - minBeat);
+ let lenBeat = maxEnd - minBeat;
+ if (!(lenBeat > onsetSpan)) lenBeat = onsetSpan + REGION_LEN_GUARD;
+ const existing = _trackRegionsNormalizePure(track.regions);
+ const existingIds = new Set(existing.map(region => region.id));
+ existingIds.add(DEFAULT_REGION_ID);
+ this._placedId = this.regionId && !existingIds.has(this.regionId)
+ ? this.regionId
+ : _nextRegionIdPure(existing);
+ const region = { id: this._placedId, startBeat: this.startBeat, lenBeat };
+ if (this.name) region.name = this.name;
+ const nextRegions = _trackRegionsNormalizePure([...existing, region]);
+ if (!nextRegions.some(item => item.id === this._placedId)) return;
+
+ this._snap = targets.map((it, i) => ({ item: it, time: times[i], sustain: sustains[i] }));
+ // Slide the content (skip the beat round trip when it wouldn't move —
+ // routing dBeat 0 through beats perturbs every note by an epsilon).
+ if (dBeat !== 0) {
+ const { times: nt, sustains: ns } = _regionRemapPure(times, sustains, dBeat, S.beats, beatOf, timeOf);
+ if (isDrums) {
+ targets.forEach((h, i) => { h.t = nt[i]; });
+ list.sort((a, b) => (a.t || 0) - (b.t || 0));
+ } else {
+ targets.forEach((n, i) => { n.time = nt[i]; n.sustain = ns[i]; });
+ list.sort((a, b) => (a.time || 0) - (b.time || 0));
+ }
+ }
+ if (isDrums) S.drumTabDirty = true;
+ // The bounded window covering the placed content (guarded so a zero-
+ // sustain tail onset stays strictly inside — see REGION_LEN_GUARD).
+ this._regionBefore = _snapRegions(track);
+ track.regions = nextRegions;
+ // Land selected — a placed region arrives ready to drag (design R3).
+ this._sel = { taken: true, track: S.selectedTrackId, region: S.selectedRegionId };
+ S.selectedTrackId = this.trackId;
+ S.selectedRegionId = this._placedId;
+ }
+
+ rollback() {
+ const list = _contentList(this.kind, this.arrIdx);
+ if (list && this._snap) {
+ if (this.kind === 'drums') {
+ for (const s of this._snap) s.item.t = s.time;
+ S.drumTabDirty = true;
+ } else {
+ for (const s of this._snap) { s.item.time = s.time; s.item.sustain = s.sustain; }
+ }
+ list.length = 0;
+ for (const it of this._before) list.push(it);
+ }
+ _restoreRegions(_findTrack(this.trackId), this._regionBefore);
+ if (this._sel.taken) { S.selectedTrackId = this._sel.track; S.selectedRegionId = this._sel.region; }
+ }
+}
+
+// ════════════════════════════════════════════════════════════════════
+// DeleteRegionCmd — remove a region block: the content its window owns AND the
+// region entry, as one undoable edit. Membership is by beat through the single
+// tempo map (the same predicate the move/place commands use), so it deletes
+// exactly the notes/hits under the block and leaves neighbours untouched.
+// Rollback re-adds the removed items in their original array order and restores
+// the track's raw `regions` and the prior selection. Browser surface: NONE.
+// ════════════════════════════════════════════════════════════════════
+export class DeleteRegionCmd {
+ constructor({ kind, arrIdx, trackId, region } = {}) {
+ this.kind = kind;
+ this.arrIdx = arrIdx;
+ this.trackId = trackId;
+ this.region = region || {};
+ // Removing a block changes no surviving note's pitch; song-level for drums.
+ this.pitchPreserving = true;
+ this.songScope = kind === 'drums';
+ this._before = null;
+ this._regionBefore = { taken: false, hadKey: false, value: undefined };
+ this._sel = { taken: false, track: '', region: '' };
+ }
+
+ exec() {
+ const regionId = typeof this.region.id === 'string' ? this.region.id.trim() : '';
+ if (!regionId || regionId.length > 160) return;
+ const track = _findTrack(this.trackId);
+ if (!track) return;
+ const list = _contentList(this.kind, this.arrIdx);
+ if (!list) return;
+ this._before = list.slice();
+ const keep = [];
+ for (const it of list) {
+ const beat = beatOf(S.beats, Number(_timeOfItem(this.kind, it)) || 0);
+ if (!_regionContainsBeatPure(this.region, beat)) keep.push(it);
+ }
+ list.length = 0;
+ for (const it of keep) list.push(it);
+ if (this.kind === 'drums') S.drumTabDirty = true;
+ this._regionBefore = _snapRegions(track);
+ const remaining = _trackRegionsNormalizePure(track.regions).filter(r => r.id !== regionId);
+ track.regions = _trackRegionsNormalizePure(remaining);
+ this._sel = { taken: true, track: S.selectedTrackId, region: S.selectedRegionId };
+ if (S.selectedRegionId === this.region.id) S.selectedRegionId = '';
+ }
+
+ rollback() {
+ const list = _contentList(this.kind, this.arrIdx);
+ if (list && this._before) {
+ list.length = 0;
+ for (const it of this._before) list.push(it);
+ if (this.kind === 'drums') S.drumTabDirty = true;
+ }
+ _restoreRegions(_findTrack(this.trackId), this._regionBefore);
+ if (this._sel.taken) { S.selectedTrackId = this._sel.track; S.selectedRegionId = this._sel.region; }
+ }
+}
diff --git a/src/region.js b/src/region.js
index 6b03361..5335c98 100644
--- a/src/region.js
+++ b/src/region.js
@@ -108,6 +108,20 @@ export function _trackRegionsResolvePure(raw) {
return norm.length ? norm : [_defaultRegionPure()];
}
+// The next free `region:N` id for a track, given its persisted regions[] (or
+// absent). N is one past the highest numeric suffix in use, and the implicit
+// default (DEFAULT_REGION_ID = `region:1`) is always counted even when it isn't
+// persisted — so a placed region never collides with it. A fresh track (no
+// regions) yields `region:2`. Non-numeric ids don't participate in the count.
+export function _nextRegionIdPure(raw) {
+ let max = 1; // DEFAULT_REGION_ID occupies region:1
+ for (const region of _trackRegionsNormalizePure(raw)) {
+ const m = /^region:(\d+)$/.exec(region.id);
+ if (m) { const n = Number(m[1]); if (n > max) max = n; }
+ }
+ return 'region:' + (max + 1);
+}
+
// Membership predicate: does a beat fall inside the region's window? startBeat
// inclusive, startBeat+lenBeat exclusive; lenBeat null = open to the end of
// content. This is the primitive a later move/trim command uses to select "the
@@ -236,3 +250,20 @@ export function _regionSnapStartPure(downbeats, rawStart, free) {
}
return Math.max(0, Number(best) || 0);
}
+
+// The startBeat an import dialog's "Place at" choice resolves to, or null for
+// "keep source timing" (no placement — the content stays where the source put
+// it). 'bar1' = the first downbeat; 'playhead' = the cursor snapped to the
+// nearest bar (the region-snap default). Downbeats are derived here from the
+// grid itself (`measure > 0` marks a bar start) so the pure stays converter-
+// free; `beatOf` is passed in like the other pures. A gridless chart resolves
+// through beatOf's own degenerate (seconds-primary) handling.
+export function _placeAtStartBeatPure(placeAt, beats, cursorTime, beatOf) {
+ if (placeAt !== 'bar1' && placeAt !== 'playhead') return null;
+ const downbeats = (Array.isArray(beats) ? beats : [])
+ .filter(b => b && b.measure > 0).map(b => b.time).sort((a, b) => a - b);
+ const t = placeAt === 'bar1'
+ ? (downbeats.length ? downbeats[0] : 0)
+ : _regionSnapStartPure(downbeats, cursorTime, false);
+ return Math.max(0, beatOf(beats, t));
+}
diff --git a/src/track-session.js b/src/track-session.js
index 1615761..73f22de 100644
--- a/src/track-session.js
+++ b/src/track-session.js
@@ -36,7 +36,9 @@ import { drumArrangementIndex, findDrumArrangement, isDrumArrangement, pitchedAr
import { _partViewKeyPure } from './keys.js';
import { arrKind, _arrTypeKind } from './instrument.js';
import { _mixerPanelRefresh, _mixerPartStatePure, mixerSetPart, mixerTogglePart } from './mixer-panel.js';
-import { _regionsAreDefaultPure, _trackRegionsNormalizePure } from './region.js';
+import { beatOf } from './beats.js';
+import { DEFAULT_REGION_ID, _placeAtStartBeatPure, _regionsAreDefaultPure, _trackRegionsNormalizePure } from './region.js';
+import { PlaceRegionCmd } from './region-commands.js';
import { S, markSessionDirty } from './state.js';
import { _editorEscHtml, setStatus } from './ui.js';
@@ -771,6 +773,39 @@ export function refreshTrackSessionSelection() {
lastRender = '';
refreshTrackSession();
}
+
+// ── Import-into-existing: land a fresh part as a region (R3b) ─────────
+// After an import adopts a new part (a drum tab today; the arrangement paths
+// can reuse the same seam), surface it in the Tracks view as a SELECTED region
+// — optionally PLACING it (content slid so its first onset lands on the
+// resolved startBeat, a bounded window written, all one undoable edit) when
+// the dialog said "Bar 1" / "Playhead". `placeAt` ∈ {'keep','bar1','playhead'};
+// keep = no content motion and no persisted window — the implicit default
+// full-span region is already selectable + draggable, so selection is all it
+// needs. Reached through host.placeImportedPartAsRegion (arrangement.js sits
+// below this module in the import graph — the usual host-table seam).
+export function placeImportedPartAsRegion({ kind, arrIdx, placeAt = 'keep', items } = {}) {
+ if (!Number.isInteger(arrIdx) || arrIdx < -1) return false;
+ if (arrIdx === -1 && kind !== 'drums') return false;
+ // The fresh part's row must exist before a region/selection can land on it.
+ S.trackSession = _trackSessionNormalizePure(S.trackSession, _liveSources(), S.arrangements, S.drumTab);
+ const targetMixKey = arrIdx === -1 ? 'drums' : 'arr:' + arrIdx;
+ const target = _trackSessionTargetsPure(S.arrangements, S.drumTab)
+ .find(t => t.mixKey === targetMixKey);
+ if (!target) return false;
+ const trackId = transcriptionTrackId(target.id);
+ const startBeat = _placeAtStartBeatPure(placeAt, S.beats, S.cursorTime || 0, beatOf);
+ if (startBeat != null && S.history) {
+ S.history.exec(new PlaceRegionCmd({ kind, arrIdx, trackId, startBeat, items }));
+ } else {
+ S.selectedTrackId = trackId;
+ S.selectedRegionId = DEFAULT_REGION_ID;
+ }
+ lastRender = '';
+ refreshTrackSession();
+ host.draw();
+ return true;
+}
function refreshTrackSelectionClass() {
const el = panel();
if (!el) return;
diff --git a/tests/region_place_delete.test.mjs b/tests/region_place_delete.test.mjs
new file mode 100644
index 0000000..ca7d615
--- /dev/null
+++ b/tests/region_place_delete.test.mjs
@@ -0,0 +1,522 @@
+/*
+ * Region PLACE + DELETE (PR 3 / R3b): the "Add Track from File" driver's
+ * command layer — drop imported content onto the timeline as a bounded,
+ * selectable, draggable block, and remove a block (content + window) as one
+ * undoable edit.
+ *
+ * Pinned here:
+ * - PlaceRegionCmd slides content so its first onset lands on `startBeat`
+ * (a MUSICAL move — beats preserved on a varying grid), writes a BOUNDED
+ * region covering it (explicit lenBeat, never the implicit default — even
+ * at bar 1 / startBeat 0), and lands it SELECTED.
+ * - the placed region is a real draggable block: a subsequent MoveRegionCmd
+ * rides its window and shifts only its notes.
+ * - DeleteRegionCmd removes exactly the notes/hits its window owns (membership
+ * by beat) and drops the region entry, leaving neighbours untouched.
+ * - round-trips: exec → rollback restores the model EXACTLY (deep-equal notes/
+ * hits AND regions[] AND selection), and redo reproduces the post-exec state
+ * — for a notation arrangement AND for the drum tab, incl. through the real
+ * S.history.exec path.
+ * - _nextRegionIdPure never collides with the implicit default region:1.
+ *
+ * This suite fails on main: PlaceRegionCmd / DeleteRegionCmd / _nextRegionIdPure
+ * do not exist there.
+ *
+ * Run: node tests/region_place_delete.test.mjs
+ */
+import assert from 'node:assert';
+
+import { seedState, trackHooks } from './_history_env.mjs';
+import { beatOf } from '../src/beats.js';
+import { DEFAULT_REGION_ID, _nextRegionIdPure, _placeAtStartBeatPure, _regionsAreDefaultPure } from '../src/region.js';
+import { DeleteRegionCmd, MoveRegionCmd, PlaceRegionCmd } from '../src/region-commands.js';
+import { EditHistory } from '../src/history.js';
+import { S } from '../src/state.js';
+import { placeImportedPartAsRegion } from '../src/track-session.js';
+
+let pass = 0; let fail = 0;
+const tests = [];
+const t = (name, fn) => tests.push([name, fn]);
+const clone = (v) => JSON.parse(JSON.stringify(v));
+
+// Constant 120 BPM (0.5 s/beat), 4 beats/bar.
+function constGrid() {
+ const b = [];
+ for (let i = 0; i < 13; i++) b.push({ time: i * 0.5, measure: i % 4 === 0 ? i / 4 + 1 : 0 });
+ return b;
+}
+// Varying: beats 0..4 @ 0.5 s/beat, beats 4..12 @ 1.0 s/beat.
+function varyGrid() {
+ const b = [];
+ for (let i = 0; i <= 4; i++) b.push({ time: i * 0.5, measure: i % 4 === 0 ? i / 4 + 1 : 0 });
+ for (let i = 5; i <= 12; i++) b.push({ time: 2.0 + (i - 4) * 1.0, measure: i % 4 === 0 ? i / 4 + 1 : 0 });
+ return b;
+}
+const note = (time, sustain = 0, string = 0, fret = 0) => ({ time, sustain, string, fret, techniques: {} });
+
+function seedNotation({ beats = constGrid(), notes, regions } = {}) {
+ const arr = { name: 'Lead', notes };
+ const trackSession = {
+ version: 3, tracks: [
+ { id: 'transcription:Lead', type: 'transcription', targetId: 'Lead', ...(regions ? { regions } : {}) },
+ ], removedSourceIds: [], tempoGuideSourceId: '', tempoGuideLocked: false, tempoGuideMode: 'audio',
+ };
+ seedState({ arrangements: [arr], currentArr: 0, beats, drumTab: null, trackSession,
+ selectedTrackId: '', selectedRegionId: '' });
+ S.history = new EditHistory();
+ trackHooks();
+ return arr;
+}
+
+function seedDrums({ beats = constGrid(), hits } = {}) {
+ const drumTab = { version: 1, name: 'Drums', kit: [], hits };
+ seedState({ arrangements: [], currentArr: 0, beats, drumTab,
+ trackSession: { version: 3, tracks: [{ id: 'transcription:drums', type: 'transcription', targetId: 'drums' }],
+ removedSourceIds: [], tempoGuideSourceId: '', tempoGuideLocked: false, tempoGuideMode: 'audio' },
+ selectedTrackId: '', selectedRegionId: '' });
+ S.history = new EditHistory();
+ trackHooks();
+ S.drumTabDirty = false;
+ return drumTab;
+}
+
+// ── _nextRegionIdPure ─────────────────────────────────────────────────
+t('_nextRegionIdPure: fresh track → region:2; never collides with the default', () => {
+ assert.strictEqual(_nextRegionIdPure(undefined), 'region:2', 'no regions → region:2 (past the implicit region:1)');
+ assert.strictEqual(_nextRegionIdPure([]), 'region:2');
+ assert.strictEqual(_nextRegionIdPure([{ id: 'region:1', startBeat: 0, lenBeat: null }]), 'region:2', 'default counted');
+ assert.strictEqual(_nextRegionIdPure([{ id: 'region:2', startBeat: 4, lenBeat: 4 }]), 'region:3');
+ assert.strictEqual(_nextRegionIdPure([{ id: 'region:5', startBeat: 8, lenBeat: 4 }]), 'region:6', 'one past the max');
+ assert.strictEqual(_nextRegionIdPure([{ id: 'weird', startBeat: 0, lenBeat: 2 }]), 'region:2', 'non-numeric ignored');
+});
+
+// ── PlaceRegionCmd: notation ──────────────────────────────────────────
+t('place: slides content to startBeat, writes a bounded region, lands selected; round-trips', () => {
+ // Notes at beats 1, 2, 3 (last sustains 1 beat → content ends at beat 4).
+ const arr = seedNotation({ notes: [note(0.5), note(1.0), note(1.5, 0.5)] });
+ const track = S.trackSession.tracks[0];
+ const before = clone(arr.notes);
+ const cmd = new PlaceRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', startBeat: 4 });
+ cmd.exec();
+ // minBeat 1 → startBeat 4 is dBeat +3 = +1.5 s @120.
+ assert.deepStrictEqual(arr.notes.map(n => n.time), [2.0, 2.5, 3.0], 'onsets rode +3 beats to bar 2');
+ assert.strictEqual(arr.notes[2].sustain, 0.5, 'constant-tempo shift keeps the sustain');
+ assert.deepStrictEqual(track.regions, [{ id: 'region:2', startBeat: 4, lenBeat: 3 }], 'bounded region covers the block');
+ assert.strictEqual(S.selectedTrackId, 'transcription:Lead', 'track selected');
+ assert.strictEqual(S.selectedRegionId, 'region:2', 'placed region selected');
+ const after = clone(arr.notes);
+ cmd.rollback();
+ assert.deepStrictEqual(arr.notes, before, 'rollback restores notes EXACTLY');
+ assert.ok(!('regions' in track), 'rollback removes the regions key that was never there');
+ assert.strictEqual(S.selectedRegionId, '', 'prior (empty) selection restored');
+ cmd.exec();
+ assert.deepStrictEqual(arr.notes, after, 'redo reproduces the placed notes');
+ assert.deepStrictEqual(track.regions, [{ id: 'region:2', startBeat: 4, lenBeat: 3 }], 'redo reproduces the region');
+});
+
+t('place at bar 1 (startBeat 0) is still BOUNDED, never the implicit default', () => {
+ const arr = seedNotation({ notes: [note(1.0), note(1.5)] }); // beats 2, 3
+ const track = S.trackSession.tracks[0];
+ new PlaceRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', startBeat: 0 }).exec();
+ assert.deepStrictEqual(arr.notes.map(n => n.time), [0.0, 0.5], 'content pulled to beat 0');
+ assert.strictEqual(track.regions.length, 1, 'one region');
+ assert.ok(track.regions[0].lenBeat > 0, 'explicit length → a real bounded block');
+ assert.strictEqual(_regionsAreDefaultPure(track.regions), false, 'NOT the implicit default region');
+});
+
+t('place: zero-sustain tail onset stays strictly inside the region window', () => {
+ const arr = seedNotation({ notes: [note(0.5), note(1.0)] }); // beats 1, 2, both sustain 0
+ const track = S.trackSession.tracks[0];
+ new PlaceRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', startBeat: 0 }).exec();
+ const r = track.regions[0];
+ const lastBeat = beatOf(S.beats, arr.notes[arr.notes.length - 1].time);
+ assert.ok(lastBeat < r.startBeat + r.lenBeat, 'end-exclusive window still owns its last onset');
+});
+
+t('place preserves musical position on a VARYING grid (beats, not seconds)', () => {
+ const arr = seedNotation({ beats: varyGrid(), notes: [note(0.5), note(1.5, 0.5)] }); // beats 1, 3
+ new PlaceRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', startBeat: 5 }).exec();
+ // minBeat 1 → startBeat 5 = +4 beats. Onsets should land on beats 5 and 7.
+ const beats = S.beats;
+ assert.ok(Math.abs(beatOf(beats, arr.notes[0].time) - 5) < 1e-9, 'first onset rode to beat 5');
+ assert.ok(Math.abs(beatOf(beats, arr.notes[1].time) - 7) < 1e-9, 'second onset rode to beat 7');
+});
+
+t('a PLACED region is a real draggable block: MoveRegionCmd rides it', () => {
+ const arr = seedNotation({ notes: [note(0.5), note(1.0, 0.5)] }); // beats 1, 2
+ const track = S.trackSession.tracks[0];
+ S.history.exec(new PlaceRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', startBeat: 4 }));
+ const placed = clone(track.regions[0]);
+ const notesAfterPlace = clone(arr.notes);
+ S.history.exec(new MoveRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', region: track.regions[0], dBeat: 4 }));
+ assert.strictEqual(track.regions[0].startBeat, placed.startBeat + 4, 'the placed window rode the drag +4 beats');
+ S.history.doUndo(); // undo the move
+ assert.deepStrictEqual(arr.notes, notesAfterPlace, 'undo restores the placed notes');
+ assert.deepStrictEqual(track.regions[0], placed, 'undo restores the placed window');
+});
+
+// ── PlaceRegionCmd: drums ─────────────────────────────────────────────
+t('place drums: hits slide, a region lands on the drum track, tab dirtied; round-trips', () => {
+ const drumTab = seedDrums({ hits: [{ t: 0.5, p: 36 }, { t: 1.0, p: 38 }, { t: 1.5, p: 42 }] }); // beats 1,2,3
+ const track = S.trackSession.tracks[0];
+ const before = clone(drumTab.hits);
+ const cmd = new PlaceRegionCmd({ kind: 'drums', trackId: 'transcription:drums', startBeat: 4 });
+ cmd.exec();
+ assert.deepStrictEqual(drumTab.hits.map(h => h.t), [2.0, 2.5, 3.0], 'hits rode +3 beats');
+ assert.strictEqual(S.drumTabDirty, true, 'placing dirties the tab');
+ assert.strictEqual(track.regions.length, 1, 'a region landed on the drum track');
+ assert.strictEqual(S.selectedRegionId, track.regions[0].id, 'placed drum region selected');
+ cmd.rollback();
+ assert.deepStrictEqual(drumTab.hits, before, 'rollback restores hits EXACTLY');
+ assert.ok(!('regions' in track), 'and removes the region key');
+});
+t('place scopes motion and bounds to imported items on a populated drum track', () => {
+ const existing = { t: 0.25, p: 36 };
+ const imported = [{ t: 1.0, p: 38 }, { t: 1.5, p: 42 }];
+ const drumTab = seedDrums({ hits: [existing, ...imported] });
+ const cmd = new PlaceRegionCmd({
+ kind: 'drums', trackId: 'transcription:drums', startBeat: 4, items: imported,
+ });
+ cmd.exec();
+ assert.strictEqual(existing.t, 0.25, 'pre-existing hit stays put');
+ assert.deepStrictEqual(imported.map(hit => hit.t), [2.0, 2.5], 'only imported hits move');
+ assert.deepStrictEqual(S.trackSession.tracks[0].regions,
+ [{ id: 'region:2', startBeat: 4, lenBeat: 1.0001 }], 'bounds cover only imported hits');
+ cmd.rollback();
+ assert.deepStrictEqual(drumTab.hits.map(hit => hit.t), [0.25, 1.0, 1.5], 'rollback is exact');
+});
+
+t('place is atomic for a missing track and avoids an explicit id collision', () => {
+ const arr = seedNotation({
+ notes: [note(0.5)],
+ regions: [{ id: 'taken', startBeat: 0, lenBeat: 1 }],
+ });
+ const before = clone(arr.notes);
+ S.selectedTrackId = 'before';
+ S.selectedRegionId = 'before-region';
+ new PlaceRegionCmd({
+ kind: 'notation', arrIdx: 0, trackId: 'missing', startBeat: 4, regionId: 'new',
+ }).exec();
+ assert.deepStrictEqual(arr.notes, before, 'missing track leaves content untouched');
+ assert.strictEqual(S.selectedRegionId, 'before-region', 'missing track leaves selection untouched');
+
+ new PlaceRegionCmd({
+ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', startBeat: 4, regionId: 'taken',
+ }).exec();
+ const ids = S.trackSession.tracks[0].regions.map(region => region.id);
+ assert.deepStrictEqual(ids.sort(), ['region:2', 'taken'], 'collision falls back to a free generated id');
+ assert.strictEqual(S.selectedRegionId, 'region:2', 'selection names the inserted region');
+});
+
+
+// ── DeleteRegionCmd: notation ─────────────────────────────────────────
+t('delete: removes only the block’s notes + its entry; leaves neighbours; round-trips', () => {
+ // A owns beat 1; B owns beats 4, 5.
+ const regions = [{ id: 'A', startBeat: 0, lenBeat: 4 }, { id: 'B', startBeat: 4, lenBeat: 4 }];
+ const arr = seedNotation({ notes: [note(0.5), note(2.0, 0.25), note(2.5)], regions }); // beats 1, 4, 5
+ const track = S.trackSession.tracks[0];
+ S.selectedTrackId = 'transcription:Lead';
+ S.selectedRegionId = 'B';
+ const notesBefore = clone(arr.notes);
+ const regionsBefore = clone(track.regions);
+ const cmd = new DeleteRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', region: { id: 'B', startBeat: 4, lenBeat: 4 } });
+ cmd.exec();
+ assert.deepStrictEqual(arr.notes.map(n => n.time), [0.5], 'only A’s note survives');
+ assert.deepStrictEqual(track.regions, [{ id: 'A', startBeat: 0, lenBeat: 4 }], 'B’s entry dropped, A kept');
+ assert.strictEqual(S.selectedRegionId, '', 'the deleted region is deselected');
+ cmd.rollback();
+ assert.deepStrictEqual(arr.notes, notesBefore, 'notes restored exactly (order + values)');
+ assert.deepStrictEqual(track.regions, regionsBefore, 'regions[] restored exactly');
+ assert.strictEqual(S.selectedRegionId, 'B', 'selection restored');
+ cmd.exec();
+ assert.deepStrictEqual(arr.notes.map(n => n.time), [0.5], 'redo re-deletes');
+});
+t('delete with a malformed region or missing track is a true no-op', () => {
+ const regions = [{ id: 'A', startBeat: 0, lenBeat: 4 }];
+ const arr = seedNotation({ notes: [note(0.5)], regions });
+ const before = clone(arr.notes);
+ const trackBefore = clone(S.trackSession.tracks[0]);
+ new DeleteRegionCmd({
+ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', region: { startBeat: 0, lenBeat: 4 },
+ }).exec();
+ new DeleteRegionCmd({
+ kind: 'notation', arrIdx: 0, trackId: 'missing', region: regions[0],
+ }).exec();
+ assert.deepStrictEqual(arr.notes, before, 'content stays untouched');
+ assert.deepStrictEqual(S.trackSession.tracks[0], trackBefore, 'region state stays untouched');
+});
+
+
+t('delete respects the end-exclusive window (a note ON the boundary is a neighbour’s)', () => {
+ // Region [0,4): owns beats 0..3; the note at beat 4 belongs to the next block.
+ const regions = [{ id: 'A', startBeat: 0, lenBeat: 4 }, { id: 'B', startBeat: 4, lenBeat: 4 }];
+ const arr = seedNotation({ notes: [note(1.5), note(2.0)], regions }); // beats 3, 4
+ new DeleteRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', region: { id: 'A', startBeat: 0, lenBeat: 4 } }).exec();
+ assert.deepStrictEqual(arr.notes.map(n => n.time), [2.0], 'beat 3 deleted; beat 4 (B’s edge) kept');
+});
+
+// ── DeleteRegionCmd: drums ────────────────────────────────────────────
+t('delete drums: removes the windowed hits + entry; round-trips and dirties', () => {
+ const drumTab = seedDrums({ hits: [{ t: 0.5, p: 36 }, { t: 2.0, p: 38 }, { t: 2.5, p: 42 }] }); // beats 1, 4, 5
+ const track = S.trackSession.tracks[0];
+ track.regions = [{ id: 'B', startBeat: 4, lenBeat: 4 }]; // owns beats 4, 5
+ const before = clone(drumTab.hits);
+ const cmd = new DeleteRegionCmd({ kind: 'drums', trackId: 'transcription:drums', region: { id: 'B', startBeat: 4, lenBeat: 4 } });
+ cmd.exec();
+ assert.deepStrictEqual(drumTab.hits.map(h => h.t), [0.5], 'the two windowed hits removed');
+ assert.strictEqual(S.drumTabDirty, true, 'delete dirties the tab');
+ assert.deepStrictEqual(track.regions, [], 'the region entry is gone');
+ cmd.rollback();
+ assert.deepStrictEqual(drumTab.hits, before, 'rollback restores hits EXACTLY');
+});
+
+// ── Multi-drum seam: commands act on the part the TRACK names ─────────
+// A song can hold several drum parts; S.drumTab is only the ACTIVE grid
+// target. A region command must resolve the tab from arrIdx (the part whose
+// track carries the region) — never assume the active tab.
+function seedTwoDrumParts() {
+ const primaryTab = { version: 1, name: 'Drums', kit: [], hits: [{ t: 0.5, p: 36 }, { t: 1.0, p: 38 }] };
+ const extraTab = { version: 1, name: 'Drums 2', kit: [], hits: [{ t: 0.5, p: 42 }, { t: 1.5, p: 42 }] };
+ const arrs = [
+ { id: 'lead', name: 'Lead', notes: [{ time: 0.5, sustain: 0, string: 0, fret: 0, techniques: {} }], chords: [] },
+ { id: 'drums', name: 'Drums', type: 'drums', drumTab: primaryTab, notes: [], chords: [] },
+ { id: 'drums-2', name: 'Drums 2', type: 'drums', drumTab: extraTab, notes: [], chords: [] },
+ ];
+ seedState({ arrangements: arrs, currentArr: 0, beats: constGrid(), drumTab: primaryTab,
+ trackSession: { version: 3, tracks: [
+ { id: 'transcription:lead', type: 'transcription', targetId: 'lead' },
+ { id: 'transcription:drums', type: 'transcription', targetId: 'drums' },
+ { id: 'transcription:drums-2', type: 'transcription', targetId: 'drums-2' },
+ ], removedSourceIds: [], tempoGuideSourceId: '', tempoGuideLocked: false, tempoGuideMode: 'audio' },
+ selectedTrackId: '', selectedRegionId: '' });
+ S.history = new EditHistory();
+ trackHooks();
+ S.drumTabDirty = false;
+ return { primaryTab, extraTab };
+}
+
+t('move on a NON-ACTIVE drum part shifts ITS hits; the active tab is untouched', () => {
+ const { primaryTab, extraTab } = seedTwoDrumParts();
+ const primaryBefore = clone(primaryTab.hits);
+ const extraBefore = clone(extraTab.hits);
+ const cmd = new MoveRegionCmd({ kind: 'drums', arrIdx: 2, trackId: 'transcription:drums-2',
+ region: { id: 'region:1', startBeat: 0, lenBeat: null }, dBeat: 2 });
+ cmd.exec();
+ assert.deepStrictEqual(extraTab.hits.map(h => h.t), [1.5, 2.5], 'the part’s own hits rode +2 beats');
+ assert.deepStrictEqual(primaryTab.hits, primaryBefore, 'the ACTIVE tab (primary) never moved');
+ cmd.rollback();
+ assert.deepStrictEqual(extraTab.hits, extraBefore, 'rollback restores the part’s hits exactly');
+ assert.deepStrictEqual(primaryTab.hits, primaryBefore, 'primary still untouched after rollback');
+});
+
+t('place on a NON-ACTIVE drum part slides ITS hits + regions land on ITS track', () => {
+ const { primaryTab, extraTab } = seedTwoDrumParts();
+ const primaryBefore = clone(primaryTab.hits);
+ const track = S.trackSession.tracks[2];
+ const cmd = new PlaceRegionCmd({ kind: 'drums', arrIdx: 2, trackId: 'transcription:drums-2', startBeat: 4 });
+ cmd.exec();
+ assert.deepStrictEqual(extraTab.hits.map(h => h.t), [2.0, 3.0], 'the part’s hits placed at bar 2');
+ assert.deepStrictEqual(primaryTab.hits, primaryBefore, 'the ACTIVE tab (primary) never moved');
+ assert.strictEqual(track.regions.length, 1, 'the region landed on the part’s OWN track');
+ assert.ok(!('regions' in S.trackSession.tracks[1]), 'not on the primary’s track');
+ cmd.rollback();
+ assert.ok(!('regions' in track), 'rollback clears it');
+});
+
+t('delete on a NON-ACTIVE drum part removes ITS windowed hits only', () => {
+ const { primaryTab, extraTab } = seedTwoDrumParts();
+ const primaryBefore = clone(primaryTab.hits);
+ S.trackSession.tracks[2].regions = [{ id: 'B', startBeat: 0, lenBeat: 2 }]; // owns beats 0..1
+ new DeleteRegionCmd({ kind: 'drums', arrIdx: 2, trackId: 'transcription:drums-2',
+ region: { id: 'B', startBeat: 0, lenBeat: 2 } }).exec();
+ assert.deepStrictEqual(extraTab.hits.map(h => h.t), [1.5], 'only the windowed hit (beat 1) removed');
+ assert.deepStrictEqual(primaryTab.hits, primaryBefore, 'the ACTIVE tab untouched');
+});
+
+t('legacy fallback: kind drums with NO arrIdx still targets S.drumTab', () => {
+ const { primaryTab } = seedTwoDrumParts();
+ const cmd = new MoveRegionCmd({ kind: 'drums', trackId: 'transcription:drums',
+ region: { id: 'region:1', startBeat: 0, lenBeat: null }, dBeat: 2 });
+ cmd.exec();
+ assert.deepStrictEqual(primaryTab.hits.map(h => h.t), [1.5, 2.0], 'no arrIdx → the active tab moved (legacy path)');
+});
+
+// ── _placeAtStartBeatPure (the import dialog's "Place at" resolution) ─
+t('_placeAtStartBeatPure: keep → null; bar1 → first downbeat; playhead snaps to bar', () => {
+ const beats = constGrid(); // downbeats at t=0,2,4,6 (beats 0,4,8,12)
+ assert.strictEqual(_placeAtStartBeatPure('keep', beats, 2.3, beatOf), null, 'keep = no placement');
+ assert.strictEqual(_placeAtStartBeatPure('nonsense', beats, 2.3, beatOf), null, 'unknown choice = keep');
+ assert.strictEqual(_placeAtStartBeatPure('bar1', beats, 2.3, beatOf), 0, 'bar 1 = the first downbeat’s beat');
+ assert.strictEqual(_placeAtStartBeatPure('playhead', beats, 2.3, beatOf), 4, 'cursor 2.3s snaps to the bar at t=2 → beat 4');
+ assert.strictEqual(_placeAtStartBeatPure('playhead', beats, 3.2, beatOf), 8, 'cursor 3.2s snaps up to t=4 → beat 8');
+ assert.strictEqual(_placeAtStartBeatPure('bar1', [], 0, (b, t) => t), 0, 'gridless chart degrades to 0');
+});
+
+// ── placeImportedPartAsRegion (the import front door's orchestrator) ──
+// The real post-import flow: the fresh part has NO track row yet — the
+// orchestrator must normalize the session (synthesizing the row), resolve the
+// track, then place/select. Stateful wiring, driven for real.
+function seedImportedDrumPart() {
+ const primaryTab = { version: 1, name: 'Drums', kit: [], hits: [{ t: 0.5, p: 36 }] };
+ const freshTab = { version: 1, name: 'Imported', kit: [], hits: [{ t: 0.5, p: 42 }, { t: 1.0, p: 38 }] }; // beats 1, 2
+ const arrs = [
+ { id: 'lead', name: 'Lead', notes: [{ time: 0.5, sustain: 0, string: 0, fret: 0, techniques: {} }], chords: [] },
+ { id: 'drums', name: 'Drums', type: 'drums', drumTab: primaryTab, notes: [], chords: [] },
+ { id: 'drums-2', name: 'Imported', type: 'drums', drumTab: freshTab, notes: [], chords: [] },
+ ];
+ seedState({ arrangements: arrs, currentArr: 0, beats: constGrid(), drumTab: freshTab,
+ audioUrl: '', stems: [], stemLinks: {}, cursorTime: 0,
+ trackSession: { version: 3, tracks: [], removedSourceIds: [], tempoGuideSourceId: '', tempoGuideLocked: false, tempoGuideMode: 'audio' },
+ selectedTrackId: '', selectedRegionId: '' });
+ S.history = new EditHistory();
+ trackHooks();
+ S.drumTabDirty = false;
+ return { primaryTab, freshTab };
+}
+function seedLegacyImportedDrums() {
+ const drumTab = { version: 1, name: 'Drums', kit: [], hits: [{ t: 0.5, p: 36 }, { t: 1.0, p: 38 }] };
+ seedState({ arrangements: [], currentArr: 0, beats: constGrid(), drumTab,
+ audioUrl: '', stems: [], stemLinks: {}, cursorTime: 0,
+ trackSession: { version: 3, tracks: [], removedSourceIds: [], tempoGuideSourceId: '', tempoGuideLocked: false, tempoGuideMode: 'audio' },
+ selectedTrackId: '', selectedRegionId: '' });
+ S.history = new EditHistory();
+ trackHooks();
+ S.drumTabDirty = false;
+ return drumTab;
+}
+const _trackById = (id) => S.trackSession.tracks.find(t => t.id === id);
+
+t('orchestrator bar1: synthesizes the row, places the part, selects the region; undo restores', () => {
+ const { primaryTab, freshTab } = seedImportedDrumPart();
+ const before = clone(freshTab.hits);
+ const ok = placeImportedPartAsRegion({ kind: 'drums', arrIdx: 2, placeAt: 'bar1' });
+ assert.strictEqual(ok, true);
+ assert.deepStrictEqual(freshTab.hits.map(h => h.t), [0.0, 0.5], 'the part slid to bar 1 (first onset at beat 0)');
+ assert.deepStrictEqual(primaryTab.hits.map(h => h.t), [0.5], 'the other part untouched');
+ const track = _trackById('transcription:drums-2');
+ assert.ok(track, 'the fresh part’s row was synthesized');
+ assert.strictEqual(track.regions.length, 1, 'a bounded region landed on it');
+ assert.strictEqual(S.selectedTrackId, 'transcription:drums-2', 'track selected');
+ assert.strictEqual(S.selectedRegionId, track.regions[0].id, 'the placed region selected');
+ S.history.doUndo();
+ assert.deepStrictEqual(freshTab.hits, before, 'undo restores the hits');
+ assert.ok(!('regions' in _trackById('transcription:drums-2')), 'and removes the window');
+});
+
+t('orchestrator keep: no motion, no persisted window — selection only', () => {
+ const { freshTab } = seedImportedDrumPart();
+ const before = clone(freshTab.hits);
+ const ok = placeImportedPartAsRegion({ kind: 'drums', arrIdx: 2, placeAt: 'keep' });
+ assert.strictEqual(ok, true);
+ assert.deepStrictEqual(freshTab.hits, before, 'keep = content stays at source timing');
+ assert.ok(!('regions' in _trackById('transcription:drums-2')), 'no regions key written');
+ assert.strictEqual(S.selectedTrackId, 'transcription:drums-2', 'track selected');
+ assert.strictEqual(S.selectedRegionId, DEFAULT_REGION_ID, 'the implicit default region selected');
+});
+
+t('orchestrator playhead: places at the cursor’s bar', () => {
+ const { freshTab } = seedImportedDrumPart();
+ S.cursorTime = 2.3; // snaps to the bar at t=2 → beat 4
+ placeImportedPartAsRegion({ kind: 'drums', arrIdx: 2, placeAt: 'playhead' });
+ assert.deepStrictEqual(freshTab.hits.map(h => h.t), [2.0, 2.5], 'first onset landed on bar 2 (beat 4)');
+ assert.strictEqual(_trackById('transcription:drums-2').regions[0].startBeat, 4);
+});
+
+t('orchestrator refuses a bad arrIdx gracefully', () => {
+ seedImportedDrumPart();
+ assert.strictEqual(placeImportedPartAsRegion({ kind: 'drums', arrIdx: -2, placeAt: 'bar1' }), false);
+ assert.strictEqual(placeImportedPartAsRegion({ kind: 'notation', arrIdx: -1, placeAt: 'bar1' }), false);
+t('orchestrator places the legacy drums-only target (arrIdx -1)', () => {
+ const drumTab = seedLegacyImportedDrums();
+ const before = clone(drumTab.hits);
+ const ok = placeImportedPartAsRegion({
+ kind: 'drums', arrIdx: -1, placeAt: 'bar1', items: drumTab.hits.slice(),
+ });
+ assert.strictEqual(ok, true);
+ assert.deepStrictEqual(drumTab.hits.map(hit => hit.t), [0, 0.5], 'legacy hits land at bar 1');
+ const track = _trackById('transcription:drums');
+ assert.ok(track, 'the legacy drums row was synthesized');
+ assert.strictEqual(S.selectedTrackId, track.id);
+ assert.strictEqual(S.selectedRegionId, track.regions[0].id);
+ S.history.doUndo();
+ assert.deepStrictEqual(drumTab.hits, before, 'undo restores the legacy tab exactly');
+ assert.ok(!('regions' in track), 'undo removes the placed window');
+});
+
+ assert.strictEqual(placeImportedPartAsRegion({ kind: 'drums', arrIdx: 99, placeAt: 'bar1' }), false);
+});
+
+// ── _partsViewRegionDelete (the Del-key surface, driven for real) ─────
+const { _partsViewRegionDelete } = await import('../src/parts-view.js');
+
+t('Del on a selected bounded region deletes its notes + window; undo restores', () => {
+ const regions = [{ id: 'A', startBeat: 0, lenBeat: 4 }, { id: 'B', startBeat: 4, lenBeat: 4 }];
+ const arr = seedNotation({ notes: [note(0.5), note(2.0), note(2.5)], regions }); // beats 1, 4, 5
+ Object.assign(S, { partsViewMode: true, audioUrl: '', stems: [], stemLinks: {},
+ selectedTrackId: 'transcription:Lead', selectedRegionId: 'B' });
+ const notesBefore = clone(arr.notes);
+ const regionsBefore = clone(S.trackSession.tracks[0].regions);
+ assert.strictEqual(_partsViewRegionDelete(), true, 'the key was consumed');
+ assert.deepStrictEqual(arr.notes.map(n => n.time), [0.5], 'B’s notes deleted, A’s kept');
+ assert.deepStrictEqual(S.trackSession.tracks[0].regions, [{ id: 'A', startBeat: 0, lenBeat: 4 }], 'B’s window dropped');
+ assert.strictEqual(S.selectedRegionId, '', 'the deleted region deselected');
+ S.history.doUndo();
+ assert.deepStrictEqual(arr.notes, notesBefore, 'undo restores the notes');
+ assert.deepStrictEqual(S.trackSession.tracks[0].regions, regionsBefore, 'and the window');
+});
+
+t('Del gates: consumed only in the Tracks view with a region selected', () => {
+ const arr = seedNotation({ notes: [note(0.5)] });
+ Object.assign(S, { audioUrl: '', stems: [], stemLinks: {} });
+ S.partsViewMode = false;
+ S.selectedTrackId = 'transcription:Lead';
+ S.selectedRegionId = 'region:1';
+ assert.strictEqual(_partsViewRegionDelete(), false, 'not in the Tracks view → fall through');
+ S.partsViewMode = true;
+ S.selectedRegionId = '';
+ assert.strictEqual(_partsViewRegionDelete(), false, 'no region selected → fall through');
+ S.selectedRegionId = 'region:404';
+ assert.strictEqual(_partsViewRegionDelete(), false, 'stale region id → fall through');
+ assert.strictEqual(arr.notes.length, 1, 'nothing was deleted by the refusals');
+});
+
+t('Del on a drum part’s region deletes ITS hits (not the active tab’s)', () => {
+ const { primaryTab, extraTab } = seedTwoDrumParts();
+ Object.assign(S, { partsViewMode: true, audioUrl: '', stems: [], stemLinks: {} });
+ S.trackSession.tracks[2].regions = [{ id: 'B', startBeat: 0, lenBeat: 2 }]; // owns beat 0..1
+ S.selectedTrackId = 'transcription:drums-2';
+ S.selectedRegionId = 'B';
+ const primaryBefore = clone(primaryTab.hits);
+ assert.strictEqual(_partsViewRegionDelete(), true);
+ assert.deepStrictEqual(extraTab.hits.map(h => h.t), [1.5], 'only the windowed hit of the part deleted');
+ assert.deepStrictEqual(primaryTab.hits, primaryBefore, 'the ACTIVE tab untouched');
+});
+
+// ── Through EditHistory (the real exec path) ──────────────────────────
+t('via S.history.exec: place → delete, undo/redo restore both', () => {
+ const arr = seedNotation({ notes: [note(0.5), note(1.0)] }); // beats 1, 2
+ const track = S.trackSession.tracks[0];
+ const empty = clone(arr.notes);
+ S.history.exec(new PlaceRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', startBeat: 4 }));
+ const placedNotes = clone(arr.notes);
+ const region = clone(track.regions[0]);
+ S.history.exec(new DeleteRegionCmd({ kind: 'notation', arrIdx: 0, trackId: 'transcription:Lead', region: track.regions[0] }));
+ assert.deepStrictEqual(arr.notes, [], 'the placed block was deleted');
+ assert.deepStrictEqual(track.regions, [], 'its entry gone');
+ S.history.doUndo(); // undo delete
+ assert.deepStrictEqual(arr.notes, placedNotes, 'undo restores the placed notes');
+ assert.deepStrictEqual(track.regions[0], region, 'undo restores the region');
+ S.history.doUndo(); // undo place
+ assert.deepStrictEqual(arr.notes, empty, 'undo the place restores the pre-place notes');
+ assert.ok(!('regions' in track), 'and the regions key');
+ S.history.doRedo(); // redo place
+ assert.deepStrictEqual(arr.notes, placedNotes, 'redo the place');
+});
+
+for (const [name, fn] of tests) {
+ try { await fn(); pass++; console.log(' ok ' + name); }
+ catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); }
+}
+
+console.log(`\n${pass} passed, ${fail} failed`);
+process.exit(fail ? 1 : 0);