@libraz/libcantus 0.9.2 → 0.9.4

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 (200) hide show
  1. package/README.md +6 -5
  2. package/dist/analyze/index.cjs +41 -1676
  3. package/dist/analyze/index.cjs.map +1 -1
  4. package/dist/analyze/index.d.cts +194 -122
  5. package/dist/analyze/index.d.ts +194 -122
  6. package/dist/analyze/index.js +19 -8
  7. package/dist/chunk-36N73KZ3.cjs +246 -0
  8. package/dist/chunk-36N73KZ3.cjs.map +1 -0
  9. package/dist/chunk-4HLLZPXT.cjs +3077 -0
  10. package/dist/chunk-4HLLZPXT.cjs.map +1 -0
  11. package/dist/chunk-7MGZ7D62.cjs +498 -0
  12. package/dist/chunk-7MGZ7D62.cjs.map +1 -0
  13. package/dist/{chunk-R3S2LUBH.js → chunk-7NKDPF74.js} +660 -305
  14. package/dist/chunk-7NKDPF74.js.map +1 -0
  15. package/dist/chunk-CAN76NCU.cjs +679 -0
  16. package/dist/chunk-CAN76NCU.cjs.map +1 -0
  17. package/dist/{chunk-7Y7WF64D.js → chunk-D2FAJBYL.js} +104 -35
  18. package/dist/chunk-D2FAJBYL.js.map +1 -0
  19. package/dist/{chunk-COKTX2G5.js → chunk-EHVEBWM5.js} +1052 -510
  20. package/dist/chunk-EHVEBWM5.js.map +1 -0
  21. package/dist/chunk-FCANXBAT.cjs +30 -0
  22. package/dist/chunk-FCANXBAT.cjs.map +1 -0
  23. package/dist/chunk-FNDWXRRI.cjs +479 -0
  24. package/dist/chunk-FNDWXRRI.cjs.map +1 -0
  25. package/dist/{chunk-GQKN5KDT.js → chunk-GQSGS2N4.js} +43 -29
  26. package/dist/chunk-GQSGS2N4.js.map +1 -0
  27. package/dist/chunk-HKGYCQ34.js +53 -0
  28. package/dist/chunk-HKGYCQ34.js.map +1 -0
  29. package/dist/chunk-I24MV65U.js +106 -0
  30. package/dist/chunk-I24MV65U.js.map +1 -0
  31. package/dist/{chunk-LN7ZZMD2.js → chunk-KMHOICIY.js} +322 -173
  32. package/dist/chunk-KMHOICIY.js.map +1 -0
  33. package/dist/chunk-LW53AHQU.cjs +1117 -0
  34. package/dist/chunk-LW53AHQU.cjs.map +1 -0
  35. package/dist/chunk-N6IHQUOX.js +1 -0
  36. package/dist/chunk-N6IHQUOX.js.map +1 -0
  37. package/dist/chunk-NKIBQ6PT.js +30 -0
  38. package/dist/chunk-NKIBQ6PT.js.map +1 -0
  39. package/dist/chunk-NP4NA2FB.cjs +106 -0
  40. package/dist/chunk-NP4NA2FB.cjs.map +1 -0
  41. package/dist/chunk-NVE34YGZ.cjs +1449 -0
  42. package/dist/chunk-NVE34YGZ.cjs.map +1 -0
  43. package/dist/{chunk-A4532ZFR.js → chunk-NYYWMV2C.js} +115 -47
  44. package/dist/chunk-NYYWMV2C.js.map +1 -0
  45. package/dist/{chunk-5HTNMQW4.js → chunk-Q2XUP227.js} +310 -118
  46. package/dist/chunk-Q2XUP227.js.map +1 -0
  47. package/dist/chunk-Q6OP4U43.cjs +327 -0
  48. package/dist/chunk-Q6OP4U43.cjs.map +1 -0
  49. package/dist/chunk-RC6GBMCD.cjs +845 -0
  50. package/dist/chunk-RC6GBMCD.cjs.map +1 -0
  51. package/dist/{chunk-XTC3X6MS.js → chunk-RK35RIN7.js} +453 -152
  52. package/dist/chunk-RK35RIN7.js.map +1 -0
  53. package/dist/chunk-RUMU3UA6.cjs +53 -0
  54. package/dist/chunk-RUMU3UA6.cjs.map +1 -0
  55. package/dist/chunk-T64C7ZD7.cjs +1 -0
  56. package/dist/chunk-T64C7ZD7.cjs.map +1 -0
  57. package/dist/chunk-XEBG3G53.js +498 -0
  58. package/dist/chunk-XEBG3G53.js.map +1 -0
  59. package/dist/chunk-YLEMGEFH.js +246 -0
  60. package/dist/chunk-YLEMGEFH.js.map +1 -0
  61. package/dist/chunk-ZEJR4TKL.cjs +173 -0
  62. package/dist/chunk-ZEJR4TKL.cjs.map +1 -0
  63. package/dist/core/index.cjs +130 -431
  64. package/dist/core/index.cjs.map +1 -1
  65. package/dist/core/index.d.cts +331 -17
  66. package/dist/core/index.d.ts +331 -17
  67. package/dist/core/index.js +71 -13
  68. package/dist/{function-2bJW1Zp_.d.ts → function-BtiZb9i9.d.ts} +28 -7
  69. package/dist/{function-r2sh-jOO.d.cts → function-DNugAOrt.d.cts} +28 -7
  70. package/dist/generate/index.cjs +30 -3751
  71. package/dist/generate/index.cjs.map +1 -1
  72. package/dist/generate/index.d.cts +373 -59
  73. package/dist/generate/index.d.ts +373 -59
  74. package/dist/generate/index.js +23 -9
  75. package/dist/{index-DZ1M21do.d.cts → index-6VR8-kit.d.ts} +20 -4
  76. package/dist/index-70p168VM.d.ts +192 -0
  77. package/dist/{index-D_ZTUNKp.d.cts → index-Be74mcon.d.cts} +18 -17
  78. package/dist/index-BtXT6UQ0.d.cts +192 -0
  79. package/dist/{index-BMPxwjwV.d.ts → index-C5UBbQ4G.d.ts} +18 -17
  80. package/dist/{index-BOBXBNwP.d.cts → index-C83mZI1P.d.ts} +93 -8
  81. package/dist/{index-BdeQ1r_p.d.cts → index-D6b6na69.d.cts} +14 -11
  82. package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
  83. package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
  84. package/dist/index-OG65ocOr.d.cts +272 -0
  85. package/dist/index-OG65ocOr.d.ts +272 -0
  86. package/dist/{index-BreoJRw0.d.ts → index-tJhkmQgE.d.cts} +20 -4
  87. package/dist/{index-CP3wAfEH.d.ts → index-tSQh-xwY.d.ts} +14 -11
  88. package/dist/{index-CM5W4rcm.d.ts → index-y_ak9_Hi.d.cts} +93 -8
  89. package/dist/index.cjs +419 -6848
  90. package/dist/index.cjs.map +1 -1
  91. package/dist/index.d.cts +14 -13
  92. package/dist/index.d.ts +14 -13
  93. package/dist/index.js +132 -27
  94. package/dist/model/index.cjs +8 -2423
  95. package/dist/model/index.cjs.map +1 -1
  96. package/dist/model/index.d.cts +433 -74
  97. package/dist/model/index.d.ts +433 -74
  98. package/dist/model/index.js +6 -7
  99. package/dist/styled-DIRkk9Uz.d.ts +506 -0
  100. package/dist/styled-DJbkJQfj.d.cts +506 -0
  101. package/dist/theory/index.cjs +172 -1676
  102. package/dist/theory/index.cjs.map +1 -1
  103. package/dist/theory/index.d.cts +151 -233
  104. package/dist/theory/index.d.ts +151 -233
  105. package/dist/theory/index.js +31 -7
  106. package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
  107. package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
  108. package/package.json +73 -24
  109. package/dist/chunk-5HTNMQW4.js.map +0 -1
  110. package/dist/chunk-7Y7WF64D.js.map +0 -1
  111. package/dist/chunk-A4532ZFR.js.map +0 -1
  112. package/dist/chunk-BHUT2S5G.js +0 -22
  113. package/dist/chunk-BHUT2S5G.js.map +0 -1
  114. package/dist/chunk-CCBLVAPK.js +0 -42
  115. package/dist/chunk-CCBLVAPK.js.map +0 -1
  116. package/dist/chunk-COKTX2G5.js.map +0 -1
  117. package/dist/chunk-DYWFNC4A.js +0 -152
  118. package/dist/chunk-DYWFNC4A.js.map +0 -1
  119. package/dist/chunk-GQKN5KDT.js.map +0 -1
  120. package/dist/chunk-LN7ZZMD2.js.map +0 -1
  121. package/dist/chunk-MSWFZF7J.js +0 -118
  122. package/dist/chunk-MSWFZF7J.js.map +0 -1
  123. package/dist/chunk-R3S2LUBH.js.map +0 -1
  124. package/dist/chunk-SDM3VJJ5.js +0 -41
  125. package/dist/chunk-SDM3VJJ5.js.map +0 -1
  126. package/dist/chunk-XTC3X6MS.js.map +0 -1
  127. package/dist/chunk-ZXW5YIWK.js +0 -55
  128. package/dist/chunk-ZXW5YIWK.js.map +0 -1
  129. package/dist/index-CGMZNCx6.d.cts +0 -140
  130. package/dist/index-CGMZNCx6.d.ts +0 -140
  131. package/dist/styled-BjXtOFNk.d.cts +0 -282
  132. package/dist/styled-D7BAx3S3.d.ts +0 -282
  133. package/src/analyze/arrange/index.ts +0 -21
  134. package/src/analyze/arrange/internal.ts +0 -125
  135. package/src/analyze/arrange/tension.ts +0 -187
  136. package/src/analyze/arrange/tracks.ts +0 -279
  137. package/src/analyze/detect/index.ts +0 -235
  138. package/src/analyze/functional/borrowed.ts +0 -84
  139. package/src/analyze/functional/cadence.ts +0 -66
  140. package/src/analyze/functional/function.ts +0 -184
  141. package/src/analyze/functional/index.ts +0 -23
  142. package/src/analyze/functional/internal.ts +0 -29
  143. package/src/analyze/functional/roman.ts +0 -393
  144. package/src/analyze/index.ts +0 -49
  145. package/src/analyze/timeline/index.ts +0 -415
  146. package/src/analyze/voice/index.ts +0 -330
  147. package/src/core/index.ts +0 -47
  148. package/src/core/interval/index.ts +0 -81
  149. package/src/core/meter/index.ts +0 -282
  150. package/src/core/pitch/index.ts +0 -311
  151. package/src/core/random/index.ts +0 -53
  152. package/src/core/tuning/index.ts +0 -156
  153. package/src/core/types.ts +0 -29
  154. package/src/generate/bass/index.ts +0 -350
  155. package/src/generate/countermelody/index.ts +0 -379
  156. package/src/generate/drums/beat.ts +0 -266
  157. package/src/generate/drums/euclid.ts +0 -58
  158. package/src/generate/drums/fills.ts +0 -356
  159. package/src/generate/drums/ghost.ts +0 -119
  160. package/src/generate/drums/hihat.ts +0 -285
  161. package/src/generate/drums/hit.ts +0 -31
  162. package/src/generate/drums/index.ts +0 -329
  163. package/src/generate/drums/internal.ts +0 -257
  164. package/src/generate/drums/kick.ts +0 -160
  165. package/src/generate/drums/percussion.ts +0 -121
  166. package/src/generate/drums/rng.ts +0 -7
  167. package/src/generate/drums/swing.ts +0 -71
  168. package/src/generate/groove/index.ts +0 -326
  169. package/src/generate/harmonize/index.ts +0 -440
  170. package/src/generate/index.ts +0 -59
  171. package/src/generate/motif/index.ts +0 -398
  172. package/src/generate/progression/index.ts +0 -370
  173. package/src/generate/reharmony/index.ts +0 -330
  174. package/src/generate/rhythm/index.ts +0 -165
  175. package/src/index.ts +0 -247
  176. package/src/model/chord.ts +0 -423
  177. package/src/model/index.ts +0 -15
  178. package/src/model/interval.ts +0 -82
  179. package/src/model/key.ts +0 -266
  180. package/src/model/note.ts +0 -176
  181. package/src/model/progression.ts +0 -161
  182. package/src/model/shared.ts +0 -12
  183. package/src/theory/chord/index.ts +0 -373
  184. package/src/theory/chordscale/index.ts +0 -374
  185. package/src/theory/counterpoint/index.ts +0 -230
  186. package/src/theory/harmony/index.ts +0 -92
  187. package/src/theory/index.ts +0 -107
  188. package/src/theory/safety/index.ts +0 -419
  189. package/src/theory/scale/degrees.ts +0 -123
  190. package/src/theory/scale/index.ts +0 -28
  191. package/src/theory/scale/key.ts +0 -56
  192. package/src/theory/scale/masks.ts +0 -153
  193. package/src/theory/spelling/index.ts +0 -182
  194. package/src/theory/symbol/index.ts +0 -268
  195. package/src/theory/voicing/index.ts +0 -5
  196. package/src/theory/voicing/internal.ts +0 -168
  197. package/src/theory/voicing/leading.ts +0 -112
  198. package/src/theory/voicing/satb.ts +0 -195
  199. package/src/theory/voicing/styled.ts +0 -176
  200. package/tsconfig.json +0 -23
