From a38ad98a1df5cdeeeffe902e8639cdc63f43a6f5 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Mon, 13 Jul 2026 00:38:35 -0500 Subject: [PATCH] feat(editor): Suggest fret-hand fingers (auto-fingering) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gap audit's flagship finding: every piece of fret_finger plumbing already existed — the teaching mark, its Guitar-Pro/XML round-trip, the fretboard strip that DISPLAYS fingers — but nothing ever PROPOSED a finger. It was manual-only. Note ▸ Suggest fret-hand fingers now assigns a fingering to every fretted note from its fret relative to the hand anchor covering its time: index (1) at the anchor fret, one finger per fret across the four-fret span, clamped 1..4; open strings → none; notes outside a reachable hand position → left untouched (a different anchor owns them). Fingers the selection when there is one, else the whole track, in ONE undoable step. - Pures in src/position.js: `_suggestFingerForFretPure` (fret+anchor → finger, reusing the existing `_activeAnchorAtPure` window model) and `_suggestFingersPure`. - New `SetTeachingMarksCmd` (src/commands.js): per-note teaching-mark assignment in one undo step — the plural of the existing single-value SetTeachingMarkCmd. - Verb `editorSuggestFingers` (src/anchor-resolve.js, beside editorResolveAnchorWindow); registry entry + Note-menu item; dispatched through `_editorRunEofCommand`. `tests/auto_fingering.test.mjs` (6: the finger pure incl. open/refuse/clamp, the map-and-omit, and the SetTeachingMarksCmd per-note exec→undo→redo). 129 JS suites green (menu_model incl.), lint 0-err (3 pre-existing warnings). routes.py untouched. Verified live on AC/DC — Back In Black: Note ▸ Suggest fret-hand fingers → "Suggested fret-hand fingers for 1328 notes in the arrangement", one undoable command, no errors. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01EBQCHCNA81E9tHmSDHSe2Q --- CHANGELOG.md | 10 ++++ src/anchor-resolve.js | 42 +++++++++++++++- src/commands.js | 30 ++++++++++++ src/input.js | 2 + src/menu-bar.js | 1 + src/position.js | 30 ++++++++++++ src/shortcuts.js | 1 + tests/auto_fingering.test.mjs | 92 +++++++++++++++++++++++++++++++++++ 8 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 tests/auto_fingering.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index ad704585..f7b0b5ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -106,6 +106,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Suggest fret-hand fingers.** **Note ▸ Suggest fret-hand fingers** now + proposes a fingering (1–4, or none for open strings) for every fretted note — + from each note's fret relative to the hand anchor covering its time: the index + finger sits at the anchor fret, one finger per fret across the four-fret span. + It fingers the selection when you have one, otherwise the whole track, in a + single undoable step. Notes that don't sit in a reachable hand position are + left untouched (a different anchor owns them), and open strings are marked as + no-finger. All the plumbing already existed — the `fret_finger` teaching mark, + its Guitar-Pro/XML round-trip, and the fretboard strip that *shows* fingers — + but until now nothing ever *proposed* one; this closes that gap. - **Song Fit — one place to line the chart up with the recording.** A **Song Fit…** button in the Tempo Map inspector opens a small menu with the three ways to fit a chart to audio, each labelled with what it does to your notes: diff --git a/src/anchor-resolve.js b/src/anchor-resolve.js index 679a6379..6daa5185 100644 --- a/src/anchor-resolve.js +++ b/src/anchor-resolve.js @@ -22,9 +22,10 @@ import { S } from './state.js'; import { host } from './host.js'; import { setStatus } from './ui.js'; import { _isSuggested, notes } from './notes.js'; -import { _suggestPositionPure } from './position.js'; +import { _activeAnchorAtPure, _suggestFingersPure, _suggestPositionPure } from './position.js'; import { _openMidiForArr, _soundingPitchPure, _stringCountFor } from './lanes.js'; -import { AcceptPositionsCmd, _prevNoteBefore } from './commands.js'; +import { AcceptPositionsCmd, SetTeachingMarksCmd, _prevNoteBefore } from './commands.js'; +import { isKeysMode } from './keys.js'; /* @pure:anchor-resolve:start */ @@ -248,6 +249,43 @@ export function editorResolveAnchorWindow(anchor) { sweepFocus(); } +// Auto-fingering: propose a fret-hand finger (1-4, or none for open strings) for +// every fretted note in the selection (or the whole arrangement when nothing is +// selected), from each note's fret relative to the hand anchor covering its time. +// All the plumbing already existed — the fret_finger teaching mark, its XML +// round-trip, the fretboard-strip display — but nothing ever PROPOSED a finger. +// One undoable SetTeachingMarksCmd; notes outside a reachable hand span are left +// untouched (a different position owns them). +export function editorSuggestFingers() { + if (isKeysMode()) { setStatus('Fret-hand fingering is for fretted (guitar/bass) parts.'); return; } + const arr = S.arrangements && S.arrangements[S.currentArr]; + if (!arr) return; + const anchors = _resolveAnchorsPure(arr); + const nn = notes(); + const idxs = (S.sel && S.sel.size) ? [...S.sel] : nn.map((_, i) => i); + const items = []; + for (const i of idxs) { + const n = nn[i]; + if (!n || !Number.isFinite(n.fret)) continue; + const a = _activeAnchorAtPure(anchors, n.time); + items.push({ + idx: i, + fret: n.fret, + anchorFret: a && Number.isFinite(a.fret) ? a.fret : null, + width: a && Number.isFinite(a.width) ? a.width : 4, + }); + } + const assigns = _suggestFingersPure(items); + if (!assigns.length) { + setStatus('No fingers to suggest — set an anchor (Shift+F) so notes sit in a hand position, then try again.'); + return; + } + S.history.exec(new SetTeachingMarksCmd('fret_finger', assigns)); + host.draw(); + const scope = (S.sel && S.sel.size) ? 'the selection' : 'the arrangement'; + setStatus(`Suggested fret-hand fingers for ${assigns.length} note${assigns.length === 1 ? '' : 's'} in ${scope}.`); +} + export function initAnchorResolve() { const bar = $bar(); if (bar) { diff --git a/src/commands.js b/src/commands.js index 714b4534..df59cc1d 100644 --- a/src/commands.js +++ b/src/commands.js @@ -363,6 +363,36 @@ export class SetTeachingMarkCmd { } } +// Per-note teaching-mark assignment (auto-fingering): each note gets its OWN +// value in one undoable step — SetTeachingMarkCmd sets a single value across +// notes; this sets a distinct value per note. `assignments`: [{ idx, value }]. +export class SetTeachingMarksCmd { + constructor(key, assignments) { + this.key = key; + this.items = (assignments || []).map(a => ({ + idx: a.idx, + value: Number.isInteger(a.value) ? a.value : -1, + old: (notes()[a.idx] && notes()[a.idx].techniques || {})[key], + })); + } + exec() { + for (const it of this.items) { + const n = notes()[it.idx]; + if (!n) continue; + if (!n.techniques) n.techniques = {}; + n.techniques[this.key] = it.value; + } + } + rollback() { + for (const it of this.items) { + const n = notes()[it.idx]; + if (!n) continue; + if (!n.techniques) n.techniques = {}; + n.techniques[this.key] = it.old; + } + } +} + export class SetPitchedSlideTargetsCmd { constructor(indices, delta) { this.indices = indices.slice(); diff --git a/src/input.js b/src/input.js index 4bae248d..34b9664a 100644 --- a/src/input.js +++ b/src/input.js @@ -6,6 +6,7 @@ import { AddAnchorCmd, AddHandshapeCmd, AddToneChangeCmd, RemoveAnchorCmd, RemoveHandshapeCmd, RemoveToneChangeCmd, _anchorLaneTopY, _currentAnchorArr, _currentToneArr, _ensureTones, _handshapeLaneTopY, _readAnchorSnapshot, onAnchorLaneContextMenu, onHandshapeLaneContextMenu, onToneLaneContextMenu } from './annotation-lanes.js'; import { _editBlipAt, _editorToggleFollow, _editorToggleGuideClap, _editorToggleLoopAB, _editorToggleMetronome, _editorToggleOnsetStrip, _editorToggleSnapMode, _ensureOnsets, startPlayback, stopPlayback } from './audio.js'; +import { editorSuggestFingers } from './anchor-resolve.js'; import { _suggestActive, _suggestCompute, _suggestDismiss } from './tempo-suggest.js'; import { editorToggleMixerPanel } from './mixer-panel.js'; import { canvas } from './canvas.js'; @@ -783,6 +784,7 @@ export function _editorRunEofCommand(cmd) { case 'snapUp': window.editorSetSnap(Math.min(SNAP_VALUES.length - 1, S.snapIdx + 1)); return true; case 'toggleSnapMode': return _editorToggleSnapMode(); case 'editFret': { const idxs = _editorCurrentNoteIndices(); if (idxs.length) promptFret(idxs[0]); else setStatus('Select a note first'); return true; } + case 'suggestFingers': editorSuggestFingers(); return true; case 'setFretTen': return _editorSetSelectedFret(10); case 'noteMenu': { const idxs = _editorCurrentNoteIndices(); if (idxs.length) showContextMenu(window.innerWidth / 2, window.innerHeight / 2, idxs[0]); else setStatus('Select a note first'); return true; } case 'bend': { const idxs = _editorCurrentNoteIndices(); if (idxs.length) promptBend(idxs[0]); else setStatus('Select a note first'); return true; } diff --git a/src/menu-bar.js b/src/menu-bar.js index 39c43970..8dbf8fb3 100644 --- a/src/menu-bar.js +++ b/src/menu-bar.js @@ -92,6 +92,7 @@ export const EDITOR_MENUS = Object.freeze([ ] }, { title: 'Note', items: [ { cmd: 'editFret' }, + { cmd: 'suggestFingers' }, { cmd: 'fretUp' }, { cmd: 'fretDown' }, { cmd: 'noteMenu' }, diff --git a/src/position.js b/src/position.js index 6ec5ade1..1d1779b1 100644 --- a/src/position.js +++ b/src/position.js @@ -147,3 +147,33 @@ export function _suggestPositionPure(pitch, time, prevNote, anchorList, occupied })[0]; return { resolved: best, reason: null, candidates }; } + +/* @pure:suggest-fingers:start */ +// The fret-hand finger for a note at `fret`, hand anchored at `anchorFret` with a +// `width`-fret span (default 4). Open string (fret 0) → -1 (none — no fretting +// finger). Inside the window [anchorFret, anchorFret+width) → one finger per +// fret, index (1) at the anchor fret, clamped to 1..4. Outside the reachable +// window, or with no hand position → null (refuse: a different hand position owns it). +export function _suggestFingerForFretPure(fret, anchorFret, width) { + if (!Number.isFinite(fret)) return null; + if (fret <= 0) return -1; // open string → none + if (!Number.isFinite(anchorFret)) return null; // no hand position → refuse + const span = Number.isFinite(width) && width > 0 ? width : 4; + const off = fret - anchorFret; + if (off < 0 || off >= span) return null; // outside the hand → refuse + return Math.min(4, off + 1); // 1..4 +} + +// Map notes → suggested fret_finger. `items`: [{ idx, fret, anchorFret, width }]. +// Returns [{ idx, value }] for every note the rule can finger (open strings → -1 +// none, in-window frets → 1..4); notes it refuses (outside the hand span, or no +// anchor) are OMITTED so their existing marks are left untouched. Pure. +export function _suggestFingersPure(items) { + const out = []; + for (const it of (Array.isArray(items) ? items : [])) { + const v = _suggestFingerForFretPure(it.fret, it.anchorFret, it.width); + if (v !== null) out.push({ idx: it.idx, value: v }); + } + return out; +} +/* @pure:suggest-fingers:end */ diff --git a/src/shortcuts.js b/src/shortcuts.js index 4ed9887e..42851da0 100644 --- a/src/shortcuts.js +++ b/src/shortcuts.js @@ -68,6 +68,7 @@ const EDITOR_SHORTCUT_COMMANDS = Object.freeze([ { id: 'snapUp', label: 'Increase snap resolution', group: 'Grid and sustain', status: 'ready', keys: { feedback: '.', eof: '.' } }, { id: 'toggleSnapMode', label: 'Toggle snap target (grid / audio onset)', group: 'Grid and sustain', status: 'ready', keys: { feedback: '', eof: '' } }, { id: 'editFret', label: 'Edit fret / fingering', group: 'Notes', status: 'ready', keys: { feedback: 'F', eof: 'F / Ctrl+F' } }, + { id: 'suggestFingers', label: 'Suggest fret-hand fingers', group: 'Notes', status: 'ready', keys: { feedback: '', eof: '' } }, { id: 'setFretDigit', label: 'Set selected fret 0-9', group: 'Notes', status: 'ready', keys: { feedback: '0-9', eof: '0-9' } }, { id: 'setFretTen', label: 'Set selected fret 10', group: 'Notes', status: 'ready', keys: { feedback: 'Shift+0', eof: 'Shift+0' } }, { id: 'noteMenu', label: 'Open note edit menu', group: 'Notes', status: 'ready', keys: { feedback: '', eof: 'N' } }, diff --git a/tests/auto_fingering.test.mjs b/tests/auto_fingering.test.mjs new file mode 100644 index 00000000..6881eaa8 --- /dev/null +++ b/tests/auto_fingering.test.mjs @@ -0,0 +1,92 @@ +/* + * Auto-fingering — propose a fret-hand finger for every fretted note from its + * fret relative to the hand anchor. All the fret_finger plumbing already existed + * (the teaching mark, its XML round-trip, the fretboard-strip display) but + * nothing ever PROPOSED a finger; this fills that gap. Proves: + * 1. _suggestFingerForFretPure — open→none, one finger per fret in the window, + * refuse outside the reachable hand span / with no anchor. + * 2. _suggestFingersPure — maps notes, omits the refused (leaving their marks). + * 3. SetTeachingMarksCmd — per-note assignment as one undoable step. + * + * Run: node tests/auto_fingering.test.mjs + */ +import assert from 'node:assert'; +import { S } from '../src/state.js'; +import { EditHistory } from '../src/history.js'; +import { _suggestFingerForFretPure, _suggestFingersPure } from '../src/position.js'; +import { SetTeachingMarksCmd } from '../src/commands.js'; +import { seedState, trackHooks } from './_history_env.mjs'; + +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); } +} + +// ── 1. _suggestFingerForFretPure ───────────────────────────────────────────── +t('open string → none (-1), regardless of anchor', () => { + assert.strictEqual(_suggestFingerForFretPure(0, 5), -1); + assert.strictEqual(_suggestFingerForFretPure(0, null), -1); +}); +t('one finger per fret across the hand window (index at the anchor)', () => { + assert.strictEqual(_suggestFingerForFretPure(5, 5), 1); + assert.strictEqual(_suggestFingerForFretPure(6, 5), 2); + assert.strictEqual(_suggestFingerForFretPure(7, 5), 3); + assert.strictEqual(_suggestFingerForFretPure(8, 5), 4); +}); +t('refuse (null) below the anchor and past the 4-fret span', () => { + assert.strictEqual(_suggestFingerForFretPure(4, 5), null); // below the anchor + assert.strictEqual(_suggestFingerForFretPure(9, 5), null); // off=4, beyond width 4 +}); +t('refuse with no hand position; clamp a wide window to 4', () => { + assert.strictEqual(_suggestFingerForFretPure(5, null), null); + assert.strictEqual(_suggestFingerForFretPure(11, 5, 8), 4); // off=6 → clamp 4 + assert.strictEqual(_suggestFingerForFretPure(NaN, 5), null); +}); + +// ── 2. _suggestFingersPure ─────────────────────────────────────────────────── +t('_suggestFingersPure fingers what it can and OMITS the refused', () => { + const out = _suggestFingersPure([ + { idx: 0, fret: 5, anchorFret: 5, width: 4 }, // → 1 + { idx: 1, fret: 0, anchorFret: 5, width: 4 }, // open → -1 + { idx: 2, fret: 20, anchorFret: 5, width: 4 }, // out of hand → omitted + { idx: 3, fret: 7, anchorFret: 5, width: 4 }, // → 3 + ]); + assert.deepStrictEqual(out, [ + { idx: 0, value: 1 }, { idx: 1, value: -1 }, { idx: 3, value: 3 }, + ], 'idx 2 (unreachable) is left untouched'); +}); + +// ── 3. SetTeachingMarksCmd round-trip ──────────────────────────────────────── +function seed() { + trackHooks(); + seedState({ + arrangements: [{ name: 'Guitar', notes: [ + { string: 0, fret: 5, time: 0, techniques: {} }, + { string: 1, fret: 7, time: 1, techniques: { fret_finger: 2 } }, // has a prior mark + { string: 2, fret: 0, time: 2, techniques: {} }, + ], chords: [] }], + currentArr: 0, + history: new EditHistory(), + }); +} +const fingerOf = i => S.arrangements[0].notes[i].techniques.fret_finger; +t('SetTeachingMarksCmd assigns per-note fingers and round-trips exec→undo→redo', () => { + seed(); + S.history.exec(new SetTeachingMarksCmd('fret_finger', [ + { idx: 0, value: 1 }, { idx: 1, value: 3 }, { idx: 2, value: -1 }, + ])); + assert.strictEqual(fingerOf(0), 1); + assert.strictEqual(fingerOf(1), 3, 'overwrote the prior mark'); + assert.strictEqual(fingerOf(2), -1); + + S.history.doUndo(); + assert.strictEqual(fingerOf(0), undefined, 'undo restored the (unset) prior'); + assert.strictEqual(fingerOf(1), 2, 'undo restored the prior mark exactly'); + + S.history.doRedo(); + assert.strictEqual(fingerOf(1), 3, 'redo re-applied'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);