@libraz/libcantus 0.9.3 → 0.9.5
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 +29 -20
- package/README_ja.md +268 -0
- package/dist/analyze/index.cjs +39 -1947
- package/dist/analyze/index.cjs.map +1 -1
- package/dist/analyze/index.d.cts +161 -91
- package/dist/analyze/index.d.ts +161 -91
- package/dist/analyze/index.js +19 -8
- package/dist/chunk-2A2QMA74.cjs +261 -0
- package/dist/chunk-2A2QMA74.cjs.map +1 -0
- package/dist/chunk-2PTFGVD7.js +261 -0
- package/dist/chunk-2PTFGVD7.js.map +1 -0
- package/dist/chunk-357ZDPVU.cjs +162 -0
- package/dist/chunk-357ZDPVU.cjs.map +1 -0
- package/dist/chunk-3KX76RLG.js +1548 -0
- package/dist/chunk-3KX76RLG.js.map +1 -0
- package/dist/chunk-4VZ5MZAM.cjs +889 -0
- package/dist/chunk-4VZ5MZAM.cjs.map +1 -0
- package/dist/{chunk-C5XYKUVG.js → chunk-7EONYVI5.js} +471 -171
- package/dist/chunk-7EONYVI5.js.map +1 -0
- package/dist/{chunk-IT2O4XFE.js → chunk-7JEP3MFY.js} +42 -34
- package/dist/chunk-7JEP3MFY.js.map +1 -0
- package/dist/{chunk-4MHA432G.js → chunk-AID75RN3.js} +572 -250
- package/dist/chunk-AID75RN3.js.map +1 -0
- package/dist/chunk-BSLTLGM3.cjs +497 -0
- package/dist/chunk-BSLTLGM3.cjs.map +1 -0
- package/dist/chunk-CU6VG2YM.cjs +109 -0
- package/dist/chunk-CU6VG2YM.cjs.map +1 -0
- package/dist/{chunk-EH6EM37U.js → chunk-DZMHIGI4.js} +81 -59
- package/dist/chunk-DZMHIGI4.js.map +1 -0
- package/dist/{chunk-AKLYPWIN.js → chunk-EDOTEABK.js} +45 -19
- package/dist/chunk-EDOTEABK.js.map +1 -0
- package/dist/{chunk-2ZWMSJH6.js → chunk-HN2OEUN2.js} +7 -4
- package/dist/chunk-HN2OEUN2.js.map +1 -0
- package/dist/{chunk-MIWO425M.js → chunk-KBTTEX7Q.js} +128 -43
- package/dist/chunk-KBTTEX7Q.js.map +1 -0
- package/dist/chunk-KQN2ZYND.cjs +724 -0
- package/dist/chunk-KQN2ZYND.cjs.map +1 -0
- package/dist/chunk-MPTVJIVG.cjs +3176 -0
- package/dist/chunk-MPTVJIVG.cjs.map +1 -0
- package/dist/chunk-MTMZTMWM.cjs +53 -0
- package/dist/chunk-MTMZTMWM.cjs.map +1 -0
- package/dist/{chunk-CHMXELDS.js → chunk-NNB67XA2.js} +372 -197
- package/dist/chunk-NNB67XA2.js.map +1 -0
- package/dist/chunk-OL3IHHSJ.cjs +1204 -0
- package/dist/chunk-OL3IHHSJ.cjs.map +1 -0
- package/dist/chunk-PI3UXRWK.cjs +528 -0
- package/dist/chunk-PI3UXRWK.cjs.map +1 -0
- package/dist/chunk-R2VVFAIN.js +528 -0
- package/dist/chunk-R2VVFAIN.js.map +1 -0
- package/dist/chunk-T64C7ZD7.cjs +1 -0
- package/dist/chunk-T64C7ZD7.cjs.map +1 -0
- package/dist/chunk-WFDXOLUB.cjs +337 -0
- package/dist/chunk-WFDXOLUB.cjs.map +1 -0
- package/dist/chunk-WTAN4ACG.cjs +1548 -0
- package/dist/chunk-WTAN4ACG.cjs.map +1 -0
- package/dist/chunk-YFJXS3AU.cjs +33 -0
- package/dist/chunk-YFJXS3AU.cjs.map +1 -0
- package/dist/{chunk-GOAW4VYL.js → chunk-YUE6TJ4M.js} +315 -145
- package/dist/chunk-YUE6TJ4M.js.map +1 -0
- package/dist/{chunk-62QZKCJQ.js → chunk-ZGLJM4A4.js} +10 -6
- package/dist/chunk-ZGLJM4A4.js.map +1 -0
- package/dist/core/index.cjs +140 -647
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +313 -41
- package/dist/core/index.d.ts +313 -41
- package/dist/core/index.js +64 -16
- package/dist/{function-DVH4Eusr.d.ts → function-CxixTB02.d.cts} +122 -10
- package/dist/{function-DYt4iKsn.d.cts → function-DTCt675z.d.ts} +122 -10
- package/dist/generate/index.cjs +28 -4085
- package/dist/generate/index.cjs.map +1 -1
- package/dist/generate/index.d.cts +374 -66
- package/dist/generate/index.d.ts +374 -66
- package/dist/generate/index.js +23 -9
- package/dist/{index-Jc0Ryi79.d.ts → index-B2Ulxxpo.d.cts} +21 -5
- package/dist/{index-CpOsP1y9.d.cts → index-BkYJ0xMq.d.cts} +18 -17
- package/dist/{index-CxqBQtH1.d.cts → index-BuaB-ypt.d.ts} +21 -5
- package/dist/index-CAN7v-kw.d.ts +199 -0
- package/dist/{index-Bt4tOUD9.d.ts → index-COw1Ky9g.d.ts} +93 -8
- package/dist/{index-Dikst44v.d.cts → index-Cfx8xgH0.d.ts} +14 -11
- package/dist/{index-DxvARIP2.d.ts → index-DRtIhlJj.d.ts} +18 -17
- package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
- package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
- package/dist/index-alrRninU.d.cts +199 -0
- package/dist/{index-BNlGP86A.d.cts → index-oTQbYYIs.d.cts} +93 -8
- package/dist/index-tR9jTn26.d.cts +275 -0
- package/dist/index-tR9jTn26.d.ts +275 -0
- package/dist/{index-Bg5dYMmZ.d.ts → index-x2vcsduE.d.cts} +14 -11
- package/dist/index.cjs +429 -7383
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -15
- package/dist/index.d.ts +15 -15
- package/dist/index.js +121 -27
- package/dist/model/index.cjs +8 -2562
- package/dist/model/index.cjs.map +1 -1
- package/dist/model/index.d.cts +471 -81
- package/dist/model/index.d.ts +471 -81
- package/dist/model/index.js +6 -7
- package/dist/styled-Bl0ZK28h.d.ts +543 -0
- package/dist/styled-Crv6SA4S.d.cts +543 -0
- package/dist/theory/index.cjs +172 -1822
- package/dist/theory/index.cjs.map +1 -1
- package/dist/theory/index.d.cts +151 -233
- package/dist/theory/index.d.ts +151 -233
- package/dist/theory/index.js +30 -7
- package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
- package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
- package/package.json +34 -13
- package/dist/chunk-2ZWMSJH6.js.map +0 -1
- package/dist/chunk-4MHA432G.js.map +0 -1
- package/dist/chunk-62QZKCJQ.js.map +0 -1
- package/dist/chunk-AKLYPWIN.js.map +0 -1
- package/dist/chunk-C5XYKUVG.js.map +0 -1
- package/dist/chunk-CHMXELDS.js.map +0 -1
- package/dist/chunk-DYWFNC4A.js +0 -152
- package/dist/chunk-DYWFNC4A.js.map +0 -1
- package/dist/chunk-EH6EM37U.js.map +0 -1
- package/dist/chunk-FBQK3ZQN.js +0 -131
- package/dist/chunk-FBQK3ZQN.js.map +0 -1
- package/dist/chunk-GOAW4VYL.js.map +0 -1
- package/dist/chunk-I5R7ZBJS.js +0 -202
- package/dist/chunk-I5R7ZBJS.js.map +0 -1
- package/dist/chunk-IT2O4XFE.js.map +0 -1
- package/dist/chunk-MIWO425M.js.map +0 -1
- package/dist/chunk-SGU7SPLK.js +0 -924
- package/dist/chunk-SGU7SPLK.js.map +0 -1
- package/dist/index-CGMZNCx6.d.cts +0 -140
- package/dist/index-CGMZNCx6.d.ts +0 -140
- package/dist/index-CnZCo29u.d.cts +0 -108
- package/dist/index-JnF4v9bx.d.ts +0 -108
- package/dist/styled-DZ7GjShb.d.ts +0 -282
- package/dist/styled-ZTbdaVFM.d.cts +0 -282
- package/src/analyze/arrange/index.ts +0 -21
- package/src/analyze/arrange/internal.ts +0 -127
- package/src/analyze/arrange/tension.ts +0 -199
- package/src/analyze/arrange/tracks.ts +0 -312
- package/src/analyze/detect/index.ts +0 -272
- package/src/analyze/functional/borrowed.ts +0 -84
- package/src/analyze/functional/cadence.ts +0 -66
- package/src/analyze/functional/function.ts +0 -184
- package/src/analyze/functional/index.ts +0 -23
- package/src/analyze/functional/internal.ts +0 -29
- package/src/analyze/functional/roman.ts +0 -402
- package/src/analyze/index.ts +0 -49
- package/src/analyze/timeline/index.ts +0 -451
- package/src/analyze/voice/index.ts +0 -339
- package/src/core/event-index/index.ts +0 -104
- package/src/core/index.ts +0 -61
- package/src/core/interval/index.ts +0 -81
- package/src/core/meter/index.ts +0 -302
- package/src/core/pitch/index.ts +0 -311
- package/src/core/random/index.ts +0 -76
- package/src/core/tuning/index.ts +0 -186
- package/src/core/types.ts +0 -29
- package/src/core/validation/index.ts +0 -119
- package/src/generate/bass/index.ts +0 -373
- package/src/generate/countermelody/index.ts +0 -470
- package/src/generate/drums/beat.ts +0 -266
- package/src/generate/drums/euclid.ts +0 -65
- package/src/generate/drums/fills.ts +0 -356
- package/src/generate/drums/ghost.ts +0 -119
- package/src/generate/drums/hihat.ts +0 -285
- package/src/generate/drums/hit.ts +0 -31
- package/src/generate/drums/index.ts +0 -361
- package/src/generate/drums/internal.ts +0 -257
- package/src/generate/drums/kick.ts +0 -160
- package/src/generate/drums/percussion.ts +0 -121
- package/src/generate/drums/rng.ts +0 -7
- package/src/generate/drums/swing.ts +0 -71
- package/src/generate/groove/index.ts +0 -351
- package/src/generate/harmonize/index.ts +0 -451
- package/src/generate/index.ts +0 -58
- package/src/generate/motif/index.ts +0 -422
- package/src/generate/progression/index.ts +0 -362
- package/src/generate/reharmony/index.ts +0 -323
- package/src/generate/rhythm/index.ts +0 -175
- package/src/index.ts +0 -13
- package/src/model/chord.ts +0 -442
- package/src/model/index.ts +0 -15
- package/src/model/interval.ts +0 -82
- package/src/model/key.ts +0 -266
- package/src/model/note.ts +0 -176
- package/src/model/progression.ts +0 -161
- package/src/model/shared.ts +0 -12
- package/src/theory/chord/index.ts +0 -375
- package/src/theory/chordscale/index.ts +0 -374
- package/src/theory/counterpoint/index.ts +0 -230
- package/src/theory/harmony/index.ts +0 -77
- package/src/theory/index.ts +0 -112
- package/src/theory/safety/index.ts +0 -488
- package/src/theory/scale/degrees.ts +0 -123
- package/src/theory/scale/index.ts +0 -28
- package/src/theory/scale/key.ts +0 -56
- package/src/theory/scale/masks.ts +0 -153
- package/src/theory/spelling/index.ts +0 -249
- package/src/theory/symbol/index.ts +0 -268
- package/src/theory/voicing/index.ts +0 -5
- package/src/theory/voicing/internal.ts +0 -168
- package/src/theory/voicing/leading.ts +0 -112
- package/src/theory/voicing/satb.ts +0 -216
- package/src/theory/voicing/styled.ts +0 -199
- package/tsconfig.json +0 -23
|
@@ -1,374 +0,0 @@
|
|
|
1
|
-
import type { Chord } from '../chord/index.js';
|
|
2
|
-
import { chordPitchClasses } from '../chord/index.js';
|
|
3
|
-
import { NAMED_SCALES } from '../scale/index.js';
|
|
4
|
-
|
|
5
|
-
/** Reduce a value to a pitch class in [0, 11]. */
|
|
6
|
-
function pitchClass(value: number): number {
|
|
7
|
-
return ((Math.trunc(value) % 12) + 12) % 12;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
/** Count the set bits (scale tones) in a 12-bit mode mask. */
|
|
11
|
-
function popcount12(mask: number): number {
|
|
12
|
-
let count = 0;
|
|
13
|
-
for (let n = 0; n < 12; n += 1) {
|
|
14
|
-
count += (mask >> n) & 1;
|
|
15
|
-
}
|
|
16
|
-
return count;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Test whether a pitch class belongs to a mode mask rooted on `scaleRootPc`. */
|
|
20
|
-
function maskHasPitchClass(mask: number, scaleRootPc: number, pc: number): boolean {
|
|
21
|
-
const offset = pitchClass(pc - scaleRootPc);
|
|
22
|
-
return ((mask >> offset) & 1) === 1;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Test whether every chord pitch class is contained in a scale.
|
|
27
|
-
*
|
|
28
|
-
* @param chordPcs The chord's pitch classes (0..11).
|
|
29
|
-
* @param scaleMask The scale's 12-bit mode mask.
|
|
30
|
-
* @param scaleRootPc The pitch class the mask is rooted on.
|
|
31
|
-
* @returns True if the scale is a superset of the chord.
|
|
32
|
-
*
|
|
33
|
-
* @category Scales
|
|
34
|
-
*/
|
|
35
|
-
export function scaleMatchesChord(
|
|
36
|
-
chordPcs: number[],
|
|
37
|
-
scaleMask: number,
|
|
38
|
-
scaleRootPc: number,
|
|
39
|
-
): boolean {
|
|
40
|
-
for (const pc of chordPcs) {
|
|
41
|
-
if (!maskHasPitchClass(scaleMask, scaleRootPc, pc)) {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
return true;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* A named scale rooted on a pitch class that fits over a chord.
|
|
50
|
-
*
|
|
51
|
-
* @category Scales
|
|
52
|
-
*/
|
|
53
|
-
export type ChordScaleMatch = {
|
|
54
|
-
name: string;
|
|
55
|
-
rootPc: number;
|
|
56
|
-
};
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* Scale names whose mask duplicates another {@link NAMED_SCALES} entry
|
|
60
|
-
* (major/ionian, naturalMinor/aeolian). Chord-scale analysis conventionally
|
|
61
|
-
* speaks in mode names, so these aliases are skipped and only the modal name
|
|
62
|
-
* is reported.
|
|
63
|
-
*/
|
|
64
|
-
const ALIASED_SCALE_NAMES = new Set(['major', 'naturalMinor']);
|
|
65
|
-
|
|
66
|
-
/** Number of tones in a heptatonic (seven-note) scale. */
|
|
67
|
-
const HEPTATONIC_SIZE = 7;
|
|
68
|
-
|
|
69
|
-
/**
|
|
70
|
-
* List the scales that fit over a chord, best fit first.
|
|
71
|
-
*
|
|
72
|
-
* Only the chord root is considered as the scale root, matching the
|
|
73
|
-
* conventional chord-scale relationship. Every entry of {@link NAMED_SCALES}
|
|
74
|
-
* whose pitch-class set is a superset of the chord's is returned once per
|
|
75
|
-
* distinct pitch-class set (aliased masks such as major/ionian report only the
|
|
76
|
-
* modal name), ranked by fewest extra scale tones beyond the chord, then by
|
|
77
|
-
* scale size (heptatonic before larger scales), then by scale name. For bare
|
|
78
|
-
* triads and smaller chords, heptatonic scales rank before pentatonics and
|
|
79
|
-
* other sizes: a pentatonic adds no modal color over a triad, so the seven-note
|
|
80
|
-
* modes are the more useful answer. The chromatic scale is only returned as a
|
|
81
|
-
* fallback when no other scale contains the chord.
|
|
82
|
-
*
|
|
83
|
-
* @param chord The chord to fit scales over.
|
|
84
|
-
* @returns The matching scales rooted on the chord root, best fit first.
|
|
85
|
-
*
|
|
86
|
-
* @example
|
|
87
|
-
* ```ts
|
|
88
|
-
* import { makeChord, chordScales } from '@libraz/libcantus';
|
|
89
|
-
* const scales = chordScales(makeChord(0, 'maj7'));
|
|
90
|
-
* // scales for Cmaj7, best fit first; each is { name, rootPc: 0 }
|
|
91
|
-
* ```
|
|
92
|
-
*
|
|
93
|
-
* @category Scales
|
|
94
|
-
*/
|
|
95
|
-
export function chordScales(chord: Chord): ChordScaleMatch[] {
|
|
96
|
-
const chordPcs = chordPitchClasses(chord);
|
|
97
|
-
const rootPc = pitchClass(chord.rootPc);
|
|
98
|
-
const ranked: { name: string; extra: number; size: number }[] = [];
|
|
99
|
-
const seenMasks = new Set<number>();
|
|
100
|
-
for (const name of Object.keys(NAMED_SCALES)) {
|
|
101
|
-
if (name === 'chromatic' || ALIASED_SCALE_NAMES.has(name)) {
|
|
102
|
-
continue;
|
|
103
|
-
}
|
|
104
|
-
const mask = NAMED_SCALES[name];
|
|
105
|
-
if (mask === undefined || seenMasks.has(mask)) {
|
|
106
|
-
continue;
|
|
107
|
-
}
|
|
108
|
-
seenMasks.add(mask);
|
|
109
|
-
if (scaleMatchesChord(chordPcs, mask, rootPc)) {
|
|
110
|
-
const size = popcount12(mask);
|
|
111
|
-
ranked.push({ name, extra: size - chordPcs.length, size });
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
if (ranked.length === 0) {
|
|
115
|
-
// No diatonic-style scale contains the chord; fall back to the chromatic
|
|
116
|
-
// scale, which trivially contains every pitch class.
|
|
117
|
-
if (NAMED_SCALES.chromatic !== undefined) {
|
|
118
|
-
return [{ name: 'chromatic', rootPc }];
|
|
119
|
-
}
|
|
120
|
-
return [];
|
|
121
|
-
}
|
|
122
|
-
const preferHeptatonic = chordPcs.length <= 3;
|
|
123
|
-
ranked.sort((a, b) => {
|
|
124
|
-
if (preferHeptatonic && (a.size === HEPTATONIC_SIZE) !== (b.size === HEPTATONIC_SIZE)) {
|
|
125
|
-
return a.size === HEPTATONIC_SIZE ? -1 : 1;
|
|
126
|
-
}
|
|
127
|
-
if (a.extra !== b.extra) {
|
|
128
|
-
return a.extra - b.extra;
|
|
129
|
-
}
|
|
130
|
-
if (a.size !== b.size) {
|
|
131
|
-
return a.size - b.size;
|
|
132
|
-
}
|
|
133
|
-
return a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
|
|
134
|
-
});
|
|
135
|
-
return ranked.map((entry) => ({ name: entry.name, rootPc }));
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/**
|
|
139
|
-
* List a scale's avoid notes over a chord.
|
|
140
|
-
*
|
|
141
|
-
* An avoid note is a non-chord scale tone that lies a semitone directly above a
|
|
142
|
-
* chord tone; sounding it against the chord clashes. The scale is rooted on the
|
|
143
|
-
* chord root. If the scale does not contain the chord, no avoid notes exist.
|
|
144
|
-
*
|
|
145
|
-
* @param chord The chord providing the chord tones.
|
|
146
|
-
* @param scaleName A key of {@link NAMED_SCALES}.
|
|
147
|
-
* @returns The avoid-note pitch classes, sorted ascending in [0, 11].
|
|
148
|
-
*
|
|
149
|
-
* @category Scales
|
|
150
|
-
*/
|
|
151
|
-
export function avoidNotes(chord: Chord, scaleName: string): number[] {
|
|
152
|
-
const mask = NAMED_SCALES[scaleName];
|
|
153
|
-
if (mask === undefined) {
|
|
154
|
-
return [];
|
|
155
|
-
}
|
|
156
|
-
const rootPc = pitchClass(chord.rootPc);
|
|
157
|
-
const chordPcs = chordPitchClasses(chord);
|
|
158
|
-
if (!scaleMatchesChord(chordPcs, mask, rootPc)) {
|
|
159
|
-
return [];
|
|
160
|
-
}
|
|
161
|
-
const chordSet = new Set(chordPcs);
|
|
162
|
-
const avoid: number[] = [];
|
|
163
|
-
for (let pc = 0; pc < 12; pc += 1) {
|
|
164
|
-
if (!maskHasPitchClass(mask, rootPc, pc) || chordSet.has(pc)) {
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
if (chordSet.has(pitchClass(pc - 1))) {
|
|
168
|
-
avoid.push(pc);
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
return avoid.sort((a, b) => a - b);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* List a scale's available tensions over a chord.
|
|
176
|
-
*
|
|
177
|
-
* Available tensions are scale tones that are neither chord tones nor avoid
|
|
178
|
-
* notes, i.e. the usable color tones (typically the 9/11/13 region). The scale
|
|
179
|
-
* is rooted on the chord root. If the scale does not contain the chord, there
|
|
180
|
-
* are no available tensions.
|
|
181
|
-
*
|
|
182
|
-
* @param chord The chord providing the chord tones.
|
|
183
|
-
* @param scaleName A key of {@link NAMED_SCALES}.
|
|
184
|
-
* @returns The available-tension pitch classes, sorted ascending in [0, 11].
|
|
185
|
-
*
|
|
186
|
-
* @example
|
|
187
|
-
* ```ts
|
|
188
|
-
* import { makeChord, availableTensions } from '@libraz/libcantus';
|
|
189
|
-
* availableTensions(makeChord(0, 'maj7'), 'ionian'); // [2, 9] — color tones over Cmaj7
|
|
190
|
-
* ```
|
|
191
|
-
*
|
|
192
|
-
* @category Scales
|
|
193
|
-
*/
|
|
194
|
-
export function availableTensions(chord: Chord, scaleName: string): number[] {
|
|
195
|
-
const mask = NAMED_SCALES[scaleName];
|
|
196
|
-
if (mask === undefined) {
|
|
197
|
-
return [];
|
|
198
|
-
}
|
|
199
|
-
const rootPc = pitchClass(chord.rootPc);
|
|
200
|
-
const chordPcs = chordPitchClasses(chord);
|
|
201
|
-
if (!scaleMatchesChord(chordPcs, mask, rootPc)) {
|
|
202
|
-
return [];
|
|
203
|
-
}
|
|
204
|
-
const chordSet = new Set(chordPcs);
|
|
205
|
-
const avoidSet = new Set(avoidNotes(chord, scaleName));
|
|
206
|
-
const tensions: number[] = [];
|
|
207
|
-
for (let pc = 0; pc < 12; pc += 1) {
|
|
208
|
-
if (!maskHasPitchClass(mask, rootPc, pc) || chordSet.has(pc) || avoidSet.has(pc)) {
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
tensions.push(pc);
|
|
212
|
-
}
|
|
213
|
-
return tensions.sort((a, b) => a - b);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
/**
|
|
217
|
-
* A scale fit over a chord together with its avoid notes and tensions.
|
|
218
|
-
*
|
|
219
|
-
* @category Scales
|
|
220
|
-
*/
|
|
221
|
-
export type ChordScaleReportEntry = {
|
|
222
|
-
name: string;
|
|
223
|
-
rootPc: number;
|
|
224
|
-
avoid: number[];
|
|
225
|
-
tensions: number[];
|
|
226
|
-
};
|
|
227
|
-
|
|
228
|
-
/**
|
|
229
|
-
* Report the best-fitting scales for a chord with their avoid notes and tensions.
|
|
230
|
-
*
|
|
231
|
-
* Combines {@link chordScales}, {@link avoidNotes}, and {@link availableTensions}
|
|
232
|
-
* into a single ergonomic result, ordered best fit first.
|
|
233
|
-
*
|
|
234
|
-
* @param chord The chord to analyze.
|
|
235
|
-
* @param limit Optional maximum number of scales to report; all by default.
|
|
236
|
-
* @returns One entry per reported scale, best fit first.
|
|
237
|
-
*
|
|
238
|
-
* @category Scales
|
|
239
|
-
*/
|
|
240
|
-
export function chordScaleReport(chord: Chord, limit?: number): ChordScaleReportEntry[] {
|
|
241
|
-
const matches = chordScales(chord);
|
|
242
|
-
const chosen = limit === undefined ? matches : matches.slice(0, Math.max(0, limit));
|
|
243
|
-
return chosen.map((match) => ({
|
|
244
|
-
name: match.name,
|
|
245
|
-
rootPc: match.rootPc,
|
|
246
|
-
avoid: avoidNotes(chord, match.name),
|
|
247
|
-
tensions: availableTensions(chord, match.name),
|
|
248
|
-
}));
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
/**
|
|
252
|
-
* A chord paired with the scale chosen for it by {@link scalesForChanges}.
|
|
253
|
-
*
|
|
254
|
-
* @category Scales
|
|
255
|
-
*/
|
|
256
|
-
export type ScaleChoice = {
|
|
257
|
-
chord: Chord;
|
|
258
|
-
scale: ChordScaleMatch;
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Small per-candidate penalty added in {@link scalesForChanges} so that, among
|
|
263
|
-
* choices of otherwise-equal transition cost, the tighter best-fit scale wins.
|
|
264
|
-
*/
|
|
265
|
-
const RANK_PENALTY = 0.01;
|
|
266
|
-
|
|
267
|
-
/** Build the pitch-class set of a named scale rooted on `rootPc`. */
|
|
268
|
-
function scalePitchClassSet(name: string, rootPc: number): Set<number> {
|
|
269
|
-
const mask = NAMED_SCALES[name];
|
|
270
|
-
const pcs = new Set<number>();
|
|
271
|
-
if (mask === undefined) {
|
|
272
|
-
return pcs;
|
|
273
|
-
}
|
|
274
|
-
for (let n = 0; n < 12; n += 1) {
|
|
275
|
-
if (((mask >> n) & 1) === 1) {
|
|
276
|
-
pcs.add(pitchClass(rootPc + n));
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
return pcs;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
/** Count pitch classes that belong to exactly one of two sets. */
|
|
283
|
-
function symmetricDifferenceSize(a: Set<number>, b: Set<number>): number {
|
|
284
|
-
let count = 0;
|
|
285
|
-
for (const pc of a) {
|
|
286
|
-
if (!b.has(pc)) {
|
|
287
|
-
count += 1;
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
for (const pc of b) {
|
|
291
|
-
if (!a.has(pc)) {
|
|
292
|
-
count += 1;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
return count;
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Choose one scale per chord across a progression, favoring continuity.
|
|
300
|
-
*
|
|
301
|
-
* Each chord's {@link chordScales} candidates form a stage in a Viterbi-style
|
|
302
|
-
* dynamic program. The transition cost between adjacent choices is the number
|
|
303
|
-
* of pitch classes that differ between their pitch-class sets (the symmetric
|
|
304
|
-
* difference), plus a small penalty for straying from a chord's best-fit scale
|
|
305
|
-
* so that ties break toward the tighter fit. The minimum-total-cost path is
|
|
306
|
-
* returned, one {@link ScaleChoice} per input chord in the original order.
|
|
307
|
-
*
|
|
308
|
-
* @param chords The chord sequence to choose scales for.
|
|
309
|
-
* @returns One scale choice per chord, in input order.
|
|
310
|
-
*
|
|
311
|
-
* @category Scales
|
|
312
|
-
*/
|
|
313
|
-
export function scalesForChanges(chords: Chord[]): ScaleChoice[] {
|
|
314
|
-
if (chords.length === 0) {
|
|
315
|
-
return [];
|
|
316
|
-
}
|
|
317
|
-
const candidateLists = chords.map((chord) => chordScales(chord));
|
|
318
|
-
const pcSets = candidateLists.map((candidates) =>
|
|
319
|
-
candidates.map((match) => scalePitchClassSet(match.name, match.rootPc)),
|
|
320
|
-
);
|
|
321
|
-
|
|
322
|
-
const first = candidateLists[0] ?? [];
|
|
323
|
-
const dp: number[][] = [first.map((_, j) => j * RANK_PENALTY)];
|
|
324
|
-
const back: number[][] = [first.map(() => -1)];
|
|
325
|
-
|
|
326
|
-
for (let i = 1; i < candidateLists.length; i += 1) {
|
|
327
|
-
const candidates = candidateLists[i] ?? [];
|
|
328
|
-
const prevCosts = dp[i - 1] ?? [];
|
|
329
|
-
const prevSets = pcSets[i - 1] ?? [];
|
|
330
|
-
const curSets = pcSets[i] ?? [];
|
|
331
|
-
const stageCosts: number[] = [];
|
|
332
|
-
const stageBack: number[] = [];
|
|
333
|
-
for (let j = 0; j < candidates.length; j += 1) {
|
|
334
|
-
let bestCost = Number.POSITIVE_INFINITY;
|
|
335
|
-
let bestPrev = -1;
|
|
336
|
-
for (let k = 0; k < prevCosts.length; k += 1) {
|
|
337
|
-
const transition = symmetricDifferenceSize(
|
|
338
|
-
prevSets[k] ?? new Set(),
|
|
339
|
-
curSets[j] ?? new Set(),
|
|
340
|
-
);
|
|
341
|
-
const cost = (prevCosts[k] ?? 0) + transition;
|
|
342
|
-
if (cost < bestCost) {
|
|
343
|
-
bestCost = cost;
|
|
344
|
-
bestPrev = k;
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
stageCosts.push(bestCost + j * RANK_PENALTY);
|
|
348
|
-
stageBack.push(bestPrev);
|
|
349
|
-
}
|
|
350
|
-
dp.push(stageCosts);
|
|
351
|
-
back.push(stageBack);
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const lastCosts = dp[dp.length - 1] ?? [];
|
|
355
|
-
let bestFinal = 0;
|
|
356
|
-
for (let j = 1; j < lastCosts.length; j += 1) {
|
|
357
|
-
if ((lastCosts[j] ?? Number.POSITIVE_INFINITY) < (lastCosts[bestFinal] ?? 0)) {
|
|
358
|
-
bestFinal = j;
|
|
359
|
-
}
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const chosenIndices: number[] = new Array(candidateLists.length);
|
|
363
|
-
let current = bestFinal;
|
|
364
|
-
for (let i = candidateLists.length - 1; i >= 0; i -= 1) {
|
|
365
|
-
chosenIndices[i] = current;
|
|
366
|
-
current = back[i]?.[current] ?? -1;
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
return chords.map((chord, i) => {
|
|
370
|
-
const candidates = candidateLists[i] ?? [];
|
|
371
|
-
const scale = candidates[chosenIndices[i] ?? 0] ?? { name: 'chromatic', rootPc: chord.rootPc };
|
|
372
|
-
return { chord, scale };
|
|
373
|
-
});
|
|
374
|
-
}
|
|
@@ -1,230 +0,0 @@
|
|
|
1
|
-
import { isConsonantInterval } from '../../core/interval/index.js';
|
|
2
|
-
import type { KeyScale } from '../../core/types.js';
|
|
3
|
-
|
|
4
|
-
/** Reduce a MIDI pitch (or pitch class) to a pitch class in [0, 11]. */
|
|
5
|
-
function pitchClass(pitch: number): number {
|
|
6
|
-
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
/**
|
|
10
|
-
* Whether an upper voice has crossed below a lower voice.
|
|
11
|
-
*
|
|
12
|
-
* @param upperPitch Pitch of the nominally higher voice.
|
|
13
|
-
* @param lowerPitch Pitch of the nominally lower voice.
|
|
14
|
-
* @returns True when the upper voice sits below the lower voice.
|
|
15
|
-
* @category Voicing & Counterpoint
|
|
16
|
-
*/
|
|
17
|
-
export function createsVoiceCrossing(upperPitch: number, lowerPitch: number): boolean {
|
|
18
|
-
return upperPitch < lowerPitch;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Whether two simultaneous pitches form a dissonance.
|
|
23
|
-
*
|
|
24
|
-
* @param a First pitch.
|
|
25
|
-
* @param b Second pitch.
|
|
26
|
-
* @param twoVoice When true, the perfect fourth counts as dissonant.
|
|
27
|
-
* @returns True when the vertical interval is dissonant.
|
|
28
|
-
* @category Voicing & Counterpoint
|
|
29
|
-
*/
|
|
30
|
-
export function createsVerticalDissonance(a: number, b: number, twoVoice: boolean): boolean {
|
|
31
|
-
return !isConsonantInterval(a - b, twoVoice);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/**
|
|
35
|
-
* Whether a melodic move is a forbidden leap: a tritone, either seventh (minor
|
|
36
|
-
* or major), or any leap wider than an octave.
|
|
37
|
-
*
|
|
38
|
-
* Operates on the raw absolute distance, so compound leaps are not reduced to
|
|
39
|
-
* their simple class before the octave check.
|
|
40
|
-
*
|
|
41
|
-
* @param prev Starting pitch.
|
|
42
|
-
* @param cur Ending pitch.
|
|
43
|
-
* @returns True when the leap is forbidden in strict counterpoint.
|
|
44
|
-
* @category Voicing & Counterpoint
|
|
45
|
-
*/
|
|
46
|
-
export function isForbiddenMelodicLeap(prev: number, cur: number): boolean {
|
|
47
|
-
const semis = Math.abs(cur - prev);
|
|
48
|
-
return semis === 6 || semis === 10 || semis === 11 || semis > 12;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** Reduce an interval to its simple class in [0, 11]. */
|
|
52
|
-
function simpleClass(semitones: number): number {
|
|
53
|
-
return Math.abs(semitones) % 12;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/** Whether a simple interval class is a perfect kind (unison/octave or fifth). */
|
|
57
|
-
function isPerfectClass(cls: number): boolean {
|
|
58
|
-
return cls === 0 || cls === 7;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Whether two voices move in the same direction (similar or parallel motion). */
|
|
62
|
-
function similarMotion(aMove: number, bMove: number): boolean {
|
|
63
|
-
return aMove !== 0 && bMove !== 0 && aMove > 0 === bMove > 0;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/** Whether both voices actually move (neither is stationary — excludes oblique motion). */
|
|
67
|
-
function bothVoicesMove(aMove: number, bMove: number): boolean {
|
|
68
|
-
return aMove !== 0 && bMove !== 0;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* Whether two voices move into consecutive perfect intervals of the same kind
|
|
73
|
-
* (fifth-to-fifth, octave-to-octave, unison-to-unison).
|
|
74
|
-
*
|
|
75
|
-
* Both true parallels (similar motion) and anti-parallels — the same perfect
|
|
76
|
-
* class reached by contrary motion, e.g. octave to octave with the voices moving
|
|
77
|
-
* in opposite directions — are flagged, as both are forbidden in strict two-voice
|
|
78
|
-
* counterpoint. The rule requires that both voices actually move: oblique motion
|
|
79
|
-
* (either voice stationary) and the no-change case (identical pitches) are excluded.
|
|
80
|
-
*
|
|
81
|
-
* A fifth expanding to a twelfth counts (same perfect class); a fifth moving to
|
|
82
|
-
* an octave does not (different perfect kinds — the direct/hidden case owned by
|
|
83
|
-
* {@link createsHiddenParallelPerfect}).
|
|
84
|
-
*
|
|
85
|
-
* @category Voicing & Counterpoint
|
|
86
|
-
*/
|
|
87
|
-
export function createsParallelPerfect(
|
|
88
|
-
aPrev: number,
|
|
89
|
-
aCur: number,
|
|
90
|
-
bPrev: number,
|
|
91
|
-
bCur: number,
|
|
92
|
-
): boolean {
|
|
93
|
-
if (!bothVoicesMove(aCur - aPrev, bCur - bPrev)) {
|
|
94
|
-
return false;
|
|
95
|
-
}
|
|
96
|
-
const nowClass = simpleClass(aCur - bCur);
|
|
97
|
-
const prevClass = simpleClass(aPrev - bPrev);
|
|
98
|
-
return isPerfectClass(nowClass) && nowClass === prevClass;
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/**
|
|
102
|
-
* Whether two voices move in consecutive parallel octaves (or unisons) by
|
|
103
|
-
* similar motion.
|
|
104
|
-
*
|
|
105
|
-
* This is a strict subset of {@link createsParallelPerfect}: a similar-motion
|
|
106
|
-
* octave-to-octave is the perfect-class-zero case that predicate already flags
|
|
107
|
-
* (and it additionally catches the contrary-motion anti-parallel). Callers that
|
|
108
|
-
* tally parallel violations should therefore use {@link createsParallelPerfect}
|
|
109
|
-
* alone to avoid double counting; this predicate remains for callers wanting a
|
|
110
|
-
* dedicated similar-motion octave test.
|
|
111
|
-
*
|
|
112
|
-
* @category Voicing & Counterpoint
|
|
113
|
-
*/
|
|
114
|
-
export function createsParallelOctave(
|
|
115
|
-
aPrev: number,
|
|
116
|
-
aCur: number,
|
|
117
|
-
bPrev: number,
|
|
118
|
-
bCur: number,
|
|
119
|
-
): boolean {
|
|
120
|
-
if (!similarMotion(aCur - aPrev, bCur - bPrev)) {
|
|
121
|
-
return false;
|
|
122
|
-
}
|
|
123
|
-
return simpleClass(aCur - bCur) === 0 && simpleClass(aPrev - bPrev) === 0;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Whether two voices move in consecutive parallel unisons — both landing on the
|
|
128
|
-
* same pitch, having shared a pitch on the previous move.
|
|
129
|
-
*
|
|
130
|
-
* @category Voicing & Counterpoint
|
|
131
|
-
*/
|
|
132
|
-
export function createsParallelUnison(
|
|
133
|
-
aPrev: number,
|
|
134
|
-
aCur: number,
|
|
135
|
-
bPrev: number,
|
|
136
|
-
bCur: number,
|
|
137
|
-
): boolean {
|
|
138
|
-
if (aCur === aPrev || bCur === bPrev) {
|
|
139
|
-
return false;
|
|
140
|
-
}
|
|
141
|
-
return aCur === bCur && aPrev === bPrev;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/**
|
|
145
|
-
* Whether two voices reach a perfect interval by similar motion from an
|
|
146
|
-
* imperfect one (a hidden/direct fifth or octave).
|
|
147
|
-
*
|
|
148
|
-
* The traditional step exception is applied: the approach is allowed when the
|
|
149
|
-
* upper of the two voices moves by step, so only leaps into the perfect
|
|
150
|
-
* interval are flagged.
|
|
151
|
-
*
|
|
152
|
-
* @category Voicing & Counterpoint
|
|
153
|
-
*/
|
|
154
|
-
export function createsHiddenParallelPerfect(
|
|
155
|
-
aPrev: number,
|
|
156
|
-
aCur: number,
|
|
157
|
-
bPrev: number,
|
|
158
|
-
bCur: number,
|
|
159
|
-
): boolean {
|
|
160
|
-
const aMove = aCur - aPrev;
|
|
161
|
-
const bMove = bCur - bPrev;
|
|
162
|
-
if (!similarMotion(aMove, bMove)) {
|
|
163
|
-
return false;
|
|
164
|
-
}
|
|
165
|
-
const nowClass = simpleClass(aCur - bCur);
|
|
166
|
-
const prevClass = simpleClass(aPrev - bPrev);
|
|
167
|
-
// Approaching the same perfect class (e.g. fifth to fifth) is a true parallel
|
|
168
|
-
// owned by createsParallelPerfect; approaching a different perfect interval
|
|
169
|
-
// (fifth to octave, or vice versa) is the hidden/direct case flagged here.
|
|
170
|
-
if (!isPerfectClass(nowClass) || prevClass === nowClass) {
|
|
171
|
-
return false;
|
|
172
|
-
}
|
|
173
|
-
const upperMove = aCur >= bCur ? aMove : bMove;
|
|
174
|
-
if (Math.abs(upperMove) <= 2) {
|
|
175
|
-
return false; // upper voice moves by step — direct interval is acceptable
|
|
176
|
-
}
|
|
177
|
-
return true;
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
/**
|
|
181
|
-
* Whether two voices overlap: the upper voice descends below where the lower
|
|
182
|
-
* voice just was, or the lower voice rises above where the upper voice just was.
|
|
183
|
-
* Distinct from a simultaneous voice crossing.
|
|
184
|
-
*
|
|
185
|
-
* @param upperPrev Previous pitch of the upper voice.
|
|
186
|
-
* @param upperCur Current pitch of the upper voice.
|
|
187
|
-
* @param lowerPrev Previous pitch of the lower voice.
|
|
188
|
-
* @param lowerCur Current pitch of the lower voice.
|
|
189
|
-
* @category Voicing & Counterpoint
|
|
190
|
-
*/
|
|
191
|
-
export function createsVoiceOverlap(
|
|
192
|
-
upperPrev: number,
|
|
193
|
-
upperCur: number,
|
|
194
|
-
lowerPrev: number,
|
|
195
|
-
lowerCur: number,
|
|
196
|
-
): boolean {
|
|
197
|
-
return upperCur < lowerPrev || lowerCur > upperPrev;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* Whether two adjacent upper voices are spaced more than a maximum apart
|
|
202
|
-
* (commonly an octave). Bass-to-tenor spacing is conventionally exempt, so this
|
|
203
|
-
* is meant for the upper voice pairs.
|
|
204
|
-
*
|
|
205
|
-
* @param upperPitch Pitch of the higher voice.
|
|
206
|
-
* @param lowerPitch Pitch of the lower voice.
|
|
207
|
-
* @param maxSemitones Maximum allowed spacing in semitones (default an octave).
|
|
208
|
-
* @category Voicing & Counterpoint
|
|
209
|
-
*/
|
|
210
|
-
export function exceedsSpacing(upperPitch: number, lowerPitch: number, maxSemitones = 12): boolean {
|
|
211
|
-
return Math.abs(upperPitch - lowerPitch) > maxSemitones;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/**
|
|
215
|
-
* Whether a leading tone resolves correctly upward to the tonic.
|
|
216
|
-
*
|
|
217
|
-
* @param prev The leading-tone pitch.
|
|
218
|
-
* @param cur The following pitch.
|
|
219
|
-
* @param key Key context supplying the tonic.
|
|
220
|
-
* @returns True when `prev` is the leading tone and `cur` is the tonic a step above.
|
|
221
|
-
* @category Voicing & Counterpoint
|
|
222
|
-
*/
|
|
223
|
-
export function isLeadingToneResolution(prev: number, cur: number, key: KeyScale): boolean {
|
|
224
|
-
const tonic = pitchClass(key.rootPc);
|
|
225
|
-
const leading = (tonic + 11) % 12;
|
|
226
|
-
if (pitchClass(prev) !== leading || pitchClass(cur) !== tonic) {
|
|
227
|
-
return false;
|
|
228
|
-
}
|
|
229
|
-
return cur > prev && cur - prev <= 2;
|
|
230
|
-
}
|
|
@@ -1,77 +0,0 @@
|
|
|
1
|
-
import { type Chord, chordToneRole } from '../chord/index.js';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* Harmonic role a pitch plays within a chord.
|
|
5
|
-
*
|
|
6
|
-
* @category Functional Harmony
|
|
7
|
-
*/
|
|
8
|
-
export type HarmonyRole = 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' | 'tension' | 'doubling';
|
|
9
|
-
|
|
10
|
-
/**
|
|
11
|
-
* How firmly a pitch is locked to the chord's identity.
|
|
12
|
-
*
|
|
13
|
-
* - `identity`: moving it produces a different chord (the root).
|
|
14
|
-
* - `quality`: moving it flips the chord quality (the third).
|
|
15
|
-
* - `voicing`: it can move freely without changing chord identity or quality.
|
|
16
|
-
*
|
|
17
|
-
* @category Functional Harmony
|
|
18
|
-
*/
|
|
19
|
-
export type LockLevel = 'identity' | 'quality' | 'voicing';
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* A pitch's role, lock level, and owning chord.
|
|
23
|
-
*
|
|
24
|
-
* @category Functional Harmony
|
|
25
|
-
*/
|
|
26
|
-
export type VoicedRole = {
|
|
27
|
-
role: HarmonyRole;
|
|
28
|
-
lock: LockLevel;
|
|
29
|
-
belongsToChordId: number;
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
/**
|
|
33
|
-
* Classify a pitch's harmonic role and lock level within a chord.
|
|
34
|
-
*
|
|
35
|
-
* The role comes from the pitch's interval class above the chord root: root (0),
|
|
36
|
-
* third (3/4), fifth (6/7/8), seventh (10/11), and 9/11/13 tensions (2/5/9, and
|
|
37
|
-
* the flat ninth 1). The root locks the chord identity, the third locks its
|
|
38
|
-
* quality, and everything else is free voicing. In a suspended chord (a chord
|
|
39
|
-
* whose intervals include a 4th (5) or 2nd (2) but no third), the suspended tone
|
|
40
|
-
* takes the third's place as the quality-defining tone and is locked to
|
|
41
|
-
* `quality`, since moving it changes the chord. Detecting an octave doubling
|
|
42
|
-
* requires the surrounding voicing, which this single-pitch query does not carry,
|
|
43
|
-
* so `doubling` is part of the type but not returned here.
|
|
44
|
-
*
|
|
45
|
-
* @param pitch MIDI pitch or bare pitch class.
|
|
46
|
-
* @param chord The chord providing the root reference.
|
|
47
|
-
* @param chordId Identifier stored on the result (defaults to 0).
|
|
48
|
-
* @returns The pitch's role, lock level, and owning chord id.
|
|
49
|
-
* @example
|
|
50
|
-
* ```ts
|
|
51
|
-
* import { roleOf, makeChord } from '@libraz/libcantus';
|
|
52
|
-
* roleOf(64, makeChord(0, 'maj')); // E over C major
|
|
53
|
-
* // { role: 'third', lock: 'quality', belongsToChordId: 0 }
|
|
54
|
-
* ```
|
|
55
|
-
* @category Functional Harmony
|
|
56
|
-
*/
|
|
57
|
-
export function roleOf(pitch: number, chord: Chord, chordId = 0): VoicedRole {
|
|
58
|
-
const interval = (((Math.trunc(pitch) - chord.rootPc) % 12) + 12) % 12;
|
|
59
|
-
const tones = new Set(chord.intervals.map((i) => ((i % 12) + 12) % 12));
|
|
60
|
-
const hasThird = tones.has(3) || tones.has(4);
|
|
61
|
-
const isSuspendedTone =
|
|
62
|
-
!hasThird && ((interval === 5 && tones.has(5)) || (interval === 2 && tones.has(2)));
|
|
63
|
-
const chordRole = chordToneRole(pitch, chord);
|
|
64
|
-
let role: HarmonyRole;
|
|
65
|
-
let lock: LockLevel;
|
|
66
|
-
if (isSuspendedTone) {
|
|
67
|
-
role = 'third'; // suspended tone occupies the third's quality-defining slot
|
|
68
|
-
lock = 'quality';
|
|
69
|
-
} else if (chordRole !== null) {
|
|
70
|
-
role = chordRole;
|
|
71
|
-
lock = chordRole === 'root' ? 'identity' : chordRole === 'third' ? 'quality' : 'voicing';
|
|
72
|
-
} else {
|
|
73
|
-
role = 'tension';
|
|
74
|
-
lock = 'voicing';
|
|
75
|
-
}
|
|
76
|
-
return { role, lock, belongsToChordId: chordId };
|
|
77
|
-
}
|