@libraz/libcantus 0.9.2 → 0.9.4

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 (200) hide show
  1. package/README.md +6 -5
  2. package/dist/analyze/index.cjs +41 -1676
  3. package/dist/analyze/index.cjs.map +1 -1
  4. package/dist/analyze/index.d.cts +194 -122
  5. package/dist/analyze/index.d.ts +194 -122
  6. package/dist/analyze/index.js +19 -8
  7. package/dist/chunk-36N73KZ3.cjs +246 -0
  8. package/dist/chunk-36N73KZ3.cjs.map +1 -0
  9. package/dist/chunk-4HLLZPXT.cjs +3077 -0
  10. package/dist/chunk-4HLLZPXT.cjs.map +1 -0
  11. package/dist/chunk-7MGZ7D62.cjs +498 -0
  12. package/dist/chunk-7MGZ7D62.cjs.map +1 -0
  13. package/dist/{chunk-R3S2LUBH.js → chunk-7NKDPF74.js} +660 -305
  14. package/dist/chunk-7NKDPF74.js.map +1 -0
  15. package/dist/chunk-CAN76NCU.cjs +679 -0
  16. package/dist/chunk-CAN76NCU.cjs.map +1 -0
  17. package/dist/{chunk-7Y7WF64D.js → chunk-D2FAJBYL.js} +104 -35
  18. package/dist/chunk-D2FAJBYL.js.map +1 -0
  19. package/dist/{chunk-COKTX2G5.js → chunk-EHVEBWM5.js} +1052 -510
  20. package/dist/chunk-EHVEBWM5.js.map +1 -0
  21. package/dist/chunk-FCANXBAT.cjs +30 -0
  22. package/dist/chunk-FCANXBAT.cjs.map +1 -0
  23. package/dist/chunk-FNDWXRRI.cjs +479 -0
  24. package/dist/chunk-FNDWXRRI.cjs.map +1 -0
  25. package/dist/{chunk-GQKN5KDT.js → chunk-GQSGS2N4.js} +43 -29
  26. package/dist/chunk-GQSGS2N4.js.map +1 -0
  27. package/dist/chunk-HKGYCQ34.js +53 -0
  28. package/dist/chunk-HKGYCQ34.js.map +1 -0
  29. package/dist/chunk-I24MV65U.js +106 -0
  30. package/dist/chunk-I24MV65U.js.map +1 -0
  31. package/dist/{chunk-LN7ZZMD2.js → chunk-KMHOICIY.js} +322 -173
  32. package/dist/chunk-KMHOICIY.js.map +1 -0
  33. package/dist/chunk-LW53AHQU.cjs +1117 -0
  34. package/dist/chunk-LW53AHQU.cjs.map +1 -0
  35. package/dist/chunk-N6IHQUOX.js +1 -0
  36. package/dist/chunk-N6IHQUOX.js.map +1 -0
  37. package/dist/chunk-NKIBQ6PT.js +30 -0
  38. package/dist/chunk-NKIBQ6PT.js.map +1 -0
  39. package/dist/chunk-NP4NA2FB.cjs +106 -0
  40. package/dist/chunk-NP4NA2FB.cjs.map +1 -0
  41. package/dist/chunk-NVE34YGZ.cjs +1449 -0
  42. package/dist/chunk-NVE34YGZ.cjs.map +1 -0
  43. package/dist/{chunk-A4532ZFR.js → chunk-NYYWMV2C.js} +115 -47
  44. package/dist/chunk-NYYWMV2C.js.map +1 -0
  45. package/dist/{chunk-5HTNMQW4.js → chunk-Q2XUP227.js} +310 -118
  46. package/dist/chunk-Q2XUP227.js.map +1 -0
  47. package/dist/chunk-Q6OP4U43.cjs +327 -0
  48. package/dist/chunk-Q6OP4U43.cjs.map +1 -0
  49. package/dist/chunk-RC6GBMCD.cjs +845 -0
  50. package/dist/chunk-RC6GBMCD.cjs.map +1 -0
  51. package/dist/{chunk-XTC3X6MS.js → chunk-RK35RIN7.js} +453 -152
  52. package/dist/chunk-RK35RIN7.js.map +1 -0
  53. package/dist/chunk-RUMU3UA6.cjs +53 -0
  54. package/dist/chunk-RUMU3UA6.cjs.map +1 -0
  55. package/dist/chunk-T64C7ZD7.cjs +1 -0
  56. package/dist/chunk-T64C7ZD7.cjs.map +1 -0
  57. package/dist/chunk-XEBG3G53.js +498 -0
  58. package/dist/chunk-XEBG3G53.js.map +1 -0
  59. package/dist/chunk-YLEMGEFH.js +246 -0
  60. package/dist/chunk-YLEMGEFH.js.map +1 -0
  61. package/dist/chunk-ZEJR4TKL.cjs +173 -0
  62. package/dist/chunk-ZEJR4TKL.cjs.map +1 -0
  63. package/dist/core/index.cjs +130 -431
  64. package/dist/core/index.cjs.map +1 -1
  65. package/dist/core/index.d.cts +331 -17
  66. package/dist/core/index.d.ts +331 -17
  67. package/dist/core/index.js +71 -13
  68. package/dist/{function-2bJW1Zp_.d.ts → function-BtiZb9i9.d.ts} +28 -7
  69. package/dist/{function-r2sh-jOO.d.cts → function-DNugAOrt.d.cts} +28 -7
  70. package/dist/generate/index.cjs +30 -3751
  71. package/dist/generate/index.cjs.map +1 -1
  72. package/dist/generate/index.d.cts +373 -59
  73. package/dist/generate/index.d.ts +373 -59
  74. package/dist/generate/index.js +23 -9
  75. package/dist/{index-DZ1M21do.d.cts → index-6VR8-kit.d.ts} +20 -4
  76. package/dist/index-70p168VM.d.ts +192 -0
  77. package/dist/{index-D_ZTUNKp.d.cts → index-Be74mcon.d.cts} +18 -17
  78. package/dist/index-BtXT6UQ0.d.cts +192 -0
  79. package/dist/{index-BMPxwjwV.d.ts → index-C5UBbQ4G.d.ts} +18 -17
  80. package/dist/{index-BOBXBNwP.d.cts → index-C83mZI1P.d.ts} +93 -8
  81. package/dist/{index-BdeQ1r_p.d.cts → index-D6b6na69.d.cts} +14 -11
  82. package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
  83. package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
  84. package/dist/index-OG65ocOr.d.cts +272 -0
  85. package/dist/index-OG65ocOr.d.ts +272 -0
  86. package/dist/{index-BreoJRw0.d.ts → index-tJhkmQgE.d.cts} +20 -4
  87. package/dist/{index-CP3wAfEH.d.ts → index-tSQh-xwY.d.ts} +14 -11
  88. package/dist/{index-CM5W4rcm.d.ts → index-y_ak9_Hi.d.cts} +93 -8
  89. package/dist/index.cjs +419 -6848
  90. package/dist/index.cjs.map +1 -1
  91. package/dist/index.d.cts +14 -13
  92. package/dist/index.d.ts +14 -13
  93. package/dist/index.js +132 -27
  94. package/dist/model/index.cjs +8 -2423
  95. package/dist/model/index.cjs.map +1 -1
  96. package/dist/model/index.d.cts +433 -74
  97. package/dist/model/index.d.ts +433 -74
  98. package/dist/model/index.js +6 -7
  99. package/dist/styled-DIRkk9Uz.d.ts +506 -0
  100. package/dist/styled-DJbkJQfj.d.cts +506 -0
  101. package/dist/theory/index.cjs +172 -1676
  102. package/dist/theory/index.cjs.map +1 -1
  103. package/dist/theory/index.d.cts +151 -233
  104. package/dist/theory/index.d.ts +151 -233
  105. package/dist/theory/index.js +31 -7
  106. package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
  107. package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
  108. package/package.json +73 -24
  109. package/dist/chunk-5HTNMQW4.js.map +0 -1
  110. package/dist/chunk-7Y7WF64D.js.map +0 -1
  111. package/dist/chunk-A4532ZFR.js.map +0 -1
  112. package/dist/chunk-BHUT2S5G.js +0 -22
  113. package/dist/chunk-BHUT2S5G.js.map +0 -1
  114. package/dist/chunk-CCBLVAPK.js +0 -42
  115. package/dist/chunk-CCBLVAPK.js.map +0 -1
  116. package/dist/chunk-COKTX2G5.js.map +0 -1
  117. package/dist/chunk-DYWFNC4A.js +0 -152
  118. package/dist/chunk-DYWFNC4A.js.map +0 -1
  119. package/dist/chunk-GQKN5KDT.js.map +0 -1
  120. package/dist/chunk-LN7ZZMD2.js.map +0 -1
  121. package/dist/chunk-MSWFZF7J.js +0 -118
  122. package/dist/chunk-MSWFZF7J.js.map +0 -1
  123. package/dist/chunk-R3S2LUBH.js.map +0 -1
  124. package/dist/chunk-SDM3VJJ5.js +0 -41
  125. package/dist/chunk-SDM3VJJ5.js.map +0 -1
  126. package/dist/chunk-XTC3X6MS.js.map +0 -1
  127. package/dist/chunk-ZXW5YIWK.js +0 -55
  128. package/dist/chunk-ZXW5YIWK.js.map +0 -1
  129. package/dist/index-CGMZNCx6.d.cts +0 -140
  130. package/dist/index-CGMZNCx6.d.ts +0 -140
  131. package/dist/styled-BjXtOFNk.d.cts +0 -282
  132. package/dist/styled-D7BAx3S3.d.ts +0 -282
  133. package/src/analyze/arrange/index.ts +0 -21
  134. package/src/analyze/arrange/internal.ts +0 -125
  135. package/src/analyze/arrange/tension.ts +0 -187
  136. package/src/analyze/arrange/tracks.ts +0 -279
  137. package/src/analyze/detect/index.ts +0 -235
  138. package/src/analyze/functional/borrowed.ts +0 -84
  139. package/src/analyze/functional/cadence.ts +0 -66
  140. package/src/analyze/functional/function.ts +0 -184
  141. package/src/analyze/functional/index.ts +0 -23
  142. package/src/analyze/functional/internal.ts +0 -29
  143. package/src/analyze/functional/roman.ts +0 -393
  144. package/src/analyze/index.ts +0 -49
  145. package/src/analyze/timeline/index.ts +0 -415
  146. package/src/analyze/voice/index.ts +0 -330
  147. package/src/core/index.ts +0 -47
  148. package/src/core/interval/index.ts +0 -81
  149. package/src/core/meter/index.ts +0 -282
  150. package/src/core/pitch/index.ts +0 -311
  151. package/src/core/random/index.ts +0 -53
  152. package/src/core/tuning/index.ts +0 -156
  153. package/src/core/types.ts +0 -29
  154. package/src/generate/bass/index.ts +0 -350
  155. package/src/generate/countermelody/index.ts +0 -379
  156. package/src/generate/drums/beat.ts +0 -266
  157. package/src/generate/drums/euclid.ts +0 -58
  158. package/src/generate/drums/fills.ts +0 -356
  159. package/src/generate/drums/ghost.ts +0 -119
  160. package/src/generate/drums/hihat.ts +0 -285
  161. package/src/generate/drums/hit.ts +0 -31
  162. package/src/generate/drums/index.ts +0 -329
  163. package/src/generate/drums/internal.ts +0 -257
  164. package/src/generate/drums/kick.ts +0 -160
  165. package/src/generate/drums/percussion.ts +0 -121
  166. package/src/generate/drums/rng.ts +0 -7
  167. package/src/generate/drums/swing.ts +0 -71
  168. package/src/generate/groove/index.ts +0 -326
  169. package/src/generate/harmonize/index.ts +0 -440
  170. package/src/generate/index.ts +0 -59
  171. package/src/generate/motif/index.ts +0 -398
  172. package/src/generate/progression/index.ts +0 -370
  173. package/src/generate/reharmony/index.ts +0 -330
  174. package/src/generate/rhythm/index.ts +0 -165
  175. package/src/index.ts +0 -247
  176. package/src/model/chord.ts +0 -423
  177. package/src/model/index.ts +0 -15
  178. package/src/model/interval.ts +0 -82
  179. package/src/model/key.ts +0 -266
  180. package/src/model/note.ts +0 -176
  181. package/src/model/progression.ts +0 -161
  182. package/src/model/shared.ts +0 -12
  183. package/src/theory/chord/index.ts +0 -373
  184. package/src/theory/chordscale/index.ts +0 -374
  185. package/src/theory/counterpoint/index.ts +0 -230
  186. package/src/theory/harmony/index.ts +0 -92
  187. package/src/theory/index.ts +0 -107
  188. package/src/theory/safety/index.ts +0 -419
  189. package/src/theory/scale/degrees.ts +0 -123
  190. package/src/theory/scale/index.ts +0 -28
  191. package/src/theory/scale/key.ts +0 -56
  192. package/src/theory/scale/masks.ts +0 -153
  193. package/src/theory/spelling/index.ts +0 -182
  194. package/src/theory/symbol/index.ts +0 -268
  195. package/src/theory/voicing/index.ts +0 -5
  196. package/src/theory/voicing/internal.ts +0 -168
  197. package/src/theory/voicing/leading.ts +0 -112
  198. package/src/theory/voicing/satb.ts +0 -195
  199. package/src/theory/voicing/styled.ts +0 -176
  200. package/tsconfig.json +0 -23