@@ -1,379 +0,0 @@
1
- import type { TimeSignature } from '../../core/meter/index.js';
2
- import { isStrongBeat } from '../../core/meter/index.js';
3
- import type { Rng } from '../../core/random/index.js';
4
- import { createRng } from '../../core/random/index.js';
5
- import type { KeyScale, NoteEvent } from '../../core/types.js';
6
- import type { Chord } from '../../theory/chord/index.js';
7
- import { chordToneRole } from '../../theory/chord/index.js';
8
- import { createsParallelPerfect, isForbiddenMelodicLeap } from '../../theory/counterpoint/index.js';
9
- import type { VoiceSnapshot } from '../../theory/safety/index.js';
10
- import { enumerateSafePitches } from '../../theory/safety/index.js';
11
- import { nearestScaleTone } from '../../theory/scale/index.js';
12
-
13
- /**
14
- * Options controlling {@link generateCounterMelody}.
15
- *
16
- * @category Voicing & Counterpoint
17
- */
18
- export type CounterMelodyOptions = {
19
- /** The lead line to write against, in ascending onset order. */
20
- melody: NoteEvent[];
21
- /** Chord sounding at an absolute beat position, or null when none applies. */
22
- chordAt: (beat: number) => Chord | null;
23
- /** Key/scale context for scale-tone decisions. */
24
- key: KeyScale;
25
- /**
26
- * Meter used for strong-beat decisions.
27
- *
28
- * @defaultValue 4/4
29
- */
30
- ts?: TimeSignature;
31
- /**
32
- * Which side of the melody the counter line occupies.
33
- *
34
- * @defaultValue 'below'
35
- */
36
- register?: 'above' | 'below';
37
- /**
38
- * Onset strategy: `'complement'` moves where the melody holds or rests and
39
- * reinforces some strong beats; `'follow'` mirrors melody onsets.
40
- *
41
- * @defaultValue 'complement'
42
- */
43
- rhythm?: 'complement' | 'follow';
44
- /**
45
- * Safety profile applied to candidate pitches.
46
- *
47
- * @defaultValue 'pop'
48
- */
49
- profile?: 'strict' | 'pop';
50
- /** Lowest MIDI pitch the counter line may use (default derived from `register`). */
51
- pitchLow?: number;
52
- /** Highest MIDI pitch the counter line may use (default derived from `register`). */
53
- pitchHigh?: number;
54
- /**
55
- * PRNG seed; the same seed always yields the same line.
56
- *
57
- * @defaultValue 0
58
- */
59
- seed?: number;
60
- };
61
-
62
- /** Meter assumed when none is supplied. */
63
- const DEFAULT_TS: TimeSignature = { numerator: 4, denominator: 4 };
64
-
65
- /** Tolerance for beat-position comparisons. */
66
- const EPS = 1e-9;
67
-
68
- /** Grid resolution, in quarter-note beats, scanned for complement onsets. */
69
- const GRID_STEP = 0.5;
70
-
71
- /** Minimum spacing between counter onsets, keeping the line uncluttered. */
72
- const MIN_ONSET_GAP = 1;
73
-
74
- /** Semitone offset of the counter register's centre from the melody's mean pitch. */
75
- const REGISTER_OFFSET = 12;
76
-
77
- /** Half-width, in semitones, of the default counter register. */
78
- const DEFAULT_HALF_RANGE = 10;
79
-
80
- /** Probability of moving on a whole beat while the melody sustains. */
81
- const HOLD_ON_BEAT_PROB = 0.55;
82
-
83
- /** Probability of moving on an off-beat subdivision while the melody sustains. */
84
- const HOLD_OFF_BEAT_PROB = 0.2;
85
-
86
- /** Probability of reinforcing a strong beat that the melody also attacks. */
87
- const REINFORCE_PROB = 0.35;
88
-
89
- /** Velocity assumed for melody notes that carry none. */
90
- const DEFAULT_MELODY_VELOCITY = 96;
91
-
92
- /** How far below the concurrent melody velocity the counter line sits. */
93
- const VELOCITY_DROP = 16;
94
-
95
- /** Whether a beat position falls on a whole quarter-note beat. */
96
- function isWholeBeat(beat: number): boolean {
97
- return Math.abs(beat - Math.round(beat)) < EPS;
98
- }
99
-
100
- /** The melody note sounding at a beat (latest onset wins on overlaps). */
101
- function melodyNoteAt(melody: NoteEvent[], beat: number): NoteEvent | undefined {
102
- let found: NoteEvent | undefined;
103
- for (const note of melody) {
104
- if (note.startBeat - EPS <= beat && beat < note.startBeat + note.durationBeat - EPS) {
105
- if (!found || note.startBeat > found.startBeat) {
106
- found = note;
107
- }
108
- }
109
- }
110
- return found;
111
- }
112
-
113
- /** Whether any melody note attacks exactly at a beat. */
114
- function melodyAttacksAt(melody: NoteEvent[], beat: number): boolean {
115
- return melody.some((note) => Math.abs(note.startBeat - beat) < EPS);
116
- }
117
-
118
- /** Counter onsets mirroring the melody's own onsets, deduplicated and sorted. */
119
- function followOnsets(melody: NoteEvent[]): number[] {
120
- return [...new Set(melody.map((note) => note.startBeat))].sort((a, b) => a - b);
121
- }
122
-
123
- /**
124
- * Counter onsets complementing the melody: whole beats inside rests are always
125
- * taken, positions where the melody sustains are taken probabilistically, and
126
- * strong beats the melody also attacks are occasionally reinforced. A minimum
127
- * gap between accepted onsets keeps the line sparse.
128
- */
129
- function complementOnsets(
130
- melody: NoteEvent[],
131
- ts: TimeSignature,
132
- spanStart: number,
133
- spanEnd: number,
134
- rng: Rng,
135
- ): number[] {
136
- const onsets: number[] = [];
137
- const gridStart = Math.floor(spanStart / GRID_STEP) * GRID_STEP;
138
- let last = Number.NEGATIVE_INFINITY;
139
- for (let beat = gridStart; beat < spanEnd - EPS; beat += GRID_STEP) {
140
- const attacked = melodyAttacksAt(melody, beat);
141
- const sounding = melodyNoteAt(melody, beat) !== undefined;
142
- let place = false;
143
- if (!attacked && !sounding) {
144
- place = isWholeBeat(beat);
145
- } else if (!attacked) {
146
- place = rng.prob(isWholeBeat(beat) ? HOLD_ON_BEAT_PROB : HOLD_OFF_BEAT_PROB);
147
- } else if (isStrongBeat(beat, ts)) {
148
- place = rng.prob(REINFORCE_PROB);
149
- }
150
- if (place && beat - last >= MIN_ONSET_GAP - EPS) {
151
- onsets.push(beat);
152
- last = beat;
153
- }
154
- }
155
- return onsets;
156
- }
157
-
158
- /**
159
- * Preference score for one candidate counter pitch. Rewards imperfect
160
- * consonance with the melody, chord-tone membership, contrary or oblique
161
- * motion, and stepwise movement; penalizes wide or forbidden leaps, weak-beat
162
- * clashes, and drifting from the register centre.
163
- */
164
- function scoreCandidate(
165
- pitch: number,
166
- melPitch: number | undefined,
167
- melPrev: number | undefined,
168
- prevPitch: number | undefined,
169
- chord: Chord | null,
170
- center: number,
171
- ): number {
172
- let score = 0;
173
- if (melPitch !== undefined) {
174
- const ic = Math.abs(pitch - melPitch) % 12;
175
- if (ic === 3 || ic === 4 || ic === 8 || ic === 9) {
176
- score += 2; // imperfect consonance: thirds and sixths
177
- } else if (ic === 0 || ic === 7) {
178
- score += 0.5; // perfect consonance: allowed but less colourful
179
- } else if (ic === 1 || ic === 2 || ic === 6 || ic === 10 || ic === 11) {
180
- score -= 2; // dissonant even on weak beats
181
- }
182
- }
183
- if (chord && chordToneRole(pitch, chord) !== null) {
184
- score += 1;
185
- }
186
- if (prevPitch !== undefined) {
187
- const move = pitch - prevPitch;
188
- const dist = Math.abs(move);
189
- score -= dist * 0.3;
190
- if (dist > 0 && dist <= 2) {
191
- score += 1; // stepwise motion
192
- }
193
- if (isForbiddenMelodicLeap(prevPitch, pitch)) {
194
- score -= 6;
195
- }
196
- if (melPitch !== undefined && melPrev !== undefined) {
197
- const melMove = melPitch - melPrev;
198
- if ((move > 0 && melMove < 0) || (move < 0 && melMove > 0)) {
199
- score += 2; // contrary motion
200
- } else if (move === 0 || melMove === 0) {
201
- score += 1; // oblique motion
202
- } else {
203
- score -= 1; // similar motion
204
- }
205
- }
206
- }
207
- score -= Math.abs(pitch - center) * 0.02;
208
- return score;
209
- }
210
-
211
- /**
212
- * Last-resort pitch when no safe candidate survives filtering: the scale tone
213
- * nearest the previous pitch (or the register centre), pushed onto the correct
214
- * side of the sounding melody note.
215
- */
216
- function fallbackPitch(
217
- melPitch: number | undefined,
218
- prevPitch: number | undefined,
219
- center: number,
220
- register: 'above' | 'below',
221
- low: number,
222
- high: number,
223
- key: KeyScale,
224
- ): number {
225
- let target = prevPitch ?? Math.round(center);
226
- if (melPitch !== undefined) {
227
- if (register === 'below' && target >= melPitch) {
228
- target = melPitch - 5;
229
- } else if (register === 'above' && target <= melPitch) {
230
- target = melPitch + 5;
231
- }
232
- }
233
- target = Math.min(Math.max(target, low), high);
234
- let pitch = nearestScaleTone(target, key);
235
- // Push onto the requested side of the melody, but only by whole octaves that
236
- // keep the pitch inside [low, high]; the declared range is a hard bound while
237
- // the register side is a soft preference. A final clamp guarantees the range.
238
- if (melPitch !== undefined) {
239
- while (register === 'below' && pitch >= melPitch && pitch - 12 >= low) {
240
- pitch -= 12;
241
- }
242
- while (register === 'above' && pitch <= melPitch && pitch + 12 <= high) {
243
- pitch += 12;
244
- }
245
- }
246
- return Math.min(Math.max(pitch, low), high);
247
- }
248
-
249
- /**
250
- * Generate a counter melody against a lead line.
251
- *
252
- * Onsets are chosen per `rhythm`: `'follow'` places one counter note on each
253
- * melody onset; `'complement'` fills melody rests and sustains and reinforces
254
- * an occasional strong beat, spaced at least a beat apart. Each onset's pitch
255
- * is picked from the safe pitches in the counter register (chord tones and
256
- * consonant tensions against the sounding melody note), rejecting candidates
257
- * that would form a parallel perfect interval with the melody or sit on the
258
- * wrong side of it, then scored to favour contrary or oblique motion, imperfect
259
- * consonance, and stepwise movement. Ties break toward the lower pitch, so a
260
- * given seed always yields the same line. Notes extend to the next counter
261
- * onset (the last to the melody's end) at a velocity slightly under the melody.
262
- *
263
- * @param opts The lead line, chord context callback, key, and generation knobs.
264
- * @returns The counter line as note events sorted by onset; `[]` for an empty melody.
265
- * @example
266
- * ```ts
267
- * import { generateCounterMelody, majorKey, parseChordSymbol } from '@libraz/libcantus';
268
- * const melody = [{ pitch: 72, startBeat: 0, durationBeat: 2 }];
269
- * const counter = generateCounterMelody({
270
- * melody,
271
- * chordAt: () => parseChordSymbol('C'),
272
- * key: majorKey(0),
273
- * }); // note events below the melody, in onset order
274
- * ```
275
- * @category Voicing & Counterpoint
276
- */
277
- export function generateCounterMelody(opts: CounterMelodyOptions): NoteEvent[] {
278
- const melody = [...opts.melody].sort((a, b) => a.startBeat - b.startBeat);
279
- if (melody.length === 0) {
280
- return [];
281
- }
282
- const ts = opts.ts ?? DEFAULT_TS;
283
- const register = opts.register ?? 'below';
284
- const rhythm = opts.rhythm ?? 'complement';
285
- const profile = opts.profile ?? 'pop';
286
- const rng = createRng(opts.seed ?? 0);
287
-
288
- const spanStart = melody[0]?.startBeat ?? 0;
289
- const spanEnd = melody.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), spanStart);
290
- const meanPitch = melody.reduce((sum, n) => sum + n.pitch, 0) / melody.length;
291
- const defaultCenter =
292
- register === 'below' ? meanPitch - REGISTER_OFFSET : meanPitch + REGISTER_OFFSET;
293
- const low = opts.pitchLow ?? Math.round(defaultCenter) - DEFAULT_HALF_RANGE;
294
- const high = opts.pitchHigh ?? Math.round(defaultCenter) + DEFAULT_HALF_RANGE;
295
- const center = (low + high) / 2;
296
-
297
- const onsets =
298
- rhythm === 'follow'
299
- ? followOnsets(melody)
300
- : complementOnsets(melody, ts, spanStart, spanEnd, rng);
301
-
302
- const out: NoteEvent[] = [];
303
- let prevPitch: number | undefined;
304
- let prevOnset: number | undefined;
305
- for (const beat of onsets) {
306
- const melNote = melodyNoteAt(melody, beat);
307
- const melPitch = melNote?.pitch;
308
- const melPrev = prevOnset !== undefined ? melodyNoteAt(melody, prevOnset)?.pitch : undefined;
309
- const chord = opts.chordAt(beat);
310
- const otherVoices: VoiceSnapshot[] =
311
- melPitch !== undefined ? [{ pitch: melPitch, prevPitch: melPrev }] : [];
312
-
313
- const candidates = enumerateSafePitches(
314
- {
315
- profile,
316
- prevPitch,
317
- chord,
318
- key: opts.key,
319
- otherVoices,
320
- strongBeat: isStrongBeat(beat, ts),
321
- vocalLow: low,
322
- vocalHigh: high,
323
- },
324
- low,
325
- high,
326
- );
327
-
328
- let bestPitch: number | undefined;
329
- let bestScore = Number.NEGATIVE_INFINITY;
330
- for (const candidate of candidates) {
331
- if (melPitch !== undefined) {
332
- if (register === 'below' && candidate >= melPitch) {
333
- continue;
334
- }
335
- if (register === 'above' && candidate <= melPitch) {
336
- continue;
337
- }
338
- if (
339
- prevPitch !== undefined &&
340
- melPrev !== undefined &&
341
- // Parallel octaves are the perfect-class-zero case of this predicate,
342
- // so a single check covers both parallel fifths and octaves.
343
- createsParallelPerfect(prevPitch, candidate, melPrev, melPitch)
344
- ) {
345
- continue;
346
- }
347
- }
348
- const score = scoreCandidate(candidate, melPitch, melPrev, prevPitch, chord, center);
349
- const tie =
350
- Math.abs(score - bestScore) <= EPS && candidate < (bestPitch ?? Number.POSITIVE_INFINITY);
351
- if (score > bestScore + EPS || tie) {
352
- bestScore = score;
353
- bestPitch = candidate;
354
- }
355
- }
356
-
357
- const pitch =
358
- bestPitch ?? fallbackPitch(melPitch, prevPitch, center, register, low, high, opts.key);
359
- const velocity = Math.max(
360
- 1,
361
- Math.min(127, Math.round((melNote?.velocity ?? DEFAULT_MELODY_VELOCITY) - VELOCITY_DROP)),
362
- );
363
- out.push({ pitch, startBeat: beat, durationBeat: GRID_STEP, velocity });
364
- prevPitch = pitch;
365
- prevOnset = beat;
366
- }
367
-
368
- // Extend each note to the next counter onset; the last to the melody's end.
369
- for (let i = 0; i < out.length; i += 1) {
370
- const note = out[i];
371
- if (!note) {
372
- continue;
373
- }
374
- const next = out[i + 1];
375
- const end = next ? next.startBeat : Math.max(spanEnd, note.startBeat + GRID_STEP);
376
- note.durationBeat = end - note.startBeat;
377
- }
378
- return out;
379
- }
@@ -1,266 +0,0 @@
1
- import {
2
- getGhostDensity,
3
- getGhostProbabilityAtPosition,
4
- getGhostVelocity,
5
- selectGhostPositions,
6
- } from './ghost.js';
7
- import {
8
- footHiHatVelocity,
9
- type HiHatLevel,
10
- hiHatNote,
11
- hiHatTypeVelocityMultiplier,
12
- hiHatVelocityMultiplier,
13
- OHH_VEL_BOOST,
14
- roleHiHatInstrument,
15
- sectionHiHatType,
16
- shouldAddOpenHHAccent,
17
- shouldPlayHiHat,
18
- shouldUseBridgeCrossStick,
19
- } from './hihat.js';
20
- import type { HitList } from './hit.js';
21
- import type { BackingDensity, DrumRole, DrumStyle, Feel, SectionType } from './internal.js';
22
- import { EIGHTH, GM, type MoodCategory, SIXTEENTH } from './internal.js';
23
- import type { KickPattern } from './kick.js';
24
- import type { DrumRng } from './rng.js';
25
- import { effectiveSwing, quantizeSwing } from './swing.js';
26
-
27
- /** Per-section drum state shared across the bars of a section. */
28
- export type SectionCtx = {
29
- style: DrumStyle;
30
- feel: Feel;
31
- densityMult: number;
32
- backingDensity: BackingDensity;
33
- hhLevel: HiHatLevel;
34
- useGhostNotes: boolean;
35
- ghostBoost: boolean;
36
- useRide: boolean;
37
- useFootHh: boolean;
38
- role: DrumRole;
39
- ghostMood: MoodCategory;
40
- snareBeat3: boolean;
41
- };
42
-
43
- /** Per-beat context passed to every beat processor. */
44
- export type BeatCtx = {
45
- track: HitList;
46
- beatTick: number;
47
- beat: number;
48
- velocity: number;
49
- section: SectionType;
50
- bpm: number;
51
- bar: number;
52
- sectionBars: number;
53
- inPrechorusLift: boolean;
54
- swingAmount: number;
55
- barHasOpenHh: boolean;
56
- openHhBeat: number;
57
- rng: DrumRng;
58
- };
59
-
60
- function swing16(tick: number, feel: Feel, swingAmount: number): number {
61
- return quantizeSwing(tick, effectiveSwing(feel, swingAmount), 'sixteenth');
62
- }
63
-
64
- function hatSwingFactor(style: DrumStyle): number {
65
- if (style === 'trap') {
66
- return 0;
67
- }
68
- if (style === 'latin') {
69
- return 0.35;
70
- }
71
- return 0.5;
72
- }
73
-
74
- /** Emit the kick for one beat. */
75
- export function generateKickForBeat(ctx: BeatCtx, sec: SectionCtx, kick: KickPattern): void {
76
- if (ctx.inPrechorusLift) {
77
- return;
78
- }
79
- const on = [kick.beat1, kick.beat2, kick.beat3, kick.beat4][ctx.beat] ?? false;
80
- const and = [kick.beat1and, kick.beat2and, kick.beat3and, kick.beat4and][ctx.beat] ?? false;
81
-
82
- if (on) {
83
- ctx.track.add(GM.BD, ctx.beatTick, EIGHTH, ctx.velocity);
84
- }
85
- if (and) {
86
- const andTick = swing16(ctx.beatTick + EIGHTH, sec.feel, ctx.swingAmount);
87
- ctx.track.add(GM.BD, andTick, EIGHTH, ctx.velocity * 0.85);
88
- }
89
- }
90
-
91
- /** Emit the backbeat snare (or side-stick) for one beat. */
92
- export function generateSnareForBeat(ctx: BeatCtx, sec: SectionCtx, isIntroFirst: boolean): void {
93
- if (ctx.inPrechorusLift) {
94
- return;
95
- }
96
- const snareOn = sec.snareBeat3 ? ctx.beat === 2 : ctx.beat === 1 || ctx.beat === 3;
97
- if (!snareOn || isIntroFirst) {
98
- return;
99
- }
100
- if (sec.useRide && shouldUseBridgeCrossStick(ctx.section, ctx.beat)) {
101
- return;
102
- }
103
-
104
- const backbeatVel = Math.min(127, ctx.velocity + 16);
105
- const promoteSparseChorus =
106
- sec.style === 'sparse' && ctx.section === 'chorus' && sec.role === 'full';
107
-
108
- if (promoteSparseChorus) {
109
- ctx.track.add(GM.SD, ctx.beatTick, EIGHTH, backbeatVel);
110
- } else if (sec.style === 'sparse' || sec.role === 'ambient') {
111
- if (sec.role !== 'fxOnly' && sec.role !== 'minimal') {
112
- ctx.track.add(GM.SIDESTICK, ctx.beatTick, EIGHTH, ctx.velocity * 0.8);
113
- }
114
- } else {
115
- ctx.track.add(GM.SD, ctx.beatTick, EIGHTH, backbeatVel);
116
- }
117
- }
118
-
119
- /** Emit ghost snares at the "e"/"a" 16ths of beats 1 and 3. */
120
- export function generateGhostNotesForBeat(ctx: BeatCtx, sec: SectionCtx): void {
121
- if (ctx.beat !== 0 && ctx.beat !== 2) {
122
- return;
123
- }
124
- const positions = selectGhostPositions(sec.ghostMood, ctx.rng);
125
- let ghostProb = getGhostDensity(sec.ghostMood, ctx.section, sec.backingDensity, ctx.bpm);
126
- if (sec.ghostBoost) {
127
- ghostProb = Math.min(1, ghostProb * 1.4);
128
- }
129
-
130
- for (const pos of positions) {
131
- const sixteenthInBeat = pos === 'e' ? 1 : 3;
132
- const posProb = getGhostProbabilityAtPosition(ctx.beat, sixteenthInBeat);
133
- if (!ctx.rng.prob(ghostProb * posProb)) {
134
- continue;
135
- }
136
- const variation = ctx.rng.float(0.85, 1.15);
137
- const ghostBase = getGhostVelocity(ctx.section, ctx.beat / 2, false);
138
- let ghostVel = ctx.velocity * ghostBase * variation;
139
- if (pos === 'a') {
140
- ghostVel *= 0.9;
141
- }
142
- const offset = pos === 'e' ? SIXTEENTH : 3 * SIXTEENTH;
143
- const tick = swing16(ctx.beatTick + offset, sec.feel, ctx.swingAmount);
144
- ctx.track.add(GM.SD, tick, SIXTEENTH, ghostVel);
145
- }
146
- }
147
-
148
- /** Emit the pre-chorus lift buildup for one beat. */
149
- export function generatePreChorusBuildup(
150
- ctx: BeatCtx,
151
- sec: SectionCtx,
152
- isSectionLastBar: boolean,
153
- ): void {
154
- if (sec.style === 'sparse') {
155
- if (isSectionLastBar && ctx.beat === 3) {
156
- ctx.track.add(GM.SD, ctx.beatTick, EIGHTH, Math.max(45, ctx.velocity * 0.75));
157
- ctx.track.add(GM.CRASH, ctx.beatTick + EIGHTH + SIXTEENTH, SIXTEENTH, ctx.velocity * 0.9);
158
- }
159
- return;
160
- }
161
- const barsInLift = 2;
162
- const barInLift = ctx.bar - (ctx.sectionBars - barsInLift);
163
- const progress = (barInLift * 4 + ctx.beat) / (barsInLift * 4);
164
- const buildupVel = ctx.velocity * (0.5 + 0.5 * progress);
165
- ctx.track.add(GM.SD, ctx.beatTick, EIGHTH, buildupVel);
166
- ctx.track.add(GM.SD, ctx.beatTick + EIGHTH, EIGHTH, buildupVel * 0.85);
167
- if (isSectionLastBar && ctx.beat === 3) {
168
- ctx.track.add(GM.CRASH, ctx.beatTick + EIGHTH + SIXTEENTH, SIXTEENTH, ctx.velocity * 1.1);
169
- }
170
- }
171
-
172
- /** Emit the timekeeping hi-hat (or ride/foot) for one beat. */
173
- export function generateHiHatForBeat(ctx: BeatCtx, sec: SectionCtx): void {
174
- if (!shouldPlayHiHat(sec.role)) {
175
- if (sec.useFootHh && (ctx.beat === 0 || ctx.beat === 2)) {
176
- ctx.track.add(GM.FHH, ctx.beatTick, EIGHTH, footHiHatVelocity(ctx.rng));
177
- }
178
- return;
179
- }
180
-
181
- const crossStick = sec.useRide && shouldUseBridgeCrossStick(ctx.section, ctx.beat);
182
- const hhInstrument = crossStick ? GM.SIDESTICK : roleHiHatInstrument(sec.role, sec.useRide);
183
- const hhType = sectionHiHatType(ctx.section, sec.role);
184
- const typeMult = hiHatTypeVelocityMultiplier(hhType);
185
- const isDynamicOpen = ctx.barHasOpenHh && ctx.beat === ctx.openHhBeat;
186
- const dm = sec.densityMult;
187
-
188
- if (sec.hhLevel === 'quarter') {
189
- const introRest = ctx.section === 'intro' && ctx.beat !== 0;
190
- if (!introRest) {
191
- if (isDynamicOpen) {
192
- ctx.track.add(
193
- GM.OHH,
194
- ctx.beatTick,
195
- EIGHTH,
196
- ctx.velocity * dm * 0.75 * typeMult + OHH_VEL_BOOST,
197
- );
198
- } else {
199
- ctx.track.add(
200
- hhInstrument,
201
- ctx.beatTick,
202
- EIGHTH,
203
- Math.max(20, ctx.velocity * dm * 0.75 * typeMult),
204
- );
205
- }
206
- } else if (sec.useFootHh) {
207
- ctx.track.add(GM.FHH, ctx.beatTick, EIGHTH, footHiHatVelocity(ctx.rng));
208
- }
209
- return;
210
- }
211
-
212
- if (sec.hhLevel === 'eighth') {
213
- for (let eighth = 0; eighth < 2; eighth += 1) {
214
- let hhTick = ctx.beatTick + eighth * EIGHTH;
215
- if (eighth === 1) {
216
- hhTick = swing16(hhTick, sec.feel, ctx.swingAmount);
217
- }
218
- if (ctx.section === 'intro' && eighth === 1) {
219
- if (sec.useFootHh && ctx.beat % 2 === 0) {
220
- ctx.track.add(GM.FHH, hhTick, EIGHTH, footHiHatVelocity(ctx.rng));
221
- }
222
- continue;
223
- }
224
- const hhVel = Math.max(20, ctx.velocity * dm * typeMult * (eighth === 0 ? 0.9 : 0.65));
225
- if (isDynamicOpen && eighth === 0) {
226
- ctx.track.add(GM.OHH, hhTick, EIGHTH, hhVel + OHH_VEL_BOOST);
227
- continue;
228
- }
229
- let useOpen = false;
230
- if (sec.style === 'fourOnFloor' && eighth === 1) {
231
- const openProb = Math.max(0.15, Math.min(0.8, 45 / ctx.bpm));
232
- useOpen = (ctx.beat === 1 || ctx.beat === 3) && ctx.rng.prob(openProb);
233
- } else if (eighth === 0) {
234
- useOpen = shouldAddOpenHHAccent(ctx.section, ctx.beat, ctx.bar, ctx.rng);
235
- }
236
- if (useOpen) {
237
- ctx.track.add(hiHatNote('open'), hhTick, EIGHTH, Math.max(20, hhVel * 1.1));
238
- } else {
239
- ctx.track.add(hhInstrument, hhTick, EIGHTH / 2, hhVel);
240
- }
241
- }
242
- return;
243
- }
244
-
245
- for (let sixteenth = 0; sixteenth < 4; sixteenth += 1) {
246
- let hhTick = ctx.beatTick + sixteenth * SIXTEENTH;
247
- if (sixteenth === 1 || sixteenth === 3) {
248
- const swung = effectiveSwing(sec.feel, ctx.swingAmount) * hatSwingFactor(sec.style);
249
- hhTick = quantizeSwing(hhTick, swung, 'sixteenth');
250
- }
251
- const metricVel = hiHatVelocityMultiplier(sixteenth, ctx.rng);
252
- const hhVel = Math.max(20, ctx.velocity * dm * typeMult * metricVel);
253
- if (isDynamicOpen && sixteenth === 0) {
254
- ctx.track.add(GM.OHH, hhTick, SIXTEENTH, hhVel + OHH_VEL_BOOST);
255
- continue;
256
- }
257
- if (ctx.beat === 3 && sixteenth === 3) {
258
- const openProb = Math.max(0.1, Math.min(0.4, 30 / ctx.bpm));
259
- if (ctx.rng.prob(openProb)) {
260
- ctx.track.add(GM.OHH, hhTick, SIXTEENTH, Math.max(20, hhVel * 1.2));
261
- continue;
262
- }
263
- }
264
- ctx.track.add(hhInstrument, hhTick, SIXTEENTH / 2, hhVel);
265
- }
266
- }
@@ -1,58 +0,0 @@
1
- /**
2
- * Generate a euclidean onset pattern (Bjorklund's algorithm), distributing
3
- * `pulses` as evenly as possible across `steps`, then rotating.
4
- *
5
- * @param pulses Number of onsets, clamped to [0, steps].
6
- * @param steps Total steps (>= 1).
7
- * @param rotation Steps to rotate onsets toward later positions.
8
- * @returns One boolean per step; true marks an onset.
9
- */
10
- export function euclideanRhythm(pulses: number, steps: number, rotation = 0): boolean[] {
11
- if (steps < 1) {
12
- return [];
13
- }
14
- const p = Math.max(0, Math.min(pulses, steps));
15
- const base = bjorklund(p, steps);
16
- const shift = ((rotation % steps) + steps) % steps;
17
- if (shift === 0) {
18
- return base;
19
- }
20
- return base.map((_, i) => base[(i - shift + steps) % steps] ?? false);
21
- }
22
-
23
- /** True when the 16-step bitmask has an onset at `step`. */
24
- export function hasHit(mask: number, step: number): boolean {
25
- return ((mask >> step) & 1) === 1;
26
- }
27
-
28
- /** Convert a euclidean boolean pattern to a 16-step bitmask (LSB = step 0). */
29
- export function patternToMask(pattern: boolean[]): number {
30
- let mask = 0;
31
- for (let i = 0; i < pattern.length; i += 1) {
32
- if (pattern[i]) {
33
- mask |= 1 << i;
34
- }
35
- }
36
- return mask;
37
- }
38
-
39
- function bjorklund(pulses: number, steps: number): boolean[] {
40
- if (pulses === 0) {
41
- return Array.from({ length: steps }, () => false);
42
- }
43
- if (pulses >= steps) {
44
- return Array.from({ length: steps }, () => true);
45
- }
46
- let head: boolean[][] = Array.from({ length: pulses }, () => [true]);
47
- let remainder: boolean[][] = Array.from({ length: steps - pulses }, () => [false]);
48
- while (remainder.length > 1) {
49
- const count = Math.min(head.length, remainder.length);
50
- const next: boolean[][] = [];
51
- for (let i = 0; i < count; i += 1) {
52
- next.push([...(head[i] ?? []), ...(remainder[i] ?? [])]);
53
- }
54
- remainder = head.length > count ? head.slice(count) : remainder.slice(count);
55
- head = next;
56
- }
57
- return [...head.flat(), ...remainder.flat()];
58
- }