@libraz/libcantus 0.9.2 → 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 (126) hide show
  1. package/README.md +4 -3
  2. package/dist/analyze/index.cjs +315 -42
  3. package/dist/analyze/index.cjs.map +1 -1
  4. package/dist/analyze/index.d.cts +11 -99
  5. package/dist/analyze/index.d.ts +11 -99
  6. package/dist/analyze/index.js +6 -6
  7. package/dist/chunk-2ZWMSJH6.js +30 -0
  8. package/dist/chunk-2ZWMSJH6.js.map +1 -0
  9. package/dist/{chunk-R3S2LUBH.js → chunk-4MHA432G.js} +316 -184
  10. package/dist/chunk-4MHA432G.js.map +1 -0
  11. package/dist/chunk-62QZKCJQ.js +49 -0
  12. package/dist/chunk-62QZKCJQ.js.map +1 -0
  13. package/dist/chunk-AKLYPWIN.js +83 -0
  14. package/dist/chunk-AKLYPWIN.js.map +1 -0
  15. package/dist/{chunk-XTC3X6MS.js → chunk-C5XYKUVG.js} +100 -12
  16. package/dist/chunk-C5XYKUVG.js.map +1 -0
  17. package/dist/{chunk-LN7ZZMD2.js → chunk-CHMXELDS.js} +33 -14
  18. package/dist/chunk-CHMXELDS.js.map +1 -0
  19. package/dist/{chunk-A4532ZFR.js → chunk-EH6EM37U.js} +60 -4
  20. package/dist/chunk-EH6EM37U.js.map +1 -0
  21. package/dist/chunk-FBQK3ZQN.js +131 -0
  22. package/dist/chunk-FBQK3ZQN.js.map +1 -0
  23. package/dist/{chunk-5HTNMQW4.js → chunk-GOAW4VYL.js} +87 -21
  24. package/dist/chunk-GOAW4VYL.js.map +1 -0
  25. package/dist/{chunk-MSWFZF7J.js → chunk-I5R7ZBJS.js} +90 -6
  26. package/dist/chunk-I5R7ZBJS.js.map +1 -0
  27. package/dist/{chunk-GQKN5KDT.js → chunk-IT2O4XFE.js} +4 -9
  28. package/dist/chunk-IT2O4XFE.js.map +1 -0
  29. package/dist/{chunk-7Y7WF64D.js → chunk-MIWO425M.js} +6 -4
  30. package/dist/chunk-MIWO425M.js.map +1 -0
  31. package/dist/chunk-N6IHQUOX.js +1 -0
  32. package/dist/chunk-N6IHQUOX.js.map +1 -0
  33. package/dist/{chunk-COKTX2G5.js → chunk-SGU7SPLK.js} +29 -12
  34. package/dist/chunk-SGU7SPLK.js.map +1 -0
  35. package/dist/core/index.cjs +230 -14
  36. package/dist/core/index.cjs.map +1 -1
  37. package/dist/core/index.d.cts +56 -4
  38. package/dist/core/index.d.ts +56 -4
  39. package/dist/core/index.js +24 -4
  40. package/dist/{function-2bJW1Zp_.d.ts → function-DVH4Eusr.d.ts} +1 -1
  41. package/dist/{function-r2sh-jOO.d.cts → function-DYt4iKsn.d.cts} +1 -1
  42. package/dist/generate/index.cjs +830 -494
  43. package/dist/generate/index.cjs.map +1 -1
  44. package/dist/generate/index.d.cts +11 -5
  45. package/dist/generate/index.d.ts +11 -5
  46. package/dist/generate/index.js +9 -9
  47. package/dist/{index-BOBXBNwP.d.cts → index-BNlGP86A.d.cts} +1 -1
  48. package/dist/{index-CP3wAfEH.d.ts → index-Bg5dYMmZ.d.ts} +1 -1
  49. package/dist/{index-CM5W4rcm.d.ts → index-Bt4tOUD9.d.ts} +1 -1
  50. package/dist/index-CnZCo29u.d.cts +108 -0
  51. package/dist/{index-D_ZTUNKp.d.cts → index-CpOsP1y9.d.cts} +2 -2
  52. package/dist/{index-DZ1M21do.d.cts → index-CxqBQtH1.d.cts} +2 -1
  53. package/dist/{index-BdeQ1r_p.d.cts → index-Dikst44v.d.cts} +1 -1
  54. package/dist/{index-BMPxwjwV.d.ts → index-DxvARIP2.d.ts} +2 -2
  55. package/dist/{index-BreoJRw0.d.ts → index-Jc0Ryi79.d.ts} +2 -1
  56. package/dist/index-JnF4v9bx.d.ts +108 -0
  57. package/dist/index.cjs +818 -283
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.d.cts +9 -8
  60. package/dist/index.d.ts +9 -8
  61. package/dist/index.js +34 -13
  62. package/dist/model/index.cjs +170 -31
  63. package/dist/model/index.cjs.map +1 -1
  64. package/dist/model/index.d.cts +6 -5
  65. package/dist/model/index.d.ts +6 -5
  66. package/dist/model/index.js +6 -6
  67. package/dist/{styled-D7BAx3S3.d.ts → styled-DZ7GjShb.d.ts} +1 -1
  68. package/dist/{styled-BjXtOFNk.d.cts → styled-ZTbdaVFM.d.cts} +1 -1
  69. package/dist/theory/index.cjs +180 -34
  70. package/dist/theory/index.cjs.map +1 -1
  71. package/dist/theory/index.d.cts +6 -6
  72. package/dist/theory/index.d.ts +6 -6
  73. package/dist/theory/index.js +6 -5
  74. package/package.json +50 -19
  75. package/src/analyze/arrange/internal.ts +3 -1
  76. package/src/analyze/arrange/tension.ts +15 -3
  77. package/src/analyze/arrange/tracks.ts +36 -3
  78. package/src/analyze/detect/index.ts +46 -9
  79. package/src/analyze/functional/roman.ts +14 -5
  80. package/src/analyze/index.ts +1 -1
  81. package/src/analyze/timeline/index.ts +43 -7
  82. package/src/analyze/voice/index.ts +11 -2
  83. package/src/core/event-index/index.ts +104 -0
  84. package/src/core/index.ts +14 -0
  85. package/src/core/meter/index.ts +24 -4
  86. package/src/core/random/index.ts +26 -3
  87. package/src/core/tuning/index.ts +36 -6
  88. package/src/core/validation/index.ts +119 -0
  89. package/src/generate/bass/index.ts +23 -0
  90. package/src/generate/countermelody/index.ts +167 -76
  91. package/src/generate/drums/euclid.ts +10 -3
  92. package/src/generate/drums/index.ts +56 -24
  93. package/src/generate/drums/percussion.ts +1 -1
  94. package/src/generate/groove/index.ts +25 -0
  95. package/src/generate/harmonize/index.ts +55 -44
  96. package/src/generate/index.ts +0 -1
  97. package/src/generate/motif/index.ts +45 -21
  98. package/src/generate/progression/index.ts +8 -16
  99. package/src/generate/reharmony/index.ts +1 -8
  100. package/src/generate/rhythm/index.ts +11 -1
  101. package/src/index.ts +13 -247
  102. package/src/model/chord.ts +29 -10
  103. package/src/theory/chord/index.ts +4 -2
  104. package/src/theory/harmony/index.ts +6 -21
  105. package/src/theory/index.ts +6 -1
  106. package/src/theory/safety/index.ts +71 -2
  107. package/src/theory/spelling/index.ts +70 -3
  108. package/src/theory/voicing/satb.ts +25 -4
  109. package/src/theory/voicing/styled.ts +26 -3
  110. package/dist/chunk-5HTNMQW4.js.map +0 -1
  111. package/dist/chunk-7Y7WF64D.js.map +0 -1
  112. package/dist/chunk-A4532ZFR.js.map +0 -1
  113. package/dist/chunk-BHUT2S5G.js +0 -22
  114. package/dist/chunk-BHUT2S5G.js.map +0 -1
  115. package/dist/chunk-CCBLVAPK.js +0 -42
  116. package/dist/chunk-CCBLVAPK.js.map +0 -1
  117. package/dist/chunk-COKTX2G5.js.map +0 -1
  118. package/dist/chunk-GQKN5KDT.js.map +0 -1
  119. package/dist/chunk-LN7ZZMD2.js.map +0 -1
  120. package/dist/chunk-MSWFZF7J.js.map +0 -1
  121. package/dist/chunk-R3S2LUBH.js.map +0 -1
  122. package/dist/chunk-SDM3VJJ5.js +0 -41
  123. package/dist/chunk-SDM3VJJ5.js.map +0 -1
  124. package/dist/chunk-XTC3X6MS.js.map +0 -1
  125. package/dist/chunk-ZXW5YIWK.js +0 -55
  126. package/dist/chunk-ZXW5YIWK.js.map +0 -1
