From d52a3afbaee9d3132c672a83cf96f18b5d1bedd3 Mon Sep 17 00:00:00 2001 From: ChrisBeWithYou Date: Wed, 22 Jul 2026 10:05:15 -0500 Subject: [PATCH] feat(editor): keys parts get a playability lint too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keys were the one instrument with NO advisory playability lint: the pass is fret/anchor-shaped, so `_lintResults()` bailed to an empty result on `isKeysArr()` — a keys chart could ask an impossible reach and nothing flagged it. A piano lint now runs for keys parts instead of bailing, over each onset-clustered simultaneity: - keys-span one HAND (by the per-note lh/rh assignment) reaching over an octave (a look) / over a 10th (beyond most hands); - keys-hand more than five notes in one hand at once (five fingers); - keys-muddy-low the two lowest simultaneous pitches within a major 3rd, below ~E2, where close intervals turn to mud. Span/count use the authored hand where present (assign hands to get that feedback — unassigned notes are skipped there); muddy-low is pitch-only, so it runs regardless. Same advisory posture as the fretted lint (yellow underline + count chip + popover, never blocks). Thresholds are exported named constants for the pedagogy seat to tune. Large-hand exception and a hand-leap/impossible-shift rule are noted as follow-ups. Tests: tests/keys_lint.test.mjs pins the rules (octave is fine, over it warns, over a 10th escalates; span is PER HAND so a wide two-hand voicing is fine; unassigned skipped; >5-in-a-hand; muddy-low high/open negatives; onset clustering) AND the wiring (_lintResults now yields keys issues + flags the notes, empty on main). npm test green apart from the two known-on-main reds; pytest untouched; lint clean. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_017xGPjDBF8NTwTK7VQvizix Signed-off-by: ChrisBeWithYou --- CHANGELOG.md | 13 +++++ src/playability-lint.js | 86 ++++++++++++++++++++++++++++-- tests/keys_lint.test.mjs | 110 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 tests/keys_lint.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 76e520e..8f6f2d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Exporting no longer clears unsaved project changes. The editor's own Back button now uses the Save / Don't Save / Cancel guard; complete shell navigation and desktop app-exit guarding still require a cancellable host navigation/close contract. +- **Keys parts now get a playability lint too.** Keys were the one instrument + with no advisory lint at all — the pass is fret/anchor-shaped, so + `_lintResults()` bailed to an empty result on any keys arrangement. A piano + lint now runs instead, over each simultaneity: a **hand stretch** (one hand, + by the per-note `lh`/`rh` assignment, reaching over an octave = a look, over a + 10th = beyond most hands), **too many notes in a hand** (more than five at + once — a hand has five fingers), and a **muddy low voicing** (the two lowest + simultaneous pitches within a major 3rd, below ~E2, where close intervals turn + to mud). Span/count use the authored hand where present (assign hands to get + that feedback); muddy-low is pitch-only. Same advisory posture as the fretted + lint — a yellow underline + the count chip + the popover, never blocking. New + pures `_keysLintPure` / thresholds in `src/playability-lint.js`, covered by + `tests/keys_lint.test.mjs`. - **The Legacy (EOF) profile is now a faithful port of EOF's pro-guitar keyset, pinned to the reference.** F1 opens help, Ctrl+1-9 / Ctrl+0 / Ctrl+` set frets diff --git a/src/playability-lint.js b/src/playability-lint.js index 6274e0b..687ad20 100644 --- a/src/playability-lint.js +++ b/src/playability-lint.js @@ -36,7 +36,7 @@ import { S, editGen } from './state.js'; import { host } from './host.js'; import { notes } from './notes.js'; -import { isKeysArr } from './keys.js'; +import { isKeysArr, noteToMidi } from './keys.js'; import { _activeAnchorAtPure } from './position.js'; import { _editorEscHtml } from './ui.js'; @@ -50,6 +50,13 @@ export const LINT_CLUSTER_EPSILON = 0.012; // "simultaneous" (the drum-lint v export const LINT_OVERLAP_EPSILON = 0.01; // seconds of real overlap before flagging export const LINT_DEFAULT_WINDOW = 4; // anchor width when none authored +// Keys (piano) thresholds — the pedagogy seat's numbers. Semitone spans. +export const KEYS_SPAN_WARN = 12; // over an OCTAVE in one hand — a real stretch +export const KEYS_SPAN_ERR = 16; // over a 10th — beyond most hands (large-hand exception: TODO) +export const KEYS_HAND_MAX = 5; // five fingers; more than five in one hand at once is impossible +export const KEYS_MUDDY_LOW_MIDI = 40; // ~E2 — dense voicings below here turn to mud +export const KEYS_MUDDY_INTERVAL = 4; // two lowest within a major 3rd = a muddy close voicing + // The fret-hand anchor list: authored anchors win, computed fall back — // the same dual-list precedence the tempo remap and the roll resolver use. export function _lintAnchorsPure(arr) { @@ -243,7 +250,7 @@ export function _lintFingerConflictPure(nn) { return issues; } -// The full pass. Empty/keys-less input degrades to no issues. +// The full FRETTED pass. Empty input degrades to no issues. export function _playabilityLintPure(nn, anchors) { return [ ..._lintStretchPure(nn, anchors), @@ -253,6 +260,70 @@ export function _playabilityLintPure(nn, anchors) { ..._lintFingerConflictPure(nn), ].sort((a, b) => a.time - b.time); } + +// midi pitch of a keys note — the piano-locked (string*24 + fret) packing. +function _keysMidiPure(n) { return noteToMidi(n.string, n.fret); } + +// The KEYS pass. Keys parts have no frets/strings-as-strings and no anchors, so +// the fretted rules don't apply — this lints the piano questions instead, per +// hand where the note carries one: +// keys-span one HAND spanning over an octave (>12) / over a 10th (>16); +// keys-hand more than five notes in one hand at once (a hand has five +// fingers); +// keys-muddy-low a tight low voicing — the two lowest simultaneous pitches +// within a major 3rd, below ~E2, where close intervals mud up. +// The span/count rules need the per-note hand ('lh'/'rh'); unassigned notes are +// skipped there (assign hands to get the feedback). muddy-low is pitch-only, so +// it runs whether or not hands are assigned. Advisory, like the fretted lint. +export function _keysLintPure(nn) { + const issues = []; + if (!Array.isArray(nn) || !nn.length) return issues; + const order = nn.map((n, i) => ({ n, i })) + .filter((e) => e.n && Number.isFinite(e.n.time) + && Number.isInteger(e.n.string) && Number.isInteger(e.n.fret)) + .sort((a, b) => a.n.time - b.n.time); + let k = 0; + while (k < order.length) { + const t0 = order[k].n.time; + const cluster = []; + while (k < order.length && order[k].n.time <= t0 + LINT_CLUSTER_EPSILON) { + cluster.push(order[k]); k++; + } + if (cluster.length < 2) continue; + for (const hand of ['lh', 'rh']) { + const group = cluster.filter((e) => e.n.techniques && e.n.techniques.hand === hand); + if (group.length >= 2) { + const midis = group.map((e) => _keysMidiPure(e.n)); + const span = Math.max(...midis) - Math.min(...midis); + if (span > KEYS_SPAN_WARN) { + issues.push({ + rule: 'keys-span', time: t0, indices: group.map((e) => e.i), + detail: span > KEYS_SPAN_ERR + ? `${span}-semitone reach in one hand (beyond a 10th)` + : `${span}-semitone reach in one hand (over an octave)`, + }); + } + } + if (group.length > KEYS_HAND_MAX) { + issues.push({ + rule: 'keys-hand', time: t0, indices: group.map((e) => e.i), + detail: `${group.length} notes in one hand (a hand has five fingers)`, + }); + } + } + // muddy low — the two LOWEST simultaneous pitches close together and low. + const byPitch = cluster.map((e) => ({ e, m: _keysMidiPure(e.n) })).sort((a, b) => a.m - b.m); + const gap = byPitch[1].m - byPitch[0].m; + if (byPitch[0].m < KEYS_MUDDY_LOW_MIDI && gap >= 1 && gap <= KEYS_MUDDY_INTERVAL) { + issues.push({ + rule: 'keys-muddy-low', time: t0, + indices: [byPitch[0].e.i, byPitch[1].e.i], + detail: `close ${gap}-semitone voicing in the low register (below ~E2)`, + }); + } + } + return issues.sort((a, b) => a.time - b.time); +} /* @pure:playability-lint:end */ // ── Memo (the draw-coalesce dirty path) ────────────────────────────── @@ -261,13 +332,18 @@ let _memo = { gen: -1, arrIdx: -1, notesRef: null, issues: [], flagged: new Set( export function _lintResults() { const arr = S.arrangements && S.arrangements[S.currentArr]; const nn = arr ? notes() : null; - if (!arr || !nn || isKeysArr() || S.drumEditMode) { + if (!arr || !nn || S.drumEditMode) { if (_memo.issues.length) _memo = { gen: -1, arrIdx: -1, notesRef: null, issues: [], flagged: new Set() }; return _memo; } const gen = typeof editGen === 'number' ? editGen : 0; if (_memo.gen === gen && _memo.arrIdx === S.currentArr && _memo.notesRef === nn) return _memo; - const issues = _playabilityLintPure(nn, _lintAnchorsPure(arr)); + // Keys parts get the piano lint (per-hand stretch / too-many-in-a-hand / + // muddy low); fretted parts get the anchor-window lint. Keys used to bail + // to no issues — the one instrument with no playability feedback at all. + const issues = isKeysArr() + ? _keysLintPure(nn) + : _playabilityLintPure(nn, _lintAnchorsPure(arr)); const flagged = new Set(); for (const iss of issues) for (const i of iss.indices) flagged.add(i); _memo = { gen, arrIdx: S.currentArr, notesRef: nn, issues, flagged }; @@ -284,6 +360,8 @@ const RULE_LABELS = { stretch: 'Stretch', overlap: 'String overlap', 'open-bend': 'Open-string bend', 'bad-fret': 'Fret out of range', 'legato-jump': 'Legato jump', 'finger-conflict': 'Finger conflict', + 'keys-span': 'Hand stretch', 'keys-hand': 'Too many notes in a hand', + 'keys-muddy-low': 'Muddy low voicing', }; export function _lintChipRefresh() { diff --git a/tests/keys_lint.test.mjs b/tests/keys_lint.test.mjs new file mode 100644 index 0000000..1461a83 --- /dev/null +++ b/tests/keys_lint.test.mjs @@ -0,0 +1,110 @@ +/* + * Keys (piano) playability lint. Keys parts were the ONE instrument with no + * playability feedback: _lintResults() bailed to an empty pass on isKeysArr(), + * and the whole lint pass is fret/anchor-shaped. This adds a piano lint — + * per-hand over-octave/10th stretch, >5-in-a-hand, and muddy low voicings — + * and routes keys parts to it instead of bailing. + * + * Pins the pure rules AND the wiring (a keys arrangement now yields issues). + * Run: node tests/keys_lint.test.mjs + */ +import assert from 'node:assert'; +import { S } from '../src/state.js'; +import { _keysLintPure, _lintResults } from '../src/playability-lint.js'; + +// A keys note: pitch is the piano-locked string*24 + fret packing; the hand +// ('lh'/'rh') rides under techniques, absent = unassigned. +const kn = (midi, time = 0, hand) => ({ + time, string: Math.floor(midi / 24), fret: midi % 24, sustain: 0, + techniques: hand ? { hand } : {}, +}); +const only = (issues, rule) => issues.filter((i) => i.rule === rule); +const has = (issues, rule) => issues.some((i) => i.rule === rule); + +let pass = 0, fail = 0; +const t = (name, fn) => { + try { fn(); pass++; console.log(' ok ' + name); } + catch (e) { fail++; console.error(' FAIL ' + name + ': ' + e.message); } +}; + +t('no issues on empty or single-note input', () => { + assert.deepStrictEqual(_keysLintPure([]), []); + assert.deepStrictEqual(_keysLintPure([kn(60, 0, 'rh')]), []); +}); + +t('an exact octave in one hand is fine (an octave is reachable, not > an octave)', () => { + assert.deepStrictEqual(_keysLintPure([kn(60, 0, 'rh'), kn(72, 0, 'rh')]), []); // span 12 +}); + +t('over an octave in one hand warns; beyond a 10th escalates', () => { + const minor9 = _keysLintPure([kn(60, 0, 'rh'), kn(73, 0, 'rh')]); // span 13 + assert.deepStrictEqual(only(minor9, 'keys-span').length, 1); + assert.match(minor9[0].detail, /over an octave/); + + const tenth = _keysLintPure([kn(60, 0, 'lh'), kn(76, 0, 'lh')]); // span 16 = a 10th + assert.match(tenth[0].detail, /over an octave/, 'a 10th itself sits in the warn band'); + + const beyond = _keysLintPure([kn(60, 0, 'rh'), kn(77, 0, 'rh')]); // span 17 + assert.match(beyond[0].detail, /beyond a 10th/); +}); + +t('span is PER HAND — a wide two-hand voicing is fine', () => { + const issues = _keysLintPure([ + kn(36, 0, 'lh'), kn(40, 0, 'lh'), // lh pair, span 4 + kn(80, 0, 'rh'), kn(84, 0, 'rh'), // rh pair, span 4; total spread 48 + ]); + assert.deepStrictEqual(only(issues, 'keys-span'), []); +}); + +t('unassigned notes are not span-linted (assign hands first)', () => { + assert.deepStrictEqual(only(_keysLintPure([kn(60, 0), kn(84, 0)]), 'keys-span'), []); // span 24, no hands +}); + +t('more than five notes in one hand is flagged (a hand has five fingers)', () => { + const six = [60, 62, 64, 65, 67, 69].map((m) => kn(m, 0, 'rh')); // 6 notes, span 9 + const issues = _keysLintPure(six); + assert.ok(has(issues, 'keys-hand')); + assert.deepStrictEqual(only(issues, 'keys-span'), [], 'a 9-semitone span (< octave) does not also flag'); + assert.strictEqual(only(issues, 'keys-hand')[0].indices.length, 6); +}); + +t('a tight low voicing is muddy; the same interval high, or an open low interval, is not', () => { + assert.ok(has(_keysLintPure([kn(36, 0), kn(38, 0)]), 'keys-muddy-low'), 'a whole tone at E2 muds up'); + assert.deepStrictEqual(only(_keysLintPure([kn(60, 0), kn(62, 0)]), 'keys-muddy-low'), [], 'fine up high'); + assert.deepStrictEqual(only(_keysLintPure([kn(36, 0), kn(43, 0)]), 'keys-muddy-low'), [], 'an open 5th is fine'); +}); + +t('muddy-low judges the two LOWEST pitches, ignoring higher cluster notes', () => { + const issues = _keysLintPure([kn(36, 0), kn(38, 0), kn(72, 0)]); + const md = issues.find((i) => i.rule === 'keys-muddy-low'); + assert.ok(md, 'the low pair is flagged'); + assert.deepStrictEqual(md.indices.slice().sort((a, b) => a - b), [0, 1], 'the two low notes, not the high one'); +}); + +t('notes at different onsets are not one cluster', () => { + assert.deepStrictEqual(_keysLintPure([kn(60, 0, 'rh'), kn(77, 1.0, 'rh')]), []); // 1s apart +}); + +t('issues carry rule / time / indices', () => { + const [iss] = _keysLintPure([kn(60, 0.5, 'rh'), kn(77, 0.5, 'rh')]); + assert.strictEqual(iss.rule, 'keys-span'); + assert.strictEqual(iss.time, 0.5); + assert.deepStrictEqual(iss.indices.slice().sort((a, b) => a - b), [0, 1]); +}); + +// ── the wiring: _lintResults routes a keys part to the keys lint ───────────── +t('_lintResults now lints a keys arrangement instead of bailing to empty', () => { + Object.assign(S, { + arrangements: [{ + name: 'Piano', // KEYS_PATTERN → isKeysArr() true (no anchors needed) + notes: [kn(60, 0.5, 'rh'), kn(77, 0.5, 'rh')], chords: [], + }], + currentArr: 0, drumEditMode: false, filename: 'k.sloppak', sel: new Set(), + }); + const res = _lintResults(); + assert.ok(has(res.issues, 'keys-span'), 'a keys part gets feedback (this was empty on main)'); + assert.ok(res.flagged.has(0) && res.flagged.has(1), 'the flagged set marks both reached notes'); +}); + +console.log(`\n${pass} passed, ${fail} failed`); +process.exit(fail ? 1 : 0);