@libraz/libcantus 0.9.1 → 0.9.3

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 (160) hide show
  1. package/README.md +19 -3
  2. package/dist/analyze/index.cjs +1970 -0
  3. package/dist/analyze/index.cjs.map +1 -0
  4. package/dist/analyze/index.d.cts +341 -0
  5. package/dist/analyze/index.d.ts +341 -0
  6. package/dist/analyze/index.js +51 -0
  7. package/dist/analyze/index.js.map +1 -0
  8. package/dist/chunk-2ZWMSJH6.js +30 -0
  9. package/dist/chunk-2ZWMSJH6.js.map +1 -0
  10. package/dist/chunk-4MHA432G.js +2854 -0
  11. package/dist/chunk-4MHA432G.js.map +1 -0
  12. package/dist/chunk-62QZKCJQ.js +49 -0
  13. package/dist/chunk-62QZKCJQ.js.map +1 -0
  14. package/dist/chunk-AKLYPWIN.js +83 -0
  15. package/dist/chunk-AKLYPWIN.js.map +1 -0
  16. package/dist/chunk-C5XYKUVG.js +904 -0
  17. package/dist/chunk-C5XYKUVG.js.map +1 -0
  18. package/dist/chunk-CHMXELDS.js +549 -0
  19. package/dist/chunk-CHMXELDS.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-EH6EM37U.js +315 -0
  23. package/dist/chunk-EH6EM37U.js.map +1 -0
  24. package/dist/chunk-FBQK3ZQN.js +131 -0
  25. package/dist/chunk-FBQK3ZQN.js.map +1 -0
  26. package/dist/chunk-GOAW4VYL.js +719 -0
  27. package/dist/chunk-GOAW4VYL.js.map +1 -0
  28. package/dist/chunk-I5R7ZBJS.js +202 -0
  29. package/dist/chunk-I5R7ZBJS.js.map +1 -0
  30. package/dist/chunk-IT2O4XFE.js +154 -0
  31. package/dist/chunk-IT2O4XFE.js.map +1 -0
  32. package/dist/chunk-MIWO425M.js +412 -0
  33. package/dist/chunk-MIWO425M.js.map +1 -0
  34. package/dist/chunk-N6IHQUOX.js +1 -0
  35. package/dist/chunk-N6IHQUOX.js.map +1 -0
  36. package/dist/chunk-SGU7SPLK.js +924 -0
  37. package/dist/chunk-SGU7SPLK.js.map +1 -0
  38. package/dist/core/index.cjs +648 -0
  39. package/dist/core/index.cjs.map +1 -0
  40. package/dist/core/index.d.cts +262 -0
  41. package/dist/core/index.d.ts +262 -0
  42. package/dist/core/index.js +93 -0
  43. package/dist/core/index.js.map +1 -0
  44. package/dist/function-DVH4Eusr.d.ts +187 -0
  45. package/dist/function-DYt4iKsn.d.cts +187 -0
  46. package/dist/generate/index.cjs +4123 -0
  47. package/dist/generate/index.cjs.map +1 -0
  48. package/dist/generate/index.d.cts +986 -0
  49. package/dist/generate/index.d.ts +986 -0
  50. package/dist/generate/index.js +52 -0
  51. package/dist/generate/index.js.map +1 -0
  52. package/dist/index-BNlGP86A.d.cts +157 -0
  53. package/dist/index-Bg5dYMmZ.d.ts +56 -0
  54. package/dist/index-Bt4tOUD9.d.ts +157 -0
  55. package/dist/index-CGMZNCx6.d.cts +140 -0
  56. package/dist/index-CGMZNCx6.d.ts +140 -0
  57. package/dist/index-Cl7mhOFM.d.cts +164 -0
  58. package/dist/index-Cl7mhOFM.d.ts +164 -0
  59. package/dist/index-CnZCo29u.d.cts +108 -0
  60. package/dist/index-CpOsP1y9.d.cts +151 -0
  61. package/dist/index-CxqBQtH1.d.cts +128 -0
  62. package/dist/index-Dikst44v.d.cts +56 -0
  63. package/dist/index-DxvARIP2.d.ts +151 -0
  64. package/dist/index-Jc0Ryi79.d.ts +128 -0
  65. package/dist/index-JnF4v9bx.d.ts +108 -0
  66. package/dist/index.cjs +3710 -3143
  67. package/dist/index.cjs.map +1 -1
  68. package/dist/index.d.cts +15 -3982
  69. package/dist/index.d.ts +15 -3982
  70. package/dist/index.js +191 -6504
  71. package/dist/index.js.map +1 -1
  72. package/dist/model/index.cjs +2574 -0
  73. package/dist/model/index.cjs.map +1 -0
  74. package/dist/model/index.d.cts +631 -0
  75. package/dist/model/index.d.ts +631 -0
  76. package/dist/model/index.js +21 -0
  77. package/dist/model/index.js.map +1 -0
  78. package/dist/styled-DZ7GjShb.d.ts +282 -0
  79. package/dist/styled-ZTbdaVFM.d.cts +282 -0
  80. package/dist/theory/index.cjs +1823 -0
  81. package/dist/theory/index.cjs.map +1 -0
  82. package/dist/theory/index.d.cts +535 -0
  83. package/dist/theory/index.d.ts +535 -0
  84. package/dist/theory/index.js +150 -0
  85. package/dist/theory/index.js.map +1 -0
  86. package/dist/types-MGP29r3t.d.cts +30 -0
  87. package/dist/types-MGP29r3t.d.ts +30 -0
  88. package/package.json +61 -5
  89. package/src/analyze/arrange/index.ts +21 -0
  90. package/src/analyze/arrange/internal.ts +127 -0
  91. package/src/analyze/arrange/tension.ts +199 -0
  92. package/src/analyze/arrange/tracks.ts +312 -0
  93. package/src/{detect → analyze/detect}/index.ts +50 -13
  94. package/src/analyze/functional/borrowed.ts +84 -0
  95. package/src/analyze/functional/cadence.ts +66 -0
  96. package/src/analyze/functional/function.ts +184 -0
  97. package/src/analyze/functional/index.ts +23 -0
  98. package/src/analyze/functional/internal.ts +29 -0
  99. package/src/{functional/index.ts → analyze/functional/roman.ts} +23 -332
  100. package/src/analyze/index.ts +49 -0
  101. package/src/{timeline → analyze/timeline}/index.ts +50 -18
  102. package/src/{analysis → analyze/voice}/index.ts +19 -7
  103. package/src/core/event-index/index.ts +104 -0
  104. package/src/core/index.ts +61 -0
  105. package/src/{meter → core/meter}/index.ts +24 -4
  106. package/src/{random → core/random}/index.ts +26 -3
  107. package/src/{tuning → core/tuning}/index.ts +36 -6
  108. package/src/core/validation/index.ts +119 -0
  109. package/src/{bass → generate/bass}/index.ts +28 -5
  110. package/src/{countermelody → generate/countermelody}/index.ts +176 -85
  111. package/src/{drums → generate/drums}/euclid.ts +10 -3
  112. package/src/{drums → generate/drums}/index.ts +58 -26
  113. package/src/{drums → generate/drums}/percussion.ts +1 -1
  114. package/src/{drums → generate/drums}/rng.ts +2 -2
  115. package/src/{groove → generate/groove}/index.ts +28 -3
  116. package/src/{harmonize → generate/harmonize}/index.ts +68 -57
  117. package/src/generate/index.ts +58 -0
  118. package/src/{motif → generate/motif}/index.ts +52 -28
  119. package/src/{progression → generate/progression}/index.ts +19 -41
  120. package/src/{reharmony → generate/reharmony}/index.ts +12 -19
  121. package/src/{rhythm → generate/rhythm}/index.ts +13 -3
  122. package/src/index.ts +13 -234
  123. package/src/model/chord.ts +442 -0
  124. package/src/model/index.ts +5 -1096
  125. package/src/model/interval.ts +82 -0
  126. package/src/model/key.ts +266 -0
  127. package/src/model/note.ts +176 -0
  128. package/src/model/progression.ts +161 -0
  129. package/src/model/shared.ts +12 -0
  130. package/src/{chord → theory/chord}/index.ts +23 -3
  131. package/src/{counterpoint → theory/counterpoint}/index.ts +2 -2
  132. package/src/{harmony → theory/harmony}/index.ts +6 -21
  133. package/src/theory/index.ts +112 -0
  134. package/src/{safety → theory/safety}/index.ts +72 -3
  135. package/src/theory/scale/degrees.ts +123 -0
  136. package/src/theory/scale/index.ts +28 -0
  137. package/src/theory/scale/key.ts +56 -0
  138. package/src/theory/scale/masks.ts +153 -0
  139. package/src/{spelling → theory/spelling}/index.ts +73 -6
  140. package/src/{symbol → theory/symbol}/index.ts +8 -1
  141. package/src/theory/voicing/index.ts +5 -0
  142. package/src/theory/voicing/internal.ts +168 -0
  143. package/src/theory/voicing/leading.ts +112 -0
  144. package/src/theory/voicing/satb.ts +216 -0
  145. package/src/theory/voicing/styled.ts +199 -0
  146. package/src/arrange/index.ts +0 -571
  147. package/src/scale/index.ts +0 -331
  148. package/src/voicing/index.ts +0 -623
  149. /package/src/{interval → core/interval}/index.ts +0 -0
  150. /package/src/{pitch → core/pitch}/index.ts +0 -0
  151. /package/src/{types.ts → core/types.ts} +0 -0
  152. /package/src/{drums → generate/drums}/beat.ts +0 -0
  153. /package/src/{drums → generate/drums}/fills.ts +0 -0
  154. /package/src/{drums → generate/drums}/ghost.ts +0 -0
  155. /package/src/{drums → generate/drums}/hihat.ts +0 -0
  156. /package/src/{drums → generate/drums}/hit.ts +0 -0
  157. /package/src/{drums → generate/drums}/internal.ts +0 -0
  158. /package/src/{drums → generate/drums}/kick.ts +0 -0
  159. /package/src/{drums → generate/drums}/swing.ts +0 -0
  160. /package/src/{chordscale → theory/chordscale}/index.ts +0 -0
@@ -1,14 +1,21 @@
1
- import type { Chord } from '../chord/index.js';
2
- import { chordPitchClasses, makeChord } from '../chord/index.js';
1
+ import { createNoteEventIndex } from '../../core/event-index/index.js';
2
+ import type { TimeSignature } from '../../core/meter/index.js';
3
+ import { beatsPerBar, metricWeight, parseTimeSignature } from '../../core/meter/index.js';
4
+ import type { KeyScale, NoteEvent } from '../../core/types.js';
5
+ import {
6
+ assertFiniteNumber,
7
+ assertGenerationBudget,
8
+ assertNoteEvents,
9
+ assertRange,
10
+ assertTimeSignature,
11
+ } from '../../core/validation/index.js';
12
+ import type { Chord, ChordSpan } from '../../theory/chord/index.js';
13
+ import { chordPitchClasses, makeChord } from '../../theory/chord/index.js';
14
+ import { isScaleTone, majorKey } from '../../theory/scale/index.js';
3
15
  import type { ChordMatch } from '../detect/index.js';
4
16
  import { detectChord, detectChordBest, detectKey } from '../detect/index.js';
5
17
  import type { Cadence } from '../functional/index.js';
6
18
  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
19
 
13
20
  /**
14
21
  * A chord occupying a half-open beat span.
@@ -45,15 +52,25 @@ export type ChordTimeline = {
45
52
  * @returns A queryable chord timeline.
46
53
  * @category Arrangement & Analysis
47
54
  */