@@ -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
+ }
package/src/core/index.ts CHANGED
@@ -2,6 +2,9 @@
2
2
  * Public API of the core layer. Re-exports every core-layer module;
3
3
  * also available from the package root.
4
4
  */
5
+
6
+ export type { IndexedNoteEvent, NoteEventIndex } from './event-index/index.js';
7
+ export { createNoteEventIndex } from './event-index/index.js';
5
8
  export {
6
9
  classifyInterval,
7
10
  IntervalQuality,
@@ -45,3 +48,14 @@ export {
45
48
  TWELVE_TET,
46
49
  } from './tuning/index.js';
47
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
+ }
@@ -15,6 +15,12 @@
15
15
  import { isStrongBeat, type TimeSignature } from '../../core/meter/index.js';
16
16
  import { createRng } from '../../core/random/index.js';
17
17
  import type { KeyScale, NoteEvent } from '../../core/types.js';
18
+ import {
19
+ assertGenerationBudget,
20
+ assertInteger,
21
+ assertRange,
22
+ assertTimeSignature,
23
+ } from '../../core/validation/index.js';
18
24
  import type { Chord } from '../../theory/chord/index.js';
19
25
  import { nearestScaleTone } from '../../theory/scale/index.js';
20
26
 
@@ -282,14 +288,31 @@ function buildWalking(ctx: BuildContext, seg: BassSegment, next: BassSegment | u
282
288
  * @category Composition
283
289
  */
284
290
  export function generateBassLine(opts: BassLineOptions): NoteEvent[] {
291
+ assertGenerationBudget(opts.segments.length, 'bass segments');
292
+ for (let index = 0; index < opts.segments.length; index += 1) {
293
+ const segment = opts.segments[index];
294
+ if (!segment) continue;
295
+ assertRange(segment.startBeat, 0, Number.MAX_SAFE_INTEGER, `segments[${index}].startBeat`);
296
+ assertRange(segment.endBeat, 0, Number.MAX_SAFE_INTEGER, `segments[${index}].endBeat`);
297
+ if (segment.endBeat <= segment.startBeat) {
298
+ throw new RangeError(`segments[${index}] must have a positive duration`);
299
+ }
300
+ }
285
301
  const segments = [...opts.segments].sort((a, b) => a.startBeat - b.startBeat);
286
302
  if (segments.length === 0) {
287
303
  return [];
288
304
  }
289
305
 
290
306
  const ts = opts.ts ?? DEFAULT_TS;
307
+ assertTimeSignature(ts);
291
308
  const style = opts.style ?? DEFAULT_STYLE;
292
309
  const octave = opts.octave ?? DEFAULT_OCTAVE;
310
+ assertInteger(octave, 'bass octave', -1, 9);
311
+ const estimatedNotes = segments.reduce(
312
+ (count, segment) => count + Math.max(1, Math.ceil(segment.endBeat - segment.startBeat)),
313
+ 0,
314
+ );
315
+ assertGenerationBudget(estimatedNotes, 'bass notes');
293
316
  const low = octave * 12 + 12;
294
317
 
295
318
  const ctx: BuildContext = {