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
16 changes: 14 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Added

- **The Tracks area — a DAW-style track column.** The persistent track tree
is now a surface you can see and arrange: a resizable header column beside
the timeline lists every track (the master mix, studio stems, and each
transcription part), with the canvas drawing a matching lane for each row —
the two always line up. Reorder tracks and group them into folders by
drag, rename inline, resize a lane's height, and pair a transcription with
its studio stem right from its row. Mute/solo/volume on a transcription
ride the same mixer state as the mixer panel. Double-click a track to open
it in its native editor; a stem's row-menu can lock it as the metronome
guide. Removing an audio track is non-destructive — the media stays in the
pack. The area is the workspace's landing surface for any loaded song.
Comment thread
byrongamatos marked this conversation as resolved.

- **Bulk barline locks + friendlier Tempo Map selection.** Lock or unlock a
whole selection in one go: the tempo toolbar's new lock button (and the
`S` key, and the right-click menu) names exactly what it will do — "Lock
Expand Down Expand Up @@ -50,8 +62,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
session state. Removing an audio track is non-destructive (the media
stays in the pack; the track can be restored), and the tree records
which audio source is the session's tempo reference. This slice is the
data model and persistence; the unified Tracks surface that renders the
tree ships separately.
data-model and persistence foundation for the unified Tracks surface
described above.

### Changed

Expand Down
65 changes: 65 additions & 0 deletions assets/v3-theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -532,3 +532,68 @@ html.fb-immersive #plugin-editor .editor-root {
#plugin-editor .editor-create-track-row input { accent-color: #22d3ee; }
#plugin-editor .editor-create-track-row button { color: #64748b; font-size: 1rem; }
#plugin-editor .editor-create-track-row button:hover { color: #f8fafc; }

