@libraz/libcantus 0.9.3 → 0.9.5

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 (201) hide show
  1. package/README.md +29 -20
  2. package/README_ja.md +268 -0
  3. package/dist/analyze/index.cjs +39 -1947
  4. package/dist/analyze/index.cjs.map +1 -1
  5. package/dist/analyze/index.d.cts +161 -91
  6. package/dist/analyze/index.d.ts +161 -91
  7. package/dist/analyze/index.js +19 -8
  8. package/dist/chunk-2A2QMA74.cjs +261 -0
  9. package/dist/chunk-2A2QMA74.cjs.map +1 -0
  10. package/dist/chunk-2PTFGVD7.js +261 -0
  11. package/dist/chunk-2PTFGVD7.js.map +1 -0
  12. package/dist/chunk-357ZDPVU.cjs +162 -0
  13. package/dist/chunk-357ZDPVU.cjs.map +1 -0
  14. package/dist/chunk-3KX76RLG.js +1548 -0
  15. package/dist/chunk-3KX76RLG.js.map +1 -0
  16. package/dist/chunk-4VZ5MZAM.cjs +889 -0
  17. package/dist/chunk-4VZ5MZAM.cjs.map +1 -0
  18. package/dist/{chunk-C5XYKUVG.js → chunk-7EONYVI5.js} +471 -171
  19. package/dist/chunk-7EONYVI5.js.map +1 -0
  20. package/dist/{chunk-IT2O4XFE.js → chunk-7JEP3MFY.js} +42 -34
  21. package/dist/chunk-7JEP3MFY.js.map +1 -0
  22. package/dist/{chunk-4MHA432G.js → chunk-AID75RN3.js} +572 -250
  23. package/dist/chunk-AID75RN3.js.map +1 -0
  24. package/dist/chunk-BSLTLGM3.cjs +497 -0
  25. package/dist/chunk-BSLTLGM3.cjs.map +1 -0
  26. package/dist/chunk-CU6VG2YM.cjs +109 -0
  27. package/dist/chunk-CU6VG2YM.cjs.map +1 -0
  28. package/dist/{chunk-EH6EM37U.js → chunk-DZMHIGI4.js} +81 -59
  29. package/dist/chunk-DZMHIGI4.js.map +1 -0
  30. package/dist/{chunk-AKLYPWIN.js → chunk-EDOTEABK.js} +45 -19
  31. package/dist/chunk-EDOTEABK.js.map +1 -0
  32. package/dist/{chunk-2ZWMSJH6.js → chunk-HN2OEUN2.js} +7 -4
  33. package/dist/chunk-HN2OEUN2.js.map +1 -0
  34. package/dist/{chunk-MIWO425M.js → chunk-KBTTEX7Q.js} +128 -43
  35. package/dist/chunk-KBTTEX7Q.js.map +1 -0
  36. package/dist/chunk-KQN2ZYND.cjs +724 -0
  37. package/dist/chunk-KQN2ZYND.cjs.map +1 -0
  38. package/dist/chunk-MPTVJIVG.cjs +3176 -0
  39. package/dist/chunk-MPTVJIVG.cjs.map +1 -0
  40. package/dist/chunk-MTMZTMWM.cjs +53 -0
  41. package/dist/chunk-MTMZTMWM.cjs.map +1 -0
  42. package/dist/{chunk-CHMXELDS.js → chunk-NNB67XA2.js} +372 -197
  43. package/dist/chunk-NNB67XA2.js.map +1 -0
  44. package/dist/chunk-OL3IHHSJ.cjs +1204 -0
  45. package/dist/chunk-OL3IHHSJ.cjs.map +1 -0
  46. package/dist/chunk-PI3UXRWK.cjs +528 -0
  47. package/dist/chunk-PI3UXRWK.cjs.map +1 -0
  48. package/dist/chunk-R2VVFAIN.js +528 -0
  49. package/dist/chunk-R2VVFAIN.js.map +1 -0
  50. package/dist/chunk-T64C7ZD7.cjs +1 -0
  51. package/dist/chunk-T64C7ZD7.cjs.map +1 -0
  52. package/dist/chunk-WFDXOLUB.cjs +337 -0
  53. package/dist/chunk-WFDXOLUB.cjs.map +1 -0
  54. package/dist/chunk-WTAN4ACG.cjs +1548 -0
  55. package/dist/chunk-WTAN4ACG.cjs.map +1 -0
  56. package/dist/chunk-YFJXS3AU.cjs +33 -0
  57. package/dist/chunk-YFJXS3AU.cjs.map +1 -0
  58. package/dist/{chunk-GOAW4VYL.js → chunk-YUE6TJ4M.js} +315 -145
  59. package/dist/chunk-YUE6TJ4M.js.map +1 -0
  60. package/dist/{chunk-62QZKCJQ.js → chunk-ZGLJM4A4.js} +10 -6
  61. package/dist/chunk-ZGLJM4A4.js.map +1 -0
  62. package/dist/core/index.cjs +140 -647
  63. package/dist/core/index.cjs.map +1 -1
  64. package/dist/core/index.d.cts +313 -41
  65. package/dist/core/index.d.ts +313 -41
  66. package/dist/core/index.js +64 -16
  67. package/dist/{function-DVH4Eusr.d.ts → function-CxixTB02.d.cts} +122 -10
  68. package/dist/{function-DYt4iKsn.d.cts → function-DTCt675z.d.ts} +122 -10
  69. package/dist/generate/index.cjs +28 -4085
  70. package/dist/generate/index.cjs.map +1 -1
  71. package/dist/generate/index.d.cts +374 -66
  72. package/dist/generate/index.d.ts +374 -66
  73. package/dist/generate/index.js +23 -9
  74. package/dist/{index-Jc0Ryi79.d.ts → index-B2Ulxxpo.d.cts} +21 -5
  75. package/dist/{index-CpOsP1y9.d.cts → index-BkYJ0xMq.d.cts} +18 -17
  76. package/dist/{index-CxqBQtH1.d.cts → index-BuaB-ypt.d.ts} +21 -5
  77. package/dist/index-CAN7v-kw.d.ts +199 -0
  78. package/dist/{index-Bt4tOUD9.d.ts → index-COw1Ky9g.d.ts} +93 -8
  79. package/dist/{index-Dikst44v.d.cts → index-Cfx8xgH0.d.ts} +14 -11
  80. package/dist/{index-DxvARIP2.d.ts → index-DRtIhlJj.d.ts} +18 -17
  81. package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
  82. package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
  83. package/dist/index-alrRninU.d.cts +199 -0
  84. package/dist/{index-BNlGP86A.d.cts → index-oTQbYYIs.d.cts} +93 -8
  85. package/dist/index-tR9jTn26.d.cts +275 -0
  86. package/dist/index-tR9jTn26.d.ts +275 -0
  87. package/dist/{index-Bg5dYMmZ.d.ts → index-x2vcsduE.d.cts} +14 -11
  88. package/dist/index.cjs +429 -7383
  89. package/dist/index.cjs.map +1 -1
  90. package/dist/index.d.cts +15 -15
  91. package/dist/index.d.ts +15 -15
  92. package/dist/index.js +121 -27
  93. package/dist/model/index.cjs +8 -2562
  94. package/dist/model/index.cjs.map +1 -1
  95. package/dist/model/index.d.cts +471 -81
  96. package/dist/model/index.d.ts +471 -81
  97. package/dist/model/index.js +6 -7
  98. package/dist/styled-Bl0ZK28h.d.ts +543 -0
  99. package/dist/styled-Crv6SA4S.d.cts +543 -0
  100. package/dist/theory/index.cjs +172 -1822
  101. package/dist/theory/index.cjs.map +1 -1
  102. package/dist/theory/index.d.cts +151 -233
  103. package/dist/theory/index.d.ts +151 -233
  104. package/dist/theory/index.js +30 -7
  105. package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
  106. package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
  107. package/package.json +34 -13
  108. package/dist/chunk-2ZWMSJH6.js.map +0 -1
  109. package/dist/chunk-4MHA432G.js.map +0 -1
  110. package/dist/chunk-62QZKCJQ.js.map +0 -1
  111. package/dist/chunk-AKLYPWIN.js.map +0 -1
  112. package/dist/chunk-C5XYKUVG.js.map +0 -1
  113. package/dist/chunk-CHMXELDS.js.map +0 -1
  114. package/dist/chunk-DYWFNC4A.js +0 -152
  115. package/dist/chunk-DYWFNC4A.js.map +0 -1
  116. package/dist/chunk-EH6EM37U.js.map +0 -1
  117. package/dist/chunk-FBQK3ZQN.js +0 -131
  118. package/dist/chunk-FBQK3ZQN.js.map +0 -1
  119. package/dist/chunk-GOAW4VYL.js.map +0 -1
  120. package/dist/chunk-I5R7ZBJS.js +0 -202
  121. package/dist/chunk-I5R7ZBJS.js.map +0 -1
  122. package/dist/chunk-IT2O4XFE.js.map +0 -1
  123. package/dist/chunk-MIWO425M.js.map +0 -1
  124. package/dist/chunk-SGU7SPLK.js +0 -924
  125. package/dist/chunk-SGU7SPLK.js.map +0 -1
  126. package/dist/index-CGMZNCx6.d.cts +0 -140
  127. package/dist/index-CGMZNCx6.d.ts +0 -140
  128. package/dist/index-CnZCo29u.d.cts +0 -108
  129. package/dist/index-JnF4v9bx.d.ts +0 -108
  130. package/dist/styled-DZ7GjShb.d.ts +0 -282
  131. package/dist/styled-ZTbdaVFM.d.cts +0 -282
  132. package/src/analyze/arrange/index.ts +0 -21
  133. package/src/analyze/arrange/internal.ts +0 -127
  134. package/src/analyze/arrange/tension.ts +0 -199
  135. package/src/analyze/arrange/tracks.ts +0 -312
  136. package/src/analyze/detect/index.ts +0 -272
  137. package/src/analyze/functional/borrowed.ts +0 -84
  138. package/src/analyze/functional/cadence.ts +0 -66
  139. package/src/analyze/functional/function.ts +0 -184
  140. package/src/analyze/functional/index.ts +0 -23
  141. package/src/analyze/functional/internal.ts +0 -29
  142. package/src/analyze/functional/roman.ts +0 -402
  143. package/src/analyze/index.ts +0 -49
  144. package/src/analyze/timeline/index.ts +0 -451
  145. package/src/analyze/voice/index.ts +0 -339
  146. package/src/core/event-index/index.ts +0 -104
  147. package/src/core/index.ts +0 -61
  148. package/src/core/interval/index.ts +0 -81
  149. package/src/core/meter/index.ts +0 -302
  150. package/src/core/pitch/index.ts +0 -311
  151. package/src/core/random/index.ts +0 -76
  152. package/src/core/tuning/index.ts +0 -186
  153. package/src/core/types.ts +0 -29
  154. package/src/core/validation/index.ts +0 -119
  155. package/src/generate/bass/index.ts +0 -373
  156. package/src/generate/countermelody/index.ts +0 -470
  157. package/src/generate/drums/beat.ts +0 -266
  158. package/src/generate/drums/euclid.ts +0 -65
  159. package/src/generate/drums/fills.ts +0 -356
  160. package/src/generate/drums/ghost.ts +0 -119
  161. package/src/generate/drums/hihat.ts +0 -285
  162. package/src/generate/drums/hit.ts +0 -31
  163. package/src/generate/drums/index.ts +0 -361
  164. package/src/generate/drums/internal.ts +0 -257
  165. package/src/generate/drums/kick.ts +0 -160
  166. package/src/generate/drums/percussion.ts +0 -121
  167. package/src/generate/drums/rng.ts +0 -7
  168. package/src/generate/drums/swing.ts +0 -71
  169. package/src/generate/groove/index.ts +0 -351
  170. package/src/generate/harmonize/index.ts +0 -451
  171. package/src/generate/index.ts +0 -58
  172. package/src/generate/motif/index.ts +0 -422
  173. package/src/generate/progression/index.ts +0 -362
  174. package/src/generate/reharmony/index.ts +0 -323
  175. package/src/generate/rhythm/index.ts +0 -175
  176. package/src/index.ts +0 -13
  177. package/src/model/chord.ts +0 -442
  178. package/src/model/index.ts +0 -15
  179. package/src/model/interval.ts +0 -82
  180. package/src/model/key.ts +0 -266
  181. package/src/model/note.ts +0 -176
  182. package/src/model/progression.ts +0 -161
  183. package/src/model/shared.ts +0 -12
  184. package/src/theory/chord/index.ts +0 -375
  185. package/src/theory/chordscale/index.ts +0 -374
  186. package/src/theory/counterpoint/index.ts +0 -230
  187. package/src/theory/harmony/index.ts +0 -77
  188. package/src/theory/index.ts +0 -112
  189. package/src/theory/safety/index.ts +0 -488
  190. package/src/theory/scale/degrees.ts +0 -123
  191. package/src/theory/scale/index.ts +0 -28
  192. package/src/theory/scale/key.ts +0 -56
  193. package/src/theory/scale/masks.ts +0 -153
  194. package/src/theory/spelling/index.ts +0 -249
  195. package/src/theory/symbol/index.ts +0 -268
  196. package/src/theory/voicing/index.ts +0 -5
  197. package/src/theory/voicing/internal.ts +0 -168
  198. package/src/theory/voicing/leading.ts +0 -112
  199. package/src/theory/voicing/satb.ts +0 -216
  200. package/src/theory/voicing/styled.ts +0 -199
  201. package/tsconfig.json +0 -23
