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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
they arrive in the editor) will move together. *(Persisting the shift into the
built pack is a follow-up — the value is wired onto the save/load path and
honored on load, pending the pack field.)*
- **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:
Expand Down
42 changes: 40 additions & 2 deletions src/anchor-resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */

Expand Down Expand Up @@ -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) {
Expand Down
30 changes: 30 additions & 0 deletions src/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions src/input.js
Original file line number Diff line number Diff line change
Expand Up @@ -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, _ensureOnsetsShifted, 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';
Expand Down Expand Up @@ -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; }
Expand Down
1 change: 1 addition & 0 deletions src/menu-bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ export const EDITOR_MENUS = Object.freeze([
] },
{ title: 'Note', items: [
{ cmd: 'editFret' },
{ cmd: 'suggestFingers' },
{ cmd: 'fretUp' },
{ cmd: 'fretDown' },
{ cmd: 'noteMenu' },
Expand Down
30 changes: 30 additions & 0 deletions src/position.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
1 change: 1 addition & 0 deletions src/shortcuts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand Down
92 changes: 92 additions & 0 deletions tests/auto_fingering.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading