@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.
Files changed (201) hide show
  1. package/README.md +29 -20
  2. package/README_ja.md +268 -0
  3. package/dist/analyze/index.cjs +39 -1947
  4. package/dist/analyze/index.cjs.map +1 -1
  5. package/dist/analyze/index.d.cts +161 -91
  6. package/dist/analyze/index.d.ts +161 -91
  7. package/dist/analyze/index.js +19 -8
  8. package/dist/chunk-2A2QMA74.cjs +261 -0
  9. package/dist/chunk-2A2QMA74.cjs.map +1 -0
  10. package/dist/chunk-2PTFGVD7.js +261 -0
  11. package/dist/chunk-2PTFGVD7.js.map +1 -0
  12. package/dist/chunk-357ZDPVU.cjs +162 -0
  13. package/dist/chunk-357ZDPVU.cjs.map +1 -0
  14. package/dist/chunk-3KX76RLG.js +1548 -0
  15. package/dist/chunk-3KX76RLG.js.map +1 -0
  16. package/dist/chunk-4VZ5MZAM.cjs +889 -0
  17. package/dist/chunk-4VZ5MZAM.cjs.map +1 -0
  18. package/dist/{chunk-C5XYKUVG.js → chunk-7EONYVI5.js} +471 -171
  19. package/dist/chunk-7EONYVI5.js.map +1 -0
  20. package/dist/{chunk-IT2O4XFE.js → chunk-7JEP3MFY.js} +42 -34
  21. package/dist/chunk-7JEP3MFY.js.map +1 -0
  22. package/dist/{chunk-4MHA432G.js → chunk-AID75RN3.js} +572 -250
  23. package/dist/chunk-AID75RN3.js.map +1 -0
  24. package/dist/chunk-BSLTLGM3.cjs +497 -0
  25. package/dist/chunk-BSLTLGM3.cjs.map +1 -0
  26. package/dist/chunk-CU6VG2YM.cjs +109 -0
  27. package/dist/chunk-CU6VG2YM.cjs.map +1 -0
  28. package/dist/{chunk-EH6EM37U.js → chunk-DZMHIGI4.js} +81 -59
  29. package/dist/chunk-DZMHIGI4.js.map +1 -0
  30. package/dist/{chunk-AKLYPWIN.js → chunk-EDOTEABK.js} +45 -19
  31. package/dist/chunk-EDOTEABK.js.map +1 -0
  32. package/dist/{chunk-2ZWMSJH6.js → chunk-HN2OEUN2.js} +7 -4
  33. package/dist/chunk-HN2OEUN2.js.map +1 -0
  34. package/dist/{chunk-MIWO425M.js → chunk-KBTTEX7Q.js} +128 -43
  35. package/dist/chunk-KBTTEX7Q.js.map +1 -0
  36. package/dist/chunk-KQN2ZYND.cjs +724 -0
  37. package/dist/chunk-KQN2ZYND.cjs.map +1 -0
  38. package/dist/chunk-MPTVJIVG.cjs +3176 -0
  39. package/dist/chunk-MPTVJIVG.cjs.map +1 -0
  40. package/dist/chunk-MTMZTMWM.cjs +53 -0
  41. package/dist/chunk-MTMZTMWM.cjs.map +1 -0
  42. package/dist/{chunk-CHMXELDS.js → chunk-NNB67XA2.js} +372 -197
  43. package/dist/chunk-NNB67XA2.js.map +1 -0
  44. package/dist/chunk-OL3IHHSJ.cjs +1204 -0
  45. package/dist/chunk-OL3IHHSJ.cjs.map +1 -0
  46. package/dist/chunk-PI3UXRWK.cjs +528 -0
  47. package/dist/chunk-PI3UXRWK.cjs.map +1 -0
  48. package/dist/chunk-R2VVFAIN.js +528 -0
  49. package/dist/chunk-R2VVFAIN.js.map +1 -0
  50. package/dist/chunk-T64C7ZD7.cjs +1 -0
  51. package/dist/chunk-T64C7ZD7.cjs.map +1 -0
  52. package/dist/chunk-WFDXOLUB.cjs +337 -0
  53. package/dist/chunk-WFDXOLUB.cjs.map +1 -0
  54. package/dist/chunk-WTAN4ACG.cjs +1548 -0
  55. package/dist/chunk-WTAN4ACG.cjs.map +1 -0
  56. package/dist/chunk-YFJXS3AU.cjs +33 -0
  57. package/dist/chunk-YFJXS3AU.cjs.map +1 -0
  58. package/dist/{chunk-GOAW4VYL.js → chunk-YUE6TJ4M.js} +315 -145
  59. package/dist/chunk-YUE6TJ4M.js.map +1 -0
  60. package/dist/{chunk-62QZKCJQ.js → chunk-ZGLJM4A4.js} +10 -6
  61. package/dist/chunk-ZGLJM4A4.js.map +1 -0
  62. package/dist/core/index.cjs +140 -647
  63. package/dist/core/index.cjs.map +1 -1
  64. package/dist/core/index.d.cts +313 -41
  65. package/dist/core/index.d.ts +313 -41
  66. package/dist/core/index.js +64 -16
  67. package/dist/{function-DVH4Eusr.d.ts → function-CxixTB02.d.cts} +122 -10
  68. package/dist/{function-DYt4iKsn.d.cts → function-DTCt675z.d.ts} +122 -10
  69. package/dist/generate/index.cjs +28 -4085
  70. package/dist/generate/index.cjs.map +1 -1
  71. package/dist/generate/index.d.cts +374 -66
  72. package/dist/generate/index.d.ts +374 -66
  73. package/dist/generate/index.js +23 -9
  74. package/dist/{index-Jc0Ryi79.d.ts → index-B2Ulxxpo.d.cts} +21 -5
  75. package/dist/{index-CpOsP1y9.d.cts → index-BkYJ0xMq.d.cts} +18 -17
  76. package/dist/{index-CxqBQtH1.d.cts → index-BuaB-ypt.d.ts} +21 -5
  77. package/dist/index-CAN7v-kw.d.ts +199 -0
  78. package/dist/{index-Bt4tOUD9.d.ts → index-COw1Ky9g.d.ts} +93 -8
  79. package/dist/{index-Dikst44v.d.cts → index-Cfx8xgH0.d.ts} +14 -11
  80. package/dist/{index-DxvARIP2.d.ts → index-DRtIhlJj.d.ts} +18 -17
  81. package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
  82. package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
  83. package/dist/index-alrRninU.d.cts +199 -0
  84. package/dist/{index-BNlGP86A.d.cts → index-oTQbYYIs.d.cts} +93 -8
  85. package/dist/index-tR9jTn26.d.cts +275 -0
  86. package/dist/index-tR9jTn26.d.ts +275 -0
  87. package/dist/{index-Bg5dYMmZ.d.ts → index-x2vcsduE.d.cts} +14 -11
  88. package/dist/index.cjs +429 -7383
  89. package/dist/index.cjs.map +1 -1
  90. package/dist/index.d.cts +15 -15
  91. package/dist/index.d.ts +15 -15
  92. package/dist/index.js +121 -27
  93. package/dist/model/index.cjs +8 -2562
  94. package/dist/model/index.cjs.map +1 -1
  95. package/dist/model/index.d.cts +471 -81
  96. package/dist/model/index.d.ts +471 -81
  97. package/dist/model/index.js +6 -7
  98. package/dist/styled-Bl0ZK28h.d.ts +543 -0
  99. package/dist/styled-Crv6SA4S.d.cts +543 -0
  100. package/dist/theory/index.cjs +172 -1822
  101. package/dist/theory/index.cjs.map +1 -1
  102. package/dist/theory/index.d.cts +151 -233
  103. package/dist/theory/index.d.ts +151 -233
  104. package/dist/theory/index.js +30 -7
  105. package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
  106. package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
  107. package/package.json +34 -13
  108. package/dist/chunk-2ZWMSJH6.js.map +0 -1
  109. package/dist/chunk-4MHA432G.js.map +0 -1
  110. package/dist/chunk-62QZKCJQ.js.map +0 -1
  111. package/dist/chunk-AKLYPWIN.js.map +0 -1
  112. package/dist/chunk-C5XYKUVG.js.map +0 -1
  113. package/dist/chunk-CHMXELDS.js.map +0 -1
  114. package/dist/chunk-DYWFNC4A.js +0 -152
  115. package/dist/chunk-DYWFNC4A.js.map +0 -1
  116. package/dist/chunk-EH6EM37U.js.map +0 -1
  117. package/dist/chunk-FBQK3ZQN.js +0 -131
  118. package/dist/chunk-FBQK3ZQN.js.map +0 -1
  119. package/dist/chunk-GOAW4VYL.js.map +0 -1
  120. package/dist/chunk-I5R7ZBJS.js +0 -202
  121. package/dist/chunk-I5R7ZBJS.js.map +0 -1
  122. package/dist/chunk-IT2O4XFE.js.map +0 -1
  123. package/dist/chunk-MIWO425M.js.map +0 -1
  124. package/dist/chunk-SGU7SPLK.js +0 -924
  125. package/dist/chunk-SGU7SPLK.js.map +0 -1
  126. package/dist/index-CGMZNCx6.d.cts +0 -140
  127. package/dist/index-CGMZNCx6.d.ts +0 -140
  128. package/dist/index-CnZCo29u.d.cts +0 -108
  129. package/dist/index-JnF4v9bx.d.ts +0 -108
  130. package/dist/styled-DZ7GjShb.d.ts +0 -282
  131. package/dist/styled-ZTbdaVFM.d.cts +0 -282
  132. package/src/analyze/arrange/index.ts +0 -21
  133. package/src/analyze/arrange/internal.ts +0 -127
  134. package/src/analyze/arrange/tension.ts +0 -199
  135. package/src/analyze/arrange/tracks.ts +0 -312
  136. package/src/analyze/detect/index.ts +0 -272
  137. package/src/analyze/functional/borrowed.ts +0 -84
  138. package/src/analyze/functional/cadence.ts +0 -66
  139. package/src/analyze/functional/function.ts +0 -184
  140. package/src/analyze/functional/index.ts +0 -23
  141. package/src/analyze/functional/internal.ts +0 -29
  142. package/src/analyze/functional/roman.ts +0 -402
  143. package/src/analyze/index.ts +0 -49
  144. package/src/analyze/timeline/index.ts +0 -451
  145. package/src/analyze/voice/index.ts +0 -339
  146. package/src/core/event-index/index.ts +0 -104
  147. package/src/core/index.ts +0 -61
  148. package/src/core/interval/index.ts +0 -81
  149. package/src/core/meter/index.ts +0 -302
  150. package/src/core/pitch/index.ts +0 -311
  151. package/src/core/random/index.ts +0 -76
  152. package/src/core/tuning/index.ts +0 -186
  153. package/src/core/types.ts +0 -29
  154. package/src/core/validation/index.ts +0 -119
  155. package/src/generate/bass/index.ts +0 -373
  156. package/src/generate/countermelody/index.ts +0 -470
  157. package/src/generate/drums/beat.ts +0 -266
  158. package/src/generate/drums/euclid.ts +0 -65
  159. package/src/generate/drums/fills.ts +0 -356
  160. package/src/generate/drums/ghost.ts +0 -119
  161. package/src/generate/drums/hihat.ts +0 -285
  162. package/src/generate/drums/hit.ts +0 -31
  163. package/src/generate/drums/index.ts +0 -361
  164. package/src/generate/drums/internal.ts +0 -257
  165. package/src/generate/drums/kick.ts +0 -160
  166. package/src/generate/drums/percussion.ts +0 -121
  167. package/src/generate/drums/rng.ts +0 -7
  168. package/src/generate/drums/swing.ts +0 -71
  169. package/src/generate/groove/index.ts +0 -351
  170. package/src/generate/harmonize/index.ts +0 -451
  171. package/src/generate/index.ts +0 -58
  172. package/src/generate/motif/index.ts +0 -422
  173. package/src/generate/progression/index.ts +0 -362
  174. package/src/generate/reharmony/index.ts +0 -323
  175. package/src/generate/rhythm/index.ts +0 -175
  176. package/src/index.ts +0 -13
  177. package/src/model/chord.ts +0 -442
  178. package/src/model/index.ts +0 -15
  179. package/src/model/interval.ts +0 -82
  180. package/src/model/key.ts +0 -266
  181. package/src/model/note.ts +0 -176
  182. package/src/model/progression.ts +0 -161
  183. package/src/model/shared.ts +0 -12
  184. package/src/theory/chord/index.ts +0 -375
  185. package/src/theory/chordscale/index.ts +0 -374
  186. package/src/theory/counterpoint/index.ts +0 -230
  187. package/src/theory/harmony/index.ts +0 -77
  188. package/src/theory/index.ts +0 -112
  189. package/src/theory/safety/index.ts +0 -488
  190. package/src/theory/scale/degrees.ts +0 -123
  191. package/src/theory/scale/index.ts +0 -28
  192. package/src/theory/scale/key.ts +0 -56
  193. package/src/theory/scale/masks.ts +0 -153
  194. package/src/theory/spelling/index.ts +0 -249
  195. package/src/theory/symbol/index.ts +0 -268
  196. package/src/theory/voicing/index.ts +0 -5
  197. package/src/theory/voicing/internal.ts +0 -168
  198. package/src/theory/voicing/leading.ts +0 -112
  199. package/src/theory/voicing/satb.ts +0 -216
  200. package/src/theory/voicing/styled.ts +0 -199
  201. package/tsconfig.json +0 -23
@@ -1,470 +0,0 @@
1
- import type { NoteEventIndex } from '../../core/event-index/index.js';
2
- import { createNoteEventIndex } from '../../core/event-index/index.js';
3
- import type { TimeSignature } from '../../core/meter/index.js';
4
- import { isStrongBeat } from '../../core/meter/index.js';
5
- import type { Rng } from '../../core/random/index.js';
6
- import { createRng } from '../../core/random/index.js';
7
- import type { KeyScale, NoteEvent } from '../../core/types.js';
8
- import {
9
- assertGenerationBudget,
10
- assertInteger,
11
- assertRange,
12
- assertTimeSignature,
13
- } from '../../core/validation/index.js';
14
- import type { Chord } from '../../theory/chord/index.js';
15
- import { chordToneRole } from '../../theory/chord/index.js';
16
- import { createsParallelPerfect, isForbiddenMelodicLeap } from '../../theory/counterpoint/index.js';
17
- import type { VoiceSnapshot } from '../../theory/safety/index.js';
18
- import { enumerateSafePitches, evaluateSafety, NoteSafety } from '../../theory/safety/index.js';
19
-
20
- /**
21
- * Options controlling {@link generateCounterMelody}.
22
- *
23
- * @category Voicing & Counterpoint
24
- */
25
- export type CounterMelodyOptions = {
26
- /** The lead line to write against, in ascending onset order. */
27
- melody: NoteEvent[];
28
- /** Chord sounding at an absolute beat position, or null when none applies. */
29
- chordAt: (beat: number) => Chord | null;
30
- /**
31
- * Exact chord-change beats exposed by the caller's timeline. The generator
32
- * also probes its half-beat grid, but callback discontinuities between grid
33
- * points are not introspectable and should be listed here.
34
- */
35
- chordChangeBeats?: number[];
36
- /** Key/scale context for scale-tone decisions. */
37
- key: KeyScale;
38
- /**
39
- * Meter used for strong-beat decisions.
40
- *
41
- * @defaultValue 4/4
42
- */
43
- ts?: TimeSignature;
44
- /**
45
- * Which side of the melody the counter line occupies.
46
- *
47
- * @defaultValue 'below'
48
- */
49
- register?: 'above' | 'below';
50
- /**
51
- * Onset strategy: `'complement'` moves where the melody holds or rests and
52
- * reinforces some strong beats; `'follow'` mirrors melody onsets.
53
- *
54
- * @defaultValue 'complement'
55
- */
56
- rhythm?: 'complement' | 'follow';
57
- /**
58
- * Safety profile applied to candidate pitches.
59
- *
60
- * @defaultValue 'pop'
61
- */
62
- profile?: 'strict' | 'pop';
63
- /** Lowest MIDI pitch the counter line may use (default derived from `register`). */
64
- pitchLow?: number;
65
- /** Highest MIDI pitch the counter line may use (default derived from `register`). */
66
- pitchHigh?: number;
67
- /**
68
- * PRNG seed; the same seed always yields the same line.
69
- *
70
- * @defaultValue 0
71
- */
72
- seed?: number;
73
- };
74
-
75
- /** Meter assumed when none is supplied. */
76
- const DEFAULT_TS: TimeSignature = { numerator: 4, denominator: 4 };
77
-
78
- /** Tolerance for beat-position comparisons. */
79
- const EPS = 1e-9;
80
-
81
- /** Grid resolution, in quarter-note beats, scanned for complement onsets. */
82
- const GRID_STEP = 0.5;
83
-
84
- /** Minimum spacing between counter onsets, keeping the line uncluttered. */
85
- const MIN_ONSET_GAP = 1;
86
-
87
- /** Semitone offset of the counter register's centre from the melody's mean pitch. */
88
- const REGISTER_OFFSET = 12;
89
-
90
- /** Half-width, in semitones, of the default counter register. */
91
- const DEFAULT_HALF_RANGE = 10;
92
-
93
- /** Probability of moving on a whole beat while the melody sustains. */
94
- const HOLD_ON_BEAT_PROB = 0.55;
95
-
96
- /** Probability of moving on an off-beat subdivision while the melody sustains. */
97
- const HOLD_OFF_BEAT_PROB = 0.2;
98
-
99
- /** Probability of reinforcing a strong beat that the melody also attacks. */
100
- const REINFORCE_PROB = 0.35;
101
-
102
- /** Velocity assumed for melody notes that carry none. */
103
- const DEFAULT_MELODY_VELOCITY = 96;
104
-
105
- /** How far below the concurrent melody velocity the counter line sits. */
106
- const VELOCITY_DROP = 16;
107
-
108
- /** Whether a beat position falls on a whole quarter-note beat. */
109
- function isWholeBeat(beat: number): boolean {
110
- return Math.abs(beat - Math.round(beat)) < EPS;
111
- }
112
-
113
- /** The melody note sounding at a beat (latest onset wins on overlaps). */
114
- function melodyNoteAt(melody: NoteEventIndex, beat: number): NoteEvent | undefined {
115
- return melody.at(beat)?.note;
116
- }
117
-
118
- /** Whether any melody note attacks exactly at a beat. */
119
- function melodyAttacksAt(melody: NoteEventIndex, beat: number): boolean {
120
- return melody.attacksAt(beat);
121
- }
122
-
123
- /** Melody voice at a transition, including the pitch immediately before it. */
124
- function melodySnapshotAt(melody: NoteEventIndex, beat: number): VoiceSnapshot | undefined {
125
- const current = melodyNoteAt(melody, beat);
126
- if (current === undefined) {
127
- return undefined;
128
- }
129
- const attacked = Math.abs(current.startBeat - beat) < EPS;
130
- const previous = attacked ? melodyNoteAt(melody, beat - EPS * 2) : current;
131
- const snapshot: VoiceSnapshot = { pitch: current.pitch };
132
- if (previous !== undefined) {
133
- snapshot.prevPitch = previous.pitch;
134
- }
135
- return snapshot;
136
- }
137
-
138
- /** Counter onsets mirroring the melody's own onsets, deduplicated and sorted. */
139
- function followOnsets(melody: NoteEventIndex): number[] {
140
- return [...new Set(melody.notes.map(({ note }) => note.startBeat))];
141
- }
142
-
143
- /**
144
- * Counter onsets complementing the melody: whole beats inside rests are always
145
- * taken, positions where the melody sustains are taken probabilistically, and
146
- * strong beats the melody also attacks are occasionally reinforced. A minimum
147
- * gap between accepted onsets keeps the line sparse.
148
- */
149
- function complementOnsets(
150
- melody: NoteEventIndex,
151
- ts: TimeSignature,
152
- spanStart: number,
153
- spanEnd: number,
154
- rng: Rng,
155
- ): number[] {
156
- const onsets: number[] = [];
157
- const gridStart = Math.floor(spanStart / GRID_STEP) * GRID_STEP;
158
- let last = Number.NEGATIVE_INFINITY;
159
- for (let beat = gridStart; beat < spanEnd - EPS; beat += GRID_STEP) {
160
- const attacked = melodyAttacksAt(melody, beat);
161
- const sounding = melodyNoteAt(melody, beat) !== undefined;
162
- let place = false;
163
- if (!attacked && !sounding) {
164
- place = isWholeBeat(beat);
165
- } else if (!attacked) {
166
- place = rng.prob(isWholeBeat(beat) ? HOLD_ON_BEAT_PROB : HOLD_OFF_BEAT_PROB);
167
- } else if (isStrongBeat(beat, ts)) {
168
- place = rng.prob(REINFORCE_PROB);
169
- }
170
- if (place && beat - last >= MIN_ONSET_GAP - EPS) {
171
- onsets.push(beat);
172
- last = beat;
173
- }
174
- }
175
- return onsets;
176
- }
177
-
178
- /**
179
- * Preference score for one candidate counter pitch. Rewards imperfect
180
- * consonance with the melody, chord-tone membership, contrary or oblique
181
- * motion, and stepwise movement; penalizes wide or forbidden leaps, weak-beat
182
- * clashes, and drifting from the register centre.
183
- */
184
- function scoreCandidate(
185
- pitch: number,
186
- melPitch: number | undefined,
187
- melPrev: number | undefined,
188
- prevPitch: number | undefined,
189
- chord: Chord | null,
190
- center: number,
191
- ): number {
192
- let score = 0;
193
- if (melPitch !== undefined) {
194
- const ic = Math.abs(pitch - melPitch) % 12;
195
- if (ic === 3 || ic === 4 || ic === 8 || ic === 9) {
196
- score += 2; // imperfect consonance: thirds and sixths
197
- } else if (ic === 0 || ic === 7) {
198
- score += 0.5; // perfect consonance: allowed but less colourful
199
- } else if (ic === 1 || ic === 2 || ic === 6 || ic === 10 || ic === 11) {
200
- score -= 2; // dissonant even on weak beats
201
- }
202
- }
203
- if (chord && chordToneRole(pitch, chord) !== null) {
204
- score += 1;
205
- }
206
- if (prevPitch !== undefined) {
207
- const move = pitch - prevPitch;
208
- const dist = Math.abs(move);
209
- score -= dist * 0.3;
210
- if (dist > 0 && dist <= 2) {
211
- score += 1; // stepwise motion
212
- }
213
- if (isForbiddenMelodicLeap(prevPitch, pitch)) {
214
- score -= 6;
215
- }
216
- if (melPitch !== undefined && melPrev !== undefined) {
217
- const melMove = melPitch - melPrev;
218
- if ((move > 0 && melMove < 0) || (move < 0 && melMove > 0)) {
219
- score += 2; // contrary motion
220
- } else if (move === 0 || melMove === 0) {
221
- score += 1; // oblique motion
222
- } else {
223
- score -= 1; // similar motion
224
- }
225
- }
226
- }
227
- score -= Math.abs(pitch - center) * 0.02;
228
- return score;
229
- }
230
-
231
- /** Event boundaries at which a held counter pitch can acquire a new context. */
232
- function heldNoteBoundaries(
233
- melody: NoteEventIndex,
234
- startBeat: number,
235
- endBeat: number,
236
- chordChangeBeats: number[],
237
- ): number[] {
238
- const boundaries = new Set<number>([startBeat]);
239
- for (const onset of melody.onsetsBetween(startBeat, endBeat)) {
240
- boundaries.add(onset);
241
- }
242
- for (const beat of chordChangeBeats) {
243
- if (beat > startBeat + EPS && beat < endBeat - EPS) {
244
- boundaries.add(beat);
245
- }
246
- }
247
- // `chordAt` is an opaque callback. Probe the same half-beat grid used by the
248
- // complement rhythm so ordinary beat/bar chord changes are still found even
249
- // when the caller does not provide `chordChangeBeats`.
250
- const firstGrid = Math.floor(startBeat / GRID_STEP + 1) * GRID_STEP;
251
- for (let beat = firstGrid; beat < endBeat - EPS; beat += GRID_STEP) {
252
- boundaries.add(beat);
253
- }
254
- return [...boundaries].sort((a, b) => a - b);
255
- }
256
-
257
- /**
258
- * Worst safety reached while `pitch` is held through its complete interval.
259
- * A wrong-side crossing is treated as unavailable. Safe candidates are later
260
- * preferred over Warning candidates; Dissonant candidates are never emitted.
261
- */
262
- function heldPitchSafety(
263
- pitch: number,
264
- startBeat: number,
265
- endBeat: number,
266
- prevPitch: number | undefined,
267
- melody: NoteEventIndex,
268
- opts: CounterMelodyOptions,
269
- profile: 'strict' | 'pop',
270
- register: 'above' | 'below',
271
- low: number,
272
- high: number,
273
- ts: TimeSignature,
274
- ): NoteSafety | null {
275
- let worst = NoteSafety.Safe;
276
- const boundaries = heldNoteBoundaries(melody, startBeat, endBeat, opts.chordChangeBeats ?? []);
277
- for (const boundary of boundaries) {
278
- const melodyVoice = melodySnapshotAt(melody, boundary);
279
- if (
280
- melodyVoice !== undefined &&
281
- ((register === 'below' && pitch >= melodyVoice.pitch) ||
282
- (register === 'above' && pitch <= melodyVoice.pitch))
283
- ) {
284
- return null;
285
- }
286
- const atCounterOnset = Math.abs(boundary - startBeat) < EPS;
287
- const result = evaluateSafety({
288
- profile,
289
- candidatePitch: pitch,
290
- prevPitch: atCounterOnset ? prevPitch : pitch,
291
- chord: opts.chordAt(boundary),
292
- key: opts.key,
293
- otherVoices: melodyVoice ? [melodyVoice] : [],
294
- strongBeat: isStrongBeat(boundary, ts),
295
- vocalLow: low,
296
- vocalHigh: high,
297
- });
298
- worst = Math.max(worst, result.safety) as NoteSafety;
299
- if (worst === NoteSafety.Dissonant) {
300
- return worst;
301
- }
302
- }
303
- return worst;
304
- }
305
-
306
- /**
307
- * Generate a counter melody against a lead line.
308
- *
309
- * Onsets are chosen per `rhythm`: `'follow'` places one counter note on each
310
- * melody onset; `'complement'` fills melody rests and sustains and reinforces
311
- * an occasional strong beat, spaced at least a beat apart. Each onset's pitch
312
- * is picked from the safe pitches in the counter register (chord tones and
313
- * consonant tensions against the sounding melody note), rejecting candidates
314
- * that would form a parallel perfect interval with the melody or sit on the
315
- * wrong side of it, then scored to favour contrary or oblique motion, imperfect
316
- * consonance, and stepwise movement. Ties break toward the lower pitch, so a
317
- * given seed always yields the same line. Notes extend to the next counter
318
- * onset (the last to the melody's end) at a velocity slightly under the melody.
319
- *
320
- * @param opts The lead line, chord context callback, key, and generation knobs.
321
- * @returns The counter line as note events sorted by onset; `[]` for an empty melody.
322
- * @example
323
- * ```ts
324
- * import { generateCounterMelody, majorKey, parseChordSymbol } from '@libraz/libcantus';
325
- * const melody = [{ pitch: 72, startBeat: 0, durationBeat: 2 }];
326
- * const counter = generateCounterMelody({
327
- * melody,
328
- * chordAt: () => parseChordSymbol('C'),
329
- * key: majorKey(0),
330
- * }); // note events below the melody, in onset order
331
- * ```
332
- * @category Voicing & Counterpoint
333
- */
334
- export function generateCounterMelody(opts: CounterMelodyOptions): NoteEvent[] {
335
- const melody = createNoteEventIndex(opts.melody);
336
- if (melody.notes.length === 0) {
337
- return [];
338
- }
339
- const ts = opts.ts ?? DEFAULT_TS;
340
- assertTimeSignature(ts);
341
- const register = opts.register ?? 'below';
342
- const rhythm = opts.rhythm ?? 'complement';
343
- const profile = opts.profile ?? 'pop';
344
- const rng = createRng(opts.seed ?? 0);
345
-
346
- const spanStart = melody.notes[0]?.note.startBeat ?? 0;
347
- const spanEnd = melody.notes.reduce((end, n) => Math.max(end, n.endBeat), spanStart);
348
- assertGenerationBudget(Math.ceil((spanEnd - spanStart) / GRID_STEP), 'countermelody grid');
349
- const meanPitch = melody.notes.reduce((sum, n) => sum + n.note.pitch, 0) / melody.notes.length;
350
- const defaultCenter =
351
- register === 'below' ? meanPitch - REGISTER_OFFSET : meanPitch + REGISTER_OFFSET;
352
- const low = opts.pitchLow ?? Math.round(defaultCenter) - DEFAULT_HALF_RANGE;
353
- const high = opts.pitchHigh ?? Math.round(defaultCenter) + DEFAULT_HALF_RANGE;
354
- assertInteger(low, 'countermelody pitchLow');
355
- assertInteger(high, 'countermelody pitchHigh');
356
- if (low > high) {
357
- throw new RangeError(
358
- `countermelody pitchLow must not exceed pitchHigh; received ${low} > ${high}`,
359
- );
360
- }
361
- assertGenerationBudget(high - low + 1, 'countermelody pitch candidates');
362
- assertGenerationBudget(opts.chordChangeBeats?.length ?? 0, 'chord change beats');
363
- for (let index = 0; index < (opts.chordChangeBeats?.length ?? 0); index += 1) {
364
- assertRange(
365
- opts.chordChangeBeats?.[index] ?? Number.NaN,
366
- 0,
367
- Number.MAX_SAFE_INTEGER,
368
- `chordChangeBeats[${index}]`,
369
- );
370
- }
371
- const center = (low + high) / 2;
372
-
373
- const onsets =
374
- rhythm === 'follow'
375
- ? followOnsets(melody)
376
- : complementOnsets(melody, ts, spanStart, spanEnd, rng);
377
- assertGenerationBudget(onsets.length * (high - low + 1), 'countermelody search');
378
-
379
- const out: NoteEvent[] = [];
380
- let prevPitch: number | undefined;
381
- for (let onsetIndex = 0; onsetIndex < onsets.length; onsetIndex += 1) {
382
- const beat = onsets[onsetIndex];
383
- if (beat === undefined) {
384
- continue;
385
- }
386
- const nextOnset = onsets[onsetIndex + 1];
387
- const endBeat = nextOnset ?? Math.max(spanEnd, beat + GRID_STEP);
388
- const melNote = melodyNoteAt(melody, beat);
389
- const melPitch = melNote?.pitch;
390
- const melodyVoice = melodySnapshotAt(melody, beat);
391
- const melPrev = melodyVoice?.prevPitch;
392
- const chord = opts.chordAt(beat);
393
- const otherVoices: VoiceSnapshot[] = melodyVoice !== undefined ? [melodyVoice] : [];
394
-
395
- const candidates = enumerateSafePitches(
396
- {
397
- profile,
398
- prevPitch,
399
- chord,
400
- key: opts.key,
401
- otherVoices,
402
- strongBeat: isStrongBeat(beat, ts),
403
- vocalLow: low,
404
- vocalHigh: high,
405
- },
406
- low,
407
- high,
408
- );
409
-
410
- let bestPitch: number | undefined;
411
- let bestWorstSafety = NoteSafety.Dissonant;
412
- let bestScore = Number.NEGATIVE_INFINITY;
413
- for (const candidate of candidates) {
414
- const worstSafety = heldPitchSafety(
415
- candidate,
416
- beat,
417
- endBeat,
418
- prevPitch,
419
- melody,
420
- opts,
421
- profile,
422
- register,
423
- low,
424
- high,
425
- ts,
426
- );
427
- if (worstSafety === null || worstSafety === NoteSafety.Dissonant) {
428
- continue;
429
- }
430
- if (melPitch !== undefined) {
431
- if (
432
- prevPitch !== undefined &&
433
- melPrev !== undefined &&
434
- // Parallel octaves are the perfect-class-zero case of this predicate,
435
- // so a single check covers both parallel fifths and octaves.
436
- createsParallelPerfect(prevPitch, candidate, melPrev, melPitch)
437
- ) {
438
- continue;
439
- }
440
- }
441
- const score = scoreCandidate(candidate, melPitch, melPrev, prevPitch, chord, center);
442
- const tie =
443
- Math.abs(score - bestScore) <= EPS && candidate < (bestPitch ?? Number.POSITIVE_INFINITY);
444
- if (
445
- worstSafety < bestWorstSafety ||
446
- (worstSafety === bestWorstSafety && (score > bestScore + EPS || tie))
447
- ) {
448
- bestWorstSafety = worstSafety;
449
- bestScore = score;
450
- bestPitch = candidate;
451
- }
452
- }
453
-
454
- if (bestPitch === undefined) {
455
- // Do not emit an unchecked fallback. Ending the previous note at this
456
- // planned onset leaves an explicit rest when the constraints are
457
- // unsatisfiable instead of returning a plausibly named unsafe line.
458
- prevPitch = undefined;
459
- continue;
460
- }
461
- const pitch = bestPitch;
462
- const velocity = Math.max(
463
- 1,
464
- Math.min(127, Math.round((melNote?.velocity ?? DEFAULT_MELODY_VELOCITY) - VELOCITY_DROP)),
465
- );
466
- out.push({ pitch, startBeat: beat, durationBeat: endBeat - beat, velocity });
467
- prevPitch = pitch;
468
- }
469
- return out;
470
- }