@pepperhorn/chordl-core 0.3.4 → 0.5.0
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/dist/audio/midi-export.js +11 -2
- package/dist/audio/midi-export.js.map +1 -1
- package/dist/chord-sheet/codec.d.ts +1 -1
- package/dist/chord-sheet/codec.js +1 -1
- package/dist/chord-sheet/codec.js.map +1 -1
- package/dist/chord-sheet/defaults.d.ts +8 -1
- package/dist/chord-sheet/defaults.js +25 -1
- package/dist/chord-sheet/defaults.js.map +1 -1
- package/dist/chord-sheet/index.d.ts +3 -3
- package/dist/chord-sheet/index.js +3 -3
- package/dist/chord-sheet/index.js.map +1 -1
- package/dist/engine/auto-fingering.js +1 -1
- package/dist/engine/auto-fingering.js.map +1 -1
- package/dist/engine/highlight-mapper.d.ts +1 -1
- package/dist/engine/highlight-mapper.js +2 -2
- package/dist/engine/highlight-mapper.js.map +1 -1
- package/dist/engine/keyboard-layout.d.ts +1 -1
- package/dist/engine/keyboard-layout.js +1 -1
- package/dist/engine/keyboard-layout.js.map +1 -1
- package/dist/engine/mei-builder.d.ts +47 -0
- package/dist/engine/mei-builder.js +155 -0
- package/dist/engine/mei-builder.js.map +1 -0
- package/dist/engine/staff-glyphs.js +5 -10
- package/dist/engine/staff-glyphs.js.map +1 -1
- package/dist/engine/staff-layout.js +2 -2
- package/dist/engine/staff-layout.js.map +1 -1
- package/dist/index.d.ts +52 -42
- package/dist/index.js +34 -28
- package/dist/index.js.map +1 -1
- package/dist/parser/natural-language.d.ts +23 -1
- package/dist/parser/natural-language.js +282 -32
- package/dist/parser/natural-language.js.map +1 -1
- package/dist/parser/progression-parser.d.ts +1 -1
- package/dist/parser/progression-parser.js +1 -1
- package/dist/parser/progression-parser.js.map +1 -1
- package/dist/pipeline.d.ts +1 -1
- package/dist/pipeline.js +46 -25
- package/dist/pipeline.js.map +1 -1
- package/dist/progression/index.d.ts +5 -5
- package/dist/progression/index.js +3 -3
- package/dist/progression/index.js.map +1 -1
- package/dist/progression/progression-resolver.js +5 -5
- package/dist/progression/progression-resolver.js.map +1 -1
- package/dist/progression/roman-numeral.js +1 -1
- package/dist/progression/roman-numeral.js.map +1 -1
- package/dist/resolver/auto-layout.d.ts +12 -1
- package/dist/resolver/auto-layout.js +27 -11
- package/dist/resolver/auto-layout.js.map +1 -1
- package/dist/resolver/chord-resolver.d.ts +11 -1
- package/dist/resolver/chord-resolver.js +28 -2
- package/dist/resolver/chord-resolver.js.map +1 -1
- package/dist/resolver/scale-resolver.d.ts +5 -2
- package/dist/resolver/scale-resolver.js +28 -8
- package/dist/resolver/scale-resolver.js.map +1 -1
- package/dist/themes/boomwhacker.d.ts +1 -1
- package/dist/themes/boomwhacker.js +1 -1
- package/dist/themes/boomwhacker.js.map +1 -1
- package/dist/themes/crf.d.ts +1 -1
- package/dist/themes/crf.js +1 -1
- package/dist/themes/crf.js.map +1 -1
- package/dist/themes/index.d.ts +1 -1
- package/dist/themes/index.js +3 -3
- package/dist/themes/index.js.map +1 -1
- package/dist/themes/simple.d.ts +1 -1
- package/dist/themes/simple.js +1 -1
- package/dist/themes/simple.js.map +1 -1
- package/dist/theory/chord-identify.d.ts +72 -0
- package/dist/theory/chord-identify.js +334 -0
- package/dist/theory/chord-identify.js.map +1 -0
- package/dist/theory/constrained-variants.d.ts +46 -0
- package/dist/theory/constrained-variants.js +117 -0
- package/dist/theory/constrained-variants.js.map +1 -0
- package/dist/theory/degree-labels.d.ts +39 -0
- package/dist/theory/degree-labels.js +102 -1
- package/dist/theory/degree-labels.js.map +1 -1
- package/dist/theory/hand-constraints.d.ts +104 -0
- package/dist/theory/hand-constraints.js +214 -0
- package/dist/theory/hand-constraints.js.map +1 -0
- package/dist/theory/starting-note.d.ts +43 -0
- package/dist/theory/starting-note.js +97 -0
- package/dist/theory/starting-note.js.map +1 -0
- package/dist/types.d.ts +19 -1
- package/package.json +9 -4
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Voicing variants that fit a given hand.
|
|
3
|
+
*
|
|
4
|
+
* This lives in core rather than chordl-voicings because it needs
|
|
5
|
+
* classifyTones/dropOrder, and voicings is a leaf package that core depends
|
|
6
|
+
* on — the dependency cannot run the other way.
|
|
7
|
+
*/
|
|
8
|
+
import { generateVariants } from "@pepperhorn/chordl-voicings";
|
|
9
|
+
import { classifyTones, dropOrder } from "./chord-tones.js";
|
|
10
|
+
import { constrainVoicing, pcSemitone } from "./hand-constraints.js";
|
|
11
|
+
/**
|
|
12
|
+
* Drop order when the chord's intervals are unknown.
|
|
13
|
+
*
|
|
14
|
+
* resolveChord returns undefined intervals for special-builder chords, so
|
|
15
|
+
* roles cannot be classified. But it returns notes in root-position order, so
|
|
16
|
+
* position stands in for role: 0 = root, 1 = 3rd, 2 = 5th, 3 = 7th, 4+ =
|
|
17
|
+
* extensions. That is enough to mirror what classifyTones would conclude on
|
|
18
|
+
* the primary path, rather than diverging from it.
|
|
19
|
+
*
|
|
20
|
+
* Triads keep the 5th — on piano a triad needs it to still be a triad, which
|
|
21
|
+
* is exactly what assignRole concludes for degree 5 in the "triad" family.
|
|
22
|
+
* Larger chords treat the 5th as omittable and protect the 3rd and 7th, so
|
|
23
|
+
* the root stays droppable and a shell voicing can form.
|
|
24
|
+
*/
|
|
25
|
+
function fallbackDropOrder(notes) {
|
|
26
|
+
if (notes.length <= 3) {
|
|
27
|
+
const keep = notes.slice(1, 3);
|
|
28
|
+
return { order: notes[0] === undefined ? [] : [notes[0]], keep };
|
|
29
|
+
}
|
|
30
|
+
const order = [];
|
|
31
|
+
for (let i = notes.length - 1; i >= 4; i--)
|
|
32
|
+
order.push(notes[i]); // extensions, top down
|
|
33
|
+
if (notes[2] !== undefined)
|
|
34
|
+
order.push(notes[2]); // 5th — conventionally omittable
|
|
35
|
+
if (notes[0] !== undefined)
|
|
36
|
+
order.push(notes[0]); // root — last, forming a shell
|
|
37
|
+
const keep = [notes[1], notes[3]].filter((n) => n !== undefined);
|
|
38
|
+
return { order, keep };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Put a variant's unclassified notes at the head of the drop order.
|
|
42
|
+
*
|
|
43
|
+
* `order` and `keep` come from the resolved chord tones, so a library
|
|
44
|
+
* voicing's added 9th or 13th — the whole point of a rootless voicing — is in
|
|
45
|
+
* neither list. `constrainVoicing` only ever drops what `dropOrder` names, so
|
|
46
|
+
* without this the reduction stalls with the voicing still over the cap.
|
|
47
|
+
* Extensions are colour tones and the safest thing to lose, so they go first,
|
|
48
|
+
* in the order the voicing lists them.
|
|
49
|
+
*
|
|
50
|
+
* Membership is tested by semitone because chordl-voicings spells library
|
|
51
|
+
* variants with sharps where core's resolver yields flats.
|
|
52
|
+
*/
|
|
53
|
+
function withUnclassifiedFirst(notes, order, keep) {
|
|
54
|
+
const classified = new Set([...order, ...keep].map(pcSemitone));
|
|
55
|
+
const extras = [];
|
|
56
|
+
for (const note of notes) {
|
|
57
|
+
const semitone = pcSemitone(note);
|
|
58
|
+
if (classified.has(semitone))
|
|
59
|
+
continue;
|
|
60
|
+
classified.add(semitone);
|
|
61
|
+
extras.push(note);
|
|
62
|
+
}
|
|
63
|
+
return extras.length === 0 ? order : [...extras, ...order];
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Generate voicing variants and fit each to a hand.
|
|
67
|
+
*
|
|
68
|
+
* Variants that satisfy the constraints are returned first, then those that
|
|
69
|
+
* could not be satisfied without losing a chord tone. Ordering matters because
|
|
70
|
+
* an inversion often fits where root position cannot: Cmaj7 spans 11 semitones
|
|
71
|
+
* in root position but 8 in first inversion.
|
|
72
|
+
*
|
|
73
|
+
* @param root - root note, e.g. "C"
|
|
74
|
+
* @param quality - voicing quality from mapToVoicingQuality, may be undefined
|
|
75
|
+
* @param resolvedNotes - pitch classes in root position
|
|
76
|
+
* @param count - how many variants to generate before constraining
|
|
77
|
+
* @param constraints - the hand's reach and note count
|
|
78
|
+
*/
|
|
79
|
+
export function generateConstrainedVariants(root, quality, resolvedNotes, count, constraints, options = {}) {
|
|
80
|
+
const variants = generateVariants(root, quality, resolvedNotes, count, {
|
|
81
|
+
styleHint: options.styleHint,
|
|
82
|
+
excludeIds: options.excludeIds,
|
|
83
|
+
});
|
|
84
|
+
if (variants.length === 0)
|
|
85
|
+
return [];
|
|
86
|
+
let order;
|
|
87
|
+
let keep;
|
|
88
|
+
if (options.intervals && options.intervals.length > 0) {
|
|
89
|
+
const analysis = classifyTones(root, options.chordType ?? "", options.intervals, resolvedNotes, "piano");
|
|
90
|
+
order = dropOrder(analysis);
|
|
91
|
+
// Identity tones only — the root is droppable as a last resort, which is
|
|
92
|
+
// exactly how a shell voicing is formed.
|
|
93
|
+
keep = analysis.identityTones;
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
({ order, keep } = fallbackDropOrder(resolvedNotes));
|
|
97
|
+
}
|
|
98
|
+
const constrained = variants.map((v) => ({
|
|
99
|
+
id: v.id,
|
|
100
|
+
label: v.label,
|
|
101
|
+
source: v.source,
|
|
102
|
+
voicing: constrainVoicing({
|
|
103
|
+
notes: v.notes,
|
|
104
|
+
handHints: v.handHints,
|
|
105
|
+
dropOrder: withUnclassifiedFirst(v.notes, order, keep),
|
|
106
|
+
keepAtLeast: keep,
|
|
107
|
+
constraints,
|
|
108
|
+
baseOctave: options.baseOctave,
|
|
109
|
+
}),
|
|
110
|
+
}));
|
|
111
|
+
// Stable partition: satisfied first, original order preserved within groups.
|
|
112
|
+
return [
|
|
113
|
+
...constrained.filter((c) => c.voicing.satisfied),
|
|
114
|
+
...constrained.filter((c) => !c.voicing.satisfied),
|
|
115
|
+
];
|
|
116
|
+
}
|
|
117
|
+
//# sourceMappingURL=constrained-variants.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"constrained-variants.js","sourceRoot":"","sources":["../../src/theory/constrained-variants.ts"],"names":[],"mappings":"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"}
|
|
@@ -29,3 +29,42 @@ export declare function intervalToDegreeLabel(interval: string): string;
|
|
|
29
29
|
* degreesForIntervals(["1P", "3M", "5A", "7m"]) → ["I", "III", "#V", "bVII"]
|
|
30
30
|
*/
|
|
31
31
|
export declare function degreesForIntervals(intervals: string[]): string[];
|
|
32
|
+
/**
|
|
33
|
+
* Label each rendered note with the degree it plays in the chord, keyed by
|
|
34
|
+
* pitch rather than by position.
|
|
35
|
+
*
|
|
36
|
+
* Position is not usable here: `notes` is rotated by inversions and by
|
|
37
|
+
* "starting on", and repeated across octaves for arpeggios, while
|
|
38
|
+
* `resolved.intervals` stays canonical (the resolver deliberately does not
|
|
39
|
+
* rotate it — the voicing lookup classifies by degree number). Zipping the two
|
|
40
|
+
* by index therefore called whatever note came first the root.
|
|
41
|
+
*
|
|
42
|
+
* Matching is spelling-first, then chroma. Spelling has to win: `C7#5b13`
|
|
43
|
+
* renders both `G#` (5A) and `Ab` (6m), which share chroma 8, and a chroma-first
|
|
44
|
+
* lookup would give them the same label. Chroma is the fallback for the cases
|
|
45
|
+
* spelling cannot cover — a `7d` off C is `Bbb` but renders as `A`, and a
|
|
46
|
+
* voicing may respell `Bb` as `A#`.
|
|
47
|
+
*
|
|
48
|
+
* A note in neither map — an added tone from a style voicing, e.g. the 9th in a
|
|
49
|
+
* rootless `Cmaj7` — is labelled from its distance to the root instead of
|
|
50
|
+
* borrowing a chord tone's label, and a 2nd, 4th or 6th is raised an octave so
|
|
51
|
+
* it reads as the extension it is: `IX`, `XI`, `XIII`. The quality rides along,
|
|
52
|
+
* so an altered extension still reads `bIX` / `#XI` / `bXIII`. Raising is safe
|
|
53
|
+
* precisely because this branch only fires for notes absent from the chord's
|
|
54
|
+
* own interval list: a chord that genuinely owns a 2nd, 4th or 6th (`Csus2`,
|
|
55
|
+
* `Csus4`, `C6`) matches by spelling or chroma first and keeps `II`, `IV`, `VI`.
|
|
56
|
+
* Anything that still cannot be named comes back `undefined`, and the keyboard
|
|
57
|
+
* draws no label for it.
|
|
58
|
+
*
|
|
59
|
+
* @param root The chord root, e.g. "C".
|
|
60
|
+
* @param intervals The chord's canonical intervals, e.g. ["1P","3M","5P"].
|
|
61
|
+
* @param notes The notes as rendered, in render order.
|
|
62
|
+
* @returns One label per note, index-parallel to `notes`.
|
|
63
|
+
*
|
|
64
|
+
* @example
|
|
65
|
+
* degreeLabelsForNotes("C", ["1P","3M","5P"], ["E","G","C"]) → ["III","V","I"]
|
|
66
|
+
* degreeLabelsForNotes("C", ["3M","5P","8P"], ["E","G","C"]) → ["III","V","8"]
|
|
67
|
+
* degreeLabelsForNotes("C", ["1P","3M","5P","7M"], ["E","G","B","D"])
|
|
68
|
+
* → ["III","V","VII","IX"] // the D is an added tone, so it reads as a 9th
|
|
69
|
+
*/
|
|
70
|
+
export declare function degreeLabelsForNotes(root: string, intervals: string[] | undefined, notes: string[]): (string | undefined)[];
|
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - Minor/Diminished = flat (b)
|
|
10
10
|
* - Augmented = sharp (#)
|
|
11
11
|
*/
|
|
12
|
-
import {
|
|
12
|
+
import { Note, Interval } from "tonal";
|
|
13
|
+
import { parseInterval } from "./chord-tones.js";
|
|
13
14
|
const ROMAN = ["", "I", "II", "III", "IV", "V", "VI", "VII"];
|
|
14
15
|
const ROMAN_EXT = {
|
|
15
16
|
9: "IX", 10: "X", 11: "XI", 12: "XII", 13: "XIII",
|
|
@@ -71,4 +72,104 @@ export function intervalToDegreeLabel(interval) {
|
|
|
71
72
|
export function degreesForIntervals(intervals) {
|
|
72
73
|
return intervals.map(intervalToDegreeLabel);
|
|
73
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Raise a simple 2nd, 4th or 6th by an octave so it reads as the extension it
|
|
77
|
+
* is: 2→9, 4→11, 6→13. The quality is carried over untouched, so `2m` becomes
|
|
78
|
+
* `9m` and `4A` becomes `11A` — a flattened or raised extension keeps its
|
|
79
|
+
* accidental. Degrees 1, 3, 5, 7 and 8 are returned unchanged.
|
|
80
|
+
*
|
|
81
|
+
* Only for the non-chord-tone fallback in `degreeLabelsForNotes`: pitch-class
|
|
82
|
+
* distances are always simple, so an added 9th arrives here as a bare `2M`.
|
|
83
|
+
*/
|
|
84
|
+
function raiseExtension(interval) {
|
|
85
|
+
const parsed = Interval.get(interval);
|
|
86
|
+
if (parsed.empty || parsed.num == null)
|
|
87
|
+
return interval;
|
|
88
|
+
if (parsed.num !== 2 && parsed.num !== 4 && parsed.num !== 6)
|
|
89
|
+
return interval;
|
|
90
|
+
return `${parsed.num + 7}${parsed.q}`;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Label each rendered note with the degree it plays in the chord, keyed by
|
|
94
|
+
* pitch rather than by position.
|
|
95
|
+
*
|
|
96
|
+
* Position is not usable here: `notes` is rotated by inversions and by
|
|
97
|
+
* "starting on", and repeated across octaves for arpeggios, while
|
|
98
|
+
* `resolved.intervals` stays canonical (the resolver deliberately does not
|
|
99
|
+
* rotate it — the voicing lookup classifies by degree number). Zipping the two
|
|
100
|
+
* by index therefore called whatever note came first the root.
|
|
101
|
+
*
|
|
102
|
+
* Matching is spelling-first, then chroma. Spelling has to win: `C7#5b13`
|
|
103
|
+
* renders both `G#` (5A) and `Ab` (6m), which share chroma 8, and a chroma-first
|
|
104
|
+
* lookup would give them the same label. Chroma is the fallback for the cases
|
|
105
|
+
* spelling cannot cover — a `7d` off C is `Bbb` but renders as `A`, and a
|
|
106
|
+
* voicing may respell `Bb` as `A#`.
|
|
107
|
+
*
|
|
108
|
+
* A note in neither map — an added tone from a style voicing, e.g. the 9th in a
|
|
109
|
+
* rootless `Cmaj7` — is labelled from its distance to the root instead of
|
|
110
|
+
* borrowing a chord tone's label, and a 2nd, 4th or 6th is raised an octave so
|
|
111
|
+
* it reads as the extension it is: `IX`, `XI`, `XIII`. The quality rides along,
|
|
112
|
+
* so an altered extension still reads `bIX` / `#XI` / `bXIII`. Raising is safe
|
|
113
|
+
* precisely because this branch only fires for notes absent from the chord's
|
|
114
|
+
* own interval list: a chord that genuinely owns a 2nd, 4th or 6th (`Csus2`,
|
|
115
|
+
* `Csus4`, `C6`) matches by spelling or chroma first and keeps `II`, `IV`, `VI`.
|
|
116
|
+
* Anything that still cannot be named comes back `undefined`, and the keyboard
|
|
117
|
+
* draws no label for it.
|
|
118
|
+
*
|
|
119
|
+
* @param root The chord root, e.g. "C".
|
|
120
|
+
* @param intervals The chord's canonical intervals, e.g. ["1P","3M","5P"].
|
|
121
|
+
* @param notes The notes as rendered, in render order.
|
|
122
|
+
* @returns One label per note, index-parallel to `notes`.
|
|
123
|
+
*
|
|
124
|
+
* @example
|
|
125
|
+
* degreeLabelsForNotes("C", ["1P","3M","5P"], ["E","G","C"]) → ["III","V","I"]
|
|
126
|
+
* degreeLabelsForNotes("C", ["3M","5P","8P"], ["E","G","C"]) → ["III","V","8"]
|
|
127
|
+
* degreeLabelsForNotes("C", ["1P","3M","5P","7M"], ["E","G","B","D"])
|
|
128
|
+
* → ["III","V","VII","IX"] // the D is an added tone, so it reads as a 9th
|
|
129
|
+
*/
|
|
130
|
+
export function degreeLabelsForNotes(root, intervals, notes) {
|
|
131
|
+
if (!intervals || intervals.length === 0)
|
|
132
|
+
return notes.map(() => undefined);
|
|
133
|
+
// First interval to claim a pitch keeps it: a chord that names the same
|
|
134
|
+
// sounding pitch twice should read as the lower of the two degrees.
|
|
135
|
+
const bySpelling = new Map();
|
|
136
|
+
const byChroma = new Map();
|
|
137
|
+
for (const iv of intervals) {
|
|
138
|
+
const pc = Note.pitchClass(Note.transpose(root, iv));
|
|
139
|
+
if (!pc)
|
|
140
|
+
continue;
|
|
141
|
+
const label = intervalToDegreeLabel(iv);
|
|
142
|
+
// A slash chord whose bass sits below the root arrives with a negative
|
|
143
|
+
// interval — C/F is ["-5P","1P","3M","5P"] — and parseInterval's `^(\d+)`
|
|
144
|
+
// does not match the leading minus, so the label is the "?" sentinel.
|
|
145
|
+
// Leaving it out of the maps hands the note to the distance fallback,
|
|
146
|
+
// which names it from the root and gets bVII for C/Bb. Storing it printed
|
|
147
|
+
// a literal "?" under the bass key.
|
|
148
|
+
if (label === "?")
|
|
149
|
+
continue;
|
|
150
|
+
if (!bySpelling.has(pc))
|
|
151
|
+
bySpelling.set(pc, label);
|
|
152
|
+
const chroma = Note.chroma(pc);
|
|
153
|
+
if (Number.isFinite(chroma) && !byChroma.has(chroma))
|
|
154
|
+
byChroma.set(chroma, label);
|
|
155
|
+
}
|
|
156
|
+
return notes.map((n) => {
|
|
157
|
+
const pc = Note.pitchClass(n) || n;
|
|
158
|
+
const spelled = bySpelling.get(pc);
|
|
159
|
+
if (spelled !== undefined)
|
|
160
|
+
return spelled;
|
|
161
|
+
const chroma = Note.chroma(pc);
|
|
162
|
+
if (Number.isFinite(chroma)) {
|
|
163
|
+
const enharmonic = byChroma.get(chroma);
|
|
164
|
+
if (enharmonic !== undefined)
|
|
165
|
+
return enharmonic;
|
|
166
|
+
}
|
|
167
|
+
// Not a chord tone — name it by its distance from the root anyway.
|
|
168
|
+
const distance = Interval.distance(root, pc);
|
|
169
|
+
if (!distance)
|
|
170
|
+
return undefined;
|
|
171
|
+
const label = intervalToDegreeLabel(raiseExtension(distance));
|
|
172
|
+
return label === "?" ? undefined : label;
|
|
173
|
+
});
|
|
174
|
+
}
|
|
74
175
|
//# sourceMappingURL=degree-labels.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"degree-labels.js","sourceRoot":"","sources":["../../src/theory/degree-labels.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,
|
|
1
|
+
{"version":3,"file":"degree-labels.js","sourceRoot":"","sources":["../../src/theory/degree-labels.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import type { Hand } from "@pepperhorn/chordl-voicings";
|
|
2
|
+
export interface HandConstraints {
|
|
3
|
+
/** Maximum semitones between lowest and highest note in one hand. */
|
|
4
|
+
maxSpanPerHand: number;
|
|
5
|
+
/** Maximum notes in one hand. Values below 2 are clamped to 2. */
|
|
6
|
+
maxNotesPerHand: number;
|
|
7
|
+
}
|
|
8
|
+
export interface PlacedNote {
|
|
9
|
+
note: string;
|
|
10
|
+
octave: number;
|
|
11
|
+
midi: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* MIDI number for a pitch class in a given octave, C4 = 60.
|
|
15
|
+
*
|
|
16
|
+
* PC_SEMITONES handles both sharps and flats, so no spelling normalization is
|
|
17
|
+
* needed here.
|
|
18
|
+
*/
|
|
19
|
+
export declare function midiOf(note: string, octave: number): number;
|
|
20
|
+
/**
|
|
21
|
+
* Stack pitch classes into an ascending voicing.
|
|
22
|
+
*
|
|
23
|
+
* @param notes - pitch classes in voicing order
|
|
24
|
+
* @param baseOctave - octave of the first note
|
|
25
|
+
* @param maxSpan - optional semitone limit; folds notes down where it can
|
|
26
|
+
* (0 = no folding). Folding is preferred over dropping because it keeps
|
|
27
|
+
* every chord tone.
|
|
28
|
+
*/
|
|
29
|
+
export declare function placeAscending(notes: string[], baseOctave: number, maxSpan?: number): PlacedNote[];
|
|
30
|
+
/** Semitones between the lowest and highest pitch. Zero for 0 or 1 notes. */
|
|
31
|
+
export declare function spanOf(pitches: number[]): number;
|
|
32
|
+
export interface ConstrainedNote extends PlacedNote {
|
|
33
|
+
hand: Hand;
|
|
34
|
+
}
|
|
35
|
+
export interface ConstrainVoicingInput {
|
|
36
|
+
/** Pitch classes in voicing order. */
|
|
37
|
+
notes: string[];
|
|
38
|
+
/**
|
|
39
|
+
* Per-note hand assignment. When absent the whole voicing counts as one
|
|
40
|
+
* hand — variants from the `inversion` and `algorithmic` sources carry no
|
|
41
|
+
* hand hints, so both caps apply to the voicing as a whole.
|
|
42
|
+
*/
|
|
43
|
+
handHints?: Hand[];
|
|
44
|
+
/**
|
|
45
|
+
* Safest-to-drop first, from core's `dropOrder(analysis)`. Matched against
|
|
46
|
+
* `notes` by semitone, so either list may be spelled with sharps or flats.
|
|
47
|
+
*/
|
|
48
|
+
dropOrder: string[];
|
|
49
|
+
/**
|
|
50
|
+
* Notes that must survive — the chord's identity tones. Matched by semitone,
|
|
51
|
+
* like `dropOrder`.
|
|
52
|
+
*/
|
|
53
|
+
keepAtLeast: string[];
|
|
54
|
+
constraints: HandConstraints;
|
|
55
|
+
/** Octave of the lowest note. Default 3. */
|
|
56
|
+
baseOctave?: number;
|
|
57
|
+
}
|
|
58
|
+
export interface ConstrainedVoicing {
|
|
59
|
+
/**
|
|
60
|
+
* Surviving notes in voicing order, each keeping its input spelling. Never
|
|
61
|
+
* two notes on the same MIDI number, and never fewer than two notes in a
|
|
62
|
+
* hand unless the input itself had fewer.
|
|
63
|
+
*/
|
|
64
|
+
notes: ConstrainedNote[];
|
|
65
|
+
/**
|
|
66
|
+
* Pitch classes removed, in the order they were dropped, spelled as the
|
|
67
|
+
* input spelled them rather than as `dropOrder` did.
|
|
68
|
+
*/
|
|
69
|
+
dropped: string[];
|
|
70
|
+
/** Largest per-hand span in the result, semitones. */
|
|
71
|
+
span: number;
|
|
72
|
+
/** True when every hand is within both caps. */
|
|
73
|
+
satisfied: boolean;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Semitone value for matching pitch classes across spellings.
|
|
77
|
+
*
|
|
78
|
+
* `PC_SEMITONES` maps sharps and flats onto the same number, so "Bb" and "A#"
|
|
79
|
+
* compare equal — necessary because chordl-voicings spells library variants
|
|
80
|
+
* with sharps while core's resolver yields flats. Unknown spellings map to -1
|
|
81
|
+
* rather than 0 so they never collide with C.
|
|
82
|
+
*/
|
|
83
|
+
export declare function pcSemitone(note: string): number;
|
|
84
|
+
/**
|
|
85
|
+
* Fit a voicing inside a hand's reach and note count.
|
|
86
|
+
*
|
|
87
|
+
* Strategy, in order:
|
|
88
|
+
* 1. Fold octaves — keeps every chord tone, so it is always preferred.
|
|
89
|
+
* 2. Drop notes in `dropOrder`, never below `keepAtLeast`, and never below
|
|
90
|
+
* two notes in any one hand.
|
|
91
|
+
*
|
|
92
|
+
* `dropOrder` and `keepAtLeast` are matched by semitone rather than by
|
|
93
|
+
* spelling, so a drop order written in flats still recognises a voicing
|
|
94
|
+
* spelled in sharps. Each returned note keeps its own original spelling —
|
|
95
|
+
* only the matching normalizes.
|
|
96
|
+
*
|
|
97
|
+
* When the constraints still cannot be met without dropping an identity tone
|
|
98
|
+
* or taking a hand below a dyad, returns the closest legal voicing with
|
|
99
|
+
* `satisfied: false` rather than returning nothing or a chord that is no
|
|
100
|
+
* longer the chord asked for. Callers wanting a voicing that genuinely fits
|
|
101
|
+
* should try other variants — an inversion often fits where root position
|
|
102
|
+
* cannot.
|
|
103
|
+
*/
|
|
104
|
+
export declare function constrainVoicing(input: ConstrainVoicingInput): ConstrainedVoicing;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard reach constraints.
|
|
3
|
+
*
|
|
4
|
+
* Two orthogonal limits, both required:
|
|
5
|
+
* - maxSpanPerHand — physical reach, in semitones
|
|
6
|
+
* - maxNotesPerHand — coordination, in note count
|
|
7
|
+
*
|
|
8
|
+
* Count alone does not express reach: a 3-note cap still admits C-E-C', three
|
|
9
|
+
* notes spanning a full octave, which a young player cannot stretch. The
|
|
10
|
+
* converse also holds — a 4-note cluster inside a 5th is an easy reach but
|
|
11
|
+
* harder to coordinate.
|
|
12
|
+
*
|
|
13
|
+
* Deriving a junior cutoff from "no 7ths or octaves": a 7th spans 10-11
|
|
14
|
+
* semitones and an octave 12, so maxSpanPerHand 9 (a major 6th) is binding.
|
|
15
|
+
*/
|
|
16
|
+
import { PC_SEMITONES, assignAscendingOctaves } from "../engine/note-spelling.js";
|
|
17
|
+
/**
|
|
18
|
+
* MIDI number for a pitch class in a given octave, C4 = 60.
|
|
19
|
+
*
|
|
20
|
+
* PC_SEMITONES handles both sharps and flats, so no spelling normalization is
|
|
21
|
+
* needed here.
|
|
22
|
+
*/
|
|
23
|
+
export function midiOf(note, octave) {
|
|
24
|
+
const semi = PC_SEMITONES[note] ?? 0;
|
|
25
|
+
return semi + (octave + 1) * 12;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Stack pitch classes into an ascending voicing.
|
|
29
|
+
*
|
|
30
|
+
* @param notes - pitch classes in voicing order
|
|
31
|
+
* @param baseOctave - octave of the first note
|
|
32
|
+
* @param maxSpan - optional semitone limit; folds notes down where it can
|
|
33
|
+
* (0 = no folding). Folding is preferred over dropping because it keeps
|
|
34
|
+
* every chord tone.
|
|
35
|
+
*/
|
|
36
|
+
export function placeAscending(notes, baseOctave, maxSpan = 0) {
|
|
37
|
+
return assignAscendingOctaves(notes, baseOctave, maxSpan).map(({ note, octave }) => ({
|
|
38
|
+
note,
|
|
39
|
+
octave,
|
|
40
|
+
midi: midiOf(note, octave),
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
/** Semitones between the lowest and highest pitch. Zero for 0 or 1 notes. */
|
|
44
|
+
export function spanOf(pitches) {
|
|
45
|
+
if (pitches.length < 2)
|
|
46
|
+
return 0;
|
|
47
|
+
return Math.max(...pitches) - Math.min(...pitches);
|
|
48
|
+
}
|
|
49
|
+
const DEFAULT_HAND = "LH";
|
|
50
|
+
/**
|
|
51
|
+
* Fewest notes a hand may be reduced to. A dyad is already a shell voicing —
|
|
52
|
+
* below that there is no chord left to name, so reduction stops and reports
|
|
53
|
+
* failure rather than returning a single note as a satisfied chord.
|
|
54
|
+
*/
|
|
55
|
+
const MIN_NOTES_PER_HAND = 2;
|
|
56
|
+
/**
|
|
57
|
+
* Semitone value for matching pitch classes across spellings.
|
|
58
|
+
*
|
|
59
|
+
* `PC_SEMITONES` maps sharps and flats onto the same number, so "Bb" and "A#"
|
|
60
|
+
* compare equal — necessary because chordl-voicings spells library variants
|
|
61
|
+
* with sharps while core's resolver yields flats. Unknown spellings map to -1
|
|
62
|
+
* rather than 0 so they never collide with C.
|
|
63
|
+
*/
|
|
64
|
+
export function pcSemitone(note) {
|
|
65
|
+
return PC_SEMITONES[note] ?? -1;
|
|
66
|
+
}
|
|
67
|
+
function groupsOf(notes) {
|
|
68
|
+
const byHand = new Map();
|
|
69
|
+
for (const n of notes) {
|
|
70
|
+
const list = byHand.get(n.hand);
|
|
71
|
+
if (list)
|
|
72
|
+
list.push(n);
|
|
73
|
+
else
|
|
74
|
+
byHand.set(n.hand, [n]);
|
|
75
|
+
}
|
|
76
|
+
return [...byHand.values()];
|
|
77
|
+
}
|
|
78
|
+
function withinCaps(groups, c, maxNotes) {
|
|
79
|
+
return groups.every((g) => g.length <= maxNotes && spanOf(g.map((n) => n.midi)) <= c.maxSpanPerHand);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Place a subset of the voicing (by index) and settle each hand's octaves.
|
|
83
|
+
*
|
|
84
|
+
* Step 1 stacks the whole subset ascending with no folding, so the voicing's
|
|
85
|
+
* true shape is preserved. Step 2 groups the result by hand and re-folds
|
|
86
|
+
* only the hands whose span exceeds the cap — folding is per-hand because
|
|
87
|
+
* two hands an octave apart should not be squeezed together just because
|
|
88
|
+
* the whole voicing looks wide. Step 3 collapses duplicate MIDI numbers
|
|
89
|
+
* (same key, one finger) so they cost one slot of the note-count budget.
|
|
90
|
+
*/
|
|
91
|
+
function place(indices, notes, baseOctave, maxSpanPerHand, handOf) {
|
|
92
|
+
const notesForThisCall = indices.map((i) => notes[i]);
|
|
93
|
+
// Step 1: Place ascending with NO folding to preserve the voicing's true shape.
|
|
94
|
+
const unfolded = placeAscending(notesForThisCall, baseOctave, 0).map((p, i) => ({ ...p, originalIndex: indices[i], hand: handOf(indices[i]) }));
|
|
95
|
+
// Step 2: Group by hand and re-fold each hand independently if needed.
|
|
96
|
+
const handToNotes = new Map();
|
|
97
|
+
for (const p of unfolded) {
|
|
98
|
+
const list = handToNotes.get(p.hand);
|
|
99
|
+
if (list)
|
|
100
|
+
list.push(p);
|
|
101
|
+
else
|
|
102
|
+
handToNotes.set(p.hand, [p]);
|
|
103
|
+
}
|
|
104
|
+
const refolded = [];
|
|
105
|
+
for (const [hand, notesInHand] of handToNotes) {
|
|
106
|
+
const span = spanOf(notesInHand.map((n) => n.midi));
|
|
107
|
+
if (span <= maxSpanPerHand) {
|
|
108
|
+
// Already fits, use as-is
|
|
109
|
+
refolded.push(...notesInHand);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
// Exceeds span, re-fold this hand's notes
|
|
113
|
+
const refoldedHand = placeAscending(notesInHand.map((n) => n.note), notesInHand[0].octave, maxSpanPerHand);
|
|
114
|
+
for (let i = 0; i < refoldedHand.length; i++) {
|
|
115
|
+
refolded.push({
|
|
116
|
+
...refoldedHand[i],
|
|
117
|
+
originalIndex: notesInHand[i].originalIndex,
|
|
118
|
+
hand,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
// Restore original voicing order by sorting by originalIndex.
|
|
123
|
+
refolded.sort((a, b) => a.originalIndex - b.originalIndex);
|
|
124
|
+
// Step 3: Two notes on the same MIDI number are one key under one finger.
|
|
125
|
+
// Collapse them so they cost one slot of the note-count budget, not two.
|
|
126
|
+
// The first in voicing order wins, so the surviving spelling and hand are
|
|
127
|
+
// the ones the voicing led with.
|
|
128
|
+
const seenMidi = new Set();
|
|
129
|
+
return refolded.filter((p) => {
|
|
130
|
+
if (seenMidi.has(p.midi))
|
|
131
|
+
return false;
|
|
132
|
+
seenMidi.add(p.midi);
|
|
133
|
+
return true;
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Fit a voicing inside a hand's reach and note count.
|
|
138
|
+
*
|
|
139
|
+
* Strategy, in order:
|
|
140
|
+
* 1. Fold octaves — keeps every chord tone, so it is always preferred.
|
|
141
|
+
* 2. Drop notes in `dropOrder`, never below `keepAtLeast`, and never below
|
|
142
|
+
* two notes in any one hand.
|
|
143
|
+
*
|
|
144
|
+
* `dropOrder` and `keepAtLeast` are matched by semitone rather than by
|
|
145
|
+
* spelling, so a drop order written in flats still recognises a voicing
|
|
146
|
+
* spelled in sharps. Each returned note keeps its own original spelling —
|
|
147
|
+
* only the matching normalizes.
|
|
148
|
+
*
|
|
149
|
+
* When the constraints still cannot be met without dropping an identity tone
|
|
150
|
+
* or taking a hand below a dyad, returns the closest legal voicing with
|
|
151
|
+
* `satisfied: false` rather than returning nothing or a chord that is no
|
|
152
|
+
* longer the chord asked for. Callers wanting a voicing that genuinely fits
|
|
153
|
+
* should try other variants — an inversion often fits where root position
|
|
154
|
+
* cannot.
|
|
155
|
+
*/
|
|
156
|
+
export function constrainVoicing(input) {
|
|
157
|
+
const baseOctave = input.baseOctave ?? 3;
|
|
158
|
+
const maxNotes = Math.max(MIN_NOTES_PER_HAND, input.constraints.maxNotesPerHand);
|
|
159
|
+
const keepSemitones = new Set(input.keepAtLeast.map(pcSemitone));
|
|
160
|
+
const handOf = (originalIndex) => input.handHints?.[originalIndex] ?? DEFAULT_HAND;
|
|
161
|
+
// Track note indices instead of strings to handle duplicates correctly.
|
|
162
|
+
// Each entry is an index into input.notes.
|
|
163
|
+
let working = input.notes.map((_, i) => i);
|
|
164
|
+
const dropped = [];
|
|
165
|
+
for (;;) {
|
|
166
|
+
const placed = place(working, input.notes, baseOctave, input.constraints.maxSpanPerHand, handOf);
|
|
167
|
+
// A collapsed duplicate is gone from the result, so drop it from `working`
|
|
168
|
+
// too — otherwise the drop loop would keep offering a note the caller can
|
|
169
|
+
// no longer see. `working` only ever shrinks, so this terminates.
|
|
170
|
+
if (placed.length < working.length) {
|
|
171
|
+
working = placed.map((p) => p.originalIndex);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const groups = groupsOf(placed);
|
|
175
|
+
const result = (satisfied) => ({
|
|
176
|
+
notes: placed.map((p) => ({ note: p.note, octave: p.octave, midi: p.midi, hand: p.hand })),
|
|
177
|
+
dropped,
|
|
178
|
+
span: Math.max(0, ...groups.map((g) => spanOf(g.map((n) => n.midi)))),
|
|
179
|
+
satisfied,
|
|
180
|
+
});
|
|
181
|
+
if (withinCaps(groups, input.constraints, maxNotes))
|
|
182
|
+
return result(true);
|
|
183
|
+
const notesPerHand = new Map();
|
|
184
|
+
for (const n of placed)
|
|
185
|
+
notesPerHand.set(n.hand, (notesPerHand.get(n.hand) ?? 0) + 1);
|
|
186
|
+
// Find the next note to drop: earliest in dropOrder, not an identity tone,
|
|
187
|
+
// and not the note that would take its hand below a dyad. Candidates are
|
|
188
|
+
// matched by semitone so spelling differences do not hide them.
|
|
189
|
+
let target = -1;
|
|
190
|
+
for (const pc of input.dropOrder) {
|
|
191
|
+
const semitone = pcSemitone(pc);
|
|
192
|
+
if (keepSemitones.has(semitone))
|
|
193
|
+
continue;
|
|
194
|
+
// Remove the LAST occurrence of this pitch class (conventional choice).
|
|
195
|
+
for (let i = working.length - 1; i >= 0; i--) {
|
|
196
|
+
if (pcSemitone(input.notes[working[i]]) !== semitone)
|
|
197
|
+
continue;
|
|
198
|
+
if ((notesPerHand.get(handOf(working[i])) ?? 0) <= MIN_NOTES_PER_HAND)
|
|
199
|
+
continue;
|
|
200
|
+
target = i;
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
if (target >= 0)
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
// Nothing left that may be dropped: every remaining note is an identity
|
|
207
|
+
// tone, or dropping it would leave a hand with fewer than two notes.
|
|
208
|
+
if (target < 0)
|
|
209
|
+
return result(false);
|
|
210
|
+
dropped.push(input.notes[working[target]]);
|
|
211
|
+
working.splice(target, 1);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
//# sourceMappingURL=hand-constraints.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"hand-constraints.js","sourceRoot":"","sources":["../../src/theory/hand-constraints.ts"],"names":[],"mappings":"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"}
|