/* ── Tracks header column (the persistent track tree, src/track-session.js) ── */
#plugin-editor #editor-track-session { width: 20rem; min-width: 11rem; max-width: 36rem; position: relative; }
#plugin-editor #editor-track-session-splitter { width: 5px; margin-left: -3px; z-index: 8; flex: none; cursor: ew-resize; background: transparent; }
#plugin-editor #editor-track-session-splitter:hover,
#plugin-editor #editor-track-session-splitter.is-dragging { background: rgba(34,211,238,.7); }
#plugin-editor .editor-track-session-head {
height: 40px; display: flex; align-items: center; gap: .28rem; padding: 0 .4rem;
border-bottom: 1px solid #374151; color: #9ca3af; font-size: 9px;
text-transform: uppercase; letter-spacing: .06em;
}
#plugin-editor .editor-track-session-head strong { color: #d1d5db; margin-right: auto; }
#plugin-editor .editor-track-session-head button {
border: 1px solid #374151; border-radius: 4px; background: #1f2937; color: #d1d5db;
padding: .12rem .28rem; font-size: 9px; text-transform: none; letter-spacing: 0; white-space: nowrap;
}
#plugin-editor .editor-track-guide-label { color: #fbbf24; }
#plugin-editor .editor-track-guide-source { width: 4.5rem; overflow: hidden; text-overflow: ellipsis; }
#plugin-editor .editor-track-session-list { position: absolute; left: 0; right: 0; top: 40px; bottom: 0; overflow-y: auto; overflow-x: hidden; }
#plugin-editor .editor-track-row {
height: var(--track-row-height); min-height: var(--track-row-height); display: flex; align-items: center; gap: .35rem;
padding: .22rem .35rem .22rem calc(.45rem + var(--track-indent));
border-bottom: 1px solid rgba(55,65,81,.7); color: #d1d5db; position: relative;
}
#plugin-editor .editor-track-row:hover { background: rgba(55,65,81,.55); }
#plugin-editor .editor-track-row.editor-track-selected { background-color: rgba(14,165,233,.2); outline: 1px solid rgba(56,189,248,.7); outline-offset: -1px; }
#plugin-editor .editor-track-folder.editor-track-selected { background-color: rgba(14,165,233,.2); }
#plugin-editor .editor-track-folder { color: #f3f4f6; font-weight: 600; background: rgba(17,24,39,.58); }
#plugin-editor .editor-track-transcription { background: linear-gradient(90deg, rgba(34,197,94,.13), rgba(21,128,61,.035)); }
#plugin-editor .editor-track-transcription .editor-track-kind { color: #86efac; }
#plugin-editor .editor-track-folder button { color: #9ca3af; width: 1rem; }
#plugin-editor .editor-track-kind { width: 1.75rem; color: #67e8f9; font: 9px/1 monospace; }
#plugin-editor .editor-track-name { min-width: 4rem; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
#plugin-editor .editor-track-guide { box-shadow: inset 3px 0 #fbbf24; }
#plugin-editor .editor-track-row select { width: 5.2rem; background: #111827; border: 1px solid #374151; border-radius: 3px; color: #cbd5e1; font-size: 10px; padding: .12rem; }
#plugin-editor #editor-track-session[data-track-density="normal"] .editor-track-row select,
#plugin-editor #editor-track-session[data-track-density="compact"] .editor-track-row select { display: none; }
#plugin-editor #editor-track-session[data-track-density="compact"] .editor-track-fader,
#plugin-editor #editor-track-session[data-track-density="compact"] [data-track-action="guide-set"] { display: none; }
#plugin-editor #editor-track-session[data-track-density="compact"] .editor-track-kind { width: 1.45rem; }
#plugin-editor #editor-track-session[data-track-density="compact"] .editor-track-session-head .editor-track-guide-label,
#plugin-editor #editor-track-session[data-track-density="compact"] .editor-track-session-head .editor-track-guide-source,
#plugin-editor #editor-track-session[data-track-density="compact"] .editor-track-session-head [data-track-action="zoom-out"] { display: none; }
#plugin-editor #editor-track-session[data-track-density="normal"] .editor-track-session-head .editor-track-guide-label,
#plugin-editor #editor-track-session[data-track-density="normal"] .editor-track-session-head [data-track-action="zoom-out"] { display: none; }
#plugin-editor #editor-track-session[data-track-density="normal"] .editor-track-guide-source { width: 3.5rem; }
#plugin-editor #editor-track-session[data-track-density="wide"] .editor-track-name { min-width: 7rem; }
#plugin-editor .editor-track-resize { position: absolute; left: 0; right: 0; bottom: -2px; height: 5px; z-index: 2; cursor: ns-resize; }
#plugin-editor .editor-track-resize:hover { background: rgba(34,211,238,.65); }
#plugin-editor .editor-track-drop-before { box-shadow: inset 0 2px #22d3ee; }
#plugin-editor .editor-track-drop-after { box-shadow: inset 0 -2px #22d3ee; }
#plugin-editor .editor-track-drop-inside { background: rgba(34,211,238,.16); outline: 1px solid rgba(34,211,238,.7); outline-offset: -2px; }
#plugin-editor .editor-track-ms {
width: 1.45rem; height: 1.25rem; border: 1px solid #4b5563; border-radius: 3px;
background: #1f2937; color: #9ca3af; font: 700 9px/1 monospace;
}
#plugin-editor [data-track-action="mix-mute"][aria-pressed="true"] { background: #b45309; color: #fff; border-color: #f59e0b; }
#plugin-editor [data-track-action="mix-solo"][aria-pressed="true"] { background: #ca8a04; color: #111827; border-color: #fde047; }
#plugin-editor .editor-track-fader { width: 3.2rem; accent-color: #22d3ee; }
#plugin-editor .editor-track-context-menu { position: fixed; z-index: 90; min-width: 8rem; padding: .25rem; border: 1px solid #4b5563; border-radius: 4px; background: #111827; box-shadow: 0 10px 25px rgba(0,0,0,.45); }
#plugin-editor .editor-track-context-menu button { width: 100%; padding: .35rem .55rem; text-align: left; color: #e5e7eb; border-radius: 3px; }
#plugin-editor .editor-track-context-menu button:hover { background: #374151; }
#plugin-editor .editor-track-context-menu .editor-track-delete { color: #fca5a5; }
#plugin-editor .editor-track-context-menu .editor-track-delete:hover { background: rgba(153,27,27,.65); color: #fff; }
#plugin-editor .editor-track-inline-rename { flex: 1; min-width: 3rem; padding: .25rem .35rem; border: 1px solid #22d3ee; border-radius: 3px; background: #030712; color: #f3f4f6; outline: none; box-shadow: 0 0 0 1px rgba(34,211,238,.2); cursor: text; user-select: text; -webkit-user-select: text; -webkit-app-region: no-drag; }
20 changes: 17 additions & 3 deletions docs/USER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,23 @@ gets the view that fits it automatically:
read-only reference, with resolver-assisted authoring).
- **Drum grid** — piece lanes, for drum tracks.

