diff --git a/CHANGELOG.md b/CHANGELOG.md index 07ccd708..ab4f4fe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **Resolving positions now shapes chords as one coherent grip.** When you run + "Resolve positions" over an anchor window, simultaneous notes (a chord) used + to be placed one at a time, each grabbing its own lowest free fret — which + could spread a chord across the neck into a stretch no hand can play (and that + the playability lint then flags). The resolver now places a chord's notes + **together**, choosing the tightest fret-hand shape (smallest fret span, + distinct strings, open strings free) that fits the hand, pulled toward the + anchor / previous note. A cluster with no playable grip falls back to the old + per-note behaviour, so nothing that resolved before stops resolving — and the + refusals stay honest: a note that could be played open **or** fretted is still + refused for you to decide, never quietly voiced open to tighten the shape. - **Flattening a variable tempo map now names both directions** instead of a bare confirm. Typing a BPM for a song with multiple tempos opens a small in-app dialog: **Conform notes to the new tempo** (notes keep their bar:beat positions diff --git a/node_modules b/node_modules new file mode 120000 index 00000000..c5cf027d --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/byron/Repositories/feedback-plugin-editor/node_modules \ No newline at end of file diff --git a/src/anchor-resolve.js b/src/anchor-resolve.js index 6daa5185..33f44488 100644 --- a/src/anchor-resolve.js +++ b/src/anchor-resolve.js @@ -22,7 +22,8 @@ import { S } from './state.js'; import { host } from './host.js'; import { setStatus } from './ui.js'; import { _isSuggested, notes } from './notes.js'; -import { _activeAnchorAtPure, _suggestFingersPure, _suggestPositionPure } from './position.js'; +import { _activeAnchorAtPure, _resolveChordGripPure, _suggestFingersPure, _suggestPositionPure } from './position.js'; +import { LINT_CLUSTER_EPSILON, LINT_DEFAULT_WINDOW, LINT_STRETCH_TOLERANCE } from './playability-lint.js'; import { _openMidiForArr, _soundingPitchPure, _stringCountFor } from './lanes.js'; import { AcceptPositionsCmd, SetTeachingMarksCmd, _prevNoteBefore } from './commands.js'; import { isKeysMode } from './keys.js'; @@ -59,6 +60,12 @@ export function _anchorWindowPure(anchors, anchor) { // Occupancy mirrors the roll writer: a note can't share a string with any // other note sounding at its instant; earlier repicks in this same pass // claim their new strings for later ones. +// +// Simultaneous notes (a CHORD) are resolved JOINTLY as one coherent grip +// (_resolveChordGripPure) — minimum fretted span within the hand — instead of +// each note greedily picking its own lowest fret and spreading the chord until +// the stretch lint scolds it. A cluster with no coherent grip (or a single +// note) falls back to the per-note resolver, so behaviour never regresses. export function _resolveWindowPure(nn, win, anchors, ctx, isSuggestedFn) { const out = { moves: [], refused: [], targets: [] }; if (!Array.isArray(nn) || !win || !ctx) return out; @@ -77,38 +84,93 @@ export function _resolveWindowPure(nn, win, anchors, ctx, isSuggestedFn) { const fretOf = new Map(); nn.forEach((n, i) => { if (n) { strOf.set(i, n.string); fretOf.set(i, n.fret); } }); - for (const { n, i } of targets) { - const pitch = _soundingPitchPure(ctx.openMidi, ctx.tuning, ctx.capo, n.string, n.fret); - if (pitch === null) { out.refused.push({ index: i, reason: 'out-of-range' }); continue; } + // Strings occupied at `n`'s instant by notes OUTSIDE `exceptIdxs` (a Set of + // this cluster's indices), using the in-pass working positions. + const occupiedFor = (n, exceptIdxs) => { const nEnd = n.time + (Number(n.sustain) || 0); const occ = new Set(); nn.forEach((o, j) => { - if (j === i || !o || !Number.isFinite(o.time)) return; + if (exceptIdxs.has(j) || !o || !Number.isFinite(o.time)) return; const oEnd = o.time + (Number(o.sustain) || 0); if (n.time <= oEnd + 1e-6 && nEnd >= o.time - 1e-6) occ.add(strOf.get(j)); }); - // ctx.prevFretAt returns { idx, fret } (or null): the fret comes from - // the arrangement, so override it with this pass's repick when the - // previous note was one of ours. - let prev = null; - if (ctx.prevFretAt) { - const p = ctx.prevFretAt(n.time, i); - if (p && Number.isFinite(p.fret)) { - prev = { fret: fretOf.has(p.idx) ? fretOf.get(p.idx) : p.fret }; - } + return occ; + }; + // The prior hand fret before `time` (excluding `exceptIdx`), read through the + // in-pass repicks so a cluster sees the hand its predecessors just moved to. + const prevFretAt = (time, exceptIdx) => { + if (!ctx.prevFretAt) return null; + const p = ctx.prevFretAt(time, exceptIdx); + if (!p || !Number.isFinite(p.fret)) return null; + return fretOf.has(p.idx) ? fretOf.get(p.idx) : p.fret; + }; + const recordMove = (i, n, newString, newFret) => { + if (newString === n.string && newFret === n.fret) { + // already where the resolver wants it — still claim the string + strOf.set(i, newString); fretOf.set(i, newFret); + return; } + out.moves.push({ index: i, oldString: n.string, oldFret: n.fret, newString, newFret }); + strOf.set(i, newString); fretOf.set(i, newFret); + }; + // Per-note greedy resolve (the pre-existing policy; the fallback path). It + // excludes only the note ITSELF from occupancy, so a cluster's siblings still + // block each other's strings through their in-pass working positions — + // exactly the original per-note behaviour. + const resolveOne = (n, i) => { + const pitch = _soundingPitchPure(ctx.openMidi, ctx.tuning, ctx.capo, n.string, n.fret); + if (pitch === null) { out.refused.push({ index: i, reason: 'out-of-range' }); return; } + const occ = occupiedFor(n, new Set([i])); + const pf = prevFretAt(n.time, i); + const prev = pf !== null ? { fret: pf } : null; const res = _suggestPositionPure(pitch, n.time, prev, anchors, occ, ctx); - if (!res.resolved) { out.refused.push({ index: i, reason: res.reason }); continue; } - if (res.resolved.string === n.string && res.resolved.fret === n.fret) { - continue; // already where the resolver wants it + if (!res.resolved) { out.refused.push({ index: i, reason: res.reason }); return; } + recordMove(i, n, res.resolved.string, res.resolved.fret); + }; + + // Walk in time order, grouping simultaneous notes into chord clusters. + let gi = 0; + while (gi < targets.length) { + const t0 = targets[gi].n.time; + const group = []; + while (gi < targets.length && targets[gi].n.time <= t0 + LINT_CLUSTER_EPSILON) { group.push(targets[gi]); gi++; } + const groupIdx = new Set(group.map(g => g.i)); + + if (group.length >= 2) { + // Try one coherent grip for the whole cluster. + const clusterPitches = []; + let allSound = true; + for (const { n, i } of group) { + const pitch = _soundingPitchPure(ctx.openMidi, ctx.tuning, ctx.capo, n.string, n.fret); + if (pitch === null) { allSound = false; break; } + clusterPitches.push({ idx: i, pitch }); + } + if (allSound) { + const anchor = _activeAnchorAtPure(anchors, t0); + // `hand`, not `window` — a local named `window` shadows the browser + // global for the whole block, which is a trap waiting for the next edit. + const hand = anchor && Number.isFinite(anchor.width) && anchor.width > 0 ? anchor.width : LINT_DEFAULT_WINDOW; + const cfg = { + anchorFret: anchor && Number.isFinite(anchor.fret) ? anchor.fret : null, + window: hand, + maxSpan: hand + LINT_STRETCH_TOLERANCE, + }; + // Occupancy from non-cluster notes overlapping the cluster; the + // grip enforces distinct strings among the cluster itself. + const occ = occupiedFor(group[0].n, groupIdx); + for (let j = 1; j < group.length; j++) for (const s of occupiedFor(group[j].n, groupIdx)) occ.add(s); + const grip = _resolveChordGripPure(clusterPitches, ctx, cfg, prevFretAt(t0, group[0].i), occ); + if (grip) { + for (const a of grip.assignments) recordMove(a.idx, nn[a.idx], a.string, a.fret); + continue; // cluster handled jointly + } + } + // No coherent grip → fall back to per-note greedy for each cluster note. + for (const { n, i } of group) resolveOne(n, i); + continue; } - out.moves.push({ - index: i, - oldString: n.string, oldFret: n.fret, - newString: res.resolved.string, newFret: res.resolved.fret, - }); - strOf.set(i, res.resolved.string); - fretOf.set(i, res.resolved.fret); + // Singleton. + resolveOne(group[0].n, group[0].i); } return out; } diff --git a/src/position.js b/src/position.js index 1d1779b1..5d77b5a2 100644 --- a/src/position.js +++ b/src/position.js @@ -148,6 +148,114 @@ export function _suggestPositionPure(pitch, time, prevNote, anchorList, occupied return { resolved: best, reason: null, candidates }; } +/* @pure:chord-grip:start */ +// Resolve a SIMULTANEOUS cluster of notes to a coherent fret-hand GRIP, instead +// of resolving each note greedily (per-note _suggestPositionPure spreads a chord +// across the neck until the playability lint scolds the stretch). Given each +// note's sounding `pitch`, enumerate its playable positions, then choose ONE +// {string, fret} per note — all on DISTINCT strings — that MINIMISES the fretted +// span (open strings, fret 0, are free and don't count), pulled toward the hand +// (prevFret, else the anchor). Only a grip whose fretted span fits `cfg.maxSpan` +// (the lint's window + tolerance) is returned; otherwise null, so the caller +// leaves the cluster to the per-note path (still lint-flagged) rather than +// writing a different unplayable spread. +// +// cluster [{ idx, pitch }] the simultaneous notes (idx = caller's key) +// ctx { openMidi, tuning, capo } +// cfg { anchorFret|null, window, maxSpan } +// prevFret the previous note's fret (hand reference) or null +// occByOthers strings already sounding from NON-cluster notes at this instant +// +// Returns { assignments: [{ idx, string, fret }], span } (assignments in the +// input cluster order) or null. ALL-OR-NOTHING: every cluster note is placed or +// none is — a partial grip would leave a note neither moved nor refused, and the +// sweep's "Accept all" would then bulk-confirm a position nobody ever picked. +// Deterministic: the search order and every tie-break are pinned, so the same +// cluster always resolves to the same grip. +export function _resolveChordGripPure(cluster, ctx, cfg, prevFret, occByOthers) { + const items = Array.isArray(cluster) ? cluster : []; + // A malformed member is NOT quietly dropped (see all-or-nothing above). + if (!items.length || items.some(c => !c || !Number.isFinite(c.pitch))) return null; + const c = ctx || {}; + const cf = cfg || {}; + const occ = occByOthers instanceof Set ? occByOthers : new Set(occByOthers || []); + const anchorFret = Number.isFinite(cf.anchorFret) ? cf.anchorFret : null; + const hand = Number.isFinite(cf.window) && cf.window > 0 ? cf.window : 4; + const maxSpan = Number.isFinite(cf.maxSpan) && cf.maxSpan >= 0 ? cf.maxSpan : hand + 1; + + // Per-note eligible candidates: a free string, inside the hand window (open + // string fret 0 is always allowed — it needs no hand position). Bail if any + // note has nowhere to go — the whole cluster is left to the per-note path. + const perNote = []; + for (const it of items) { + const cands = _enumerateFrettedPositionsPure(it.pitch, c.openMidi, c.tuning, c.capo) + .filter(p => !occ.has(p.string)) + .filter(p => anchorFret === null || p.fret === 0 || (p.fret >= anchorFret && p.fret < anchorFret + hand)); + if (!cands.length) return null; + // Open vs fretted, both playable → a real articulation choice, and + // _suggestPositionPure REFUSES it rather than guess. The grip must not + // quietly decide it either — worse, open frets are free in the span + // metric, so the search actively PREFERS voicing a note open (a D+G + // dyad would collapse to two open strings, span 0). Bail: the caller's + // per-note path then refuses this note, honestly, as it always did. + if (cands.some(p => p.fret === 0) && cands.some(p => p.fret > 0)) return null; + perNote.push({ idx: it.idx, cands }); + } + // Assign the fewest-choice notes first (fail fast, and pins the walk order). + const order = perNote.map((_, k) => k).sort((a, b) => perNote[a].cands.length - perNote[b].cands.length); + const ref = (prevFret !== null && Number.isFinite(prevFret)) ? prevFret : anchorFret; + + const frettedSpan = (picks) => { + const frets = picks.map(p => p.fret).filter(f => f > 0); + return frets.length ? Math.max(...frets) - Math.min(...frets) : 0; + }; + const scoreOf = (picks) => { + const frets = picks.map(p => p.fret).filter(f => f > 0); + const span = frets.length ? Math.max(...frets) - Math.min(...frets) : 0; + const travel = ref !== null ? picks.reduce((s, p) => s + (p.fret > 0 ? Math.abs(p.fret - ref) : 0), 0) : 0; + const maxFret = frets.length ? Math.max(...frets) : 0; + const strSum = picks.reduce((s, p) => s + p.string, 0); + return [span, travel, maxFret, strSum]; + }; + const lt = (a, b) => { + for (let i = 0; i < a.length; i++) { if (a[i] !== b[i]) return a[i] < b[i]; } + return false; + }; + + let best = null; // { picks, score } — picks parallel to `order` + const usedStr = new Set(); + const chosen = []; + const search = (oi) => { + // Prune: the fretted span only grows, so a partial over the ceiling (or + // already worse than the best full grip) can never win. + const ps = frettedSpan(chosen); + if (ps > maxSpan) return; + if (best && ps > best.score[0]) return; + if (oi === order.length) { + const score = scoreOf(chosen); + if (score[0] <= maxSpan && (!best || lt(score, best.score))) { + best = { picks: chosen.slice(), score }; + } + return; + } + for (const cand of perNote[order[oi]].cands) { + if (usedStr.has(cand.string)) continue; + usedStr.add(cand.string); chosen.push(cand); + search(oi + 1); + chosen.pop(); usedStr.delete(cand.string); + } + }; + search(0); + if (!best) return null; + const byIdx = new Map(); + order.forEach((k, ci) => byIdx.set(perNote[k].idx, best.picks[ci])); + return { + assignments: items.map(it => ({ idx: it.idx, string: byIdx.get(it.idx).string, fret: byIdx.get(it.idx).fret })), + span: best.score[0], + }; +} +/* @pure:chord-grip:end */ + /* @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 diff --git a/tests/chord_grip.test.mjs b/tests/chord_grip.test.mjs new file mode 100644 index 00000000..ce2f9879 --- /dev/null +++ b/tests/chord_grip.test.mjs @@ -0,0 +1,179 @@ +/* + * Chord-shape-aware joint position resolve (gap-audit #7). The bulk resolver + * used to place each note of a simultaneous cluster GREEDILY (lowest free fret, + * one at a time), which spreads a chord across the neck until the stretch lint + * scolds it. Now a cluster is resolved JOINTLY as one coherent grip + * (_resolveChordGripPure): the minimum-fretted-span distinct-string assignment + * within the hand. A cluster with no coherent grip falls back to the per-note + * path, so nothing regresses. + * + * Pinned: the grip search itself (min span, distinct strings, open strings are + * free, anchor-window eligibility, occupancy, the too-wide refusal), and the + * end-to-end _resolveWindowPure integration — a chord that greedy spreads to a + * 6-fret stretch now lands as a 2-fret grip (fails on main: greedy per-note). + * + * Standard 6-string, no capo: openMidi = [40,45,50,55,59,64]; sounding pitch of + * (string s, fret f) = openMidi[s] + f. Run: node --test tests/chord_grip.test.mjs + */ +import assert from 'node:assert'; + +globalThis.document = globalThis.document || { getElementById: () => null, addEventListener: () => {}, activeElement: null }; +globalThis.localStorage = globalThis.localStorage || { getItem: () => null, setItem: () => {} }; +globalThis.window = globalThis.window || globalThis; + +const { _resolveChordGripPure } = await import('../src/position.js'); +const { _resolveWindowPure } = await import('../src/anchor-resolve.js'); + +const OPEN = [40, 45, 50, 55, 59, 64]; +const CTX = { openMidi: OPEN, tuning: [0, 0, 0, 0, 0, 0], capo: 0 }; +const NOANCHOR = { anchorFret: null, window: 4, maxSpan: 5 }; +const N = (time, string, fret, sustain = 0) => ({ time, string, fret, sustain, techniques: {} }); +const grip = (r) => (r ? r.assignments.map(a => ({ i: a.idx, s: a.string, f: a.fret })) : null); + +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); } +} + +// ── _resolveChordGripPure ──────────────────────────────────────────────────── +t('picks the minimum-span distinct-string grip (60+62 → span 2, not 6)', () => { + // 60: s3f5 / s4f1 … 62: s4f3 / s3f7 … Greedy lowest-fret would take 60→s4f1 + // then 62→s3f7 (span 6). The tight grip is 60→s3f5, 62→s4f3 (span 2). + const r = _resolveChordGripPure([{ idx: 0, pitch: 60 }, { idx: 1, pitch: 62 }], CTX, NOANCHOR, null, new Set()); + assert.strictEqual(r.span, 2); + assert.deepStrictEqual(grip(r), [{ i: 0, s: 3, f: 5 }, { i: 1, s: 4, f: 3 }]); +}); + +// Anchor at fret 10, window 4 → [10,14). 60 fits only s2f10, 57 only s1f12, and +// 64 fits NOWHERE fretted — its open string (s5f0) is its ONE position, so it is +// not an articulation choice, just the only way to sound it. +const ANCH10 = { anchorFret: 10, window: 4, maxSpan: 5 }; +const OPENCHORD = [{ idx: 0, pitch: 60 }, { idx: 1, pitch: 57 }, { idx: 2, pitch: 64 }]; + +t('an unambiguous open string is free (not counted in the span)', () => { + const r = _resolveChordGripPure(OPENCHORD, CTX, ANCH10, null, new Set()); + assert.strictEqual(r.span, 2, 'fretted {10,12} = 2 — the open string is free'); + assert.deepStrictEqual(grip(r), [{ i: 0, s: 2, f: 10 }, { i: 1, s: 1, f: 12 }, { i: 2, s: 5, f: 0 }]); +}); + +t('every note lands on a distinct string', () => { + const r = _resolveChordGripPure(OPENCHORD, CTX, ANCH10, null, new Set()); + const strings = r.assignments.map(a => a.string); + assert.strictEqual(new Set(strings).size, strings.length, 'no two notes share a string'); +}); + +t('refuses (null) when even the tightest grip is wider than maxSpan', () => { + // The tightest 60+62 grip is span 2; a maxSpan of 1 has no coherent grip. + const r = _resolveChordGripPure([{ idx: 0, pitch: 60 }, { idx: 1, pitch: 62 }], + CTX, { anchorFret: null, window: 1, maxSpan: 1 }, null, new Set()); + assert.strictEqual(r, null); +}); + +t('routes around strings occupied by other (non-cluster) notes', () => { + // Block s3 and s4 (the natural grip). 60→s1f15, 62→s2f12 is then the min span (3). + const r = _resolveChordGripPure([{ idx: 0, pitch: 60 }, { idx: 1, pitch: 62 }], + CTX, NOANCHOR, null, new Set([3, 4])); + assert.deepStrictEqual(grip(r), [{ i: 0, s: 1, f: 15 }, { i: 1, s: 2, f: 12 }]); + assert.strictEqual(r.span, 3); +}); + +t('honours the anchor window: only in-window (or open) frets are eligible', () => { + // Anchor at fret 3, window 4 → [3,7). 60 has only s3f5 in-window; 62 only s4f3. + const r = _resolveChordGripPure([{ idx: 0, pitch: 60 }, { idx: 1, pitch: 62 }], + CTX, { anchorFret: 3, window: 4, maxSpan: 5 }, null, new Set()); + assert.deepStrictEqual(grip(r), [{ i: 0, s: 3, f: 5 }, { i: 1, s: 4, f: 3 }]); +}); + +t('refuses when a note has no eligible position (fully occupied / out of window)', () => { + // Occupy every string 60 could use → no candidate → whole cluster refused. + const r = _resolveChordGripPure([{ idx: 0, pitch: 60 }, { idx: 1, pitch: 62 }], + CTX, NOANCHOR, null, new Set([0, 1, 2, 3, 4, 5])); + assert.strictEqual(r, null); +}); + +t('never voices a note open when a fretted position is also playable', () => { + // 64 can be s5f0 (open E) OR s4f5 / s3f9 / … — the open-vs-fretted articulation + // choice _suggestPositionPure refuses. Open frets are FREE in the span metric, so + // an unguarded search actively prefers the open voicing: the grip must bail and + // let the per-note path refuse, not silently re-voice the chart. + const r = _resolveChordGripPure( + [{ idx: 0, pitch: 60 }, { idx: 1, pitch: 62 }, { idx: 2, pitch: 64 }], CTX, NOANCHOR, null, new Set()); + assert.strictEqual(r, null, 'ambiguous open-vs-fretted → no grip, never a guess'); +}); + +t('a D+G dyad is not collapsed to two open strings', () => { + // 50 = s1f5 / s2f0(open D); 55 = s2f5 / s3f0(open G). Both ambiguous. The span + // metric would score the all-open grip 0 — the tightest possible — and write it. + const r = _resolveChordGripPure([{ idx: 0, pitch: 50 }, { idx: 1, pitch: 55 }], + CTX, NOANCHOR, null, new Set()); + assert.strictEqual(r, null); +}); + +t('a malformed cluster member is never quietly dropped', () => { + // A partial grip leaves the bad note neither moved nor refused — and the sweep's + // "Accept all" would then confirm a position nobody picked. All-or-nothing. + const r = _resolveChordGripPure([{ idx: 0, pitch: 60 }, { idx: 1, pitch: NaN }], + CTX, NOANCHOR, null, new Set()); + assert.strictEqual(r, null); +}); + +t('deterministic: identical clusters always resolve to the same grip', () => { + const a = _resolveChordGripPure([{ idx: 0, pitch: 60 }, { idx: 1, pitch: 62 }], CTX, NOANCHOR, null, new Set()); + const b = _resolveChordGripPure([{ idx: 0, pitch: 60 }, { idx: 1, pitch: 62 }], CTX, NOANCHOR, null, new Set()); + assert.deepStrictEqual(grip(a), grip(b)); +}); + +// ── _resolveWindowPure integration (fails on main — greedy per-note) ────────── +t('bulk resolve lands a chord as one grip, not a greedy spread', () => { + // Two simultaneous suggested notes sounding 60 and 62 (both parked on s2). + // Greedy (main): 60→s4f1, 62→s3f7 — a 6-fret stretch the lint flags. + // Grip (here): 60→s3f5, 62→s4f3 — a 2-fret hand. + const nn = [N(2, 2, 10), N(2, 2, 12)]; + const ctx = { ...CTX, prevFretAt: () => null }; + const r = _resolveWindowPure(nn, { start: 0, end: Infinity }, [], ctx, () => true); + assert.strictEqual(r.refused.length, 0); + const m0 = r.moves.find(m => m.index === 0); + const m1 = r.moves.find(m => m.index === 1); + assert.deepStrictEqual({ s: m0.newString, f: m0.newFret }, { s: 3, f: 5 }); + assert.deepStrictEqual({ s: m1.newString, f: m1.newFret }, { s: 4, f: 3 }); + const frets = [m0.newFret, m1.newFret]; + assert.ok(Math.max(...frets) - Math.min(...frets) <= 4, 'the chord fits one hand'); +}); + +t('an open-vs-fretted note in a chord is REFUSED, not silently voiced open', () => { + // Three simultaneous notes sounding 60, 62, 64 (all parked on s2). 64 can be + // played open (s5f0) or fretted — main REFUSES it (open-vs-fretted) and so must + // the grip path: it bails, the per-note fallback places 60 and 62 and refuses 64. + // Pre-fix the grip voiced 64 open and "Accept all" would have confirmed it. + const nn = [N(1, 2, 10), N(1, 2, 12), N(1, 2, 14)]; + const ctx = { ...CTX, prevFretAt: () => null }; + const r = _resolveWindowPure(nn, { start: 0, end: Infinity }, [], ctx, () => true); + assert.deepStrictEqual(r.refused, [{ index: 2, reason: 'open-vs-fretted' }]); + assert.ok(!r.moves.some(m => m.index === 2), 'the ambiguous note is left exactly as-is'); + assert.strictEqual(r.moves.length, 2, 'its unambiguous siblings still resolve'); +}); + +t('a singleton note still resolves through the per-note path unchanged', () => { + // One suggested note sounding 48 (s0 f8), anchor window [2,6) → s1 f3. + const nn = [N(1, 0, 8)]; + const anchors = [{ time: 0, fret: 2, width: 4 }]; + const ctx = { ...CTX, prevFretAt: () => null }; + const r = _resolveWindowPure(nn, { start: 0, end: Infinity }, anchors, ctx, () => true); + assert.deepStrictEqual(r.moves[0], { index: 0, oldString: 0, oldFret: 8, newString: 1, newFret: 3 }); +}); + +t('a cluster with no coherent grip falls back to per-note (no regression)', () => { + // Anchor window [10,12) width 2, maxSpan 3. Two notes sounding 48: candidates + // s1f3 (out of window) and s0f8 (out of window) → no in-window grip AND the + // per-note resolver also refuses (outside-anchor-window) — both left as-is. + const nn = [N(2, 0, 8, 0.5), N(2, 0, 8, 0.5)]; + const anchors = [{ time: 0, fret: 10, width: 2 }]; + const ctx = { ...CTX, prevFretAt: () => null }; + const r = _resolveWindowPure(nn, { start: 0, end: Infinity }, anchors, ctx, () => true); + assert.strictEqual(r.moves.length, 0); + assert.strictEqual(r.refused.length, 2, 'both fall back and refuse — never guessed'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);