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
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions node_modules
110 changes: 86 additions & 24 deletions src/anchor-resolve.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down
108 changes: 108 additions & 0 deletions src/position.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading