@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
@@ -1,23 +1,30 @@
1
- import type { Chord } from '../chord/index.js';
2
- import { chordPitchClasses, makeChord } from '../chord/index.js';
1
+ import type { TimeSignature } from '../../core/meter/index.js';
2
+ import { beatsPerBar, metricWeight, parseTimeSignature } from '../../core/meter/index.js';
3
+ import type { KeyScale, NoteEvent } from '../../core/types.js';
4
+ import type { Chord, ChordSpan } from '../../theory/chord/index.js';
5
+ import { chordPitchClasses, makeChord } from '../../theory/chord/index.js';
6
+ import { isScaleTone, majorKey } from '../../theory/scale/index.js';
3
7
  import type { ChordMatch } from '../detect/index.js';
4
8
  import { detectChord, detectChordBest, detectKey } from '../detect/index.js';
5
9
  import type { Cadence } from '../functional/index.js';
6
10
  import { detectCadence } from '../functional/index.js';
7
- import type { TimeSignature } from '../meter/index.js';
8
- import { beatsPerBar, metricWeight, parseTimeSignature } from '../meter/index.js';
9
- import type { GeneratedChord } from '../progression/index.js';
10
- import { isScaleTone, majorKey } from '../scale/index.js';
11
- import type { KeyScale, NoteEvent } from '../types.js';
12
11
 
13
- /** A chord occupying a half-open beat span. */
12
+ /**
13
+ * A chord occupying a half-open beat span.
14
+ *
15
+ * @category Arrangement & Analysis
16
+ */
14
17
  export type ChordSegment = {
15
18
  startBeat: number;
16
19
  endBeat: number;
17
20
  chord: Chord;
18
21
  };
19
22
 
20
- /** A beat-indexed sequence of chord segments. */
23
+ /**
24
+ * A beat-indexed sequence of chord segments.
25
+ *
26
+ * @category Arrangement & Analysis
27
+ */
21
28
  export type ChordTimeline = {
22
29
  /** The chord sounding at a beat, or null when no segment covers it. */
23
30
  at: (beat: number) => Chord | null;
@@ -35,11 +42,9 @@ export type ChordTimeline = {
35
42
  * @param chords Placed chords in time order.
36
43
  * @param totalBeats End of the timeline in beats.
37
44
  * @returns A queryable chord timeline.
45
+ * @category Arrangement & Analysis
38
46
  */
39
- export function chordTimelineFromChords(
40
- chords: GeneratedChord[],
41
- totalBeats: number,
42
- ): ChordTimeline {
47
+ export function chordTimelineFromChords(chords: ChordSpan[], totalBeats: number): ChordTimeline {
43
48
  const sorted = [...chords].sort((a, b) => a.startBeat - b.startBeat);
44
49
  const segments: ChordSegment[] = sorted
45
50
  .map((gc, i) => {
@@ -96,19 +101,39 @@ function pitchClass(value: number): number {
96
101
  return ((Math.trunc(value) % 12) + 12) % 12;
97
102
  }
98
103
 
99
- /** Options controlling {@link chordTimelineFromNotes}. */
104
+ /**
105
+ * Options controlling {@link chordTimelineFromNotes}.
106
+ *
107
+ * @category Arrangement & Analysis
108
+ */
100
109
  export type ChordTimelineOptions = {
101
110
  /** Key context; inferred from the notes with {@link detectKey} when omitted. */
102
111
  key?: KeyScale;
103
- /** Time signature used for metric accents; defaults to 4/4. */
112
+ /**
113
+ * Time signature used for metric accents; defaults to 4/4.
114
+ *
115
+ * @defaultValue `4/4`
116
+ */
104
117
  ts?: TimeSignature;
105
- /** Window length in beats per chord slot; defaults to one bar of `ts`. */
118
+ /**
119
+ * Window length in beats per chord slot; defaults to one bar of `ts`.
120
+ *
121
+ * @defaultValue one bar of `ts`
122
+ */
106
123
  harmonicRhythm?: number;
107
- /** End of the analyzed span in beats; defaults to the end of the last note. */
124
+ /**
125
+ * End of the analyzed span in beats; defaults to the end of the last note.
126
+ *
127
+ * @defaultValue the end of the last note
128
+ */
108
129
  totalBeats?: number;
109
130
  };
110
131
 
111
- /** The result of {@link chordTimelineFromNotes}. */
132
+ /**
133
+ * The result of {@link chordTimelineFromNotes}.
134
+ *
135
+ * @category Arrangement & Analysis
136
+ */
112
137
  export type ChordTimelineResult = {
113
138
  /** The inferred timeline, with adjacent identical chords merged. */
114
139
  timeline: ChordTimeline;
@@ -266,10 +291,26 @@ function sameChord(a: Chord, b: Chord): boolean {
266
291
  * the fraction of the window weight explained by chord tones, reduced when the
267
292
  * match is inexact, and duration-weighted across merged windows.
268
293
  *
294
+ * Notes with a zero or negative duration never sound, so they are dropped at
295
+ * ingest: they contribute to neither the key inference, the span, nor any
296
+ * window's histogram.
297
+ *
269
298
  * @param notes The notes to analyze (any number of tracks, flattened).
270
299
  * @param opts Analysis options; see {@link ChordTimelineOptions}.
271
300
  * @returns The inferred timeline, the key used, and per-segment confidences.
272
301
  * @throws If `harmonicRhythm` is not positive.
302
+ * @example
303
+ * ```ts
304
+ * import { chordTimelineFromNotes } from '@libraz/libcantus';
305
+ * const notes = [
306
+ * { pitch: 60, startBeat: 0, durationBeat: 2 }, // C
307
+ * { pitch: 64, startBeat: 0, durationBeat: 2 }, // E
308
+ * { pitch: 67, startBeat: 0, durationBeat: 2 }, // G
309
+ * ];
310
+ * const { timeline, key } = chordTimelineFromNotes(notes);
311
+ * timeline.at(0); // the chord inferred over beat 0, or null
312
+ * ```
313
+ * @category Arrangement & Analysis
273
314
  */
274
315
  export function chordTimelineFromNotes(
275
316
  notes: NoteEvent[],
@@ -280,9 +321,11 @@ export function chordTimelineFromNotes(
280
321
  if (!(harmonicRhythm > 0)) {
281
322
  throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
282
323
  }
283
- const lastNoteEnd = notes.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
324
+ // Zero/negative-length notes never sound; drop them before any inference.
325
+ const sounding = notes.filter((note) => note.durationBeat > 0);
326
+ const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
284
327
  const totalBeats = opts.totalBeats ?? lastNoteEnd;
285
- const key = opts.key ?? detectKey(notes.map((n) => n.pitch))[0]?.key ?? majorKey(0);
328
+ const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
286
329
 
287
330
  const segments: ChordSegment[] = [];
288
331
  const segmentConfidence: number[] = [];
@@ -290,7 +333,7 @@ export function chordTimelineFromNotes(
290
333
  for (let i = 0; i < windowCount; i += 1) {
291
334
  const start = i * harmonicRhythm;
292
335
  const end = Math.min(start + harmonicRhythm, totalBeats);
293
- const inferred = analyzeWindow(notes, start, end, ts, key);
336
+ const inferred = analyzeWindow(sounding, start, end, ts, key);
294
337
  if (!inferred) {
295
338
  continue;
296
339
  }
@@ -316,7 +359,11 @@ export function chordTimelineFromNotes(
316
359
  };
317
360
  }
318
361
 
319
- /** A cadence found between two consecutive timeline segments. */
362
+ /**
363
+ * A cadence found between two consecutive timeline segments.
364
+ *
365
+ * @category Arrangement & Analysis
366
+ */
320
367
  export type CadenceHit = {
321
368
  /** The beat where the cadence arrives (the second chord's onset). */
322
369
  atBeat: number;
@@ -328,12 +375,25 @@ export type CadenceHit = {
328
375
  /**
329
376
  * Find cadences between consecutive segments of a chord timeline.
330
377
  *
331
- * Each adjacent segment pair is classified with {@link detectCadence}; pairs
332
- * forming no cadence are skipped.
378
+ * Each temporally adjacent segment pair is classified with
379
+ * {@link detectCadence}; pairs forming no cadence are skipped. Segments
380
+ * separated by a gap (a rest in the timeline) are not a chord-to-chord
381
+ * progression, so they are never paired.
333
382
  *
334
383
  * @param timeline The chord timeline to scan.
335
384
  * @param key The prevailing key.
336
385
  * @returns The cadences found, in time order.
386
+ * @example
387
+ * ```ts
388
+ * import { chordTimelineFromNotes, detectCadences } from '@libraz/libcantus';
389
+ * const notes = [
390
+ * { pitch: 67, startBeat: 0, durationBeat: 4 }, // G, a dominant
391
+ * { pitch: 60, startBeat: 4, durationBeat: 4 }, // C, the tonic
392
+ * ];
393
+ * const { timeline, key } = chordTimelineFromNotes(notes);
394
+ * const cadences = detectCadences(timeline, key);
395
+ * ```
396
+ * @category Arrangement & Analysis
337
397
  */
338
398
  export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceHit[] {
339
399
  const hits: CadenceHit[] = [];
@@ -343,6 +403,9 @@ export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceH
343
403
  if (!prev || !cur) {
344
404
  continue;
345
405
  }
406
+ if (Math.abs(cur.startBeat - prev.endBeat) > EPS) {
407
+ continue; // A rest separates the chords; no cadential motion across it.
408
+ }
346
409
  const type = detectCadence(prev.chord, cur.chord, key);
347
410
  if (type !== null) {
348
411
  hits.push({ atBeat: cur.startBeat, type, from: prev.chord, to: cur.chord });
@@ -1,10 +1,17 @@
1
- import type { Chord } from '../chord/index.js';
2
- import { chordPitchClasses, chordToneRole } from '../chord/index.js';
3
- import { createsVerticalDissonance, isLeadingToneResolution } from '../counterpoint/index.js';
4
- import type { VoiceSnapshot } from '../safety/index.js';
5
- import type { KeyScale, NoteEvent } from '../types.js';
1
+ import type { KeyScale, NoteEvent } from '../../core/types.js';
2
+ import type { Chord } from '../../theory/chord/index.js';
3
+ import { chordPitchClasses, chordToneRole } from '../../theory/chord/index.js';
4
+ import {
5
+ createsVerticalDissonance,
6
+ isLeadingToneResolution,
7
+ } from '../../theory/counterpoint/index.js';
8
+ import type { VoiceSnapshot } from '../../theory/safety/index.js';
6
9
 
7
- /** A theory annotation attached to a note. */
10
+ /**
11
+ * A theory annotation attached to a note.
12
+ *
13
+ * @category Arrangement & Analysis
14
+ */
8
15
  export type TheoryLabel =
9
16
  | { kind: 'chordTone'; role: 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' }
10
17
  | { kind: 'tension'; degree: 9 | 11 | 13 }
@@ -17,16 +24,27 @@ export type TheoryLabel =
17
24
  | { kind: 'needsResolution'; resolveTo: number }
18
25
  | { kind: 'leadingTone'; resolveTo: number };
19
26
 
20
- /** A note with its theory labels and a short rationale. */
27
+ /**
28
+ * A note with its theory labels and a short rationale.
29
+ *
30
+ * @category Arrangement & Analysis
31
+ */
21
32
  export type AnalyzedNote = {
22
33
  noteId: number;
23
34
  labels: TheoryLabel[];
24
35
  rationale?: string;
25
36
  };
26
37
 
27
- /** A single note in a monophonic voice: a {@link NoteEvent} with a stable id. */
38
+ /**
39
+ * A single note in a monophonic voice: a {@link NoteEvent} with a stable id.
40
+ *
41
+ * @category Arrangement & Analysis
42
+ */
28
43
  export type VoiceNote = NoteEvent & { id: number };
29
44
 
45
+ /** Float tolerance for beat comparisons. */
46
+ const EPS = 1e-9;
47
+
30
48
  function pitchClass(pitch: number): number {
31
49
  return ((Math.trunc(pitch) % 12) + 12) % 12;
32
50
  }
@@ -35,6 +53,26 @@ function intervalAboveRoot(pitch: number, chord: Chord): number {
35
53
  return (((pitchClass(pitch) - pitchClass(chord.rootPc)) % 12) + 12) % 12;
36
54
  }
37
55
 
56
+ /**
57
+ * Interval class of a pitch above the actual sounding bass.
58
+ *
59
+ * Suspension figures (4-3, 7-6, 9-8) are named for the interval above the bass,
60
+ * not the chord root, so this prefers the lowest pitch among the other sounding
61
+ * voices when one lies below the note. Without a lower sounding voice it falls
62
+ * back to the chord's bass pitch class (or root in root position).
63
+ */
64
+ function intervalAboveBass(pitch: number, others: VoiceSnapshot[], chord: Chord): number {
65
+ let bass = Number.POSITIVE_INFINITY;
66
+ for (const ov of others) {
67
+ bass = Math.min(bass, ov.pitch);
68
+ }
69
+ if (Number.isFinite(bass) && bass < pitch) {
70
+ return (((pitch - bass) % 12) + 12) % 12;
71
+ }
72
+ const ref = chord.bassPc ?? chord.rootPc;
73
+ return (((pitchClass(pitch) - pitchClass(ref)) % 12) + 12) % 12;
74
+ }
75
+
38
76
  /**
39
77
  * Map an interval class above the root to its extended-tension scale degree:
40
78
  * a flat/natural ninth (1 or 2) to 9, an eleventh (5 or 6) to 11, and a
@@ -59,6 +97,10 @@ function isStep(a: number, b: number): boolean {
59
97
  return d === 1 || d === 2;
60
98
  }
61
99
 
100
+ /**
101
+ * Classify a suspension figure from the interval class above the sounding bass
102
+ * (`ic`) and the resolution direction (`delta`, positive when resolving upward).
103
+ */
62
104
  function suspensionType(ic: number, delta: number): TheoryLabel & { kind: 'suspension' } {
63
105
  let type: 'sus4-3' | 'sus7-6' | 'sus9-8' | 'sus2-3';
64
106
  if (delta > 0) {
@@ -92,11 +134,30 @@ function stepResolution(pitch: number, chord: Chord): number | undefined {
92
134
  * avoid, or an unresolved-dissonance label. Leading-tone resolutions are noted
93
135
  * additionally.
94
136
  *
137
+ * The voice is expected to be monophonic (one note at a time). Notes sharing an
138
+ * onset are treated as simultaneous cluster members, not melodic neighbors, so
139
+ * they receive no melodic labels (suspension, passing, neighbor, anticipation,
140
+ * escape) — only harmonic ones. Callers with truly polyphonic material should
141
+ * split it into monophonic sub-voices first, as `analyzeArrangement` does.
142
+ *
95
143
  * @param voice The monophonic voice, in time order.
96
144
  * @param chordAtBeat Chord sounding at a given beat, or null.
97
145
  * @param key Key context for leading-tone detection.
98
146
  * @param otherVoicesAtBeat Other sounding voices at a given beat.
99
147
  * @returns One annotation per input note.
148
+ * @example
149
+ * ```ts
150
+ * import { analyzeVoice, makeChord, majorKey } from '@libraz/libcantus';
151
+ * const voice = [
152
+ * { id: 0, pitch: 60, startBeat: 0, durationBeat: 1 },
153
+ * { id: 1, pitch: 62, startBeat: 1, durationBeat: 1 },
154
+ * { id: 2, pitch: 64, startBeat: 2, durationBeat: 1 },
155
+ * ];
156
+ * const cMajor = makeChord(0, 'maj');
157
+ * const labels = analyzeVoice(voice, () => cMajor, majorKey(0), () => []);
158
+ * labels; // one AnalyzedNote per input note, in the same order
159
+ * ```
160
+ * @category Arrangement & Analysis
100
161
  */
101
162
  export function analyzeVoice(
102
163
  voice: VoiceNote[],
@@ -111,8 +172,15 @@ export function analyzeVoice(
111
172
  if (!note) {
112
173
  continue;
113
174
  }
114
- const prev = i > 0 ? voice[i - 1] : undefined;
115
- const next = i + 1 < voice.length ? voice[i + 1] : undefined;
175
+ const prevNote = i > 0 ? voice[i - 1] : undefined;
176
+ const nextNote = i + 1 < voice.length ? voice[i + 1] : undefined;
177
+ // A neighboring note sharing this note's onset is a simultaneous cluster
178
+ // member, not a melodic predecessor/successor; treating it as one would
179
+ // fabricate passing/suspension figures, so it is dropped here.
180
+ const prev =
181
+ prevNote !== undefined && prevNote.startBeat < note.startBeat - EPS ? prevNote : undefined;
182
+ const next =
183
+ nextNote !== undefined && nextNote.startBeat > note.startBeat + EPS ? nextNote : undefined;
116
184
  const chord = chordAtBeat(note.startBeat);
117
185
  const labels: TheoryLabel[] = [];
118
186
  const member = isChordMember(note.pitch, chord);
@@ -133,11 +201,12 @@ export function analyzeVoice(
133
201
  const prevChord = chordAtBeat(prev.startBeat);
134
202
  const prepared = prev.pitch === note.pitch && isChordMember(prev.pitch, prevChord);
135
203
  const resolves = isStep(next.pitch, note.pitch);
136
- const verticallyDissonant = otherVoicesAtBeat(note.startBeat).some((ov) =>
204
+ const others = otherVoicesAtBeat(note.startBeat);
205
+ const verticallyDissonant = others.some((ov) =>
137
206
  createsVerticalDissonance(note.pitch, ov.pitch, true),
138
207
  );
139
208
  if (prepared && verticallyDissonant && resolves) {
140
- const ic = intervalAboveRoot(note.pitch, chord);
209
+ const ic = intervalAboveBass(note.pitch, others, chord);
141
210
  const sus = suspensionType(ic, next.pitch - note.pitch);
142
211
  sus.resolveTo = next.pitch;
143
212
  labels.push(sus);
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Public API of the core layer. Re-exports every core-layer module;
3
+ * also available from the package root.
4
+ */
5
+ export {
6
+ classifyInterval,
7
+ IntervalQuality,
8
+ isConsonantInterval,
9
+ isPerfectInterval,
10
+ } from './interval/index.js';
11
+ export type { BarPosition, TimeSignature } from './meter/index.js';
12
+ export {
13
+ barPositionToBeat,
14
+ beatsPerBar,
15
+ beatToBarPosition,
16
+ formatTimeSignature,
17
+ isCompound,
18
+ isStrongBeat,
19
+ metricWeight,
20
+ parseTimeSignature,
21
+ pulsesPerBar,
22
+ tuplet,
23
+ } from './meter/index.js';
24
+ export type { Note as NoteData, SpelledInterval } from './pitch/index.js';
25
+ export {
26
+ formatNote,
27
+ midiToNote,
28
+ noteToMidi,
29
+ noteToPitchClass,
30
+ parseNote,
31
+ spelledInterval,
32
+ } from './pitch/index.js';
33
+ export type { Rng } from './random/index.js';
34
+ export { createRng } from './random/index.js';
35
+ export type { Tuning } from './tuning/index.js';
36
+ export {
37
+ centsBetweenFreq,
38
+ centsOfSteps,
39
+ edo,
40
+ frequencyOf,
41
+ JUST_RATIOS,
42
+ justDeviationCents,
43
+ nearestStep,
44
+ ratioToCents,
45
+ TWELVE_TET,
46
+ } from './tuning/index.js';
47
+ export type { KeyScale, NoteEvent } from './types.js';
@@ -1,4 +1,8 @@
1
- /** Counterpoint classification of a harmonic interval. */
1
+ /**
2
+ * Counterpoint classification of a harmonic interval.
3
+ *
4
+ * @category Pitch & Intervals
5
+ */
2
6
  export enum IntervalQuality {
3
7
  PerfectConsonance = 0,
4
8
  ImperfectConsonance = 1,
@@ -21,6 +25,14 @@ function simpleInterval(semitones: number): number {
21
25
  * @param semitones Interval size in semitones (may be negative or compound).
22
26
  * @param twoVoice When true, the perfect fourth is treated as dissonant.
23
27
  * @returns The counterpoint quality of the interval.
28
+ * @example
29
+ * ```ts
30
+ * import { classifyInterval, IntervalQuality } from '@libraz/libcantus';
31
+ * classifyInterval(7); // IntervalQuality.PerfectConsonance
32
+ * classifyInterval(5); // IntervalQuality.Dissonance (two-voice)
33
+ * classifyInterval(5, false); // IntervalQuality.ImperfectConsonance
34
+ * ```
35
+ * @category Pitch & Intervals
24
36
  */
25
37
  export function classifyInterval(semitones: number, twoVoice = true): IntervalQuality {
26
38
  const pc = simpleInterval(semitones);
@@ -41,6 +53,7 @@ export function classifyInterval(semitones: number, twoVoice = true): IntervalQu
41
53
  *
42
54
  * @param semitones Interval size in semitones (may be negative or compound).
43
55
  * @returns True for the unison/octave (0) and the perfect fifth (7) mod 12.
56
+ * @category Pitch & Intervals
44
57
  */
45
58
  export function isPerfectInterval(semitones: number): boolean {
46
59
  const pc = simpleInterval(semitones);
@@ -57,6 +70,7 @@ export function isPerfectInterval(semitones: number): boolean {
57
70
  * @param semitones Interval size in semitones (may be negative or compound).
58
71
  * @param twoVoice When true, the perfect fourth is treated as dissonant.
59
72
  * @returns True if the interval is consonant in the given context.
73
+ * @category Pitch & Intervals
60
74
  */
61
75
  export function isConsonantInterval(semitones: number, twoVoice = true): boolean {
62
76
  const pc = simpleInterval(semitones);
@@ -6,13 +6,31 @@
6
6
  * of the library's beat convention (four quarter-note beats per bar in 4/4).
7
7
  */
8
8
 
9
- /** A time signature as a numerator over a note-value denominator. */
9
+ /**
10
+ * A time signature as a numerator over a note-value denominator.
11
+ *
12
+ * @category Rhythm & Meter
13
+ */
10
14
  export type TimeSignature = {
11
15
  numerator: number;
12
16
  denominator: number;
17
+ /**
18
+ * Optional additive grouping of the bar's main pulses, as the felt-beat
19
+ * lengths in pulses — e.g. `[2, 2, 3]` for a 2+2+3 reading of 7/8, or
20
+ * `[3, 2]` for 5/8. The entries must be positive integers summing to
21
+ * {@link pulsesPerBar}. When present, the head pulse of each group (other
22
+ * than the downbeat) is treated as a secondary strong pulse by
23
+ * {@link metricWeight}/{@link isStrongBeat}; when absent, all main pulses
24
+ * are weighted equally as flat, evenly divided pulses.
25
+ */
26
+ grouping?: number[];
13
27
  };
14
28
 
15
- /** A position expressed as a bar index and a quarter-note offset within the bar. */
29
+ /**
30
+ * A position expressed as a bar index and a quarter-note offset within the bar.
31
+ *
32
+ * @category Rhythm & Meter
33
+ */
16
34
  export type BarPosition = {
17
35
  bar: number;
18
36
  /** Quarter-note offset from the start of the bar. */
@@ -36,6 +54,12 @@ function isMultiple(value: number, unit: number): boolean {
36
54
  * @param text The signature text.
37
55
  * @returns The parsed time signature.
38
56
  * @throws If the text is not `n/d` with positive integers.
57
+ * @example
58
+ * ```ts
59
+ * import { parseTimeSignature } from '@libraz/libcantus';
60
+ * parseTimeSignature('6/8'); // { numerator: 6, denominator: 8 }
61
+ * ```
62
+ * @category Rhythm & Meter
39
63
  */
40
64
  export function parseTimeSignature(text: string): TimeSignature {
41
65
  const match = /^\s*(\d+)\s*\/\s*(\d+)\s*$/.exec(text);
@@ -50,7 +74,11 @@ export function parseTimeSignature(text: string): TimeSignature {
50
74
  return { numerator, denominator };
51
75
  }
52
76
 
53
- /** Render a time signature as `"n/d"`. */
77
+ /**
78
+ * Render a time signature as `"n/d"`.
79
+ *
80
+ * @category Rhythm & Meter
81
+ */
54
82
  export function formatTimeSignature(ts: TimeSignature): string {
55
83
  return `${ts.numerator}/${ts.denominator}`;
56
84
  }
@@ -63,6 +91,7 @@ export function formatTimeSignature(ts: TimeSignature): string {
63
91
  *
64
92
  * @param ts The time signature.
65
93
  * @returns True for compound meters.
94
+ * @category Rhythm & Meter
66
95
  */
67
96
  export function isCompound(ts: TimeSignature): boolean {
68
97
  return ts.numerator % 3 === 0 && ts.numerator > 3;
@@ -78,6 +107,7 @@ function unitBeats(ts: TimeSignature): number {
78
107
  *
79
108
  * @param ts The time signature.
80
109
  * @returns The bar length in quarter notes.
110
+ * @category Rhythm & Meter
81
111
  */
82
112
  export function beatsPerBar(ts: TimeSignature): number {
83
113
  return ts.numerator * unitBeats(ts);
@@ -92,19 +122,63 @@ function pulseBeats(ts: TimeSignature): number {
92
122
  * Number of main pulses (felt beats) per bar: the numerator for simple meters,
93
123
  * a third of it for compound meters.
94
124
  *
125
+ * For additive/irregular meters such as 7/8 or 5/8 this counts the raw pulses
126
+ * (7 and 5 respectively), which are otherwise felt as flat, evenly divided and
127
+ * equally accented pulses. To recover a 2+2+3 or 3+2 felt-beat grouping, set
128
+ * {@link TimeSignature.grouping}; {@link metricWeight} then accents each
129
+ * group's head pulse.
130
+ *
95
131
  * @param ts The time signature.
96
132
  * @returns The pulse count per bar.
133
+ * @category Rhythm & Meter
97
134
  */
98
135
  export function pulsesPerBar(ts: TimeSignature): number {
99
136
  return beatsPerBar(ts) / pulseBeats(ts);
100
137
  }
101
138
 
139
+ /**
140
+ * Whether `grouping` is a valid additive grouping for a bar of `pulses` main
141
+ * pulses: a non-empty list of positive integers summing to `pulses`.
142
+ */
143
+ function isValidGrouping(grouping: number[], pulses: number): boolean {
144
+ if (grouping.length === 0) {
145
+ return false;
146
+ }
147
+ let sum = 0;
148
+ for (const g of grouping) {
149
+ if (!Number.isInteger(g) || g <= 0) {
150
+ return false;
151
+ }
152
+ sum += g;
153
+ }
154
+ return sum === pulses;
155
+ }
156
+
157
+ /** Whether `pulseIndex` is the head pulse of one of the additive groups. */
158
+ function isGroupHead(grouping: number[], pulseIndex: number): boolean {
159
+ let acc = 0;
160
+ for (const g of grouping) {
161
+ if (acc === pulseIndex) {
162
+ return true;
163
+ }
164
+ acc += g;
165
+ }
166
+ return false;
167
+ }
168
+
102
169
  /**
103
170
  * Convert an absolute quarter-note position to a bar index and in-bar offset.
104
171
  *
105
172
  * @param beatInQuarters Absolute position in quarter-note beats.
106
173
  * @param ts The time signature.
107
174
  * @returns The bar and in-bar quarter-note offset.
175
+ * @example
176
+ * ```ts
177
+ * import { parseTimeSignature, beatToBarPosition } from '@libraz/libcantus';
178
+ * const ts = parseTimeSignature('4/4');
179
+ * beatToBarPosition(5, ts); // { bar: 1, beat: 1 }
180
+ * ```
181
+ * @category Rhythm & Meter
108
182
  */
109
183
  export function beatToBarPosition(beatInQuarters: number, ts: TimeSignature): BarPosition {
110
184
  const barLen = beatsPerBar(ts);
@@ -119,6 +193,7 @@ export function beatToBarPosition(beatInQuarters: number, ts: TimeSignature): Ba
119
193
  * @param pos The bar position.
120
194
  * @param ts The time signature.
121
195
  * @returns The absolute position in quarter-note beats.
196
+ * @category Rhythm & Meter
122
197
  */
123
198
  export function barPositionToBeat(pos: BarPosition, ts: TimeSignature): number {
124
199
  return pos.bar * beatsPerBar(ts) + pos.beat;
@@ -126,12 +201,28 @@ export function barPositionToBeat(pos: BarPosition, ts: TimeSignature): number {
126
201
 
127
202
  /**
128
203
  * Metric weight of a position within its bar, on a 0–3 scale:
129
- * 3 the downbeat, 2 a secondary strong pulse (the bar's midpoint in even
130
- * meters), 1 any other main pulse, and 0 an off-pulse subdivision.
204
+ * 3 the downbeat, 2 a secondary strong pulse, 1 any other main pulse, and 0 an
205
+ * off-pulse subdivision.
206
+ *
207
+ * The secondary strong pulse is the bar's midpoint in even simple/compound
208
+ * meters. For an additive/irregular meter with {@link TimeSignature.grouping}
209
+ * set (e.g. `[2, 2, 3]` for 7/8), each group's head pulse other than the
210
+ * downbeat is the secondary strong pulse instead; without a grouping every
211
+ * non-downbeat main pulse of such a meter weighs 1 (flat, evenly divided
212
+ * pulses).
131
213
  *
132
214
  * @param beatInQuarters Absolute or in-bar quarter-note position.
133
215
  * @param ts The time signature.
134
216
  * @returns The metric weight (0–3).
217
+ * @throws If `ts.grouping` is present but is not a positive-integer list
218
+ * summing to {@link pulsesPerBar}.
219
+ * @example
220
+ * ```ts
221
+ * import { parseTimeSignature, metricWeight } from '@libraz/libcantus';
222
+ * const ts = parseTimeSignature('4/4');
223
+ * metricWeight(0, ts); // 3 (the downbeat)
224
+ * ```
225
+ * @category Rhythm & Meter
135
226
  */
136
227
  export function metricWeight(beatInQuarters: number, ts: TimeSignature): number {
137
228
  const { beat } = beatToBarPosition(beatInQuarters, ts);
@@ -144,6 +235,16 @@ export function metricWeight(beatInQuarters: number, ts: TimeSignature): number
144
235
  if (pulseIndex === 0) {
145
236
  return 3;
146
237
  }
238
+ const grouping = ts.grouping;
239
+ if (grouping !== undefined) {
240
+ if (!isValidGrouping(grouping, pulses)) {
241
+ throw new Error(
242
+ `Invalid grouping [${grouping.join(', ')}] for ${formatTimeSignature(ts)}: ` +
243
+ `entries must be positive integers summing to ${pulses}`,
244
+ );
245
+ }
246
+ return isGroupHead(grouping, pulseIndex) ? 2 : 1;
247
+ }
147
248
  if (pulses % 2 === 0 && pulseIndex === pulses / 2) {
148
249
  return 2;
149
250
  }
@@ -157,6 +258,7 @@ export function metricWeight(beatInQuarters: number, ts: TimeSignature): number
157
258
  * @param beatInQuarters Absolute or in-bar quarter-note position.
158
259
  * @param ts The time signature.
159
260
  * @returns True on strong beats.
261
+ * @category Rhythm & Meter
160
262
  */
161
263
  export function isStrongBeat(beatInQuarters: number, ts: TimeSignature): boolean {
162
264
  return metricWeight(beatInQuarters, ts) >= 2;
@@ -170,6 +272,7 @@ export function isStrongBeat(beatInQuarters: number, ts: TimeSignature): boolean
170
272
  * @param count Number of equal parts.
171
273
  * @returns `count` equal durations summing to `totalBeats`.
172
274
  * @throws If `count` is not a positive integer.
275
+ * @category Rhythm & Meter
173
276
  */
174
277
  export function tuplet(totalBeats: number, count: number): number[] {
175
278
  if (!Number.isInteger(count) || count <= 0) {