@libraz/libcantus 0.9.3 → 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.
- package/README.md +2 -2
- package/dist/analyze/index.cjs +39 -1947
- package/dist/analyze/index.cjs.map +1 -1
- package/dist/analyze/index.d.cts +197 -37
- package/dist/analyze/index.d.ts +197 -37
- package/dist/analyze/index.js +19 -8
- package/dist/chunk-36N73KZ3.cjs +246 -0
- package/dist/chunk-36N73KZ3.cjs.map +1 -0
- package/dist/chunk-4HLLZPXT.cjs +3077 -0
- package/dist/chunk-4HLLZPXT.cjs.map +1 -0
- package/dist/chunk-7MGZ7D62.cjs +498 -0
- package/dist/chunk-7MGZ7D62.cjs.map +1 -0
- package/dist/{chunk-4MHA432G.js → chunk-7NKDPF74.js} +403 -180
- package/dist/chunk-7NKDPF74.js.map +1 -0
- package/dist/chunk-CAN76NCU.cjs +679 -0
- package/dist/chunk-CAN76NCU.cjs.map +1 -0
- package/dist/{chunk-MIWO425M.js → chunk-D2FAJBYL.js} +101 -34
- package/dist/chunk-D2FAJBYL.js.map +1 -0
- package/dist/{chunk-SGU7SPLK.js → chunk-EHVEBWM5.js} +1056 -531
- package/dist/chunk-EHVEBWM5.js.map +1 -0
- package/dist/chunk-FCANXBAT.cjs +30 -0
- package/dist/chunk-FCANXBAT.cjs.map +1 -0
- package/dist/chunk-FNDWXRRI.cjs +479 -0
- package/dist/chunk-FNDWXRRI.cjs.map +1 -0
- package/dist/{chunk-IT2O4XFE.js → chunk-GQSGS2N4.js} +43 -24
- package/dist/chunk-GQSGS2N4.js.map +1 -0
- package/dist/{chunk-62QZKCJQ.js → chunk-HKGYCQ34.js} +10 -6
- package/dist/chunk-HKGYCQ34.js.map +1 -0
- package/dist/{chunk-AKLYPWIN.js → chunk-I24MV65U.js} +29 -6
- package/dist/chunk-I24MV65U.js.map +1 -0
- package/dist/{chunk-CHMXELDS.js → chunk-KMHOICIY.js} +311 -181
- package/dist/chunk-KMHOICIY.js.map +1 -0
- package/dist/chunk-LW53AHQU.cjs +1117 -0
- package/dist/chunk-LW53AHQU.cjs.map +1 -0
- package/dist/{chunk-2ZWMSJH6.js → chunk-NKIBQ6PT.js} +2 -2
- package/dist/chunk-NKIBQ6PT.js.map +1 -0
- package/dist/chunk-NP4NA2FB.cjs +106 -0
- package/dist/chunk-NP4NA2FB.cjs.map +1 -0
- package/dist/chunk-NVE34YGZ.cjs +1449 -0
- package/dist/chunk-NVE34YGZ.cjs.map +1 -0
- package/dist/{chunk-EH6EM37U.js → chunk-NYYWMV2C.js} +61 -49
- package/dist/chunk-NYYWMV2C.js.map +1 -0
- package/dist/{chunk-GOAW4VYL.js → chunk-Q2XUP227.js} +243 -117
- package/dist/chunk-Q2XUP227.js.map +1 -0
- package/dist/chunk-Q6OP4U43.cjs +327 -0
- package/dist/chunk-Q6OP4U43.cjs.map +1 -0
- package/dist/chunk-RC6GBMCD.cjs +845 -0
- package/dist/chunk-RC6GBMCD.cjs.map +1 -0
- package/dist/{chunk-C5XYKUVG.js → chunk-RK35RIN7.js} +370 -157
- package/dist/chunk-RK35RIN7.js.map +1 -0
- package/dist/chunk-RUMU3UA6.cjs +53 -0
- package/dist/chunk-RUMU3UA6.cjs.map +1 -0
- package/dist/chunk-T64C7ZD7.cjs +1 -0
- package/dist/chunk-T64C7ZD7.cjs.map +1 -0
- package/dist/chunk-XEBG3G53.js +498 -0
- package/dist/chunk-XEBG3G53.js.map +1 -0
- package/dist/{chunk-I5R7ZBJS.js → chunk-YLEMGEFH.js} +103 -59
- package/dist/chunk-YLEMGEFH.js.map +1 -0
- package/dist/chunk-ZEJR4TKL.cjs +173 -0
- package/dist/chunk-ZEJR4TKL.cjs.map +1 -0
- package/dist/core/index.cjs +130 -647
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +301 -39
- package/dist/core/index.d.ts +301 -39
- package/dist/core/index.js +54 -16
- package/dist/{function-DYt4iKsn.d.cts → function-BtiZb9i9.d.ts} +28 -7
- package/dist/{function-DVH4Eusr.d.ts → function-DNugAOrt.d.cts} +28 -7
- package/dist/generate/index.cjs +28 -4085
- package/dist/generate/index.cjs.map +1 -1
- package/dist/generate/index.d.cts +370 -62
- package/dist/generate/index.d.ts +370 -62
- package/dist/generate/index.js +23 -9
- package/dist/{index-CxqBQtH1.d.cts → index-6VR8-kit.d.ts} +19 -4
- package/dist/index-70p168VM.d.ts +192 -0
- package/dist/{index-CpOsP1y9.d.cts → index-Be74mcon.d.cts} +18 -17
- package/dist/index-BtXT6UQ0.d.cts +192 -0
- package/dist/{index-DxvARIP2.d.ts → index-C5UBbQ4G.d.ts} +18 -17
- package/dist/{index-Bt4tOUD9.d.ts → index-C83mZI1P.d.ts} +93 -8
- package/dist/{index-Bg5dYMmZ.d.ts → index-D6b6na69.d.cts} +14 -11
- package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
- package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
- package/dist/index-OG65ocOr.d.cts +272 -0
- package/dist/index-OG65ocOr.d.ts +272 -0
- package/dist/{index-Jc0Ryi79.d.ts → index-tJhkmQgE.d.cts} +19 -4
- package/dist/{index-Dikst44v.d.cts → index-tSQh-xwY.d.ts} +14 -11
- package/dist/{index-BNlGP86A.d.cts → index-y_ak9_Hi.d.cts} +93 -8
- package/dist/index.cjs +419 -7383
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -14
- package/dist/index.d.ts +14 -14
- package/dist/index.js +111 -27
- package/dist/model/index.cjs +8 -2562
- package/dist/model/index.cjs.map +1 -1
- package/dist/model/index.d.cts +433 -75
- package/dist/model/index.d.ts +433 -75
- package/dist/model/index.js +6 -7
- package/dist/styled-DIRkk9Uz.d.ts +506 -0
- package/dist/styled-DJbkJQfj.d.cts +506 -0
- package/dist/theory/index.cjs +172 -1822
- package/dist/theory/index.cjs.map +1 -1
- package/dist/theory/index.d.cts +151 -233
- package/dist/theory/index.d.ts +151 -233
- package/dist/theory/index.js +30 -7
- package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
- package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
- package/package.json +24 -6
- package/dist/chunk-2ZWMSJH6.js.map +0 -1
- package/dist/chunk-4MHA432G.js.map +0 -1
- package/dist/chunk-62QZKCJQ.js.map +0 -1
- package/dist/chunk-AKLYPWIN.js.map +0 -1
- package/dist/chunk-C5XYKUVG.js.map +0 -1
- package/dist/chunk-CHMXELDS.js.map +0 -1
- package/dist/chunk-DYWFNC4A.js +0 -152
- package/dist/chunk-DYWFNC4A.js.map +0 -1
- package/dist/chunk-EH6EM37U.js.map +0 -1
- package/dist/chunk-FBQK3ZQN.js +0 -131
- package/dist/chunk-FBQK3ZQN.js.map +0 -1
- package/dist/chunk-GOAW4VYL.js.map +0 -1
- package/dist/chunk-I5R7ZBJS.js.map +0 -1
- package/dist/chunk-IT2O4XFE.js.map +0 -1
- package/dist/chunk-MIWO425M.js.map +0 -1
- package/dist/chunk-SGU7SPLK.js.map +0 -1
- package/dist/index-CGMZNCx6.d.cts +0 -140
- package/dist/index-CGMZNCx6.d.ts +0 -140
- package/dist/index-CnZCo29u.d.cts +0 -108
- package/dist/index-JnF4v9bx.d.ts +0 -108
- package/dist/styled-DZ7GjShb.d.ts +0 -282
- package/dist/styled-ZTbdaVFM.d.cts +0 -282
- package/src/analyze/arrange/index.ts +0 -21
- package/src/analyze/arrange/internal.ts +0 -127
- package/src/analyze/arrange/tension.ts +0 -199
- package/src/analyze/arrange/tracks.ts +0 -312
- package/src/analyze/detect/index.ts +0 -272
- package/src/analyze/functional/borrowed.ts +0 -84
- package/src/analyze/functional/cadence.ts +0 -66
- package/src/analyze/functional/function.ts +0 -184
- package/src/analyze/functional/index.ts +0 -23
- package/src/analyze/functional/internal.ts +0 -29
- package/src/analyze/functional/roman.ts +0 -402
- package/src/analyze/index.ts +0 -49
- package/src/analyze/timeline/index.ts +0 -451
- package/src/analyze/voice/index.ts +0 -339
- package/src/core/event-index/index.ts +0 -104
- package/src/core/index.ts +0 -61
- package/src/core/interval/index.ts +0 -81
- package/src/core/meter/index.ts +0 -302
- package/src/core/pitch/index.ts +0 -311
- package/src/core/random/index.ts +0 -76
- package/src/core/tuning/index.ts +0 -186
- package/src/core/types.ts +0 -29
- package/src/core/validation/index.ts +0 -119
- package/src/generate/bass/index.ts +0 -373
- package/src/generate/countermelody/index.ts +0 -470
- package/src/generate/drums/beat.ts +0 -266
- package/src/generate/drums/euclid.ts +0 -65
- package/src/generate/drums/fills.ts +0 -356
- package/src/generate/drums/ghost.ts +0 -119
- package/src/generate/drums/hihat.ts +0 -285
- package/src/generate/drums/hit.ts +0 -31
- package/src/generate/drums/index.ts +0 -361
- package/src/generate/drums/internal.ts +0 -257
- package/src/generate/drums/kick.ts +0 -160
- package/src/generate/drums/percussion.ts +0 -121
- package/src/generate/drums/rng.ts +0 -7
- package/src/generate/drums/swing.ts +0 -71
- package/src/generate/groove/index.ts +0 -351
- package/src/generate/harmonize/index.ts +0 -451
- package/src/generate/index.ts +0 -58
- package/src/generate/motif/index.ts +0 -422
- package/src/generate/progression/index.ts +0 -362
- package/src/generate/reharmony/index.ts +0 -323
- package/src/generate/rhythm/index.ts +0 -175
- package/src/index.ts +0 -13
- package/src/model/chord.ts +0 -442
- package/src/model/index.ts +0 -15
- package/src/model/interval.ts +0 -82
- package/src/model/key.ts +0 -266
- package/src/model/note.ts +0 -176
- package/src/model/progression.ts +0 -161
- package/src/model/shared.ts +0 -12
- package/src/theory/chord/index.ts +0 -375
- package/src/theory/chordscale/index.ts +0 -374
- package/src/theory/counterpoint/index.ts +0 -230
- package/src/theory/harmony/index.ts +0 -77
- package/src/theory/index.ts +0 -112
- package/src/theory/safety/index.ts +0 -488
- package/src/theory/scale/degrees.ts +0 -123
- package/src/theory/scale/index.ts +0 -28
- package/src/theory/scale/key.ts +0 -56
- package/src/theory/scale/masks.ts +0 -153
- package/src/theory/spelling/index.ts +0 -249
- package/src/theory/symbol/index.ts +0 -268
- package/src/theory/voicing/index.ts +0 -5
- package/src/theory/voicing/internal.ts +0 -168
- package/src/theory/voicing/leading.ts +0 -112
- package/src/theory/voicing/satb.ts +0 -216
- package/src/theory/voicing/styled.ts +0 -199
- package/tsconfig.json +0 -23
|
@@ -1,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
|
-
}
|