Switch tracks from the track selector. **Tracks overview** (`Shift+A`) stacks every
track for a bird's-eye look. Rename / reorder tracks from the **Track** menu.
Add a track by importing into it (e.g. Import MIDI as a keys track).
The **Tracks column** down the left side lists every track — the master mix,
any studio stems, and each transcription part — beside a matching timeline lane.
From a track's row you can:

- **rename** it (double-click the name, or right-click ▸ Rename),
- **reorder** it or drop it into a **folder** by dragging (+ Folder up top),
- **resize** its lane by dragging the bottom edge (or the + / − header buttons
to grow/shrink them all),
- **pair** a transcription with the studio stem it was charted against (the
dropdown on its row), and mute / solo / set its level,
- **open** it in its native editor (double-click), or right-click a stem to
**lock it as the metronome guide**.

Drag the divider to widen the column; narrow it and the detail controls fold
away first, keeping each track's name and type visible. Removing an audio
track is non-destructive — the media stays in the pack and can come back.
`Shift+A` jumps to this Tracks view.

---

Expand Down
7 changes: 6 additions & 1 deletion screen.html
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,12 @@
<div id="editor-transport-bar" class="editor-transport-bar shrink-0"></div>

<!-- Canvas area + inspector panel -->
<div class="flex-1 flex overflow-hidden">
<div class="flex-1 flex overflow-hidden relative">
<!-- Tracks header column (src/track-session.js): the persistent track
tree made visible — one header cell per lane the canvas draws.
Empty until render() fills it; the splitter resizes it. -->
<aside id="editor-track-session" class="w-80 shrink-0 bg-dark-800 border-r border-gray-800 overflow-hidden text-xs text-gray-200"></aside>
<div id="editor-track-session-splitter" role="separator" aria-orientation="vertical" aria-label="Resize track headers" title="Drag to resize track headers"></div>
<div id="editor-canvas-wrap" class="flex-1 relative overflow-hidden bg-dark-900">
<!-- Position sweep bar (P8) - shown by anchor "Resolve positions in
this window"; Enter accepts, arrows move, A accepts all, Esc done. -->
Expand Down
1 change: 1 addition & 0 deletions src/arrangement.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ function _renameGuardPure(oldName, rawNewName, otherNames) {
return { ok: true, reason: '', name };
}
/* @pure:rename-arr:end */
export { _renameGuardPure };

