Skip to content
Closed
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
durations read from the spacing between notes, and anything outside the
barline span is counted in the status rather than silently dropped.
Fretted tracks only for now; standard notation is the follow-up.
- **Standard notation in the score view — View ▸ Score staff.** Pick what
the live score engraves: **tablature only** (the default), **standard
notation only**, or **both staves together**. Clicking a beat selects its
notes on every staff choice, the pick is remembered per browser, and
choosing a staff while the score view is off switches you into it.

### Fixed

Expand Down
24 changes: 24 additions & 0 deletions src/menu-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
applyToolbarPreset, getToolbarCtx, resetToolbarLayout, toggleToolbar,
} from './toolbars.js';
import { _clearBarSelection, editorLoopSnapMode, editorSetLoopSnapMode } from './loop.js';
import { editorSetTabViewStaff, editorTabViewStaff } from './tab-view-live.js';

/* @pure:menu-model:start */
// The nine menus (charrette §2.2). Item kinds:
Expand Down Expand Up @@ -150,6 +151,14 @@ export const EDITOR_MENUS = Object.freeze([
{ cmd: 'toggleTabView' },
{ cmd: 'showTabPreview' },
{ sep: true },
// Score-staff radio (rides the live Tab/Score view): which staves
// the engraving shows. Checkmarks resolve from ctx.scoreStaff at
// open time, same as the loop-snap trio.
{ hdr: 'Score staff' },
{ scoreStaff: 'tab', label: 'Tablature only' },
{ scoreStaff: 'notation', label: 'Standard notation only' },
{ scoreStaff: 'both', label: 'Notation + tablature' },
{ sep: true },
{ label: 'Theme: Dark → Medium → Light', fn: 'editorCycleTheme', v3Only: true },
{ sep: true },
{ hdr: 'Panels' },
Expand Down Expand Up @@ -287,6 +296,19 @@ export function _menuModelPure(menus, rows, ctx) {
});
continue;
}
if (it.scoreStaff) {
// Score-staff radio: ctx.scoreStaff is absent in older
// callers -> unchecked (same degradation as the loop trio).
const on = ctx.scoreStaff === it.scoreStaff;
items.push({
label: (on ? '✓ ' : ' ') + it.label,
key: '',
dispatch: { scoreStaff: it.scoreStaff },
disabled: false,
planned: false,
});
continue;
}
if (it.loopSnap || it.loopClear) {
// Loop rows (B3). The snap trio renders like a radio group;
// ctx.loopSnapMode is absent in older callers -> unchecked.
Expand Down Expand Up @@ -402,6 +424,7 @@ function currentModel() {
v3: !!(window.slopsmith && window.slopsmith.uiVersion === 'v3'),
toolbars: getToolbarCtx(),
loopSnapMode: editorLoopSnapMode(),
scoreStaff: editorTabViewStaff(),
gmGuide: _gmGuideMenuCtx(),
});
}
Expand All @@ -413,6 +436,7 @@ function dispatch(d) {
if (d.tbPreset) { applyToolbarPreset(d.tbPreset); return; }
if (d.tbReset) { resetToolbarLayout(); return; }
if (d.loopSnap) { editorSetLoopSnapMode(d.loopSnap); return; }
if (d.scoreStaff) { editorSetTabViewStaff(d.scoreStaff); return; }
if (d.loopClear) { _clearBarSelection(); return; }
if (d.guideVoice) { _editorSetGuideVoiceMode(d.guideVoice); return; }
if (d.gmVoice != null) { editorSetGmVoice(d.gmKind, d.gmVoice); return; }
Expand Down
2 changes: 2 additions & 0 deletions src/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ export const S = {
// Live Tab view (view-modality): an orthogonal lens flag like
// partsViewMode — mode toggles clear it, the draw pass enforces it.
tabViewMode: false,
// Which staves the score view engraves: 'tab' | 'notation' | 'both'.
tabViewStaff: 'tab',
// True when this editor session was opened from the 3D highway's
// "Edit region" action. Used to make the preview button read as a
// return trip instead of a fresh action.
Expand Down
75 changes: 66 additions & 9 deletions src/tab-view-live.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
* plugin (contrast tab-preview.js, the saved-pack proofreading modal, which
* stays for checking what actually shipped to disk).
*
* v1 contract (Christian's calls): tab staff only (standard notation is the
* follow-up), read + CLICK-TO-SELECT — clicking an engraved beat selects its
* source notes and seeks the playhead; editing stays in the timeline views,
* whose shortcuts keep working while the score refreshes live.
* Contract (Christian's calls): read + CLICK-TO-SELECT — clicking an
* engraved beat selects its source notes and seeks the playhead; editing
* stays in the timeline views, whose shortcuts keep working while the score
* refreshes live. The engraved STAFF is a per-browser reading preference
* (View ▸ Score staff): tab only (default), standard notation only, or both
* — same generated alphaTex either way, alphaTab derives pitch from
* tuning + fret.
*
* Lifecycle: `S.tabViewMode` is an orthogonal lens flag like partsViewMode —
* the drum / tempo-map / parts toggles clear it, and the draw pass is the
Expand All @@ -30,43 +33,74 @@ import { TAB_RENDERER_FONT_DIR, _tabPreviewLoadScript } from './tab-preview.js';

let _api = null;
let _apiMount = null; // the DOM node the api was built on (re-injection guard)
let _apiStaff = ''; // the staff profile the api was built with
let _domHandler = null; // the capture-phase mousedown fallback (removed on destroy)
let _beatMap = null;
let _renderedKey = ''; // editGen|arr|session — regen only on real change
let _debounce = 0;

const $mount = () => document.getElementById('editor-tabview-mount');

// Restore the staff preference — a READING preference, never chart data, so
// it lives in localStorage like the loop-snap mode does.
try {
const m = localStorage.getItem('editorTabViewStaff');
if (m === 'notation' || m === 'both') S.tabViewStaff = m;
} catch (_) { /* storage unavailable — the 'tab' default stands */ }

// The staff preference → alphaTab's StaveProfile enum KEY. A pure string map
// (no alphaTab needed) so the contract is pinnable in node, and so an
// unknown/legacy stored value degrades to tab instead of throwing.
export function _scoreStaffProfilePure(staff) {
if (staff === 'notation') return 'Score';
if (staff === 'both') return 'ScoreTab';
return 'Tab';
}

function _keyNow() {
return `${editGen}|${S.currentArr}|${S.sessionId}`;
}

function _destroyApi() {
if (_api) { try { _api.destroy(); } catch (_) { /* best-effort */ } }
// Our capture-phase DOM listener is our own closure — alphaTab.destroy()
// never touches it, so remove it here or a same-node rebuild (e.g. a
// staff switch) accumulates a live listener on the surviving mount.
if (_apiMount && _domHandler) {
try { _apiMount.removeEventListener('mousedown', _domHandler, true); } catch (_) { /* best-effort */ }
}
_domHandler = null;
_api = null;
_apiMount = null;
_apiStaff = '';
_beatMap = null;
_renderedKey = '';
}

// Build (or rebuild after a screen re-injection replaced the mount) and wire
// the click-to-select: alphaTab reports the clicked beat's bar + in-bar index,
// which is exactly how the generator's beatMap is keyed.
function _ensureApi(mount) {
if (_api && _apiMount === mount) return _api;
export function _ensureApi(mount) {
if (_api && _apiMount === mount && _apiStaff === S.tabViewStaff) return _api;
_destroyApi();
/* global alphaTab */
_api = new alphaTab.AlphaTabApi(mount, {
// The music font MUST come from the same pinned CDN — a null/missing
// fontDirectory engraves an invisible score (glyphs never load).
core: { fontDirectory: TAB_RENDERER_FONT_DIR, includeNoteBounds: true },
display: { layoutMode: alphaTab.LayoutMode.Page, scale: 0.85 },
display: {
layoutMode: alphaTab.LayoutMode.Page,
scale: 0.85,
staveProfile: alphaTab.StaveProfile[_scoreStaffProfilePure(S.tabViewStaff)],
},
// The interaction layer (beat clicks + the bounds lookup behind them)
// is gated on the player flag in this alphaTab line — enable it with
// NO soundfont and no cursor: nothing downloads, nothing sounds, the
// editor still owns all audio; we only want clickable beats.
player: { enablePlayer: true, enableCursor: false, enableUserInteraction: true },
});
_apiMount = mount;
_apiStaff = S.tabViewStaff;
const select = (beat) => {
try {
const barIdx = beat.voice.bar.index;
Expand All @@ -89,7 +123,7 @@ function _ensureApi(mount) {
// the events it consumes — so this fallback listens in CAPTURE phase (it
// runs before alphaTab's own handlers, whatever they swallow) and pairs
// the plain DOM click with the bounds lookup.
mount.addEventListener('mousedown', (e) => {
_domHandler = (e) => {
try {
const lookup = _api && _api.renderer && _api.renderer.boundsLookup;
if (!lookup || !lookup.getBeatAtPos) return;
Expand All @@ -99,7 +133,8 @@ function _ensureApi(mount) {
e.clientY - r.top + mount.scrollTop);
if (beat) select(beat);
} catch (_) { /* no lookup yet — ignore */ }
}, true);
};
mount.addEventListener('mousedown', _domHandler, true);
return _api;
}

Expand Down Expand Up @@ -208,6 +243,28 @@ export function editorToggleTabView(force) {
return true;
}

// The menu's Score-staff radio: getter feeds the checkmarks, setter applies
// + persists. Picking a staff while the score view is OFF also enters it —
// choosing what to read implies wanting to read.
export function editorTabViewStaff() { return S.tabViewStaff; }

export function editorSetTabViewStaff(staff) {
const v = (staff === 'notation' || staff === 'both') ? staff : 'tab';
const label = v === 'notation' ? 'standard notation'
: v === 'both' ? 'notation + tab' : 'tablature';
if (v !== S.tabViewStaff) {
S.tabViewStaff = v;
try { localStorage.setItem('editorTabViewStaff', v); } catch (_) { /* preference just won't persist */ }
// A different staff needs a rebuilt renderer (staveProfile is a
// construction-time setting) AND a re-render: _ensureApi sees the
// staff mismatch, _renderedKey forces the ping to regenerate.
_renderedKey = '';
}
if (!S.tabViewMode) { editorToggleTabView(true); return; }
setStatus(`Score staff: ${label}.`);
host.draw();
}

// Session teardown: the mount is being replaced wholesale — drop the api so
// the next entry rebuilds against the fresh DOM.
export function teardownTabView() {
Expand Down
14 changes: 14 additions & 0 deletions tests/menu_model.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,20 @@ t('v3-only theme cycling hides outside the v3 layout', () => {
assert.strictEqual(labels({ ...CTX, v3: true }).includes('Theme: Dark → Medium → Light'), true);
});

t('Score-staff trio renders as a radio from ctx.scoreStaff; absent ctx unchecks all', () => {
const staffItems = (ctx) => _menuModelPure(EDITOR_MENUS, rows, ctx)
.find((m) => m.title === 'View').items
.filter((i) => i.dispatch && i.dispatch.scoreStaff);
const withPref = staffItems({ ...CTX, scoreStaff: 'notation' });
assert.strictEqual(withPref.length, 3, 'tab / notation / both');
assert.deepStrictEqual(withPref.map((i) => i.label[0] === '✓'),
[false, true, false], 'exactly the active staff is checked');
assert.deepStrictEqual(withPref.map((i) => i.dispatch.scoreStaff),
['tab', 'notation', 'both']);
const older = staffItems(CTX); // ctx without scoreStaff (older caller)
assert.ok(older.every((i) => i.label[0] !== '✓'), 'degrades unchecked, never crashes');
});

t('Help ▸ User Guide surfaces, enabled, and dispatches to its toggle', () => {
const model = _menuModelPure(EDITOR_MENUS, rows, CTX);
const help = model.find((m) => m.title === 'Help');
Expand Down
121 changes: 121 additions & 0 deletions tests/tab_view_staff.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
/*
* The Score-staff preference (live Tab/Score view): the staff → alphaTab
* StaveProfile mapping, the localStorage restore-on-load, and the setter's
* validate + persist + re-render-arming behavior.
*
* Pinned here: unknown/legacy stored values degrade to 'tab' (never throw
* into the renderer), the preference is a READING preference restored at
* module load, and picking a staff while the score view is off ENTERS it.
*
* Fails on main (the exports don't exist there).
* Run: node tests/tab_view_staff.test.mjs
*/
import assert from 'node:assert';

// localStorage stub BEFORE the module import — the restore-on-load read
// happens at import time, which is exactly what the first test pins.
const stored = { editorTabViewStaff: 'both' };
const setCalls = [];
globalThis.localStorage = {
getItem: (k) => (k in stored ? stored[k] : null),
setItem: (k, v) => { stored[k] = v; setCalls.push([k, v]); },
};
globalThis.document = globalThis.document || {
getElementById: () => null, addEventListener: () => {}, activeElement: null,
};
globalThis.window = globalThis.window || globalThis;

// Minimal alphaTab stub so _ensureApi can build without the CDN bundle. Only
// the surface _ensureApi touches at construction time is stubbed.
globalThis.alphaTab = {
LayoutMode: { Page: 'Page' },
StaveProfile: { Tab: 'Tab', Score: 'Score', ScoreTab: 'ScoreTab' },
AlphaTabApi: class { constructor(mount, opts) { this.mount = mount; this.opts = opts; this.renderer = {}; } destroy() {} },
};

const { S } = await import('../src/state.js');
const { _scoreStaffProfilePure, editorSetTabViewStaff, editorTabViewStaff, _ensureApi } =
await import('../src/tab-view-live.js');

// A mount that records add/removeEventListener so we can count live listeners.
function fakeMount() {
const listeners = [];
return {
listeners,
addEventListener(type, fn, capture) { listeners.push({ type, fn, capture }); },
removeEventListener(type, fn, capture) {
const i = listeners.findIndex((l) => l.type === type && l.fn === fn && l.capture === capture);
if (i >= 0) listeners.splice(i, 1);
},
getBoundingClientRect: () => ({ left: 0, top: 0 }),
scrollLeft: 0, scrollTop: 0,
};
}

let pass = 0, fail = 0;
function t(name, fn) {
try { fn(); pass++; console.log(' ok ' + name); }
catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); }
}

t('a stored preference is restored at module load', () => {
assert.strictEqual(S.tabViewStaff, 'both');
assert.strictEqual(editorTabViewStaff(), 'both');
});

t('staff → StaveProfile key: the three real values', () => {
assert.strictEqual(_scoreStaffProfilePure('tab'), 'Tab');
assert.strictEqual(_scoreStaffProfilePure('notation'), 'Score');
assert.strictEqual(_scoreStaffProfilePure('both'), 'ScoreTab');
});

t('unknown/legacy values degrade to Tab, never throw into the renderer', () => {
assert.strictEqual(_scoreStaffProfilePure('score'), 'Tab');
assert.strictEqual(_scoreStaffProfilePure(''), 'Tab');
assert.strictEqual(_scoreStaffProfilePure(undefined), 'Tab');
assert.strictEqual(_scoreStaffProfilePure(null), 'Tab');
});

t('the setter validates, applies, and persists', () => {
S.tabViewMode = true; // stay off the enter-the-view path
editorSetTabViewStaff('notation');
assert.strictEqual(S.tabViewStaff, 'notation');
assert.deepStrictEqual(setCalls.pop(), ['editorTabViewStaff', 'notation']);
editorSetTabViewStaff('nonsense');
assert.strictEqual(S.tabViewStaff, 'tab', 'junk input degrades to tab');
assert.deepStrictEqual(setCalls.pop(), ['editorTabViewStaff', 'tab']);
});

t('re-picking the current staff does not re-persist', () => {
S.tabViewMode = true;
const n = setCalls.length;
editorSetTabViewStaff('tab');
assert.strictEqual(setCalls.length, n);
});

t('picking a staff while the score view is off enters it (guard: needs a song)', () => {
S.tabViewMode = false;
Object.assign(S, { arrangements: [{ name: 'Lead' }], currentArr: 0, sel: new Set() });
editorSetTabViewStaff('both');
assert.strictEqual(S.tabViewStaff, 'both');
assert.strictEqual(S.tabViewMode, true, 'choosing what to read implies wanting to read');
});

t('a staff-change rebuild on the same mount does not leak DOM mousedown listeners', () => {
const mount = fakeMount();
const count = () => mount.listeners.filter((l) => l.type === 'mousedown').length;
S.tabViewStaff = 'tab';
_ensureApi(mount);
assert.strictEqual(count(), 1, 'first build wires exactly one capture listener');
// Same node, different staff -> _ensureApi rebuilds (staveProfile is a
// construction-time setting). Pre-fix the old listener stayed bound.
S.tabViewStaff = 'notation';
_ensureApi(mount);
assert.strictEqual(count(), 1, 'rebuild removes the stale listener instead of stacking a second');
S.tabViewStaff = 'both';
_ensureApi(mount);
assert.strictEqual(count(), 1, 'still one after a third staff switch');
});

console.log(`\n${pass} passed, ${fail} failed`);
process.exit(fail ? 1 : 0);