@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.
- package/README.md +29 -20
- package/README_ja.md +268 -0
- package/dist/analyze/index.cjs +39 -1947
- package/dist/analyze/index.cjs.map +1 -1
- package/dist/analyze/index.d.cts +161 -91
- package/dist/analyze/index.d.ts +161 -91
- package/dist/analyze/index.js +19 -8
- package/dist/chunk-2A2QMA74.cjs +261 -0
- package/dist/chunk-2A2QMA74.cjs.map +1 -0
- package/dist/chunk-2PTFGVD7.js +261 -0
- package/dist/chunk-2PTFGVD7.js.map +1 -0
- package/dist/chunk-357ZDPVU.cjs +162 -0
- package/dist/chunk-357ZDPVU.cjs.map +1 -0
- package/dist/chunk-3KX76RLG.js +1548 -0
- package/dist/chunk-3KX76RLG.js.map +1 -0
- package/dist/chunk-4VZ5MZAM.cjs +889 -0
- package/dist/chunk-4VZ5MZAM.cjs.map +1 -0
- package/dist/{chunk-C5XYKUVG.js → chunk-7EONYVI5.js} +471 -171
- package/dist/chunk-7EONYVI5.js.map +1 -0
- package/dist/{chunk-IT2O4XFE.js → chunk-7JEP3MFY.js} +42 -34
- package/dist/chunk-7JEP3MFY.js.map +1 -0
- package/dist/{chunk-4MHA432G.js → chunk-AID75RN3.js} +572 -250
- package/dist/chunk-AID75RN3.js.map +1 -0
- package/dist/chunk-BSLTLGM3.cjs +497 -0
- package/dist/chunk-BSLTLGM3.cjs.map +1 -0
- package/dist/chunk-CU6VG2YM.cjs +109 -0
- package/dist/chunk-CU6VG2YM.cjs.map +1 -0
- package/dist/{chunk-EH6EM37U.js → chunk-DZMHIGI4.js} +81 -59
- package/dist/chunk-DZMHIGI4.js.map +1 -0
- package/dist/{chunk-AKLYPWIN.js → chunk-EDOTEABK.js} +45 -19
- package/dist/chunk-EDOTEABK.js.map +1 -0
- package/dist/{chunk-2ZWMSJH6.js → chunk-HN2OEUN2.js} +7 -4
- package/dist/chunk-HN2OEUN2.js.map +1 -0
- package/dist/{chunk-MIWO425M.js → chunk-KBTTEX7Q.js} +128 -43
- package/dist/chunk-KBTTEX7Q.js.map +1 -0
- package/dist/chunk-KQN2ZYND.cjs +724 -0
- package/dist/chunk-KQN2ZYND.cjs.map +1 -0
- package/dist/chunk-MPTVJIVG.cjs +3176 -0
- package/dist/chunk-MPTVJIVG.cjs.map +1 -0
- package/dist/chunk-MTMZTMWM.cjs +53 -0
- package/dist/chunk-MTMZTMWM.cjs.map +1 -0
- package/dist/{chunk-CHMXELDS.js → chunk-NNB67XA2.js} +372 -197
- package/dist/chunk-NNB67XA2.js.map +1 -0
- package/dist/chunk-OL3IHHSJ.cjs +1204 -0
- package/dist/chunk-OL3IHHSJ.cjs.map +1 -0
- package/dist/chunk-PI3UXRWK.cjs +528 -0
- package/dist/chunk-PI3UXRWK.cjs.map +1 -0
- package/dist/chunk-R2VVFAIN.js +528 -0
- package/dist/chunk-R2VVFAIN.js.map +1 -0
- package/dist/chunk-T64C7ZD7.cjs +1 -0
- package/dist/chunk-T64C7ZD7.cjs.map +1 -0
- package/dist/chunk-WFDXOLUB.cjs +337 -0
- package/dist/chunk-WFDXOLUB.cjs.map +1 -0
- package/dist/chunk-WTAN4ACG.cjs +1548 -0
- package/dist/chunk-WTAN4ACG.cjs.map +1 -0
- package/dist/chunk-YFJXS3AU.cjs +33 -0
- package/dist/chunk-YFJXS3AU.cjs.map +1 -0
- package/dist/{chunk-GOAW4VYL.js → chunk-YUE6TJ4M.js} +315 -145
- package/dist/chunk-YUE6TJ4M.js.map +1 -0
- package/dist/{chunk-62QZKCJQ.js → chunk-ZGLJM4A4.js} +10 -6
- package/dist/chunk-ZGLJM4A4.js.map +1 -0
- package/dist/core/index.cjs +140 -647
- package/dist/core/index.cjs.map +1 -1
- package/dist/core/index.d.cts +313 -41
- package/dist/core/index.d.ts +313 -41
- package/dist/core/index.js +64 -16
- package/dist/{function-DVH4Eusr.d.ts → function-CxixTB02.d.cts} +122 -10
- package/dist/{function-DYt4iKsn.d.cts → function-DTCt675z.d.ts} +122 -10
- package/dist/generate/index.cjs +28 -4085
- package/dist/generate/index.cjs.map +1 -1
- package/dist/generate/index.d.cts +374 -66
- package/dist/generate/index.d.ts +374 -66
- package/dist/generate/index.js +23 -9
- package/dist/{index-Jc0Ryi79.d.ts → index-B2Ulxxpo.d.cts} +21 -5
- package/dist/{index-CpOsP1y9.d.cts → index-BkYJ0xMq.d.cts} +18 -17
- package/dist/{index-CxqBQtH1.d.cts → index-BuaB-ypt.d.ts} +21 -5
- package/dist/index-CAN7v-kw.d.ts +199 -0
- package/dist/{index-Bt4tOUD9.d.ts → index-COw1Ky9g.d.ts} +93 -8
- package/dist/{index-Dikst44v.d.cts → index-Cfx8xgH0.d.ts} +14 -11
- package/dist/{index-DxvARIP2.d.ts → index-DRtIhlJj.d.ts} +18 -17
- package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
- package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
- package/dist/index-alrRninU.d.cts +199 -0
- package/dist/{index-BNlGP86A.d.cts → index-oTQbYYIs.d.cts} +93 -8
- package/dist/index-tR9jTn26.d.cts +275 -0
- package/dist/index-tR9jTn26.d.ts +275 -0
- package/dist/{index-Bg5dYMmZ.d.ts → index-x2vcsduE.d.cts} +14 -11
- package/dist/index.cjs +429 -7383
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -15
- package/dist/index.d.ts +15 -15
- package/dist/index.js +121 -27
- package/dist/model/index.cjs +8 -2562
- package/dist/model/index.cjs.map +1 -1
- package/dist/model/index.d.cts +471 -81
- package/dist/model/index.d.ts +471 -81
- package/dist/model/index.js +6 -7
- package/dist/styled-Bl0ZK28h.d.ts +543 -0
- package/dist/styled-Crv6SA4S.d.cts +543 -0
- package/dist/theory/index.cjs +172 -1822
- package/dist/theory/index.cjs.map +1 -1
- package/dist/theory/index.d.cts +151 -233
- package/dist/theory/index.d.ts +151 -233
- package/dist/theory/index.js +30 -7
- package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
- package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
- package/package.json +34 -13
- package/dist/chunk-2ZWMSJH6.js.map +0 -1
- package/dist/chunk-4MHA432G.js.map +0 -1
- package/dist/chunk-62QZKCJQ.js.map +0 -1
- package/dist/chunk-AKLYPWIN.js.map +0 -1
- package/dist/chunk-C5XYKUVG.js.map +0 -1
- package/dist/chunk-CHMXELDS.js.map +0 -1
- package/dist/chunk-DYWFNC4A.js +0 -152
- package/dist/chunk-DYWFNC4A.js.map +0 -1
- package/dist/chunk-EH6EM37U.js.map +0 -1
- package/dist/chunk-FBQK3ZQN.js +0 -131
- package/dist/chunk-FBQK3ZQN.js.map +0 -1
- package/dist/chunk-GOAW4VYL.js.map +0 -1
- package/dist/chunk-I5R7ZBJS.js +0 -202
- package/dist/chunk-I5R7ZBJS.js.map +0 -1
- package/dist/chunk-IT2O4XFE.js.map +0 -1
- package/dist/chunk-MIWO425M.js.map +0 -1
- package/dist/chunk-SGU7SPLK.js +0 -924
- package/dist/chunk-SGU7SPLK.js.map +0 -1
- package/dist/index-CGMZNCx6.d.cts +0 -140
- package/dist/index-CGMZNCx6.d.ts +0 -140
- package/dist/index-CnZCo29u.d.cts +0 -108
- package/dist/index-JnF4v9bx.d.ts +0 -108
- package/dist/styled-DZ7GjShb.d.ts +0 -282
- package/dist/styled-ZTbdaVFM.d.cts +0 -282
- package/src/analyze/arrange/index.ts +0 -21
- package/src/analyze/arrange/internal.ts +0 -127
- package/src/analyze/arrange/tension.ts +0 -199
- package/src/analyze/arrange/tracks.ts +0 -312
- package/src/analyze/detect/index.ts +0 -272
- package/src/analyze/functional/borrowed.ts +0 -84
- package/src/analyze/functional/cadence.ts +0 -66
- package/src/analyze/functional/function.ts +0 -184
- package/src/analyze/functional/index.ts +0 -23
- package/src/analyze/functional/internal.ts +0 -29
- package/src/analyze/functional/roman.ts +0 -402
- package/src/analyze/index.ts +0 -49
- package/src/analyze/timeline/index.ts +0 -451
- package/src/analyze/voice/index.ts +0 -339
- package/src/core/event-index/index.ts +0 -104
- package/src/core/index.ts +0 -61
- package/src/core/interval/index.ts +0 -81
- package/src/core/meter/index.ts +0 -302
- package/src/core/pitch/index.ts +0 -311
- package/src/core/random/index.ts +0 -76
- package/src/core/tuning/index.ts +0 -186
- package/src/core/types.ts +0 -29
- package/src/core/validation/index.ts +0 -119
- package/src/generate/bass/index.ts +0 -373
- package/src/generate/countermelody/index.ts +0 -470
- package/src/generate/drums/beat.ts +0 -266
- package/src/generate/drums/euclid.ts +0 -65
- package/src/generate/drums/fills.ts +0 -356
- package/src/generate/drums/ghost.ts +0 -119
- package/src/generate/drums/hihat.ts +0 -285
- package/src/generate/drums/hit.ts +0 -31
- package/src/generate/drums/index.ts +0 -361
- package/src/generate/drums/internal.ts +0 -257
- package/src/generate/drums/kick.ts +0 -160
- package/src/generate/drums/percussion.ts +0 -121
- package/src/generate/drums/rng.ts +0 -7
- package/src/generate/drums/swing.ts +0 -71
- package/src/generate/groove/index.ts +0 -351
- package/src/generate/harmonize/index.ts +0 -451
- package/src/generate/index.ts +0 -58
- package/src/generate/motif/index.ts +0 -422
- package/src/generate/progression/index.ts +0 -362
- package/src/generate/reharmony/index.ts +0 -323
- package/src/generate/rhythm/index.ts +0 -175
- package/src/index.ts +0 -13
- package/src/model/chord.ts +0 -442
- package/src/model/index.ts +0 -15
- package/src/model/interval.ts +0 -82
- package/src/model/key.ts +0 -266
- package/src/model/note.ts +0 -176
- package/src/model/progression.ts +0 -161
- package/src/model/shared.ts +0 -12
- package/src/theory/chord/index.ts +0 -375
- package/src/theory/chordscale/index.ts +0 -374
- package/src/theory/counterpoint/index.ts +0 -230
- package/src/theory/harmony/index.ts +0 -77
- package/src/theory/index.ts +0 -112
- package/src/theory/safety/index.ts +0 -488
- package/src/theory/scale/degrees.ts +0 -123
- package/src/theory/scale/index.ts +0 -28
- package/src/theory/scale/key.ts +0 -56
- package/src/theory/scale/masks.ts +0 -153
- package/src/theory/spelling/index.ts +0 -249
- package/src/theory/symbol/index.ts +0 -268
- package/src/theory/voicing/index.ts +0 -5
- package/src/theory/voicing/internal.ts +0 -168
- package/src/theory/voicing/leading.ts +0 -112
- package/src/theory/voicing/satb.ts +0 -216
- package/src/theory/voicing/styled.ts +0 -199
- 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
|
-
}
|