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
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
86 changes: 82 additions & 4 deletions src/playability-lint.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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) {
Expand Down Expand Up @@ -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),
Expand All @@ -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) ──────────────────────────────
Expand All @@ -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 };
Expand All @@ -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() {
Expand Down
110 changes: 110 additions & 0 deletions tests/keys_lint.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
Loading