48
- export function chordTimelineFromChords(
49
- chords: GeneratedChord[],
50
- totalBeats: number,
51
- ): ChordTimeline {
52
- const sorted = [...chords].sort((a, b) => a.startBeat - b.startBeat);
55
+ export function chordTimelineFromChords(chords: ChordSpan[], totalBeats: number): ChordTimeline {
56
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, 'timeline totalBeats');
57
+ assertGenerationBudget(chords.length, 'timeline chords');
58
+ for (let index = 0; index < chords.length; index += 1) {
59
+ assertRange(
60
+ chords[index]?.startBeat ?? Number.NaN,
61
+ 0,
62
+ Number.MAX_SAFE_INTEGER,
63
+ `chords[${index}].startBeat`,
64
+ );
65
+ }
66
+ const sorted = [...chords]
67
+ .filter((chord) => chord.startBeat < totalBeats)
68
+ .sort((a, b) => a.startBeat - b.startBeat);
53
69
  const segments: ChordSegment[] = sorted
54
70
  .map((gc, i) => {
55
71
  const next = sorted[i + 1];
56
- const endBeat = Math.max(gc.startBeat, next ? next.startBeat : totalBeats);
72
+ const nextBeat = next ? next.startBeat : totalBeats;
73
+ const endBeat = Math.min(totalBeats, Math.max(gc.startBeat, nextBeat));
57
74
  return {
58
75
  startBeat: gc.startBeat,
59
76
  endBeat,
@@ -68,6 +85,7 @@ export function chordTimelineFromChords(
68
85
  /** Build the `at(beat)` lookup over a segment list. */
69
86
  function segmentLookup(segments: ChordSegment[]): (beat: number) => Chord | null {
70
87
  return (beat) => {
88
+ assertFiniteNumber(beat, 'timeline query beat');
71
89
  for (const seg of segments) {
72
90
  if (beat >= seg.startBeat && beat < seg.endBeat) {
73
91
  return seg.chord;
@@ -321,23 +339,37 @@ export function chordTimelineFromNotes(
321
339
  opts: ChordTimelineOptions = {},
322
340
  ): ChordTimelineResult {
323
341
  const ts = opts.ts ?? parseTimeSignature('4/4');
342
+ assertTimeSignature(ts);
324
343
  const harmonicRhythm = opts.harmonicRhythm ?? beatsPerBar(ts);
325
- if (!(harmonicRhythm > 0)) {
326
- throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
327
- }
344
+ assertRange(harmonicRhythm, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, 'harmonic rhythm');
345
+ assertNoteEvents(notes, 'timeline notes', { allowNonPositiveDuration: true });
328
346
  // Zero/negative-length notes never sound; drop them before any inference.
329
- const sounding = notes.filter((note) => note.durationBeat > 0);
347
+ const soundingIndex = createNoteEventIndex(notes.filter((note) => note.durationBeat > 0));
348
+ const sounding = soundingIndex.notes.map(({ note }) => note);
330
349
  const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
331
350
  const totalBeats = opts.totalBeats ?? lastNoteEnd;
351
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, 'timeline totalBeats');
332
352
  const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
333
353
 
334
354
  const segments: ChordSegment[] = [];
335
355
  const segmentConfidence: number[] = [];
336
356
  const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS));
357
+ assertGenerationBudget(windowCount, 'timeline windows');
358
+ const windowNotes: NoteEvent[][] = Array.from({ length: windowCount }, () => []);
359
+ let memberships = 0;
360
+ for (const indexed of soundingIndex.notes) {
361
+ const first = Math.max(0, Math.floor(indexed.note.startBeat / harmonicRhythm));
362
+ const lastExclusive = Math.min(windowCount, Math.ceil(indexed.endBeat / harmonicRhythm));
363
+ memberships += Math.max(0, lastExclusive - first);
364
+ assertGenerationBudget(memberships, 'timeline note-to-window memberships');
365
+ for (let window = first; window < lastExclusive; window += 1) {
366
+ windowNotes[window]?.push(indexed.note);
367
+ }
368
+ }
337
369
  for (let i = 0; i < windowCount; i += 1) {
338
370
  const start = i * harmonicRhythm;
339
371
  const end = Math.min(start + harmonicRhythm, totalBeats);
340
- const inferred = analyzeWindow(sounding, start, end, ts, key);
372
+ const inferred = analyzeWindow(windowNotes[i] ?? [], start, end, ts, key);
341
373
  if (!inferred) {
342
374
  continue;
343
375
  }
@@ -1,8 +1,12 @@
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 { assertNoteEvents } from '../../core/validation/index.js';
3
+ import type { Chord } from '../../theory/chord/index.js';
4
+ import { chordPitchClasses, chordToneRole } from '../../theory/chord/index.js';
5
+ import {
6
+ createsVerticalDissonance,
7
+ isLeadingToneResolution,
8
+ } from '../../theory/counterpoint/index.js';
9
+ import type { VoiceSnapshot } from '../../theory/safety/index.js';
6
10
 
7
11
  /**
8
12
  * A theory annotation attached to a note.
@@ -162,6 +166,7 @@ export function analyzeVoice(
162
166
  key: KeyScale,
163
167
  otherVoicesAtBeat: (beat: number) => VoiceSnapshot[],
164
168
  ): AnalyzedNote[] {
169
+ assertNoteEvents(voice, 'voice notes', { allowNonPositiveDuration: true });
165
170
  const result: AnalyzedNote[] = [];
166
171
 
167
172
  for (let i = 0; i < voice.length; i += 1) {
@@ -174,10 +179,17 @@ export function analyzeVoice(
174
179
  // A neighboring note sharing this note's onset is a simultaneous cluster
175
180
  // member, not a melodic predecessor/successor; treating it as one would
176
181
  // fabricate passing/suspension figures, so it is dropped here.
182
+ const prevEnd =
183
+ prevNote === undefined
184
+ ? Number.NEGATIVE_INFINITY
185
+ : prevNote.startBeat + prevNote.durationBeat;
186
+ const noteEnd = note.startBeat + note.durationBeat;
177
187
  const prev =
178
- prevNote !== undefined && prevNote.startBeat < note.startBeat - EPS ? prevNote : undefined;
188
+ prevNote !== undefined && Math.abs(prevEnd - note.startBeat) <= EPS ? prevNote : undefined;
179
189
  const next =
180
- nextNote !== undefined && nextNote.startBeat > note.startBeat + EPS ? nextNote : undefined;
190
+ nextNote !== undefined && Math.abs(noteEnd - nextNote.startBeat) <= EPS
191
+ ? nextNote
192
+ : undefined;
181
193
  const chord = chordAtBeat(note.startBeat);
182
194
  const labels: TheoryLabel[] = [];
183
195
  const member = isChordMember(note.pitch, chord);
@@ -0,0 +1,104 @@
1
+ import type { NoteEvent } from '../types.js';
2
+ import { assertNoteEvents } from '../validation/index.js';
3
+
4
+ /** A validated note retaining its position in the caller's original array. */
5
+ export type IndexedNoteEvent = {
6
+ note: NoteEvent;
7
+ originalIndex: number;
8
+ endBeat: number;
9
+ };
10
+
11
+ /** Binary-searchable, stable-sorted index over note onsets and active spans. */
12
+ export type NoteEventIndex = {
13
+ notes: IndexedNoteEvent[];
14
+ /** Latest-onset note sounding at `beat`, with later input order winning ties. */
15
+ at: (beat: number) => IndexedNoteEvent | undefined;
16
+ /** Whether one or more notes attack at `beat`. */
17
+ attacksAt: (beat: number) => boolean;
18
+ /** Unique attack beats strictly inside `(startBeat, endBeat)`. */
19
+ onsetsBetween: (startBeat: number, endBeat: number) => number[];
20
+ };
21
+
22
+ const EPS = 1e-9;
23
+
24
+ function upperBound(values: IndexedNoteEvent[], beat: number): number {
25
+ let low = 0;
26
+ let high = values.length;
27
+ while (low < high) {
28
+ const middle = Math.floor((low + high) / 2);
29
+ if ((values[middle]?.note.startBeat ?? Number.POSITIVE_INFINITY) <= beat) {
30
+ low = middle + 1;
31
+ } else {
32
+ high = middle;
33
+ }
34
+ }
35
+ return low;
36
+ }
37
+
38
+ /**
39
+ * Validate and stable-sort note events once, then expose logarithmic onset and
40
+ * active-note lookups. Non-positive-duration notes may be retained for callers
41
+ * that intentionally filter them later, but never count as sounding.
42
+ */
43
+ export function createNoteEventIndex(
44
+ events: NoteEvent[],
45
+ options: { allowNonPositiveDuration?: boolean } = {},
46
+ ): NoteEventIndex {
47
+ assertNoteEvents(events, 'note events', options);
48
+ const notes = events
49
+ .map((note, originalIndex) => ({
50
+ note,
51
+ originalIndex,
52
+ endBeat: note.startBeat + note.durationBeat,
53
+ }))
54
+ .sort((a, b) => a.note.startBeat - b.note.startBeat || a.originalIndex - b.originalIndex);
55
+ const prefixMaxEnd: number[] = [];
56
+ let maxEnd = Number.NEGATIVE_INFINITY;
57
+ for (const indexed of notes) {
58
+ maxEnd = Math.max(maxEnd, indexed.endBeat);
59
+ prefixMaxEnd.push(maxEnd);
60
+ }
61
+
62
+ return {
63
+ notes,
64
+ at(beat) {
65
+ let index = upperBound(notes, beat + EPS) - 1;
66
+ while (index >= 0) {
67
+ if ((prefixMaxEnd[index] ?? Number.NEGATIVE_INFINITY) <= beat + EPS) {
68
+ return undefined;
69
+ }
70
+ const indexed = notes[index];
71
+ if (
72
+ indexed &&
73
+ indexed.note.durationBeat > 0 &&
74
+ indexed.note.startBeat - EPS <= beat &&
75
+ beat < indexed.endBeat - EPS
76
+ ) {
77
+ return indexed;
78
+ }
79
+ index -= 1;
80
+ }
81
+ return undefined;
82
+ },
83
+ attacksAt(beat) {
84
+ const firstAfter = upperBound(notes, beat + EPS);
85
+ const candidate = notes[firstAfter - 1];
86
+ return candidate !== undefined && Math.abs(candidate.note.startBeat - beat) < EPS;
87
+ },
88
+ onsetsBetween(startBeat, endBeat) {
89
+ const result: number[] = [];
90
+ let index = upperBound(notes, startBeat + EPS);
91
+ while (index < notes.length) {
92
+ const onset = notes[index]?.note.startBeat;
93
+ if (onset === undefined || onset >= endBeat - EPS) {
94
+ break;
95
+ }
96
+ if (result[result.length - 1] !== onset) {
97
+ result.push(onset);
98
+ }
99
+ index += 1;
100
+ }
101
+ return result;
102
+ },
103
+ };
104
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Public API of the core layer. Re-exports every core-layer module;
3
+ * also available from the package root.
4
+ */
5
+
6
+ export type { IndexedNoteEvent, NoteEventIndex } from './event-index/index.js';
7
+ export { createNoteEventIndex } from './event-index/index.js';
8
+ export {
9
+ classifyInterval,
10
+ IntervalQuality,
11
+ isConsonantInterval,
12
+ isPerfectInterval,
13
+ } from './interval/index.js';
14
+ export type { BarPosition, TimeSignature } from './meter/index.js';
15
+ export {
16
+ barPositionToBeat,
17
+ beatsPerBar,
18
+ beatToBarPosition,
19
+ formatTimeSignature,
20
+ isCompound,
21
+ isStrongBeat,
22
+ metricWeight,
23
+ parseTimeSignature,
24
+ pulsesPerBar,
25
+ tuplet,
26
+ } from './meter/index.js';
27
+ export type { Note as NoteData, SpelledInterval } from './pitch/index.js';
28
+ export {
29
+ formatNote,
30
+ midiToNote,
31
+ noteToMidi,
32
+ noteToPitchClass,
33
+ parseNote,
34
+ spelledInterval,
35
+ } from './pitch/index.js';
36
+ export type { Rng } from './random/index.js';
37
+ export { createRng } from './random/index.js';
38
+ export type { Tuning } from './tuning/index.js';
39
+ export {
40
+ centsBetweenFreq,
41
+ centsOfSteps,
42
+ edo,
43
+ frequencyOf,
44
+ JUST_RATIOS,
45
+ justDeviationCents,
46
+ nearestStep,
47
+ ratioToCents,
48
+ TWELVE_TET,
49
+ } from './tuning/index.js';
50
+ export type { KeyScale, NoteEvent } from './types.js';
51
+ export {
52
+ assertFiniteNumber,
53
+ assertGenerationBudget,
54
+ assertInteger,
55
+ assertNoteEvent,
56
+ assertNoteEvents,
57
+ assertPositiveInt,
58
+ assertRange,
59
+ assertTimeSignature,
60
+ DEFAULT_GENERATION_BUDGET,
61
+ } from './validation/index.js';
@@ -6,6 +6,14 @@
6
6
  * of the library's beat convention (four quarter-note beats per bar in 4/4).
7
7
  */
8
8
 
9
+ import {
10
+ assertFiniteNumber,
11
+ assertInteger,
12
+ assertPositiveInt,
13
+ assertRange,
14
+ assertTimeSignature,
15
+ } from '../validation/index.js';
16
+
9
17
  /**
10
18
  * A time signature as a numerator over a note-value denominator.
11
19
  *
@@ -68,7 +76,12 @@ export function parseTimeSignature(text: string): TimeSignature {
68
76
  }
69
77
  const numerator = Number.parseInt(match[1] ?? '', 10);
70
78
  const denominator = Number.parseInt(match[2] ?? '', 10);
71
- if (numerator <= 0 || denominator <= 0) {
79
+ if (
80
+ !Number.isSafeInteger(numerator) ||
81
+ !Number.isSafeInteger(denominator) ||
82
+ numerator <= 0 ||
83
+ denominator <= 0
84
+ ) {
72
85
  throw new Error(`Invalid time signature: ${text}`);
73
86
  }
74
87
  return { numerator, denominator };
@@ -80,6 +93,7 @@ export function parseTimeSignature(text: string): TimeSignature {
80
93
  * @category Rhythm & Meter
81
94
  */
82
95
  export function formatTimeSignature(ts: TimeSignature): string {
96
+ assertTimeSignature(ts);
83
97
  return `${ts.numerator}/${ts.denominator}`;
84
98
  }
85
99
 
@@ -94,6 +108,7 @@ export function formatTimeSignature(ts: TimeSignature): string {
94
108
  * @category Rhythm & Meter
95
109
  */
96
110
  export function isCompound(ts: TimeSignature): boolean {
111
+ assertTimeSignature(ts);
97
112
  return ts.numerator % 3 === 0 && ts.numerator > 3;
98
113
  }
99
114
 
@@ -110,6 +125,7 @@ function unitBeats(ts: TimeSignature): number {
110
125
  * @category Rhythm & Meter
111
126
  */
112
127
  export function beatsPerBar(ts: TimeSignature): number {
128
+ assertTimeSignature(ts);
113
129
  return ts.numerator * unitBeats(ts);
114
130
  }
115
131
 
@@ -181,6 +197,7 @@ function isGroupHead(grouping: number[], pulseIndex: number): boolean {
181
197
  * @category Rhythm & Meter
182
198
  */
183
199
  export function beatToBarPosition(beatInQuarters: number, ts: TimeSignature): BarPosition {
200
+ assertFiniteNumber(beatInQuarters, 'beat');
184
201
  const barLen = beatsPerBar(ts);
185
202
  const bar = Math.floor(beatInQuarters / barLen);
186
203
  return { bar, beat: beatInQuarters - bar * barLen };
@@ -196,6 +213,8 @@ export function beatToBarPosition(beatInQuarters: number, ts: TimeSignature): Ba
196
213
  * @category Rhythm & Meter
197
214
  */
198
215
  export function barPositionToBeat(pos: BarPosition, ts: TimeSignature): number {
216
+ assertInteger(pos.bar, 'bar position bar');
217
+ assertFiniteNumber(pos.beat, 'bar position beat');
199
218
  return pos.bar * beatsPerBar(ts) + pos.beat;
200
219
  }
201
220
 
@@ -225,6 +244,8 @@ export function barPositionToBeat(pos: BarPosition, ts: TimeSignature): number {
225
244
  * @category Rhythm & Meter
226
245
  */
227
246
  export function metricWeight(beatInQuarters: number, ts: TimeSignature): number {
247
+ assertFiniteNumber(beatInQuarters, 'beat');
248
+ assertTimeSignature(ts);
228
249
  const { beat } = beatToBarPosition(beatInQuarters, ts);
229
250
  const pulse = pulseBeats(ts);
230
251
  if (!isMultiple(beat, pulse)) {
@@ -275,8 +296,7 @@ export function isStrongBeat(beatInQuarters: number, ts: TimeSignature): boolean
275
296
  * @category Rhythm & Meter
276
297
  */
277
298
  export function tuplet(totalBeats: number, count: number): number[] {
278
- if (!Number.isInteger(count) || count <= 0) {
279
- throw new Error(`Invalid tuplet count: ${count}`);
280
- }
299
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, 'tuplet total beats');
300
+ assertPositiveInt(count, 'tuplet count');
281
301
  return new Array<number>(count).fill(totalBeats / count);
282
302
  }
@@ -5,6 +5,8 @@
5
5
  * seed the stream is fully reproducible.
6
6
  */
7
7
 
8
+ import { assertFiniteNumber, assertInteger, assertRange } from '../validation/index.js';
9
+
8
10
  /**
9
11
  * A deterministic PRNG with the sampling helpers the generators need.
10
12
  *
@@ -36,6 +38,7 @@ export type Rng = {
36
38
  * @category Utilities
37
39
  */
38
40
  export function createRng(seed: number): Rng {
41
+ assertFiniteNumber(seed, 'seed');
39
42
  let state = seed >>> 0;
40
43
  const next = () => {
41
44
  state = (state + 0x6d2b79f5) >>> 0;
@@ -46,8 +49,28 @@ export function createRng(seed: number): Rng {
46
49
  };
47
50
  return {
48
51
  next,
49
- prob: (p) => next() < p,
50
- range: (lo, hi) => lo + Math.floor(next() * (hi - lo + 1)),
51
- float: (lo, hi) => lo + next() * (hi - lo),
52
+ prob: (p) => next() < assertRange(p, 0, 1, 'probability'),
53
+ range: (lo, hi) => {
54
+ assertInteger(lo, 'range lower bound');
55
+ assertInteger(hi, 'range upper bound');
56
+ if (lo > hi) {
57
+ throw new RangeError(
58
+ `range lower bound must not exceed upper bound; received ${lo} > ${hi}`,
59
+ );
60
+ }
61
+ assertInteger(hi - lo, 'range span', 0);
62
+ return lo + Math.floor(next() * (hi - lo + 1));
63
+ },
64
+ float: (lo, hi) => {
65
+ assertFiniteNumber(lo, 'float lower bound');
66
+ assertFiniteNumber(hi, 'float upper bound');
67
+ if (lo > hi) {
68
+ throw new RangeError(
69
+ `float lower bound must not exceed upper bound; received ${lo} > ${hi}`,
70
+ );
71
+ }
72
+ assertFiniteNumber(hi - lo, 'float span');
73
+ return lo + next() * (hi - lo);
74
+ },
52
75
  };
53
76
  }
@@ -8,6 +8,13 @@
8
8
  * acoustic (just) tuning behind the tempered intervals.
9
9
  */
10
10
 
11
+ import {
12
+ assertFiniteNumber,
13
+ assertInteger,
14
+ assertPositiveInt,
15
+ assertRange,
16
+ } from '../validation/index.js';
17
+
11
18
  /**
12
19
  * An equal-tempered tuning: a reference pitch and a number of equal divisions of
13
20
  * the octave. With `divisions === 12` a step index is an ordinary MIDI number.
@@ -30,6 +37,13 @@ export type Tuning = {
30
37
  */
31
38
  export const TWELVE_TET: Tuning = { refStep: 69, refFreq: 440, divisions: 12 };
32
39
 
40
+ function assertTuning(tuning: Tuning): Tuning {
41
+ assertFiniteNumber(tuning.refStep, 'tuning.refStep');
42
+ assertRange(tuning.refFreq, Number.MIN_VALUE, Number.MAX_VALUE, 'tuning.refFreq');
43
+ assertPositiveInt(tuning.divisions, 'tuning.divisions');
44
+ return tuning;
45
+ }
46
+
33
47
  /**
34
48
  * Build an equal temperament with `n` divisions of the octave.
35
49
  *
@@ -46,6 +60,9 @@ export const TWELVE_TET: Tuning = { refStep: 69, refFreq: 440, divisions: 12 };
46
60
  * @category Pitch & Intervals
47
61
  */
48
62
  export function edo(n: number, refFreq = 440, refStep = 69): Tuning {
63
+ assertPositiveInt(n, 'EDO divisions');
64
+ assertRange(refFreq, Number.MIN_VALUE, Number.MAX_VALUE, 'reference frequency');
65
+ assertFiniteNumber(refStep, 'reference step');
49
66
  return { refStep, refFreq, divisions: n };
50
67
  }
51
68
 
@@ -64,7 +81,10 @@ export function edo(n: number, refFreq = 440, refStep = 69): Tuning {
64
81
  * @category Pitch & Intervals
65
82
  */
66
83
  export function frequencyOf(step: number, tuning: Tuning = TWELVE_TET): number {
67
- return tuning.refFreq * 2 ** ((step - tuning.refStep) / tuning.divisions);
84
+ assertFiniteNumber(step, 'step');
85
+ assertTuning(tuning);
86
+ const result = tuning.refFreq * 2 ** ((step - tuning.refStep) / tuning.divisions);
87
+ return assertRange(result, Number.MIN_VALUE, Number.MAX_VALUE, 'frequency result');
68
88
  }
69
89
 
70
90
  /**
@@ -77,6 +97,8 @@ export function frequencyOf(step: number, tuning: Tuning = TWELVE_TET): number {
77
97
  * @category Pitch & Intervals
78
98
  */
79
99
  export function nearestStep(freq: number, tuning: Tuning = TWELVE_TET): number {
100
+ assertRange(freq, Number.MIN_VALUE, Number.MAX_VALUE, 'frequency');
101
+ assertTuning(tuning);
80
102
  return Math.round(tuning.refStep + tuning.divisions * Math.log2(freq / tuning.refFreq));
81
103
  }
82
104
 
@@ -89,7 +111,9 @@ export function nearestStep(freq: number, tuning: Tuning = TWELVE_TET): number {
89
111
  * @category Pitch & Intervals
90
112
  */
91
113
  export function centsBetweenFreq(a: number, b: number): number {
92
- return 1200 * Math.log2(b / a);
114
+ assertRange(a, Number.MIN_VALUE, Number.MAX_VALUE, 'first frequency');
115
+ assertRange(b, Number.MIN_VALUE, Number.MAX_VALUE, 'second frequency');
116
+ return 1200 * (Math.log2(b) - Math.log2(a));
93
117
  }
94
118
 
95
119
  /**
@@ -101,7 +125,9 @@ export function centsBetweenFreq(a: number, b: number): number {
101
125
  * @category Pitch & Intervals
102
126
  */
103
127
  export function centsOfSteps(steps: number, tuning: Tuning = TWELVE_TET): number {
104
- return (steps * 1200) / tuning.divisions;
128
+ assertFiniteNumber(steps, 'steps');
129
+ assertTuning(tuning);
130
+ return assertFiniteNumber((steps * 1200) / tuning.divisions, 'cents result');
105
131
  }
106
132
 
107
133
  /**
@@ -114,7 +140,9 @@ export function centsOfSteps(steps: number, tuning: Tuning = TWELVE_TET): number
114
140
  * @category Pitch & Intervals
115
141
  */
116
142
  export function ratioToCents(numerator: number, denominator: number): number {
117
- return 1200 * Math.log2(numerator / denominator);
143
+ assertRange(numerator, Number.MIN_VALUE, Number.MAX_VALUE, 'ratio numerator');
144
+ assertRange(denominator, Number.MIN_VALUE, Number.MAX_VALUE, 'ratio denominator');
145
+ return 1200 * (Math.log2(numerator) - Math.log2(denominator));
118
146
  }
119
147
 
120
148
  /**
@@ -144,13 +172,15 @@ export const JUST_RATIOS: Record<number, [number, number]> = {
144
172
  * (positive means the just interval is wider).
145
173
  *
146
174
  * @param semitoneClass Semitone class in [0, 12].
147
- * @returns The deviation in cents, or NaN if the class has no listed ratio.
175
+ * @returns The deviation in cents.
176
+ * @throws If `semitoneClass` is not an integer in [0, 12].
148
177
  * @category Pitch & Intervals
149
178
  */
150
179
  export function justDeviationCents(semitoneClass: number): number {
180
+ assertInteger(semitoneClass, 'semitone class', 0, 12);
151
181
  const ratio = JUST_RATIOS[semitoneClass];
152
182
  if (!ratio) {
153
- return Number.NaN;
183
+ throw new RangeError(`semitone class has no just ratio: ${semitoneClass}`);
154
184
  }
155
185
  return ratioToCents(ratio[0], ratio[1]) - semitoneClass * 100;
156
186
  }
@@ -0,0 +1,119 @@
1
+ import type { TimeSignature } from '../meter/index.js';
2
+ import type { NoteEvent } from '../types.js';
3
+
4
+ /** Default upper bound for synchronous event/window/candidate generation. */
5
+ export const DEFAULT_GENERATION_BUDGET = 1_000_000;
6
+
7
+ /** Require a finite JavaScript number and return it unchanged. */
8
+ export function assertFiniteNumber(value: number, name: string): number {
9
+ if (!Number.isFinite(value)) {
10
+ throw new RangeError(`${name} must be finite; received ${value}`);
11
+ }
12
+ return value;
13
+ }
14
+
15
+ /** Require an integer in the inclusive range `[min, max]`. */
16
+ export function assertInteger(
17
+ value: number,
18
+ name: string,
19
+ min = Number.MIN_SAFE_INTEGER,
20
+ max = Number.MAX_SAFE_INTEGER,
21
+ ): number {
22
+ assertFiniteNumber(value, name);
23
+ if (!Number.isSafeInteger(value) || value < min || value > max) {
24
+ throw new RangeError(`${name} must be an integer in [${min}, ${max}]; received ${value}`);
25
+ }
26
+ return value;
27
+ }
28
+
29
+ /** Require a positive safe integer, optionally capped by `max`. */
30
+ export function assertPositiveInt(
31
+ value: number,
32
+ name: string,
33
+ max = DEFAULT_GENERATION_BUDGET,
34
+ ): number {
35
+ return assertInteger(value, name, 1, max);
36
+ }
37
+
38
+ /** Require a finite number in the inclusive range `[min, max]`. */
39
+ export function assertRange(value: number, min: number, max: number, name: string): number {
40
+ assertFiniteNumber(value, name);
41
+ if (value < min || value > max) {
42
+ throw new RangeError(`${name} must be in [${min}, ${max}]; received ${value}`);
43
+ }
44
+ return value;
45
+ }
46
+
47
+ /** Reject work estimates that would exceed a synchronous generation budget. */
48
+ export function assertGenerationBudget(
49
+ estimated: number,
50
+ name: string,
51
+ limit = DEFAULT_GENERATION_BUDGET,
52
+ ): number {
53
+ assertFiniteNumber(estimated, name);
54
+ assertPositiveInt(limit, `${name} limit`, Number.MAX_SAFE_INTEGER);
55
+ if (estimated < 0 || estimated > limit) {
56
+ throw new RangeError(`${name} exceeds the generation budget ${limit}; received ${estimated}`);
57
+ }
58
+ return estimated;
59
+ }
60
+
61
+ /** Validate a time signature, including additive grouping, before any early return. */
62
+ export function assertTimeSignature(ts: TimeSignature, name = 'time signature'): TimeSignature {
63
+ assertPositiveInt(ts.numerator, `${name}.numerator`);
64
+ assertPositiveInt(ts.denominator, `${name}.denominator`);
65
+ if (ts.grouping !== undefined) {
66
+ if (ts.grouping.length === 0) {
67
+ throw new RangeError(`${name}.grouping must not be empty`);
68
+ }
69
+ let sum = 0;
70
+ for (let index = 0; index < ts.grouping.length; index += 1) {
71
+ sum += assertPositiveInt(ts.grouping[index] ?? Number.NaN, `${name}.grouping[${index}]`);
72
+ }
73
+ const compound = ts.numerator % 3 === 0 && ts.numerator > 3;
74
+ const pulses = compound ? ts.numerator / 3 : ts.numerator;
75
+ if (sum !== pulses) {
76
+ throw new RangeError(`${name}.grouping must sum to ${pulses}; received ${sum}`);
77
+ }
78
+ }
79
+ return ts;
80
+ }
81
+
82
+ /** Validate the finite fields of one timeline note event. */
83
+ export function assertNoteEvent(
84
+ event: NoteEvent,
85
+ name = 'note event',
86
+ options: { allowNonPositiveDuration?: boolean } = {},
87
+ ): NoteEvent {
88
+ assertFiniteNumber(event.pitch, `${name}.pitch`);
89
+ assertRange(event.startBeat, 0, Number.MAX_SAFE_INTEGER, `${name}.startBeat`);
90
+ assertRange(
91
+ event.durationBeat,
92
+ options.allowNonPositiveDuration ? -Number.MAX_SAFE_INTEGER : Number.MIN_VALUE,
93
+ Number.MAX_SAFE_INTEGER,
94
+ `${name}.durationBeat`,
95
+ );
96
+ if (!options.allowNonPositiveDuration && event.durationBeat <= 0) {
97
+ throw new RangeError(`${name}.durationBeat must be positive; received ${event.durationBeat}`);
98
+ }
99
+ if (event.velocity !== undefined) {
100
+ assertRange(event.velocity, 0, 127, `${name}.velocity`);
101
+ }
102
+ return event;
103
+ }
104
+
105
+ /** Validate an event array and its allocation budget without copying it. */
106
+ export function assertNoteEvents(
107
+ events: NoteEvent[],
108
+ name = 'note events',
109
+ options: { allowNonPositiveDuration?: boolean; budget?: number } = {},
110
+ ): NoteEvent[] {
111
+ assertGenerationBudget(events.length, `${name} count`, options.budget);
112
+ for (let index = 0; index < events.length; index += 1) {
113
+ const event = events[index];
114
+ if (event !== undefined) {
115
+ assertNoteEvent(event, `${name}[${index}]`, options);
116
+ }
117
+ }
118
+ return events;
119
+ }