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