@libraz/libcantus 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +25 -2
  2. package/dist/analyze/index.cjs +1697 -0
  3. package/dist/analyze/index.cjs.map +1 -0
  4. package/dist/analyze/index.d.cts +429 -0
  5. package/dist/analyze/index.d.ts +429 -0
  6. package/dist/analyze/index.js +51 -0
  7. package/dist/analyze/index.js.map +1 -0
  8. package/dist/chunk-5HTNMQW4.js +653 -0
  9. package/dist/chunk-5HTNMQW4.js.map +1 -0
  10. package/dist/chunk-7Y7WF64D.js +410 -0
  11. package/dist/chunk-7Y7WF64D.js.map +1 -0
  12. package/dist/chunk-A4532ZFR.js +259 -0
  13. package/dist/chunk-A4532ZFR.js.map +1 -0
  14. package/dist/chunk-BHUT2S5G.js +22 -0
  15. package/dist/chunk-BHUT2S5G.js.map +1 -0
  16. package/dist/chunk-CCBLVAPK.js +42 -0
  17. package/dist/chunk-CCBLVAPK.js.map +1 -0
  18. package/dist/chunk-COKTX2G5.js +907 -0
  19. package/dist/chunk-COKTX2G5.js.map +1 -0
  20. package/dist/chunk-DYWFNC4A.js +152 -0
  21. package/dist/chunk-DYWFNC4A.js.map +1 -0
  22. package/dist/chunk-GQKN5KDT.js +159 -0
  23. package/dist/chunk-GQKN5KDT.js.map +1 -0
  24. package/dist/chunk-LN7ZZMD2.js +530 -0
  25. package/dist/chunk-LN7ZZMD2.js.map +1 -0
  26. package/dist/chunk-MSWFZF7J.js +118 -0
  27. package/dist/chunk-MSWFZF7J.js.map +1 -0
  28. package/dist/chunk-R3S2LUBH.js +2722 -0
  29. package/dist/chunk-R3S2LUBH.js.map +1 -0
  30. package/dist/chunk-SDM3VJJ5.js +41 -0
  31. package/dist/chunk-SDM3VJJ5.js.map +1 -0
  32. package/dist/chunk-XTC3X6MS.js +816 -0
  33. package/dist/chunk-XTC3X6MS.js.map +1 -0
  34. package/dist/chunk-ZXW5YIWK.js +55 -0
  35. package/dist/chunk-ZXW5YIWK.js.map +1 -0
  36. package/dist/core/index.cjs +432 -0
  37. package/dist/core/index.cjs.map +1 -0
  38. package/dist/core/index.d.cts +210 -0
  39. package/dist/core/index.d.ts +210 -0
  40. package/dist/core/index.js +73 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/function-2bJW1Zp_.d.ts +187 -0
  43. package/dist/function-r2sh-jOO.d.cts +187 -0
  44. package/dist/generate/index.cjs +3787 -0
  45. package/dist/generate/index.cjs.map +1 -0
  46. package/dist/generate/index.d.cts +980 -0
  47. package/dist/generate/index.d.ts +980 -0
  48. package/dist/generate/index.js +52 -0
  49. package/dist/generate/index.js.map +1 -0
  50. package/dist/index-BMPxwjwV.d.ts +151 -0
  51. package/dist/index-BOBXBNwP.d.cts +157 -0
  52. package/dist/index-BdeQ1r_p.d.cts +56 -0
  53. package/dist/index-BreoJRw0.d.ts +127 -0
  54. package/dist/index-CGMZNCx6.d.cts +140 -0
  55. package/dist/index-CGMZNCx6.d.ts +140 -0
  56. package/dist/index-CM5W4rcm.d.ts +157 -0
  57. package/dist/index-CP3wAfEH.d.ts +56 -0
  58. package/dist/index-Cl7mhOFM.d.cts +164 -0
  59. package/dist/index-Cl7mhOFM.d.ts +164 -0
  60. package/dist/index-DZ1M21do.d.cts +127 -0
  61. package/dist/index-D_ZTUNKp.d.cts +151 -0
  62. package/dist/index.cjs +5277 -4893
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +14 -2621
  65. package/dist/index.d.ts +14 -2621
  66. package/dist/index.js +170 -6152
  67. package/dist/index.js.map +1 -1
  68. package/dist/model/index.cjs +2435 -0
  69. package/dist/model/index.cjs.map +1 -0
  70. package/dist/model/index.d.cts +630 -0
  71. package/dist/model/index.d.ts +630 -0
  72. package/dist/model/index.js +21 -0
  73. package/dist/model/index.js.map +1 -0
  74. package/dist/styled-BjXtOFNk.d.cts +282 -0
  75. package/dist/styled-D7BAx3S3.d.ts +282 -0
  76. package/dist/theory/index.cjs +1677 -0
  77. package/dist/theory/index.cjs.map +1 -0
  78. package/dist/theory/index.d.cts +535 -0
  79. package/dist/theory/index.d.ts +535 -0
  80. package/dist/theory/index.js +149 -0
  81. package/dist/theory/index.js.map +1 -0
  82. package/dist/types-MGP29r3t.d.cts +30 -0
  83. package/dist/types-MGP29r3t.d.ts +30 -0
  84. package/package.json +31 -1
  85. package/src/analyze/arrange/index.ts +21 -0
  86. package/src/analyze/arrange/internal.ts +125 -0
  87. package/src/analyze/arrange/tension.ts +187 -0
  88. package/src/analyze/arrange/tracks.ts +279 -0
  89. package/src/{detect → analyze/detect}/index.ts +82 -23
  90. package/src/analyze/functional/borrowed.ts +84 -0
  91. package/src/analyze/functional/cadence.ts +66 -0
  92. package/src/analyze/functional/function.ts +184 -0
  93. package/src/analyze/functional/index.ts +23 -0
  94. package/src/analyze/functional/internal.ts +29 -0
  95. package/src/analyze/functional/roman.ts +393 -0
  96. package/src/analyze/index.ts +49 -0
  97. package/src/{timeline → analyze/timeline}/index.ts +87 -24
  98. package/src/{analysis → analyze/voice}/index.ts +81 -12
  99. package/src/core/index.ts +47 -0
  100. package/src/{interval → core/interval}/index.ts +15 -1
  101. package/src/{meter → core/meter}/index.ts +108 -5
  102. package/src/{pitch → core/pitch}/index.ts +58 -3
  103. package/src/{random → core/random}/index.ts +13 -1
  104. package/src/{tuning → core/tuning}/index.ts +28 -1
  105. package/src/{bass → generate/bass}/index.ts +66 -17
  106. package/src/{countermelody → generate/countermelody}/index.ts +54 -24
  107. package/src/{drums → generate/drums}/fills.ts +31 -20
  108. package/src/{drums → generate/drums}/index.ts +127 -9
  109. package/src/{drums → generate/drums}/internal.ts +7 -1
  110. package/src/{drums → generate/drums}/rng.ts +2 -2
  111. package/src/{drums → generate/drums}/swing.ts +7 -2
  112. package/src/{groove → generate/groove}/index.ts +118 -22
  113. package/src/{harmonize → generate/harmonize}/index.ts +96 -23
  114. package/src/generate/index.ts +59 -0
  115. package/src/{motif → generate/motif}/index.ts +94 -17
  116. package/src/{progression → generate/progression}/index.ts +61 -27
  117. package/src/{reharmony → generate/reharmony}/index.ts +46 -15
  118. package/src/{rhythm → generate/rhythm}/index.ts +47 -13
  119. package/src/index.ts +130 -116
  120. package/src/model/chord.ts +423 -0
  121. package/src/model/index.ts +5 -947
  122. package/src/model/interval.ts +82 -0
  123. package/src/model/key.ts +266 -0
  124. package/src/model/note.ts +176 -0
  125. package/src/model/progression.ts +161 -0
  126. package/src/model/shared.ts +12 -0
  127. package/src/{chord → theory/chord}/index.ts +85 -4
  128. package/src/{chordscale → theory/chordscale}/index.ts +67 -9
  129. package/src/{counterpoint → theory/counterpoint}/index.ts +23 -2
  130. package/src/{harmony → theory/harmony}/index.ts +19 -2
  131. package/src/theory/index.ts +107 -0
  132. package/src/{safety → theory/safety}/index.ts +59 -12
  133. package/src/theory/scale/degrees.ts +123 -0
  134. package/src/theory/scale/index.ts +28 -0
  135. package/src/theory/scale/key.ts +56 -0
  136. package/src/theory/scale/masks.ts +153 -0
  137. package/src/{spelling → theory/spelling}/index.ts +21 -3
  138. package/src/{symbol → theory/symbol}/index.ts +91 -36
  139. package/src/theory/voicing/index.ts +5 -0
  140. package/src/theory/voicing/internal.ts +168 -0
  141. package/src/theory/voicing/leading.ts +112 -0
  142. package/src/theory/voicing/satb.ts +195 -0
  143. package/src/theory/voicing/styled.ts +176 -0
  144. package/src/arrange/index.ts +0 -409
  145. package/src/functional/index.ts +0 -602
  146. package/src/scale/index.ts +0 -215
  147. package/src/voicing/index.ts +0 -523
  148. /package/src/{types.ts → core/types.ts} +0 -0
  149. /package/src/{drums → generate/drums}/beat.ts +0 -0
  150. /package/src/{drums → generate/drums}/euclid.ts +0 -0
  151. /package/src/{drums → generate/drums}/ghost.ts +0 -0
  152. /package/src/{drums → generate/drums}/hihat.ts +0 -0
  153. /package/src/{drums → generate/drums}/hit.ts +0 -0
  154. /package/src/{drums → generate/drums}/kick.ts +0 -0
  155. /package/src/{drums → generate/drums}/percussion.ts +0 -0
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Arrangement track-role analysis: a single harmony is inferred from every
3
+ * track's notes pooled together, each track is then analysed against that shared
4
+ * harmony, and notes that clash with the sounding chord are collected as
5
+ * conflicts.
6
+ */
7
+
8
+ import { isStrongBeat, parseTimeSignature, type TimeSignature } from '../../core/meter/index.js';
9
+ import type { KeyScale, NoteEvent } from '../../core/types.js';
10
+ import {
11
+ evaluateSafety,
12
+ NoteSafety,
13
+ type SafetyProfile,
14
+ type VoiceSnapshot,
15
+ } from '../../theory/safety/index.js';
16
+ import {
17
+ type CadenceHit,
18
+ type ChordTimeline,
19
+ chordTimelineFromNotes,
20
+ detectCadences,
21
+ } from '../timeline/index.js';
22
+ import { type AnalyzedNote, analyzeVoice, type VoiceNote } from '../voice/index.js';
23
+ import { covers, EPS, type PreparedTrack, poolNotes, prepareTracks } from './internal.js';
24
+
25
+ /**
26
+ * The musical role a track plays in the arrangement.
27
+ *
28
+ * @category Arrangement & Analysis
29
+ */
30
+ export type TrackRole = 'melody' | 'harmony' | 'bass' | 'other';
31
+
32
+ /**
33
+ * One input track: its notes plus optional name and role.
34
+ *
35
+ * @category Arrangement & Analysis
36
+ */
37
+ export type ArrangementTrack = {
38
+ name?: string;
39
+ role?: TrackRole;
40
+ notes: NoteEvent[];
41
+ };
42
+
43
+ /**
44
+ * A track after analysis: its resolved name, role, and per-note annotations.
45
+ *
46
+ * @category Arrangement & Analysis
47
+ */
48
+ export type TrackAnalysis = {
49
+ name: string;
50
+ role: TrackRole;
51
+ /** One {@link AnalyzedNote} per note, labelled against the inferred chords. */
52
+ notes: AnalyzedNote[];
53
+ };
54
+
55
+ /**
56
+ * A note that clashes with the harmony sounding beneath it.
57
+ *
58
+ * @category Arrangement & Analysis
59
+ */
60
+ export type Conflict = {
61
+ beat: number;
62
+ trackName: string;
63
+ pitch: number;
64
+ /** The clash severity: {@link NoteSafety.Warning} or {@link NoteSafety.Dissonant}. */
65
+ safety: NoteSafety;
66
+ /** {@link import('../safety/index.js').SafetyResult.reasons} bitmask. */
67
+ reasons: number;
68
+ rationale?: string;
69
+ };
70
+
71
+ /**
72
+ * The full result of {@link analyzeArrangement}.
73
+ *
74
+ * @category Arrangement & Analysis
75
+ */
76
+ export type ArrangementAnalysis = {
77
+ key: KeyScale;
78
+ timeline: ChordTimeline;
79
+ segmentConfidence: number[];
80
+ cadences: CadenceHit[];
81
+ tracks: TrackAnalysis[];
82
+ /** Notes clashing with the sounding harmony, worst severity first. */
83
+ conflicts: Conflict[];
84
+ };
85
+
86
+ /**
87
+ * Options controlling {@link analyzeArrangement} and {@link tensionCurve}.
88
+ *
89
+ * @category Arrangement & Analysis
90
+ */
91
+ export type ArrangementOptions = {
92
+ /** Key context; inferred from the pooled notes when omitted. */
93
+ key?: KeyScale;
94
+ /**
95
+ * Time signature; defaults to 4/4.
96
+ *
97
+ * @defaultValue `4/4`
98
+ */
99
+ ts?: TimeSignature;
100
+ /**
101
+ * Chord-slot length in beats; defaults to one bar of `ts`.
102
+ *
103
+ * @defaultValue one bar of `ts`
104
+ */
105
+ harmonicRhythm?: number;
106
+ /**
107
+ * Safety profile used for conflict detection; defaults to `pop`.
108
+ *
109
+ * @defaultValue `'pop'`
110
+ */
111
+ profile?: SafetyProfile;
112
+ };
113
+
114
+ /**
115
+ * Sounding pitches of every sub-voice at a beat, excluding one sub-voice.
116
+ *
117
+ * Only the sub-voice under analysis is excluded — sibling sub-voices of the
118
+ * same track are included, so dissonant clusters inside a single polyphonic
119
+ * track are still detected.
120
+ */
121
+ function otherVoicesSounding(
122
+ prepared: PreparedTrack[],
123
+ excludeTrack: number,
124
+ excludeVoice: number,
125
+ beat: number,
126
+ ): VoiceSnapshot[] {
127
+ const out: VoiceSnapshot[] = [];
128
+ for (let t = 0; t < prepared.length; t += 1) {
129
+ const track = prepared[t];
130
+ if (!track) {
131
+ continue;
132
+ }
133
+ for (let v = 0; v < track.voices.length; v += 1) {
134
+ if (t === excludeTrack && v === excludeVoice) {
135
+ continue;
136
+ }
137
+ const subVoice = track.voices[v];
138
+ if (!subVoice) {
139
+ continue;
140
+ }
141
+ for (const note of subVoice.sounding) {
142
+ if (!covers(note, beat)) {
143
+ continue;
144
+ }
145
+ const snap: VoiceSnapshot = { pitch: note.pitch };
146
+ if (note.prevPitch !== undefined) {
147
+ snap.prevPitch = note.prevPitch;
148
+ }
149
+ out.push(snap);
150
+ }
151
+ }
152
+ }
153
+ return out;
154
+ }
155
+
156
+ /**
157
+ * The beats at which a note must be checked against the harmony: its onset,
158
+ * plus the onset of every later chord segment the note sustains across. Each
159
+ * crossed segment is visited exactly once (at its own start), so a boundary
160
+ * beat is never evaluated twice for the same note.
161
+ */
162
+ function evaluationBeats(note: VoiceNote, timeline: ChordTimeline): number[] {
163
+ const noteEnd = note.startBeat + note.durationBeat;
164
+ const beats = [note.startBeat];
165
+ for (const segment of timeline.segments) {
166
+ if (segment.startBeat > note.startBeat + EPS && segment.startBeat < noteEnd - EPS) {
167
+ beats.push(segment.startBeat);
168
+ }
169
+ }
170
+ return beats;
171
+ }
172
+
173
+ /**
174
+ * Analyse a whole arrangement against a single inferred harmony.
175
+ *
176
+ * The chord timeline and key are inferred from the pooled notes of every track
177
+ * (see {@link chordTimelineFromNotes}); pooling all voices is robust even when
178
+ * roles are absent or a track doubles the harmony, so it is preferred over
179
+ * deriving the harmony from a subset. Zero- and negative-length notes never
180
+ * sound, so they are dropped at ingest and appear in neither the annotations
181
+ * nor the conflicts.
182
+ *
183
+ * Voice analysis assumes one monophonic voice at a time, so each track is
184
+ * first partitioned into monophonic sub-voices (a polyphonic block-chord track
185
+ * fans out into one sub-voice per simultaneous note; a monophonic track is
186
+ * unaffected). Each sub-voice is labelled note-by-note against the harmony
187
+ * with {@link analyzeVoice}, seeing every other sub-voice — including siblings
188
+ * within the same track — as its accompaniment. Every note is then re-checked
189
+ * with {@link evaluateSafety} at its onset and again at each chord change it
190
+ * sustains across, so a held note that clashes with a later chord is caught:
191
+ * evaluations that are not {@link NoteSafety.Safe} become conflicts, one per
192
+ * clashing beat, sorted worst severity first and then by beat.
193
+ *
194
+ * @param tracks The tracks to analyse.
195
+ * @param opts Analysis options; see {@link ArrangementOptions}.
196
+ * @returns The inferred harmony, per-track annotations, cadences, and conflicts.
197
+ * @example
198
+ * ```ts
199
+ * import { analyzeArrangement } from '@libraz/libcantus';
200
+ * const melody = [
201
+ * { pitch: 60, startBeat: 0, durationBeat: 2 },
202
+ * { pitch: 67, startBeat: 2, durationBeat: 2 },
203
+ * ];
204
+ * const { key, conflicts } = analyzeArrangement([{ role: 'melody', notes: melody }]);
205
+ * conflicts; // notes clashing with the inferred harmony, worst severity first
206
+ * ```
207
+ * @category Arrangement & Analysis
208
+ */
209
+ export function analyzeArrangement(
210
+ tracks: ArrangementTrack[],
211
+ opts: ArrangementOptions = {},
212
+ ): ArrangementAnalysis {
213
+ const ts = opts.ts ?? parseTimeSignature('4/4');
214
+ const profile: SafetyProfile = opts.profile ?? 'pop';
215
+ const pooled = poolNotes(tracks);
216
+
217
+ const { timeline, key, segmentConfidence } = chordTimelineFromNotes(pooled, {
218
+ key: opts.key,
219
+ ts,
220
+ harmonicRhythm: opts.harmonicRhythm,
221
+ });
222
+ const cadences = detectCadences(timeline, key);
223
+ const prepared = prepareTracks(tracks);
224
+
225
+ const trackAnalyses: TrackAnalysis[] = [];
226
+ const conflicts: Conflict[] = [];
227
+
228
+ for (let t = 0; t < prepared.length; t += 1) {
229
+ const track = prepared[t];
230
+ if (!track) {
231
+ continue;
232
+ }
233
+ const notes: AnalyzedNote[] = [];
234
+ for (let v = 0; v < track.voices.length; v += 1) {
235
+ const subVoice = track.voices[v];
236
+ if (!subVoice) {
237
+ continue;
238
+ }
239
+ notes.push(
240
+ ...analyzeVoice(subVoice.voice, timeline.at, key, (beat) =>
241
+ otherVoicesSounding(prepared, t, v, beat),
242
+ ),
243
+ );
244
+
245
+ for (const note of subVoice.voice) {
246
+ for (const beat of evaluationBeats(note, timeline)) {
247
+ const result = evaluateSafety({
248
+ profile,
249
+ candidatePitch: note.pitch,
250
+ chord: timeline.at(beat),
251
+ key,
252
+ otherVoices: otherVoicesSounding(prepared, t, v, beat),
253
+ strongBeat: isStrongBeat(beat, ts),
254
+ });
255
+ if (result.safety !== NoteSafety.Safe) {
256
+ const conflict: Conflict = {
257
+ beat,
258
+ trackName: track.name,
259
+ pitch: note.pitch,
260
+ safety: result.safety,
261
+ reasons: result.reasons,
262
+ };
263
+ if (result.rationale !== undefined) {
264
+ conflict.rationale = result.rationale;
265
+ }
266
+ conflicts.push(conflict);
267
+ }
268
+ }
269
+ }
270
+ }
271
+ // Sub-voice results interleave; restore the track's onset-then-pitch order.
272
+ notes.sort((a, b) => a.noteId - b.noteId);
273
+ trackAnalyses.push({ name: track.name, role: track.role, notes });
274
+ }
275
+
276
+ conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
277
+
278
+ return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
279
+ }
@@ -4,12 +4,23 @@
4
4
  * which only go name -> notes.
