@libraz/libcantus 0.9.1 → 0.9.2

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.
Files changed (154) hide show
  1. package/README.md +15 -0
  2. package/dist/analyze/index.cjs +1697 -0
  3. package/dist/analyze/index.cjs.map +1 -0
  4. package/dist/analyze/index.d.cts +429 -0
  5. package/dist/analyze/index.d.ts +429 -0
  6. package/dist/analyze/index.js +51 -0
  7. package/dist/analyze/index.js.map +1 -0
  8. package/dist/chunk-5HTNMQW4.js +653 -0
  9. package/dist/chunk-5HTNMQW4.js.map +1 -0
  10. package/dist/chunk-7Y7WF64D.js +410 -0
  11. package/dist/chunk-7Y7WF64D.js.map +1 -0
  12. package/dist/chunk-A4532ZFR.js +259 -0
  13. package/dist/chunk-A4532ZFR.js.map +1 -0
  14. package/dist/chunk-BHUT2S5G.js +22 -0
  15. package/dist/chunk-BHUT2S5G.js.map +1 -0
  16. package/dist/chunk-CCBLVAPK.js +42 -0
  17. package/dist/chunk-CCBLVAPK.js.map +1 -0
  18. package/dist/chunk-COKTX2G5.js +907 -0
  19. package/dist/chunk-COKTX2G5.js.map +1 -0
  20. package/dist/chunk-DYWFNC4A.js +152 -0
  21. package/dist/chunk-DYWFNC4A.js.map +1 -0
  22. package/dist/chunk-GQKN5KDT.js +159 -0
  23. package/dist/chunk-GQKN5KDT.js.map +1 -0
  24. package/dist/chunk-LN7ZZMD2.js +530 -0
  25. package/dist/chunk-LN7ZZMD2.js.map +1 -0
  26. package/dist/chunk-MSWFZF7J.js +118 -0
  27. package/dist/chunk-MSWFZF7J.js.map +1 -0
  28. package/dist/chunk-R3S2LUBH.js +2722 -0
  29. package/dist/chunk-R3S2LUBH.js.map +1 -0
  30. package/dist/chunk-SDM3VJJ5.js +41 -0
  31. package/dist/chunk-SDM3VJJ5.js.map +1 -0
  32. package/dist/chunk-XTC3X6MS.js +816 -0
  33. package/dist/chunk-XTC3X6MS.js.map +1 -0
  34. package/dist/chunk-ZXW5YIWK.js +55 -0
  35. package/dist/chunk-ZXW5YIWK.js.map +1 -0
  36. package/dist/core/index.cjs +432 -0
  37. package/dist/core/index.cjs.map +1 -0
  38. package/dist/core/index.d.cts +210 -0
  39. package/dist/core/index.d.ts +210 -0
  40. package/dist/core/index.js +73 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/function-2bJW1Zp_.d.ts +187 -0
  43. package/dist/function-r2sh-jOO.d.cts +187 -0
  44. package/dist/generate/index.cjs +3787 -0
  45. package/dist/generate/index.cjs.map +1 -0
  46. package/dist/generate/index.d.cts +980 -0
  47. package/dist/generate/index.d.ts +980 -0
  48. package/dist/generate/index.js +52 -0
  49. package/dist/generate/index.js.map +1 -0
  50. package/dist/index-BMPxwjwV.d.ts +151 -0
  51. package/dist/index-BOBXBNwP.d.cts +157 -0
  52. package/dist/index-BdeQ1r_p.d.cts +56 -0
  53. package/dist/index-BreoJRw0.d.ts +127 -0
  54. package/dist/index-CGMZNCx6.d.cts +140 -0
  55. package/dist/index-CGMZNCx6.d.ts +140 -0
  56. package/dist/index-CM5W4rcm.d.ts +157 -0
  57. package/dist/index-CP3wAfEH.d.ts +56 -0
  58. package/dist/index-Cl7mhOFM.d.cts +164 -0
  59. package/dist/index-Cl7mhOFM.d.ts +164 -0
  60. package/dist/index-DZ1M21do.d.cts +127 -0
  61. package/dist/index-D_ZTUNKp.d.cts +151 -0
  62. package/dist/index.cjs +3016 -2984
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +14 -3982
  65. package/dist/index.d.ts +14 -3982
  66. package/dist/index.js +170 -6504
  67. package/dist/index.js.map +1 -1
  68. package/dist/model/index.cjs +2435 -0
  69. package/dist/model/index.cjs.map +1 -0
  70. package/dist/model/index.d.cts +630 -0
  71. package/dist/model/index.d.ts +630 -0
  72. package/dist/model/index.js +21 -0
  73. package/dist/model/index.js.map +1 -0
  74. package/dist/styled-BjXtOFNk.d.cts +282 -0
  75. package/dist/styled-D7BAx3S3.d.ts +282 -0
  76. package/dist/theory/index.cjs +1677 -0
  77. package/dist/theory/index.cjs.map +1 -0
  78. package/dist/theory/index.d.cts +535 -0
  79. package/dist/theory/index.d.ts +535 -0
  80. package/dist/theory/index.js +149 -0
  81. package/dist/theory/index.js.map +1 -0
  82. package/dist/types-MGP29r3t.d.cts +30 -0
  83. package/dist/types-MGP29r3t.d.ts +30 -0
  84. package/package.json +27 -2
  85. package/src/analyze/arrange/index.ts +21 -0
  86. package/src/analyze/arrange/internal.ts +125 -0
  87. package/src/analyze/arrange/tension.ts +187 -0
  88. package/src/analyze/arrange/tracks.ts +279 -0
  89. package/src/{detect → analyze/detect}/index.ts +4 -4
  90. package/src/analyze/functional/borrowed.ts +84 -0
  91. package/src/analyze/functional/cadence.ts +66 -0
  92. package/src/analyze/functional/function.ts +184 -0
  93. package/src/analyze/functional/index.ts +23 -0
  94. package/src/analyze/functional/internal.ts +29 -0
  95. package/src/{functional/index.ts → analyze/functional/roman.ts} +9 -327
  96. package/src/analyze/index.ts +49 -0
  97. package/src/{timeline → analyze/timeline}/index.ts +7 -11
  98. package/src/{analysis → analyze/voice}/index.ts +8 -5
  99. package/src/core/index.ts +47 -0
  100. package/src/{bass → generate/bass}/index.ts +5 -5
  101. package/src/{countermelody → generate/countermelody}/index.ts +11 -11
  102. package/src/{drums → generate/drums}/index.ts +2 -2
  103. package/src/{drums → generate/drums}/rng.ts +2 -2
  104. package/src/{groove → generate/groove}/index.ts +3 -3
  105. package/src/{harmonize → generate/harmonize}/index.ts +13 -13
  106. package/src/generate/index.ts +59 -0
  107. package/src/{motif → generate/motif}/index.ts +7 -7
  108. package/src/{progression → generate/progression}/index.ts +11 -25
  109. package/src/{reharmony → generate/reharmony}/index.ts +11 -11
  110. package/src/{rhythm → generate/rhythm}/index.ts +2 -2
  111. package/src/index.ts +130 -117
  112. package/src/model/chord.ts +423 -0
  113. package/src/model/index.ts +5 -1096
  114. package/src/model/interval.ts +82 -0
  115. package/src/model/key.ts +266 -0
  116. package/src/model/note.ts +176 -0
  117. package/src/model/progression.ts +161 -0
  118. package/src/model/shared.ts +12 -0
  119. package/src/{chord → theory/chord}/index.ts +19 -1
  120. package/src/{counterpoint → theory/counterpoint}/index.ts +2 -2
  121. package/src/theory/index.ts +107 -0
  122. package/src/{safety → theory/safety}/index.ts +1 -1
  123. package/src/theory/scale/degrees.ts +123 -0
  124. package/src/theory/scale/index.ts +28 -0
  125. package/src/theory/scale/key.ts +56 -0
  126. package/src/theory/scale/masks.ts +153 -0
  127. package/src/{spelling → theory/spelling}/index.ts +3 -3
  128. package/src/{symbol → theory/symbol}/index.ts +8 -1
  129. package/src/theory/voicing/index.ts +5 -0
  130. package/src/theory/voicing/internal.ts +168 -0
  131. package/src/theory/voicing/leading.ts +112 -0
  132. package/src/theory/voicing/satb.ts +195 -0
  133. package/src/theory/voicing/styled.ts +176 -0
  134. package/src/arrange/index.ts +0 -571
  135. package/src/scale/index.ts +0 -331
  136. package/src/voicing/index.ts +0 -623
  137. /package/src/{interval → core/interval}/index.ts +0 -0
  138. /package/src/{meter → core/meter}/index.ts +0 -0
  139. /package/src/{pitch → core/pitch}/index.ts +0 -0
  140. /package/src/{random → core/random}/index.ts +0 -0
  141. /package/src/{tuning → core/tuning}/index.ts +0 -0
  142. /package/src/{types.ts → core/types.ts} +0 -0
  143. /package/src/{drums → generate/drums}/beat.ts +0 -0
  144. /package/src/{drums → generate/drums}/euclid.ts +0 -0
  145. /package/src/{drums → generate/drums}/fills.ts +0 -0
  146. /package/src/{drums → generate/drums}/ghost.ts +0 -0
  147. /package/src/{drums → generate/drums}/hihat.ts +0 -0
  148. /package/src/{drums → generate/drums}/hit.ts +0 -0
  149. /package/src/{drums → generate/drums}/internal.ts +0 -0
  150. /package/src/{drums → generate/drums}/kick.ts +0 -0
  151. /package/src/{drums → generate/drums}/percussion.ts +0 -0
  152. /package/src/{drums → generate/drums}/swing.ts +0 -0
  153. /package/src/{chordscale → theory/chordscale}/index.ts +0 -0
  154. /package/src/{harmony → theory/harmony}/index.ts +0 -0
