@libraz/libcantus 0.9.0

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 (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +291 -0
  3. package/dist/index.cjs +6465 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +2621 -0
  6. package/dist/index.d.ts +2621 -0
  7. package/dist/index.js +6297 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +71 -0
  10. package/src/analysis/index.ts +261 -0
  11. package/src/arrange/index.ts +409 -0
  12. package/src/bass/index.ts +301 -0
  13. package/src/chord/index.ts +292 -0
  14. package/src/chordscale/index.ts +316 -0
  15. package/src/countermelody/index.ts +349 -0
  16. package/src/counterpoint/index.ts +209 -0
  17. package/src/detect/index.ts +176 -0
  18. package/src/drums/beat.ts +266 -0
  19. package/src/drums/euclid.ts +58 -0
  20. package/src/drums/fills.ts +345 -0
  21. package/src/drums/ghost.ts +119 -0
  22. package/src/drums/hihat.ts +285 -0
  23. package/src/drums/hit.ts +31 -0
  24. package/src/drums/index.ts +211 -0
  25. package/src/drums/internal.ts +251 -0
  26. package/src/drums/kick.ts +160 -0
  27. package/src/drums/percussion.ts +121 -0
  28. package/src/drums/rng.ts +7 -0
  29. package/src/drums/swing.ts +66 -0
  30. package/src/functional/index.ts +602 -0
  31. package/src/groove/index.ts +230 -0
  32. package/src/harmonize/index.ts +367 -0
  33. package/src/harmony/index.ts +75 -0
  34. package/src/index.ts +233 -0
  35. package/src/interval/index.ts +67 -0
  36. package/src/meter/index.ts +179 -0
  37. package/src/model/index.ts +957 -0
  38. package/src/motif/index.ts +321 -0
  39. package/src/pitch/index.ts +256 -0
  40. package/src/progression/index.ts +336 -0
  41. package/src/random/index.ts +41 -0
  42. package/src/reharmony/index.ts +299 -0
  43. package/src/rhythm/index.ts +131 -0
  44. package/src/safety/index.ts +372 -0
  45. package/src/scale/index.ts +215 -0
  46. package/src/spelling/index.ts +164 -0
  47. package/src/symbol/index.ts +213 -0
  48. package/src/timeline/index.ts +352 -0
  49. package/src/tuning/index.ts +129 -0
  50. package/src/types.ts +29 -0
  51. package/src/voicing/index.ts +523 -0
  52. package/tsconfig.json +23 -0
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Groove and humanization: nudging quantized note events toward a more human
3
+ * feel, and capturing/replaying the timing-and-velocity "feel" of a
4
+ * performance as a reusable groove template.
5
+ *
6
+ * Positions and durations are measured in quarter-note beats, matching the
7
+ * library-wide convention. Randomness, where used, always goes through the
8
+ * seeded PRNG from the `random` module so results are fully reproducible.
9
+ */
10
+
11
+ import { beatsPerBar, metricWeight, type TimeSignature } from '../meter/index.js';
12
+ import { createRng } from '../random/index.js';
13
+ import type { NoteEvent } from '../types.js';
14
+
15
+ /** Options controlling {@link humanize}. */
16
+ export type HumanizeOptions = {
17
+ /** Time signature used to derive metric accents. Default 4/4. */
18
+ ts?: TimeSignature;
19
+ /** Maximum timing jitter in quarter-note beats, applied as ±this value. Default 0.02. */
20
+ timing?: number;
21
+ /** Maximum velocity jitter (MIDI units), applied as ±this value. Default 8. */
22
+ velocity?: number;
23
+ /** How much louder strong beats get relative to weak ones, in MIDI velocity units. Default 12. */
24
+ accent?: number;
25
+ /** Velocity assumed for events with no `velocity` of their own. Default 80. */
26
+ baseVelocity?: number;
27
+ /** Seed for the deterministic PRNG. Default 0. */
28
+ seed?: number;
29
+ };
30
+
31
+ /** Default time signature for {@link humanize} when none is given. */
32
+ const DEFAULT_TS: TimeSignature = { numerator: 4, denominator: 4 };
33
+ const DEFAULT_TIMING = 0.02;
34
+ const DEFAULT_VELOCITY_JITTER = 8;
35
+ const DEFAULT_ACCENT = 12;
36
+ const DEFAULT_BASE_VELOCITY = 80;
37
+ const MAX_METRIC_WEIGHT = 3;
38
+ const MIN_VELOCITY = 1;
39
+ const MAX_VELOCITY = 127;
40
+
41
+ /**
42
+ * Humanize a sequence of note events by nudging their timing and velocity
43
+ * with small, deterministic (seeded) randomness plus a metric-accent shape:
44
+ * events on strong beats (see {@link metricWeight}) come out louder on
45
+ * average than events on weak ones.
46
+ *
47
+ * Each returned event is a copy; `startBeat` is jittered within
48
+ * `[-timing, +timing]` beats and clamped to be non-negative, and `velocity`
49
+ * is the event's own velocity (or `baseVelocity` if it has none) plus an
50
+ * accent term scaled by the event's metric weight plus a jitter within
51
+ * `[-velocity, +velocity]`, clamped to `[1, 127]` and rounded. The metric
52
+ * weight is computed from each event's original (pre-jitter) `startBeat`.
53
+ * `pitch` and `durationBeat` pass through unchanged, and the output keeps the
54
+ * input order.
55
+ *
56
+ * @param events The events to humanize.
57
+ * @param opts Humanization options.
58
+ * @returns Humanized copies of `events`, in input order.
59
+ */
60
+ export function humanize(events: NoteEvent[], opts: HumanizeOptions = {}): NoteEvent[] {
61
+ const ts = opts.ts ?? DEFAULT_TS;
62
+ const timing = opts.timing ?? DEFAULT_TIMING;
63
+ const velocityJitter = opts.velocity ?? DEFAULT_VELOCITY_JITTER;
64
+ const accent = opts.accent ?? DEFAULT_ACCENT;
65
+ const baseVelocity = opts.baseVelocity ?? DEFAULT_BASE_VELOCITY;
66
+ const rng = createRng(opts.seed ?? 0);
67
+
68
+ return events.map((event) => {
69
+ const timingOffset = rng.float(-timing, timing);
70
+ const startBeat = Math.max(0, event.startBeat + timingOffset);
71
+
72
+ const weight = metricWeight(event.startBeat, ts);
73
+ const accentBoost = (weight / MAX_METRIC_WEIGHT) * accent;
74
+ const velocityOffset = rng.float(-velocityJitter, velocityJitter);
75
+ const rawVelocity = (event.velocity ?? baseVelocity) + accentBoost + velocityOffset;
76
+ const velocity = Math.min(MAX_VELOCITY, Math.max(MIN_VELOCITY, Math.round(rawVelocity)));
77
+
78
+ return {
79
+ pitch: event.pitch,
80
+ startBeat,
81
+ durationBeat: event.durationBeat,
82
+ velocity,
83
+ };
84
+ });
85
+ }
86
+
87
+ /** The recorded feel at a single grid slot: an average timing deviation and velocity. */
88
+ export type GrooveSlot = {
89
+ /** Average `actualStartBeat - quantizedBeat` (in quarter-note beats) for events landing on this slot. */
90
+ timingOffset: number;
91
+ /** Average velocity of events landing on this slot; 0 if none did. */
92
+ velocity: number;
93
+ };
94
+
95
+ /** A per-bar grid of timing and velocity deviations captured from a performance. */
96
+ export type GrooveTemplate = {
97
+ /** Grid resolution: equal grid steps per quarter-note beat. */
98
+ subdivision: number;
99
+ /** Number of grid slots per bar (`beatsPerBar(ts) * subdivision`). */
100
+ slotsPerBar: number;
101
+ /** One entry per grid slot, in slot-index order (slot 0 is the downbeat). */
102
+ slots: GrooveSlot[];
103
+ };
104
+
105
+ /**
106
+ * Quantize a beat position to the nearest grid slot at the given subdivision,
107
+ * within the bar that contains it.
108
+ *
109
+ * @param beat Absolute position in quarter-note beats.
110
+ * @param barBeats Bar length in quarter-note beats.
111
+ * @param subdivision Grid steps per quarter-note beat.
112
+ * @param slotsPerBar Number of grid slots per bar.
113
+ * @returns The quantized absolute beat and its 0-based slot index within the bar.
114
+ */
115
+ function quantizeToGrid(
116
+ beat: number,
117
+ barBeats: number,
118
+ subdivision: number,
119
+ slotsPerBar: number,
120
+ ): { quantizedBeat: number; slotIndex: number } {
121
+ const barIndex = Math.floor(beat / barBeats);
122
+ const modBeat = beat - barIndex * barBeats;
123
+ let slotIndex = Math.round(modBeat * subdivision);
124
+ let effectiveBarIndex = barIndex;
125
+ // Rounding the last slot in a bar up to the grid step count means it
126
+ // actually lands on the next bar's downbeat.
127
+ if (slotIndex >= slotsPerBar) {
128
+ slotIndex = 0;
129
+ effectiveBarIndex += 1;
130
+ }
131
+ return {
132
+ quantizedBeat: effectiveBarIndex * barBeats + slotIndex / subdivision,
133
+ slotIndex,
134
+ };
135
+ }
136
+
137
+ /**
138
+ * Extract a groove template from a set of note events: for each grid slot,
139
+ * the average timing deviation from the quantized grid and the average
140
+ * velocity of events landing on it. Slots with no events default to
141
+ * `{ timingOffset: 0, velocity: 0 }`.
142
+ *
143
+ * @param events The (typically "groovy", human-played) events to analyze.
144
+ * @param ts The time signature, used to compute the bar length.
145
+ * @param subdivision Grid resolution: equal grid steps per quarter-note beat.
146
+ * @returns The extracted groove template.
147
+ */
148
+ export function extractGrooveTemplate(
149
+ events: NoteEvent[],
150
+ ts: TimeSignature,
151
+ subdivision: number,
152
+ ): GrooveTemplate {
153
+ const barBeats = beatsPerBar(ts);
154
+ const slotsPerBar = Math.round(barBeats * subdivision);
155
+ const offsetSums = new Array<number>(slotsPerBar).fill(0);
156
+ const velocitySums = new Array<number>(slotsPerBar).fill(0);
157
+ const counts = new Array<number>(slotsPerBar).fill(0);
158
+ const velocityCounts = new Array<number>(slotsPerBar).fill(0);
159
+
160
+ for (const event of events) {
161
+ const { quantizedBeat, slotIndex } = quantizeToGrid(
162
+ event.startBeat,
163
+ barBeats,
164
+ subdivision,
165
+ slotsPerBar,
166
+ );
167
+ offsetSums[slotIndex] = (offsetSums[slotIndex] ?? 0) + (event.startBeat - quantizedBeat);
168
+ counts[slotIndex] = (counts[slotIndex] ?? 0) + 1;
169
+ // Only events that carry a velocity contribute to the velocity average, so a
170
+ // velocity-less event does not pull the slot's recorded velocity toward zero.
171
+ if (event.velocity !== undefined) {
172
+ velocitySums[slotIndex] = (velocitySums[slotIndex] ?? 0) + event.velocity;
173
+ velocityCounts[slotIndex] = (velocityCounts[slotIndex] ?? 0) + 1;
174
+ }
175
+ }
176
+
177
+ const slots: GrooveSlot[] = [];
178
+ for (let i = 0; i < slotsPerBar; i += 1) {
179
+ const count = counts[i] ?? 0;
180
+ if (count === 0) {
181
+ slots.push({ timingOffset: 0, velocity: 0 });
182
+ continue;
183
+ }
184
+ const velocityCount = velocityCounts[i] ?? 0;
185
+ slots.push({
186
+ timingOffset: (offsetSums[i] ?? 0) / count,
187
+ velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount : 0,
188
+ });
189
+ }
190
+
191
+ return { subdivision, slotsPerBar, slots };
192
+ }
193
+
194
+ /**
195
+ * Apply a groove template to a set of (typically quantized) note events: each
196
+ * event is snapped to its grid slot and then offset by that slot's recorded
197
+ * timing deviation, pushing stiff timing toward the template's feel.
198
+ * Velocity is set to the slot's recorded average when it recorded any
199
+ * (`velocity > 0`); otherwise the event's own velocity is left untouched.
200
+ *
201
+ * @param events The events to reshape.
202
+ * @param template The groove template, from {@link extractGrooveTemplate}.
203
+ * @param ts The time signature, used to compute the bar length.
204
+ * @returns Reshaped copies of `events`, in input order.
205
+ */
206
+ export function applyGrooveTemplate(
207
+ events: NoteEvent[],
208
+ template: GrooveTemplate,
209
+ ts: TimeSignature,
210
+ ): NoteEvent[] {
211
+ const barBeats = beatsPerBar(ts);
212
+ return events.map((event) => {
213
+ const { quantizedBeat, slotIndex } = quantizeToGrid(
214
+ event.startBeat,
215
+ barBeats,
216
+ template.subdivision,
217
+ template.slotsPerBar,
218
+ );
219
+ const slot = template.slots[slotIndex];
220
+ const startBeat = quantizedBeat + (slot?.timingOffset ?? 0);
221
+ const velocity = slot && slot.velocity > 0 ? Math.round(slot.velocity) : event.velocity;
222
+
223
+ return {
224
+ pitch: event.pitch,
225
+ startBeat,
226
+ durationBeat: event.durationBeat,
227
+ velocity,
228
+ };
229
+ });
230
+ }
@@ -0,0 +1,367 @@
1
+ import type { Chord, ChordQuality } from '../chord/index.js';
2
+ import { chordPitchClasses, diatonicTriad, makeChord } from '../chord/index.js';
3
+ import type { HarmonyRole } from '../harmony/index.js';
4
+ import { roleOf } from '../harmony/index.js';
5
+ import type { TimeSignature } from '../meter/index.js';
6
+ import { isStrongBeat } from '../meter/index.js';
7
+ import type { GeneratedChord } from '../progression/index.js';
8
+ import { isScaleTone, majorKey, scaleTonesInDegreeOrder } from '../scale/index.js';
9
+ import type { KeyScale, NoteEvent } from '../types.js';
10
+
11
+ /** A melody note supplied to the harmonizer: a {@link NoteEvent}. */
12
+ export type MelodyNote = NoteEvent;
13
+
14
+ /** Options controlling {@link harmonizeMelody}. */
15
+ export type HarmonizeOptions = {
16
+ melody: MelodyNote[];
17
+ key: KeyScale | 'infer';
18
+ harmonicRhythm: number;
19
+ reharmonize: 'diatonic' | 'secondaryDominant' | 'borrowed';
20
+ placement: { transposeSearch: boolean; octaveSearch: boolean };
21
+ seed?: number;
22
+ };
23
+
24
+ /** The chosen transpose, key, chord path, and per-note roles. */
25
+ export type HarmonizeResult = {
26
+ transposeSemitones: number;
27
+ key: KeyScale;
28
+ chords: GeneratedChord[];
29
+ melodyRoles: { noteIndex: number; role: HarmonyRole }[];
30
+ };
31
+
32
+ type Candidate = {
33
+ rootPc: number;
34
+ quality: ChordQuality;
35
+ degree?: number;
36
+ secondaryDominant: boolean;
37
+ targetDegree?: number;
38
+ base: number;
39
+ };
40
+
41
+ type Segment = { startBeat: number; endBeat: number; noteIndices: number[] };
42
+
43
+ const FALLBACK: Candidate = { rootPc: 0, quality: 'maj', secondaryDominant: false, base: 0 };
44
+
45
+ /** Default meter used to weight metric accents when none is supplied. */
46
+ const DEFAULT_METER: TimeSignature = { numerator: 4, denominator: 4 };
47
+
48
+ /** Comfortable melodic range (MIDI) and the per-semitone cost of leaving it. */
49
+ const COMFORT_LOW = 55;
50
+ const COMFORT_HIGH = 79;
51
+ const TESSITURA_WEIGHT = 0.001;
52
+
53
+ function pitchClass(pitch: number): number {
54
+ return ((Math.trunc(pitch) % 12) + 12) % 12;
55
+ }
56
+
57
+ function mulberry32(seed: number): () => number {
58
+ let state = seed >>> 0;
59
+ return () => {
60
+ state = (state + 0x6d2b79f5) >>> 0;
61
+ let t = state;
62
+ t = Math.imul(t ^ (t >>> 15), t | 1);
63
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
64
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
65
+ };
66
+ }
67
+
68
+ /** Estimate the best-fit major key from a melody's pitch-class weighting. */
69
+ function inferKey(melody: MelodyNote[]): KeyScale {
70
+ let best = 0;
71
+ let bestScore = Number.NEGATIVE_INFINITY;
72
+ for (let tonic = 0; tonic < 12; tonic += 1) {
73
+ const key = majorKey(tonic);
74
+ let score = 0;
75
+ for (const n of melody) {
76
+ const w = Math.max(0.25, n.durationBeat);
77
+ if (isScaleTone(n.pitch, key)) {
78
+ score += w;
79
+ }
80
+ if (pitchClass(n.pitch) === tonic) {
81
+ score += 0.5 * w;
82
+ }
83
+ }
84
+ if (score > bestScore) {
85
+ bestScore = score;
86
+ best = tonic;
87
+ }
88
+ }
89
+ return majorKey(best);
90
+ }
91
+
92
+ /** Enumerate candidate chords for the key, gated by reharmonization strength. */
93
+ function buildCandidates(key: KeyScale, reharmonize: HarmonizeOptions['reharmonize']): Candidate[] {
94
+ const tones = scaleTonesInDegreeOrder(key);
95
+ const candidates: Candidate[] = tones.map((rootPc, degree) => ({
96
+ rootPc,
97
+ quality: diatonicTriad(degree, key).quality,
98
+ degree,
99
+ secondaryDominant: false,
100
+ base: 0,
101
+ }));
102
+
103
+ const tonic = tones[0] ?? 0;
104
+ if (reharmonize !== 'diatonic') {
105
+ for (const target of [1, 3, 4, 5]) {
106
+ const targetRoot = tones[target] ?? 0;
107
+ candidates.push({
108
+ rootPc: (targetRoot + 7) % 12,
109
+ quality: 'dom7',
110
+ secondaryDominant: true,
111
+ targetDegree: target,
112
+ base: 1.5,
113
+ });
114
+ }
115
+ }
116
+
117
+ if (reharmonize === 'borrowed') {
118
+ candidates.push({
119
+ rootPc: (tonic + 10) % 12,
120
+ quality: 'maj',
121
+ secondaryDominant: false,
122
+ base: 2,
123
+ }); // bVII
124
+ candidates.push({
125
+ rootPc: (tonic + 8) % 12,
126
+ quality: 'maj',
127
+ secondaryDominant: false,
128
+ base: 2,
129
+ }); // bVI
130
+ candidates.push({
131
+ rootPc: (tonic + 5) % 12,
132
+ quality: 'min',
133
+ secondaryDominant: false,
134
+ base: 2,
135
+ }); // iv
136
+ }
137
+
138
+ return candidates;
139
+ }
140
+
141
+ /**
142
+ * Melody-fit cost of a candidate over a segment's notes.
143
+ *
144
+ * Each note is weighted by the portion of its duration that overlaps the
145
+ * segment, so a note sustained across a boundary contributes to every segment
146
+ * it sounds in rather than only the one it starts in.
147
+ */
148
+ function emissionCost(seg: Segment, cand: Candidate, melody: MelodyNote[], key: KeyScale): number {
149
+ const pcs = chordPitchClasses(makeChord(cand.rootPc, cand.quality));
150
+ let cost = cand.base;
151
+ for (const idx of seg.noteIndices) {
152
+ const note = melody[idx];
153
+ if (!note) {
154
+ continue;
155
+ }
156
+ const overlapStart = Math.max(note.startBeat, seg.startBeat);
157
+ const overlap = Math.min(note.startBeat + note.durationBeat, seg.endBeat) - overlapStart;
158
+ if (overlap <= 0) {
159
+ continue;
160
+ }
161
+ const w = Math.max(0.25, overlap);
162
+ if (pcs.includes(pitchClass(note.pitch))) {
163
+ continue;
164
+ }
165
+ const strong = isStrongBeat(overlapStart, DEFAULT_METER);
166
+ cost += (strong ? 10 : 2) * w;
167
+ if (!isScaleTone(note.pitch, key)) {
168
+ cost += w;
169
+ }
170
+ }
171
+ return cost;
172
+ }
173
+
174
+ /**
175
+ * Register cost of a placed melody: a small penalty for notes pushed outside a
176
+ * comfortable range. Kept far below emission/transition costs so it only breaks
177
+ * ties between otherwise-equal octave placements.
178
+ */
179
+ function tessituraCost(melody: MelodyNote[]): number {
180
+ let cost = 0;
181
+ for (const n of melody) {
182
+ if (n.pitch < COMFORT_LOW) {
183
+ cost += (COMFORT_LOW - n.pitch) * TESSITURA_WEIGHT;
184
+ } else if (n.pitch > COMFORT_HIGH) {
185
+ cost += (n.pitch - COMFORT_HIGH) * TESSITURA_WEIGHT;
186
+ }
187
+ }
188
+ return cost;
189
+ }
190
+
191
+ /** Functional-flow cost of moving from one candidate chord to the next. */
192
+ function transitionCost(prev: Candidate, cur: Candidate, tonicPc: number): number {
193
+ let cost = 0;
194
+ if ((prev.rootPc - cur.rootPc + 12) % 12 === 7) {
195
+ cost -= 2; // descending-fifth root motion
196
+ }
197
+ if (
198
+ prev.rootPc === (tonicPc + 7) % 12 &&
199
+ (prev.quality === 'maj' || prev.quality === 'dom7') &&
200
+ cur.rootPc === tonicPc
201
+ ) {
202
+ cost -= 3; // V -> I
203
+ }
204
+ if (prev.secondaryDominant) {
205
+ cost += cur.degree === prev.targetDegree ? -3 : 2;
206
+ }
207
+ const dist = Math.min((prev.rootPc - cur.rootPc + 12) % 12, (cur.rootPc - prev.rootPc + 12) % 12);
208
+ cost += dist * 0.3;
209
+ return cost;
210
+ }
211
+
212
+ /** Run one Viterbi harmonization pass over a fixed melody and key. */
213
+ function harmonizeOnce(
214
+ melody: MelodyNote[],
215
+ key: KeyScale,
216
+ candidates: Candidate[],
217
+ segments: Segment[],
218
+ jitter: number[],
219
+ ): { cost: number; path: number[] } {
220
+ const tonicPc = pitchClass(key.rootPc);
221
+ const n = candidates.length;
222
+ const candAt = (i: number): Candidate => candidates[i] ?? FALLBACK;
223
+ const seg0 = segments[0];
224
+
225
+ let dp = candidates.map(
226
+ (c, ci) => (seg0 ? emissionCost(seg0, c, melody, key) : 0) + (jitter[ci] ?? 0),
227
+ );
228
+ const back: number[][] = [];
229
+
230
+ for (let s = 1; s < segments.length; s += 1) {
231
+ const seg = segments[s];
232
+ if (!seg) {
233
+ continue;
234
+ }
235
+ const next: number[] = [];
236
+ const ptr: number[] = [];
237
+ for (let c = 0; c < n; c += 1) {
238
+ let best = Number.POSITIVE_INFINITY;
239
+ let bestPrev = 0;
240
+ for (let p = 0; p < n; p += 1) {
241
+ const cost =
242
+ (dp[p] ?? Number.POSITIVE_INFINITY) + transitionCost(candAt(p), candAt(c), tonicPc);
243
+ if (cost < best) {
244
+ best = cost;
245
+ bestPrev = p;
246
+ }
247
+ }
248
+ next[c] = best + emissionCost(seg, candAt(c), melody, key) + (jitter[c] ?? 0);
249
+ ptr[c] = bestPrev;
250
+ }
251
+ dp = next;
252
+ back.push(ptr);
253
+ }
254
+
255
+ let bestCost = Number.POSITIVE_INFINITY;
256
+ let bestEnd = 0;
257
+ for (let c = 0; c < n; c += 1) {
258
+ const cadence = candAt(c).rootPc === tonicPc ? -2 : 0;
259
+ const total = (dp[c] ?? Number.POSITIVE_INFINITY) + cadence;
260
+ if (total < bestCost) {
261
+ bestCost = total;
262
+ bestEnd = c;
263
+ }
264
+ }
265
+
266
+ const path: number[] = new Array<number>(segments.length).fill(bestEnd);
267
+ for (let s = segments.length - 2; s >= 0; s -= 1) {
268
+ const ptr = back[s];
269
+ const nextIdx = path[s + 1] ?? bestEnd;
270
+ path[s] = ptr ? (ptr[nextIdx] ?? bestEnd) : bestEnd;
271
+ }
272
+ return { cost: bestCost, path };
273
+ }
274
+
275
+ /**
276
+ * Harmonize a melody with a min-cost chord progression.
277
+ *
278
+ * The melody is segmented by `harmonicRhythm`; each segment is scored against
279
+ * diatonic (and, per `reharmonize`, secondary-dominant and borrowed) candidate
280
+ * chords by melody fit, and a Viterbi search picks the lowest-cost path using a
281
+ * functional-flow transition cost. When `placement.transposeSearch`/`octaveSearch`
282
+ * is set, the whole melody is transposed across a range and the best
283
+ * `(transpose, progression)` pair is returned; a small tessitura cost breaks
284
+ * ties toward placements that keep the melody in a comfortable register.
285
+ *
286
+ * @param opts Melody, key (or `'infer'`), harmonic rhythm, reharmonization
287
+ * strength, height-search flags, and seed.
288
+ * @returns The chosen transpose, key, chord path, and per-note roles.
289
+ */
290
+ export function harmonizeMelody(opts: HarmonizeOptions): HarmonizeResult {
291
+ const key = opts.key === 'infer' ? inferKey(opts.melody) : opts.key;
292
+ const candidates = buildCandidates(key, opts.reharmonize);
293
+ const candAt = (i: number): Candidate => candidates[i] ?? FALLBACK;
294
+ const rng = mulberry32(opts.seed ?? 0);
295
+ const jitter = candidates.map(() => rng() * 1e-6);
296
+
297
+ const melodyEnd = opts.melody.reduce((m, n) => Math.max(m, n.startBeat + n.durationBeat), 0);
298
+ const hr = Math.max(0.25, opts.harmonicRhythm);
299
+ const segCount = Math.max(1, Math.ceil(melodyEnd / hr));
300
+ const segments: Segment[] = [];
301
+ for (let s = 0; s < segCount; s += 1) {
302
+ const startBeat = s * hr;
303
+ const endBeat = startBeat + hr;
304
+ const noteIndices = opts.melody
305
+ .map((n, i) => ({ n, i }))
306
+ .filter(({ n }) => n.startBeat < endBeat && n.startBeat + n.durationBeat > startBeat)
307
+ .map(({ i }) => i);
308
+ segments.push({ startBeat, endBeat, noteIndices });
309
+ }
310
+
311
+ const transposes: number[] = [0];
312
+ if (opts.placement.transposeSearch) {
313
+ for (let s = -6; s <= 6; s += 1) {
314
+ if (s !== 0) {
315
+ transposes.push(s);
316
+ }
317
+ }
318
+ }
319
+ if (opts.placement.octaveSearch) {
320
+ for (const o of [-12, 12]) {
321
+ if (!transposes.includes(o)) {
322
+ transposes.push(o);
323
+ }
324
+ }
325
+ }
326
+
327
+ let bestCost = Number.POSITIVE_INFINITY;
328
+ let bestTs = 0;
329
+ let bestPath: number[] = [];
330
+ let bestMelody = opts.melody;
331
+ for (const ts of transposes) {
332
+ const shifted = opts.melody.map((n) => ({ ...n, pitch: n.pitch + ts }));
333
+ const { cost, path } = harmonizeOnce(shifted, key, candidates, segments, jitter);
334
+ const total = cost + tessituraCost(shifted);
335
+ if (total < bestCost) {
336
+ bestCost = total;
337
+ bestTs = ts;
338
+ bestPath = path;
339
+ bestMelody = shifted;
340
+ }
341
+ }
342
+
343
+ const chords: GeneratedChord[] = bestPath.map((ci, s) => {
344
+ const cand = candAt(ci);
345
+ const chord: GeneratedChord = {
346
+ rootPc: cand.rootPc,
347
+ quality: cand.quality,
348
+ startBeat: segments[s]?.startBeat ?? s * hr,
349
+ };
350
+ if (cand.degree !== undefined) {
351
+ chord.degree = cand.degree;
352
+ }
353
+ if (cand.secondaryDominant) {
354
+ chord.secondaryDominant = true;
355
+ }
356
+ return chord;
357
+ });
358
+
359
+ const melodyRoles = bestMelody.map((note, noteIndex) => {
360
+ const segIdx = Math.min(segments.length - 1, Math.max(0, Math.floor(note.startBeat / hr)));
361
+ const cand = candAt(bestPath[segIdx] ?? 0);
362
+ const chord: Chord = makeChord(cand.rootPc, cand.quality);
363
+ return { noteIndex, role: roleOf(note.pitch, chord).role };
364
+ });
365
+
366
+ return { transposeSemitones: bestTs, key, chords, melodyRoles };
367
+ }
@@ -0,0 +1,75 @@
1
+ import type { Chord } from '../chord/index.js';
2
+
3
+ /** Harmonic role a pitch plays within a chord. */
4
+ export type HarmonyRole = 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' | 'tension' | 'doubling';
5
+
6
+ /**
7
+ * How firmly a pitch is locked to the chord's identity.
8
+ *
9
+ * - `identity`: moving it produces a different chord (the root).
10
+ * - `quality`: moving it flips the chord quality (the third).
11
+ * - `voicing`: it can move freely without changing chord identity or quality.
12
+ */
13
+ export type LockLevel = 'identity' | 'quality' | 'voicing';
14
+
15
+ /** A pitch's role, lock level, and owning chord. */
16
+ export type VoicedRole = {
17
+ role: HarmonyRole;
18
+ lock: LockLevel;
19
+ belongsToChordId: number;
20
+ };
21
+
22
+ /**
23
+ * Classify a pitch's harmonic role and lock level within a chord.
24
+ *
25
+ * The role comes from the pitch's interval class above the chord root: root (0),
26
+ * third (3/4), fifth (6/7/8), seventh (10/11), and 9/11/13 tensions (2/5/9, and
27
+ * the flat ninth 1). The root locks the chord identity, the third locks its
28
+ * quality, and everything else is free voicing. In a suspended chord (a chord
29
+ * whose intervals include a 4th (5) or 2nd (2) but no third), the suspended tone
30
+ * takes the third's place as the quality-defining tone and is locked to
31
+ * `quality`, since moving it changes the chord. Detecting an octave doubling
32
+ * requires the surrounding voicing, which this single-pitch query does not carry,
33
+ * so `doubling` is part of the type but not returned here.
34
+ *
35
+ * @param pitch MIDI pitch or bare pitch class.
36
+ * @param chord The chord providing the root reference.
37
+ * @param chordId Identifier stored on the result (defaults to 0).
38
+ * @returns The pitch's role, lock level, and owning chord id.
39
+ */
40
+ export function roleOf(pitch: number, chord: Chord, chordId = 0): VoicedRole {
41
+ const interval = (((Math.trunc(pitch) - chord.rootPc) % 12) + 12) % 12;
42
+ const tones = new Set(chord.intervals.map((i) => ((i % 12) + 12) % 12));
43
+ const hasHigherSeventh = tones.has(10) || tones.has(11);
44
+ const hasThird = tones.has(3) || tones.has(4);
45
+ const isSuspendedTone =
46
+ !hasThird && ((interval === 5 && tones.has(5)) || (interval === 2 && tones.has(2)));
47
+ let role: HarmonyRole;
48
+ let lock: LockLevel;
49
+ if (interval === 0) {
50
+ role = 'root';
51
+ lock = 'identity';
52
+ } else if (isSuspendedTone) {
53
+ role = 'third'; // suspended tone occupies the third's quality-defining slot
54
+ lock = 'quality';
55
+ } else if (interval === 3 || interval === 4) {
56
+ role = 'third';
57
+ lock = 'quality';
58
+ } else if (interval === 6 || interval === 7 || interval === 8) {
59
+ role = 'fifth';
60
+ lock = 'voicing';
61
+ } else if (interval === 9 && tones.has(3) && tones.has(6) && !hasHigherSeventh) {
62
+ role = 'seventh'; // diminished seventh
63
+ lock = 'voicing';
64
+ } else if (interval === 9 && tones.has(9) && !hasHigherSeventh) {
65
+ role = 'sixth';
66
+ lock = 'voicing';
67
+ } else if (interval === 10 || interval === 11) {
68
+ role = 'seventh';
69
+ lock = 'voicing';
70
+ } else {
71
+ role = 'tension';
72
+ lock = 'voicing';
73
+ }
74
+ return { role, lock, belongsToChordId: chordId };
75
+ }