@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,5 +1,5 @@
1
+ import type { KeyScale } from '../../core/types.js';
1
2
  import { scaleTonesInDegreeOrder } from '../scale/index.js';
2
- import type { KeyScale } from '../types.js';
3
3
 
4
4
  /**
5
5
  * Chord quality identifiers understood by the chord builder.
@@ -70,6 +70,24 @@ export type Chord = {
70
70
  bassSpelling?: PitchSpelling;
71
71
  };
72
72
 
73
+ /**
74
+ * A chord positioned in time: a root pitch class and quality placed at a beat,
75
+ * with optional harmonic-analysis hints. Serves as the neutral exchange record
76
+ * between progression generators and timeline analysers.
77
+ *
78
+ * @category Composition
79
+ */
80
+ export type ChordSpan = {
81
+ rootPc: number;
82
+ quality: ChordQuality;
83
+ startBeat: number;
84
+ bassPc?: number;
85
+ /** 0-based scale degree of the chord root, when known. */
86
+ degree?: number;
87
+ /** True when the chord is a secondary dominant tonicizing another degree. */
88
+ secondaryDominant?: boolean;
89
+ };
90
+
73
91
  /** Semitone offsets from the root for each supported chord quality. */
74
92
  const QUALITY_INTERVALS: Record<ChordQuality, number[]> = {
75
93
  maj: [0, 4, 7],
@@ -299,8 +317,10 @@ function classifySeventh(thirdIc: number, fifthIc: number, seventhIc: number): C
299
317
  function stackThirds(degree: number, key: KeyScale, size: 3 | 4): Chord {
300
318
  const tones = scaleTonesInDegreeOrder(key);
301
319
  const length = tones.length;
302
- if (length === 0) {
303
- return { rootPc: pitchClass(key.rootPc), quality: 'maj', intervals: [0, 4, 7] };
320
+ if (length !== 7) {
321
+ throw new Error(
322
+ `diatonic chord stacking requires a heptatonic scale (received ${length} tones)`,
323
+ );
304
324
  }
305
325
  const idx = (step: number) => (((degree + step) % length) + length) % length;
306
326
  const rootPc = tones[idx(0)] ?? pitchClass(key.rootPc);
@@ -1,5 +1,5 @@
1
- import { isConsonantInterval } from '../interval/index.js';
2
- import type { KeyScale } from '../types.js';
1
+ import { isConsonantInterval } from '../../core/interval/index.js';
2
+ import type { KeyScale } from '../../core/types.js';
3
3
 
4
4
  /** Reduce a MIDI pitch (or pitch class) to a pitch class in [0, 11]. */
5
5
  function pitchClass(pitch: number): number {
@@ -1,4 +1,4 @@
1
- import type { Chord } from '../chord/index.js';
1
+ import { type Chord, chordToneRole } from '../chord/index.js';
2
2
 
3
3
  /**
4
4
  * Harmonic role a pitch plays within a chord.
@@ -57,33 +57,18 @@ export type VoicedRole = {
57
57
  export function roleOf(pitch: number, chord: Chord, chordId = 0): VoicedRole {
58
58
  const interval = (((Math.trunc(pitch) - chord.rootPc) % 12) + 12) % 12;
59
59
  const tones = new Set(chord.intervals.map((i) => ((i % 12) + 12) % 12));
60
- const hasHigherSeventh = tones.has(10) || tones.has(11);
61
60
  const hasThird = tones.has(3) || tones.has(4);
62
61
  const isSuspendedTone =
63
62
  !hasThird && ((interval === 5 && tones.has(5)) || (interval === 2 && tones.has(2)));
63
+ const chordRole = chordToneRole(pitch, chord);
64
64
  let role: HarmonyRole;
65
65
  let lock: LockLevel;
66
- if (interval === 0) {
67
- role = 'root';
68
- lock = 'identity';
69
- } else if (isSuspendedTone) {
66
+ if (isSuspendedTone) {
70
67
  role = 'third'; // suspended tone occupies the third's quality-defining slot
71
68
  lock = 'quality';
72
- } else if (interval === 3 || interval === 4) {
73
- role = 'third';
74
- lock = 'quality';
75
- } else if (interval === 6 || interval === 7 || interval === 8) {
76
- role = 'fifth';
77
- lock = 'voicing';
78
- } else if (interval === 9 && tones.has(3) && tones.has(6) && !hasHigherSeventh) {
79
- role = 'seventh'; // diminished seventh
80
- lock = 'voicing';
81
- } else if (interval === 9 && tones.has(9) && !hasHigherSeventh) {
82
- role = 'sixth';
83
- lock = 'voicing';
84
- } else if (interval === 10 || interval === 11) {
85
- role = 'seventh';
86
- lock = 'voicing';
69
+ } else if (chordRole !== null) {
70
+ role = chordRole;
71
+ lock = chordRole === 'root' ? 'identity' : chordRole === 'third' ? 'quality' : 'voicing';
87
72
  } else {
88
73
  role = 'tension';
89
74
  lock = 'voicing';
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Public API of the theory layer. Re-exports every theory-layer module;
3
+ * also available from the package root.
4
+ */
5
+ export type {
6
+ Chord as ChordData,
7
+ ChordQuality,
8
+ ChordSpan,
9
+ PitchSpelling,
10
+ } from './chord/index.js';
11
+ export {
12
+ chordFromDegree,
13
+ chordPitchClasses,
14
+ chordQualities,
15
+ chordToneRole,
16
+ diatonicSeventh,
17
+ diatonicTriad,
18
+ makeChord,
19
+ } from './chord/index.js';
20
+ export type {
21
+ ChordScaleMatch,
22
+ ChordScaleReportEntry,
23
+ ScaleChoice,
24
+ } from './chordscale/index.js';
25
+ export {
26
+ availableTensions,
27
+ avoidNotes,
28
+ chordScaleReport,
29
+ chordScales,
30
+ scaleMatchesChord,
31
+ scalesForChanges,
32
+ } from './chordscale/index.js';
33
+ export {
34
+ createsHiddenParallelPerfect,
35
+ createsParallelOctave,
36
+ createsParallelPerfect,
37
+ createsParallelUnison,
38
+ createsVerticalDissonance,
39
+ createsVoiceCrossing,
40
+ createsVoiceOverlap,
41
+ exceedsSpacing,
42
+ isForbiddenMelodicLeap,
43
+ isLeadingToneResolution,
44
+ } from './counterpoint/index.js';
45
+ export type { HarmonyRole, LockLevel, VoicedRole } from './harmony/index.js';
46
+ export { roleOf } from './harmony/index.js';
47
+ export type {
48
+ SafetyProfile,
49
+ SafetyQuery,
50
+ SafetyResult,
51
+ VoiceSnapshot,
52
+ } from './safety/index.js';
53
+ export {
54
+ enumerateSafePitches,
55
+ evaluateSafety,
56
+ NoteSafety,
57
+ ReasonFlag,
58
+ } from './safety/index.js';
59
+ export {
60
+ BLUES_MASK,
61
+ CHROMATIC_MASK,
62
+ DORIAN_MASK,
63
+ diatonicPitchClasses,
64
+ HARMONIC_MINOR_MASK,
65
+ isScaleTone,
66
+ LOCRIAN_MASK,
67
+ LYDIAN_MASK,
68
+ MAJOR_MASK,
69
+ MAJOR_PENTATONIC_MASK,
70
+ MELODIC_MINOR_MASK,
71
+ MINOR_PENTATONIC_MASK,
72
+ MIXOLYDIAN_MASK,
73
+ majorKey,
74
+ maskFromOffsets,
75
+ minorKey,
76
+ NAMED_SCALES,
77
+ NATURAL_MINOR_MASK,
78
+ nearestScaleTone,
79
+ OCTATONIC_HALF_WHOLE_MASK,
80
+ OCTATONIC_WHOLE_HALF_MASK,
81
+ PHRYGIAN_MASK,
82
+ pitchToScaleDegree,
83
+ scaleByName,
84
+ scaleTonesInDegreeOrder,
85
+ WHOLE_TONE_MASK,
86
+ } from './scale/index.js';
87
+ export {
88
+ noteNames,
89
+ spellChord,
90
+ spellPitchClass,
91
+ spellPitchClasses,
92
+ spellScale,
93
+ } from './spelling/index.js';
94
+ export {
95
+ formatChordSymbol,
96
+ parseChordSymbol,
97
+ transposeChordSymbol,
98
+ } from './symbol/index.js';
99
+ export type {
100
+ StyledVoicingOptions,
101
+ VoiceRange,
102
+ VoicingOptions,
103
+ VoicingStyle,
104
+ } from './voicing/index.js';
105
+ export {
106
+ nextVoicing,
107
+ SATB_RANGES,
108
+ voiceChord,
109
+ voiceChordStyled,
110
+ voiceLeadingCost,
111
+ voiceProgression,
112
+ } from './voicing/index.js';
@@ -1,3 +1,9 @@
1
+ import type { KeyScale } from '../../core/types.js';
2
+ import {
3
+ assertFiniteNumber,
4
+ assertGenerationBudget,
5
+ assertInteger,
6
+ } from '../../core/validation/index.js';
1
7
  import type { Chord } from '../chord/index.js';
2
8
  import { chordPitchClasses } from '../chord/index.js';
3
9
  import {
@@ -7,7 +13,6 @@ import {
7
13
  isForbiddenMelodicLeap,
8
14
  } from '../counterpoint/index.js';
9
15
  import { isScaleTone } from '../scale/index.js';
10
- import type { KeyScale } from '../types.js';
11
16
 
12
17
  /**
13
18
  * Severity policy: `strict` counterpoint vs. lenient `pop` voice-leading.
@@ -96,6 +101,36 @@ const SUGGESTION_WINDOW = 12;
96
101
  /** Maximum number of alternative pitches returned in `SafetyResult.suggestions`. */
97
102
  const MAX_SUGGESTIONS = 3;
98
103
 
104
+ /** Canonical minimum verdict contributed by each non-informational reason. */
105
+ const REASON_SEVERITY: ReadonlyArray<
106
+ readonly [flag: ReasonFlag, pop: NoteSafety, strict: NoteSafety]
107
+ > = [
108
+ [ReasonFlag.Tension, NoteSafety.Warning, NoteSafety.Warning],
109
+ [ReasonFlag.AvoidNote, NoteSafety.Warning, NoteSafety.Dissonant],
110
+ [ReasonFlag.ScaleTone, NoteSafety.Warning, NoteSafety.Warning],
111
+ [ReasonFlag.NonScale, NoteSafety.Dissonant, NoteSafety.Dissonant],
112
+ [ReasonFlag.OutOfRange, NoteSafety.Warning, NoteSafety.Warning],
113
+ [ReasonFlag.Tritone, NoteSafety.Dissonant, NoteSafety.Dissonant],
114
+ [ReasonFlag.LargeLeap, NoteSafety.Warning, NoteSafety.Dissonant],
115
+ [ReasonFlag.VerticalDissonance, NoteSafety.Dissonant, NoteSafety.Dissonant],
116
+ [ReasonFlag.ParallelPerfect, NoteSafety.Warning, NoteSafety.Dissonant],
117
+ [ReasonFlag.HiddenParallel, NoteSafety.Warning, NoteSafety.Dissonant],
118
+ [ReasonFlag.VoiceCrossing, NoteSafety.Warning, NoteSafety.Dissonant],
119
+ [ReasonFlag.Suspension, NoteSafety.Dissonant, NoteSafety.Dissonant],
120
+ [ReasonFlag.NeedsResolution, NoteSafety.Warning, NoteSafety.Dissonant],
121
+ ];
122
+
123
+ function minimumSafetyForReasons(reasons: number, profile: SafetyProfile): NoteSafety {
124
+ let minimum = NoteSafety.Safe;
125
+ const severityIndex = profile === 'strict' ? 2 : 1;
126
+ for (const entry of REASON_SEVERITY) {
127
+ if (reasons & entry[0]) {
128
+ minimum = Math.max(minimum, entry[severityIndex]) as NoteSafety;
129
+ }
130
+ }
131
+ return minimum;
132
+ }
133
+
99
134
  function pitchClass(pitch: number): number {
100
135
  return ((Math.trunc(pitch) % 12) + 12) % 12;
101
136
  }
@@ -154,9 +189,30 @@ function stepResolution(pitch: number, chord: Chord): number | undefined {
154
189
  * @category Arrangement & Analysis
155
190
  */
156
191
  export function evaluateSafety(q: SafetyQuery): SafetyResult {
192
+ assertSafetyContext(q);
193
+ assertFiniteNumber(q.candidatePitch, 'candidatePitch');
157
194
  return evaluateInternal(q, true);
158
195
  }
159
196
 
197
+ /** Validate all numeric context fields once at a public safety entry point. */
198
+ function assertSafetyContext(q: Omit<SafetyQuery, 'candidatePitch'>): void {
199
+ if (q.prevPitch !== undefined) assertFiniteNumber(q.prevPitch, 'prevPitch');
200
+ if (q.vocalLow !== undefined) assertFiniteNumber(q.vocalLow, 'vocalLow');
201
+ if (q.vocalHigh !== undefined) assertFiniteNumber(q.vocalHigh, 'vocalHigh');
202
+ if (q.vocalLow !== undefined && q.vocalHigh !== undefined && q.vocalLow > q.vocalHigh) {
203
+ throw new RangeError('vocalLow must not exceed vocalHigh');
204
+ }
205
+ assertGenerationBudget(q.otherVoices.length, 'other voices');
206
+ for (let index = 0; index < q.otherVoices.length; index += 1) {
207
+ const voice = q.otherVoices[index];
208
+ if (!voice) continue;
209
+ assertFiniteNumber(voice.pitch, `otherVoices[${index}].pitch`);
210
+ if (voice.prevPitch !== undefined) {
211
+ assertFiniteNumber(voice.prevPitch, `otherVoices[${index}].prevPitch`);
212
+ }
213
+ }
214
+ }
215
+
160
216
  /**
161
217
  * Core safety evaluation.
162
218
  *
@@ -239,9 +295,11 @@ function evaluateInternal(q: SafetyQuery, collectSuggestions: boolean): SafetyRe
239
295
  const pc = Math.abs(pitch - q.prevPitch) % 12;
240
296
  if (pc === 6) {
241
297
  reasons |= ReasonFlag.Tritone;
298
+ raise(NoteSafety.Dissonant);
242
299
  }
243
300
  if (isForbiddenMelodicLeap(q.prevPitch, pitch)) {
244
301
  reasons |= ReasonFlag.LargeLeap;
302
+ raise(parallelSeverity);
245
303
  }
246
304
  if (pc === 1) {
247
305
  reasons |= ReasonFlag.MinorSecond;
@@ -298,6 +356,12 @@ function evaluateInternal(q: SafetyQuery, collectSuggestions: boolean): SafetyRe
298
356
  }
299
357
  }
300
358
 
359
+ // Keep reason production and verdict policy as one invariant even when a new
360
+ // call path skips an earlier local `raise`: informational flags (ChordTone,
361
+ // MinorSecond, MajorSeventh) may coexist with Safe; every policy flag above
362
+ // contributes at least its table severity.
363
+ raise(minimumSafetyForReasons(reasons, q.profile));
364
+
301
365
  const result: SafetyResult = { safety: level, reasons, rationale: describe(reasons, q) };
302
366
  if (resolveTo !== undefined) {
303
367
  result.resolveTo = resolveTo;
@@ -382,6 +446,7 @@ function describe(reasons: number, q: SafetyQuery): string {
382
446
  * @param pitchLow Lowest MIDI pitch to consider (inclusive).
383
447
  * @param pitchHigh Highest MIDI pitch to consider (inclusive).
384
448
  * @returns Placeable pitches (non-dissonant), chord tones before others, each group descending.
449
+ * @throws If either bound is non-finite/non-integral, reversed, or exceeds the generation budget.
385
450
  * @example
386
451
  * ```ts
387
452
  * import { enumerateSafePitches, makeChord, majorKey } from '@libraz/libcantus';
@@ -399,11 +464,15 @@ export function enumerateSafePitches(
399
464
  pitchLow: number,
400
465
  pitchHigh: number,
401
466
  ): number[] {
467
+ assertSafetyContext(q);
402
468
  const chordTones: number[] = [];
403
469
  const others: number[] = [];
404
- if (!Number.isFinite(pitchLow) || !Number.isFinite(pitchHigh)) {
405
- return [];
470
+ assertInteger(pitchLow, 'pitchLow');
471
+ assertInteger(pitchHigh, 'pitchHigh');
472
+ if (pitchLow > pitchHigh) {
473
+ throw new RangeError(`pitchLow must not exceed pitchHigh; received ${pitchLow} > ${pitchHigh}`);
406
474
  }
475
+ assertGenerationBudget(pitchHigh - pitchLow + 1, 'safe pitch candidates');
407
476
  for (let pitch = pitchHigh; pitch >= pitchLow; pitch -= 1) {
408
477
  const result = evaluateInternal({ ...q, candidatePitch: pitch }, false);
409
478
  if (result.safety === NoteSafety.Dissonant) {
@@ -0,0 +1,123 @@
1
+ import type { KeyScale } from '../../core/types.js';
2
+
3
+ /** Reduce a pitch (or pitch class) to a pitch class in [0, 11]. */
4
+ function pitchClass(pitch: number): number {
5
+ return ((Math.trunc(pitch) % 12) + 12) % 12;
6
+ }
7
+
8
+ /** Scale offset of a pitch relative to the key root, in [0, 11]. */
9
+ function scaleOffset(pitch: number, key: KeyScale): number {
10
+ return (pitchClass(pitch) - pitchClass(key.rootPc) + 12) % 12;
11
+ }
12
+
13
+ /**
14
+ * Test whether a pitch belongs to the scale.
15
+ *
16
+ * @param pitch MIDI pitch or bare pitch class.
17
+ * @param key The key/scale to test against.
18
+ * @returns True if the pitch class is a member of the scale.
19
+ *
20
+ * @example
21
+ * ```ts
22
+ * import { majorKey, isScaleTone } from '@libraz/libcantus';
23
+ * const key = majorKey(0); // C major
24
+ * isScaleTone(64, key); // true — E is in C major
25
+ * isScaleTone(61, key); // false — C# is not
26
+ * ```
27
+ *
28
+ * @category Scales
29
+ */
30
+ export function isScaleTone(pitch: number, key: KeyScale): boolean {
31
+ return ((key.modeMask12 >> scaleOffset(pitch, key)) & 1) === 1;
32
+ }
33
+
34
+ /**
35
+ * Find the nearest MIDI pitch whose pitch class is in the scale.
36
+ *
37
+ * The search expands symmetrically outward from `pitch`. On a tie (equal
38
+ * distance above and below) the lower pitch is returned.
39
+ *
40
+ * @param pitch MIDI pitch to snap.
41
+ * @param key The key/scale to snap to.
42
+ * @returns The nearest in-scale MIDI pitch.
43
+ *
44
+ * @category Scales
45
+ */
46
+ export function nearestScaleTone(pitch: number, key: KeyScale): number {
47
+ const base = Math.round(pitch);
48
+ for (let distance = 0; distance < 12; distance += 1) {
49
+ const lower = base - distance;
50
+ if (isScaleTone(lower, key)) {
51
+ return lower;
52
+ }
53
+ const higher = base + distance;
54
+ if (isScaleTone(higher, key)) {
55
+ return higher;
56
+ }
57
+ }
58
+ return base;
59
+ }
60
+
61
+ /**
62
+ * Get the 0-based scale degree of a pitch.
63
+ *
64
+ * @param pitch MIDI pitch or bare pitch class.
65
+ * @param key The key/scale to measure against.
66
+ * @returns The 0-based degree, or -1 if the pitch is not in the scale.
67
+ *
68
+ * @category Scales
69
+ */
70
+ export function pitchToScaleDegree(pitch: number, key: KeyScale): number {
71
+ const offset = scaleOffset(pitch, key);
72
+ if (((key.modeMask12 >> offset) & 1) === 0) {
73
+ return -1;
74
+ }
75
+ let degree = 0;
76
+ for (let n = 0; n < offset; n += 1) {
77
+ if (((key.modeMask12 >> n) & 1) === 1) {
78
+ degree += 1;
79
+ }
80
+ }
81
+ return degree;
82
+ }
83
+
84
+ /**
85
+ * List the pitch classes of a key's scale.
86
+ *
87
+ * @param key The key/scale to enumerate.
88
+ * @returns The member pitch classes, sorted ascending in [0, 11].
89
+ *
90
+ * @category Scales
91
+ */
92
+ export function diatonicPitchClasses(key: KeyScale): number[] {
93
+ const root = pitchClass(key.rootPc);
94
+ const pcs: number[] = [];
95
+ for (let n = 0; n < 12; n += 1) {
96
+ if (((key.modeMask12 >> n) & 1) === 1) {
97
+ pcs.push((root + n) % 12);
98
+ }
99
+ }
100
+ return pcs.sort((a, b) => a - b);
101
+ }
102
+
103
+ /**
104
+ * List the pitch classes of a key's scale in ascending scale-degree order.
105
+ *
106
+ * Degree 0 is the root; degrees follow the mask bits in offset order rather
107
+ * than sorted pitch-class order.
108
+ *
109
+ * @param key The key/scale to enumerate.
110
+ * @returns The member pitch classes ordered by scale degree.
111
+ *
112
+ * @category Scales
113
+ */
114
+ export function scaleTonesInDegreeOrder(key: KeyScale): number[] {
115
+ const root = pitchClass(key.rootPc);
116
+ const pcs: number[] = [];
117
+ for (let n = 0; n < 12; n += 1) {
118
+ if (((key.modeMask12 >> n) & 1) === 1) {
119
+ pcs.push((root + n) % 12);
120
+ }
121
+ }
122
+ return pcs;
123
+ }
@@ -0,0 +1,28 @@
1
+ export {
2
+ diatonicPitchClasses,
3
+ isScaleTone,
4
+ nearestScaleTone,
5
+ pitchToScaleDegree,
6
+ scaleTonesInDegreeOrder,
7
+ } from './degrees.js';
8
+ export { majorKey, minorKey, scaleByName } from './key.js';
9
+ export {
10
+ BLUES_MASK,
11
+ CHROMATIC_MASK,
12
+ DORIAN_MASK,
13
+ HARMONIC_MINOR_MASK,
14
+ LOCRIAN_MASK,
15
+ LYDIAN_MASK,
16
+ MAJOR_MASK,
17
+ MAJOR_PENTATONIC_MASK,
18
+ MELODIC_MINOR_MASK,
19
+ MINOR_PENTATONIC_MASK,
20
+ MIXOLYDIAN_MASK,
21
+ maskFromOffsets,
22
+ NAMED_SCALES,
23
+ NATURAL_MINOR_MASK,
24
+ OCTATONIC_HALF_WHOLE_MASK,
25
+ OCTATONIC_WHOLE_HALF_MASK,
26
+ PHRYGIAN_MASK,
27
+ WHOLE_TONE_MASK,
28
+ } from './masks.js';
@@ -0,0 +1,56 @@
1
+ import type { KeyScale } from '../../core/types.js';
2
+ import { MAJOR_MASK, NAMED_SCALES, NATURAL_MINOR_MASK } from './masks.js';
3
+
4
+ /**
5
+ * Build a `KeyScale` for a major key on the given root pitch class.
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * import { majorKey } from '@libraz/libcantus';
10
+ * const cMajor = majorKey(0); // C major: { rootPc: 0, modeMask12: MAJOR_MASK }
11
+ * ```
12
+ *
13
+ * @category Scales
14
+ */
15
+ export function majorKey(rootPc: number): KeyScale {
16
+ return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: MAJOR_MASK };
17
+ }
18
+
19
+ /**
20
+ * Build a `KeyScale` for a natural-minor key on the given root pitch class.
21
+ *
22
+ * @example
23
+ * ```ts
24
+ * import { minorKey } from '@libraz/libcantus';
25
+ * const aMinor = minorKey(9); // A natural minor: { rootPc: 9, modeMask12: NATURAL_MINOR_MASK }
26
+ * ```
27
+ *
28
+ * @category Scales
29
+ */
30
+ export function minorKey(rootPc: number): KeyScale {
31
+ return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: NATURAL_MINOR_MASK };
32
+ }
33
+
34
+ /**
35
+ * Build a `KeyScale` from a named scale (see {@link NAMED_SCALES}).
36
+ *
37
+ * @param name The scale name, e.g. `'dorian'` or `'harmonicMinor'`.
38
+ * @param rootPc The root pitch class.
39
+ * @returns The key/scale.
40
+ * @throws If the name is not a known scale.
41
+ *
42
+ * @example
43
+ * ```ts
44
+ * import { scaleByName } from '@libraz/libcantus';
45
+ * const dDorian = scaleByName('dorian', 2); // D Dorian, rootPc 2
46
+ * ```
47
+ *
48
+ * @category Scales
49
+ */
50
+ export function scaleByName(name: string, rootPc: number): KeyScale {
51
+ const mask = NAMED_SCALES[name];
52
+ if (mask === undefined) {
53
+ throw new Error(`Unknown scale: ${name}`);
54
+ }
55
+ return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: mask };
56
+ }