@pepperhorn/chordl-guitar 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/chordLookup.d.ts +3 -0
- package/dist/chordLookup.js +29 -62
- package/dist/chordLookup.js.map +1 -1
- package/dist/chordNames.d.ts +24 -0
- package/dist/chordNames.js +55 -0
- package/dist/chordNames.js.map +1 -0
- package/dist/experience.d.ts +129 -0
- package/dist/experience.js +164 -0
- package/dist/experience.js.map +1 -0
- package/dist/guitarPosition.d.ts +33 -0
- package/dist/guitarPosition.js +115 -0
- package/dist/guitarPosition.js.map +1 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/instruments.js +12 -6
- package/dist/instruments.js.map +1 -1
- package/dist/pitch.d.ts +6 -0
- package/dist/pitch.js +16 -10
- package/dist/pitch.js.map +1 -1
- package/dist/staticPresets.d.ts +61 -5
- package/dist/staticPresets.js +95 -34
- package/dist/staticPresets.js.map +1 -1
- package/dist/top3Generated.d.ts +29 -0
- package/dist/top3Generated.js +553 -0
- package/dist/top3Generated.js.map +1 -0
- package/dist/voicingGenerator.d.ts +94 -0
- package/dist/voicingGenerator.js +642 -0
- package/dist/voicingGenerator.js.map +1 -0
- package/dist/voicingReview.d.ts +35 -0
- package/dist/voicingReview.js +90 -0
- package/dist/voicingReview.js.map +1 -0
- package/package.json +7 -1
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# Guitar voicing generation and review
|
|
2
|
+
|
|
3
|
+
`guitarPositionFor(spelledChord, options?)` first validates chords-db positions,
|
|
4
|
+
then tries the regular power-chord shape, then ranks an exhaustive playable
|
|
5
|
+
candidate set. `generateGuitarVoicings(notes, options?)` exposes that ranked set.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
const position = guitarPositionFor(
|
|
9
|
+
{ root: "A", pitchClasses: ["A", "C#", "Eb", "G", "Bb", "F#"] },
|
|
10
|
+
{ level: 3 },
|
|
11
|
+
);
|
|
12
|
+
// A13(b9,b5): a rootless grip can retain 3, b5, b7, b9, 13.
|
|
13
|
+
// position.review.reasons includes "omits:1" and "rootless".
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The default teaching level is **2**. Levels 0–2 always retain the root. At
|
|
17
|
+
level 3, the root may be omitted from chords with six or more required tones,
|
|
18
|
+
from five-tone upper chords over a slash bass (including the formerly missing
|
|
19
|
+
Ab9/Eb), or from any five-tone chord with an explicit `{ comping: true }`.
|
|
20
|
+
A rootless grip must put a third/seventh lowest or sound the written slash bass
|
|
21
|
+
lowest. A rooted grip still puts the root/written slash bass lowest. A slash
|
|
22
|
+
bass equal to the root cannot be omitted. `rootless` and `omitted` disclose
|
|
23
|
+
root omissions; a renderer should explain “root left out; the bass player has
|
|
24
|
+
it”. Thirds, sevenths and named alterations are preserved in dense chords.
|
|
25
|
+
|
|
26
|
+
Natural 9 remains required under #11 alone. It is optional under a natural 11
|
|
27
|
+
or 13. These are spelling-aware rules: #11 differs from b5 even when the pitch
|
|
28
|
+
classes coincide.
|
|
29
|
+
|
|
30
|
+
Ranking penalizes register-dependent low seconds/thirds and minor ninths,
|
|
31
|
+
inner-string basses, wide finger reaches, long barres, high positions (convex
|
|
32
|
+
cost above fret 9), and opens under a high hand. Five-tone and larger chords
|
|
33
|
+
have no six-string bonus. These costs rank compromises; they do not certify
|
|
34
|
+
playability for a particular hand or enforce the future level-specific tiers.
|
|
35
|
+
The top tension need not be the highest voice.
|
|
36
|
+
|
|
37
|
+
Every returned position, including validated library shapes, carries `review`:
|
|
38
|
+
|
|
39
|
+
- `origin`: `chords-db`, `template:power-chord`, or `search`.
|
|
40
|
+
- `level`: requested teaching level, default 2.
|
|
41
|
+
- `reasons`: omission, register, dissonance, hand geometry and rejected-library
|
|
42
|
+
reason codes (`ReasonCode` is exported). Empty means no measured warning.
|
|
43
|
+
- `confidence`: deterministic attention heuristic in 0–1, **not a probability**
|
|
44
|
+
or teacher approval. Unflagged search starts at 0.88, library/template at 0.98.
|
|
45
|
+
- `needsReview`: `reasons.length > 0 || confidence < 0.8`.
|
|
46
|
+
- `reviewKey`: stable chord-constraint/level/absolute-grip/fingering identity.
|
|
47
|
+
Group by this key to review enharmonic twins once (Bb9/C# and Bb9/Db, for
|
|
48
|
+
example). Different fingerings or levels remain separate review items.
|
|
49
|
+
|
|
50
|
+
The key deduplicates review work without requiring a catalogue slug or a
|
|
51
|
+
stateful registry, and without marking otherwise clean twins as warnings.
|
|
52
|
+
The root's pitch class and each tone's degree/role remain in the key; merely sharing
|
|
53
|
+
an unordered pitch set does not conflate different bass or omission policies.
|
|
54
|
+
A non-chord bass is distinct from a seventh (which may double), even if both
|
|
55
|
+
spellings produce the same pitch set. No template origin is claimed for brute-force search results.
|
|
56
|
+
|
|
57
|
+
This package supplies metadata. Card/manifest consumers must forward it and
|
|
58
|
+
pass the teaching level; existing card rendering is unchanged.
|
|
59
|
+
|
|
60
|
+
## Reproduce the independent-oracle comparison
|
|
61
|
+
|
|
62
|
+
From the repository root, with the existing review outputs available:
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
node_modules/.bin/tsx packages/chordl-guitar/scripts/compare-voicings.mts \
|
|
66
|
+
--plain /path/to/plain/verdicts.json \
|
|
67
|
+
--slash /path/to/slash/verdicts.json \
|
|
68
|
+
--missing /path/to/missing38.json \
|
|
69
|
+
--out /path/to/generator-comparison \
|
|
70
|
+
--baseline-ref d307e3b378c6864cd61fc39f6571cf0ee1a81586
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Both versions regenerate all 3,157 oracle rows. The script refuses baseline
|
|
74
|
+
shape/fingering mismatches, validates new pitch coverage, reports changed
|
|
75
|
+
`replace` and `accept` rows separately, counts all 38 recoveries, and writes
|
|
76
|
+
review distributions, raw JSON and an HTML contact sheet. Comparison uses
|
|
77
|
+
explicit level 3; the default remains root-preserving level 2. Original verdicts
|
|
78
|
+
judge the old shapes: a changed `accept` is a regression watch item, and a
|
|
79
|
+
changed `replace` still needs a teacher's playing/listening check.
|
|
80
|
+
|
|
81
|
+
Package checks: `pnpm --filter @pepperhorn/chordl-guitar test:run` and
|
|
82
|
+
`pnpm --filter @pepperhorn/chordl-guitar build`.
|
package/dist/chordLookup.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Chord } from "svguitar";
|
|
2
2
|
import type { ChordsDbPosition, InstrumentId } from "./instruments.js";
|
|
3
|
+
import type { ExperienceLevel } from "./experience.js";
|
|
3
4
|
export interface GuitarChordResult {
|
|
4
5
|
/** The chord label as given. */
|
|
5
6
|
label: string;
|
|
@@ -8,6 +9,8 @@ export interface GuitarChordResult {
|
|
|
8
9
|
positions: ChordsDbPosition[];
|
|
9
10
|
/** svguitar Chord objects, one per position, ready to render. */
|
|
10
11
|
shapes: Chord[];
|
|
12
|
+
/** How hard each shape is to play, parallel to `positions`/`shapes`. */
|
|
13
|
+
levels: ExperienceLevel[];
|
|
11
14
|
}
|
|
12
15
|
/**
|
|
13
16
|
* Look up all stored shapes for a chord label. Returns null when the chord or
|
package/dist/chordLookup.js
CHANGED
|
@@ -21,8 +21,11 @@ import guitarDb from "./generated/guitar.slim.json" with { type: "json" };
|
|
|
21
21
|
import ukuleleDb from "./generated/ukulele.slim.json" with { type: "json" };
|
|
22
22
|
import { INSTRUMENTS, dbPositionToChord, } from "./instruments.js";
|
|
23
23
|
import { rootPitchClass } from "./pitch.js";
|
|
24
|
+
import { splitLabel, toDbSuffix } from "./chordNames.js";
|
|
24
25
|
import { powerChordPosition } from "./powerChords.js";
|
|
25
|
-
import {
|
|
26
|
+
import { findTop3Preset } from "./staticPresets.js";
|
|
27
|
+
import { positionFacts } from "./voicingFacts.js";
|
|
28
|
+
import { levelForFacts } from "./experience.js";
|
|
26
29
|
const GUITAR_DB = guitarDb;
|
|
27
30
|
const UKULELE_DB = ukuleleDb;
|
|
28
31
|
// chords-db ships guitar and ukulele voicing libraries.
|
|
@@ -51,43 +54,6 @@ const ROOT_TO_DB_KEYS = {
|
|
|
51
54
|
"A#": ["Bb", "Asharp"], Bb: ["Bb", "Asharp"],
|
|
52
55
|
B: ["B"], Cb: ["B"],
|
|
53
56
|
};
|
|
54
|
-
// chordl suffix → chords-db suffix. Most match verbatim; only the triads and a
|
|
55
|
-
// couple of aliases need translating.
|
|
56
|
-
function toDbSuffix(suffix) {
|
|
57
|
-
const s = suffix.trim();
|
|
58
|
-
if (s === "" || s === "maj" || s === "M")
|
|
59
|
-
return "major";
|
|
60
|
-
if (s === "m" || s === "min" || s === "-")
|
|
61
|
-
return "minor";
|
|
62
|
-
if (s === "min7")
|
|
63
|
-
return "m7";
|
|
64
|
-
if (s === "M7")
|
|
65
|
-
return "maj7";
|
|
66
|
-
if (s === "°")
|
|
67
|
-
return "dim";
|
|
68
|
-
if (s === "°7")
|
|
69
|
-
return "dim7";
|
|
70
|
-
if (s === "ø" || s === "ø7")
|
|
71
|
-
return "m7b5";
|
|
72
|
-
// chords-db spells the 6/9 voicings without the slash.
|
|
73
|
-
if (s === "6/9")
|
|
74
|
-
return "69";
|
|
75
|
-
if (s === "m6/9")
|
|
76
|
-
return "m69";
|
|
77
|
-
// Bare "sus" (and "7sus") carry no extension number; sus4 is the convention.
|
|
78
|
-
if (s === "sus")
|
|
79
|
-
return "sus4";
|
|
80
|
-
if (s === "7sus")
|
|
81
|
-
return "7sus4";
|
|
82
|
-
return s; // m7, maj7, 7, dim7, sus4, 6, 9, 11, 13, m7b5, ... match verbatim
|
|
83
|
-
}
|
|
84
|
-
function splitLabel(label) {
|
|
85
|
-
const m = label.trim().match(/^([A-Ga-g][#b]?)(.*)$/);
|
|
86
|
-
if (!m)
|
|
87
|
-
return null;
|
|
88
|
-
const root = m[1].charAt(0).toUpperCase() + m[1].slice(1);
|
|
89
|
-
return { root, suffix: m[2] };
|
|
90
|
-
}
|
|
91
57
|
function findEntry(db, label) {
|
|
92
58
|
const parsed = splitLabel(label);
|
|
93
59
|
if (!parsed)
|
|
@@ -126,24 +92,15 @@ function powerChordPositions(label) {
|
|
|
126
92
|
.filter((p) => p !== null);
|
|
127
93
|
}
|
|
128
94
|
// ── Top-3-string presets ────────────────────────────────────────────────────
|
|
129
|
-
/**
|
|
130
|
-
* The preset table spells qualities in words where chords-db uses symbols, so a
|
|
131
|
-
* chordl label has to be translated before lookup. Only the suffixes the table
|
|
132
|
-
* actually carries are mapped; anything else finds nothing, which is correct —
|
|
133
|
-
* falling back to a six-string shape under a three-string label would be a lie.
|
|
134
|
-
*/
|
|
135
|
-
function toPresetSuffix(suffix) {
|
|
136
|
-
const s = suffix.trim();
|
|
137
|
-
if (s === "")
|
|
138
|
-
return "major";
|
|
139
|
-
if (s === "m" || s === "min")
|
|
140
|
-
return "minor";
|
|
141
|
-
return s; // 7, m7, maj7 match the table verbatim
|
|
142
|
-
}
|
|
143
95
|
/**
|
|
144
96
|
* Presets are authored as svguitar shapes, but callers expect chords-db-style
|
|
145
97
|
* positions (that is what carries `baseFret` for the "fret N" caption). Convert
|
|
146
98
|
* back: svguitar numbers strings high-to-low from 1, positions run low-to-high.
|
|
99
|
+
*
|
|
100
|
+
* The window is already decided — `toPreset` put it on `chord.position`, so
|
|
101
|
+
* every consumer of the preset table sees the same one — and `chord` frets are
|
|
102
|
+
* relative to it, which is exactly what `baseFret` means. So there is nothing to
|
|
103
|
+
* recompute here, only to rename.
|
|
147
104
|
*/
|
|
148
105
|
function presetToPosition(preset) {
|
|
149
106
|
const frets = new Array(6).fill(-1);
|
|
@@ -161,16 +118,19 @@ function presetToPosition(preset) {
|
|
|
161
118
|
const n = Number(label);
|
|
162
119
|
fingers[idx] = Number.isFinite(n) ? n : 0;
|
|
163
120
|
}
|
|
164
|
-
|
|
165
|
-
// first position, so the diagram window always starts at the nut.
|
|
166
|
-
return { frets, fingers, baseFret: 1, barres: [] };
|
|
121
|
+
return { frets, fingers, baseFret: preset.chord.position ?? 1, barres: [] };
|
|
167
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* The table resolves both halves of the label itself — root by pitch class, and
|
|
125
|
+
* suffix through the same `toDbSuffix` the six-string path uses — so there is
|
|
126
|
+
* nothing to translate here beyond finding where the root stops.
|
|
127
|
+
*
|
|
128
|
+
* A miss still returns null. Falling back to a six-string shape under a
|
|
129
|
+
* three-string label would be a lie.
|
|
130
|
+
*/
|
|
168
131
|
function findPreset(label) {
|
|
169
132
|
const parsed = splitLabel(label);
|
|
170
|
-
|
|
171
|
-
return null;
|
|
172
|
-
const suffix = toPresetSuffix(parsed.suffix);
|
|
173
|
-
return (GUITAR_TOP3_PRESETS.find((p) => p.key === parsed.root && p.suffix === suffix) ?? null);
|
|
133
|
+
return parsed ? findTop3Preset(parsed.root, parsed.suffix) : null;
|
|
174
134
|
}
|
|
175
135
|
/**
|
|
176
136
|
* Look up all stored shapes for a chord label. Returns null when the chord or
|
|
@@ -180,19 +140,26 @@ export function lookupGuitarChord(label, instrument = "guitar") {
|
|
|
180
140
|
const strings = INSTRUMENTS[instrument]?.strings;
|
|
181
141
|
if (strings == null)
|
|
182
142
|
return null;
|
|
183
|
-
const
|
|
143
|
+
const openMidi = INSTRUMENTS[instrument].openMidi;
|
|
144
|
+
const rootPc = rootPitchClass(label);
|
|
145
|
+
// chords-db and generated positions have no stored level: derive it from the
|
|
146
|
+
// same facts levelForFacts always ranks on.
|
|
147
|
+
const derivedLevels = (positions) => positions.map((pos) => levelForFacts(positionFacts(pos, openMidi, rootPc)));
|
|
148
|
+
const toResult = (positions, levels = derivedLevels(positions)) => positions.length === 0
|
|
184
149
|
? null
|
|
185
150
|
: {
|
|
186
151
|
label,
|
|
187
152
|
instrument,
|
|
188
153
|
positions,
|
|
189
154
|
shapes: positions.map((pos) => dbPositionToChord(pos, strings, label)),
|
|
155
|
+
levels,
|
|
190
156
|
};
|
|
191
157
|
// 1. Hand-authored beats generated: the preset table is the only source of
|
|
192
|
-
// three-string voicings, and it does not fall back to chords-db.
|
|
158
|
+
// three-string voicings, and it does not fall back to chords-db. Its
|
|
159
|
+
// level is precomputed and stored on the preset — see src/experience.ts.
|
|
193
160
|
if (instrument === "guitar-top3") {
|
|
194
161
|
const preset = findPreset(label);
|
|
195
|
-
return preset ? toResult([presetToPosition(preset)]) : null;
|
|
162
|
+
return preset ? toResult([presetToPosition(preset)], [preset.level]) : null;
|
|
196
163
|
}
|
|
197
164
|
// 2. chords-db, for the instruments that ship a library.
|
|
198
165
|
const db = dbFor(instrument);
|
package/dist/chordLookup.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"chordLookup.js","sourceRoot":"","sources":["../src/chordLookup.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AACH,+EAA+E;AAC/E,2EAA2E;AAC3E,OAAO,QAAQ,MAAM,8BAA8B,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAC1E,OAAO,SAAS,MAAM,+BAA+B,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAE5E,OAAO,EACL,WAAW,EACX,iBAAiB,GAClB,MAAM,kBAAkB,CAAC;AAO1B,OAAO,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AAC5C,OAAO,EAAE,kBAAkB,EAAE,MAAM,kBAAkB,CAAC;AAEtD,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"chordLookup.js","sourceRoot":"","sources":["../src/chordLookup.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chord-label spelling, resolved once for every lookup path.
|
|
3
|
+
*
|
|
4
|
+
* A user types `Db`, `C#`, `Cø`, `CM7`, `Csus`; chords-db stores `Csharp`,
|
|
5
|
+
* `m7b5`, `maj7`, `sus4`. Both the six-string library and the top-3 table need
|
|
6
|
+
* the same translation, and two copies of it drift — the top-3 path used to
|
|
7
|
+
* carry a three-line version of `toDbSuffix` that knew only `""`, `m` and `min`,
|
|
8
|
+
* which left the generated table addressable for a fraction of what it holds.
|
|
9
|
+
*
|
|
10
|
+
* Roots are resolved by pitch class rather than by spelling wherever the target
|
|
11
|
+
* is keyed that way; `rootPitchClass` in pitch.ts is the one parser for that.
|
|
12
|
+
* This module carries what pitch classes cannot express: where the suffix ends
|
|
13
|
+
* and, for chords-db, which of its inconsistent root spellings to try.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* chordl suffix → chords-db suffix. Most match verbatim; only the triads and a
|
|
17
|
+
* handful of aliases need translating.
|
|
18
|
+
*/
|
|
19
|
+
export declare function toDbSuffix(suffix: string): string;
|
|
20
|
+
/** Split a label into its root spelling and everything after it. */
|
|
21
|
+
export declare function splitLabel(label: string): {
|
|
22
|
+
root: string;
|
|
23
|
+
suffix: string;
|
|
24
|
+
} | null;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chord-label spelling, resolved once for every lookup path.
|
|
3
|
+
*
|
|
4
|
+
* A user types `Db`, `C#`, `Cø`, `CM7`, `Csus`; chords-db stores `Csharp`,
|
|
5
|
+
* `m7b5`, `maj7`, `sus4`. Both the six-string library and the top-3 table need
|
|
6
|
+
* the same translation, and two copies of it drift — the top-3 path used to
|
|
7
|
+
* carry a three-line version of `toDbSuffix` that knew only `""`, `m` and `min`,
|
|
8
|
+
* which left the generated table addressable for a fraction of what it holds.
|
|
9
|
+
*
|
|
10
|
+
* Roots are resolved by pitch class rather than by spelling wherever the target
|
|
11
|
+
* is keyed that way; `rootPitchClass` in pitch.ts is the one parser for that.
|
|
12
|
+
* This module carries what pitch classes cannot express: where the suffix ends
|
|
13
|
+
* and, for chords-db, which of its inconsistent root spellings to try.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* chordl suffix → chords-db suffix. Most match verbatim; only the triads and a
|
|
17
|
+
* handful of aliases need translating.
|
|
18
|
+
*/
|
|
19
|
+
export function toDbSuffix(suffix) {
|
|
20
|
+
const s = suffix.trim();
|
|
21
|
+
if (s === "" || s === "maj" || s === "M")
|
|
22
|
+
return "major";
|
|
23
|
+
if (s === "m" || s === "min" || s === "-")
|
|
24
|
+
return "minor";
|
|
25
|
+
if (s === "min7")
|
|
26
|
+
return "m7";
|
|
27
|
+
if (s === "M7")
|
|
28
|
+
return "maj7";
|
|
29
|
+
if (s === "°")
|
|
30
|
+
return "dim";
|
|
31
|
+
if (s === "°7")
|
|
32
|
+
return "dim7";
|
|
33
|
+
if (s === "ø" || s === "ø7")
|
|
34
|
+
return "m7b5";
|
|
35
|
+
// chords-db spells the 6/9 voicings without the slash.
|
|
36
|
+
if (s === "6/9")
|
|
37
|
+
return "69";
|
|
38
|
+
if (s === "m6/9")
|
|
39
|
+
return "m69";
|
|
40
|
+
// Bare "sus" (and "7sus") carry no extension number; sus4 is the convention.
|
|
41
|
+
if (s === "sus")
|
|
42
|
+
return "sus4";
|
|
43
|
+
if (s === "7sus")
|
|
44
|
+
return "7sus4";
|
|
45
|
+
return s; // m7, maj7, 7, dim7, sus4, 6, 9, 11, 13, m7b5, ... match verbatim
|
|
46
|
+
}
|
|
47
|
+
/** Split a label into its root spelling and everything after it. */
|
|
48
|
+
export function splitLabel(label) {
|
|
49
|
+
const m = label.trim().match(/^([A-Ga-g][#b]?)(.*)$/);
|
|
50
|
+
if (!m)
|
|
51
|
+
return null;
|
|
52
|
+
const root = m[1].charAt(0).toUpperCase() + m[1].slice(1);
|
|
53
|
+
return { root, suffix: m[2] };
|
|
54
|
+
}
|
|
55
|
+
//# sourceMappingURL=chordNames.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"chordNames.js","sourceRoot":"","sources":["../src/chordNames.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH;;;GAGG;AACH,MAAM,UAAU,UAAU,CAAC,MAAc;IACvC,MAAM,CAAC,GAAG,MAAM,CAAC,IAAI,EAAE,CAAC;IACxB,IAAI,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,GAAG;QAAE,OAAO,OAAO,CAAC;IACzD,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,KAAK,GAAG;QAAE,OAAO,OAAO,CAAC;IAC1D,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,IAAI,CAAC;IAC9B,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,MAAM,CAAC;IAC9B,IAAI,CAAC,KAAK,GAAG;QAAE,OAAO,KAAK,CAAC;IAC5B,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,MAAM,CAAC;IAC9B,IAAI,CAAC,KAAK,GAAG,IAAI,CAAC,KAAK,IAAI;QAAE,OAAO,MAAM,CAAC;IAC3C,uDAAuD;IACvD,IAAI,CAAC,KAAK,KAAK;QAAE,OAAO,IAAI,CAAC;IAC7B,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,KAAK,CAAC;IAC/B,6EAA6E;IAC7E,IAAI,CAAC,KAAK,KAAK;QAAE,OAAO,MAAM,CAAC;IAC/B,IAAI,CAAC,KAAK,MAAM;QAAE,OAAO,OAAO,CAAC;IACjC,OAAO,CAAC,CAAC,CAAC,kEAAkE;AAC9E,CAAC;AAED,oEAAoE;AACpE,MAAM,UAAU,UAAU,CAAC,KAAa;IACtC,MAAM,CAAC,GAAG,KAAK,CAAC,IAAI,EAAE,CAAC,KAAK,CAAC,uBAAuB,CAAC,CAAC;IACtD,IAAI,CAAC,CAAC;QAAE,OAAO,IAAI,CAAC;IACpB,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,WAAW,EAAE,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;IAC1D,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;AAChC,CAAC"}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How hard a shape is to play, as three rungs a learner moves through.
|
|
3
|
+
*
|
|
4
|
+
* Derived from the facts, never stored: a level that can be hand-edited drifts
|
|
5
|
+
* from the shape it describes. `ShapeClass` in voicingSelect.ts filters on the
|
|
6
|
+
* same facts; this ranks on them. They are deliberately separate — a filter
|
|
7
|
+
* answers "show me the open ones", a rank answers "can I play this yet".
|
|
8
|
+
*/
|
|
9
|
+
import type { ChordsDbPosition } from "./instruments.js";
|
|
10
|
+
import type { PositionFacts } from "./voicingFacts.js";
|
|
11
|
+
import type { ShapeClass } from "./voicingSelect.js";
|
|
12
|
+
export type ExperienceLevel = "beginner" | "emerging" | "established";
|
|
13
|
+
/** Easiest first, so `indexOf` gives the rung number and +1 widens. */
|
|
14
|
+
export declare const EXPERIENCE_LADDER: ExperienceLevel[];
|
|
15
|
+
/**
|
|
16
|
+
* An open shape is barre-free and at the nut — nothing to hold down across
|
|
17
|
+
* strings and no hand position to find. Barre-free but moved up the neck is
|
|
18
|
+
* the next step. A barre is the rung most beginners stall on, so it anchors
|
|
19
|
+
* the top.
|
|
20
|
+
*/
|
|
21
|
+
export declare function levelForFacts(facts: PositionFacts): ExperienceLevel;
|
|
22
|
+
/**
|
|
23
|
+
* Rank a three-string shape. `PositionFacts` cannot: it assumes six strings
|
|
24
|
+
* and real barres, and a top-3 shape has neither.
|
|
25
|
+
*
|
|
26
|
+
* The axes are the ones the generator already ranks candidates by — stretch,
|
|
27
|
+
* finger count, open strings, distance from the nut. A shape a learner can
|
|
28
|
+
* play without moving out of first position is the dividing line, because
|
|
29
|
+
* finding a hand position is the skill that separates the first two rungs.
|
|
30
|
+
*
|
|
31
|
+
* Accepts either of two representations of the same shape, and returns the
|
|
32
|
+
* same level for both:
|
|
33
|
+
*
|
|
34
|
+
* - absolute frets measured from the nut, with `position` pinned at 1 —
|
|
35
|
+
* what the generated table stores (`Top3GeneratedEntry.frets`);
|
|
36
|
+
* - window-relative frets paired with the diagram's real `position` —
|
|
37
|
+
* what `staticPresets.top3Window()` produces for rendering.
|
|
38
|
+
*
|
|
39
|
+
* They agree because the function only ever compares
|
|
40
|
+
* `position - 1 + max(fretted)` to the nut: sliding a window down by
|
|
41
|
+
* `position - 1` and adding it back is the same transform run forward and
|
|
42
|
+
* back, so the recovered highest fret — and therefore the level — is
|
|
43
|
+
* identical either way. A caller holding a rendered preset (windowed frets
|
|
44
|
+
* plus its real position) can pass those straight in rather than
|
|
45
|
+
* reconstructing absolute frets by hand, which is exactly the arithmetic that
|
|
46
|
+
* has produced off-diagram bugs elsewhere in this codebase.
|
|
47
|
+
*/
|
|
48
|
+
export declare function levelForTop3(frets: number[], position: number): ExperienceLevel;
|
|
49
|
+
export interface ExperienceQuery {
|
|
50
|
+
level: ExperienceLevel;
|
|
51
|
+
/**
|
|
52
|
+
* Optional refinement within the level. Default "any".
|
|
53
|
+
*
|
|
54
|
+
* Published API, kept for when the level control gains a paired shape-class
|
|
55
|
+
* control — deliberately not wired into any UI yet (the only level control
|
|
56
|
+
* shipped so far is `dev/App.tsx`'s radio group; `GuitarChordPanel` draws
|
|
57
|
+
* no control of its own). Under cumulative level matching it now genuinely
|
|
58
|
+
* refines at the `emerging` and `established` rungs (it can drop shapes
|
|
59
|
+
* those levels would otherwise include), so do not delete this as dead —
|
|
60
|
+
* only its UI is deferred, not the mechanism.
|
|
61
|
+
*/
|
|
62
|
+
shapeClass?: ShapeClass;
|
|
63
|
+
}
|
|
64
|
+
export interface ExperienceSelection {
|
|
65
|
+
/** Indices into the input array, in input order. Never empty when input is non-empty. */
|
|
66
|
+
indices: number[];
|
|
67
|
+
/** The level actually served — differs from the request when it widened. */
|
|
68
|
+
level: ExperienceLevel;
|
|
69
|
+
/** True when the shape-class refinement had to be dropped to find anything. */
|
|
70
|
+
droppedShapeClass: boolean;
|
|
71
|
+
/** The level originally asked for, when it had to widen. Absent otherwise. */
|
|
72
|
+
widenedFrom?: ExperienceLevel;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Filter by level, refine by shape class, and relax rather than show nothing.
|
|
76
|
+
*
|
|
77
|
+
* Level matching is CUMULATIVE, not exclusive: `query.level` answers "can I
|
|
78
|
+
* play this yet", so a level matches every shape ranked at or below it on
|
|
79
|
+
* `EXPERIENCE_LADDER` — `established` matches everything, `emerging` matches
|
|
80
|
+
* beginner + emerging, `beginner` matches only beginner (there is nothing
|
|
81
|
+
* below it to include). An established player can certainly play an open C;
|
|
82
|
+
* excluding the open shape from an "established" query was the bug, not a
|
|
83
|
+
* feature.
|
|
84
|
+
*
|
|
85
|
+
* Emptiness is still possible at the bottom rung: 220 of 529 chords have no
|
|
86
|
+
* open shape at all and 88 have only barre shapes, so a beginner filter finds
|
|
87
|
+
* nothing for nearly half the corpus. Returning an empty frame there would
|
|
88
|
+
* punish exactly the learner the filter is for, so relaxation still widens
|
|
89
|
+
* one rung at a time rather than giving up.
|
|
90
|
+
*
|
|
91
|
+
* Relaxation order is deliberate. The refinement goes first because the level
|
|
92
|
+
* is what the user asked for and the shape class only narrows it; widening the
|
|
93
|
+
* level changes the answer to the question they asked.
|
|
94
|
+
*
|
|
95
|
+
* @param levels - Optional, parallel to `facts`. When an entry is present it
|
|
96
|
+
* is used verbatim instead of `levelForFacts(facts[i])`. This is for
|
|
97
|
+
* shapes whose facts cannot support the six-string derivation — top-3
|
|
98
|
+
* presets never carry barres, so `levelForFacts` can never call one
|
|
99
|
+
* "established" even when its authoritative, stored level (`levelForTop3`)
|
|
100
|
+
* says otherwise. Shape-class matching still reads `facts[i]` either way:
|
|
101
|
+
* only the level lookup is overridable. Missing entries (a shorter array,
|
|
102
|
+
* or `undefined` at an index) fall back to the derived level. Prefer
|
|
103
|
+
* `selectForResult` when a `GuitarChordResult` (or equivalent
|
|
104
|
+
* positions+levels pair) is in hand — it threads this argument for you so a
|
|
105
|
+
* caller cannot forget it and silently reintroduce derived-only ranking.
|
|
106
|
+
*/
|
|
107
|
+
export declare function selectForExperience(facts: PositionFacts[], query: ExperienceQuery, levels?: ExperienceLevel[]): ExperienceSelection;
|
|
108
|
+
/**
|
|
109
|
+
* `selectForExperience`, but for a `GuitarChordResult` (or any duck-typed
|
|
110
|
+
* `{ positions, levels }` pair) instead of a bare facts array.
|
|
111
|
+
*
|
|
112
|
+
* `selectForExperience`'s third argument is what stops top-3 shapes being
|
|
113
|
+
* mis-ranked: the six-string derivation (`levelForFacts`) is structurally
|
|
114
|
+
* unable to call a top-3 shape "established" because top-3 positions never
|
|
115
|
+
* carry barres, so a caller that forgets to pass `levels` silently gets a
|
|
116
|
+
* shape re-ranked one or more rungs too easy. Threading it by hand is exactly
|
|
117
|
+
* the kind of thing a future caller forgets, so this wrapper does it for you:
|
|
118
|
+
* it derives `facts` from `result.positions` and always passes
|
|
119
|
+
* `result.levels` through, leaving nowhere for the argument to get dropped.
|
|
120
|
+
*
|
|
121
|
+
* Prefer this whenever a full result (not just bare facts) is in hand. The
|
|
122
|
+
* bare-facts form of `selectForExperience` stays published for callers that
|
|
123
|
+
* only ever have `PositionFacts[]` — most tests, and anything working one
|
|
124
|
+
* level below a `GuitarChordResult`.
|
|
125
|
+
*/
|
|
126
|
+
export declare function selectForResult(result: {
|
|
127
|
+
positions: ChordsDbPosition[];
|
|
128
|
+
levels: ExperienceLevel[];
|
|
129
|
+
}, openMidi: number[], rootPc: number | null, query: ExperienceQuery): ExperienceSelection;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { positionFacts } from "./voicingFacts.js";
|
|
2
|
+
import { matchesShapeClass } from "./voicingSelect.js";
|
|
3
|
+
/** Easiest first, so `indexOf` gives the rung number and +1 widens. */
|
|
4
|
+
export const EXPERIENCE_LADDER = [
|
|
5
|
+
"beginner",
|
|
6
|
+
"emerging",
|
|
7
|
+
"established",
|
|
8
|
+
];
|
|
9
|
+
/**
|
|
10
|
+
* An open shape is barre-free and at the nut — nothing to hold down across
|
|
11
|
+
* strings and no hand position to find. Barre-free but moved up the neck is
|
|
12
|
+
* the next step. A barre is the rung most beginners stall on, so it anchors
|
|
13
|
+
* the top.
|
|
14
|
+
*/
|
|
15
|
+
export function levelForFacts(facts) {
|
|
16
|
+
if (facts.isOpenShape)
|
|
17
|
+
return "beginner";
|
|
18
|
+
if (!facts.hasBarre)
|
|
19
|
+
return "emerging";
|
|
20
|
+
return "established";
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Rank a three-string shape. `PositionFacts` cannot: it assumes six strings
|
|
24
|
+
* and real barres, and a top-3 shape has neither.
|
|
25
|
+
*
|
|
26
|
+
* The axes are the ones the generator already ranks candidates by — stretch,
|
|
27
|
+
* finger count, open strings, distance from the nut. A shape a learner can
|
|
28
|
+
* play without moving out of first position is the dividing line, because
|
|
29
|
+
* finding a hand position is the skill that separates the first two rungs.
|
|
30
|
+
*
|
|
31
|
+
* Accepts either of two representations of the same shape, and returns the
|
|
32
|
+
* same level for both:
|
|
33
|
+
*
|
|
34
|
+
* - absolute frets measured from the nut, with `position` pinned at 1 —
|
|
35
|
+
* what the generated table stores (`Top3GeneratedEntry.frets`);
|
|
36
|
+
* - window-relative frets paired with the diagram's real `position` —
|
|
37
|
+
* what `staticPresets.top3Window()` produces for rendering.
|
|
38
|
+
*
|
|
39
|
+
* They agree because the function only ever compares
|
|
40
|
+
* `position - 1 + max(fretted)` to the nut: sliding a window down by
|
|
41
|
+
* `position - 1` and adding it back is the same transform run forward and
|
|
42
|
+
* back, so the recovered highest fret — and therefore the level — is
|
|
43
|
+
* identical either way. A caller holding a rendered preset (windowed frets
|
|
44
|
+
* plus its real position) can pass those straight in rather than
|
|
45
|
+
* reconstructing absolute frets by hand, which is exactly the arithmetic that
|
|
46
|
+
* has produced off-diagram bugs elsewhere in this codebase.
|
|
47
|
+
*/
|
|
48
|
+
export function levelForTop3(frets, position) {
|
|
49
|
+
const fretted = frets.filter((f) => f > 0);
|
|
50
|
+
const opens = frets.filter((f) => f === 0).length;
|
|
51
|
+
const span = fretted.length ? Math.max(...fretted) - Math.min(...fretted) : 0;
|
|
52
|
+
const highest = position - 1 + (fretted.length ? Math.max(...fretted) : 0);
|
|
53
|
+
// Away from the nut is established regardless of how few fingers it takes:
|
|
54
|
+
// the hand has to be placed before it can be shaped.
|
|
55
|
+
if (position > 1 || highest > 4)
|
|
56
|
+
return "established";
|
|
57
|
+
// At the nut: open strings or at most two fingers, with no stretch.
|
|
58
|
+
if (span <= 1 && (opens > 0 || fretted.length <= 2))
|
|
59
|
+
return "beginner";
|
|
60
|
+
return "emerging";
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Filter by level, refine by shape class, and relax rather than show nothing.
|
|
64
|
+
*
|
|
65
|
+
* Level matching is CUMULATIVE, not exclusive: `query.level` answers "can I
|
|
66
|
+
* play this yet", so a level matches every shape ranked at or below it on
|
|
67
|
+
* `EXPERIENCE_LADDER` — `established` matches everything, `emerging` matches
|
|
68
|
+
* beginner + emerging, `beginner` matches only beginner (there is nothing
|
|
69
|
+
* below it to include). An established player can certainly play an open C;
|
|
70
|
+
* excluding the open shape from an "established" query was the bug, not a
|
|
71
|
+
* feature.
|
|
72
|
+
*
|
|
73
|
+
* Emptiness is still possible at the bottom rung: 220 of 529 chords have no
|
|
74
|
+
* open shape at all and 88 have only barre shapes, so a beginner filter finds
|
|
75
|
+
* nothing for nearly half the corpus. Returning an empty frame there would
|
|
76
|
+
* punish exactly the learner the filter is for, so relaxation still widens
|
|
77
|
+
* one rung at a time rather than giving up.
|
|
78
|
+
*
|
|
79
|
+
* Relaxation order is deliberate. The refinement goes first because the level
|
|
80
|
+
* is what the user asked for and the shape class only narrows it; widening the
|
|
81
|
+
* level changes the answer to the question they asked.
|
|
82
|
+
*
|
|
83
|
+
* @param levels - Optional, parallel to `facts`. When an entry is present it
|
|
84
|
+
* is used verbatim instead of `levelForFacts(facts[i])`. This is for
|
|
85
|
+
* shapes whose facts cannot support the six-string derivation — top-3
|
|
86
|
+
* presets never carry barres, so `levelForFacts` can never call one
|
|
87
|
+
* "established" even when its authoritative, stored level (`levelForTop3`)
|
|
88
|
+
* says otherwise. Shape-class matching still reads `facts[i]` either way:
|
|
89
|
+
* only the level lookup is overridable. Missing entries (a shorter array,
|
|
90
|
+
* or `undefined` at an index) fall back to the derived level. Prefer
|
|
91
|
+
* `selectForResult` when a `GuitarChordResult` (or equivalent
|
|
92
|
+
* positions+levels pair) is in hand — it threads this argument for you so a
|
|
93
|
+
* caller cannot forget it and silently reintroduce derived-only ranking.
|
|
94
|
+
*/
|
|
95
|
+
export function selectForExperience(facts, query, levels) {
|
|
96
|
+
if (facts.length === 0) {
|
|
97
|
+
return { indices: [], level: query.level, droppedShapeClass: false };
|
|
98
|
+
}
|
|
99
|
+
const cls = query.shapeClass ?? "any";
|
|
100
|
+
const levelOf = (i) => levels?.[i] ?? levelForFacts(facts[i]);
|
|
101
|
+
const rank = (level) => EXPERIENCE_LADDER.indexOf(level);
|
|
102
|
+
// Cumulative: everything ranked at or below `level` counts as a match.
|
|
103
|
+
const at = (level, withClass) => facts
|
|
104
|
+
.map((f, i) => i)
|
|
105
|
+
.filter((i) => rank(levelOf(i)) <= rank(level) &&
|
|
106
|
+
(!withClass || matchesShapeClass(facts[i], cls)));
|
|
107
|
+
// 1. level + refinement
|
|
108
|
+
const exact = at(query.level, true);
|
|
109
|
+
if (exact.length > 0) {
|
|
110
|
+
return { indices: exact, level: query.level, droppedShapeClass: false };
|
|
111
|
+
}
|
|
112
|
+
// 2. drop the refinement, same level
|
|
113
|
+
const noClass = at(query.level, false);
|
|
114
|
+
if (noClass.length > 0) {
|
|
115
|
+
return { indices: noClass, level: query.level, droppedShapeClass: cls !== "any" };
|
|
116
|
+
}
|
|
117
|
+
// 3. widen the level, one rung at a time, refinement already gone. This
|
|
118
|
+
// loop always returns before exhausting the ladder: matching is cumulative
|
|
119
|
+
// (step 2 above), so `at("established", false)` — the last rung — matches
|
|
120
|
+
// every index whenever `facts` is non-empty, which the guard at the top of
|
|
121
|
+
// this function guarantees. A fourth "take everything" fallback after this
|
|
122
|
+
// loop used to exist for the case where even that failed; it was dead code
|
|
123
|
+
// that could never run, and it lied about `level` when it did (claiming to
|
|
124
|
+
// serve `query.level` while actually serving every shape). Deleted rather
|
|
125
|
+
// than kept honest, because there is nothing truthful left for it to say —
|
|
126
|
+
// the loop already covers the one case it was for.
|
|
127
|
+
const start = EXPERIENCE_LADDER.indexOf(query.level);
|
|
128
|
+
for (let i = start + 1; i < EXPERIENCE_LADDER.length; i++) {
|
|
129
|
+
const wider = at(EXPERIENCE_LADDER[i], false);
|
|
130
|
+
if (wider.length > 0) {
|
|
131
|
+
return {
|
|
132
|
+
indices: wider,
|
|
133
|
+
level: EXPERIENCE_LADDER[i],
|
|
134
|
+
droppedShapeClass: cls !== "any",
|
|
135
|
+
widenedFrom: query.level,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
throw new Error("selectForExperience: unreachable — cumulative matching guarantees the " +
|
|
140
|
+
"widening loop returns by \"established\" whenever facts is non-empty");
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* `selectForExperience`, but for a `GuitarChordResult` (or any duck-typed
|
|
144
|
+
* `{ positions, levels }` pair) instead of a bare facts array.
|
|
145
|
+
*
|
|
146
|
+
* `selectForExperience`'s third argument is what stops top-3 shapes being
|
|
147
|
+
* mis-ranked: the six-string derivation (`levelForFacts`) is structurally
|
|
148
|
+
* unable to call a top-3 shape "established" because top-3 positions never
|
|
149
|
+
* carry barres, so a caller that forgets to pass `levels` silently gets a
|
|
150
|
+
* shape re-ranked one or more rungs too easy. Threading it by hand is exactly
|
|
151
|
+
* the kind of thing a future caller forgets, so this wrapper does it for you:
|
|
152
|
+
* it derives `facts` from `result.positions` and always passes
|
|
153
|
+
* `result.levels` through, leaving nowhere for the argument to get dropped.
|
|
154
|
+
*
|
|
155
|
+
* Prefer this whenever a full result (not just bare facts) is in hand. The
|
|
156
|
+
* bare-facts form of `selectForExperience` stays published for callers that
|
|
157
|
+
* only ever have `PositionFacts[]` — most tests, and anything working one
|
|
158
|
+
* level below a `GuitarChordResult`.
|
|
159
|
+
*/
|
|
160
|
+
export function selectForResult(result, openMidi, rootPc, query) {
|
|
161
|
+
const facts = result.positions.map((pos) => positionFacts(pos, openMidi, rootPc));
|
|
162
|
+
return selectForExperience(facts, query, result.levels);
|
|
163
|
+
}
|
|
164
|
+
//# sourceMappingURL=experience.js.map
|