// Undoable rename. Holds the arrangement INDEX (undo can fire after a
// switch; EditHistory's per-arrangement tagging routes back) plus both
Expand Down
10 changes: 10 additions & 0 deletions src/host.js
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ export const host = {
/** Parts-view mousedown / dblclick routing (parts view lives in main.js). */
partsViewOnMouseDown: () => {},
partsViewOnDblClick: () => {},
/** Arm a transcription target (arrangement / drums) from the Tracks area. */
selectTrackSessionTarget: () => {},
/** Leave the Tracks overview and open a transcription's native editor. */
openTrackSessionTarget: () => {},
/** Vertically scroll the shared track-header/canvas lane stack. */
scrollTrackArea: () => false,
/** Cached waveform for one audio source's lane ({peaks, duration} or null). */
trackWaveform: () => null,
/** View-mode / part-move / key-highlight / parts-view toggles (main.js). */
editorCycleViewMode: () => {},
editorMovePart: () => {},
Expand Down Expand Up @@ -205,6 +213,8 @@ export const host = {
* an import cycle. Inert defaults for bare-host unit tests.
*/
partMixChanged: () => {},
/** The visible audio-source roster changed (remove/restore/import/rename). */
audioSourcesChanged: () => {},
stripUiChanged: () => {},
/** The persisted band-mode pref, read by the panel's header toggle. */
playAllTracksEnabled: () => false,
Expand Down
54 changes: 51 additions & 3 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@
import { _tabViewHideIfShown, _tabViewPing, editorToggleTabView, teardownTabView } from './tab-view-live.js';
import { initToolbars } from './toolbars.js';
import { editorStartTour, editorTourEscape, editorTourSkip, _tourAdvance, _tourNoteAction } from './tour.js';
import { installCreatedTrackSession } from './track-session.js';
import { _trackSessionTargetsPure, initTrackSession, installCreatedTrackSession, refreshTrackSession, scrollTrackSessionBy } from './track-session.js';
import { editorDismissSignpost } from './signposts.js';
import { _editorSongFit } from './song-fit.js';
import { _transportBarTick, initTransportBar } from './transport-bar.js';
Expand Down Expand Up @@ -173,7 +173,7 @@
lanes
} from './lanes.js';
import {
LABEL_W, setLaneMetrics } from './geometry.js';
LABEL_W, TIMELINE_TOP, setLaneMetrics } from './geometry.js';
import {
KEYS_PATTERN, _rollLockNotice,
_rollMidiForNote, _rollPitchCtx, _rollReadOnly, editorKeyNoteNames, isKeysMode, midiToNote, updatePianoRange } from './keys.js';
Expand Down Expand Up @@ -524,13 +524,48 @@
mixUiState: () => ({ pcts: _mixLoadPct(), blip: editorEditBlipEnabled() }),
// Band mode (multi-track MIDI playback): the strips are the mixer.
partStripState: (key) => _mixerPartStripState(key),
partMixChanged: () => _partGainsApply(false),
partMixChanged: () => { _partGainsApply(false); refreshTrackSession(); },
playAllTracksEnabled: () => editorPlayAllTracksEnabled(),
stripUiChanged: () => _mixerPanelRefresh(),
// Persistent track tree: create/import hands every create-time source to
// the installer so the tree exists from the first moment, not only after
// a save/reopen (the seam #286 reserved).
installCreatedTrackSession: (raw, sources) => installCreatedTrackSession(raw, sources),
// Tracks area: arm / open a transcription target from a header cell or a
// canvas lane. targetIds are chart-track keys (the stemLinks dialect);
// _trackSessionTargetsPure maps them back to arrangement indices.
selectTrackSessionTarget: (targetId) => {
if (targetId === 'drums') return;
const target = _trackSessionTargetsPure(S.arrangements, S.drumTab).find(t => t.id === targetId);
const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1;
if (index >= 0 && index !== S.currentArr) window.editorSelectArrangement(String(index));
},
openTrackSessionTarget: (targetId) => {
_finalizeActiveDrag();
S.partsViewMode = false;
S.tempoMapMode = false;
S.tempoSel = -1;
if (targetId === 'drums' && S.drumTab && S.format === 'sloppak') {
S.drumEditMode = true;
S.drumSel = new Set();
} else {
S.drumEditMode = false;
const target = _trackSessionTargetsPure(S.arrangements, S.drumTab).find(t => t.id === targetId);
const index = target && target.mixKey.startsWith('arr:') ? Number(target.mixKey.slice(4)) : -1;
if (index >= 0) window.editorSelectArrangement(String(index));
}
_refreshPartsViewButton();
_refreshDrumEditButton();
_refreshTempoMapButton();
draw();
updateStatus();
},
// Vertical wheel over the Tracks area scrolls the shared lane stack.
scrollTrackArea: (deltaY) => scrollTrackSessionBy(deltaY),
// Lane waveforms: the master mix draws from the session's decoded peaks;
// stems light up when the engine slice caches theirs.
trackWaveform: (sourceId) => (sourceId === 'master' && S.waveformPeaks && S.duration > 0
? { peaks: S.waveformPeaks, duration: S.duration } : null),
});

// Re-attach the song-import modal handlers (import.js owns the logic; the HTML
Expand Down Expand Up @@ -1021,6 +1056,10 @@
downBtn.classList.toggle('hidden', !canReorder);
downBtn.disabled = !canReorder || S.currentArr >= S.arrangements.length - 1;
}