@@ -1,415 +0,0 @@
1
- import type { TimeSignature } from '../../core/meter/index.js';
2
- import { beatsPerBar, metricWeight, parseTimeSignature } from '../../core/meter/index.js';
3
- import type { KeyScale, NoteEvent } from '../../core/types.js';
4
- import type { Chord, ChordSpan } from '../../theory/chord/index.js';
5
- import { chordPitchClasses, makeChord } from '../../theory/chord/index.js';
6
- import { isScaleTone, majorKey } from '../../theory/scale/index.js';
7
- import type { ChordMatch } from '../detect/index.js';
8
- import { detectChord, detectChordBest, detectKey } from '../detect/index.js';
9
- import type { Cadence } from '../functional/index.js';
10
- import { detectCadence } from '../functional/index.js';
11
-
12
- /**
13
- * A chord occupying a half-open beat span.
14
- *
15
- * @category Arrangement & Analysis
16
- */
17
- export type ChordSegment = {
18
- startBeat: number;
19
- endBeat: number;
20
- chord: Chord;
21
- };
22
-
23
- /**
24
- * A beat-indexed sequence of chord segments.
25
- *
26
- * @category Arrangement & Analysis
27
- */
28
- export type ChordTimeline = {
29
- /** The chord sounding at a beat, or null when no segment covers it. */
30
- at: (beat: number) => Chord | null;
31
- segments: ChordSegment[];
32
- };
33
-
34
- /**
35
- * Build a chord timeline from placed chords.
36
- *
37
- * Each chord spans from its `startBeat` to the next chord's `startBeat`; the last
38
- * chord runs to `totalBeats`. Segments with no positive length (from duplicate
39
- * onsets or a last chord at or past `totalBeats`) are dropped. `at(beat)` returns
40
- * the covering segment's chord, or null when the beat lies outside every segment.
41
- *
42
- * @param chords Placed chords in time order.
43
- * @param totalBeats End of the timeline in beats.
44
- * @returns A queryable chord timeline.
45
- * @category Arrangement & Analysis
46
- */
47
- export function chordTimelineFromChords(chords: ChordSpan[], totalBeats: number): ChordTimeline {
48
- const sorted = [...chords].sort((a, b) => a.startBeat - b.startBeat);
49
- const segments: ChordSegment[] = sorted
50
- .map((gc, i) => {
51
- const next = sorted[i + 1];
52
- const endBeat = Math.max(gc.startBeat, next ? next.startBeat : totalBeats);
53
- return {
54
- startBeat: gc.startBeat,
55
- endBeat,
56
- chord: makeChord(gc.rootPc, gc.quality, gc.bassPc),
57
- };
58
- })
59
- .filter((seg) => seg.endBeat > seg.startBeat);
60
-
61
- return { at: segmentLookup(segments), segments };
62
- }
63
-
64
- /** Build the `at(beat)` lookup over a segment list. */
65
- function segmentLookup(segments: ChordSegment[]): (beat: number) => Chord | null {
66
- return (beat) => {
67
- for (const seg of segments) {
68
- if (beat >= seg.startBeat && beat < seg.endBeat) {
69
- return seg.chord;
70
- }
71
- }
72
- return null;
73
- };
74
- }
75
-
76
- const EPS = 1e-9;
77
-
78
- /** Fraction of the strongest pitch-class weight below which a pc is noise. */
79
- const NOISE_THRESHOLD_RATIO = 0.2;
80
-
81
- /** Maximum number of pitch classes fed to chord detection per window. */
82
- const MAX_DETECTION_PCS = 6;
83
-
84
- /** Number of top-weighted pitch classes tried in the detection fallback. */
85
- const FALLBACK_PCS = 3;
86
-
87
- /** Confidence multiplier applied when the chosen chord match is inexact. */
88
- const INEXACT_CONFIDENCE_FACTOR = 0.85;
89
-
90
- /** Score bonus for a match whose tones are all in the key. */
91
- const DIATONIC_BONUS = 0.5;
92
-
93
- /** Score bonus for an exact match (no extra and no missing tones). */
94
- const EXACT_BONUS = 0.5;
95
-
96
- /** Score penalty per extra or missing tone in a match. */
97
- const MISMATCH_PENALTY = 0.3;
98
-
99
- /** Reduce a value to a pitch class in [0, 11]. */
100
- function pitchClass(value: number): number {
101
- return ((Math.trunc(value) % 12) + 12) % 12;
102
- }
103
-
104
- /**
105
- * Options controlling {@link chordTimelineFromNotes}.
106
- *
107
- * @category Arrangement & Analysis
108
- */
109
- export type ChordTimelineOptions = {
110
- /** Key context; inferred from the notes with {@link detectKey} when omitted. */
111
- key?: KeyScale;
112
- /**
113
- * Time signature used for metric accents; defaults to 4/4.
114
- *
115
- * @defaultValue `4/4`
116
- */
117
- ts?: TimeSignature;
118
- /**
119
- * Window length in beats per chord slot; defaults to one bar of `ts`.
120
- *
121
- * @defaultValue one bar of `ts`
122
- */
123
- harmonicRhythm?: number;
124
- /**
125
- * End of the analyzed span in beats; defaults to the end of the last note.
126
- *
127
- * @defaultValue the end of the last note
128
- */
129
- totalBeats?: number;
130
- };
131
-
132
- /**
133
- * The result of {@link chordTimelineFromNotes}.
134
- *
135
- * @category Arrangement & Analysis
136
- */
137
- export type ChordTimelineResult = {
138
- /** The inferred timeline, with adjacent identical chords merged. */
139
- timeline: ChordTimeline;
140
- /** The key used for the analysis (given or inferred). */
141
- key: KeyScale;
142
- /** One confidence value in [0, 1] per segment, in segment order. */
143
- segmentConfidence: number[];
144
- };
145
-
146
- /** A window's inferred chord and its confidence, or null for an empty window. */
147
- type WindowChord = {
148
- chord: Chord;
149
- confidence: number;
150
- };
151
-
152
- /**
153
- * Score a chord match against a window's pitch-class weights: a heavily
154
- * weighted root, in-key tones, and exactness all raise the score; extra or
155
- * missing tones lower it.
156
- */
157
- function scoreMatch(
158
- match: ChordMatch,
159
- weights: number[],
160
- maxWeight: number,
161
- key: KeyScale,
162
- ): number {
163
- const rootWeight = maxWeight > 0 ? (weights[match.rootPc] ?? 0) / maxWeight : 0;
164
- const tones = chordPitchClasses(makeChord(match.rootPc, match.quality));
165
- let score = rootWeight;
166
- if (tones.every((pc) => isScaleTone(pc, key))) {
167
- score += DIATONIC_BONUS;
168
- }
169
- if (match.exact) {
170
- score += EXACT_BONUS;
171
- }
172
- score -= MISMATCH_PENALTY * (match.extraPcs.length + match.missingPcs.length);
173
- return score;
174
- }
175
-
176
- /** Confidence of a chord for a window: chord-tone weight over total weight. */
177
- function chordConfidence(
178
- chord: Chord,
179
- weights: number[],
180
- totalWeight: number,
181
- exact: boolean,
182
- ): number {
183
- const tones = new Set(chordPitchClasses(chord));
184
- let chordWeight = 0;
185
- for (let pc = 0; pc < 12; pc += 1) {
186
- if (tones.has(pc)) {
187
- chordWeight += weights[pc] ?? 0;
188
- }
189
- }
190
- const raw = totalWeight > 0 ? chordWeight / totalWeight : 0;
191
- const confidence = Math.min(1, Math.max(0, raw));
192
- return exact ? confidence : confidence * INEXACT_CONFIDENCE_FACTOR;
193
- }
194
-
195
- /**
196
- * Infer the chord sounding in one window from the notes overlapping it.
197
- *
198
- * Builds a pitch-class weight histogram (overlap duration x velocity x
199
- * metric-accent bonus for onsets inside the window), keeps the significantly
200
- * weighted pitch classes, and picks the best {@link detectChord} match by
201
- * root weight, key membership, and exactness.
202
- */
203
- function analyzeWindow(
204
- notes: NoteEvent[],
205
- windowStart: number,
206
- windowEnd: number,
207
- ts: TimeSignature,
208
- key: KeyScale,
209
- ): WindowChord | null {
210
- const weights = new Array<number>(12).fill(0);
211
- let lowestPitch = Number.POSITIVE_INFINITY;
212
- for (const note of notes) {
213
- const noteEnd = note.startBeat + note.durationBeat;
214
- const overlap = Math.min(noteEnd, windowEnd) - Math.max(note.startBeat, windowStart);
215
- if (overlap <= EPS) {
216
- continue;
217
- }
218
- const velocityFactor = note.velocity !== undefined ? note.velocity / 127 : 1;
219
- const onsetInWindow = note.startBeat >= windowStart - EPS && note.startBeat < windowEnd - EPS;
220
- const accent = onsetInWindow ? 1 + metricWeight(note.startBeat, ts) / 3 : 1;
221
- const pc = pitchClass(note.pitch);
222
- weights[pc] = (weights[pc] ?? 0) + overlap * velocityFactor * accent;
223
- if (note.pitch < lowestPitch) {
224
- lowestPitch = note.pitch;
225
- }
226
- }
227
- let totalWeight = 0;
228
- let maxWeight = 0;
229
- for (const w of weights) {
230
- totalWeight += w;
231
- maxWeight = Math.max(maxWeight, w);
232
- }
233
- if (totalWeight <= EPS) {
234
- return null;
235
- }
236
-
237
- // Keep pitch classes with meaningful weight, strongest first.
238
- const ranked = weights
239
- .map((weight, pc) => ({ pc, weight }))
240
- .filter(({ weight }) => weight > 0)
241
- .sort((a, b) => b.weight - a.weight || a.pc - b.pc);
242
- const selected = ranked
243
- .filter(({ weight }) => weight >= maxWeight * NOISE_THRESHOLD_RATIO)
244
- .slice(0, MAX_DETECTION_PCS)
245
- .map(({ pc }) => pc);
246
-
247
- // Feed detection with the window's true bass as the lowest pitch so inversion
248
- // detection works; when the bass pc was filtered out as noise, anchor on the
249
- // heaviest pc instead so no spurious inversion is reported.
250
- const bassPc = pitchClass(lowestPitch);
251
- const anchorPc = selected.includes(bassPc) ? bassPc : (selected[0] ?? bassPc);
252
- const detectionPitches = selected.map((pc) => (pc === anchorPc ? pc : pc + 12));
253
-
254
- const matches = detectChord(detectionPitches);
255
- let bestMatch: ChordMatch | null = null;
256
- let bestScore = Number.NEGATIVE_INFINITY;
257
- for (const match of matches) {
258
- const score = scoreMatch(match, weights, maxWeight, key);
259
- if (score > bestScore) {
260
- bestScore = score;
261
- bestMatch = match;
262
- }
263
- }
264
- if (bestMatch) {
265
- const chord = makeChord(bestMatch.rootPc, bestMatch.quality, bestMatch.bassPc);
266
- return { chord, confidence: chordConfidence(chord, weights, totalWeight, bestMatch.exact) };
267
- }
268
-
269
- // Fallback: retry on only the top-weighted pitch classes.
270
- const fallback = detectChordBest(selected.slice(0, FALLBACK_PCS));
271
- if (fallback) {
272
- return { chord: fallback, confidence: chordConfidence(fallback, weights, totalWeight, false) };
273
- }
274
- return null;
275
- }
276
-
277
- /** Whether two chords are the same root, quality, and bass. */
278
- function sameChord(a: Chord, b: Chord): boolean {
279
- return a.rootPc === b.rootPc && a.quality === b.quality && a.bassPc === b.bassPc;
280
- }
281
-
282
- /**
283
- * Infer a chord timeline from raw multi-track notes.
284
- *
285
- * The span `[0, totalBeats)` is sliced into `harmonicRhythm`-beat windows; each
286
- * window's chord is inferred from a pitch-class weight histogram of the notes
287
- * overlapping it (weight = overlap duration x velocity x metric-accent bonus
288
- * for onsets in the window). Adjacent windows carrying the identical chord are
289
- * merged into one segment; windows with no notes produce no segment, so
290
- * `at(beat)` returns null there. Each segment carries a confidence in [0, 1]:
291
- * the fraction of the window weight explained by chord tones, reduced when the
292
- * match is inexact, and duration-weighted across merged windows.
293
- *
294
- * Notes with a zero or negative duration never sound, so they are dropped at
295
- * ingest: they contribute to neither the key inference, the span, nor any
296
- * window's histogram.
297
- *
298
- * @param notes The notes to analyze (any number of tracks, flattened).
299
- * @param opts Analysis options; see {@link ChordTimelineOptions}.
300
- * @returns The inferred timeline, the key used, and per-segment confidences.
301
- * @throws If `harmonicRhythm` is not positive.
302
- * @example
303
- * ```ts
304
- * import { chordTimelineFromNotes } from '@libraz/libcantus';
305
- * const notes = [
306
- * { pitch: 60, startBeat: 0, durationBeat: 2 }, // C
307
- * { pitch: 64, startBeat: 0, durationBeat: 2 }, // E
308
- * { pitch: 67, startBeat: 0, durationBeat: 2 }, // G
309
- * ];
310
- * const { timeline, key } = chordTimelineFromNotes(notes);
311
- * timeline.at(0); // the chord inferred over beat 0, or null
312
- * ```
313
- * @category Arrangement & Analysis
314
- */
315
- export function chordTimelineFromNotes(
316
- notes: NoteEvent[],
317
- opts: ChordTimelineOptions = {},
318
- ): ChordTimelineResult {
319
- const ts = opts.ts ?? parseTimeSignature('4/4');
320
- const harmonicRhythm = opts.harmonicRhythm ?? beatsPerBar(ts);
321
- if (!(harmonicRhythm > 0)) {
322
- throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
323
- }
324
- // Zero/negative-length notes never sound; drop them before any inference.
325
- const sounding = notes.filter((note) => note.durationBeat > 0);
326
- const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
327
- const totalBeats = opts.totalBeats ?? lastNoteEnd;
328
- const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
329
-
330
- const segments: ChordSegment[] = [];
331
- const segmentConfidence: number[] = [];
332
- const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS));
333
- for (let i = 0; i < windowCount; i += 1) {
334
- const start = i * harmonicRhythm;
335
- const end = Math.min(start + harmonicRhythm, totalBeats);
336
- const inferred = analyzeWindow(sounding, start, end, ts, key);
337
- if (!inferred) {
338
- continue;
339
- }
340
- const last = segments[segments.length - 1];
341
- if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS) {
342
- // Merge into the previous segment, blending confidence by duration.
343
- const lastLength = last.endBeat - last.startBeat;
344
- const length = end - start;
345
- const lastConfidence = segmentConfidence[segmentConfidence.length - 1] ?? 0;
346
- segmentConfidence[segmentConfidence.length - 1] =
347
- (lastConfidence * lastLength + inferred.confidence * length) / (lastLength + length);
348
- last.endBeat = end;
349
- } else {
350
- segments.push({ startBeat: start, endBeat: end, chord: inferred.chord });
351
- segmentConfidence.push(inferred.confidence);
352
- }
353
- }
354
-
355
- return {
356
- timeline: { at: segmentLookup(segments), segments },
357
- key,
358
- segmentConfidence,
359
- };
360
- }
361
-
362
- /**
363
- * A cadence found between two consecutive timeline segments.
364
- *
365
- * @category Arrangement & Analysis
366
- */
367
- export type CadenceHit = {
368
- /** The beat where the cadence arrives (the second chord's onset). */
369
- atBeat: number;
370
- type: Exclude<Cadence, null>;
371
- from: Chord;
372
- to: Chord;
373
- };
374
-
375
- /**
376
- * Find cadences between consecutive segments of a chord timeline.
377
- *
378
- * Each temporally adjacent segment pair is classified with
379
- * {@link detectCadence}; pairs forming no cadence are skipped. Segments
380
- * separated by a gap (a rest in the timeline) are not a chord-to-chord
381
- * progression, so they are never paired.
382
- *
383
- * @param timeline The chord timeline to scan.
384
- * @param key The prevailing key.
385
- * @returns The cadences found, in time order.
386
- * @example
387
- * ```ts
388
- * import { chordTimelineFromNotes, detectCadences } from '@libraz/libcantus';
389
- * const notes = [
390
- * { pitch: 67, startBeat: 0, durationBeat: 4 }, // G, a dominant
391
- * { pitch: 60, startBeat: 4, durationBeat: 4 }, // C, the tonic
392
- * ];
393
- * const { timeline, key } = chordTimelineFromNotes(notes);
394
- * const cadences = detectCadences(timeline, key);
395
- * ```
396
- * @category Arrangement & Analysis
397
- */
398
- export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceHit[] {
399
- const hits: CadenceHit[] = [];
400
- for (let i = 1; i < timeline.segments.length; i += 1) {
401
- const prev = timeline.segments[i - 1];
402
- const cur = timeline.segments[i];
403
- if (!prev || !cur) {
404
- continue;
405
- }
406
- if (Math.abs(cur.startBeat - prev.endBeat) > EPS) {
407
- continue; // A rest separates the chords; no cadential motion across it.
408
- }
409
- const type = detectCadence(prev.chord, cur.chord, key);
410
- if (type !== null) {
411
- hits.push({ atBeat: cur.startBeat, type, from: prev.chord, to: cur.chord });
412
- }
413
- }
414
- return hits;
415
- }