@@ -0,0 +1,168 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import { chordPitchClasses } from '../chord/index.js';
3
+ import {
4
+ createsParallelPerfect,
5
+ createsVoiceCrossing,
6
+ createsVoiceOverlap,
7
+ exceedsSpacing,
8
+ } from '../counterpoint/index.js';
9
+ import type { VoiceRange } from './satb.js';
10
+
11
+ /** Default maximum spacing between adjacent upper voices (one octave). */
12
+ export const DEFAULT_MAX_SPACING = 12;
13
+
14
+ /** Score penalty per counterpoint violation between consecutive voicings. */
15
+ export const VIOLATION_PENALTY = 1000;
16
+ /** Score penalty per chord tone absent from a voicing. */
17
+ const MISSING_TONE_PENALTY = 500;
18
+ /** Score penalty per doubled tone that is neither the root nor the fifth. */
19
+ const POOR_DOUBLING_PENALTY = 4;
20
+ /** Hard cap on candidate voicings evaluated per chord, keeping the search bounded. */
21
+ const MAX_CANDIDATES = 4000;
22
+
23
+ /** Reduce a value to a pitch class in [0, 11]. */
24
+ export function pitchClass(value: number): number {
25
+ return ((Math.trunc(value) % 12) + 12) % 12;
26
+ }
27
+
28
+ /**
29
+ * All MIDI pitches of a pitch class inside an inclusive range, ordered from the
30
+ * centre of the range outward (ties break toward the lower pitch). Enumerating
31
+ * centre-outward keeps the candidate set balanced around the register when it
32
+ * is truncated at {@link MAX_CANDIDATES}, instead of skewing to the low octaves
33
+ * that a plain ascending scan would visit first.
34
+ */
35
+ function pitchesForPc(pc: number, range: VoiceRange): number[] {
36
+ const result: number[] = [];
37
+ for (let midi = Math.ceil(range.min); midi <= range.max; midi += 1) {
38
+ if (pitchClass(midi) === pc) {
39
+ result.push(midi);
40
+ }
41
+ }
42
+ const center = (range.min + range.max) / 2;
43
+ return result.sort((a, b) => {
44
+ const da = Math.abs(a - center);
45
+ const db = Math.abs(b - center);
46
+ return da === db ? a - b : da - db;
47
+ });
48
+ }
49
+
50
+ /**
51
+ * Enumerate candidate voicings for a chord: the bass takes the chord's bass
52
+ * (or root) pitch class at each available octave, and each upper voice takes
53
+ * any chord pitch class within its range. Candidates are built in ascending
54
+ * voice order and pruned to exclude voice crossings and over-wide adjacent
55
+ * spacing (the bass–tenor pair is allowed an extra octave, per convention).
56
+ * Enumeration is deterministic and capped at {@link MAX_CANDIDATES}.
57
+ */
58
+ export function enumerateVoicings(
59
+ chord: Chord,
60
+ ranges: VoiceRange[],
61
+ maxSpacing: number,
62
+ ): number[][] {
63
+ const chordPcs = chordPitchClasses(chord);
64
+ const bassPc = pitchClass(chord.bassPc ?? chord.rootPc);
65
+ const results: number[][] = [];
66
+ const current: number[] = [];
67
+ const build = (voice: number): void => {
68
+ if (results.length >= MAX_CANDIDATES) {
69
+ return;
70
+ }
71
+ if (voice === ranges.length) {
72
+ results.push([...current]);
73
+ return;
74
+ }
75
+ const range = ranges[voice];
76
+ if (range === undefined) {
77
+ return;
78
+ }
79
+ const pcs = voice === 0 ? [bassPc] : chordPcs;
80
+ const prev = current[voice - 1];
81
+ for (const pc of pcs) {
82
+ for (const pitch of pitchesForPc(pc, range)) {
83
+ if (prev !== undefined) {
84
+ if (pitch < prev) {
85
+ continue; // would cross below the next lower voice
86
+ }
87
+ const spacingLimit = voice === 1 ? maxSpacing + 12 : maxSpacing;
88
+ if (pitch - prev > spacingLimit) {
89
+ continue;
90
+ }
91
+ }
92
+ current.push(pitch);
93
+ build(voice + 1);
94
+ current.pop();
95
+ }
96
+ }
97
+ };
98
+ build(0);
99
+ return results;
100
+ }
101
+
102
+ /**
103
+ * Structural quality penalty of a single voicing: heavily penalize missing
104
+ * chord tones, mildly penalize doubling anything other than the root or fifth.
105
+ */
106
+ export function structuralPenalty(pitches: number[], chord: Chord): number {
107
+ const counts = new Map<number, number>();
108
+ for (const pitch of pitches) {
109
+ const pc = pitchClass(pitch);
110
+ counts.set(pc, (counts.get(pc) ?? 0) + 1);
111
+ }
112
+ let penalty = 0;
113
+ for (const pc of chordPitchClasses(chord)) {
114
+ if (!counts.has(pc)) {
115
+ penalty += MISSING_TONE_PENALTY;
116
+ }
117
+ }
118
+ const rootPc = pitchClass(chord.rootPc);
119
+ const fifthPc = pitchClass(chord.rootPc + 7);
120
+ for (const [pc, count] of counts) {
121
+ if (count > 1 && pc !== rootPc && pc !== fifthPc) {
122
+ penalty += (count - 1) * POOR_DOUBLING_PENALTY;
123
+ }
124
+ }
125
+ return penalty;
126
+ }
127
+
128
+ /**
129
+ * Count counterpoint violations between two consecutive voicings of equal
130
+ * length: parallel perfects (fifths and octaves alike, since octaves are the
131
+ * perfect-class-zero case of {@link createsParallelPerfect}) on every voice
132
+ * pair, voice crossings in the new voicing, and — on adjacent pairs — voice
133
+ * overlaps and over-wide spacing between upper voices.
134
+ */
135
+ export function violationCount(prev: number[], cur: number[], maxSpacing: number): number {
136
+ let count = 0;
137
+ for (let lower = 0; lower < cur.length; lower += 1) {
138
+ for (let upper = lower + 1; upper < cur.length; upper += 1) {
139
+ const prevLower = prev[lower];
140
+ const prevUpper = prev[upper];
141
+ const curLower = cur[lower];
142
+ const curUpper = cur[upper];
143
+ if (
144
+ prevLower === undefined ||
145
+ prevUpper === undefined ||
146
+ curLower === undefined ||
147
+ curUpper === undefined
148
+ ) {
149
+ continue;
150
+ }
151
+ if (createsParallelPerfect(prevUpper, curUpper, prevLower, curLower)) {
152
+ count += 1;
153
+ }
154
+ if (createsVoiceCrossing(curUpper, curLower)) {
155
+ count += 1;
156
+ }
157
+ if (upper === lower + 1) {
158
+ if (createsVoiceOverlap(prevUpper, curUpper, prevLower, curLower)) {
159
+ count += 1;
160
+ }
161
+ if (lower > 0 && exceedsSpacing(curUpper, curLower, maxSpacing)) {
162
+ count += 1;
163
+ }
164
+ }
165
+ }
166
+ }
167
+ return count;
168
+ }
@@ -0,0 +1,112 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import { createsHiddenParallelPerfect } from '../counterpoint/index.js';
3
+ import {
4
+ DEFAULT_MAX_SPACING,
5
+ enumerateVoicings,
6
+ structuralPenalty,
7
+ VIOLATION_PENALTY,
8
+ violationCount,
9
+ } from './internal.js';
10
+ import type { VoicingOptions } from './satb.js';
11
+ import { resolveRanges } from './satb.js';
12
+
13
+ /**
14
+ * Moderate penalty for a hidden/direct perfect fifth or octave reached on the
15
+ * outer-voice (bass–soprano) pair. Unlike a true parallel perfect it is
16
+ * discouraged rather than forbidden, so the weight sits alongside voice-leading
17
+ * motion rather than the hard {@link VIOLATION_PENALTY}.
18
+ */
19
+ const HIDDEN_PERFECT_PENALTY = 6;
20
+
21
+ /**
22
+ * Total voice-leading cost between two voicings: the sum of absolute semitone
23
+ * motion across voices, plus a moderate {@link HIDDEN_PERFECT_PENALTY} when the
24
+ * outer-voice (bass–soprano) pair reaches a hidden/direct perfect fifth or
25
+ * octave by similar motion. The arrays must be the same length; when they
26
+ * differ the voicings are not comparable and the cost is `Infinity`.
27
+ *
28
+ * @param from The previous voicing, one MIDI pitch per voice.
29
+ * @param to The next voicing, one MIDI pitch per voice.
30
+ * @returns The summed absolute motion (plus any hidden-perfect penalty), or
31
+ * `Infinity` when lengths differ.
32
+ * @category Voicing & Counterpoint
33
+ */
34
+ export function voiceLeadingCost(from: number[], to: number[]): number {
35
+ if (from.length !== to.length) {
36
+ return Number.POSITIVE_INFINITY;
37
+ }
38
+ let total = 0;
39
+ for (let i = 0; i < from.length; i += 1) {
40
+ const a = from[i];
41
+ const b = to[i];
42
+ if (a === undefined || b === undefined) {
43
+ continue;
44
+ }
45
+ total += Math.abs(b - a);
46
+ }
47
+ // Discourage hidden/direct perfects between the outermost voices, where they
48
+ // are most audible. True parallels are handled (and forbidden) elsewhere.
49
+ if (from.length >= 2) {
50
+ const bassPrev = from[0];
51
+ const bassCur = to[0];
52
+ const sopPrev = from[from.length - 1];
53
+ const sopCur = to[to.length - 1];
54
+ if (
55
+ bassPrev !== undefined &&
56
+ bassCur !== undefined &&
57
+ sopPrev !== undefined &&
58
+ sopCur !== undefined &&
59
+ createsHiddenParallelPerfect(bassPrev, bassCur, sopPrev, sopCur)
60
+ ) {
61
+ total += HIDDEN_PERFECT_PENALTY;
62
+ }
63
+ }
64
+ return total;
65
+ }
66
+
67
+ /**
68
+ * Voice a single chord to follow smoothly from an arbitrary current voicing.
69
+ *
70
+ * Candidate voicings of `chord` are enumerated within ranges taken from `opts`,
71
+ * or — when neither `voices` nor `ranges` is given — from a one-octave window
72
+ * around each pitch of `current` (so the result matches `current`'s voice
73
+ * count). Each candidate is scored by structural quality, voice-leading motion
74
+ * from `current`, and a large penalty per counterpoint violation; the lowest
75
+ * scoring candidate is returned, ascending.
76
+ *
77
+ * @param current The current voicing to lead from, ascending (index 0 = lowest).
78
+ * @param chord The next chord to voice.
79
+ * @param opts Voicing options; when omitted, ranges follow `current`'s span.
80
+ * @returns The chosen voicing, ascending with one MIDI pitch per voice.
81
+ * @throws If no voicing fits the derived ranges.
82
+ * @category Voicing & Counterpoint
83
+ */
84
+ export function nextVoicing(current: number[], chord: Chord, opts?: VoicingOptions): number[] {
85
+ const ranges =
86
+ opts?.ranges !== undefined || opts?.voices !== undefined
87
+ ? resolveRanges(opts)
88
+ : current.map((pitch) => {
89
+ // Window one octave around each current pitch, clamped so extreme-low
90
+ // or extreme-high input can never yield MIDI outside [0, 127].
91
+ const centre = Math.min(127, Math.max(0, pitch));
92
+ return { min: Math.max(0, centre - 12), max: Math.min(127, centre + 12) };
93
+ });
94
+ const maxSpacing = opts?.maxSpacing ?? DEFAULT_MAX_SPACING;
95
+ const candidates = enumerateVoicings(chord, ranges, maxSpacing);
96
+ let best: number[] | undefined;
97
+ let bestScore = Number.POSITIVE_INFINITY;
98
+ for (const candidate of candidates) {
99
+ const score =
100
+ structuralPenalty(candidate, chord) +
101
+ voiceLeadingCost(current, candidate) +
102
+ VIOLATION_PENALTY * violationCount(current, candidate, maxSpacing);
103
+ if (score < bestScore) {
104
+ bestScore = score;
105
+ best = candidate;
106
+ }
107
+ }
108
+ if (best === undefined) {
109
+ throw new Error('no voicing satisfies the given ranges');
110
+ }
111
+ return best;
112
+ }
@@ -0,0 +1,195 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import {
3
+ DEFAULT_MAX_SPACING,
4
+ enumerateVoicings,
5
+ structuralPenalty,
6
+ VIOLATION_PENALTY,
7
+ violationCount,
8
+ } from './internal.js';
9
+ import { voiceLeadingCost } from './leading.js';
10
+
11
+ /**
12
+ * An inclusive MIDI pitch range for a single voice.
13
+ *
14
+ * @category Voicing & Counterpoint
15
+ */
16
+ export type VoiceRange = {
17
+ min: number;
18
+ max: number;
19
+ };
20
+
21
+ /**
22
+ * Default four-voice SATB ranges, ascending (index 0 = lowest):
23
+ * bass E2–C4 (40–60), tenor C3–G4 (48–67), alto G3–D5 (55–74),
24
+ * soprano C4–G5 (60–79).
25
+ *
26
+ * @category Voicing & Counterpoint
27
+ */
28
+ export const SATB_RANGES: readonly VoiceRange[] = [
29
+ { min: 40, max: 60 },
30
+ { min: 48, max: 67 },
31
+ { min: 55, max: 74 },
32
+ { min: 60, max: 79 },
33
+ ];
34
+
35
+ /**
36
+ * Options controlling {@link voiceChord} and {@link voiceProgression}.
37
+ *
38
+ * @category Voicing & Counterpoint
39
+ */
40
+ export type VoicingOptions = {
41
+ /**
42
+ * Number of voices to realize. Ignored when `ranges` is given.
43
+ *
44
+ * @defaultValue 4
45
+ */
46
+ voices?: number;
47
+ /** Explicit per-voice ranges, ascending (index 0 = lowest). Takes precedence over `voices`. */
48
+ ranges?: VoiceRange[];
49
+ /**
50
+ * Maximum spacing in semitones between adjacent upper voices.
51
+ *
52
+ * @defaultValue 12
53
+ */
54
+ maxSpacing?: number;
55
+ };
56
+
57
+ /** Overall pitch floor/ceiling used when deriving ranges for arbitrary voice counts. */
58
+ const DERIVED_LOW = 40;
59
+ const DERIVED_HIGH = 79;
60
+ /** Span of each derived per-voice range in semitones. */
61
+ const DERIVED_SPAN = 19;
62
+
63
+ /**
64
+ * Resolve the per-voice ranges implied by the options: explicit `ranges` win,
65
+ * four voices use {@link SATB_RANGES}, and other counts get evenly spaced
66
+ * ranges spanning roughly the bass-to-soprano compass.
67
+ */
68
+ export function resolveRanges(opts?: VoicingOptions): VoiceRange[] {
69
+ if (opts?.ranges !== undefined) {
70
+ if (opts.ranges.length === 0) {
71
+ throw new Error('ranges must contain at least one voice range');
72
+ }
73
+ return opts.ranges.map((range) => ({ ...range }));
74
+ }
75
+ const voices = opts?.voices ?? 4;
76
+ if (voices < 1) {
77
+ throw new Error('voices must be at least 1');
78
+ }
79
+ if (voices === 4) {
80
+ return SATB_RANGES.map((range) => ({ ...range }));
81
+ }
82
+ if (voices === 1) {
83
+ return [{ min: DERIVED_LOW, max: DERIVED_HIGH }];
84
+ }
85
+ const ranges: VoiceRange[] = [];
86
+ for (let i = 0; i < voices; i += 1) {
87
+ const min = Math.round(
88
+ DERIVED_LOW + (i * (DERIVED_HIGH - DERIVED_SPAN - DERIVED_LOW)) / (voices - 1),
89
+ );
90
+ ranges.push({ min, max: min + DERIVED_SPAN });
91
+ }
92
+ return ranges;
93
+ }
94
+
95
+ /**
96
+ * Realize a single chord as one MIDI pitch per voice, ascending (index 0 =
97
+ * lowest). The bass voice takes the chord's `bassPc` when set, otherwise the
98
+ * root; upper voices take chord pitch classes, doubling the root or fifth as
99
+ * needed to fill all voices. The result stays inside each voice's range, keeps
100
+ * adjacent upper voices within `maxSpacing`, avoids voice crossing, and is
101
+ * deterministic: a compact close-position voicing centered in the ranges.
102
+ *
103
+ * @param chord The chord to voice.
104
+ * @param opts Voicing options; defaults to four voices in {@link SATB_RANGES}.
105
+ * @returns MIDI pitches, ascending, one per voice.
106
+ * @throws If no voicing fits the given ranges.
107
+ * @example
108
+ * ```ts
109
+ * import { parseChordSymbol, voiceChord } from '@libraz/libcantus';
110
+ * const chord = parseChordSymbol('Cmaj7');
111
+ * voiceChord(chord); // four ascending MIDI pitches within the SATB ranges
112
+ * ```
113
+ * @category Voicing & Counterpoint
114
+ */
115
+ export function voiceChord(chord: Chord, opts?: VoicingOptions): number[] {
116
+ const ranges = resolveRanges(opts);
117
+ const maxSpacing = opts?.maxSpacing ?? DEFAULT_MAX_SPACING;
118
+ const candidates = enumerateVoicings(chord, ranges, maxSpacing);
119
+ let best: number[] | undefined;
120
+ let bestScore = Number.POSITIVE_INFINITY;
121
+ for (const candidate of candidates) {
122
+ let score = structuralPenalty(candidate, chord);
123
+ for (let i = 0; i < candidate.length; i += 1) {
124
+ const pitch = candidate[i];
125
+ const range = ranges[i];
126
+ if (pitch === undefined || range === undefined) {
127
+ continue;
128
+ }
129
+ // Prefer pitches near the middle of each voice's range for a centered,
130
+ // compact default voicing.
131
+ score += Math.abs(pitch - (range.min + range.max) / 2);
132
+ }
133
+ if (score < bestScore) {
134
+ bestScore = score;
135
+ best = candidate;
136
+ }
137
+ }
138
+ if (best === undefined) {
139
+ throw new Error('no voicing satisfies the given ranges');
140
+ }
141
+ return best;
142
+ }
143
+
144
+ /**
145
+ * Voice a chord progression with smooth voice leading. The first chord is
146
+ * voiced with {@link voiceChord}; each subsequent chord picks, from a bounded
147
+ * deterministic candidate set, the voicing minimizing the voice-leading cost
148
+ * from the previous voicing plus a large penalty per counterpoint violation
149
+ * (parallel perfects/octaves, voice crossing, voice overlap, and over-wide
150
+ * upper-voice spacing).
151
+ *
152
+ * @param chords The chords to voice in order.
153
+ * @param opts Voicing options; defaults to four voices in {@link SATB_RANGES}.
154
+ * @returns One voicing per chord, each ascending with one MIDI pitch per voice.
155
+ * @throws If any chord admits no voicing within the given ranges.
156
+ * @example
157
+ * ```ts
158
+ * import { parseChordSymbol, voiceProgression } from '@libraz/libcantus';
159
+ * const chords = ['C', 'Am', 'F', 'G'].map((s) => parseChordSymbol(s));
160
+ * voiceProgression(chords); // one four-voice voicing per chord, smoothly led
161
+ * ```
162
+ * @category Voicing & Counterpoint
163
+ */
164
+ export function voiceProgression(chords: Chord[], opts?: VoicingOptions): number[][] {
165
+ const ranges = resolveRanges(opts);
166
+ const maxSpacing = opts?.maxSpacing ?? DEFAULT_MAX_SPACING;
167
+ const result: number[][] = [];
168
+ let prev: number[] | undefined;
169
+ for (const chord of chords) {
170
+ if (prev === undefined) {
171
+ prev = voiceChord(chord, opts);
172
+ result.push(prev);
173
+ continue;
174
+ }
175
+ const candidates = enumerateVoicings(chord, ranges, maxSpacing);
176
+ let best: number[] | undefined;
177
+ let bestScore = Number.POSITIVE_INFINITY;
178
+ for (const candidate of candidates) {
179
+ const score =
180
+ structuralPenalty(candidate, chord) +
181
+ voiceLeadingCost(prev, candidate) +
182
+ VIOLATION_PENALTY * violationCount(prev, candidate, maxSpacing);
183
+ if (score < bestScore) {
184
+ bestScore = score;
185
+ best = candidate;
186
+ }
187
+ }
188
+ if (best === undefined) {
189
+ throw new Error('no voicing satisfies the given ranges');
190
+ }
191
+ result.push(best);
192
+ prev = best;
193
+ }
194
+ return result;
195
+ }
@@ -0,0 +1,176 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import { chordToneRole } from '../chord/index.js';
3
+ import { pitchClass } from './internal.js';
4
+
5
+ /**
6
+ * A tertian voicing style for {@link voiceChordStyled}.
7
+ *
8
+ * - `close`: the plain close-position tertian stack.
9
+ * - `drop2`: the second voice from the top dropped an octave (drop-2 voicing).
10
+ * - `drop3`: the third voice from the top dropped an octave (drop-3 voicing).
11
+ * - `shell`: root plus guide tones (third and seventh) for seventh chords, or
12
+ * root/third/fifth for triads; the fifth and tensions are omitted.
13
+ * - `rootless`: the root omitted, keeping third/fifth/seventh and tensions
14
+ * (a typical left-hand jazz voicing).
15
+ *
16
+ * @category Voicing & Counterpoint
17
+ */
18
+ export type VoicingStyle = 'close' | 'drop2' | 'drop3' | 'shell' | 'rootless';
19
+
20
+ /**
21
+ * Options controlling {@link voiceChordStyled}.
22
+ *
23
+ * @category Voicing & Counterpoint
24
+ */
25
+ export type StyledVoicingOptions = {
26
+ /**
27
+ * Voicing style to build.
28
+ *
29
+ * @defaultValue 'close'
30
+ */
31
+ style?: VoicingStyle;
32
+ /** Constrain the highest voice to this pitch class (0..11) when given. */
33
+ topNote?: number;
34
+ /**
35
+ * Base octave for the close stack; the stack begins near `12 * octave`.
36
+ *
37
+ * @defaultValue 4
38
+ */
39
+ octave?: number;
40
+ /**
41
+ * Omit the root regardless of style (relevant for jazz voicings).
42
+ *
43
+ * @defaultValue false
44
+ */
45
+ rootless?: boolean;
46
+ };
47
+
48
+ /** Default base octave for a styled close-position stack. */
49
+ const DEFAULT_STYLE_OCTAVE = 4;
50
+
51
+ /** Return the pitch classes of a list in order, without duplicates. */
52
+ function dedupePcs(pcs: number[]): number[] {
53
+ const seen = new Set<number>();
54
+ const result: number[] = [];
55
+ for (const pc of pcs) {
56
+ if (!seen.has(pc)) {
57
+ seen.add(pc);
58
+ result.push(pc);
59
+ }
60
+ }
61
+ return result;
62
+ }
63
+
64
+ /** The lowest MIDI pitch of a pitch class at or above a floor. */
65
+ function lowestPitchAtOrAbove(pc: number, floor: number): number {
66
+ return floor + ((((pc - pitchClass(floor)) % 12) + 12) % 12);
67
+ }
68
+
69
+ /** The pitch class in `pcs` whose circular distance to `target` is smallest. */
70
+ function nearestPc(target: number, pcs: number[]): number {
71
+ let best = pcs[0] ?? target;
72
+ let bestDist = 12;
73
+ for (const pc of pcs) {
74
+ const dist = Math.min((((pc - target) % 12) + 12) % 12, (((target - pc) % 12) + 12) % 12);
75
+ if (dist < bestDist) {
76
+ bestDist = dist;
77
+ best = pc;
78
+ }
79
+ }
80
+ return best;
81
+ }
82
+
83
+ /**
84
+ * Realize a single chord as an explicit tertian voicing in a chosen style,
85
+ * independent of the SATB range search used by {@link voiceChord}.
86
+ *
87
+ * The chord tones are stacked in close position from the bass (`bassPc`, else
88
+ * `rootPc`) in the given scientific-pitch `octave` upward (octave 4 starts the
89
+ * bass near C4 = MIDI 60, matching {@link midiToNote} and the bass module).
90
+ * When `topNote` is given the stack is rotated so the highest voice's pitch
91
+ * class equals that note (or the nearest chord tone). The chosen
92
+ * {@link VoicingStyle} then transforms the stack, and the result is returned as
93
+ * ascending MIDI pitches (index 0 = lowest).
94
+ *
95
+ * @param chord The chord to voice.
96
+ * @param opts Styled voicing options; defaults to a close voicing at octave 4.
97
+ * @returns MIDI pitches, ascending, one per retained voice.
98
+ * @example
99
+ * ```ts
100
+ * import { parseChordSymbol, voiceChordStyled } from '@libraz/libcantus';
101
+ * const chord = parseChordSymbol('Dm7');
102
+ * voiceChordStyled(chord, { style: 'drop2' }); // ascending MIDI pitches, drop-2 voicing
103
+ * ```
104
+ * @category Voicing & Counterpoint
105
+ */
106
+ export function voiceChordStyled(chord: Chord, opts?: StyledVoicingOptions): number[] {
107
+ const style = opts?.style ?? 'close';
108
+ const base = 12 * ((opts?.octave ?? DEFAULT_STYLE_OCTAVE) + 1);
109
+ const rootPc = pitchClass(chord.rootPc);
110
+ const bassPc = pitchClass(chord.bassPc ?? chord.rootPc);
111
+ const omitRoot = style === 'rootless' || opts?.rootless === true;
112
+
113
+ // Tertian chord tones in order, with the bass tone rotated to the bottom.
114
+ let order = dedupePcs(chord.intervals.map((interval) => pitchClass(chord.rootPc + interval)));
115
+ const bassIndex = order.indexOf(bassPc);
116
+ if (bassIndex > 0) {
117
+ order = [...order.slice(bassIndex), ...order.slice(0, bassIndex)];
118
+ } else if (bassIndex < 0) {
119
+ order = [bassPc, ...order];
120
+ }
121
+
122
+ if (style === 'shell') {
123
+ const roles = order.map((pc) => chordToneRole(pc, chord));
124
+ // The guide tone is the seventh when present; on a sixth chord the sixth
125
+ // stands in for it, and otherwise the voicing falls back to the fifth.
126
+ const guide = roles.includes('seventh')
127
+ ? 'seventh'
128
+ : roles.includes('sixth')
129
+ ? 'sixth'
130
+ : 'fifth';
131
+ order = order.filter((_pc, i) => {
132
+ const role = roles[i];
133
+ return role === 'root' || role === 'third' || role === guide;
134
+ });
135
+ }
136
+
137
+ if (omitRoot) {
138
+ order = order.filter((pc) => pc !== rootPc);
139
+ }
140
+
141
+ if (order.length === 0) {
142
+ return [];
143
+ }
144
+
145
+ if (opts?.topNote !== undefined) {
146
+ const target = order.includes(pitchClass(opts.topNote))
147
+ ? pitchClass(opts.topNote)
148
+ : nearestPc(pitchClass(opts.topNote), order);
149
+ const targetIndex = order.indexOf(target);
150
+ order = [...order.slice(targetIndex + 1), ...order.slice(0, targetIndex + 1)];
151
+ }
152
+
153
+ // Stack the ordered pitch classes upward in close position.
154
+ const stack: number[] = [];
155
+ let prev: number | undefined;
156
+ for (const pc of order) {
157
+ if (prev === undefined) {
158
+ prev = lowestPitchAtOrAbove(pc, base);
159
+ } else {
160
+ let delta = (((pc - pitchClass(prev)) % 12) + 12) % 12;
161
+ if (delta === 0) {
162
+ delta = 12;
163
+ }
164
+ prev += delta;
165
+ }
166
+ stack.push(prev);
167
+ }
168
+
169
+ if (style === 'drop2' && stack.length >= 2) {
170
+ stack[stack.length - 2] = (stack[stack.length - 2] ?? 0) - 12;
171
+ } else if (style === 'drop3' && stack.length >= 3) {
172
+ stack[stack.length - 3] = (stack[stack.length - 3] ?? 0) - 12;
173
+ }
174
+
175
+ return stack.sort((a, b) => a - b);
176
+ }