@libraz/libcantus 0.9.0 → 0.9.1

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.
@@ -19,10 +19,18 @@ import {
19
19
  } from '../scale/index.js';
20
20
  import type { KeyScale } from '../types.js';
21
21
 
22
- /** The three broad harmonic functions of tonal music. */
22
+ /**
23
+ * The three broad harmonic functions of tonal music.
24
+ *
25
+ * @category Functional Harmony
26
+ */
23
27
  export type HarmonicFunction = 'tonic' | 'subdominant' | 'dominant';
24
28
 
25
- /** A recognized cadence type, or null when a chord pair forms none. */
29
+ /**
30
+ * A recognized cadence type, or null when a chord pair forms none.
31
+ *
32
+ * @category Functional Harmony
33
+ */
26
34
  export type Cadence = 'authentic' | 'plagal' | 'half' | 'deceptive' | null;
27
35
 
28
36
  /** Roman numeral glyphs indexed by degree number - 1. */
@@ -75,7 +83,11 @@ function mod12(n: number): number {
75
83
  return ((n % 12) + 12) % 12;
76
84
  }
77
85
 
78
- /** Whether a key's scale has a minor third and no major third (a minor key). */
86
+ /**
87
+ * Whether a key's scale has a minor third and no major third (a minor key).
88
+ *
89
+ * @category Functional Harmony
90
+ */
79
91
  export function isMinorKey(key: KeyScale): boolean {
80
92
  const hasMinorThird = (key.modeMask12 >> 3) & 1;
81
93
  const hasMajorThird = (key.modeMask12 >> 4) & 1;
@@ -108,13 +120,56 @@ function seventhQuality(base: 'maj' | 'min' | 'dim' | 'aug', halfDim: boolean):
108
120
  /** Figure strings that denote a recognized figured-bass inversion. */
109
121
  const INVERSION_FIGURES = new Set(['6', '64', '65', '43', '42', '2', '7']);
110
122
 
111
- /** Figure strings that denote a fixed root-position extension quality. */
112
- const EXTENSION_FIGURES: Record<string, ChordQuality> = {
113
- '9': 'dom9',
114
- '11': '11',
115
- '13': '13',
123
+ /**
124
+ * Canonical Roman-numeral rendering (numeral case and quality suffix) for every
125
+ * supported chord quality. The table is the single source of truth for both
126
+ * directions: {@link chordToRoman} renders from it and {@link romanToChord}
127
+ * recognizes its exact case-sensitive suffixes, so the two stay mutual
128
+ * inverses by construction. Suffixes are chosen to never collide with
129
+ * figured-bass inversion digits (`6`, `64`, `65`, `43`, `42`, `2`) — the added
130
+ * sixth is `add6`, the six-nine chord is `69` — and each (case, suffix) pair is
131
+ * unique.
132
+ */
133
+ const ROMAN_STYLE: Record<ChordQuality, { lower: boolean; suffix: string }> = {
134
+ maj: { lower: false, suffix: '' },
135
+ min: { lower: true, suffix: '' },
136
+ dim: { lower: true, suffix: 'o' },
137
+ aug: { lower: false, suffix: '+' },
138
+ maj7: { lower: false, suffix: 'maj7' },
139
+ min7: { lower: true, suffix: '7' },
140
+ dom7: { lower: false, suffix: '7' },
141
+ dim7: { lower: true, suffix: 'o7' },
142
+ m7b5: { lower: true, suffix: 'ø7' },
143
+ minMaj7: { lower: true, suffix: 'maj7' },
144
+ aug7: { lower: false, suffix: '+7' },
145
+ augMaj7: { lower: false, suffix: '+maj7' },
146
+ majb5: { lower: false, suffix: 'b5' },
147
+ '6': { lower: false, suffix: 'add6' },
148
+ min6: { lower: true, suffix: 'add6' },
149
+ '6/9': { lower: false, suffix: '69' },
150
+ sus2: { lower: false, suffix: 'sus2' },
151
+ sus4: { lower: false, suffix: 'sus4' },
152
+ add9: { lower: false, suffix: 'add9' },
153
+ add11: { lower: false, suffix: 'add11' },
154
+ maj9: { lower: false, suffix: 'maj9' },
155
+ min9: { lower: true, suffix: '9' },
156
+ dom9: { lower: false, suffix: '9' },
157
+ '7b9': { lower: false, suffix: '7b9' },
158
+ '7#9': { lower: false, suffix: '7#9' },
159
+ '7#11': { lower: false, suffix: '7#11' },
160
+ '7b13': { lower: false, suffix: '7b13' },
161
+ '11': { lower: false, suffix: '11' },
162
+ '13': { lower: false, suffix: '13' },
163
+ '5': { lower: false, suffix: '5' },
116
164
  };
117
165
 
166
+ /** Reverse lookup of {@link ROMAN_STYLE}: `(case, exact suffix) -> quality`. */
167
+ const SUFFIX_QUALITY: ReadonlyMap<string, ChordQuality> = new Map(
168
+ (Object.entries(ROMAN_STYLE) as [ChordQuality, { lower: boolean; suffix: string }][]).map(
169
+ ([quality, style]) => [`${style.lower ? 'l' : 'u'}:${style.suffix}`, quality],
170
+ ),
171
+ );
172
+
118
173
  /** Read figured-bass digits into a chord inversion and whether a seventh is implied. */
119
174
  function parseInversion(figures: string): { inversion: number; seventh: boolean } {
120
175
  if (figures === '65') {
@@ -157,20 +212,24 @@ function parseSimpleRoman(
157
212
  const suffix = match[3] ?? '';
158
213
  const rootPc = mod12(degreeRootPc(degreeNumber, key) + accidental);
159
214
 
215
+ // Canonical quality suffixes (exact match, case-sensitive on the numeral)
216
+ // come first so every chordToRoman rendering re-parses to the same quality:
217
+ // `V9` -> dom9 but `ii9` -> min9 and `Imaj9` -> maj9, and added-tone suffixes
218
+ // whose digits would otherwise be misread as figured bass (`Iadd6`, `I69`,
219
+ // `Isus2`, `Iadd11`) resolve to their qualities in root position.
220
+ const canonical = SUFFIX_QUALITY.get(`${isUpper ? 'u' : 'l'}:${suffix}`);
221
+ if (canonical !== undefined) {
222
+ return { rootPc, quality: canonical, inversion: 0 };
223
+ }
224
+
160
225
  const isDim = /o|°|dim/.test(suffix);
161
226
  const isAug = /\+|aug/.test(suffix);
162
227
  const isHalfDim = /ø/.test(suffix);
163
228
  const explicitMaj7 = /maj7|M7/.test(suffix);
164
229
  const figures = suffix.replace(/maj7|M7/g, '').replace(/[^0-9]/g, '');
165
230
 
166
- // Extension figures (9/11/13) denote a fixed root-position tension quality,
167
- // not a figured-bass inversion, so they short-circuit the case/quality logic.
168
- const extension = EXTENSION_FIGURES[figures];
169
- if (extension !== undefined) {
170
- return { rootPc, quality: extension, inversion: 0 };
171
- }
172
231
  // Reject figure strings that are neither a known figured-bass inversion nor a
173
- // supported extension rather than silently degrading to a root triad.
232
+ // canonical quality suffix rather than silently degrading to a root triad.
174
233
  if (figures !== '' && !INVERSION_FIGURES.has(figures)) {
175
234
  throw new Error(`Unsupported figured-bass or extension "${figures}" in Roman numeral: ${text}`);
176
235
  }
@@ -191,7 +250,7 @@ function parseSimpleRoman(
191
250
 
192
251
  let quality: ChordQuality;
193
252
  if (explicitMaj7) {
194
- quality = base === 'min' ? 'minMaj7' : 'maj7';
253
+ quality = base === 'min' ? 'minMaj7' : base === 'aug' ? 'augMaj7' : 'maj7';
195
254
  } else if (hasSeventh) {
196
255
  quality = seventhQuality(base, isHalfDim);
197
256
  } else if (base === 'dim') {
@@ -228,14 +287,24 @@ function chordFromParsed(parsed: {
228
287
  * carry a `bassPc`.
229
288
  *
230
289
  * The `ø` glyph always denotes the half-diminished seventh (`iiø` == `iiø7` ==
231
- * `m7b5`), since no half-diminished triad exists. Root-position extension
232
- * figures map to fixed qualities (`V9` -> `dom9`, `V11` -> `11`, `V13` -> `13`).
290
+ * `m7b5`), since no half-diminished triad exists. Extension and added-tone
291
+ * suffixes are the canonical case-sensitive forms emitted by
292
+ * {@link chordToRoman} and honor the numeral case: `V9` -> `dom9`, `ii9` ->
293
+ * `min9`, `Imaj9` -> `maj9`, `V11` -> `11`, `V13` -> `13`, `Iadd6`/`iadd6` ->
294
+ * `6`/`min6`, `I69` -> `6/9`, `Isus2`/`Isus4`, `Iadd9`/`Iadd11`, `Ib5` ->
295
+ * `majb5`, `I5` -> `5`, and the altered dominants `V7b9`/`V7#9`/`V7#11`/`V7b13`.
233
296
  * A figure string that is neither a recognized inversion nor a supported
234
- * extension throws rather than silently degrading to a triad.
297
+ * quality suffix throws rather than silently degrading to a triad.
235
298
  *
236
299
  * @param text The Roman numeral.
237
300
  * @param key The prevailing key.
238
301
  * @returns The chord.
302
+ * @example
303
+ * ```ts
304
+ * import { romanToChord, majorKey } from '@libraz/libcantus';
305
+ * romanToChord('V7', majorKey(0)); // G7 in C major: { rootPc: 7, quality: 'dom7', ... }
306
+ * ```
307
+ * @category Functional Harmony
239
308
  */
240
309
  export function romanToChord(text: string, key: KeyScale): Chord {
241
310
  const trimmed = text.trim();
@@ -252,30 +321,7 @@ export function romanToChord(text: string, key: KeyScale): Chord {
252
321
 
253
322
  /** Case and suffix for rendering a chord quality as a Roman numeral. */
254
323
  function romanStyle(quality: ChordQuality): { lower: boolean; suffix: string } {
255
- switch (quality) {
256
- case 'min':
257
- return { lower: true, suffix: '' };
258
- case 'dim':
259
- return { lower: true, suffix: 'o' };
260
- case 'aug':
261
- return { lower: false, suffix: '+' };
262
- case 'maj7':
263
- return { lower: false, suffix: 'maj7' };
264
- case 'dom7':
265
- return { lower: false, suffix: '7' };
266
- case 'min7':
267
- return { lower: true, suffix: '7' };
268
- case 'm7b5':
269
- return { lower: true, suffix: 'ø7' };
270
- case 'dim7':
271
- return { lower: true, suffix: 'o7' };
272
- case 'minMaj7':
273
- return { lower: true, suffix: 'maj7' };
274
- case 'maj':
275
- return { lower: false, suffix: '' };
276
- default:
277
- return { lower: false, suffix: quality };
278
- }
324
+ return ROMAN_STYLE[quality];
279
325
  }
280
326
 
281
327
  /** Choose the degree number and accidental to spell a root as a Roman numeral. */
@@ -323,6 +369,9 @@ function baseMarker(quality: ChordQuality): string {
323
369
  if (quality === 'aug' || quality === 'aug7') {
324
370
  return '+';
325
371
  }
372
+ if (quality === 'augMaj7') {
373
+ return '+maj7';
374
+ }
326
375
  if (quality === 'maj7' || quality === 'minMaj7') {
327
376
  return 'maj7';
328
377
  }
@@ -332,6 +381,26 @@ function baseMarker(quality: ChordQuality): string {
332
381
  const TRIAD_FIGURES: Record<number, string> = { 1: '6', 2: '64' };
333
382
  const SEVENTH_FIGURES: Record<number, string> = { 1: '65', 2: '43', 3: '42' };
334
383
 
384
+ /** Qualities whose inversions render as lossless triad figures (6, 64). */
385
+ const TRIAD_FIGURE_QUALITIES: ReadonlySet<ChordQuality> = new Set(['maj', 'min', 'dim', 'aug']);
386
+
387
+ /**
388
+ * Qualities carrying a chordal seventh, whose inversions render as seventh
389
+ * figures (65, 43, 42). Membership is decided by the quality itself — a 7th
390
+ * above the root (10 or 11 semitones, or the diminished 7th of `dim7`) — never
391
+ * by interval count, which would turn added-tone chords into false sevenths.
392
+ */
393
+ const SEVENTH_FIGURE_QUALITIES: ReadonlySet<ChordQuality> = new Set([
394
+ 'maj7',
395
+ 'min7',
396
+ 'dom7',
397
+ 'dim7',
398
+ 'm7b5',
399
+ 'minMaj7',
400
+ 'aug7',
401
+ 'augMaj7',
402
+ ]);
403
+
335
404
  /**
336
405
  * Render a chord as a Roman numeral relative to a key.
337
406
  *
@@ -339,11 +408,20 @@ const SEVENTH_FIGURES: Record<number, string> = { 1: '65', 2: '43', 3: '42' };
339
408
  * correct in both major and minor keys (and any custom scale). The quality
340
409
  * selects the case and suffix; chromatic roots receive a flat/sharp spelling by
341
410
  * convention. When the chord carries a `bassPc` on a chord tone, a figured-bass
342
- * inversion (`6`, `64`, `65`, `43`, `42`) is emitted.
411
+ * inversion (`6`, `64`, `65`, `43`, `42`) is emitted for plain triads and true
412
+ * seventh chords; added-tone and extended qualities have no lossless figure and
413
+ * render in root position instead (the bass is dropped, pitch classes are
414
+ * preserved).
343
415
  *
344
416
  * @param chord The chord to name.
345
417
  * @param key The prevailing key.
346
418
  * @returns The Roman numeral string.
419
+ * @example
420
+ * ```ts
421
+ * import { chordToRoman, makeChord, majorKey } from '@libraz/libcantus';
422
+ * chordToRoman(makeChord(7, 'dom7'), majorKey(0)); // => 'V7' (G7 in C major)
423
+ * ```
424
+ * @category Functional Harmony
347
425
  */
348
426
  export function chordToRoman(chord: Chord, key: KeyScale): string {
349
427
  const { degreeNumber, accidental } = romanSpelling(chord.rootPc, key);
@@ -359,14 +437,18 @@ export function chordToRoman(chord: Chord, key: KeyScale): string {
359
437
  }
360
438
  }
361
439
  if (inversion > 0) {
362
- const isSeventh = chord.intervals.length >= 4;
363
- const figure = (isSeventh ? SEVENTH_FIGURES : TRIAD_FIGURES)[inversion];
440
+ const figure = SEVENTH_FIGURE_QUALITIES.has(chord.quality)
441
+ ? SEVENTH_FIGURES[inversion]
442
+ : TRIAD_FIGURE_QUALITIES.has(chord.quality)
443
+ ? TRIAD_FIGURES[inversion]
444
+ : undefined;
364
445
  if (figure !== undefined) {
365
446
  return `${accidental}${cased}${baseMarker(chord.quality)}${figure}`;
366
447
  }
367
- // The bass falls on a tension beyond the seventh (e.g. a ninth in the bass);
368
- // no figured-bass symbol exists for it, so fall back to root-position
369
- // rendering with the quality suffix rather than emitting a bare numeral.
448
+ // No lossless figured-bass symbol exists — the quality is an added-tone or
449
+ // extended chord, or the bass falls on a tension beyond the seventh — so
450
+ // fall back to root-position rendering with the quality suffix rather than
451
+ // emitting a figure that would re-parse as a different chord.
370
452
  }
371
453
  return `${accidental}${cased}${suffix}`;
372
454
  }
@@ -379,15 +461,24 @@ export function chordToRoman(chord: Chord, key: KeyScale): string {
379
461
  * @param chord The chord.
380
462
  * @param key The prevailing key.
381
463
  * @returns The harmonic function.
464
+ * @category Functional Harmony
382
465
  */
383
466
  export function functionOf(chord: Chord, key: KeyScale): HarmonicFunction {
384
467
  return FUNCTION_BY_OFFSET[mod12(chord.rootPc - key.rootPc)] ?? 'tonic';
385
468
  }
386
469
 
387
- /** The origin of a recognized non-diatonic chord, or null when none applies. */
470
+ /**
471
+ * The origin of a recognized non-diatonic chord, or null when none applies.
472
+ *
473
+ * @category Functional Harmony
474
+ */
388
475
  export type BorrowedSource = 'parallel-minor' | 'parallel-major' | 'neapolitan' | null;
389
476
 
390
- /** The result of {@link analyzeChord}: function, borrowing, and Roman numeral. */
477
+ /**
478
+ * The result of {@link analyzeChord}: function, borrowing, and Roman numeral.
479
+ *
480
+ * @category Functional Harmony
481
+ */
391
482
  export type ChordAnalysis = {
392
483
  function: HarmonicFunction;
393
484
  borrowed: boolean;
@@ -406,6 +497,7 @@ export type ChordAnalysis = {
406
497
  * @param chord The chord to test.
407
498
  * @param key The prevailing key.
408
499
  * @returns True if all chord pitch classes are scale tones.
500
+ * @category Functional Harmony
409
501
  */
410
502
  export function isDiatonic(chord: Chord, key: KeyScale): boolean {
411
503
  return chordPitchClasses(chord).every((pc) => isScaleTone(pc, key));
@@ -419,6 +511,7 @@ export function isDiatonic(chord: Chord, key: KeyScale): boolean {
419
511
  *
420
512
  * @param key The key to mirror.
421
513
  * @returns The parallel major or natural-minor key on the same tonic.
514
+ * @category Functional Harmony
422
515
  */
423
516
  export function parallelKey(key: KeyScale): KeyScale {
424
517
  return {
@@ -458,6 +551,7 @@ function isNeapolitan(chord: Chord, key: KeyScale): boolean {
458
551
  * @param chord The chord to test.
459
552
  * @param key The prevailing key.
460
553
  * @returns True if the chord is borrowed from the parallel mode.
554
+ * @category Functional Harmony
461
555
  */
462
556
  export function isBorrowedChord(chord: Chord, key: KeyScale): boolean {
463
557
  if (isDiatonic(chord, key)) {
@@ -480,6 +574,7 @@ export function isBorrowedChord(chord: Chord, key: KeyScale): boolean {
480
574
  * @param chord The chord to classify.
481
575
  * @param key The prevailing key.
482
576
  * @returns The borrowing source, or null.
577
+ * @category Functional Harmony
483
578
  */
484
579
  export function borrowedSource(chord: Chord, key: KeyScale): BorrowedSource {
485
580
  if (isDiatonic(chord, key)) {
@@ -537,6 +632,13 @@ function qualityAwareFunction(chord: Chord, key: KeyScale): HarmonicFunction {
537
632
  * @param chord The chord to analyze.
538
633
  * @param key The prevailing key.
539
634
  * @returns The chord analysis.
635
+ * @example
636
+ * ```ts
637
+ * import { analyzeChord, makeChord, majorKey } from '@libraz/libcantus';
638
+ * analyzeChord(makeChord(7, 'dom7'), majorKey(0));
639
+ * // { function: 'dominant', borrowed: false, source: null, roman: 'V7' }
640
+ * ```
641
+ * @category Functional Harmony
540
642
  */
541
643
  export function analyzeChord(chord: Chord, key: KeyScale): ChordAnalysis {
542
644
  const source = borrowedSource(chord, key);
@@ -563,6 +665,12 @@ export function analyzeChord(chord: Chord, key: KeyScale): ChordAnalysis {
563
665
  * @param to The final chord.
564
666
  * @param key The prevailing key.
565
667
  * @returns The cadence type, or null.
668
+ * @example
669
+ * ```ts
670
+ * import { detectCadence, makeChord, majorKey } from '@libraz/libcantus';
671
+ * detectCadence(makeChord(7, 'maj'), makeChord(0, 'maj'), majorKey(0)); // => 'authentic'
672
+ * ```
673
+ * @category Functional Harmony
566
674
  */
567
675
  export function detectCadence(from: Chord, to: Chord, key: KeyScale): Cadence {
568
676
  const tonic = mod12(key.rootPc);
@@ -595,6 +703,7 @@ export function detectCadence(from: Chord, to: Chord, key: KeyScale): Cadence {
595
703
  * @param targetDegree 0-based scale degree to tonicize.
596
704
  * @param key The prevailing key.
597
705
  * @returns A dominant-seventh chord a fifth above the target's root.
706
+ * @category Functional Harmony
598
707
  */
599
708
  export function secondaryDominant(targetDegree: number, key: KeyScale): Chord {
600
709
  const targetRoot = degreeRootPc(targetDegree + 1, key);
@@ -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';
11
+ import {
12
+ beatsPerBar,
13
+ formatTimeSignature,
14
+ metricWeight,
15
+ type TimeSignature,
16
+ } from '../meter/index.js';
12
17
  import { createRng } from '../random/index.js';
13
18
  import type { NoteEvent } from '../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,