@libraz/libcantus 0.9.4 → 1.0.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/README.md +61 -261
- package/README_ja.md +115 -0
- package/dist/analyze/index.cjs +54 -7
- package/dist/analyze/index.cjs.map +1 -1
- package/dist/analyze/index.d.cts +213 -456
- package/dist/analyze/index.d.ts +213 -456
- package/dist/analyze/index.js +56 -9
- package/dist/chunk-3JXCW6DX.cjs +3220 -0
- package/dist/chunk-3JXCW6DX.cjs.map +1 -0
- package/dist/chunk-4A2RMUCC.js +7647 -0
- package/dist/chunk-4A2RMUCC.js.map +1 -0
- package/dist/chunk-6XPTBE7Y.cjs +49 -0
- package/dist/chunk-6XPTBE7Y.cjs.map +1 -0
- package/dist/chunk-AFM2ULJH.cjs +5121 -0
- package/dist/chunk-AFM2ULJH.cjs.map +1 -0
- package/dist/chunk-E3F4AHAH.cjs +690 -0
- package/dist/chunk-E3F4AHAH.cjs.map +1 -0
- package/dist/chunk-ELCTZUJJ.js +379 -0
- package/dist/chunk-ELCTZUJJ.js.map +1 -0
- package/dist/chunk-FVSI36UV.js +690 -0
- package/dist/chunk-FVSI36UV.js.map +1 -0
- package/dist/chunk-GEDTJHAC.cjs +1 -0
- package/dist/chunk-GEDTJHAC.cjs.map +1 -0
- package/dist/chunk-GOZG57ER.js +3220 -0
- package/dist/chunk-GOZG57ER.js.map +1 -0
- package/dist/chunk-H3VORETI.js +2243 -0
- package/dist/chunk-H3VORETI.js.map +1 -0
- package/dist/chunk-H42UHPYW.js +1577 -0
- package/dist/chunk-H42UHPYW.js.map +1 -0
- package/dist/chunk-HFPS7JFD.js +1 -0
- package/dist/chunk-INHVXP6Z.js +3053 -0
- package/dist/chunk-INHVXP6Z.js.map +1 -0
- package/dist/chunk-K4ZJUGF4.cjs +379 -0
- package/dist/chunk-K4ZJUGF4.cjs.map +1 -0
- package/dist/chunk-KNJEKLKV.js +49 -0
- package/dist/chunk-KNJEKLKV.js.map +1 -0
- package/dist/chunk-M4CWTJWJ.cjs +7647 -0
- package/dist/chunk-M4CWTJWJ.cjs.map +1 -0
- package/dist/chunk-MXO72PLY.cjs +211 -0
- package/dist/chunk-MXO72PLY.cjs.map +1 -0
- package/dist/chunk-NLEU4QSE.cjs +360 -0
- package/dist/chunk-NLEU4QSE.cjs.map +1 -0
- package/dist/chunk-OE6WK2Y2.cjs +1577 -0
- package/dist/chunk-OE6WK2Y2.cjs.map +1 -0
- package/dist/chunk-Q2WGKJNN.cjs +1064 -0
- package/dist/chunk-Q2WGKJNN.cjs.map +1 -0
- package/dist/chunk-QYQQ5J6E.js +1 -0
- package/dist/chunk-QYQQ5J6E.js.map +1 -0
- package/dist/chunk-RHIJ3S4I.cjs +1 -0
- package/dist/chunk-RHIJ3S4I.cjs.map +1 -0
- package/dist/chunk-TTCLJX6D.js +211 -0
- package/dist/chunk-TTCLJX6D.js.map +1 -0
- package/dist/chunk-UADYAYAT.cjs +3053 -0
- package/dist/chunk-UADYAYAT.cjs.map +1 -0
- package/dist/chunk-VOHZB45S.js +5121 -0
- package/dist/chunk-VOHZB45S.js.map +1 -0
- package/dist/chunk-VTI6FOLB.js +1064 -0
- package/dist/chunk-VTI6FOLB.js.map +1 -0
- package/dist/chunk-WWURE4XT.cjs +2243 -0
- package/dist/chunk-WWURE4XT.cjs.map +1 -0
- package/dist/chunk-YH3BIKHH.js +360 -0
- package/dist/chunk-YH3BIKHH.js.map +1 -0
- package/dist/core/index.cjs +103 -6
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +96 -345
- package/dist/core/index.d.ts +96 -345
- package/dist/core/index.js +104 -7
- package/dist/generate/index.cjs +89 -10
- package/dist/generate/index.cjs.map +1 -1
- package/dist/generate/index.d.cts +437 -1095
- package/dist/generate/index.d.ts +437 -1095
- package/dist/generate/index.js +94 -15
- package/dist/index-9wcMhG73.d.ts +1095 -0
- package/dist/{index-D6b6na69.d.cts → index-B4o55NDr.d.cts} +13 -9
- package/dist/index-B9MNZboQ.d.cts +770 -0
- package/dist/index-B9MNZboQ.d.ts +770 -0
- package/dist/index-BGFW-8P2.d.cts +2307 -0
- package/dist/index-BO6xE9YU.d.cts +1804 -0
- package/dist/index-BkGmlyI3.d.cts +470 -0
- package/dist/index-CCNJRG-P.d.cts +1095 -0
- package/dist/index-CGpRv5sl.d.cts +643 -0
- package/dist/index-C_8o--VE.d.cts +1242 -0
- package/dist/index-CgHqxdps.d.ts +2307 -0
- package/dist/index-Chbst3Jx.d.ts +1343 -0
- package/dist/index-Cj49F892.d.ts +470 -0
- package/dist/{index-tSQh-xwY.d.ts → index-CunYKLiu.d.ts} +13 -9
- package/dist/index-D57QRbVn.d.ts +1804 -0
- package/dist/index-DInQqT8Q.d.cts +66 -0
- package/dist/index-DInQqT8Q.d.ts +66 -0
- package/dist/index-Dzr13q3d.d.cts +1343 -0
- package/dist/index-G1uwGGWQ.d.ts +643 -0
- package/dist/index-szBv3496.d.ts +1242 -0
- package/dist/index.cjs +358 -15
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +17 -15
- package/dist/index.d.ts +17 -15
- package/dist/index.js +407 -64
- package/dist/masks-DYbE485J.d.cts +445 -0
- package/dist/masks-DYbE485J.d.ts +445 -0
- package/dist/model/index.cjs +39 -7
- package/dist/model/index.cjs.map +1 -1
- package/dist/model/index.d.cts +4685 -243
- package/dist/model/index.d.ts +4685 -243
- package/dist/model/index.js +40 -8
- package/dist/positional-Cptlj-xs.d.ts +287 -0
- package/dist/positional-DvH_sd8u.d.cts +287 -0
- package/dist/theory/index.cjs +101 -7
- package/dist/theory/index.cjs.map +1 -1
- package/dist/theory/index.d.cts +513 -173
- package/dist/theory/index.d.ts +513 -173
- package/dist/theory/index.js +118 -24
- package/package.json +16 -11
- package/dist/chunk-36N73KZ3.cjs +0 -246
- package/dist/chunk-36N73KZ3.cjs.map +0 -1
- package/dist/chunk-4HLLZPXT.cjs +0 -3077
- package/dist/chunk-4HLLZPXT.cjs.map +0 -1
- package/dist/chunk-7MGZ7D62.cjs +0 -498
- package/dist/chunk-7MGZ7D62.cjs.map +0 -1
- package/dist/chunk-7NKDPF74.js +0 -3077
- package/dist/chunk-7NKDPF74.js.map +0 -1
- package/dist/chunk-CAN76NCU.cjs +0 -679
- package/dist/chunk-CAN76NCU.cjs.map +0 -1
- package/dist/chunk-D2FAJBYL.js +0 -479
- package/dist/chunk-D2FAJBYL.js.map +0 -1
- package/dist/chunk-EHVEBWM5.js +0 -1449
- package/dist/chunk-EHVEBWM5.js.map +0 -1
- package/dist/chunk-FCANXBAT.cjs +0 -30
- package/dist/chunk-FCANXBAT.cjs.map +0 -1
- package/dist/chunk-FNDWXRRI.cjs +0 -479
- package/dist/chunk-FNDWXRRI.cjs.map +0 -1
- package/dist/chunk-GQSGS2N4.js +0 -173
- package/dist/chunk-GQSGS2N4.js.map +0 -1
- package/dist/chunk-HKGYCQ34.js +0 -53
- package/dist/chunk-HKGYCQ34.js.map +0 -1
- package/dist/chunk-I24MV65U.js +0 -106
- package/dist/chunk-I24MV65U.js.map +0 -1
- package/dist/chunk-KMHOICIY.js +0 -679
- package/dist/chunk-KMHOICIY.js.map +0 -1
- package/dist/chunk-LW53AHQU.cjs +0 -1117
- package/dist/chunk-LW53AHQU.cjs.map +0 -1
- package/dist/chunk-N6IHQUOX.js +0 -1
- package/dist/chunk-NKIBQ6PT.js +0 -30
- package/dist/chunk-NKIBQ6PT.js.map +0 -1
- package/dist/chunk-NP4NA2FB.cjs +0 -106
- package/dist/chunk-NP4NA2FB.cjs.map +0 -1
- package/dist/chunk-NVE34YGZ.cjs +0 -1449
- package/dist/chunk-NVE34YGZ.cjs.map +0 -1
- package/dist/chunk-NYYWMV2C.js +0 -327
- package/dist/chunk-NYYWMV2C.js.map +0 -1
- package/dist/chunk-Q2XUP227.js +0 -845
- package/dist/chunk-Q2XUP227.js.map +0 -1
- package/dist/chunk-Q6OP4U43.cjs +0 -327
- package/dist/chunk-Q6OP4U43.cjs.map +0 -1
- package/dist/chunk-RC6GBMCD.cjs +0 -845
- package/dist/chunk-RC6GBMCD.cjs.map +0 -1
- package/dist/chunk-RK35RIN7.js +0 -1117
- package/dist/chunk-RK35RIN7.js.map +0 -1
- package/dist/chunk-RUMU3UA6.cjs +0 -53
- package/dist/chunk-RUMU3UA6.cjs.map +0 -1
- package/dist/chunk-T64C7ZD7.cjs +0 -1
- package/dist/chunk-T64C7ZD7.cjs.map +0 -1
- package/dist/chunk-XEBG3G53.js +0 -498
- package/dist/chunk-XEBG3G53.js.map +0 -1
- package/dist/chunk-YLEMGEFH.js +0 -246
- package/dist/chunk-YLEMGEFH.js.map +0 -1
- package/dist/chunk-ZEJR4TKL.cjs +0 -173
- package/dist/chunk-ZEJR4TKL.cjs.map +0 -1
- package/dist/function-BtiZb9i9.d.ts +0 -208
- package/dist/function-DNugAOrt.d.cts +0 -208
- package/dist/index-6VR8-kit.d.ts +0 -143
- package/dist/index-70p168VM.d.ts +0 -192
- package/dist/index-Be74mcon.d.cts +0 -152
- package/dist/index-BtXT6UQ0.d.cts +0 -192
- package/dist/index-C5UBbQ4G.d.ts +0 -152
- package/dist/index-C83mZI1P.d.ts +0 -242
- package/dist/index-DXd9JikN.d.cts +0 -246
- package/dist/index-DXd9JikN.d.ts +0 -246
- package/dist/index-OG65ocOr.d.cts +0 -272
- package/dist/index-OG65ocOr.d.ts +0 -272
- package/dist/index-tJhkmQgE.d.cts +0 -143
- package/dist/index-y_ak9_Hi.d.cts +0 -242
- package/dist/styled-DIRkk9Uz.d.ts +0 -506
- package/dist/styled-DJbkJQfj.d.cts +0 -506
- package/dist/types-BN6_Q9Kl.d.cts +0 -39
- package/dist/types-BN6_Q9Kl.d.ts +0 -39
- /package/dist/{chunk-N6IHQUOX.js.map → chunk-HFPS7JFD.js.map} +0 -0
|
@@ -0,0 +1,2243 @@
|
|
|
1
|
+
import {
|
|
2
|
+
NAMED_SCALES,
|
|
3
|
+
chordPitchClasses,
|
|
4
|
+
chordQualities,
|
|
5
|
+
chordToneRole,
|
|
6
|
+
classifySpelledInterval,
|
|
7
|
+
createsBattuta,
|
|
8
|
+
createsHiddenParallelPerfect,
|
|
9
|
+
createsParallelPerfect,
|
|
10
|
+
createsVoiceCrossing,
|
|
11
|
+
createsVoiceOverlap,
|
|
12
|
+
exceedsSpacing,
|
|
13
|
+
formatChordSymbol,
|
|
14
|
+
isAugmentedMelodicInterval,
|
|
15
|
+
isForbiddenMelodicLeap,
|
|
16
|
+
isLeadingToneResolution,
|
|
17
|
+
makeChord,
|
|
18
|
+
namedScaleMask,
|
|
19
|
+
noteNames,
|
|
20
|
+
requireScaleMask,
|
|
21
|
+
scaleTonesInDegreeOrder,
|
|
22
|
+
simpleIntervalNumber,
|
|
23
|
+
spellChord,
|
|
24
|
+
spellPitch,
|
|
25
|
+
spellScale,
|
|
26
|
+
spelledKeyOf,
|
|
27
|
+
toChordData,
|
|
28
|
+
toKeyScale
|
|
29
|
+
} from "./chunk-INHVXP6Z.js";
|
|
30
|
+
import {
|
|
31
|
+
InvalidInputError,
|
|
32
|
+
NoSolutionError,
|
|
33
|
+
assertFiniteNumber,
|
|
34
|
+
assertGenerationBudget,
|
|
35
|
+
assertInteger,
|
|
36
|
+
assertPositiveInt,
|
|
37
|
+
diatonicLetterOf,
|
|
38
|
+
formatNote,
|
|
39
|
+
noteToMidi,
|
|
40
|
+
noteToPitchClass,
|
|
41
|
+
parseInterval,
|
|
42
|
+
pitchClassOf,
|
|
43
|
+
spelledInterval,
|
|
44
|
+
toNoteData,
|
|
45
|
+
toSpelledInterval,
|
|
46
|
+
transposeByInterval
|
|
47
|
+
} from "./chunk-VTI6FOLB.js";
|
|
48
|
+
|
|
49
|
+
// src/theory/transposition/index.ts
|
|
50
|
+
var TRANSPOSING_INSTRUMENTS = Object.freeze({
|
|
51
|
+
// In B flat: a written C sounds B flat. The lower members of the family read
|
|
52
|
+
// in the same key an octave higher, so their interval is the compound one.
|
|
53
|
+
clarinetBb: "-M2",
|
|
54
|
+
trumpetBb: "-M2",
|
|
55
|
+
sopranoSax: "-M2",
|
|
56
|
+
tenorSax: "-M9",
|
|
57
|
+
bassClarinet: "-M9",
|
|
58
|
+
// In A: a written C sounds A.
|
|
59
|
+
clarinetA: "-m3",
|
|
60
|
+
// In F: a written C sounds F.
|
|
61
|
+
hornF: "-P5",
|
|
62
|
+
englishHorn: "-P5",
|
|
63
|
+
// In E flat: one key, three registers. The sopranino clarinet sounds above
|
|
64
|
+
// what it reads, the alto saxophone a major sixth below, and the baritone
|
|
65
|
+
// saxophone an octave below that.
|
|
66
|
+
clarinetEb: "m3",
|
|
67
|
+
altoSax: "-M6",
|
|
68
|
+
baritoneSax: "-M13",
|
|
69
|
+
// In D: a written C sounds D.
|
|
70
|
+
trumpetD: "M2",
|
|
71
|
+
// Octave transposers: the instrument is in C, only the register moves.
|
|
72
|
+
piccolo: "P8",
|
|
73
|
+
doubleBass: "-P8",
|
|
74
|
+
contrabassoon: "-P8",
|
|
75
|
+
guitar: "-P8"
|
|
76
|
+
});
|
|
77
|
+
function instrumentTransposition(instrument) {
|
|
78
|
+
if (typeof instrument !== "string") {
|
|
79
|
+
return toSpelledInterval(instrument);
|
|
80
|
+
}
|
|
81
|
+
if (Object.hasOwn(TRANSPOSING_INSTRUMENTS, instrument)) {
|
|
82
|
+
return parseInterval(TRANSPOSING_INSTRUMENTS[instrument]);
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
return parseInterval(instrument);
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (error instanceof InvalidInputError) {
|
|
88
|
+
throw new InvalidInputError(
|
|
89
|
+
`unknown transposing instrument ${JSON.stringify(instrument)}; pass a name from TRANSPOSING_INSTRUMENTS or an interval such as '-M2'`
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
throw error;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function reversedInterval(step) {
|
|
96
|
+
const reversed = {
|
|
97
|
+
number: step.number,
|
|
98
|
+
quality: step.quality,
|
|
99
|
+
// Negating a zero span yields -0, which compares unequal to 0 under Object.is.
|
|
100
|
+
semitones: step.semitones === 0 ? 0 : -step.semitones
|
|
101
|
+
};
|
|
102
|
+
if (!(step.descending ?? step.semitones < 0)) {
|
|
103
|
+
reversed.descending = true;
|
|
104
|
+
}
|
|
105
|
+
return reversed;
|
|
106
|
+
}
|
|
107
|
+
function toSoundingPitch(note, instrument) {
|
|
108
|
+
return transposeByInterval(toNoteData(note), instrumentTransposition(instrument));
|
|
109
|
+
}
|
|
110
|
+
function toWrittenPitch(note, instrument) {
|
|
111
|
+
return transposeByInterval(
|
|
112
|
+
toNoteData(note),
|
|
113
|
+
reversedInterval(instrumentTransposition(instrument))
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/theory/chordscale/index.ts
|
|
118
|
+
function popcount12(mask) {
|
|
119
|
+
let count = 0;
|
|
120
|
+
for (let n = 0; n < 12; n += 1) {
|
|
121
|
+
count += mask >> n & 1;
|
|
122
|
+
}
|
|
123
|
+
return count;
|
|
124
|
+
}
|
|
125
|
+
function maskHasPitchClass(mask, scaleRootPc, pc) {
|
|
126
|
+
const offset = pitchClassOf(pc - scaleRootPc);
|
|
127
|
+
return (mask >> offset & 1) === 1;
|
|
128
|
+
}
|
|
129
|
+
function scaleMatchesChord(chordPcs, scaleMask, scaleRootPc) {
|
|
130
|
+
for (const pc of chordPcs) {
|
|
131
|
+
if (!maskHasPitchClass(scaleMask, scaleRootPc, pc)) {
|
|
132
|
+
return false;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
var ALIASED_SCALE_NAMES = /* @__PURE__ */ new Set(["major", "naturalMinor"]);
|
|
138
|
+
var HEPTATONIC_SIZE = 7;
|
|
139
|
+
var SCALE_PREFERENCE = [
|
|
140
|
+
"ionian",
|
|
141
|
+
"major",
|
|
142
|
+
"mixolydian",
|
|
143
|
+
"dorian",
|
|
144
|
+
"lydian",
|
|
145
|
+
"aeolian",
|
|
146
|
+
"naturalMinor",
|
|
147
|
+
"phrygian",
|
|
148
|
+
"locrian"
|
|
149
|
+
];
|
|
150
|
+
var IDIOMATIC_SCALES = {
|
|
151
|
+
aug: ["wholeTone"],
|
|
152
|
+
m7b5: ["locrian"],
|
|
153
|
+
// The diminished scale played over a diminished seventh is the whole-half
|
|
154
|
+
// octatonic. Its half-whole rotation belongs to the dominant it colours: over
|
|
155
|
+
// a diminished seventh every one of its non-chord tones lands a semitone
|
|
156
|
+
// above a chord tone, so it offers no tension at all.
|
|
157
|
+
dim7: ["octatonicWholeHalf"],
|
|
158
|
+
minMaj7: ["melodicMinor"]
|
|
159
|
+
};
|
|
160
|
+
function conventionalRank(name) {
|
|
161
|
+
const index = SCALE_PREFERENCE.indexOf(name);
|
|
162
|
+
return index < 0 ? SCALE_PREFERENCE.length : index;
|
|
163
|
+
}
|
|
164
|
+
function idiomaticRank(chord, name) {
|
|
165
|
+
const choices = IDIOMATIC_SCALES[chord.quality];
|
|
166
|
+
if (choices === void 0) {
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
const index = choices.indexOf(name);
|
|
170
|
+
return index < 0 ? choices.length + 1 : index;
|
|
171
|
+
}
|
|
172
|
+
function isAlteredDominant(chord, scaleName) {
|
|
173
|
+
return chord.quality === "dom7" && scaleName === "altered";
|
|
174
|
+
}
|
|
175
|
+
function chordScales(chord) {
|
|
176
|
+
const data = toChordData(chord);
|
|
177
|
+
const chordPcs = chordPitchClasses(data);
|
|
178
|
+
const rootPc = pitchClassOf(data.rootPc);
|
|
179
|
+
const ranked = [];
|
|
180
|
+
const seenMasks = /* @__PURE__ */ new Set();
|
|
181
|
+
for (const name of Object.keys(NAMED_SCALES)) {
|
|
182
|
+
if (name === "chromatic" || ALIASED_SCALE_NAMES.has(name)) {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
const mask = namedScaleMask(name);
|
|
186
|
+
if (mask === void 0 || seenMasks.has(mask)) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
seenMasks.add(mask);
|
|
190
|
+
if (scaleMatchesChord(chordPcs, mask, rootPc) || isAlteredDominant(data, name)) {
|
|
191
|
+
const size = popcount12(mask);
|
|
192
|
+
ranked.push({
|
|
193
|
+
name,
|
|
194
|
+
extra: size - chordPcs.length,
|
|
195
|
+
size,
|
|
196
|
+
avoid: avoidNotes(data, name).length
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (ranked.length === 0) {
|
|
201
|
+
if (NAMED_SCALES.chromatic !== void 0) {
|
|
202
|
+
return [{ name: "chromatic", rootPc }];
|
|
203
|
+
}
|
|
204
|
+
return [];
|
|
205
|
+
}
|
|
206
|
+
const preferHeptatonic = chordPcs.length <= 3;
|
|
207
|
+
ranked.sort((a, b) => {
|
|
208
|
+
const idiomaticA = idiomaticRank(data, a.name);
|
|
209
|
+
const idiomaticB = idiomaticRank(data, b.name);
|
|
210
|
+
if (idiomaticA !== idiomaticB) {
|
|
211
|
+
return idiomaticA - idiomaticB;
|
|
212
|
+
}
|
|
213
|
+
if (preferHeptatonic && a.size === HEPTATONIC_SIZE !== (b.size === HEPTATONIC_SIZE)) {
|
|
214
|
+
return a.size === HEPTATONIC_SIZE ? -1 : 1;
|
|
215
|
+
}
|
|
216
|
+
if (a.extra !== b.extra) {
|
|
217
|
+
return a.extra - b.extra;
|
|
218
|
+
}
|
|
219
|
+
const rankA = conventionalRank(a.name);
|
|
220
|
+
const rankB = conventionalRank(b.name);
|
|
221
|
+
if (rankA !== rankB) {
|
|
222
|
+
return rankA - rankB;
|
|
223
|
+
}
|
|
224
|
+
if (a.avoid !== b.avoid) {
|
|
225
|
+
return a.avoid - b.avoid;
|
|
226
|
+
}
|
|
227
|
+
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
228
|
+
});
|
|
229
|
+
return ranked.map((entry) => ({ name: entry.name, rootPc }));
|
|
230
|
+
}
|
|
231
|
+
function suspendedTone(chordPcs, rootPc) {
|
|
232
|
+
const sounds = (semitones) => chordPcs.includes(pitchClassOf(rootPc + semitones));
|
|
233
|
+
if (sounds(3) || sounds(4)) {
|
|
234
|
+
return void 0;
|
|
235
|
+
}
|
|
236
|
+
if (sounds(5)) {
|
|
237
|
+
return pitchClassOf(rootPc + 5);
|
|
238
|
+
}
|
|
239
|
+
if (sounds(2)) {
|
|
240
|
+
return pitchClassOf(rootPc + 2);
|
|
241
|
+
}
|
|
242
|
+
return void 0;
|
|
243
|
+
}
|
|
244
|
+
function avoidNotes(chord, scaleName, opts = {}) {
|
|
245
|
+
const mask = requireScaleMask(scaleName);
|
|
246
|
+
const data = toChordData(chord);
|
|
247
|
+
const rootPc = pitchClassOf(data.rootPc);
|
|
248
|
+
const chordPcs = chordPitchClasses(data);
|
|
249
|
+
const alteredDominant = isAlteredDominant(data, scaleName);
|
|
250
|
+
if (!scaleMatchesChord(chordPcs, mask, rootPc) && !alteredDominant) {
|
|
251
|
+
return [];
|
|
252
|
+
}
|
|
253
|
+
const chordSet = new Set(chordPcs);
|
|
254
|
+
const suspended = suspendedTone(chordPcs, rootPc);
|
|
255
|
+
const displacedThird = suspended === void 0 ? void 0 : pitchClassOf(suspended - 1);
|
|
256
|
+
const melodic = opts.use === "melodic";
|
|
257
|
+
const semitoneAboveRoot = pitchClassOf(rootPc + 1);
|
|
258
|
+
const avoid = [];
|
|
259
|
+
for (let pc = 0; pc < 12; pc += 1) {
|
|
260
|
+
if (!maskHasPitchClass(mask, rootPc, pc) || chordSet.has(pc)) {
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (melodic && pc !== semitoneAboveRoot) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (!alteredDominant && (chordSet.has(pitchClassOf(pc - 1)) || pc === displacedThird)) {
|
|
267
|
+
avoid.push(pc);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return avoid.sort((a, b) => a - b);
|
|
271
|
+
}
|
|
272
|
+
var MINOR_RESOLUTION_TENSIONS = [1, 3, 8];
|
|
273
|
+
function resolvesToMinorTonic(chord, chordPcs, target) {
|
|
274
|
+
if (target === void 0) {
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
const rootPc = pitchClassOf(chord.rootPc);
|
|
278
|
+
const sounds = (semitones) => chordPcs.includes(pitchClassOf(rootPc + semitones));
|
|
279
|
+
if (!sounds(4) || !sounds(10)) {
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
const targetRootPc = pitchClassOf(target.rootPc);
|
|
283
|
+
if (targetRootPc !== pitchClassOf(rootPc + 5)) {
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
return chordPitchClasses(target).includes(pitchClassOf(targetRootPc + 3));
|
|
287
|
+
}
|
|
288
|
+
function availableTensions(chord, scaleName, opts = {}) {
|
|
289
|
+
const mask = requireScaleMask(scaleName);
|
|
290
|
+
const data = toChordData(chord);
|
|
291
|
+
const rootPc = pitchClassOf(data.rootPc);
|
|
292
|
+
const chordPcs = chordPitchClasses(data);
|
|
293
|
+
const alteredDominant = isAlteredDominant(data, scaleName);
|
|
294
|
+
if (!scaleMatchesChord(chordPcs, mask, rootPc) && !alteredDominant) {
|
|
295
|
+
return [];
|
|
296
|
+
}
|
|
297
|
+
const chordSet = new Set(chordPcs);
|
|
298
|
+
const avoidSet = alteredDominant ? /* @__PURE__ */ new Set() : new Set(avoidNotes(data, scaleName));
|
|
299
|
+
const functional = resolvesToMinorTonic(
|
|
300
|
+
data,
|
|
301
|
+
chordPcs,
|
|
302
|
+
opts.resolvesTo === void 0 ? void 0 : toChordData(opts.resolvesTo)
|
|
303
|
+
) ? new Set(MINOR_RESOLUTION_TENSIONS.map((semitones) => pitchClassOf(rootPc + semitones))) : void 0;
|
|
304
|
+
const tensions = [];
|
|
305
|
+
for (let pc = 0; pc < 12; pc += 1) {
|
|
306
|
+
if (!maskHasPitchClass(mask, rootPc, pc) || chordSet.has(pc)) {
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (avoidSet.has(pc) && functional?.has(pc) !== true) {
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
tensions.push(pc);
|
|
313
|
+
}
|
|
314
|
+
return tensions.sort((a, b) => a - b);
|
|
315
|
+
}
|
|
316
|
+
function chordScaleReport(chord, limit) {
|
|
317
|
+
if (limit !== void 0) {
|
|
318
|
+
assertPositiveInt(limit, "chord-scale report limit");
|
|
319
|
+
}
|
|
320
|
+
const data = toChordData(chord);
|
|
321
|
+
const matches = chordScales(data);
|
|
322
|
+
const chosen = limit === void 0 ? matches : matches.slice(0, limit);
|
|
323
|
+
return chosen.map((match) => {
|
|
324
|
+
const harmonic = avoidNotes(data, match.name);
|
|
325
|
+
const melodic = new Set(avoidNotes(data, match.name, { use: "melodic" }));
|
|
326
|
+
return {
|
|
327
|
+
name: match.name,
|
|
328
|
+
rootPc: match.rootPc,
|
|
329
|
+
avoid: harmonic.filter((pc) => melodic.has(pc)),
|
|
330
|
+
passing: harmonic.filter((pc) => !melodic.has(pc)),
|
|
331
|
+
tensions: availableTensions(data, match.name)
|
|
332
|
+
};
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
var RANK_PENALTY = 0.01;
|
|
336
|
+
function scalePitchClassSet(name, rootPc) {
|
|
337
|
+
const mask = namedScaleMask(name);
|
|
338
|
+
const pcs = /* @__PURE__ */ new Set();
|
|
339
|
+
if (mask === void 0) {
|
|
340
|
+
return pcs;
|
|
341
|
+
}
|
|
342
|
+
for (let n = 0; n < 12; n += 1) {
|
|
343
|
+
if ((mask >> n & 1) === 1) {
|
|
344
|
+
pcs.add(pitchClassOf(rootPc + n));
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
return pcs;
|
|
348
|
+
}
|
|
349
|
+
function symmetricDifferenceSize(a, b) {
|
|
350
|
+
let count = 0;
|
|
351
|
+
for (const pc of a) {
|
|
352
|
+
if (!b.has(pc)) {
|
|
353
|
+
count += 1;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
for (const pc of b) {
|
|
357
|
+
if (!a.has(pc)) {
|
|
358
|
+
count += 1;
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
return count;
|
|
362
|
+
}
|
|
363
|
+
function scalesForChanges(chords) {
|
|
364
|
+
assertGenerationBudget(chords.length, "chord-scale changes");
|
|
365
|
+
if (chords.length === 0) {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
const candidateLists = chords.map((chord) => chordScales(chord));
|
|
369
|
+
const pcSets = candidateLists.map(
|
|
370
|
+
(candidates) => candidates.map((match) => scalePitchClassSet(match.name, match.rootPc))
|
|
371
|
+
);
|
|
372
|
+
const first = candidateLists[0] ?? [];
|
|
373
|
+
const dp = [first.map((_, j) => j * RANK_PENALTY)];
|
|
374
|
+
const back = [first.map(() => -1)];
|
|
375
|
+
for (let i = 1; i < candidateLists.length; i += 1) {
|
|
376
|
+
const candidates = candidateLists[i] ?? [];
|
|
377
|
+
const prevCosts = dp[i - 1] ?? [];
|
|
378
|
+
const prevSets = pcSets[i - 1] ?? [];
|
|
379
|
+
const curSets = pcSets[i] ?? [];
|
|
380
|
+
const stageCosts = [];
|
|
381
|
+
const stageBack = [];
|
|
382
|
+
for (let j = 0; j < candidates.length; j += 1) {
|
|
383
|
+
let bestCost = Number.POSITIVE_INFINITY;
|
|
384
|
+
let bestPrev = -1;
|
|
385
|
+
for (let k = 0; k < prevCosts.length; k += 1) {
|
|
386
|
+
const transition = symmetricDifferenceSize(
|
|
387
|
+
prevSets[k] ?? /* @__PURE__ */ new Set(),
|
|
388
|
+
curSets[j] ?? /* @__PURE__ */ new Set()
|
|
389
|
+
);
|
|
390
|
+
const cost = (prevCosts[k] ?? 0) + transition;
|
|
391
|
+
if (cost < bestCost) {
|
|
392
|
+
bestCost = cost;
|
|
393
|
+
bestPrev = k;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
stageCosts.push(bestCost + j * RANK_PENALTY);
|
|
397
|
+
stageBack.push(bestPrev);
|
|
398
|
+
}
|
|
399
|
+
dp.push(stageCosts);
|
|
400
|
+
back.push(stageBack);
|
|
401
|
+
}
|
|
402
|
+
const lastCosts = dp[dp.length - 1] ?? [];
|
|
403
|
+
let bestFinal = 0;
|
|
404
|
+
for (let j = 1; j < lastCosts.length; j += 1) {
|
|
405
|
+
if ((lastCosts[j] ?? Number.POSITIVE_INFINITY) < (lastCosts[bestFinal] ?? 0)) {
|
|
406
|
+
bestFinal = j;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const chosenIndices = new Array(candidateLists.length);
|
|
410
|
+
let current = bestFinal;
|
|
411
|
+
for (let i = candidateLists.length - 1; i >= 0; i -= 1) {
|
|
412
|
+
chosenIndices[i] = current;
|
|
413
|
+
current = back[i]?.[current] ?? -1;
|
|
414
|
+
}
|
|
415
|
+
return chords.map((chord, i) => {
|
|
416
|
+
const candidates = candidateLists[i] ?? [];
|
|
417
|
+
const scale = candidates[chosenIndices[i] ?? 0] ?? { name: "chromatic", rootPc: chord.rootPc };
|
|
418
|
+
return { chord, scale };
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// src/theory/figured-bass/index.ts
|
|
423
|
+
var MIN_FIGURE_NUMBER = 2;
|
|
424
|
+
var MAX_FIGURE_NUMBER = 9;
|
|
425
|
+
var OCTAVE_FIGURE = 8;
|
|
426
|
+
var MAX_STACK_SIZE = 4;
|
|
427
|
+
var DOUBLE_ACCIDENTALS = {
|
|
428
|
+
"##": "doubleSharp",
|
|
429
|
+
"\u266F\u266F": "doubleSharp",
|
|
430
|
+
bb: "doubleFlat",
|
|
431
|
+
"\u266D\u266D": "doubleFlat"
|
|
432
|
+
};
|
|
433
|
+
var SINGLE_ACCIDENTALS = {
|
|
434
|
+
"#": "sharp",
|
|
435
|
+
"\u266F": "sharp",
|
|
436
|
+
"+": "sharp",
|
|
437
|
+
b: "flat",
|
|
438
|
+
"\u266D": "flat",
|
|
439
|
+
n: "natural",
|
|
440
|
+
"\u266E": "natural"
|
|
441
|
+
};
|
|
442
|
+
var FIGURE_SEPARATORS = /* @__PURE__ */ new Set([" ", " ", ",", "/"]);
|
|
443
|
+
var MOTION_MARKS = /* @__PURE__ */ new Set(["-", "\u2013"]);
|
|
444
|
+
var ACCIDENTAL_GLYPHS = {
|
|
445
|
+
1: "#",
|
|
446
|
+
2: "##",
|
|
447
|
+
"-1": "b",
|
|
448
|
+
"-2": "bb"
|
|
449
|
+
};
|
|
450
|
+
function invalidFigures(figures, detail) {
|
|
451
|
+
return new InvalidInputError(`figured bass ${JSON.stringify(figures)} ${detail}`);
|
|
452
|
+
}
|
|
453
|
+
function alteredBy(diatonicAlter, accidental) {
|
|
454
|
+
switch (accidental) {
|
|
455
|
+
case "sharp":
|
|
456
|
+
return diatonicAlter + 1;
|
|
457
|
+
case "doubleSharp":
|
|
458
|
+
return diatonicAlter + 2;
|
|
459
|
+
case "flat":
|
|
460
|
+
return diatonicAlter - 1;
|
|
461
|
+
case "doubleFlat":
|
|
462
|
+
return diatonicAlter - 2;
|
|
463
|
+
// A natural sign names the natural note, whatever the key would have given.
|
|
464
|
+
case "natural":
|
|
465
|
+
return 0;
|
|
466
|
+
default:
|
|
467
|
+
return diatonicAlter;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
function readFigure(text, start) {
|
|
471
|
+
let index = start;
|
|
472
|
+
let accidental = "none";
|
|
473
|
+
const pair = DOUBLE_ACCIDENTALS[text.slice(index, index + 2)];
|
|
474
|
+
const single = SINGLE_ACCIDENTALS[text[index] ?? ""];
|
|
475
|
+
if (pair !== void 0) {
|
|
476
|
+
accidental = pair;
|
|
477
|
+
index += 2;
|
|
478
|
+
} else if (single !== void 0) {
|
|
479
|
+
accidental = single;
|
|
480
|
+
index += 1;
|
|
481
|
+
}
|
|
482
|
+
const digit = text[index] ?? "";
|
|
483
|
+
if (digit >= "0" && digit <= "9") {
|
|
484
|
+
const number = Number(digit);
|
|
485
|
+
if (number < MIN_FIGURE_NUMBER || number > MAX_FIGURE_NUMBER) {
|
|
486
|
+
throw invalidFigures(
|
|
487
|
+
text,
|
|
488
|
+
`names the interval ${number}, which is outside ${MIN_FIGURE_NUMBER}..${MAX_FIGURE_NUMBER}`
|
|
489
|
+
);
|
|
490
|
+
}
|
|
491
|
+
return { figure: { number, accidental }, index: index + 1 };
|
|
492
|
+
}
|
|
493
|
+
if (accidental === "none") {
|
|
494
|
+
const found = text.slice(start, start + 1);
|
|
495
|
+
throw invalidFigures(
|
|
496
|
+
text,
|
|
497
|
+
found === "" ? `ends where a figure was expected, at position ${start}` : `has an unexpected character ${JSON.stringify(found)}`
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
return { figure: { number: 3, accidental }, index };
|
|
501
|
+
}
|
|
502
|
+
function parseFigures(text) {
|
|
503
|
+
const figures = [];
|
|
504
|
+
const motions = [];
|
|
505
|
+
let index = 0;
|
|
506
|
+
while (index < text.length) {
|
|
507
|
+
if (FIGURE_SEPARATORS.has(text[index] ?? "")) {
|
|
508
|
+
index += 1;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
const first = readFigure(text, index);
|
|
512
|
+
index = first.index;
|
|
513
|
+
if (!MOTION_MARKS.has(text[index] ?? "")) {
|
|
514
|
+
figures.push(first.figure);
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
const second = readFigure(text, index + 1);
|
|
518
|
+
index = second.index;
|
|
519
|
+
if (MOTION_MARKS.has(text[index] ?? "")) {
|
|
520
|
+
throw invalidFigures(text, "chains more than one motion onto a single figure");
|
|
521
|
+
}
|
|
522
|
+
if (first.figure.number === second.figure.number) {
|
|
523
|
+
throw invalidFigures(text, `moves the interval ${first.figure.number} onto itself`);
|
|
524
|
+
}
|
|
525
|
+
motions.push({ from: first.figure, to: second.figure });
|
|
526
|
+
}
|
|
527
|
+
return { figures, motions };
|
|
528
|
+
}
|
|
529
|
+
var TRIAD = { intervals: [3, 5], rootInterval: 1 };
|
|
530
|
+
var TRIAD_FIRST = { intervals: [3, 6], rootInterval: 6 };
|
|
531
|
+
var TRIAD_SECOND = { intervals: [4, 6], rootInterval: 4 };
|
|
532
|
+
var SEVENTH = { intervals: [3, 5, 7], rootInterval: 1 };
|
|
533
|
+
var SEVENTH_FIRST = { intervals: [3, 5, 6], rootInterval: 6 };
|
|
534
|
+
var SEVENTH_SECOND = { intervals: [3, 4, 6], rootInterval: 4 };
|
|
535
|
+
var SEVENTH_THIRD = { intervals: [2, 4, 6], rootInterval: 2 };
|
|
536
|
+
var FIGURE_SETS = /* @__PURE__ */ new Map([
|
|
537
|
+
["", TRIAD],
|
|
538
|
+
["3", TRIAD],
|
|
539
|
+
["5", TRIAD],
|
|
540
|
+
["3,5", TRIAD],
|
|
541
|
+
["6", TRIAD_FIRST],
|
|
542
|
+
["3,6", TRIAD_FIRST],
|
|
543
|
+
["4,6", TRIAD_SECOND],
|
|
544
|
+
["7", SEVENTH],
|
|
545
|
+
["3,7", SEVENTH],
|
|
546
|
+
["5,7", SEVENTH],
|
|
547
|
+
["3,5,7", SEVENTH],
|
|
548
|
+
["5,6", SEVENTH_FIRST],
|
|
549
|
+
["3,5,6", SEVENTH_FIRST],
|
|
550
|
+
["3,4", SEVENTH_SECOND],
|
|
551
|
+
["3,4,6", SEVENTH_SECOND],
|
|
552
|
+
["2", SEVENTH_THIRD],
|
|
553
|
+
["2,4", SEVENTH_THIRD],
|
|
554
|
+
["2,4,6", SEVENTH_THIRD]
|
|
555
|
+
]);
|
|
556
|
+
var FIGURE_ABBREVIATIONS = /* @__PURE__ */ new Map([
|
|
557
|
+
[TRIAD.intervals.join(","), []],
|
|
558
|
+
[TRIAD_FIRST.intervals.join(","), [6]],
|
|
559
|
+
[TRIAD_SECOND.intervals.join(","), [6, 4]],
|
|
560
|
+
[SEVENTH.intervals.join(","), [7]],
|
|
561
|
+
[SEVENTH_FIRST.intervals.join(","), [6, 5]],
|
|
562
|
+
[SEVENTH_SECOND.intervals.join(","), [4, 3]],
|
|
563
|
+
[SEVENTH_THIRD.intervals.join(","), [4, 2]]
|
|
564
|
+
]);
|
|
565
|
+
function tertianQualityIndex() {
|
|
566
|
+
const index = /* @__PURE__ */ new Map();
|
|
567
|
+
for (const quality of chordQualities()) {
|
|
568
|
+
const { intervals } = makeChord(0, quality);
|
|
569
|
+
const template = intervals.join(",");
|
|
570
|
+
if (intervals.length >= 3 && intervals.length <= MAX_STACK_SIZE && !index.has(template)) {
|
|
571
|
+
index.set(template, quality);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
return index;
|
|
575
|
+
}
|
|
576
|
+
var TERTIAN_QUALITIES = tertianQualityIndex();
|
|
577
|
+
function keyLetterAlters(key) {
|
|
578
|
+
const size = scaleTonesInDegreeOrder(key).length;
|
|
579
|
+
if (size !== 7) {
|
|
580
|
+
throw new InvalidInputError(`figured bass requires a heptatonic key (received ${size} tones)`);
|
|
581
|
+
}
|
|
582
|
+
const alters = [];
|
|
583
|
+
for (const note of spellScale(spelledKeyOf(key).tonic, key)) {
|
|
584
|
+
alters[diatonicLetterOf(note.letter)] = note.alter;
|
|
585
|
+
}
|
|
586
|
+
return alters;
|
|
587
|
+
}
|
|
588
|
+
function noteAbove(bass, figure, alters) {
|
|
589
|
+
const letter = diatonicLetterOf(bass.letter + figure.number - 1);
|
|
590
|
+
return { letter, alter: alteredBy(alters[letter] ?? 0, figure.accidental) };
|
|
591
|
+
}
|
|
592
|
+
function bareNote(note) {
|
|
593
|
+
return { letter: diatonicLetterOf(note.letter), alter: note.alter };
|
|
594
|
+
}
|
|
595
|
+
function realizeSonority(bass, resolved, text, alters) {
|
|
596
|
+
const numbers = [...new Set(resolved.map((figure) => figure.number))].sort((a, b) => a - b);
|
|
597
|
+
const sonority = FIGURE_SETS.get(numbers.join(","));
|
|
598
|
+
if (sonority === void 0) {
|
|
599
|
+
throw invalidFigures(text, "names no chord over the bass");
|
|
600
|
+
}
|
|
601
|
+
const written = new Map(resolved.map((figure) => [figure.number, figure.accidental]));
|
|
602
|
+
const notes = sonority.intervals.map(
|
|
603
|
+
(number) => noteAbove(bass, { number, accidental: written.get(number) ?? "none" }, alters)
|
|
604
|
+
);
|
|
605
|
+
const tones = [bass, ...notes];
|
|
606
|
+
const root = tones[[1, ...sonority.intervals].indexOf(sonority.rootInterval)] ?? bass;
|
|
607
|
+
const rootPc = noteToPitchClass(root);
|
|
608
|
+
const stack = [];
|
|
609
|
+
for (let step = 0; step < tones.length; step += 1) {
|
|
610
|
+
const tone = tones.find((candidate) => diatonicLetterOf(candidate.letter - root.letter) === step * 2);
|
|
611
|
+
if (tone === void 0) {
|
|
612
|
+
throw new NoSolutionError(
|
|
613
|
+
`figured bass ${JSON.stringify(text)} sounds ${noteNames(tones).join(" ")}, which does not stack in thirds`
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
stack.push(pitchClassOf(noteToPitchClass(tone) - rootPc));
|
|
617
|
+
}
|
|
618
|
+
const quality = TERTIAN_QUALITIES.get(stack.join(","));
|
|
619
|
+
if (quality === void 0) {
|
|
620
|
+
throw new NoSolutionError(
|
|
621
|
+
`figured bass ${JSON.stringify(text)} sounds ${noteNames(tones).join(" ")}, which no chord quality names`
|
|
622
|
+
);
|
|
623
|
+
}
|
|
624
|
+
const chord = makeChord(rootPc, quality, noteToPitchClass(bass));
|
|
625
|
+
chord.rootSpelling = { letter: root.letter, alter: root.alter };
|
|
626
|
+
chord.bassSpelling = { letter: bass.letter, alter: bass.alter };
|
|
627
|
+
return { chord, notes };
|
|
628
|
+
}
|
|
629
|
+
function figuredBassRealization(bass, figures, key) {
|
|
630
|
+
if (typeof figures !== "string") {
|
|
631
|
+
throw new InvalidInputError(`figured bass must be a string; received ${typeof figures}`);
|
|
632
|
+
}
|
|
633
|
+
const bassData = toNoteData(bass);
|
|
634
|
+
const scale = toKeyScale(key);
|
|
635
|
+
const alters = keyLetterAlters(scale);
|
|
636
|
+
noteToPitchClass(bassData);
|
|
637
|
+
const bassNote = bareNote(bassData);
|
|
638
|
+
const text = figures.trim();
|
|
639
|
+
const { figures: written, motions } = parseFigures(text);
|
|
640
|
+
const suspensions = motions.map((motion) => ({
|
|
641
|
+
from: motion.from.number,
|
|
642
|
+
to: motion.to.number,
|
|
643
|
+
note: noteAbove(bassNote, motion.from, alters),
|
|
644
|
+
resolution: noteAbove(bassNote, motion.to, alters)
|
|
645
|
+
}));
|
|
646
|
+
const resolved = [
|
|
647
|
+
...written,
|
|
648
|
+
...motions.map((motion) => motion.to).filter((figure) => figure.number !== OCTAVE_FIGURE)
|
|
649
|
+
];
|
|
650
|
+
const { chord, notes } = realizeSonority(bassNote, resolved, text, alters);
|
|
651
|
+
return { chord, notes: [bassNote, ...notes], suspensions };
|
|
652
|
+
}
|
|
653
|
+
function realizeFiguredBass(bass, figures, key) {
|
|
654
|
+
return figuredBassRealization(bass, figures, key).chord;
|
|
655
|
+
}
|
|
656
|
+
function figuredBassOf(chord, key) {
|
|
657
|
+
const data = toChordData(chord);
|
|
658
|
+
const scale = toKeyScale(key);
|
|
659
|
+
const alters = keyLetterAlters(scale);
|
|
660
|
+
const tones = spellChord(data, spelledKeyOf(scale).tonic, scale);
|
|
661
|
+
const bassPc = pitchClassOf(data.bassPc ?? data.rootPc);
|
|
662
|
+
const bass = tones.find((tone) => noteToPitchClass(tone) === bassPc);
|
|
663
|
+
if (bass === void 0) {
|
|
664
|
+
throw new NoSolutionError(
|
|
665
|
+
"figured bass measures intervals above a chord tone, and this chord's bass is not one of its tones"
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
const written = /* @__PURE__ */ new Map();
|
|
669
|
+
for (const tone of tones) {
|
|
670
|
+
if (tone !== bass) {
|
|
671
|
+
written.set(diatonicLetterOf(tone.letter - bass.letter) + 1, tone);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
const digits = FIGURE_ABBREVIATIONS.get([...written.keys()].sort((a, b) => a - b).join(","));
|
|
675
|
+
if (digits === void 0) {
|
|
676
|
+
throw new NoSolutionError(
|
|
677
|
+
`no figured bass names ${noteNames(tones).join(" ")} over ${noteNames([bass])[0]}`
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
const accidentals = /* @__PURE__ */ new Map();
|
|
681
|
+
for (const [number, tone] of written) {
|
|
682
|
+
const alteration = tone.alter - (alters[diatonicLetterOf(tone.letter)] ?? 0);
|
|
683
|
+
if (alteration === 0) {
|
|
684
|
+
continue;
|
|
685
|
+
}
|
|
686
|
+
const glyph = ACCIDENTAL_GLYPHS[alteration];
|
|
687
|
+
if (glyph === void 0) {
|
|
688
|
+
throw new NoSolutionError(
|
|
689
|
+
`no figure names an interval altered by ${alteration} semitones from the key`
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
accidentals.set(number, glyph);
|
|
693
|
+
}
|
|
694
|
+
const figures = [.../* @__PURE__ */ new Set([...digits, ...accidentals.keys()])].sort((a, b) => b - a).map((number) => `${accidentals.get(number) ?? ""}${number}`).join("");
|
|
695
|
+
assertRealizesAs(bass, figures, scale, tones);
|
|
696
|
+
return figures;
|
|
697
|
+
}
|
|
698
|
+
function pitchClassesOf(notes) {
|
|
699
|
+
return [...new Set(notes.map(noteToPitchClass))].sort((a, b) => a - b);
|
|
700
|
+
}
|
|
701
|
+
function assertRealizesAs(bass, figures, key, tones) {
|
|
702
|
+
const noFigures = (detail) => new NoSolutionError(
|
|
703
|
+
`no figured bass names ${noteNames(tones).join(" ")} over ${noteNames([bass])[0]}: the figures ${JSON.stringify(figures)} ${detail}`
|
|
704
|
+
);
|
|
705
|
+
let realized;
|
|
706
|
+
try {
|
|
707
|
+
realized = figuredBassRealization(bass, figures, key);
|
|
708
|
+
} catch (error) {
|
|
709
|
+
throw error instanceof NoSolutionError ? noFigures("read back as no chord at all") : error;
|
|
710
|
+
}
|
|
711
|
+
const written = pitchClassesOf(realized.notes);
|
|
712
|
+
const expected = pitchClassesOf(tones);
|
|
713
|
+
if (written.length !== expected.length || written.some((pc, index) => pc !== expected[index])) {
|
|
714
|
+
throw noFigures(`sound ${noteNames(realized.notes).join(" ")} instead`);
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// src/theory/voicing/tendency.ts
|
|
719
|
+
function leadingTonePcOf(key) {
|
|
720
|
+
return pitchClassOf(key.rootPc - 1);
|
|
721
|
+
}
|
|
722
|
+
var LEADING_TONE_QUALITIES = /* @__PURE__ */ new Set(["dim", "dim7", "m7b5"]);
|
|
723
|
+
function isFunctioningLeadingTone(chord, key) {
|
|
724
|
+
const role = chordToneRole(leadingTonePcOf(key), chord);
|
|
725
|
+
if (role === "third") {
|
|
726
|
+
return pitchClassOf(chord.rootPc) === pitchClassOf(key.rootPc + 7);
|
|
727
|
+
}
|
|
728
|
+
if (role === "root") {
|
|
729
|
+
return LEADING_TONE_QUALITIES.has(chord.quality);
|
|
730
|
+
}
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
function seventhPcOf(chord) {
|
|
734
|
+
for (const interval of chord.intervals) {
|
|
735
|
+
const pc = pitchClassOf(chord.rootPc + interval);
|
|
736
|
+
if (chordToneRole(pc, chord) === "seventh") {
|
|
737
|
+
return pc;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
return void 0;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// src/theory/voicing/internal.ts
|
|
744
|
+
var DEFAULT_MAX_SPACING = 12;
|
|
745
|
+
var VIOLATION_PENALTY = 1e3;
|
|
746
|
+
var MISSING_TONE_PENALTY = 500;
|
|
747
|
+
var MISSING_FIFTH_PENALTY = 20;
|
|
748
|
+
var POOR_DOUBLING_PENALTY = 4;
|
|
749
|
+
var UNISON_PENALTY = 100;
|
|
750
|
+
var LEADING_TONE_DOUBLING_PENALTY = 200;
|
|
751
|
+
var RESOLUTION_PENALTY = 200;
|
|
752
|
+
var DEFAULT_MAX_CANDIDATES = 4e3;
|
|
753
|
+
var SEARCH_NODES_PER_CANDIDATE = 16;
|
|
754
|
+
var HIDDEN_PERFECT_PENALTY = 6;
|
|
755
|
+
var INITIAL_CANDIDATE_CAPACITY = 512;
|
|
756
|
+
function createCandidateBuffer() {
|
|
757
|
+
return { pitches: new Int32Array(0), count: 0, voices: 0 };
|
|
758
|
+
}
|
|
759
|
+
function pitchesForPc(pc, range) {
|
|
760
|
+
const result = [];
|
|
761
|
+
for (let midi = Math.ceil(range.min); midi <= range.max; midi += 1) {
|
|
762
|
+
if (pitchClassOf(midi) === pc) {
|
|
763
|
+
result.push(midi);
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const center = (range.min + range.max) / 2;
|
|
767
|
+
return result.sort((a, b) => {
|
|
768
|
+
const da = Math.abs(a - center);
|
|
769
|
+
const db = Math.abs(b - center);
|
|
770
|
+
return da === db ? a - b : da - db;
|
|
771
|
+
});
|
|
772
|
+
}
|
|
773
|
+
function enumerateVoicings(chord, ranges, maxSpacing, maxCandidates = DEFAULT_MAX_CANDIDATES, into = createCandidateBuffer()) {
|
|
774
|
+
const chordPcs = chordPitchClasses(chord);
|
|
775
|
+
const bassPc = pitchClassOf(chord.bassPc ?? chord.rootPc);
|
|
776
|
+
const byVoice = ranges.map(
|
|
777
|
+
(range, voice) => (voice === 0 ? [bassPc] : chordPcs).flatMap((pc) => pitchesForPc(pc, range))
|
|
778
|
+
);
|
|
779
|
+
for (let voice = 0; voice < byVoice.length; voice += 1) {
|
|
780
|
+
if ((byVoice[voice] ?? []).length === 0) {
|
|
781
|
+
throw new NoSolutionError("no voicing satisfies the given ranges");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const voices = ranges.length;
|
|
785
|
+
into.voices = voices;
|
|
786
|
+
into.count = 0;
|
|
787
|
+
let capacity = Math.floor(into.pitches.length / Math.max(1, voices));
|
|
788
|
+
if (capacity === 0) {
|
|
789
|
+
capacity = Math.min(maxCandidates, INITIAL_CANDIDATE_CAPACITY);
|
|
790
|
+
into.pitches = new Int32Array(capacity * voices);
|
|
791
|
+
}
|
|
792
|
+
const current = new Int32Array(voices);
|
|
793
|
+
let nodes = 0;
|
|
794
|
+
const maxSearchNodes = maxCandidates * SEARCH_NODES_PER_CANDIDATE;
|
|
795
|
+
const build = (voice) => {
|
|
796
|
+
nodes += 1;
|
|
797
|
+
if (into.count >= maxCandidates || nodes > maxSearchNodes) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
if (voice === voices) {
|
|
801
|
+
if (into.count === capacity) {
|
|
802
|
+
capacity = Math.min(maxCandidates, capacity * 2);
|
|
803
|
+
const grown = new Int32Array(capacity * voices);
|
|
804
|
+
grown.set(into.pitches.subarray(0, into.count * voices));
|
|
805
|
+
into.pitches = grown;
|
|
806
|
+
}
|
|
807
|
+
into.pitches.set(current, into.count * voices);
|
|
808
|
+
into.count += 1;
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
const prev = voice === 0 ? void 0 : current[voice - 1];
|
|
812
|
+
for (const pitch of byVoice[voice] ?? []) {
|
|
813
|
+
if (prev !== void 0) {
|
|
814
|
+
if (pitch < prev) {
|
|
815
|
+
continue;
|
|
816
|
+
}
|
|
817
|
+
const spacingLimit = voice === 1 ? maxSpacing + 12 : maxSpacing;
|
|
818
|
+
if (pitch - prev > spacingLimit) {
|
|
819
|
+
continue;
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
current[voice] = pitch;
|
|
823
|
+
build(voice + 1);
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
build(0);
|
|
827
|
+
return into;
|
|
828
|
+
}
|
|
829
|
+
function structuralTables(chord, key) {
|
|
830
|
+
const tones = chordPitchClasses(chord);
|
|
831
|
+
const missing = tones.map(
|
|
832
|
+
(pc) => chordToneRole(pc, chord) === "fifth" ? MISSING_FIFTH_PENALTY : MISSING_TONE_PENALTY
|
|
833
|
+
);
|
|
834
|
+
const rootPc = pitchClassOf(chord.rootPc);
|
|
835
|
+
const leadingTonePc = key !== void 0 && isFunctioningLeadingTone(chord, key) ? leadingTonePcOf(key) : -1;
|
|
836
|
+
const doubling = new Float64Array(12);
|
|
837
|
+
for (let pc = 0; pc < 12; pc += 1) {
|
|
838
|
+
if (pc === leadingTonePc) {
|
|
839
|
+
doubling[pc] = LEADING_TONE_DOUBLING_PENALTY;
|
|
840
|
+
} else if (pc !== rootPc && chordToneRole(pc, chord) !== "fifth") {
|
|
841
|
+
doubling[pc] = POOR_DOUBLING_PENALTY;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
return { tones, missing, doubling };
|
|
845
|
+
}
|
|
846
|
+
var PITCH_CLASS_COUNTS = new Int32Array(12);
|
|
847
|
+
function structuralPenalty(tables, pitches, offset, voices) {
|
|
848
|
+
PITCH_CLASS_COUNTS.fill(0);
|
|
849
|
+
let penalty = 0;
|
|
850
|
+
for (let index = 0; index < voices; index += 1) {
|
|
851
|
+
const pitch = pitches[offset + index];
|
|
852
|
+
if (pitch === void 0) {
|
|
853
|
+
continue;
|
|
854
|
+
}
|
|
855
|
+
const pc = pitchClassOf(pitch);
|
|
856
|
+
PITCH_CLASS_COUNTS[pc] = (PITCH_CLASS_COUNTS[pc] ?? 0) + 1;
|
|
857
|
+
if (index > 0 && pitch === pitches[offset + index - 1]) {
|
|
858
|
+
penalty += UNISON_PENALTY;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
for (let index = 0; index < tables.tones.length; index += 1) {
|
|
862
|
+
const pc = tables.tones[index];
|
|
863
|
+
if (pc !== void 0 && PITCH_CLASS_COUNTS[pc] === 0) {
|
|
864
|
+
penalty += tables.missing[index] ?? 0;
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
for (let pc = 0; pc < 12; pc += 1) {
|
|
868
|
+
const count = PITCH_CLASS_COUNTS[pc] ?? 0;
|
|
869
|
+
if (count > 1) {
|
|
870
|
+
penalty += (count - 1) * (tables.doubling[pc] ?? 0);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
return penalty;
|
|
874
|
+
}
|
|
875
|
+
function resolutionTables(prevChord, nextChord, key) {
|
|
876
|
+
const nextHas = new Uint8Array(12);
|
|
877
|
+
for (const pc of chordPitchClasses(nextChord)) {
|
|
878
|
+
nextHas[pc] = 1;
|
|
879
|
+
}
|
|
880
|
+
return {
|
|
881
|
+
seventhPc: seventhPcOf(prevChord) ?? -1,
|
|
882
|
+
leadingTonePc: key !== void 0 && isFunctioningLeadingTone(prevChord, key) ? leadingTonePcOf(key) : -1,
|
|
883
|
+
tonicPc: key === void 0 ? -1 : pitchClassOf(key.rootPc),
|
|
884
|
+
nextHas
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
function resolutionViolations(tables, prev, prevOffset, cur, curOffset, voices) {
|
|
888
|
+
const { seventhPc, leadingTonePc, tonicPc, nextHas } = tables;
|
|
889
|
+
let count = 0;
|
|
890
|
+
for (let voice = 0; voice < voices; voice += 1) {
|
|
891
|
+
const from = prev[prevOffset + voice];
|
|
892
|
+
const to = cur[curOffset + voice];
|
|
893
|
+
if (from === void 0 || to === void 0) {
|
|
894
|
+
continue;
|
|
895
|
+
}
|
|
896
|
+
const fromPc = pitchClassOf(from);
|
|
897
|
+
const motion = to - from;
|
|
898
|
+
if (fromPc === seventhPc && nextHas[fromPc] === 0 && motion !== -1 && motion !== -2) {
|
|
899
|
+
count += 1;
|
|
900
|
+
}
|
|
901
|
+
if (fromPc === leadingTonePc && tonicPc >= 0 && nextHas[tonicPc] === 1 && nextHas[fromPc] === 0 && motion !== 1) {
|
|
902
|
+
count += 1;
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
return count;
|
|
906
|
+
}
|
|
907
|
+
function leadingCost(prev, prevOffset, cur, curOffset, voices) {
|
|
908
|
+
let total = 0;
|
|
909
|
+
for (let voice = 0; voice < voices; voice += 1) {
|
|
910
|
+
const a = prev[prevOffset + voice];
|
|
911
|
+
const b = cur[curOffset + voice];
|
|
912
|
+
if (a === void 0 || b === void 0) {
|
|
913
|
+
continue;
|
|
914
|
+
}
|
|
915
|
+
total += Math.abs(b - a);
|
|
916
|
+
}
|
|
917
|
+
if (voices >= 2) {
|
|
918
|
+
const bassPrev = prev[prevOffset];
|
|
919
|
+
const bassCur = cur[curOffset];
|
|
920
|
+
const sopPrev = prev[prevOffset + voices - 1];
|
|
921
|
+
const sopCur = cur[curOffset + voices - 1];
|
|
922
|
+
if (bassPrev !== void 0 && bassCur !== void 0 && sopPrev !== void 0 && sopCur !== void 0 && createsHiddenParallelPerfect(bassPrev, bassCur, sopPrev, sopCur)) {
|
|
923
|
+
total += HIDDEN_PERFECT_PENALTY;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
return total;
|
|
927
|
+
}
|
|
928
|
+
function violationCount(prev, prevOffset, cur, curOffset, voices) {
|
|
929
|
+
let count = 0;
|
|
930
|
+
for (let lower = 0; lower < voices; lower += 1) {
|
|
931
|
+
for (let upper = lower + 1; upper < voices; upper += 1) {
|
|
932
|
+
const prevLower = prev[prevOffset + lower];
|
|
933
|
+
const prevUpper = prev[prevOffset + upper];
|
|
934
|
+
const curLower = cur[curOffset + lower];
|
|
935
|
+
const curUpper = cur[curOffset + upper];
|
|
936
|
+
if (prevLower === void 0 || prevUpper === void 0 || curLower === void 0 || curUpper === void 0) {
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
if (createsParallelPerfect(prevUpper, curUpper, prevLower, curLower)) {
|
|
940
|
+
count += 1;
|
|
941
|
+
}
|
|
942
|
+
if (upper === lower + 1) {
|
|
943
|
+
if (createsVoiceOverlap(prevUpper, curUpper, prevLower, curLower)) {
|
|
944
|
+
count += 1;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
return count;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// src/theory/voicing/satb.ts
|
|
953
|
+
var SATB_RANGES = Object.freeze([
|
|
954
|
+
Object.freeze({ min: 40, max: 60 }),
|
|
955
|
+
Object.freeze({ min: 48, max: 67 }),
|
|
956
|
+
Object.freeze({ min: 55, max: 74 }),
|
|
957
|
+
Object.freeze({ min: 60, max: 79 })
|
|
958
|
+
]);
|
|
959
|
+
var DERIVED_LOW = 40;
|
|
960
|
+
var DERIVED_HIGH = 79;
|
|
961
|
+
var DERIVED_SPAN = 19;
|
|
962
|
+
function resolveRanges(opts) {
|
|
963
|
+
if (opts?.ranges !== void 0) {
|
|
964
|
+
if (opts.ranges.length === 0) {
|
|
965
|
+
throw new InvalidInputError("ranges must contain at least one voice range");
|
|
966
|
+
}
|
|
967
|
+
assertGenerationBudget(opts.ranges.length, "voice ranges", 128);
|
|
968
|
+
return opts.ranges.map((range, index) => {
|
|
969
|
+
assertFiniteNumber(range.min, `ranges[${index}].min`);
|
|
970
|
+
assertFiniteNumber(range.max, `ranges[${index}].max`);
|
|
971
|
+
if (range.min > range.max) {
|
|
972
|
+
throw new InvalidInputError(`ranges[${index}].min must not exceed max`);
|
|
973
|
+
}
|
|
974
|
+
assertGenerationBudget(
|
|
975
|
+
Math.floor(range.max) - Math.ceil(range.min) + 1,
|
|
976
|
+
`ranges[${index}] span`,
|
|
977
|
+
4096
|
|
978
|
+
);
|
|
979
|
+
return { ...range };
|
|
980
|
+
});
|
|
981
|
+
}
|
|
982
|
+
const voices = opts?.voices ?? 4;
|
|
983
|
+
assertPositiveInt(voices, "voices", 128);
|
|
984
|
+
if (voices === 4) {
|
|
985
|
+
return SATB_RANGES.map((range) => ({ ...range }));
|
|
986
|
+
}
|
|
987
|
+
if (voices === 1) {
|
|
988
|
+
return [{ min: DERIVED_LOW, max: DERIVED_HIGH }];
|
|
989
|
+
}
|
|
990
|
+
const ranges = [];
|
|
991
|
+
for (let i = 0; i < voices; i += 1) {
|
|
992
|
+
const min = Math.round(
|
|
993
|
+
DERIVED_LOW + i * (DERIVED_HIGH - DERIVED_SPAN - DERIVED_LOW) / (voices - 1)
|
|
994
|
+
);
|
|
995
|
+
ranges.push({ min, max: min + DERIVED_SPAN });
|
|
996
|
+
}
|
|
997
|
+
return ranges;
|
|
998
|
+
}
|
|
999
|
+
function resolveMaxSpacing(opts) {
|
|
1000
|
+
const maxSpacing = opts?.maxSpacing ?? DEFAULT_MAX_SPACING;
|
|
1001
|
+
assertFiniteNumber(maxSpacing, "maxSpacing");
|
|
1002
|
+
if (maxSpacing < 0) {
|
|
1003
|
+
throw new InvalidInputError("maxSpacing must be non-negative");
|
|
1004
|
+
}
|
|
1005
|
+
return maxSpacing;
|
|
1006
|
+
}
|
|
1007
|
+
function resolveKey(opts) {
|
|
1008
|
+
return opts?.key === void 0 ? void 0 : toKeyScale(opts.key);
|
|
1009
|
+
}
|
|
1010
|
+
function resolvePreviousChord(opts) {
|
|
1011
|
+
return opts?.previousChord === void 0 ? void 0 : toChordData(opts.previousChord);
|
|
1012
|
+
}
|
|
1013
|
+
function resolveMaxCandidates(opts) {
|
|
1014
|
+
if (opts?.maxCandidates === void 0) return void 0;
|
|
1015
|
+
assertPositiveInt(opts.maxCandidates, "maxCandidates", 1e6);
|
|
1016
|
+
return opts.maxCandidates;
|
|
1017
|
+
}
|
|
1018
|
+
function voiceChord(chord, opts) {
|
|
1019
|
+
const data = toChordData(chord);
|
|
1020
|
+
const ranges = resolveRanges(opts);
|
|
1021
|
+
const maxSpacing = resolveMaxSpacing(opts);
|
|
1022
|
+
const candidates = enumerateVoicings(data, ranges, maxSpacing, resolveMaxCandidates(opts));
|
|
1023
|
+
const tables = structuralTables(data, resolveKey(opts));
|
|
1024
|
+
const { pitches, voices } = candidates;
|
|
1025
|
+
let bestOffset = -1;
|
|
1026
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
1027
|
+
for (let index = 0; index < candidates.count; index += 1) {
|
|
1028
|
+
const offset = index * voices;
|
|
1029
|
+
let score = structuralPenalty(tables, pitches, offset, voices);
|
|
1030
|
+
for (let i = 0; i < voices; i += 1) {
|
|
1031
|
+
const pitch = pitches[offset + i];
|
|
1032
|
+
const range = ranges[i];
|
|
1033
|
+
if (pitch === void 0 || range === void 0) {
|
|
1034
|
+
continue;
|
|
1035
|
+
}
|
|
1036
|
+
score += Math.abs(pitch - (range.min + range.max) / 2);
|
|
1037
|
+
}
|
|
1038
|
+
if (score < bestScore) {
|
|
1039
|
+
bestScore = score;
|
|
1040
|
+
bestOffset = offset;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
if (bestOffset < 0) {
|
|
1044
|
+
throw new NoSolutionError(
|
|
1045
|
+
`no voicing satisfies the given ranges for ${formatChordSymbol(chord)}`
|
|
1046
|
+
);
|
|
1047
|
+
}
|
|
1048
|
+
return [...pitches.subarray(bestOffset, bestOffset + voices)];
|
|
1049
|
+
}
|
|
1050
|
+
function noSolutionAt(index, chord) {
|
|
1051
|
+
return new NoSolutionError(
|
|
1052
|
+
`no voicing satisfies the given ranges for ${formatChordSymbol(chord)} at index ${index}`,
|
|
1053
|
+
{ at: index }
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
function locate(index, chord, work) {
|
|
1057
|
+
try {
|
|
1058
|
+
return work();
|
|
1059
|
+
} catch (error) {
|
|
1060
|
+
if (error instanceof NoSolutionError) {
|
|
1061
|
+
throw noSolutionAt(index, chord);
|
|
1062
|
+
}
|
|
1063
|
+
throw error;
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
function voiceProgression(chords, opts) {
|
|
1067
|
+
assertGenerationBudget(chords.length, "voiced progression chords", opts?.budget);
|
|
1068
|
+
const data = chords.map((chord) => toChordData(chord));
|
|
1069
|
+
const key = resolveKey(opts);
|
|
1070
|
+
const ranges = resolveRanges(opts);
|
|
1071
|
+
const maxSpacing = resolveMaxSpacing(opts);
|
|
1072
|
+
const maxCandidates = resolveMaxCandidates(opts);
|
|
1073
|
+
const result = [];
|
|
1074
|
+
const buffer = createCandidateBuffer();
|
|
1075
|
+
let prev;
|
|
1076
|
+
let prevChord;
|
|
1077
|
+
for (let index = 0; index < data.length; index += 1) {
|
|
1078
|
+
const chord = data[index];
|
|
1079
|
+
if (chord === void 0) {
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (prev === void 0) {
|
|
1083
|
+
prev = locate(index, chord, () => voiceChord(chord, opts));
|
|
1084
|
+
prevChord = chord;
|
|
1085
|
+
result.push(prev);
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
const candidates = locate(
|
|
1089
|
+
index,
|
|
1090
|
+
chord,
|
|
1091
|
+
() => enumerateVoicings(chord, ranges, maxSpacing, maxCandidates, buffer)
|
|
1092
|
+
);
|
|
1093
|
+
const { pitches, voices } = candidates;
|
|
1094
|
+
const structure = structuralTables(chord, key);
|
|
1095
|
+
const resolution = prevChord === void 0 ? void 0 : resolutionTables(prevChord, chord, key);
|
|
1096
|
+
let bestOffset = -1;
|
|
1097
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
1098
|
+
for (let candidate = 0; candidate < candidates.count; candidate += 1) {
|
|
1099
|
+
const offset = candidate * voices;
|
|
1100
|
+
const unresolved = resolution === void 0 ? 0 : resolutionViolations(resolution, prev, 0, pitches, offset, voices);
|
|
1101
|
+
const score = structuralPenalty(structure, pitches, offset, voices) + leadingCost(prev, 0, pitches, offset, voices) + VIOLATION_PENALTY * violationCount(prev, 0, pitches, offset, voices) + RESOLUTION_PENALTY * unresolved;
|
|
1102
|
+
if (score < bestScore) {
|
|
1103
|
+
bestScore = score;
|
|
1104
|
+
bestOffset = offset;
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
if (bestOffset < 0) {
|
|
1108
|
+
throw noSolutionAt(index, chord);
|
|
1109
|
+
}
|
|
1110
|
+
const best = [...pitches.subarray(bestOffset, bestOffset + voices)];
|
|
1111
|
+
result.push(best);
|
|
1112
|
+
prev = best;
|
|
1113
|
+
prevChord = chord;
|
|
1114
|
+
}
|
|
1115
|
+
return result;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
// src/theory/voicing/leading.ts
|
|
1119
|
+
function voiceLeadingCost(from, to) {
|
|
1120
|
+
if (from.length !== to.length) {
|
|
1121
|
+
return Number.POSITIVE_INFINITY;
|
|
1122
|
+
}
|
|
1123
|
+
return leadingCost(from, 0, to, 0, from.length);
|
|
1124
|
+
}
|
|
1125
|
+
function nextVoicing(current, chord, opts) {
|
|
1126
|
+
const data = toChordData(chord);
|
|
1127
|
+
const derived = opts?.ranges === void 0 && opts?.voices === void 0;
|
|
1128
|
+
if (derived) {
|
|
1129
|
+
if (current.length === 0) {
|
|
1130
|
+
throw new InvalidInputError("current must contain at least one pitch");
|
|
1131
|
+
}
|
|
1132
|
+
for (let index = 0; index < current.length; index += 1) {
|
|
1133
|
+
assertFiniteNumber(current[index] ?? Number.NaN, `current[${index}]`);
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
const ranges = derived ? resolveRanges({
|
|
1137
|
+
ranges: current.map((pitch) => {
|
|
1138
|
+
const centre = Math.min(127, Math.max(0, pitch));
|
|
1139
|
+
return { min: Math.max(0, centre - 12), max: Math.min(127, centre + 12) };
|
|
1140
|
+
})
|
|
1141
|
+
}) : resolveRanges(opts);
|
|
1142
|
+
for (let index = 0; index < current.length; index += 1) {
|
|
1143
|
+
assertFiniteNumber(current[index] ?? Number.NaN, `current[${index}]`);
|
|
1144
|
+
}
|
|
1145
|
+
if (current.length < ranges.length) {
|
|
1146
|
+
throw new InvalidInputError(
|
|
1147
|
+
`current has ${current.length} voices but the requested ranges require ${ranges.length}`
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
const source = current.length === ranges.length ? current : ranges.length === 1 ? [current[0] ?? Number.NaN] : ranges.map((_, index) => {
|
|
1151
|
+
const sourceIndex = Math.round(index * (current.length - 1) / (ranges.length - 1));
|
|
1152
|
+
return current[sourceIndex] ?? Number.NaN;
|
|
1153
|
+
});
|
|
1154
|
+
const maxSpacing = resolveMaxSpacing(opts);
|
|
1155
|
+
const candidates = enumerateVoicings(data, ranges, maxSpacing, resolveMaxCandidates(opts));
|
|
1156
|
+
const key = resolveKey(opts);
|
|
1157
|
+
const previousChord = resolvePreviousChord(opts);
|
|
1158
|
+
const structure = structuralTables(data, key);
|
|
1159
|
+
const resolution = previousChord === void 0 ? void 0 : resolutionTables(previousChord, data, key);
|
|
1160
|
+
const { pitches, voices } = candidates;
|
|
1161
|
+
let bestOffset = -1;
|
|
1162
|
+
let bestScore = Number.POSITIVE_INFINITY;
|
|
1163
|
+
for (let candidate = 0; candidate < candidates.count; candidate += 1) {
|
|
1164
|
+
const offset = candidate * voices;
|
|
1165
|
+
const score = structuralPenalty(structure, pitches, offset, voices) + leadingCost(source, 0, pitches, offset, voices) + VIOLATION_PENALTY * violationCount(source, 0, pitches, offset, voices) + RESOLUTION_PENALTY * (resolution === void 0 ? 0 : resolutionViolations(resolution, source, 0, pitches, offset, voices));
|
|
1166
|
+
if (score < bestScore) {
|
|
1167
|
+
bestScore = score;
|
|
1168
|
+
bestOffset = offset;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
if (bestOffset < 0) {
|
|
1172
|
+
throw new NoSolutionError("no voicing satisfies the given ranges");
|
|
1173
|
+
}
|
|
1174
|
+
return [...pitches.subarray(bestOffset, bestOffset + voices)];
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
// src/theory/voicing/styled.ts
|
|
1178
|
+
var DEFAULT_STYLE_OCTAVE = 4;
|
|
1179
|
+
function dedupePcs(pcs) {
|
|
1180
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1181
|
+
const result = [];
|
|
1182
|
+
for (const pc of pcs) {
|
|
1183
|
+
if (!seen.has(pc)) {
|
|
1184
|
+
seen.add(pc);
|
|
1185
|
+
result.push(pc);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return result;
|
|
1189
|
+
}
|
|
1190
|
+
function lowestPitchAtOrAbove(pc, floor) {
|
|
1191
|
+
return floor + ((pc - pitchClassOf(floor)) % 12 + 12) % 12;
|
|
1192
|
+
}
|
|
1193
|
+
function nearestPc(target, pcs) {
|
|
1194
|
+
let best = pcs[0] ?? target;
|
|
1195
|
+
let bestDist = 12;
|
|
1196
|
+
for (const pc of pcs) {
|
|
1197
|
+
const dist = Math.min(((pc - target) % 12 + 12) % 12, ((target - pc) % 12 + 12) % 12);
|
|
1198
|
+
if (dist < bestDist) {
|
|
1199
|
+
bestDist = dist;
|
|
1200
|
+
best = pc;
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
return best;
|
|
1204
|
+
}
|
|
1205
|
+
function voiceChordStyled(chord, opts) {
|
|
1206
|
+
const style = opts?.style ?? "close";
|
|
1207
|
+
const octave = opts?.octave ?? DEFAULT_STYLE_OCTAVE;
|
|
1208
|
+
assertInteger(octave, "styled voicing octave", -10, 10);
|
|
1209
|
+
if (opts?.topNote !== void 0) {
|
|
1210
|
+
assertFiniteNumber(opts.topNote, "styled voicing topNote");
|
|
1211
|
+
}
|
|
1212
|
+
const base = 12 * (octave + 1);
|
|
1213
|
+
const rootPc = pitchClassOf(chord.rootPc);
|
|
1214
|
+
const bassPc = pitchClassOf(chord.bassPc ?? chord.rootPc);
|
|
1215
|
+
const omitRoot = style === "rootless" || opts?.rootless === true;
|
|
1216
|
+
let order = dedupePcs(chord.intervals.map((interval) => pitchClassOf(chord.rootPc + interval)));
|
|
1217
|
+
const bassIndex = order.indexOf(bassPc);
|
|
1218
|
+
if (bassIndex > 0) {
|
|
1219
|
+
order = [...order.slice(bassIndex), ...order.slice(0, bassIndex)];
|
|
1220
|
+
} else if (bassIndex < 0) {
|
|
1221
|
+
order = [bassPc, ...order];
|
|
1222
|
+
}
|
|
1223
|
+
if (style === "shell") {
|
|
1224
|
+
const roles = order.map((pc) => chordToneRole(pc, chord));
|
|
1225
|
+
const guide = roles.includes("seventh") ? "seventh" : roles.includes("sixth") ? "sixth" : "fifth";
|
|
1226
|
+
order = order.filter((_pc, i) => {
|
|
1227
|
+
const role = roles[i];
|
|
1228
|
+
return _pc === bassPc || role === "root" || role === "third" || role === guide;
|
|
1229
|
+
});
|
|
1230
|
+
}
|
|
1231
|
+
if (omitRoot) {
|
|
1232
|
+
order = order.filter((pc) => pc !== rootPc || chord.bassPc !== void 0 && pc === bassPc);
|
|
1233
|
+
}
|
|
1234
|
+
if (order.length === 0) {
|
|
1235
|
+
return [];
|
|
1236
|
+
}
|
|
1237
|
+
if (opts?.topNote !== void 0) {
|
|
1238
|
+
const target = order.includes(pitchClassOf(opts.topNote)) ? pitchClassOf(opts.topNote) : nearestPc(pitchClassOf(opts.topNote), order);
|
|
1239
|
+
const targetIndex = order.indexOf(target);
|
|
1240
|
+
order = [...order.slice(targetIndex + 1), ...order.slice(0, targetIndex + 1)];
|
|
1241
|
+
}
|
|
1242
|
+
const stack = [];
|
|
1243
|
+
let prev;
|
|
1244
|
+
for (const pc of order) {
|
|
1245
|
+
if (prev === void 0) {
|
|
1246
|
+
prev = lowestPitchAtOrAbove(pc, base);
|
|
1247
|
+
} else {
|
|
1248
|
+
let delta = ((pc - pitchClassOf(prev)) % 12 + 12) % 12;
|
|
1249
|
+
if (delta === 0) {
|
|
1250
|
+
delta = 12;
|
|
1251
|
+
}
|
|
1252
|
+
prev += delta;
|
|
1253
|
+
}
|
|
1254
|
+
stack.push(prev);
|
|
1255
|
+
}
|
|
1256
|
+
if (style === "drop2" && stack.length >= 2) {
|
|
1257
|
+
stack[stack.length - 2] = (stack[stack.length - 2] ?? 0) - 12;
|
|
1258
|
+
} else if (style === "drop3" && stack.length >= 3) {
|
|
1259
|
+
stack[stack.length - 3] = (stack[stack.length - 3] ?? 0) - 12;
|
|
1260
|
+
}
|
|
1261
|
+
if (chord.bassPc !== void 0) {
|
|
1262
|
+
const bassIndexInStack = stack.findIndex((pitch) => pitchClassOf(pitch) === bassPc);
|
|
1263
|
+
if (bassIndexInStack >= 0) {
|
|
1264
|
+
const otherLowest = Math.min(
|
|
1265
|
+
...stack.filter((_pitch, index) => index !== bassIndexInStack),
|
|
1266
|
+
Number.POSITIVE_INFINITY
|
|
1267
|
+
);
|
|
1268
|
+
while ((stack[bassIndexInStack] ?? 0) >= otherLowest) {
|
|
1269
|
+
stack[bassIndexInStack] = (stack[bassIndexInStack] ?? 0) - 12;
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const voicing = stack.sort((a, b) => a - b);
|
|
1274
|
+
const lowest = voicing[0] ?? 0;
|
|
1275
|
+
const highest = voicing[voicing.length - 1] ?? 0;
|
|
1276
|
+
if (lowest < 0 || highest > 127) {
|
|
1277
|
+
throw new InvalidInputError(
|
|
1278
|
+
`styled voicing at octave ${octave} spans MIDI ${lowest}..${highest}, outside 0..127`
|
|
1279
|
+
);
|
|
1280
|
+
}
|
|
1281
|
+
return voicing;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// src/theory/partwriting/internal.ts
|
|
1285
|
+
var INTERVAL_WORDS = [
|
|
1286
|
+
"unison",
|
|
1287
|
+
"second",
|
|
1288
|
+
"third",
|
|
1289
|
+
"fourth",
|
|
1290
|
+
"fifth",
|
|
1291
|
+
"sixth",
|
|
1292
|
+
"seventh",
|
|
1293
|
+
"octave"
|
|
1294
|
+
];
|
|
1295
|
+
function intervalWord(numberValue) {
|
|
1296
|
+
return INTERVAL_WORDS[numberValue - 1] ?? "interval";
|
|
1297
|
+
}
|
|
1298
|
+
function violation(kind, voices, fromIndex, toIndex, rationale) {
|
|
1299
|
+
return { kind, voices, fromIndex, toIndex, rationale };
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// src/theory/partwriting/species.ts
|
|
1303
|
+
var EPS = 1e-9;
|
|
1304
|
+
var SPECIES_RATIO = { 1: 1, 2: 2, 3: 4, 4: 2, 5: 0 };
|
|
1305
|
+
var SPECIES_NUMBERS = Object.keys(SPECIES_RATIO).map(Number);
|
|
1306
|
+
var MIN_FLORID_VALUE = 0.125;
|
|
1307
|
+
function meanPitch(notes) {
|
|
1308
|
+
if (notes.length === 0) {
|
|
1309
|
+
return 0;
|
|
1310
|
+
}
|
|
1311
|
+
return notes.reduce((sum, note) => sum + noteToMidi(note), 0) / notes.length;
|
|
1312
|
+
}
|
|
1313
|
+
function impliedDurations(count, measures, ratio) {
|
|
1314
|
+
const full = ratio * Math.max(measures - 1, 0);
|
|
1315
|
+
const closing = Math.max(count - full, 1);
|
|
1316
|
+
return Array.from({ length: count }, (_, index) => index < full ? 1 / ratio : 1 / closing);
|
|
1317
|
+
}
|
|
1318
|
+
function isStep(interval) {
|
|
1319
|
+
return interval.number === 2;
|
|
1320
|
+
}
|
|
1321
|
+
function direction(interval) {
|
|
1322
|
+
return Math.sign(interval.semitones);
|
|
1323
|
+
}
|
|
1324
|
+
function sameNote(a, b) {
|
|
1325
|
+
return a.letter === b.letter && a.alter === b.alter && a.octave === b.octave;
|
|
1326
|
+
}
|
|
1327
|
+
function isPassedThrough(entries, position) {
|
|
1328
|
+
const previous = entries[position - 1];
|
|
1329
|
+
const current = entries[position];
|
|
1330
|
+
const next = entries[position + 1];
|
|
1331
|
+
if (previous === void 0 || current === void 0 || next === void 0) {
|
|
1332
|
+
return false;
|
|
1333
|
+
}
|
|
1334
|
+
const incoming = spelledInterval(previous.note, current.note);
|
|
1335
|
+
const outgoing = spelledInterval(current.note, next.note);
|
|
1336
|
+
if (!isStep(incoming) || !isStep(outgoing)) {
|
|
1337
|
+
return false;
|
|
1338
|
+
}
|
|
1339
|
+
const passing = direction(incoming) === direction(outgoing);
|
|
1340
|
+
const neighbour = sameNote(previous.note, next.note);
|
|
1341
|
+
return passing || neighbour;
|
|
1342
|
+
}
|
|
1343
|
+
function isThird(interval) {
|
|
1344
|
+
return interval.number === 3;
|
|
1345
|
+
}
|
|
1346
|
+
function figureAt(entries, start) {
|
|
1347
|
+
const first = entries[start];
|
|
1348
|
+
const second = entries[start + 1];
|
|
1349
|
+
const third = entries[start + 2];
|
|
1350
|
+
const fourth = entries[start + 3];
|
|
1351
|
+
if (first === void 0 || second === void 0 || third === void 0 || fourth === void 0) {
|
|
1352
|
+
return void 0;
|
|
1353
|
+
}
|
|
1354
|
+
return [first, second, third, fourth];
|
|
1355
|
+
}
|
|
1356
|
+
function isCambiata(entries, start) {
|
|
1357
|
+
const figure = figureAt(entries, start);
|
|
1358
|
+
if (figure === void 0) {
|
|
1359
|
+
return false;
|
|
1360
|
+
}
|
|
1361
|
+
const [first, second, third, fourth] = figure;
|
|
1362
|
+
if (first.consonance === 2 /* Dissonance */ || third.consonance === 2 /* Dissonance */) {
|
|
1363
|
+
return false;
|
|
1364
|
+
}
|
|
1365
|
+
const approach = spelledInterval(first.note, second.note);
|
|
1366
|
+
const leap = spelledInterval(second.note, third.note);
|
|
1367
|
+
const answer = spelledInterval(third.note, fourth.note);
|
|
1368
|
+
return isStep(approach) && direction(approach) < 0 && isThird(leap) && direction(leap) < 0 && isStep(answer) && direction(answer) > 0;
|
|
1369
|
+
}
|
|
1370
|
+
function isDoubleNeighbour(entries, start) {
|
|
1371
|
+
const figure = figureAt(entries, start);
|
|
1372
|
+
if (figure === void 0) {
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
const [first, second, third, fourth] = figure;
|
|
1376
|
+
if (first.consonance === 2 /* Dissonance */ || !sameNote(first.note, fourth.note)) {
|
|
1377
|
+
return false;
|
|
1378
|
+
}
|
|
1379
|
+
const away = spelledInterval(first.note, second.note);
|
|
1380
|
+
const across = spelledInterval(second.note, third.note);
|
|
1381
|
+
const back = spelledInterval(third.note, fourth.note);
|
|
1382
|
+
return isStep(away) && isThird(across) && isStep(back) && direction(away) !== 0 && direction(across) === -direction(away);
|
|
1383
|
+
}
|
|
1384
|
+
function isFiguredDissonance(entries, position, species) {
|
|
1385
|
+
if (species !== 3 && species !== 5) {
|
|
1386
|
+
return false;
|
|
1387
|
+
}
|
|
1388
|
+
return isCambiata(entries, position - 1) || isDoubleNeighbour(entries, position - 1) || isDoubleNeighbour(entries, position - 2);
|
|
1389
|
+
}
|
|
1390
|
+
function licenceFor(species, downbeat) {
|
|
1391
|
+
if (species === 1) {
|
|
1392
|
+
return "none";
|
|
1393
|
+
}
|
|
1394
|
+
if (species === 4) {
|
|
1395
|
+
return downbeat ? "suspension" : "none";
|
|
1396
|
+
}
|
|
1397
|
+
if (species === 5) {
|
|
1398
|
+
return downbeat ? "suspension" : "passing";
|
|
1399
|
+
}
|
|
1400
|
+
return downbeat ? "none" : "passing";
|
|
1401
|
+
}
|
|
1402
|
+
function verticalViolations(entries, position, species, counterpointAbove) {
|
|
1403
|
+
const entry = entries[position];
|
|
1404
|
+
if (entry === void 0) {
|
|
1405
|
+
return [];
|
|
1406
|
+
}
|
|
1407
|
+
const found = [];
|
|
1408
|
+
const upper = counterpointAbove ? entry.note : entry.against;
|
|
1409
|
+
const lower = counterpointAbove ? entry.against : entry.note;
|
|
1410
|
+
if (createsVoiceCrossing(upper, lower)) {
|
|
1411
|
+
found.push(
|
|
1412
|
+
violation(
|
|
1413
|
+
"voiceCrossing",
|
|
1414
|
+
[0, 1],
|
|
1415
|
+
entry.index,
|
|
1416
|
+
entry.index,
|
|
1417
|
+
"The counterpoint crosses to the wrong side of the cantus firmus"
|
|
1418
|
+
)
|
|
1419
|
+
);
|
|
1420
|
+
}
|
|
1421
|
+
if (entry.consonance !== 2 /* Dissonance */) {
|
|
1422
|
+
return found;
|
|
1423
|
+
}
|
|
1424
|
+
const licence = licenceFor(species, entry.downbeat);
|
|
1425
|
+
if (licence === "passing" && (isPassedThrough(entries, position) || isFiguredDissonance(entries, position, species))) {
|
|
1426
|
+
return found;
|
|
1427
|
+
}
|
|
1428
|
+
if (licence === "suspension") {
|
|
1429
|
+
found.push(...suspensionViolations(entries, position));
|
|
1430
|
+
return found;
|
|
1431
|
+
}
|
|
1432
|
+
found.push(
|
|
1433
|
+
violation(
|
|
1434
|
+
"unpreparedDissonance",
|
|
1435
|
+
[0, 1],
|
|
1436
|
+
entry.index,
|
|
1437
|
+
entry.index,
|
|
1438
|
+
licence === "none" ? `A ${entry.interval.quality}${entry.interval.number} falls where this species allows only a consonance` : "The dissonance is not passed through by step"
|
|
1439
|
+
)
|
|
1440
|
+
);
|
|
1441
|
+
return found;
|
|
1442
|
+
}
|
|
1443
|
+
function suspensionViolations(entries, position) {
|
|
1444
|
+
const entry = entries[position];
|
|
1445
|
+
if (entry === void 0) {
|
|
1446
|
+
return [];
|
|
1447
|
+
}
|
|
1448
|
+
const previous = entries[position - 1];
|
|
1449
|
+
const next = entries[position + 1];
|
|
1450
|
+
const prepared = previous !== void 0 && sameNote(previous.note, entry.note) && previous.consonance !== 2 /* Dissonance */;
|
|
1451
|
+
if (!prepared) {
|
|
1452
|
+
return [
|
|
1453
|
+
violation(
|
|
1454
|
+
"unpreparedDissonance",
|
|
1455
|
+
[0, 1],
|
|
1456
|
+
entry.index,
|
|
1457
|
+
entry.index,
|
|
1458
|
+
"The suspended dissonance is not tied over from a consonance"
|
|
1459
|
+
)
|
|
1460
|
+
];
|
|
1461
|
+
}
|
|
1462
|
+
const resolution = next === void 0 ? void 0 : spelledInterval(entry.note, next.note);
|
|
1463
|
+
if (next === void 0 || resolution === void 0 || !isStep(resolution) || direction(resolution) >= 0) {
|
|
1464
|
+
return [
|
|
1465
|
+
violation(
|
|
1466
|
+
"unresolvedSuspension",
|
|
1467
|
+
[0, 1],
|
|
1468
|
+
entry.index,
|
|
1469
|
+
next === void 0 ? entry.index : next.index,
|
|
1470
|
+
"The suspension does not fall by step onto its resolution"
|
|
1471
|
+
)
|
|
1472
|
+
];
|
|
1473
|
+
}
|
|
1474
|
+
return [];
|
|
1475
|
+
}
|
|
1476
|
+
function melodicViolations(from, to) {
|
|
1477
|
+
const interval = spelledInterval(from.note, to.note);
|
|
1478
|
+
if (isAugmentedMelodicInterval(from.note, to.note)) {
|
|
1479
|
+
return [
|
|
1480
|
+
violation(
|
|
1481
|
+
"augmentedMelodicInterval",
|
|
1482
|
+
[1],
|
|
1483
|
+
from.index,
|
|
1484
|
+
to.index,
|
|
1485
|
+
`The counterpoint moves by an augmented ${intervalWord(interval.number)}`
|
|
1486
|
+
)
|
|
1487
|
+
];
|
|
1488
|
+
}
|
|
1489
|
+
if (isForbiddenMelodicLeap(from.note, to.note)) {
|
|
1490
|
+
return [
|
|
1491
|
+
violation(
|
|
1492
|
+
"illegalLeap",
|
|
1493
|
+
[1],
|
|
1494
|
+
from.index,
|
|
1495
|
+
to.index,
|
|
1496
|
+
`The counterpoint leaps by a ${interval.quality}${interval.number}`
|
|
1497
|
+
)
|
|
1498
|
+
];
|
|
1499
|
+
}
|
|
1500
|
+
return [];
|
|
1501
|
+
}
|
|
1502
|
+
var MAX_LEAPS_IN_ONE_DIRECTION = 2;
|
|
1503
|
+
var MAX_LEAP_CHAIN_SPAN = 12;
|
|
1504
|
+
var LEAP_NEEDING_ANSWER = 6;
|
|
1505
|
+
var TRITONE_SEMITONES = 6;
|
|
1506
|
+
function isLeap(interval) {
|
|
1507
|
+
return interval.number > 2;
|
|
1508
|
+
}
|
|
1509
|
+
function melodicMoves(entries) {
|
|
1510
|
+
const moves = [];
|
|
1511
|
+
for (let position = 1; position < entries.length; position += 1) {
|
|
1512
|
+
const from = entries[position - 1];
|
|
1513
|
+
const to = entries[position];
|
|
1514
|
+
if (from === void 0 || to === void 0) {
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
moves.push({ from, to, interval: spelledInterval(from.note, to.note) });
|
|
1518
|
+
}
|
|
1519
|
+
return moves;
|
|
1520
|
+
}
|
|
1521
|
+
function climaxViolations(entries) {
|
|
1522
|
+
const body = entries.slice(0, -1);
|
|
1523
|
+
const peak = body.reduce(
|
|
1524
|
+
(high, entry) => Math.max(high, noteToMidi(entry.note)),
|
|
1525
|
+
Number.NEGATIVE_INFINITY
|
|
1526
|
+
);
|
|
1527
|
+
const arrivals = body.filter((entry, position) => {
|
|
1528
|
+
const previous = body[position - 1];
|
|
1529
|
+
return noteToMidi(entry.note) === peak && (previous === void 0 || noteToMidi(previous.note) !== peak);
|
|
1530
|
+
});
|
|
1531
|
+
const first = arrivals[0];
|
|
1532
|
+
const second = arrivals[1];
|
|
1533
|
+
if (first === void 0 || second === void 0) {
|
|
1534
|
+
return [];
|
|
1535
|
+
}
|
|
1536
|
+
return [
|
|
1537
|
+
violation(
|
|
1538
|
+
"melodicShape",
|
|
1539
|
+
[1],
|
|
1540
|
+
first.index,
|
|
1541
|
+
second.index,
|
|
1542
|
+
"The line reaches its highest note more than once, so it has no one climax"
|
|
1543
|
+
)
|
|
1544
|
+
];
|
|
1545
|
+
}
|
|
1546
|
+
function leapChainViolations(moves) {
|
|
1547
|
+
const found = [];
|
|
1548
|
+
let run = [];
|
|
1549
|
+
const close = () => {
|
|
1550
|
+
const first = run[0];
|
|
1551
|
+
const last = run[run.length - 1];
|
|
1552
|
+
const span = run.reduce((total, move) => total + Math.abs(move.interval.semitones), 0);
|
|
1553
|
+
if (first !== void 0 && last !== void 0) {
|
|
1554
|
+
if (run.length > MAX_LEAPS_IN_ONE_DIRECTION) {
|
|
1555
|
+
found.push(
|
|
1556
|
+
violation(
|
|
1557
|
+
"melodicShape",
|
|
1558
|
+
[1],
|
|
1559
|
+
first.from.index,
|
|
1560
|
+
last.to.index,
|
|
1561
|
+
`The counterpoint leaps ${run.length} times in a row in the same direction`
|
|
1562
|
+
)
|
|
1563
|
+
);
|
|
1564
|
+
} else if (run.length > 1 && span > MAX_LEAP_CHAIN_SPAN) {
|
|
1565
|
+
found.push(
|
|
1566
|
+
violation(
|
|
1567
|
+
"melodicShape",
|
|
1568
|
+
[1],
|
|
1569
|
+
first.from.index,
|
|
1570
|
+
last.to.index,
|
|
1571
|
+
"Two leaps in the same direction carry the line further than an octave"
|
|
1572
|
+
)
|
|
1573
|
+
);
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
run = [];
|
|
1577
|
+
};
|
|
1578
|
+
for (const move of moves) {
|
|
1579
|
+
if (!isLeap(move.interval)) {
|
|
1580
|
+
close();
|
|
1581
|
+
continue;
|
|
1582
|
+
}
|
|
1583
|
+
const previous = run[run.length - 1];
|
|
1584
|
+
if (previous !== void 0 && direction(previous.interval) !== direction(move.interval)) {
|
|
1585
|
+
close();
|
|
1586
|
+
}
|
|
1587
|
+
run.push(move);
|
|
1588
|
+
}
|
|
1589
|
+
close();
|
|
1590
|
+
return found;
|
|
1591
|
+
}
|
|
1592
|
+
function leapAnswerViolations(moves) {
|
|
1593
|
+
const found = [];
|
|
1594
|
+
for (let position = 0; position < moves.length; position += 1) {
|
|
1595
|
+
const leap = moves[position];
|
|
1596
|
+
const answer = moves[position + 1];
|
|
1597
|
+
if (leap === void 0 || answer === void 0 || leap.interval.number < LEAP_NEEDING_ANSWER) {
|
|
1598
|
+
continue;
|
|
1599
|
+
}
|
|
1600
|
+
if (isStep(answer.interval) && direction(answer.interval) === -direction(leap.interval)) {
|
|
1601
|
+
continue;
|
|
1602
|
+
}
|
|
1603
|
+
const word = intervalWord(leap.interval.number);
|
|
1604
|
+
found.push(
|
|
1605
|
+
violation(
|
|
1606
|
+
"melodicShape",
|
|
1607
|
+
[1],
|
|
1608
|
+
leap.from.index,
|
|
1609
|
+
answer.to.index,
|
|
1610
|
+
`The leap of ${word === "octave" ? "an" : "a"} ${word} is not answered by a step the other way`
|
|
1611
|
+
)
|
|
1612
|
+
);
|
|
1613
|
+
}
|
|
1614
|
+
return found;
|
|
1615
|
+
}
|
|
1616
|
+
function tritoneOutlineViolations(entries) {
|
|
1617
|
+
const found = [];
|
|
1618
|
+
let run = [];
|
|
1619
|
+
const close = () => {
|
|
1620
|
+
const first = run[0];
|
|
1621
|
+
const last = run[run.length - 1];
|
|
1622
|
+
if (first !== void 0 && last !== void 0 && run.length > 1) {
|
|
1623
|
+
const outline = spelledInterval(first.from.note, last.to.note);
|
|
1624
|
+
if (Math.abs(outline.semitones) === TRITONE_SEMITONES) {
|
|
1625
|
+
found.push(
|
|
1626
|
+
violation(
|
|
1627
|
+
"melodicShape",
|
|
1628
|
+
[1],
|
|
1629
|
+
first.from.index,
|
|
1630
|
+
last.to.index,
|
|
1631
|
+
"The line turns around on a tritone, outlining the interval it may not leap"
|
|
1632
|
+
)
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
run = [];
|
|
1637
|
+
};
|
|
1638
|
+
for (const move of melodicMoves(entries)) {
|
|
1639
|
+
if (direction(move.interval) === 0) {
|
|
1640
|
+
continue;
|
|
1641
|
+
}
|
|
1642
|
+
const previous = run[run.length - 1];
|
|
1643
|
+
if (previous !== void 0 && direction(previous.interval) !== direction(move.interval)) {
|
|
1644
|
+
close();
|
|
1645
|
+
}
|
|
1646
|
+
run.push(move);
|
|
1647
|
+
}
|
|
1648
|
+
close();
|
|
1649
|
+
return found;
|
|
1650
|
+
}
|
|
1651
|
+
function repeatedNoteViolations(moves, closingMeasure) {
|
|
1652
|
+
return moves.filter(
|
|
1653
|
+
(move) => sameNote(move.from.note, move.to.note) && move.from.againstIndex < closingMeasure
|
|
1654
|
+
).map(
|
|
1655
|
+
(move) => violation(
|
|
1656
|
+
"melodicShape",
|
|
1657
|
+
[1],
|
|
1658
|
+
move.from.index,
|
|
1659
|
+
move.to.index,
|
|
1660
|
+
"The counterpoint repeats a note where this species keeps the line moving"
|
|
1661
|
+
)
|
|
1662
|
+
);
|
|
1663
|
+
}
|
|
1664
|
+
function melodicShapeViolations(entries, species) {
|
|
1665
|
+
const moves = melodicMoves(entries);
|
|
1666
|
+
const closingMeasure = entries[entries.length - 1]?.againstIndex ?? 0;
|
|
1667
|
+
const found = [
|
|
1668
|
+
...climaxViolations(entries),
|
|
1669
|
+
...leapChainViolations(moves),
|
|
1670
|
+
...leapAnswerViolations(moves),
|
|
1671
|
+
...tritoneOutlineViolations(entries),
|
|
1672
|
+
...species === 2 || species === 3 ? repeatedNoteViolations(moves, closingMeasure) : []
|
|
1673
|
+
];
|
|
1674
|
+
return found.sort((left, right) => left.fromIndex - right.fromIndex);
|
|
1675
|
+
}
|
|
1676
|
+
function motionViolations(from, to, counterpointAbove) {
|
|
1677
|
+
const found = [];
|
|
1678
|
+
const upperPrev = counterpointAbove ? from.note : from.against;
|
|
1679
|
+
const upperCur = counterpointAbove ? to.note : to.against;
|
|
1680
|
+
const lowerPrev = counterpointAbove ? from.against : from.note;
|
|
1681
|
+
const lowerCur = counterpointAbove ? to.against : to.note;
|
|
1682
|
+
if (createsParallelPerfect(upperPrev, upperCur, lowerPrev, lowerCur)) {
|
|
1683
|
+
const fifth = simpleIntervalNumber(to.interval.number) === 5;
|
|
1684
|
+
found.push(
|
|
1685
|
+
violation(
|
|
1686
|
+
fifth ? "parallelFifth" : "parallelOctave",
|
|
1687
|
+
[0, 1],
|
|
1688
|
+
from.index,
|
|
1689
|
+
to.index,
|
|
1690
|
+
fifth ? "The two voices move into consecutive perfect fifths" : "The two voices move into consecutive perfect octaves"
|
|
1691
|
+
)
|
|
1692
|
+
);
|
|
1693
|
+
}
|
|
1694
|
+
if (createsHiddenParallelPerfect(upperPrev, upperCur, lowerPrev, lowerCur)) {
|
|
1695
|
+
found.push(
|
|
1696
|
+
violation(
|
|
1697
|
+
"hiddenPerfect",
|
|
1698
|
+
[0, 1],
|
|
1699
|
+
from.index,
|
|
1700
|
+
to.index,
|
|
1701
|
+
"The voices leap into a perfect interval by similar motion"
|
|
1702
|
+
)
|
|
1703
|
+
);
|
|
1704
|
+
}
|
|
1705
|
+
if (createsBattuta(upperPrev, upperCur, lowerPrev, lowerCur)) {
|
|
1706
|
+
found.push(
|
|
1707
|
+
violation(
|
|
1708
|
+
"battuta",
|
|
1709
|
+
[0, 1],
|
|
1710
|
+
from.index,
|
|
1711
|
+
to.index,
|
|
1712
|
+
"The upper voice leaps down into an octave against the lower voice"
|
|
1713
|
+
)
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
return found;
|
|
1717
|
+
}
|
|
1718
|
+
function lastEntryBefore(entries, againstIndex) {
|
|
1719
|
+
for (let position = entries.length - 1; position >= 0; position -= 1) {
|
|
1720
|
+
const entry = entries[position];
|
|
1721
|
+
if (entry !== void 0 && entry.againstIndex < againstIndex) {
|
|
1722
|
+
return entry;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
return void 0;
|
|
1726
|
+
}
|
|
1727
|
+
function cadenceViolations(entries, mode, counterpointAbove) {
|
|
1728
|
+
const found = [];
|
|
1729
|
+
const first = entries[0];
|
|
1730
|
+
const last = entries[entries.length - 1];
|
|
1731
|
+
if (first === void 0 || last === void 0) {
|
|
1732
|
+
return found;
|
|
1733
|
+
}
|
|
1734
|
+
if (classifySpelledInterval(first.interval) !== 0 /* PerfectConsonance */) {
|
|
1735
|
+
found.push(
|
|
1736
|
+
violation(
|
|
1737
|
+
"missingCadence",
|
|
1738
|
+
[0, 1],
|
|
1739
|
+
first.index,
|
|
1740
|
+
first.index,
|
|
1741
|
+
"The exercise does not begin on a perfect consonance"
|
|
1742
|
+
)
|
|
1743
|
+
);
|
|
1744
|
+
}
|
|
1745
|
+
if (noteToPitchClass(last.note) !== pitchClassOf(mode.rootPc)) {
|
|
1746
|
+
found.push(
|
|
1747
|
+
violation(
|
|
1748
|
+
"missingCadence",
|
|
1749
|
+
[0, 1],
|
|
1750
|
+
last.index,
|
|
1751
|
+
last.index,
|
|
1752
|
+
"The counterpoint does not close on the final of the mode"
|
|
1753
|
+
)
|
|
1754
|
+
);
|
|
1755
|
+
}
|
|
1756
|
+
const closing = last.interval;
|
|
1757
|
+
if (closing.quality !== "P" || simpleIntervalNumber(closing.number) !== 1) {
|
|
1758
|
+
found.push(
|
|
1759
|
+
violation(
|
|
1760
|
+
"missingCadence",
|
|
1761
|
+
[0, 1],
|
|
1762
|
+
last.index,
|
|
1763
|
+
last.index,
|
|
1764
|
+
"The exercise does not close on an octave or unison"
|
|
1765
|
+
)
|
|
1766
|
+
);
|
|
1767
|
+
return found;
|
|
1768
|
+
}
|
|
1769
|
+
const penultimate = lastEntryBefore(entries, last.againstIndex);
|
|
1770
|
+
if (penultimate === void 0) {
|
|
1771
|
+
return found;
|
|
1772
|
+
}
|
|
1773
|
+
const arrival = entries.find((entry) => entry.againstIndex === last.againstIndex) ?? last;
|
|
1774
|
+
const approach = spelledInterval(penultimate.note, arrival.note);
|
|
1775
|
+
const expected = counterpointAbove ? { quality: "M", number: 6 } : { quality: "m", number: 3 };
|
|
1776
|
+
if (penultimate.interval.quality !== expected.quality || simpleIntervalNumber(penultimate.interval.number) !== expected.number || !isStep(approach)) {
|
|
1777
|
+
found.push(
|
|
1778
|
+
violation(
|
|
1779
|
+
"missingCadence",
|
|
1780
|
+
[0, 1],
|
|
1781
|
+
penultimate.index,
|
|
1782
|
+
arrival.index,
|
|
1783
|
+
counterpointAbove ? "The close is not the major sixth stepping out to the octave" : "The close is not the minor third stepping in to the unison"
|
|
1784
|
+
)
|
|
1785
|
+
);
|
|
1786
|
+
}
|
|
1787
|
+
return found;
|
|
1788
|
+
}
|
|
1789
|
+
function resolveDurations(counterpoint, measures, species, opts) {
|
|
1790
|
+
const given = opts?.durations;
|
|
1791
|
+
if (given !== void 0) {
|
|
1792
|
+
if (given.length !== counterpoint.length) {
|
|
1793
|
+
return {
|
|
1794
|
+
problem: `The exercise gives ${given.length} durations for ${counterpoint.length} notes`
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
for (const duration of given) {
|
|
1798
|
+
const units = duration / MIN_FLORID_VALUE;
|
|
1799
|
+
if (!(duration > 0) || duration > 1 || Math.abs(units - Math.round(units)) > EPS) {
|
|
1800
|
+
return {
|
|
1801
|
+
problem: `A note lasts ${duration} of a measure, which this style does not write`
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
const total = given.reduce((sum, duration) => sum + duration, 0);
|
|
1806
|
+
if (Math.abs(total - measures) > EPS) {
|
|
1807
|
+
return { problem: `The counterpoint fills ${total} of the ${measures} measures` };
|
|
1808
|
+
}
|
|
1809
|
+
return { durations: [...given] };
|
|
1810
|
+
}
|
|
1811
|
+
if (species === 5) {
|
|
1812
|
+
throw new InvalidInputError(
|
|
1813
|
+
"checkSpecies needs durations for the fifth species, whose note values are not fixed"
|
|
1814
|
+
);
|
|
1815
|
+
}
|
|
1816
|
+
const ratio = SPECIES_RATIO[species];
|
|
1817
|
+
const full = ratio * measures;
|
|
1818
|
+
if (counterpoint.length !== full && counterpoint.length !== full - ratio + 1) {
|
|
1819
|
+
return {
|
|
1820
|
+
problem: `Species ${species} sets ${ratio} notes against each of the ${measures} cantus firmus notes, and the counterpoint has ${counterpoint.length}`
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
return { durations: impliedDurations(counterpoint.length, measures, ratio) };
|
|
1824
|
+
}
|
|
1825
|
+
function buildEntries(cantusFirmus, counterpoint, durations, counterpointAbove) {
|
|
1826
|
+
const entries = [];
|
|
1827
|
+
let onset = 0;
|
|
1828
|
+
for (let index = 0; index < counterpoint.length; index += 1) {
|
|
1829
|
+
const note = counterpoint[index];
|
|
1830
|
+
const duration = durations[index] ?? 0;
|
|
1831
|
+
if (note === void 0) {
|
|
1832
|
+
continue;
|
|
1833
|
+
}
|
|
1834
|
+
const againstIndex = Math.min(Math.floor(onset + EPS), cantusFirmus.length - 1);
|
|
1835
|
+
const against = cantusFirmus[againstIndex];
|
|
1836
|
+
if (against === void 0) {
|
|
1837
|
+
continue;
|
|
1838
|
+
}
|
|
1839
|
+
const interval = counterpointAbove ? spelledInterval(against, note) : spelledInterval(note, against);
|
|
1840
|
+
entries.push({
|
|
1841
|
+
note,
|
|
1842
|
+
index,
|
|
1843
|
+
onset,
|
|
1844
|
+
duration,
|
|
1845
|
+
against,
|
|
1846
|
+
againstIndex,
|
|
1847
|
+
downbeat: Math.abs(onset - Math.round(onset)) < EPS,
|
|
1848
|
+
interval,
|
|
1849
|
+
consonance: classifySpelledInterval(interval)
|
|
1850
|
+
});
|
|
1851
|
+
onset += duration;
|
|
1852
|
+
}
|
|
1853
|
+
return entries;
|
|
1854
|
+
}
|
|
1855
|
+
function motionPairs(entries) {
|
|
1856
|
+
const pairs = [];
|
|
1857
|
+
for (let position = 1; position < entries.length; position += 1) {
|
|
1858
|
+
const previous = entries[position - 1];
|
|
1859
|
+
const current = entries[position];
|
|
1860
|
+
if (previous === void 0 || current === void 0) {
|
|
1861
|
+
continue;
|
|
1862
|
+
}
|
|
1863
|
+
if (previous.againstIndex !== current.againstIndex) {
|
|
1864
|
+
pairs.push([position - 1, position]);
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
return pairs;
|
|
1868
|
+
}
|
|
1869
|
+
function checkSpecies(cantusFirmus, counterpoint, species, mode, opts) {
|
|
1870
|
+
if (!SPECIES_NUMBERS.includes(species)) {
|
|
1871
|
+
throw new InvalidInputError(
|
|
1872
|
+
`species must be one of ${SPECIES_NUMBERS.join(", ")}; received ${JSON.stringify(species)}`
|
|
1873
|
+
);
|
|
1874
|
+
}
|
|
1875
|
+
if (cantusFirmus.length === 0 || counterpoint.length === 0) {
|
|
1876
|
+
throw new InvalidInputError("checkSpecies needs both a cantus firmus and a counterpoint");
|
|
1877
|
+
}
|
|
1878
|
+
const resolved = resolveDurations(counterpoint, cantusFirmus.length, species, opts);
|
|
1879
|
+
if ("problem" in resolved) {
|
|
1880
|
+
return [violation("wrongRhythmicRatio", [1], 0, counterpoint.length - 1, resolved.problem)];
|
|
1881
|
+
}
|
|
1882
|
+
const counterpointAbove = opts?.counterpointAbove ?? meanPitch(counterpoint) >= meanPitch(cantusFirmus);
|
|
1883
|
+
const entries = buildEntries(cantusFirmus, counterpoint, resolved.durations, counterpointAbove);
|
|
1884
|
+
const violations = [];
|
|
1885
|
+
for (let position = 0; position < entries.length; position += 1) {
|
|
1886
|
+
violations.push(...verticalViolations(entries, position, species, counterpointAbove));
|
|
1887
|
+
const previous = entries[position - 1];
|
|
1888
|
+
const current = entries[position];
|
|
1889
|
+
if (previous !== void 0 && current !== void 0) {
|
|
1890
|
+
violations.push(...melodicViolations(previous, current));
|
|
1891
|
+
}
|
|
1892
|
+
}
|
|
1893
|
+
violations.push(...melodicShapeViolations(entries, species));
|
|
1894
|
+
for (const [from, to] of motionPairs(entries)) {
|
|
1895
|
+
const previous = entries[from];
|
|
1896
|
+
const current = entries[to];
|
|
1897
|
+
if (previous !== void 0 && current !== void 0) {
|
|
1898
|
+
violations.push(...motionViolations(previous, current, counterpointAbove));
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
violations.push(...cadenceViolations(entries, mode, counterpointAbove));
|
|
1902
|
+
return violations;
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
// src/theory/partwriting/index.ts
|
|
1906
|
+
function bare(note) {
|
|
1907
|
+
return { letter: note.letter, alter: note.alter };
|
|
1908
|
+
}
|
|
1909
|
+
function midiOf(note, index, voice) {
|
|
1910
|
+
if (note.octave === void 0) {
|
|
1911
|
+
throw new InvalidInputError(`voicings[${index}][${voice}] must carry an octave`);
|
|
1912
|
+
}
|
|
1913
|
+
return noteToMidi(note);
|
|
1914
|
+
}
|
|
1915
|
+
function isDescendingStep(interval) {
|
|
1916
|
+
return interval.number === 2 && (interval.semitones === -1 || interval.semitones === -2);
|
|
1917
|
+
}
|
|
1918
|
+
function verticalViolations2(moment, ranges, maxSpacing) {
|
|
1919
|
+
const found = [];
|
|
1920
|
+
const { pitches, index } = moment;
|
|
1921
|
+
for (let voice = 1; voice < pitches.length; voice += 1) {
|
|
1922
|
+
const upper = pitches[voice];
|
|
1923
|
+
const lower = pitches[voice - 1];
|
|
1924
|
+
if (upper === void 0 || lower === void 0) {
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
if (createsVoiceCrossing(upper, lower)) {
|
|
1928
|
+
found.push(
|
|
1929
|
+
violation(
|
|
1930
|
+
"voiceCrossing",
|
|
1931
|
+
[voice - 1, voice],
|
|
1932
|
+
index,
|
|
1933
|
+
index,
|
|
1934
|
+
"The upper voice lies below the voice beneath it"
|
|
1935
|
+
)
|
|
1936
|
+
);
|
|
1937
|
+
}
|
|
1938
|
+
if (voice >= 2 && exceedsSpacing(upper, lower, maxSpacing)) {
|
|
1939
|
+
found.push(
|
|
1940
|
+
violation(
|
|
1941
|
+
"spacing",
|
|
1942
|
+
[voice - 1, voice],
|
|
1943
|
+
index,
|
|
1944
|
+
index,
|
|
1945
|
+
`Adjacent upper voices lie more than ${maxSpacing} semitones apart`
|
|
1946
|
+
)
|
|
1947
|
+
);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
if (ranges === void 0) {
|
|
1951
|
+
return found;
|
|
1952
|
+
}
|
|
1953
|
+
for (let voice = 0; voice < pitches.length; voice += 1) {
|
|
1954
|
+
const pitch = pitches[voice];
|
|
1955
|
+
const range = ranges[voice];
|
|
1956
|
+
if (pitch === void 0 || range === void 0) {
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
if (pitch < range.min || pitch > range.max) {
|
|
1960
|
+
found.push(violation("range", [voice], index, index, "The voice sings outside its range"));
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
return found;
|
|
1964
|
+
}
|
|
1965
|
+
function pairViolations(transition) {
|
|
1966
|
+
const found = [];
|
|
1967
|
+
const { from, to } = transition;
|
|
1968
|
+
const voices = Math.min(from.pitches.length, to.pitches.length);
|
|
1969
|
+
for (let lower = 0; lower < voices; lower += 1) {
|
|
1970
|
+
for (let upper = lower + 1; upper < voices; upper += 1) {
|
|
1971
|
+
const upperPrev = from.pitches[upper];
|
|
1972
|
+
const upperCur = to.pitches[upper];
|
|
1973
|
+
const lowerPrev = from.pitches[lower];
|
|
1974
|
+
const lowerCur = to.pitches[lower];
|
|
1975
|
+
if (upperPrev === void 0 || upperCur === void 0 || lowerPrev === void 0 || lowerCur === void 0) {
|
|
1976
|
+
continue;
|
|
1977
|
+
}
|
|
1978
|
+
if (createsParallelPerfect(upperPrev, upperCur, lowerPrev, lowerCur)) {
|
|
1979
|
+
const fifth = pitchClassOf(upperCur - lowerCur) === 7;
|
|
1980
|
+
found.push(
|
|
1981
|
+
violation(
|
|
1982
|
+
fifth ? "parallelFifth" : "parallelOctave",
|
|
1983
|
+
[lower, upper],
|
|
1984
|
+
from.index,
|
|
1985
|
+
to.index,
|
|
1986
|
+
fifth ? "The two voices move into consecutive perfect fifths" : "The two voices move into consecutive perfect octaves"
|
|
1987
|
+
)
|
|
1988
|
+
);
|
|
1989
|
+
}
|
|
1990
|
+
if (upper === lower + 1 && createsVoiceOverlap(upperPrev, upperCur, lowerPrev, lowerCur)) {
|
|
1991
|
+
found.push(
|
|
1992
|
+
violation(
|
|
1993
|
+
"overlap",
|
|
1994
|
+
[lower, upper],
|
|
1995
|
+
from.index,
|
|
1996
|
+
to.index,
|
|
1997
|
+
"The voice moves past where its neighbour just was"
|
|
1998
|
+
)
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
const bassPrev = from.pitches[0];
|
|
2004
|
+
const bassCur = to.pitches[0];
|
|
2005
|
+
const topPrev = from.pitches[voices - 1];
|
|
2006
|
+
const topCur = to.pitches[voices - 1];
|
|
2007
|
+
if (voices >= 2 && bassPrev !== void 0 && bassCur !== void 0 && topPrev !== void 0 && topCur !== void 0 && createsHiddenParallelPerfect(topPrev, topCur, bassPrev, bassCur)) {
|
|
2008
|
+
found.push(
|
|
2009
|
+
violation(
|
|
2010
|
+
"hiddenPerfect",
|
|
2011
|
+
[0, voices - 1],
|
|
2012
|
+
from.index,
|
|
2013
|
+
to.index,
|
|
2014
|
+
"The outer voices leap into a perfect interval by similar motion"
|
|
2015
|
+
)
|
|
2016
|
+
);
|
|
2017
|
+
}
|
|
2018
|
+
return found;
|
|
2019
|
+
}
|
|
2020
|
+
function inKey(pc, key) {
|
|
2021
|
+
return (key.modeMask12 >> pitchClassOf(pc - key.rootPc) & 1) === 1;
|
|
2022
|
+
}
|
|
2023
|
+
function isDominantSonority(chord) {
|
|
2024
|
+
const has = (semitones) => chord.intervals.some((interval) => pitchClassOf(interval) === semitones);
|
|
2025
|
+
return chord.quality === "maj" || has(4) && has(10);
|
|
2026
|
+
}
|
|
2027
|
+
function isAppliedDominant(chord, next, key) {
|
|
2028
|
+
if (next === void 0 || !isDominantSonority(chord)) {
|
|
2029
|
+
return false;
|
|
2030
|
+
}
|
|
2031
|
+
const target = pitchClassOf(chord.rootPc + 5);
|
|
2032
|
+
return target !== pitchClassOf(key.rootPc) && inKey(target, key) && pitchClassOf(next.rootPc) === target;
|
|
2033
|
+
}
|
|
2034
|
+
function isNeapolitan(chord, key) {
|
|
2035
|
+
return chord.quality === "maj" && pitchClassOf(chord.rootPc - key.rootPc) === 1;
|
|
2036
|
+
}
|
|
2037
|
+
function soundsAugmentedSixth(moment, key) {
|
|
2038
|
+
const bass = moment.notes[0];
|
|
2039
|
+
if (bass === void 0 || noteToPitchClass(bass) !== pitchClassOf(key.rootPc + 8)) {
|
|
2040
|
+
return false;
|
|
2041
|
+
}
|
|
2042
|
+
return moment.notes.some((note, voice) => {
|
|
2043
|
+
if (voice === 0) {
|
|
2044
|
+
return false;
|
|
2045
|
+
}
|
|
2046
|
+
const interval = spelledInterval(bass, note);
|
|
2047
|
+
return (interval.number - 1) % 7 + 1 === 6 && pitchClassOf(interval.semitones) === 10;
|
|
2048
|
+
});
|
|
2049
|
+
}
|
|
2050
|
+
function isChromaticHarmony(moment, next, key) {
|
|
2051
|
+
return isAppliedDominant(moment.chord, next, key) || isNeapolitan(moment.chord, key) || soundsAugmentedSixth(moment, key);
|
|
2052
|
+
}
|
|
2053
|
+
function approachedByStep(before, moment, voice) {
|
|
2054
|
+
const earlier = before?.notes[voice];
|
|
2055
|
+
const later = moment.notes[voice];
|
|
2056
|
+
if (earlier === void 0 || later === void 0) {
|
|
2057
|
+
return false;
|
|
2058
|
+
}
|
|
2059
|
+
return spelledInterval(earlier, later).number === 2;
|
|
2060
|
+
}
|
|
2061
|
+
function isOuterVoice(voice, moment) {
|
|
2062
|
+
return voice === 0 || voice === moment.notes.length - 1;
|
|
2063
|
+
}
|
|
2064
|
+
function crossRelationViolations(transition) {
|
|
2065
|
+
const found = [];
|
|
2066
|
+
const { from, to, key, before, after } = transition;
|
|
2067
|
+
const chromatic = isChromaticHarmony(from, to.chord, key) || isChromaticHarmony(to, after?.chord, key);
|
|
2068
|
+
const reported = /* @__PURE__ */ new Set();
|
|
2069
|
+
for (let first = 0; first < from.notes.length; first += 1) {
|
|
2070
|
+
for (let second = 0; second < to.notes.length; second += 1) {
|
|
2071
|
+
if (first === second) {
|
|
2072
|
+
continue;
|
|
2073
|
+
}
|
|
2074
|
+
const earlier = from.notes[first];
|
|
2075
|
+
const later = to.notes[second];
|
|
2076
|
+
if (earlier === void 0 || later === void 0) {
|
|
2077
|
+
continue;
|
|
2078
|
+
}
|
|
2079
|
+
const letter = diatonicLetterOf(earlier.letter);
|
|
2080
|
+
if (letter !== diatonicLetterOf(later.letter) || earlier.alter === later.alter) {
|
|
2081
|
+
continue;
|
|
2082
|
+
}
|
|
2083
|
+
if (chromatic || approachedByStep(before, from, first) || approachedByStep(from, to, second) || !isOuterVoice(first, from) && !isOuterVoice(second, to)) {
|
|
2084
|
+
continue;
|
|
2085
|
+
}
|
|
2086
|
+
const pair = `${letter}:${Math.min(first, second)}:${Math.max(first, second)}`;
|
|
2087
|
+
if (reported.has(pair)) {
|
|
2088
|
+
continue;
|
|
2089
|
+
}
|
|
2090
|
+
reported.add(pair);
|
|
2091
|
+
found.push(
|
|
2092
|
+
violation(
|
|
2093
|
+
"crossRelation",
|
|
2094
|
+
[first, second],
|
|
2095
|
+
from.index,
|
|
2096
|
+
to.index,
|
|
2097
|
+
`${formatNote(bare(earlier))} is contradicted by ${formatNote(bare(later))} in another voice`
|
|
2098
|
+
)
|
|
2099
|
+
);
|
|
2100
|
+
}
|
|
2101
|
+
}
|
|
2102
|
+
return found;
|
|
2103
|
+
}
|
|
2104
|
+
function melodicViolations2(transition) {
|
|
2105
|
+
const found = [];
|
|
2106
|
+
const { from, to, key } = transition;
|
|
2107
|
+
const seventhPc = seventhPcOf(from.chord);
|
|
2108
|
+
const nextPcs = new Set(chordPitchClasses(to.chord));
|
|
2109
|
+
const leadingTonePc = leadingTonePcOf(key);
|
|
2110
|
+
const tonicPc = pitchClassOf(key.rootPc);
|
|
2111
|
+
const voices = Math.min(from.notes.length, to.notes.length);
|
|
2112
|
+
for (let voice = 0; voice < voices; voice += 1) {
|
|
2113
|
+
const earlier = from.notes[voice];
|
|
2114
|
+
const later = to.notes[voice];
|
|
2115
|
+
const earlierPitch = from.pitches[voice];
|
|
2116
|
+
const laterPitch = to.pitches[voice];
|
|
2117
|
+
if (earlier === void 0 || later === void 0 || earlierPitch === void 0 || laterPitch === void 0) {
|
|
2118
|
+
continue;
|
|
2119
|
+
}
|
|
2120
|
+
const interval = spelledInterval(earlier, later);
|
|
2121
|
+
if (isAugmentedMelodicInterval(earlier, later)) {
|
|
2122
|
+
found.push(
|
|
2123
|
+
violation(
|
|
2124
|
+
"augmentedMelodicInterval",
|
|
2125
|
+
[voice],
|
|
2126
|
+
from.index,
|
|
2127
|
+
to.index,
|
|
2128
|
+
`The voice moves by an augmented ${intervalWord(interval.number)}`
|
|
2129
|
+
)
|
|
2130
|
+
);
|
|
2131
|
+
}
|
|
2132
|
+
const earlierPc = noteToPitchClass(earlier);
|
|
2133
|
+
if (earlierPc === seventhPc && !nextPcs.has(earlierPc) && !isDescendingStep(interval)) {
|
|
2134
|
+
found.push(
|
|
2135
|
+
violation(
|
|
2136
|
+
"unresolvedSeventh",
|
|
2137
|
+
[voice],
|
|
2138
|
+
from.index,
|
|
2139
|
+
to.index,
|
|
2140
|
+
"The chordal seventh does not fall by step"
|
|
2141
|
+
)
|
|
2142
|
+
);
|
|
2143
|
+
}
|
|
2144
|
+
if (earlierPc === leadingTonePc && isFunctioningLeadingTone(from.chord, key) && nextPcs.has(tonicPc) && !nextPcs.has(earlierPc) && !isLeadingToneResolution(earlierPitch, laterPitch, key)) {
|
|
2145
|
+
found.push(
|
|
2146
|
+
violation(
|
|
2147
|
+
"unresolvedLeadingTone",
|
|
2148
|
+
[voice],
|
|
2149
|
+
from.index,
|
|
2150
|
+
to.index,
|
|
2151
|
+
"The leading tone does not rise to the tonic"
|
|
2152
|
+
)
|
|
2153
|
+
);
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
return found;
|
|
2157
|
+
}
|
|
2158
|
+
function spellVoicing(voicing, chord, key) {
|
|
2159
|
+
const { tonic } = spelledKeyOf(key);
|
|
2160
|
+
return voicing.map((pitch) => spellPitch(pitch, tonic, key, { chordRoot: chord.rootPc }));
|
|
2161
|
+
}
|
|
2162
|
+
function checkPartWriting(voicings, chords, key, opts) {
|
|
2163
|
+
if (voicings.length !== chords.length) {
|
|
2164
|
+
throw new InvalidInputError(
|
|
2165
|
+
`checkPartWriting needs one chord per voicing; received ${voicings.length} voicings and ${chords.length} chords`
|
|
2166
|
+
);
|
|
2167
|
+
}
|
|
2168
|
+
const maxSpacing = resolveMaxSpacing(opts);
|
|
2169
|
+
const voiceCount = voicings.reduce((widest, voicing) => Math.max(widest, voicing.length), 0);
|
|
2170
|
+
let ranges;
|
|
2171
|
+
if (opts?.ranges === void 0) {
|
|
2172
|
+
ranges = voiceCount === 4 ? SATB_RANGES : void 0;
|
|
2173
|
+
} else {
|
|
2174
|
+
ranges = resolveRanges(opts);
|
|
2175
|
+
if (ranges.length < voiceCount) {
|
|
2176
|
+
throw new InvalidInputError(
|
|
2177
|
+
`checkPartWriting needs one range per voice; received ${ranges.length} ranges for ${voiceCount} voices`
|
|
2178
|
+
);
|
|
2179
|
+
}
|
|
2180
|
+
}
|
|
2181
|
+
const moments = [];
|
|
2182
|
+
for (let index = 0; index < voicings.length; index += 1) {
|
|
2183
|
+
const notes = voicings[index];
|
|
2184
|
+
const chord = chords[index];
|
|
2185
|
+
if (notes === void 0 || chord === void 0) {
|
|
2186
|
+
continue;
|
|
2187
|
+
}
|
|
2188
|
+
moments.push({
|
|
2189
|
+
notes,
|
|
2190
|
+
pitches: notes.map((note, voice) => midiOf(note, index, voice)),
|
|
2191
|
+
chord,
|
|
2192
|
+
index
|
|
2193
|
+
});
|
|
2194
|
+
}
|
|
2195
|
+
const violations = [];
|
|
2196
|
+
for (let position = 0; position < moments.length; position += 1) {
|
|
2197
|
+
const moment = moments[position];
|
|
2198
|
+
const previous = position > 0 ? moments[position - 1] : void 0;
|
|
2199
|
+
if (moment === void 0) {
|
|
2200
|
+
continue;
|
|
2201
|
+
}
|
|
2202
|
+
if (previous !== void 0) {
|
|
2203
|
+
const transition = {
|
|
2204
|
+
from: previous,
|
|
2205
|
+
to: moment,
|
|
2206
|
+
key,
|
|
2207
|
+
before: position > 1 ? moments[position - 2] : void 0,
|
|
2208
|
+
after: moments[position + 1]
|
|
2209
|
+
};
|
|
2210
|
+
violations.push(...pairViolations(transition));
|
|
2211
|
+
violations.push(...crossRelationViolations(transition));
|
|
2212
|
+
violations.push(...melodicViolations2(transition));
|
|
2213
|
+
}
|
|
2214
|
+
violations.push(...verticalViolations2(moment, ranges, maxSpacing));
|
|
2215
|
+
}
|
|
2216
|
+
return violations;
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
export {
|
|
2220
|
+
TRANSPOSING_INSTRUMENTS,
|
|
2221
|
+
instrumentTransposition,
|
|
2222
|
+
toSoundingPitch,
|
|
2223
|
+
toWrittenPitch,
|
|
2224
|
+
scaleMatchesChord,
|
|
2225
|
+
chordScales,
|
|
2226
|
+
avoidNotes,
|
|
2227
|
+
availableTensions,
|
|
2228
|
+
chordScaleReport,
|
|
2229
|
+
scalesForChanges,
|
|
2230
|
+
figuredBassRealization,
|
|
2231
|
+
realizeFiguredBass,
|
|
2232
|
+
figuredBassOf,
|
|
2233
|
+
SATB_RANGES,
|
|
2234
|
+
voiceChord,
|
|
2235
|
+
voiceProgression,
|
|
2236
|
+
voiceLeadingCost,
|
|
2237
|
+
nextVoicing,
|
|
2238
|
+
voiceChordStyled,
|
|
2239
|
+
checkSpecies,
|
|
2240
|
+
spellVoicing,
|
|
2241
|
+
checkPartWriting
|
|
2242
|
+
};
|
|
2243
|
+
//# sourceMappingURL=chunk-H3VORETI.js.map
|