@@ -1,112 +0,0 @@
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,488 +0,0 @@
1
- import type { KeyScale } from '../../core/types.js';
2
- import {
3
- assertFiniteNumber,
4
- assertGenerationBudget,
5
- assertInteger,
6
- } from '../../core/validation/index.js';
7
- import type { Chord } from '../chord/index.js';
8
- import { chordPitchClasses } from '../chord/index.js';
9
- import {
10
- createsHiddenParallelPerfect,
11
- createsParallelPerfect,
12
- createsVerticalDissonance,
13
- isForbiddenMelodicLeap,
14
- } from '../counterpoint/index.js';
15
- import { isScaleTone } from '../scale/index.js';
16
-
17
- /**
18
- * Severity policy: `strict` counterpoint vs. lenient `pop` voice-leading.
19
- *
20
- * @category Arrangement & Analysis
21
- */
22
- export type SafetyProfile = 'strict' | 'pop';
23
-
24
- /**
25
- * Overall placeability verdict for a candidate pitch.
26
- *
27
- * @category Arrangement & Analysis
28
- */
29
- export enum NoteSafety {
30
- Safe = 0,
31
- Warning = 1,
32
- Dissonant = 2,
33
- }
34
-
35
- /**
36
- * Bit flags describing why a pitch received its verdict.
37
- *
38
- * @category Arrangement & Analysis
39
- */
40
- export enum ReasonFlag {
41
- ChordTone = 1 << 0,
42
- Tension = 1 << 1,
43
- AvoidNote = 1 << 2,
44
- ScaleTone = 1 << 3,
45
- NonScale = 1 << 4,
46
- OutOfRange = 1 << 5,
47
- Tritone = 1 << 6,
48
- LargeLeap = 1 << 7,
49
- MinorSecond = 1 << 8,
50
- MajorSeventh = 1 << 9,
51
- VerticalDissonance = 1 << 10,
52
- ParallelPerfect = 1 << 11,
53
- HiddenParallel = 1 << 12,
54
- VoiceCrossing = 1 << 13,
55
- Suspension = 1 << 14,
56
- NeedsResolution = 1 << 15,
57
- }
58
-
59
- /**
60
- * A neighbouring voice's current and previous pitch.
61
- *
62
- * @category Arrangement & Analysis
63
- */
64
- export type VoiceSnapshot = {
65
- pitch: number;
66
- prevPitch?: number;
67
- };
68
-
69
- /**
70
- * Everything needed to evaluate one candidate pitch.
71
- *
72
- * @category Arrangement & Analysis
73
- */
74
- export type SafetyQuery = {
75
- profile: SafetyProfile;
76
- candidatePitch: number;
77
- prevPitch?: number;
78
- chord: Chord | null;
79
- key: KeyScale;
80
- otherVoices: VoiceSnapshot[];
81
- strongBeat: boolean;
82
- vocalLow?: number;
83
- vocalHigh?: number;
84
- };
85
-
86
- /**
87
- * The verdict, reason flags, and optional resolution guidance.
88
- *
89
- * @category Arrangement & Analysis
90
- */
91
- export type SafetyResult = {
92
- safety: NoteSafety;
93
- reasons: number;
94
- resolveTo?: number;
95
- suggestions?: number[];
96
- rationale?: string;
97
- };
98
-
99
- /** Semitone radius searched around a rejected candidate for safe alternatives. */
100
- const SUGGESTION_WINDOW = 12;
101
- /** Maximum number of alternative pitches returned in `SafetyResult.suggestions`. */
102
- const MAX_SUGGESTIONS = 3;
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
-
134
- function pitchClass(pitch: number): number {
135
- return ((Math.trunc(pitch) % 12) + 12) % 12;
136
- }
137
-
138
- function intervalAboveRoot(pitch: number, chord: Chord): number {
139
- return (((pitchClass(pitch) - pitchClass(chord.rootPc)) % 12) + 12) % 12;
140
- }
141
-
142
- function isChordMember(pitch: number, chord: Chord): boolean {
143
- return chordPitchClasses(chord).includes(pitchClass(pitch));
144
- }
145
-
146
- /** Whether the perfect fourth is an avoid note over this chord. */
147
- function avoidsFourth(chord: Chord): boolean {
148
- return chord.intervals.includes(4) && !chord.intervals.includes(5);
149
- }
150
-
151
- /** Whether the major seventh is an avoid note over this chord (dominant). */
152
- function avoidsMajorSeventh(chord: Chord): boolean {
153
- return chord.intervals.includes(4) && chord.intervals.includes(10);
154
- }
155
-
156
- /** Nearest chord tone at or below the candidate, within two semitones. */
157
- function stepResolution(pitch: number, chord: Chord): number | undefined {
158
- for (let delta = 1; delta <= 2; delta += 1) {
159
- if (isChordMember(pitch - delta, chord)) {
160
- return pitch - delta;
161
- }
162
- }
163
- return undefined;
164
- }
165
-
166
- /**
167
- * Evaluate the safety of placing a candidate pitch in a voice.
168
- *
169
- * Reasons are merged from harmonic context (chord tone / tension / avoid / scale)
170
- * and counterpoint (vertical dissonance, parallels, voice crossing). The `strict`
171
- * profile treats avoid notes and parallels as dissonant; `pop` treats them as
172
- * warnings. The final verdict is the worst severity among the reasons.
173
- *
174
- * @param q The candidate and its harmonic/voice-leading context.
175
- * @returns The verdict, reason bitmask, and optional resolution guidance.
176
- * @example
177
- * ```ts
178
- * import { evaluateSafety, makeChord, majorKey, NoteSafety } from '@libraz/libcantus';
179
- * const result = evaluateSafety({
180
- * profile: 'pop',
181
- * candidatePitch: 60, // C over a C major chord
182
- * chord: makeChord(0, 'maj'),
183
- * key: majorKey(0),
184
- * otherVoices: [],
185
- * strongBeat: true,
186
- * });
187
- * result.safety === NoteSafety.Safe; // true — C is a chord tone
188
- * ```
189
- * @category Arrangement & Analysis
190
- */
191
- export function evaluateSafety(q: SafetyQuery): SafetyResult {
192
- assertSafetyContext(q);
193
- assertFiniteNumber(q.candidatePitch, 'candidatePitch');
194
- return evaluateInternal(q, true);
195
- }
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
-
216
- /**
217
- * Core safety evaluation.
218
- *
219
- * @param q The candidate and its harmonic/voice-leading context.
220
- * @param collectSuggestions Whether to search for safe alternatives on a
221
- * non-Safe verdict. Set false during that search itself to bound recursion.
222
- * @returns The verdict, reason bitmask, and optional guidance.
223
- */
224
- function evaluateInternal(q: SafetyQuery, collectSuggestions: boolean): SafetyResult {
225
- let reasons = 0;
226
- let level = NoteSafety.Safe;
227
- let resolveTo: number | undefined;
228
- const raise = (l: NoteSafety) => {
229
- if (l > level) {
230
- level = l;
231
- }
232
- };
233
- const avoidSeverity = q.profile === 'strict' ? NoteSafety.Dissonant : NoteSafety.Warning;
234
- const parallelSeverity = avoidSeverity;
235
- const pitch = q.candidatePitch;
236
-
237
- if (
238
- (q.vocalLow !== undefined && pitch < q.vocalLow) ||
239
- (q.vocalHigh !== undefined && pitch > q.vocalHigh)
240
- ) {
241
- reasons |= ReasonFlag.OutOfRange;
242
- raise(NoteSafety.Warning);
243
- }
244
-
245
- const inScale = isScaleTone(pitch, q.key);
246
- const chord = q.chord;
247
-
248
- if (chord && isChordMember(pitch, chord)) {
249
- reasons |= ReasonFlag.ChordTone;
250
- } else if (chord) {
251
- const ic = intervalAboveRoot(pitch, chord);
252
- const isTension = ic === 2 || ic === 5 || ic === 9;
253
- const avoid = (ic === 5 && avoidsFourth(chord)) || (ic === 11 && avoidsMajorSeventh(chord));
254
- if (avoid) {
255
- reasons |= ReasonFlag.AvoidNote;
256
- if (isTension) {
257
- reasons |= ReasonFlag.Tension;
258
- }
259
- reasons |= ReasonFlag.NeedsResolution;
260
- resolveTo = stepResolution(pitch, chord);
261
- raise(avoidSeverity);
262
- } else if (isTension && inScale) {
263
- reasons |= ReasonFlag.Tension;
264
- raise(NoteSafety.Warning);
265
- } else if (inScale) {
266
- reasons |= ReasonFlag.ScaleTone;
267
- raise(NoteSafety.Warning);
268
- } else {
269
- reasons |= ReasonFlag.NonScale;
270
- raise(NoteSafety.Dissonant);
271
- }
272
- } else if (inScale) {
273
- reasons |= ReasonFlag.ScaleTone;
274
- raise(NoteSafety.Warning);
275
- } else {
276
- reasons |= ReasonFlag.NonScale;
277
- raise(NoteSafety.Dissonant);
278
- }
279
-
280
- // Harmonic tritone against a chord tone (except in dominant chords, where the
281
- // tritone is a defining, stable colour).
282
- if (chord && !(chord.intervals.includes(4) && chord.intervals.includes(10))) {
283
- const ic = intervalAboveRoot(pitch, chord);
284
- const toneOffsets = chord.intervals.map((i) => ((i % 12) + 12) % 12);
285
- if (toneOffsets.some((t) => Math.abs(ic - t) === 6)) {
286
- reasons |= ReasonFlag.Tritone;
287
- }
288
- }
289
-
290
- if (q.prevPitch !== undefined) {
291
- // Quality flags reduce the melodic interval to its pitch-class interval so
292
- // compound intervals are treated uniformly with their simple form. The
293
- // forbidden-leap flag defers to the counterpoint rule, which preserves the
294
- // octave boundary (an octave leap is allowed, wider leaps are not).
295
- const pc = Math.abs(pitch - q.prevPitch) % 12;
296
- if (pc === 6) {
297
- reasons |= ReasonFlag.Tritone;
298
- raise(NoteSafety.Dissonant);
299
- }
300
- if (isForbiddenMelodicLeap(q.prevPitch, pitch)) {
301
- reasons |= ReasonFlag.LargeLeap;
302
- raise(parallelSeverity);
303
- }
304
- if (pc === 1) {
305
- reasons |= ReasonFlag.MinorSecond;
306
- }
307
- if (pc === 11) {
308
- reasons |= ReasonFlag.MajorSeventh;
309
- }
310
- }
311
-
312
- if (q.strongBeat) {
313
- const twoVoice = q.otherVoices.length === 1;
314
- const prev = q.prevPitch;
315
- for (const ov of q.otherVoices) {
316
- if (!createsVerticalDissonance(pitch, ov.pitch, twoVoice)) {
317
- continue;
318
- }
319
- reasons |= ReasonFlag.VerticalDissonance;
320
- raise(NoteSafety.Dissonant);
321
- // A held pitch that was consonant on the previous step and is now
322
- // dissonant against the same voice is a prepared suspension.
323
- if (
324
- prev !== undefined &&
325
- pitch === prev &&
326
- ov.prevPitch !== undefined &&
327
- !createsVerticalDissonance(prev, ov.prevPitch, twoVoice)
328
- ) {
329
- reasons |= ReasonFlag.Suspension;
330
- }
331
- }
332
- }
333
-
334
- if (q.prevPitch !== undefined) {
335
- const prev = q.prevPitch;
336
- for (const ov of q.otherVoices) {
337
- if (ov.prevPitch === undefined) {
338
- continue;
339
- }
340
- // createsParallelPerfect covers every perfect class (unison/octave and
341
- // fifth), so no separate octave check is needed.
342
- if (createsParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch)) {
343
- reasons |= ReasonFlag.ParallelPerfect;
344
- raise(parallelSeverity);
345
- }
346
- if (createsHiddenParallelPerfect(prev, pitch, ov.prevPitch, ov.pitch)) {
347
- reasons |= ReasonFlag.HiddenParallel;
348
- raise(parallelSeverity);
349
- }
350
- const crossedNow = pitch - ov.pitch;
351
- const crossedPrev = prev - ov.prevPitch;
352
- if (crossedNow !== 0 && crossedPrev !== 0 && crossedNow > 0 !== crossedPrev > 0) {
353
- reasons |= ReasonFlag.VoiceCrossing;
354
- raise(parallelSeverity);
355
- }
356
- }
357
- }
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
-
365
- const result: SafetyResult = { safety: level, reasons, rationale: describe(reasons, q) };
366
- if (resolveTo !== undefined) {
367
- result.resolveTo = resolveTo;
368
- }
369
- if (collectSuggestions && level !== NoteSafety.Safe) {
370
- const suggestions = findSafeNearby(q, pitch);
371
- if (suggestions.length > 0) {
372
- result.suggestions = suggestions;
373
- }
374
- }
375
- return result;
376
- }
377
-
378
- /**
379
- * Find safe alternative pitches near a rejected candidate.
380
- *
381
- * Searches outward from the candidate within `SUGGESTION_WINDOW` semitones,
382
- * probing the pitch below before the one above at each distance so the result
383
- * is deterministic and ordered by nearness.
384
- *
385
- * @param q The safety context of the rejected candidate.
386
- * @param candidate The candidate pitch that was not Safe.
387
- * @returns Up to `MAX_SUGGESTIONS` safe pitches, nearest first.
388
- */
389
- function findSafeNearby(q: SafetyQuery, candidate: number): number[] {
390
- const out: number[] = [];
391
- for (let d = 1; d <= SUGGESTION_WINDOW; d += 1) {
392
- for (const p of [candidate - d, candidate + d]) {
393
- if (evaluateInternal({ ...q, candidatePitch: p }, false).safety === NoteSafety.Safe) {
394
- out.push(p);
395
- if (out.length >= MAX_SUGGESTIONS) {
396
- return out;
397
- }
398
- }
399
- }
400
- }
401
- return out;
402
- }
403
-
404
- /** Build a short human explanation from the reason flags. */
405
- function describe(reasons: number, q: SafetyQuery): string {
406
- if (reasons & ReasonFlag.OutOfRange) {
407
- return 'Outside the target vocal range';
408
- }
409
- if (reasons & ReasonFlag.Suspension) {
410
- return 'Prepared suspension — a held consonance now dissonant, awaiting resolution';
411
- }
412
- if (reasons & ReasonFlag.VerticalDissonance) {
413
- return 'Dissonant against a sounding voice on a strong beat';
414
- }
415
- if (reasons & ReasonFlag.AvoidNote) {
416
- return 'Avoid note — clashes with a chord tone a semitone away';
417
- }
418
- if (reasons & ReasonFlag.ParallelPerfect) {
419
- return 'Parallel perfect interval with another voice';
420
- }
421
- if (reasons & ReasonFlag.HiddenParallel) {
422
- return 'Hidden perfect interval by similar motion';
423
- }
424
- if (reasons & ReasonFlag.VoiceCrossing) {
425
- return 'Crosses another voice';
426
- }
427
- if (reasons & ReasonFlag.ChordTone) {
428
- return 'Chord tone';
429
- }
430
- if (reasons & ReasonFlag.Tension) {
431
- return 'Chord tension';
432
- }
433
- if (reasons & ReasonFlag.NonScale) {
434
- return 'Outside the key';
435
- }
436
- if (reasons & ReasonFlag.ScaleTone) {
437
- return 'Scale tone, not in the chord';
438
- }
439
- return q.chord ? 'Placeable' : 'No chord context';
440
- }
441
-
442
- /**
443
- * Enumerate placeable pitches in a range, chord tones first, descending.
444
- *
445
- * @param q The safety context minus the candidate pitch.
446
- * @param pitchLow Lowest MIDI pitch to consider (inclusive).
447
- * @param pitchHigh Highest MIDI pitch to consider (inclusive).
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.
450
- * @example
451
- * ```ts
452
- * import { enumerateSafePitches, makeChord, majorKey } from '@libraz/libcantus';
453
- * const pitches = enumerateSafePitches(
454
- * { profile: 'pop', chord: makeChord(0, 'maj'), key: majorKey(0), otherVoices: [], strongBeat: true },
455
- * 60,
456
- * 72,
457
- * );
458
- * pitches; // placeable pitches in [60, 72], chord tones first, each group descending
459
- * ```
460
- * @category Arrangement & Analysis
461
- */
462
- export function enumerateSafePitches(
463
- q: Omit<SafetyQuery, 'candidatePitch'>,
464
- pitchLow: number,
465
- pitchHigh: number,
466
- ): number[] {
467
- assertSafetyContext(q);
468
- const chordTones: number[] = [];
469
- const others: number[] = [];
470
- assertInteger(pitchLow, 'pitchLow');
471
- assertInteger(pitchHigh, 'pitchHigh');
472
- if (pitchLow > pitchHigh) {
473
- throw new RangeError(`pitchLow must not exceed pitchHigh; received ${pitchLow} > ${pitchHigh}`);
474
- }
475
- assertGenerationBudget(pitchHigh - pitchLow + 1, 'safe pitch candidates');
476
- for (let pitch = pitchHigh; pitch >= pitchLow; pitch -= 1) {
477
- const result = evaluateInternal({ ...q, candidatePitch: pitch }, false);
478
- if (result.safety === NoteSafety.Dissonant) {
479
- continue;
480
- }
481
- if (result.reasons & ReasonFlag.ChordTone) {
482
- chordTones.push(pitch);
483
- } else {
484
- others.push(pitch);
485
- }
486
- }
487
- return [...chordTones, ...others];
488
- }