5
5
  */
6
6
 
7
- import type { Chord, ChordQuality } from '../chord/index.js';
8
- import { chordPitchClasses, chordQualities, makeChord } from '../chord/index.js';
9
- import { majorKey, minorKey } from '../scale/index.js';
10
- import type { KeyScale } from '../types.js';
7
+ import type { KeyScale } from '../../core/types.js';
8
+ import type { Chord, ChordQuality } from '../../theory/chord/index.js';
9
+ import { chordPitchClasses, chordQualities, makeChord } from '../../theory/chord/index.js';
10
+ import {
11
+ HARMONIC_MINOR_MASK,
12
+ MAJOR_MASK,
13
+ MELODIC_MINOR_MASK,
14
+ majorKey,
15
+ minorKey,
16
+ NATURAL_MINOR_MASK,
17
+ } from '../../theory/scale/index.js';
11
18
 
12
- /** A candidate chord interpretation of a pitch set. */
19
+ /**
20
+ * A candidate chord interpretation of a pitch set.
21
+ *
22
+ * @category Recognition
23
+ */
13
24
  export type ChordMatch = {
14
25
  rootPc: number;
15
26
  quality: ChordQuality;
@@ -25,7 +36,11 @@ export type ChordMatch = {
25
36
  bassPc?: number;
26
37
  };
27
38
 
28
- /** A candidate key interpretation of a pitch-class set. */
39
+ /**
40
+ * A candidate key interpretation of a pitch-class set.
41
+ *
42
+ * @category Recognition
43
+ */
29
44
  export type KeyMatch = {
30
45
  key: KeyScale;
31
46
  mode: 'major' | 'minor';
@@ -55,6 +70,13 @@ function uniquePitchClasses(pitches: number[]): number[] {
55
70
  *
56
71
  * @param pitches MIDI pitches or bare pitch classes (octave-agnostic).
57
72
  * @returns Ranked chord interpretations (may be empty).
73
+ * @example
74
+ * ```ts
75
+ * import { detectChord } from '@libraz/libcantus';
76
+ * const matches = detectChord([60, 64, 67]); // C E G
77
+ * matches[0]; // { rootPc: 0, quality: 'maj', exact: true, ... }
78
+ * ```
79
+ * @category Recognition
58
80
  */
59
81
  export function detectChord(pitches: number[]): ChordMatch[] {
60
82
  const input = uniquePitchClasses(pitches);
@@ -85,7 +107,7 @@ export function detectChord(pitches: number[]): ChordMatch[] {
85
107
  quality,
86
108
  missingPcs,
87
109
  extraPcs,
88
- exact: extraPcs.length === 0,
110
+ exact: extraPcs.length === 0 && missingPcs.length === 0,
89
111
  inversion,
90
112
  };
91
113
  if (inversion > 0) {
@@ -117,6 +139,12 @@ export function detectChord(pitches: number[]): ChordMatch[] {
117
139
  *
118
140
  * @param pitches MIDI pitches or bare pitch classes.
119
141
  * @returns The top-ranked chord, or null when nothing matches.
142
+ * @example
143
+ * ```ts
144
+ * import { detectChordBest } from '@libraz/libcantus';
145
+ * detectChordBest([60, 64, 67]); // C major triad: { rootPc: 0, quality: 'maj', ... }
146
+ * ```
147
+ * @category Recognition
120
148
  */
121
149
  export function detectChordBest(pitches: number[]): Chord | null {
122
150
  const best = detectChord(pitches)[0];
@@ -126,47 +154,78 @@ export function detectChordBest(pitches: number[]): Chord | null {
126
154
  return makeChord(best.rootPc, best.quality, best.bassPc);
127
155
  }
128
156
 
157
+ /**
158
+ * Minor-scale variants scored for each minor-key candidate. Scoring against all
159
+ * three lets the raised sixth and seventh (e.g. the leading tone G# in A minor)
160
+ * count toward their own tonic instead of only penalizing it.
161
+ */
162
+ const MINOR_MASK_VARIANTS = [NATURAL_MINOR_MASK, HARMONIC_MINOR_MASK, MELODIC_MINOR_MASK];
163
+
129
164
  /**
130
165
  * Rank major and minor keys by how well they contain a set of pitch classes.
131
166
  *
132
167
  * The tonic is weighted so that, among equally-fitting keys, the one whose root
133
- * appears in the input is preferred. Returns all 24 keys ranked best-first.
168
+ * appears in the input is preferred. Minor candidates are scored against the
169
+ * natural, harmonic, and melodic minor variants and keep the best of the three,
170
+ * so a minor cadence containing the leading tone still resolves to its own
171
+ * tonic; the returned key is always the natural-minor scale. Returns all 24
172
+ * keys ranked best-first, or an empty array for an empty input (mirroring
173
+ * {@link detectChord}).
134
174
  *
135
175
  * @param pitches MIDI pitches or bare pitch classes.
136
- * @returns Ranked key interpretations.
176
+ * @returns Ranked key interpretations (empty for an empty input).
177
+ * @example
178
+ * ```ts
179
+ * import { detectKey } from '@libraz/libcantus';
180
+ * const keys = detectKey([60, 62, 64, 65, 67, 69, 71]); // C major scale
181
+ * keys[0].mode; // 'major', with keys[0].key.rootPc === 0
182
+ * ```
183
+ * @category Recognition
137
184
  */
138
185
  export function detectKey(pitches: number[]): KeyMatch[] {
139
186
  const input = uniquePitchClasses(pitches);
187
+ if (input.length === 0) {
188
+ return [];
189
+ }
140
190
  const counts = new Map<number, number>();
141
191
  for (const pc of pitches.map(pitchClass)) {
142
192
  counts.set(pc, (counts.get(pc) ?? 0) + 1);
143
193
  }
144
- const total = pitches.length || 1;
194
+ const total = pitches.length;
145
195
  const results: (KeyMatch & { score: number })[] = [];
146
196
  for (let tonic = 0; tonic < 12; tonic += 1) {
147
197
  for (const mode of ['major', 'minor'] as const) {
148
198
  const key = mode === 'major' ? majorKey(tonic) : minorKey(tonic);
199
+ const masks = mode === 'major' ? [MAJOR_MASK] : MINOR_MASK_VARIANTS;
200
+ // Score each scale variant and keep the best; ties keep the earlier
201
+ // (more diatonic) variant.
149
202
  let inScale = 0;
150
- let weighted = 0;
151
- for (const pc of input) {
152
- const offset = (pc - tonic + 12) % 12;
153
- if ((key.modeMask12 >> offset) & 1) {
154
- inScale += 1;
203
+ let weighted = Number.NEGATIVE_INFINITY;
204
+ for (const mask of masks) {
205
+ let variantInScale = 0;
206
+ let variantWeighted = 0;
207
+ for (const pc of input) {
208
+ const offset = (pc - tonic + 12) % 12;
209
+ if ((mask >> offset) & 1) {
210
+ variantInScale += 1;
211
+ }
155
212
  }
156
- }
157
- for (const [pc, count] of counts) {
158
- const offset = (pc - tonic + 12) % 12;
159
- if ((key.modeMask12 >> offset) & 1) {
160
- weighted += count;
213
+ for (const [pc, count] of counts) {
214
+ const offset = (pc - tonic + 12) % 12;
215
+ if ((mask >> offset) & 1) {
216
+ variantWeighted += count;
217
+ }
161
218
  }
162
- if (pc === tonic) {
163
- weighted += count * 0.5;
219
+ if (variantWeighted > weighted) {
220
+ weighted = variantWeighted;
221
+ inScale = variantInScale;
164
222
  }
165
223
  }
224
+ weighted += (counts.get(tonic) ?? 0) * 0.5;
166
225
  results.push({
167
226
  key,
168
227
  mode,
169
- fit: input.length === 0 ? 0 : inScale / input.length,
228
+ fit: inScale / input.length,
170
229
  score: weighted / total,
171
230
  });
172
231
  }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Borrowed chords (modal interchange) and the Neapolitan.
3
+ *
4
+ * Roots are pitch classes measured against the key tonic, so borrowed and
5
+ * chromatic chords are handled by their semitone offset rather than requiring a
6
+ * spelled key signature.
7
+ */
8
+
9
+ import type { KeyScale } from '../../core/types.js';
10
+ import type { Chord } from '../../theory/chord/index.js';
11
+ import { HARMONIC_MINOR_MASK } from '../../theory/scale/index.js';
12
+ import { isDiatonic, isMinorKey, parallelKey } from './function.js';
13
+ import { isNeapolitan, mod12 } from './internal.js';
14
+
15
+ /**
16
+ * The origin of a recognized non-diatonic chord, or null when none applies.
17
+ *
18
+ * @category Functional Harmony
19
+ */
20
+ export type BorrowedSource = 'parallel-minor' | 'parallel-major' | 'neapolitan' | null;
21
+
22
+ /**
23
+ * In a minor key, whether a non-diatonic chord is explained by the harmonic
24
+ * minor scale on the same tonic (major V, V7, the raised-leading-tone viio).
25
+ * Such chords are in-key chromatic alterations, not modal interchange.
26
+ */
27
+ function isHarmonicMinorAlteration(chord: Chord, key: KeyScale): boolean {
28
+ return (
29
+ isMinorKey(key) &&
30
+ isDiatonic(chord, { rootPc: mod12(key.rootPc), modeMask12: HARMONIC_MINOR_MASK })
31
+ );
32
+ }
33
+
34
+ /**
35
+ * Whether a chord is borrowed from the parallel mode (modal interchange).
36
+ *
37
+ * True when the chord is not diatonic to `key` but is diatonic to its
38
+ * {@link parallelKey} — e.g. iv, bVI, or bVII in a major key, or the Picardy
39
+ * tonic and major IV in a minor key. Two non-diatonic families are excluded:
40
+ * chords diatonic to neither mode (they are chromatic, not borrowed), and, in
41
+ * a minor key, chords explained by the harmonic minor scale (the major
42
+ * dominant and raised-leading-tone chords), which are in-key alterations
43
+ * rather than interchange even though they happen to fit the parallel major.
44
+ *
45
+ * @param chord The chord to test.
46
+ * @param key The prevailing key.
47
+ * @returns True if the chord is borrowed from the parallel mode.
48
+ * @category Functional Harmony
49
+ */
50
+ export function isBorrowedChord(chord: Chord, key: KeyScale): boolean {
51
+ if (isDiatonic(chord, key)) {
52
+ return false;
53
+ }
54
+ if (isHarmonicMinorAlteration(chord, key)) {
55
+ return false;
56
+ }
57
+ return isDiatonic(chord, parallelKey(key));
58
+ }
59
+
60
+ /**
61
+ * Identify where a non-diatonic chord comes from.
62
+ *
63
+ * The Neapolitan (major triad on b2) is recognized first, since it sits
64
+ * outside both parallel modes; parallel-mode borrowing follows the
65
+ * {@link isBorrowedChord} rules. Diatonic chords, harmonic-minor alterations,
66
+ * and unrecognized chromatic chords all yield null.
67
+ *
68
+ * @param chord The chord to classify.
69
+ * @param key The prevailing key.
70
+ * @returns The borrowing source, or null.
71
+ * @category Functional Harmony
72
+ */
73
+ export function borrowedSource(chord: Chord, key: KeyScale): BorrowedSource {
74
+ if (isDiatonic(chord, key)) {
75
+ return null;
76
+ }
77
+ if (isNeapolitan(chord, key)) {
78
+ return 'neapolitan';
79
+ }
80
+ if (isBorrowedChord(chord, key)) {
81
+ return isMinorKey(key) ? 'parallel-major' : 'parallel-minor';
82
+ }
83
+ return null;
84
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Cadence detection between two successive chords.
3
+ *
4
+ * Roots are pitch classes measured against the key tonic, so borrowed and
5
+ * chromatic chords are handled by their semitone offset rather than requiring a
6
+ * spelled key signature.
7
+ */
8
+
9
+ import type { KeyScale } from '../../core/types.js';
10
+ import type { Chord } from '../../theory/chord/index.js';
11
+ import { isMinorKey } from './function.js';
12
+ import { mod12 } from './internal.js';
13
+
14
+ /**
15
+ * A recognized cadence type, or null when a chord pair forms none.
16
+ *
17
+ * @category Functional Harmony
18
+ */
19
+ export type Cadence = 'authentic' | 'plagal' | 'half' | 'deceptive' | null;
20
+
21
+ /**
22
+ * Classify the cadence formed by moving from one chord to the next.
23
+ *
24
+ * - authentic: V (dominant a fifth above the tonic) to I
25
+ * - plagal: IV (a fourth above the tonic) to I
26
+ * - deceptive: V to the submediant (diatonic vi or borrowed bVI in major, VI in
27
+ * minor)
28
+ * - half: any chord other than the dominant itself to V
29
+ *
30
+ * A static V-to-V repeat with no root motion is not a cadence and yields null.
31
+ *
32
+ * @param from The penultimate chord.
33
+ * @param to The final chord.
34
+ * @param key The prevailing key.
35
+ * @returns The cadence type, or null.
36
+ * @example
37
+ * ```ts
38
+ * import { detectCadence, makeChord, majorKey } from '@libraz/libcantus';
39
+ * detectCadence(makeChord(7, 'maj'), makeChord(0, 'maj'), majorKey(0)); // => 'authentic'
40
+ * ```
41
+ * @category Functional Harmony
42
+ */
43
+ export function detectCadence(from: Chord, to: Chord, key: KeyScale): Cadence {
44
+ const tonic = mod12(key.rootPc);
45
+ const fromOffset = mod12(from.rootPc - tonic);
46
+ const toOffset = mod12(to.rootPc - tonic);
47
+ // Deceptive targets: the diatonic submediant (vi at offset 9 in major, VI at
48
+ // offset 8 in minor) plus, in a major key, the borrowed flat-submediant bVI
49
+ // at offset 8.
50
+ const deceptiveTargets = isMinorKey(key) ? [8] : [8, 9];
51
+ if (fromOffset === 7 && toOffset === 0) {
52
+ return 'authentic';
53
+ }
54
+ if (fromOffset === 5 && toOffset === 0) {
55
+ return 'plagal';
56
+ }
57
+ if (fromOffset === 7 && deceptiveTargets.includes(toOffset)) {
58
+ return 'deceptive';
59
+ }
60
+ // A move to the dominant is a half cadence, but a no-root-motion V-to-V repeat
61
+ // is not a cadence at all.
62
+ if (toOffset === 7 && fromOffset !== 7) {
63
+ return 'half';
64
+ }
65
+ return null;
66
+ }