@libraz/libcantus 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +25 -2
  2. package/dist/analyze/index.cjs +1697 -0
  3. package/dist/analyze/index.cjs.map +1 -0
  4. package/dist/analyze/index.d.cts +429 -0
  5. package/dist/analyze/index.d.ts +429 -0
  6. package/dist/analyze/index.js +51 -0
  7. package/dist/analyze/index.js.map +1 -0
  8. package/dist/chunk-5HTNMQW4.js +653 -0
  9. package/dist/chunk-5HTNMQW4.js.map +1 -0
  10. package/dist/chunk-7Y7WF64D.js +410 -0
  11. package/dist/chunk-7Y7WF64D.js.map +1 -0
  12. package/dist/chunk-A4532ZFR.js +259 -0
  13. package/dist/chunk-A4532ZFR.js.map +1 -0
  14. package/dist/chunk-BHUT2S5G.js +22 -0
  15. package/dist/chunk-BHUT2S5G.js.map +1 -0
  16. package/dist/chunk-CCBLVAPK.js +42 -0
  17. package/dist/chunk-CCBLVAPK.js.map +1 -0
  18. package/dist/chunk-COKTX2G5.js +907 -0
  19. package/dist/chunk-COKTX2G5.js.map +1 -0
  20. package/dist/chunk-DYWFNC4A.js +152 -0
  21. package/dist/chunk-DYWFNC4A.js.map +1 -0
  22. package/dist/chunk-GQKN5KDT.js +159 -0
  23. package/dist/chunk-GQKN5KDT.js.map +1 -0
  24. package/dist/chunk-LN7ZZMD2.js +530 -0
  25. package/dist/chunk-LN7ZZMD2.js.map +1 -0
  26. package/dist/chunk-MSWFZF7J.js +118 -0
  27. package/dist/chunk-MSWFZF7J.js.map +1 -0
  28. package/dist/chunk-R3S2LUBH.js +2722 -0
  29. package/dist/chunk-R3S2LUBH.js.map +1 -0
  30. package/dist/chunk-SDM3VJJ5.js +41 -0
  31. package/dist/chunk-SDM3VJJ5.js.map +1 -0
  32. package/dist/chunk-XTC3X6MS.js +816 -0
  33. package/dist/chunk-XTC3X6MS.js.map +1 -0
  34. package/dist/chunk-ZXW5YIWK.js +55 -0
  35. package/dist/chunk-ZXW5YIWK.js.map +1 -0
  36. package/dist/core/index.cjs +432 -0
  37. package/dist/core/index.cjs.map +1 -0
  38. package/dist/core/index.d.cts +210 -0
  39. package/dist/core/index.d.ts +210 -0
  40. package/dist/core/index.js +73 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/function-2bJW1Zp_.d.ts +187 -0
  43. package/dist/function-r2sh-jOO.d.cts +187 -0
  44. package/dist/generate/index.cjs +3787 -0
  45. package/dist/generate/index.cjs.map +1 -0
  46. package/dist/generate/index.d.cts +980 -0
  47. package/dist/generate/index.d.ts +980 -0
  48. package/dist/generate/index.js +52 -0
  49. package/dist/generate/index.js.map +1 -0
  50. package/dist/index-BMPxwjwV.d.ts +151 -0
  51. package/dist/index-BOBXBNwP.d.cts +157 -0
  52. package/dist/index-BdeQ1r_p.d.cts +56 -0
  53. package/dist/index-BreoJRw0.d.ts +127 -0
  54. package/dist/index-CGMZNCx6.d.cts +140 -0
  55. package/dist/index-CGMZNCx6.d.ts +140 -0
  56. package/dist/index-CM5W4rcm.d.ts +157 -0
  57. package/dist/index-CP3wAfEH.d.ts +56 -0
  58. package/dist/index-Cl7mhOFM.d.cts +164 -0
  59. package/dist/index-Cl7mhOFM.d.ts +164 -0
  60. package/dist/index-DZ1M21do.d.cts +127 -0
  61. package/dist/index-D_ZTUNKp.d.cts +151 -0
  62. package/dist/index.cjs +5277 -4893
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +14 -2621
  65. package/dist/index.d.ts +14 -2621
  66. package/dist/index.js +170 -6152
  67. package/dist/index.js.map +1 -1
  68. package/dist/model/index.cjs +2435 -0
  69. package/dist/model/index.cjs.map +1 -0
  70. package/dist/model/index.d.cts +630 -0
  71. package/dist/model/index.d.ts +630 -0
  72. package/dist/model/index.js +21 -0
  73. package/dist/model/index.js.map +1 -0
  74. package/dist/styled-BjXtOFNk.d.cts +282 -0
  75. package/dist/styled-D7BAx3S3.d.ts +282 -0
  76. package/dist/theory/index.cjs +1677 -0
  77. package/dist/theory/index.cjs.map +1 -0
  78. package/dist/theory/index.d.cts +535 -0
  79. package/dist/theory/index.d.ts +535 -0
  80. package/dist/theory/index.js +149 -0
  81. package/dist/theory/index.js.map +1 -0
  82. package/dist/types-MGP29r3t.d.cts +30 -0
  83. package/dist/types-MGP29r3t.d.ts +30 -0
  84. package/package.json +31 -1
  85. package/src/analyze/arrange/index.ts +21 -0
  86. package/src/analyze/arrange/internal.ts +125 -0
  87. package/src/analyze/arrange/tension.ts +187 -0
  88. package/src/analyze/arrange/tracks.ts +279 -0
  89. package/src/{detect → analyze/detect}/index.ts +82 -23
  90. package/src/analyze/functional/borrowed.ts +84 -0
  91. package/src/analyze/functional/cadence.ts +66 -0
  92. package/src/analyze/functional/function.ts +184 -0
  93. package/src/analyze/functional/index.ts +23 -0
  94. package/src/analyze/functional/internal.ts +29 -0
  95. package/src/analyze/functional/roman.ts +393 -0
  96. package/src/analyze/index.ts +49 -0
  97. package/src/{timeline → analyze/timeline}/index.ts +87 -24
  98. package/src/{analysis → analyze/voice}/index.ts +81 -12
  99. package/src/core/index.ts +47 -0
  100. package/src/{interval → core/interval}/index.ts +15 -1
  101. package/src/{meter → core/meter}/index.ts +108 -5
  102. package/src/{pitch → core/pitch}/index.ts +58 -3
  103. package/src/{random → core/random}/index.ts +13 -1
  104. package/src/{tuning → core/tuning}/index.ts +28 -1
  105. package/src/{bass → generate/bass}/index.ts +66 -17
  106. package/src/{countermelody → generate/countermelody}/index.ts +54 -24
  107. package/src/{drums → generate/drums}/fills.ts +31 -20
  108. package/src/{drums → generate/drums}/index.ts +127 -9
  109. package/src/{drums → generate/drums}/internal.ts +7 -1
  110. package/src/{drums → generate/drums}/rng.ts +2 -2
  111. package/src/{drums → generate/drums}/swing.ts +7 -2
  112. package/src/{groove → generate/groove}/index.ts +118 -22
  113. package/src/{harmonize → generate/harmonize}/index.ts +96 -23
  114. package/src/generate/index.ts +59 -0
  115. package/src/{motif → generate/motif}/index.ts +94 -17
  116. package/src/{progression → generate/progression}/index.ts +61 -27
  117. package/src/{reharmony → generate/reharmony}/index.ts +46 -15
  118. package/src/{rhythm → generate/rhythm}/index.ts +47 -13
  119. package/src/index.ts +130 -116
  120. package/src/model/chord.ts +423 -0
  121. package/src/model/index.ts +5 -947
  122. package/src/model/interval.ts +82 -0
  123. package/src/model/key.ts +266 -0
  124. package/src/model/note.ts +176 -0
  125. package/src/model/progression.ts +161 -0
  126. package/src/model/shared.ts +12 -0
  127. package/src/{chord → theory/chord}/index.ts +85 -4
  128. package/src/{chordscale → theory/chordscale}/index.ts +67 -9
  129. package/src/{counterpoint → theory/counterpoint}/index.ts +23 -2
  130. package/src/{harmony → theory/harmony}/index.ts +19 -2
  131. package/src/theory/index.ts +107 -0
  132. package/src/{safety → theory/safety}/index.ts +59 -12
  133. package/src/theory/scale/degrees.ts +123 -0
  134. package/src/theory/scale/index.ts +28 -0
  135. package/src/theory/scale/key.ts +56 -0
  136. package/src/theory/scale/masks.ts +153 -0
  137. package/src/{spelling → theory/spelling}/index.ts +21 -3
  138. package/src/{symbol → theory/symbol}/index.ts +91 -36
  139. package/src/theory/voicing/index.ts +5 -0
  140. package/src/theory/voicing/internal.ts +168 -0
  141. package/src/theory/voicing/leading.ts +112 -0
  142. package/src/theory/voicing/satb.ts +195 -0
  143. package/src/theory/voicing/styled.ts +176 -0
  144. package/src/arrange/index.ts +0 -409
  145. package/src/functional/index.ts +0 -602
  146. package/src/scale/index.ts +0 -215
  147. package/src/voicing/index.ts +0 -523
  148. /package/src/{types.ts → core/types.ts} +0 -0
  149. /package/src/{drums → generate/drums}/beat.ts +0 -0
  150. /package/src/{drums → generate/drums}/euclid.ts +0 -0
  151. /package/src/{drums → generate/drums}/ghost.ts +0 -0
  152. /package/src/{drums → generate/drums}/hihat.ts +0 -0
  153. /package/src/{drums → generate/drums}/hit.ts +0 -0
  154. /package/src/{drums → generate/drums}/kick.ts +0 -0
  155. /package/src/{drums → generate/drums}/percussion.ts +0 -0