// The Tracks header column mirrors arrangement names/count — keep it in
// sync with every structural rebuild (memoized; cheap when unchanged).
refreshTrackSession();
}


Expand Down Expand Up @@ -1332,6 +1371,14 @@
// setLaneMetrics writes them.
setLaneMetrics(h);

// Feed the Tracks area its viewport height (drives the modest lane
// auto-fit shared by the header column and the canvas lanes).
const trackViewport = Math.max(0, h - TIMELINE_TOP);
if (trackViewport !== S.trackViewportHeight) {
S.trackViewportHeight = trackViewport;
refreshTrackSession();
}

canvas.width = w * DPR;
canvas.height = h * DPR;
canvas.style.width = w + 'px';
Expand Down Expand Up @@ -1752,7 +1799,7 @@
// the same save path as the Save button (in-place sloppak write, not the
// heavy create-mode build).
if (S.sessionId) {
try { await saveCDLC(); } catch (e) { /* surfaced via setStatus */ }

Check warning on line 1802 in src/main.js

View workflow job for this annotation

GitHub Actions / lint

'e' is defined but never used. Allowed unused caught errors must match /^_/u
}
// Capture where we are so the return trip lands on the same spot.
const returnCtx = {
Expand Down Expand Up @@ -2020,6 +2067,7 @@
initTempoList();
window.editorToggleTempoList = editorToggleTempoList;
initStemTracks();
initTrackSession();
window.editorToggleStemTracks = editorToggleStemTracks;
window.editorSoloMyStem = editorSoloMyStem;
// Registry commands run through `editorRunShortcutCommand` — the SAME
Expand Down
17 changes: 17 additions & 0 deletions src/mixer-panel.js
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,23 @@ function _setPart(key, patch) {
host.partMixChanged();
}

// Companion surfaces (the Tracks header column) mutate the SAME canonical
// partMix through these thin verbs — this panel stays the owner, so the
// solo rule and the partMixChanged notification can never fork.
export function mixerTogglePart(key, which) {
const st = _mixerPartStatePure(S.partMix, key);
_setPart(key, which === 'mute' ? { mute: !st.mute } : { solo: !st.solo });
_lastKey = '';
_mixerPanelRefresh();
}
export function mixerSetPart(key, patch) {
_setPart(key, patch);
// Keep the mixer panel's companion fader in sync when the change came in
// from the Tracks header column (track-session.js) — same as the M/S verb.
_lastKey = '';
_mixerPanelRefresh();
}
Comment thread
byrongamatos marked this conversation as resolved.

// One delegated listener pair on the (static) panel element — guarded so a
// defensive double-init can never stack handlers. The panel element itself is
// replaced when the host re-injects the screen, so nothing leaks across
Expand Down
13 changes: 11 additions & 2 deletions src/mouse.js
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,13 @@ export function onDblClick(e) {

export function onWheel(e) {
e.preventDefault();
// Tracks area: a vertical-dominant wheel scrolls the shared lane stack
// (header column + canvas lanes together); a horizontal-dominant swipe
// still pans the timeline.
if (S.partsViewMode && !e.ctrlKey && Math.abs(e.deltaY) >= Math.abs(e.deltaX || 0)) {
host.scrollTrackArea(e.deltaY);
return;
}
if (e.ctrlKey) {
// Ctrl+scroll = zoom
const { x } = getMousePos(e);
Expand All @@ -662,8 +669,10 @@ export function onWheel(e) {
S.scrollX = timeBefore - (x - LABEL_W) / S.zoom;
S.scrollX = _editorClampScrollX(S.scrollX);
} else {
// Scroll = pan
S.scrollX = _editorClampScrollX(S.scrollX + e.deltaY / S.zoom * 2);
// Scroll = pan: a horizontal-dominant swipe pans by deltaX, a plain
// vertical wheel keeps panning by deltaY (the long-standing gesture).
const pan = Math.abs(e.deltaX || 0) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
S.scrollX = _editorClampScrollX(S.scrollX + pan / S.zoom * 2);
}
host.updateZoomDisplay();
host.draw();
Expand Down
Loading
Loading