@@ -7,6 +7,7 @@ import {
7
7
  generateSnareForBeat,
8
8
  type SectionCtx,
9
9
  } from './beat.js';
10
+ import { euclideanRhythm, patternToMask } from './euclid.js';
10
11
  import { type FillType, generateFill, getFillStartBeat, selectFillType } from './fills.js';
11
12
  import {
12
13
  footHiHatVelocity,
@@ -34,28 +35,106 @@ import {
34
35
  sectionDensityMultiplier,
35
36
  sectionEnergy,
36
37
  } from './internal.js';
37
- import { getKickPattern, isInPreChorusLift, type KickPattern } from './kick.js';
38
+ import {
39
+ euclideanToKickPattern,
40
+ getKickPattern,
41
+ isInPreChorusLift,
42
+ type KickPattern,
43
+ } from './kick.js';
38
44
  import { generateAuxPercussionForBar, getPercussionConfig } from './percussion.js';
39
45
  import { createRng } from './rng.js';
40
46
 
47
+ /**
48
+ * A single drum onset emitted by {@link generateDrums}.
49
+ *
50
+ * @category Composition
51
+ */
41
52
  export type { DrumHit } from './hit.js';
53
+
54
+ /**
55
+ * Groove feel (the swing/straight rhythmic character) for {@link generateDrums}.
56
+ *
57
+ * @category Composition
58
+ */
42
59
  export type GrooveFeel = Feel;
60
+
61
+ /**
62
+ * Drum voicing role and groove style identifiers for {@link generateDrums}.
63
+ *
64
+ * @category Composition
65
+ */
43
66
  export type { DrumRole, GrooveStyle } from './internal.js';
44
67
 
45
- /** Public section identifiers for {@link generateDrums}. */
68
+ /**
69
+ * Public section identifiers for {@link generateDrums}.
70
+ *
71
+ * @category Composition
72
+ */
46
73
  export type Section = PublicSection;
47
74
 
48
- /** Options controlling {@link generateDrums}. */
49
- export type DrumGenOptions = {
75
+ /**
76
+ * A Euclidean (Bjorklund) kick pattern for {@link generateDrums}.
77
+ *
78
+ * @category Composition
79
+ */
80
+ export type EuclideanKick = {
81
+ /** Number of kick onsets, clamped to `[0, steps]`. */
82
+ pulses: number;
83
+ /**
84
+ * Total steps in the bar (1..16).
85
+ *
86
+ * @defaultValue 16
87
+ */
88
+ steps?: number;
89
+ /**
90
+ * Steps to rotate the onsets toward later positions.
91
+ *
92
+ * @defaultValue 0
93
+ */
94
+ rotation?: number;
95
+ };
96
+
97
+ /**
98
+ * Options controlling {@link generateDrums}.
99
+ *
100
+ * @category Composition
101
+ */
102
+ export type DrumsOptions = {
50
103
  bars: number;
51
104
  bpm: number;
52
105
  style: GrooveStyle;
53
106
  section: Section;
54
107
  density: number;
108
+ /**
109
+ * Replace the final bar with a fill.
110
+ *
111
+ * @defaultValue false
112
+ */
55
113
  fills?: boolean;
56
114
  feel?: GrooveFeel;
115
+ /**
116
+ * Voicing role; `'fxOnly'` suppresses the main kick/snare/ghost/fill voices.
117
+ *
118
+ * @defaultValue `'full'`
119
+ */
57
120
  role?: DrumRole;
121
+ /**
122
+ * Seed for the deterministic PRNG.
123
+ *
124
+ * @defaultValue 0
125
+ */
58
126
  seed?: number;
127
+ /**
128
+ * Section the final-bar fill leads into. Shapes which fill archetype is
129
+ * chosen (into-chorus and out-of-intro fills differ from generic ones).
130
+ * Defaults to `section`, i.e. a within-section fill.
131
+ */
132
+ nextSection?: Section;
133
+ /**
134
+ * When set, the kick follows this Euclidean rhythm instead of the
135
+ * style/section pattern, giving direct access to evenly spread onsets.
136
+ */
137
+ euclideanKick?: EuclideanKick;
59
138
  };
60
139
 
61
140
  /**
@@ -66,13 +145,25 @@ export type DrumGenOptions = {
66
145
  * by its General MIDI pitch. Groove style selects an internal style and feel;
67
146
  * `density` sets the backing-density level; `section` shapes kick, hi-hat, ghost,
68
147
  * and percussion density. 16th-note hi-hats drop to 8ths at or above 150 BPM.
69
- * When `fills` is true the final bar is replaced with a fill. Output is fully
148
+ * When `fills` is true the final bar is replaced with a fill whose archetype is
149
+ * shaped by `nextSection`; a fill that would emit nothing on its beat falls back
150
+ * to the normal groove so the phrase end is never silent. `euclideanKick`
151
+ * overrides the kick with an evenly-spread Euclidean pattern. Output is fully
70
152
  * determined by the options plus `seed`.
71
153
  *
72
154
  * @param opts Generation options.
73
155
  * @returns Percussion onsets in bar order.
156
+ *
157
+ * @example
158
+ * ```ts
159
+ * import { generateDrums } from '@libraz/libcantus';
160
+ * const hits = generateDrums({ bars: 4, bpm: 120, style: 'standard', section: 'chorus', density: 0.6, fills: true });
161
+ * // Fully determined by the options plus seed (defaults to 0).
162
+ * ```
163
+ *
164
+ * @category Composition
74
165
  */
75
- export function generateDrums(opts: DrumGenOptions): DrumHit[] {
166
+ export function generateDrums(opts: DrumsOptions): DrumHit[] {
76
167
  const track = new HitList();
77
168
  const rng = createRng(opts.seed ?? 0);
78
169
  const mapping = mapStyle(opts.style);
@@ -114,6 +205,23 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
114
205
  const fillStartBeat = getFillStartBeat(energy);
115
206
  const percMood = percMoodCategory(style);
116
207
 
208
+ // The final-bar fill is shaped by the section it leads into. When no next
209
+ // section is given the fill is treated as within-section (from === to).
210
+ const nextSection = opts.nextSection ? mapSection(opts.nextSection) : section;
211
+ const nextEnergy = sectionEnergy(nextSection);
212
+
213
+ const euclidKick: KickPattern | undefined = opts.euclideanKick
214
+ ? euclideanToKickPattern(
215
+ patternToMask(
216
+ euclideanRhythm(
217
+ opts.euclideanKick.pulses,
218
+ opts.euclideanKick.steps ?? 16,
219
+ opts.euclideanKick.rotation ?? 0,
220
+ ),
221
+ ),
222
+ )
223
+ : undefined;
224
+
117
225
  const reuseSectionKick = (section === 'b' || section === 'chorus') && style !== 'sparse';
118
226
  let sectionKick: KickPattern | undefined;
119
227
 
@@ -133,7 +241,9 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
133
241
  }
134
242
 
135
243
  let kick: KickPattern;
136
- if (reuseSectionKick) {
244
+ if (euclidKick) {
245
+ kick = euclidKick;
246
+ } else if (reuseSectionKick) {
137
247
  sectionKick ??= getKickPattern(section, style, 0, rng);
138
248
  kick = sectionKick;
139
249
  } else {
@@ -149,12 +259,20 @@ export function generateDrums(opts: DrumGenOptions): DrumHit[] {
149
259
 
150
260
  if (opts.fills && isLastBar && !inLift && beat >= fillStartBeat) {
151
261
  if (beat === fillStartBeat) {
152
- currentFill = selectFillType(section, section, style, energy, rng);
262
+ currentFill = selectFillType(section, nextSection, style, nextEnergy, rng);
153
263
  }
154
264
  if (playMainVoices) {
265
+ const before = track.hits.length;
155
266
  generateFill(track, beatTick, beat, currentFill, velocity);
267
+ if (track.hits.length > before) {
268
+ continue;
269
+ }
270
+ // Safety net: an archetype that contributes nothing on this beat would
271
+ // leave a silent phrase end, so fall through to the normal groove.
272
+ } else {
273
+ // fxOnly deliberately emits no main voices on the fill beats.
274
+ continue;
156
275
  }
157
- continue;
158
276
  }
159
277
 
160
278
  const ctx: BeatCtx = {
@@ -67,7 +67,9 @@ export type GrooveStyle =
67
67
  | 'bossa'
68
68
  | 'trap'
69
69
  | 'halftime'
70
- | 'breakbeat';
70
+ | 'breakbeat'
71
+ | 'house'
72
+ | 'synthpop';
71
73
 
72
74
  /** Public role identifiers gating which voices are present. */
73
75
  export type DrumRole = 'full' | 'ambient' | 'minimal' | 'fxOnly';
@@ -92,6 +94,10 @@ const STYLE_MAP: Record<GrooveStyle, StyleMapping> = {
92
94
  trap: { style: 'trap', feel: 'straight', ghostBoost: false, snareBeat3: true },
93
95
  halftime: { style: 'sparse', feel: 'straight', ghostBoost: false, snareBeat3: true },
94
96
  breakbeat: { style: 'rock', feel: 'straight', ghostBoost: false, snareBeat3: false },
97
+ // Four-on-the-floor dance kick with idol-style auxiliary percussion.
98
+ house: { style: 'fourOnFloor', feel: 'straight', ghostBoost: false, snareBeat3: false },
99
+ // Synth-pop pulse with denser 16th hi-hats and busy kick syncopation.
100
+ synthpop: { style: 'synth', feel: 'straight', ghostBoost: true, snareBeat3: false },
95
101
  };
96
102
 
97
103
  /** Resolve the internal style/feel parameters for a public groove style. */
@@ -3,5 +3,5 @@
3
3
  * `DrumRng` is retained as the drum-facing name of the shared {@link Rng} type.
4
4
  */
5
5
 
6
- export type { Rng as DrumRng } from '../random/index.js';
7
- export { createRng } from '../random/index.js';
6
+ export type { Rng as DrumRng } from '../../core/random/index.js';
7
+ export { createRng } from '../../core/random/index.js';
@@ -53,8 +53,13 @@ export function quantizeSwing(
53
53
  return base + EIGHTH + DELTA_8 * clamped;
54
54
  }
55
55
  if (offset >= EIGHTH + HALF_16TH && offset < 3 * SIXTEENTH + HALF_16TH) {
56
- const result = base + EIGHTH + DELTA_8 * clamped + SIXTEENTH + DELTA_16 * clamped;
57
- return Math.min(result, base + 1 - 1 / 480);
56
+ // The "a" 16th is the midpoint of the compressed second half of the beat
57
+ // (between the swung "and" and the next downbeat), mirroring the "e" 16th
58
+ // in the first half. Because DELTA_8 = 2 * DELTA_16, that midpoint reduces
59
+ // to a single DELTA_16 delay off the straight position, so the "a" never
60
+ // overshoots the downbeat and needs no clamp. At the default shuffle feel
61
+ // (swing 0.75) this lands near 0.8125 rather than the doubly-swung 0.9375.
62
+ return base + 3 * SIXTEENTH + DELTA_16 * clamped;
58
63
  }
59
64
  return startBeat;
60
65
  }
@@ -8,23 +8,56 @@
8
8
  * seeded PRNG from the `random` module so results are fully reproducible.
9
9
  */
10
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';
11
+ import {
12
+ beatsPerBar,
13
+ formatTimeSignature,
14
+ metricWeight,
15
+ type TimeSignature,
16
+ } from '../../core/meter/index.js';
17
+ import { createRng } from '../../core/random/index.js';
18
+ import type { NoteEvent } from '../../core/types.js';
14
19
 
15
- /** Options controlling {@link humanize}. */
20
+ /**
21
+ * Options controlling {@link humanize}.
22
+ *
23
+ * @category Rhythm & Meter
24
+ */
16
25
  export type HumanizeOptions = {
17
- /** Time signature used to derive metric accents. Default 4/4. */
26
+ /**
27
+ * Time signature used to derive metric accents.
28
+ *
29
+ * @defaultValue 4/4
30
+ */
18
31
  ts?: TimeSignature;
19
- /** Maximum timing jitter in quarter-note beats, applied as ±this value. Default 0.02. */
32
+ /**
33
+ * Maximum timing jitter in quarter-note beats, applied as ±this value.
34
+ *
35
+ * @defaultValue 0.02
36
+ */
20
37
  timing?: number;
21
- /** Maximum velocity jitter (MIDI units), applied as ±this value. Default 8. */
38
+ /**
39
+ * Maximum velocity jitter (MIDI units), applied as ±this value.
40
+ *
41
+ * @defaultValue 8
42
+ */
22
43
  velocity?: number;
23
- /** How much louder strong beats get relative to weak ones, in MIDI velocity units. Default 12. */
44
+ /**
45
+ * How much louder strong beats get relative to weak ones, in MIDI velocity units.
46
+ *
47
+ * @defaultValue 12
48
+ */
24
49
  accent?: number;
25
- /** Velocity assumed for events with no `velocity` of their own. Default 80. */
50
+ /**
51
+ * Velocity assumed for events with no `velocity` of their own.
52
+ *
53
+ * @defaultValue 80
54
+ */
26
55
  baseVelocity?: number;
27
- /** Seed for the deterministic PRNG. Default 0. */
56
+ /**
57
+ * Seed for the deterministic PRNG.
58
+ *
59
+ * @defaultValue 0
60
+ */
28
61
  seed?: number;
29
62
  };
30
63
 
@@ -56,6 +89,13 @@ const MAX_VELOCITY = 127;
56
89
  * @param events The events to humanize.
57
90
  * @param opts Humanization options.
58
91
  * @returns Humanized copies of `events`, in input order.
92
+ * @example
93
+ * ```ts
94
+ * import { humanize } from '@libraz/libcantus';
95
+ * const events = [{ pitch: 60, startBeat: 0, durationBeat: 1, velocity: 80 }];
96
+ * humanize(events, { seed: 1, timing: 0.03 }); // copies with jittered timing and accented velocity
97
+ * ```
98
+ * @category Rhythm & Meter
59
99
  */
60
100
  export function humanize(events: NoteEvent[], opts: HumanizeOptions = {}): NoteEvent[] {
61
101
  const ts = opts.ts ?? DEFAULT_TS;
@@ -84,15 +124,27 @@ export function humanize(events: NoteEvent[], opts: HumanizeOptions = {}): NoteE
84
124
  });
85
125
  }
86
126
 
87
- /** The recorded feel at a single grid slot: an average timing deviation and velocity. */
127
+ /**
128
+ * The recorded feel at a single grid slot: an average timing deviation and velocity.
129
+ *
130
+ * @category Rhythm & Meter
131
+ */
88
132
  export type GrooveSlot = {
89
133
  /** Average `actualStartBeat - quantizedBeat` (in quarter-note beats) for events landing on this slot. */
90
134
  timingOffset: number;
91
- /** Average velocity of events landing on this slot; 0 if none did. */
92
- velocity: number;
135
+ /**
136
+ * Average velocity of events landing on this slot, or `null` if no event
137
+ * with a velocity did. `null` (not 0) is the "unrecorded" sentinel so that a
138
+ * genuine velocity of 0 is preserved and applied like any other value.
139
+ */
140
+ velocity: number | null;
93
141
  };
94
142
 
95
- /** A per-bar grid of timing and velocity deviations captured from a performance. */
143
+ /**
144
+ * A per-bar grid of timing and velocity deviations captured from a performance.
145
+ *
146
+ * @category Rhythm & Meter
147
+ */
96
148
  export type GrooveTemplate = {
97
149
  /** Grid resolution: equal grid steps per quarter-note beat. */
98
150
  subdivision: number;
@@ -100,6 +152,13 @@ export type GrooveTemplate = {
100
152
  slotsPerBar: number;
101
153
  /** One entry per grid slot, in slot-index order (slot 0 is the downbeat). */
102
154
  slots: GrooveSlot[];
155
+ /**
156
+ * The time signature the template was extracted under. {@link applyGrooveTemplate}
157
+ * requires the apply-time meter to match this so the per-bar grid lines up
158
+ * instead of silently drifting. Optional for backward compatibility with
159
+ * templates constructed without it (in which case no check is performed).
160
+ */
161
+ ts?: TimeSignature;
103
162
  };
104
163
 
105
164
  /**
@@ -137,13 +196,16 @@ function quantizeToGrid(
137
196
  /**
138
197
  * Extract a groove template from a set of note events: for each grid slot,
139
198
  * 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 }`.
199
+ * velocity of events landing on it. Slots with no velocity-carrying event
200
+ * record `velocity: null` (the "unrecorded" sentinel); empty slots default to
201
+ * `{ timingOffset: 0, velocity: null }`. The time signature is stored on the
202
+ * template so {@link applyGrooveTemplate} can verify the meter matches.
142
203
  *
143
204
  * @param events The (typically "groovy", human-played) events to analyze.
144
205
  * @param ts The time signature, used to compute the bar length.
145
206
  * @param subdivision Grid resolution: equal grid steps per quarter-note beat.
146
207
  * @returns The extracted groove template.
208
+ * @category Rhythm & Meter
147
209
  */
148
210
  export function extractGrooveTemplate(
149
211
  events: NoteEvent[],
@@ -178,36 +240,67 @@ export function extractGrooveTemplate(
178
240
  for (let i = 0; i < slotsPerBar; i += 1) {
179
241
  const count = counts[i] ?? 0;
180
242
  if (count === 0) {
181
- slots.push({ timingOffset: 0, velocity: 0 });
243
+ slots.push({ timingOffset: 0, velocity: null });
182
244
  continue;
183
245
  }
184
246
  const velocityCount = velocityCounts[i] ?? 0;
185
247
  slots.push({
186
248
  timingOffset: (offsetSums[i] ?? 0) / count,
187
- velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount : 0,
249
+ velocity: velocityCount > 0 ? (velocitySums[i] ?? 0) / velocityCount : null,
188
250
  });
189
251
  }
190
252
 
191
- return { subdivision, slotsPerBar, slots };
253
+ return { subdivision, slotsPerBar, slots, ts };
192
254
  }
193
255
 
194
256
  /**
195
257
  * Apply a groove template to a set of (typically quantized) note events: each
196
258
  * event is snapped to its grid slot and then offset by that slot's recorded
197
259
  * 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.
260
+ * Velocity is set to the slot's recorded average when it recorded one (a
261
+ * non-`null` velocity, including a genuine 0); otherwise the event's own
262
+ * velocity is left untouched.
263
+ *
264
+ * If the template carries a time signature (see {@link GrooveTemplate.ts}) it
265
+ * must match `ts`, since a differing bar length would misalign the per-bar
266
+ * grid and drift the feel; a mismatch throws rather than corrupting timing
267
+ * silently.
200
268
  *
201
269
  * @param events The events to reshape.
202
270
  * @param template The groove template, from {@link extractGrooveTemplate}.
203
271
  * @param ts The time signature, used to compute the bar length.
204
272
  * @returns Reshaped copies of `events`, in input order.
273
+ * @throws If `template.ts` is set and does not match `ts`.
274
+ * @example
275
+ * ```ts
276
+ * import { extractGrooveTemplate, applyGrooveTemplate, parseTimeSignature } from '@libraz/libcantus';
277
+ * const ts = parseTimeSignature('4/4');
278
+ * // Learn the feel from a loosely played performance...
279
+ * const groovy = [
280
+ * { pitch: 36, startBeat: 0.02, durationBeat: 1, velocity: 100 },
281
+ * { pitch: 38, startBeat: 1.06, durationBeat: 1, velocity: 70 },
282
+ * ];
283
+ * const template = extractGrooveTemplate(groovy, ts, 4);
284
+ * // ...then impose it on stiff, quantized events.
285
+ * const quantized = [{ pitch: 36, startBeat: 0, durationBeat: 1, velocity: 80 }];
286
+ * applyGrooveTemplate(quantized, template, ts); // events nudged toward the captured feel
287
+ * ```
288
+ * @category Rhythm & Meter
205
289
  */
206
290
  export function applyGrooveTemplate(
207
291
  events: NoteEvent[],
208
292
  template: GrooveTemplate,
209
293
  ts: TimeSignature,
210
294
  ): NoteEvent[] {
295
+ if (
296
+ template.ts &&
297
+ (template.ts.numerator !== ts.numerator || template.ts.denominator !== ts.denominator)
298
+ ) {
299
+ throw new Error(
300
+ `Groove template meter ${formatTimeSignature(template.ts)} does not match ` +
301
+ `apply-time meter ${formatTimeSignature(ts)}`,
302
+ );
303
+ }
211
304
  const barBeats = beatsPerBar(ts);
212
305
  return events.map((event) => {
213
306
  const { quantizedBeat, slotIndex } = quantizeToGrid(
@@ -218,7 +311,10 @@ export function applyGrooveTemplate(
218
311
  );
219
312
  const slot = template.slots[slotIndex];
220
313
  const startBeat = quantizedBeat + (slot?.timingOffset ?? 0);
221
- const velocity = slot && slot.velocity > 0 ? Math.round(slot.velocity) : event.velocity;
314
+ const velocity =
315
+ slot && slot.velocity !== null && slot.velocity !== undefined
316
+ ? Math.round(slot.velocity)
317
+ : event.velocity;
222
318
 
223
319
  return {
224
320
  pitch: event.pitch,
@@ -1,31 +1,54 @@
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';
1
+ import type { TimeSignature } from '../../core/meter/index.js';
2
+ import { isStrongBeat } from '../../core/meter/index.js';
3
+ import type { KeyScale, NoteEvent } from '../../core/types.js';
4
+ import type { Chord, ChordQuality } from '../../theory/chord/index.js';
5
+ import { chordPitchClasses, diatonicTriad, makeChord } from '../../theory/chord/index.js';
6
+ import type { HarmonyRole } from '../../theory/harmony/index.js';
7
+ import { roleOf } from '../../theory/harmony/index.js';
8
+ import {
9
+ isScaleTone,
10
+ majorKey,
11
+ minorKey,
12
+ NATURAL_MINOR_MASK,
13
+ scaleTonesInDegreeOrder,
14
+ } from '../../theory/scale/index.js';
15
+ import type { ChordSpan } from '../progression/index.js';
10
16
 
11
- /** A melody note supplied to the harmonizer: a {@link NoteEvent}. */
17
+ /**
18
+ * A melody note supplied to the harmonizer: a {@link NoteEvent}.
19
+ *
20
+ * @category Reharmonization
21
+ */
12
22
  export type MelodyNote = NoteEvent;
13
23
 
14
- /** Options controlling {@link harmonizeMelody}. */
24
+ /**
25
+ * Options controlling {@link harmonizeMelody}.
26
+ *
27
+ * @category Reharmonization
28
+ */
15
29
  export type HarmonizeOptions = {
16
30
  melody: MelodyNote[];
17
31
  key: KeyScale | 'infer';
18
32
  harmonicRhythm: number;
19
33
  reharmonize: 'diatonic' | 'secondaryDominant' | 'borrowed';
20
34
  placement: { transposeSearch: boolean; octaveSearch: boolean };
35
+ /**
36
+ * Seed for the deterministic tie-break perturbation.
37
+ *
38
+ * @defaultValue 0
39
+ */
21
40
  seed?: number;
22
41
  };
23
42
 
24
- /** The chosen transpose, key, chord path, and per-note roles. */
43
+ /**
44
+ * The chosen transpose, key, chord path, and per-note roles.
45
+ *
46
+ * @category Reharmonization
47
+ */
25
48
  export type HarmonizeResult = {
26
49
  transposeSemitones: number;
27
50
  key: KeyScale;
28
- chords: GeneratedChord[];
51
+ chords: ChordSpan[];
29
52
  melodyRoles: { noteIndex: number; role: HarmonyRole }[];
30
53
  };
31
54
 
@@ -50,6 +73,15 @@ const COMFORT_LOW = 55;
50
73
  const COMFORT_HIGH = 79;
51
74
  const TESSITURA_WEIGHT = 0.001;
52
75
 
76
+ /**
77
+ * Magnitude of the seed-driven per-candidate perturbation. Kept far below the
78
+ * smallest real cost difference (transition/emission terms are on the order of
79
+ * 0.3 and up), so it can only decide between candidates or paths whose costs are
80
+ * otherwise exactly equal. The seed therefore breaks ties deterministically and
81
+ * never overrides melody fit or functional flow.
82
+ */
83
+ const TIE_BREAK_JITTER = 1e-6;
84
+
53
85
  function pitchClass(pitch: number): number {
54
86
  return ((Math.trunc(pitch) % 12) + 12) % 12;
55
87
  }
@@ -65,28 +97,47 @@ function mulberry32(seed: number): () => number {
65
97
  };
66
98
  }
67
99
 
68
- /** Estimate the best-fit major key from a melody's pitch-class weighting. */
100
+ /**
101
+ * Estimate the best-fit key from a melody's pitch-class weighting.
102
+ *
103
+ * Both major and natural-minor tonics are scored, so a minor melody is
104
+ * recognized as its own minor key rather than being folded into the relative
105
+ * major (which shares identical pitch-class content). Each in-scale note adds
106
+ * its duration weight; notes matching the candidate tonic add a further
107
+ * half-weight, and that tonic emphasis is what separates a minor key from its
108
+ * relative major. For minor candidates the raised leading tone (the
109
+ * harmonic-minor seventh) also counts as in-scale, so cadential leading tones do
110
+ * not penalize the true minor key. Ties are broken toward the earlier candidate,
111
+ * and major is scored before minor at each tonic.
112
+ */
69
113
  function inferKey(melody: MelodyNote[]): KeyScale {
70
- let best = 0;
114
+ let best: KeyScale = majorKey(0);
71
115
  let bestScore = Number.NEGATIVE_INFINITY;
116
+ const candidates: KeyScale[] = [];
72
117
  for (let tonic = 0; tonic < 12; tonic += 1) {
73
- const key = majorKey(tonic);
118
+ candidates.push(majorKey(tonic), minorKey(tonic));
119
+ }
120
+ for (const key of candidates) {
121
+ const tonic = pitchClass(key.rootPc);
122
+ const isMinor = key.modeMask12 === NATURAL_MINOR_MASK;
123
+ const leadingTone = (tonic + 11) % 12;
74
124
  let score = 0;
75
125
  for (const n of melody) {
76
126
  const w = Math.max(0.25, n.durationBeat);
77
- if (isScaleTone(n.pitch, key)) {
127
+ const pc = pitchClass(n.pitch);
128
+ if (isScaleTone(n.pitch, key) || (isMinor && pc === leadingTone)) {
78
129
  score += w;
79
130
  }
80
- if (pitchClass(n.pitch) === tonic) {
131
+ if (pc === tonic) {
81
132
  score += 0.5 * w;
82
133
  }
83
134
  }
84
135
  if (score > bestScore) {
85
136
  bestScore = score;
86
- best = tonic;
137
+ best = key;
87
138
  }
88
139
  }
89
- return majorKey(best);
140
+ return best;
90
141
  }
91
142
 
92
143
  /** Enumerate candidate chords for the key, gated by reharmonization strength. */
@@ -283,16 +334,38 @@ function harmonizeOnce(
283
334
  * `(transpose, progression)` pair is returned; a small tessitura cost breaks
284
335
  * ties toward placements that keep the melody in a comfortable register.
285
336
  *
337
+ * The `seed` drives a deterministic tie-break only: it perturbs candidates by a
338
+ * magnitude far below any real cost difference (see {@link TIE_BREAK_JITTER}),
339
+ * so it can decide between chords or paths of otherwise-equal cost but never
340
+ * overrides melody fit or functional flow. For a well-determined melody the
341
+ * result is identical across seeds; the same seed always yields the same result.
342
+ *
286
343
  * @param opts Melody, key (or `'infer'`), harmonic rhythm, reharmonization
287
344
  * strength, height-search flags, and seed.
288
345
  * @returns The chosen transpose, key, chord path, and per-note roles.
346
+ * @example
347
+ * ```ts
348
+ * import { harmonizeMelody, majorKey } from '@libraz/libcantus';
349
+ * const result = harmonizeMelody({
350
+ * melody: [
351
+ * { pitch: 60, startBeat: 0, durationBeat: 1 },
352
+ * { pitch: 64, startBeat: 1, durationBeat: 1 },
353
+ * ],
354
+ * key: majorKey(0),
355
+ * harmonicRhythm: 1,
356
+ * reharmonize: 'diatonic',
357
+ * placement: { transposeSearch: false, octaveSearch: false },
358
+ * });
359
+ * result.chords; // one ChordSpan per harmonic-rhythm segment
360
+ * ```
361
+ * @category Reharmonization
289
362
  */
290
363
  export function harmonizeMelody(opts: HarmonizeOptions): HarmonizeResult {
291
364
  const key = opts.key === 'infer' ? inferKey(opts.melody) : opts.key;
292
365
  const candidates = buildCandidates(key, opts.reharmonize);
293
366
  const candAt = (i: number): Candidate => candidates[i] ?? FALLBACK;
294
367
  const rng = mulberry32(opts.seed ?? 0);
295
- const jitter = candidates.map(() => rng() * 1e-6);
368
+ const jitter = candidates.map(() => rng() * TIE_BREAK_JITTER);
296
369
 
297
370
  const melodyEnd = opts.melody.reduce((m, n) => Math.max(m, n.startBeat + n.durationBeat), 0);
298
371
  const hr = Math.max(0.25, opts.harmonicRhythm);
@@ -340,9 +413,9 @@ export function harmonizeMelody(opts: HarmonizeOptions): HarmonizeResult {
340
413
  }
341
414
  }
342
415
 
343
- const chords: GeneratedChord[] = bestPath.map((ci, s) => {
416
+ const chords: ChordSpan[] = bestPath.map((ci, s) => {
344
417
  const cand = candAt(ci);
345
- const chord: GeneratedChord = {
418
+ const chord: ChordSpan = {
346
419
  rootPc: cand.rootPc,
347
420
  quality: cand.quality,
348
421
  startBeat: segments[s]?.startBeat ?? s * hr,