@libraz/libcantus 0.9.1 → 0.9.3
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 +19 -3
- package/dist/analyze/index.cjs +1970 -0
- package/dist/analyze/index.cjs.map +1 -0
- package/dist/analyze/index.d.cts +341 -0
- package/dist/analyze/index.d.ts +341 -0
- package/dist/analyze/index.js +51 -0
- package/dist/analyze/index.js.map +1 -0
- package/dist/chunk-2ZWMSJH6.js +30 -0
- package/dist/chunk-2ZWMSJH6.js.map +1 -0
- package/dist/chunk-4MHA432G.js +2854 -0
- package/dist/chunk-4MHA432G.js.map +1 -0
- package/dist/chunk-62QZKCJQ.js +49 -0
- package/dist/chunk-62QZKCJQ.js.map +1 -0
- package/dist/chunk-AKLYPWIN.js +83 -0
- package/dist/chunk-AKLYPWIN.js.map +1 -0
- package/dist/chunk-C5XYKUVG.js +904 -0
- package/dist/chunk-C5XYKUVG.js.map +1 -0
- package/dist/chunk-CHMXELDS.js +549 -0
- package/dist/chunk-CHMXELDS.js.map +1 -0
- package/dist/chunk-DYWFNC4A.js +152 -0
- package/dist/chunk-DYWFNC4A.js.map +1 -0
- package/dist/chunk-EH6EM37U.js +315 -0
- package/dist/chunk-EH6EM37U.js.map +1 -0
- package/dist/chunk-FBQK3ZQN.js +131 -0
- package/dist/chunk-FBQK3ZQN.js.map +1 -0
- package/dist/chunk-GOAW4VYL.js +719 -0
- package/dist/chunk-GOAW4VYL.js.map +1 -0
- package/dist/chunk-I5R7ZBJS.js +202 -0
- package/dist/chunk-I5R7ZBJS.js.map +1 -0
- package/dist/chunk-IT2O4XFE.js +154 -0
- package/dist/chunk-IT2O4XFE.js.map +1 -0
- package/dist/chunk-MIWO425M.js +412 -0
- package/dist/chunk-MIWO425M.js.map +1 -0
- package/dist/chunk-N6IHQUOX.js +1 -0
- package/dist/chunk-N6IHQUOX.js.map +1 -0
- package/dist/chunk-SGU7SPLK.js +924 -0
- package/dist/chunk-SGU7SPLK.js.map +1 -0
- package/dist/core/index.cjs +648 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +262 -0
- package/dist/core/index.d.ts +262 -0
- package/dist/core/index.js +93 -0
- package/dist/core/index.js.map +1 -0
- package/dist/function-DVH4Eusr.d.ts +187 -0
- package/dist/function-DYt4iKsn.d.cts +187 -0
- package/dist/generate/index.cjs +4123 -0
- package/dist/generate/index.cjs.map +1 -0
- package/dist/generate/index.d.cts +986 -0
- package/dist/generate/index.d.ts +986 -0
- package/dist/generate/index.js +52 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/index-BNlGP86A.d.cts +157 -0
- package/dist/index-Bg5dYMmZ.d.ts +56 -0
- package/dist/index-Bt4tOUD9.d.ts +157 -0
- package/dist/index-CGMZNCx6.d.cts +140 -0
- package/dist/index-CGMZNCx6.d.ts +140 -0
- package/dist/index-Cl7mhOFM.d.cts +164 -0
- package/dist/index-Cl7mhOFM.d.ts +164 -0
- package/dist/index-CnZCo29u.d.cts +108 -0
- package/dist/index-CpOsP1y9.d.cts +151 -0
- package/dist/index-CxqBQtH1.d.cts +128 -0
- package/dist/index-Dikst44v.d.cts +56 -0
- package/dist/index-DxvARIP2.d.ts +151 -0
- package/dist/index-Jc0Ryi79.d.ts +128 -0
- package/dist/index-JnF4v9bx.d.ts +108 -0
- package/dist/index.cjs +3710 -3143
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -3982
- package/dist/index.d.ts +15 -3982
- package/dist/index.js +191 -6504
- package/dist/index.js.map +1 -1
- package/dist/model/index.cjs +2574 -0
- package/dist/model/index.cjs.map +1 -0
- package/dist/model/index.d.cts +631 -0
- package/dist/model/index.d.ts +631 -0
- package/dist/model/index.js +21 -0
- package/dist/model/index.js.map +1 -0
- package/dist/styled-DZ7GjShb.d.ts +282 -0
- package/dist/styled-ZTbdaVFM.d.cts +282 -0
- package/dist/theory/index.cjs +1823 -0
- package/dist/theory/index.cjs.map +1 -0
- package/dist/theory/index.d.cts +535 -0
- package/dist/theory/index.d.ts +535 -0
- package/dist/theory/index.js +150 -0
- package/dist/theory/index.js.map +1 -0
- package/dist/types-MGP29r3t.d.cts +30 -0
- package/dist/types-MGP29r3t.d.ts +30 -0
- package/package.json +61 -5
- package/src/analyze/arrange/index.ts +21 -0
- package/src/analyze/arrange/internal.ts +127 -0
- package/src/analyze/arrange/tension.ts +199 -0
- package/src/analyze/arrange/tracks.ts +312 -0
- package/src/{detect → analyze/detect}/index.ts +50 -13
- package/src/analyze/functional/borrowed.ts +84 -0
- package/src/analyze/functional/cadence.ts +66 -0
- package/src/analyze/functional/function.ts +184 -0
- package/src/analyze/functional/index.ts +23 -0
- package/src/analyze/functional/internal.ts +29 -0
- package/src/{functional/index.ts → analyze/functional/roman.ts} +23 -332
- package/src/analyze/index.ts +49 -0
- package/src/{timeline → analyze/timeline}/index.ts +50 -18
- package/src/{analysis → analyze/voice}/index.ts +19 -7
- package/src/core/event-index/index.ts +104 -0
- package/src/core/index.ts +61 -0
- package/src/{meter → core/meter}/index.ts +24 -4
- package/src/{random → core/random}/index.ts +26 -3
- package/src/{tuning → core/tuning}/index.ts +36 -6
- package/src/core/validation/index.ts +119 -0
- package/src/{bass → generate/bass}/index.ts +28 -5
- package/src/{countermelody → generate/countermelody}/index.ts +176 -85
- package/src/{drums → generate/drums}/euclid.ts +10 -3
- package/src/{drums → generate/drums}/index.ts +58 -26
- package/src/{drums → generate/drums}/percussion.ts +1 -1
- package/src/{drums → generate/drums}/rng.ts +2 -2
- package/src/{groove → generate/groove}/index.ts +28 -3
- package/src/{harmonize → generate/harmonize}/index.ts +68 -57
- package/src/generate/index.ts +58 -0
- package/src/{motif → generate/motif}/index.ts +52 -28
- package/src/{progression → generate/progression}/index.ts +19 -41
- package/src/{reharmony → generate/reharmony}/index.ts +12 -19
- package/src/{rhythm → generate/rhythm}/index.ts +13 -3
- package/src/index.ts +13 -234
- package/src/model/chord.ts +442 -0
- package/src/model/index.ts +5 -1096
- package/src/model/interval.ts +82 -0
- package/src/model/key.ts +266 -0
- package/src/model/note.ts +176 -0
- package/src/model/progression.ts +161 -0
- package/src/model/shared.ts +12 -0
- package/src/{chord → theory/chord}/index.ts +23 -3
- package/src/{counterpoint → theory/counterpoint}/index.ts +2 -2
- package/src/{harmony → theory/harmony}/index.ts +6 -21
- package/src/theory/index.ts +112 -0
- package/src/{safety → theory/safety}/index.ts +72 -3
- package/src/theory/scale/degrees.ts +123 -0
- package/src/theory/scale/index.ts +28 -0
- package/src/theory/scale/key.ts +56 -0
- package/src/theory/scale/masks.ts +153 -0
- package/src/{spelling → theory/spelling}/index.ts +73 -6
- package/src/{symbol → theory/symbol}/index.ts +8 -1
- package/src/theory/voicing/index.ts +5 -0
- package/src/theory/voicing/internal.ts +168 -0
- package/src/theory/voicing/leading.ts +112 -0
- package/src/theory/voicing/satb.ts +216 -0
- package/src/theory/voicing/styled.ts +199 -0
- package/src/arrange/index.ts +0 -571
- package/src/scale/index.ts +0 -331
- package/src/voicing/index.ts +0 -623
- /package/src/{interval → core/interval}/index.ts +0 -0
- /package/src/{pitch → core/pitch}/index.ts +0 -0
- /package/src/{types.ts → core/types.ts} +0 -0
- /package/src/{drums → generate/drums}/beat.ts +0 -0
- /package/src/{drums → generate/drums}/fills.ts +0 -0
- /package/src/{drums → generate/drums}/ghost.ts +0 -0
- /package/src/{drums → generate/drums}/hihat.ts +0 -0
- /package/src/{drums → generate/drums}/hit.ts +0 -0
- /package/src/{drums → generate/drums}/internal.ts +0 -0
- /package/src/{drums → generate/drums}/kick.ts +0 -0
- /package/src/{drums → generate/drums}/swing.ts +0 -0
- /package/src/{chordscale → theory/chordscale}/index.ts +0 -0
|
@@ -0,0 +1,312 @@
|
|
|
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
|
+
assertGenerationBudget,
|
|
12
|
+
assertNoteEvents,
|
|
13
|
+
assertTimeSignature,
|
|
14
|
+
} from '../../core/validation/index.js';
|
|
15
|
+
import {
|
|
16
|
+
evaluateSafety,
|
|
17
|
+
NoteSafety,
|
|
18
|
+
type SafetyProfile,
|
|
19
|
+
type VoiceSnapshot,
|
|
20
|
+
} from '../../theory/safety/index.js';
|
|
21
|
+
import {
|
|
22
|
+
type CadenceHit,
|
|
23
|
+
type ChordTimeline,
|
|
24
|
+
chordTimelineFromNotes,
|
|
25
|
+
detectCadences,
|
|
26
|
+
} from '../timeline/index.js';
|
|
27
|
+
import { type AnalyzedNote, analyzeVoice, type VoiceNote } from '../voice/index.js';
|
|
28
|
+
import { covers, EPS, type PreparedTrack, poolNotes, prepareTracks } from './internal.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The musical role a track plays in the arrangement.
|
|
32
|
+
*
|
|
33
|
+
* @category Arrangement & Analysis
|
|
34
|
+
*/
|
|
35
|
+
export type TrackRole = 'melody' | 'harmony' | 'bass' | 'other';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One input track: its notes plus optional name and role.
|
|
39
|
+
*
|
|
40
|
+
* @category Arrangement & Analysis
|
|
41
|
+
*/
|
|
42
|
+
export type ArrangementTrack = {
|
|
43
|
+
name?: string;
|
|
44
|
+
role?: TrackRole;
|
|
45
|
+
notes: NoteEvent[];
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A track after analysis: its resolved name, role, and per-note annotations.
|
|
50
|
+
*
|
|
51
|
+
* @category Arrangement & Analysis
|
|
52
|
+
*/
|
|
53
|
+
export type TrackAnalysis = {
|
|
54
|
+
name: string;
|
|
55
|
+
role: TrackRole;
|
|
56
|
+
/** One {@link AnalyzedNote} per note, labelled against the inferred chords. */
|
|
57
|
+
notes: AnalyzedNote[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A note that clashes with the harmony sounding beneath it.
|
|
62
|
+
*
|
|
63
|
+
* @category Arrangement & Analysis
|
|
64
|
+
*/
|
|
65
|
+
export type Conflict = {
|
|
66
|
+
beat: number;
|
|
67
|
+
trackName: string;
|
|
68
|
+
pitch: number;
|
|
69
|
+
/** The clash severity: {@link NoteSafety.Warning} or {@link NoteSafety.Dissonant}. */
|
|
70
|
+
safety: NoteSafety;
|
|
71
|
+
/** {@link import('../safety/index.js').SafetyResult.reasons} bitmask. */
|
|
72
|
+
reasons: number;
|
|
73
|
+
/** Preferred stepwise resolution, when the safety evaluator provides one. */
|
|
74
|
+
resolveTo?: number;
|
|
75
|
+
/** Nearby fully safe replacement pitches, nearest first. */
|
|
76
|
+
suggestions?: number[];
|
|
77
|
+
rationale?: string;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The full result of {@link analyzeArrangement}.
|
|
82
|
+
*
|
|
83
|
+
* @category Arrangement & Analysis
|
|
84
|
+
*/
|
|
85
|
+
export type ArrangementAnalysis = {
|
|
86
|
+
key: KeyScale;
|
|
87
|
+
timeline: ChordTimeline;
|
|
88
|
+
segmentConfidence: number[];
|
|
89
|
+
cadences: CadenceHit[];
|
|
90
|
+
tracks: TrackAnalysis[];
|
|
91
|
+
/** Notes clashing with the sounding harmony, worst severity first. */
|
|
92
|
+
conflicts: Conflict[];
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Options controlling {@link analyzeArrangement} and {@link tensionCurve}.
|
|
97
|
+
*
|
|
98
|
+
* @category Arrangement & Analysis
|
|
99
|
+
*/
|
|
100
|
+
export type ArrangementOptions = {
|
|
101
|
+
/** Key context; inferred from the pooled notes when omitted. */
|
|
102
|
+
key?: KeyScale;
|
|
103
|
+
/**
|
|
104
|
+
* Time signature; defaults to 4/4.
|
|
105
|
+
*
|
|
106
|
+
* @defaultValue `4/4`
|
|
107
|
+
*/
|
|
108
|
+
ts?: TimeSignature;
|
|
109
|
+
/**
|
|
110
|
+
* Chord-slot length in beats; defaults to one bar of `ts`.
|
|
111
|
+
*
|
|
112
|
+
* @defaultValue one bar of `ts`
|
|
113
|
+
*/
|
|
114
|
+
harmonicRhythm?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Safety profile used for conflict detection; defaults to `pop`.
|
|
117
|
+
*
|
|
118
|
+
* @defaultValue `'pop'`
|
|
119
|
+
*/
|
|
120
|
+
profile?: SafetyProfile;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Sounding pitches of every sub-voice at a beat, excluding one sub-voice.
|
|
125
|
+
*
|
|
126
|
+
* Only the sub-voice under analysis is excluded — sibling sub-voices of the
|
|
127
|
+
* same track are included, so dissonant clusters inside a single polyphonic
|
|
128
|
+
* track are still detected.
|
|
129
|
+
*/
|
|
130
|
+
function otherVoicesSounding(
|
|
131
|
+
prepared: PreparedTrack[],
|
|
132
|
+
excludeTrack: number,
|
|
133
|
+
excludeVoice: number,
|
|
134
|
+
beat: number,
|
|
135
|
+
): VoiceSnapshot[] {
|
|
136
|
+
const out: VoiceSnapshot[] = [];
|
|
137
|
+
for (let t = 0; t < prepared.length; t += 1) {
|
|
138
|
+
const track = prepared[t];
|
|
139
|
+
if (!track) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
for (let v = 0; v < track.voices.length; v += 1) {
|
|
143
|
+
if (t === excludeTrack && v === excludeVoice) {
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const subVoice = track.voices[v];
|
|
147
|
+
if (!subVoice) {
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const note of subVoice.sounding) {
|
|
151
|
+
if (!covers(note, beat)) {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const snap: VoiceSnapshot = { pitch: note.pitch };
|
|
155
|
+
// Motion reasons compare one real transition shared by both voices.
|
|
156
|
+
// A voice attacking exactly here contributes its adjacent predecessor;
|
|
157
|
+
// a sustained voice contributes the same pitch (oblique motion).
|
|
158
|
+
const previous = Math.abs(note.startBeat - beat) <= EPS ? note.prevPitch : note.pitch;
|
|
159
|
+
if (previous !== undefined) {
|
|
160
|
+
snap.prevPitch = previous;
|
|
161
|
+
}
|
|
162
|
+
out.push(snap);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The beats at which a note must be checked against the harmony: its onset,
|
|
171
|
+
* plus the onset of every later chord segment the note sustains across. Each
|
|
172
|
+
* crossed segment is visited exactly once (at its own start), so a boundary
|
|
173
|
+
* beat is never evaluated twice for the same note.
|
|
174
|
+
*/
|
|
175
|
+
function evaluationBeats(note: VoiceNote, timeline: ChordTimeline): number[] {
|
|
176
|
+
const noteEnd = note.startBeat + note.durationBeat;
|
|
177
|
+
const beats = [note.startBeat];
|
|
178
|
+
for (const segment of timeline.segments) {
|
|
179
|
+
if (segment.startBeat > note.startBeat + EPS && segment.startBeat < noteEnd - EPS) {
|
|
180
|
+
beats.push(segment.startBeat);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return beats;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Analyse a whole arrangement against a single inferred harmony.
|
|
188
|
+
*
|
|
189
|
+
* The chord timeline and key are inferred from the pooled notes of every track
|
|
190
|
+
* (see {@link chordTimelineFromNotes}); pooling all voices is robust even when
|
|
191
|
+
* roles are absent or a track doubles the harmony, so it is preferred over
|
|
192
|
+
* deriving the harmony from a subset. Zero- and negative-length notes never
|
|
193
|
+
* sound, so they are dropped at ingest and appear in neither the annotations
|
|
194
|
+
* nor the conflicts.
|
|
195
|
+
*
|
|
196
|
+
* Voice analysis assumes one monophonic voice at a time, so each track is
|
|
197
|
+
* first partitioned into monophonic sub-voices (a polyphonic block-chord track
|
|
198
|
+
* fans out into one sub-voice per simultaneous note; a monophonic track is
|
|
199
|
+
* unaffected). Each sub-voice is labelled note-by-note against the harmony
|
|
200
|
+
* with {@link analyzeVoice}, seeing every other sub-voice — including siblings
|
|
201
|
+
* within the same track — as its accompaniment. Every note is then re-checked
|
|
202
|
+
* with {@link evaluateSafety} at its onset and again at each chord change it
|
|
203
|
+
* sustains across, so a held note that clashes with a later chord is caught:
|
|
204
|
+
* evaluations that are not {@link NoteSafety.Safe} become conflicts, one per
|
|
205
|
+
* clashing beat, sorted worst severity first and then by beat.
|
|
206
|
+
*
|
|
207
|
+
* @param tracks The tracks to analyse.
|
|
208
|
+
* @param opts Analysis options; see {@link ArrangementOptions}.
|
|
209
|
+
* @returns The inferred harmony, per-track annotations, cadences, and conflicts.
|
|
210
|
+
* @example
|
|
211
|
+
* ```ts
|
|
212
|
+
* import { analyzeArrangement } from '@libraz/libcantus';
|
|
213
|
+
* const melody = [
|
|
214
|
+
* { pitch: 60, startBeat: 0, durationBeat: 2 },
|
|
215
|
+
* { pitch: 67, startBeat: 2, durationBeat: 2 },
|
|
216
|
+
* ];
|
|
217
|
+
* const { key, conflicts } = analyzeArrangement([{ role: 'melody', notes: melody }]);
|
|
218
|
+
* conflicts; // notes clashing with the inferred harmony, worst severity first
|
|
219
|
+
* ```
|
|
220
|
+
* @category Arrangement & Analysis
|
|
221
|
+
*/
|
|
222
|
+
export function analyzeArrangement(
|
|
223
|
+
tracks: ArrangementTrack[],
|
|
224
|
+
opts: ArrangementOptions = {},
|
|
225
|
+
): ArrangementAnalysis {
|
|
226
|
+
const ts = opts.ts ?? parseTimeSignature('4/4');
|
|
227
|
+
assertTimeSignature(ts);
|
|
228
|
+
assertGenerationBudget(tracks.length, 'arrangement tracks');
|
|
229
|
+
for (let index = 0; index < tracks.length; index += 1) {
|
|
230
|
+
assertNoteEvents(tracks[index]?.notes ?? [], `tracks[${index}].notes`, {
|
|
231
|
+
allowNonPositiveDuration: true,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
const profile: SafetyProfile = opts.profile ?? 'pop';
|
|
235
|
+
const pooled = poolNotes(tracks);
|
|
236
|
+
|
|
237
|
+
const { timeline, key, segmentConfidence } = chordTimelineFromNotes(pooled, {
|
|
238
|
+
key: opts.key,
|
|
239
|
+
ts,
|
|
240
|
+
harmonicRhythm: opts.harmonicRhythm,
|
|
241
|
+
});
|
|
242
|
+
const cadences = detectCadences(timeline, key);
|
|
243
|
+
const prepared = prepareTracks(tracks);
|
|
244
|
+
|
|
245
|
+
const trackAnalyses: TrackAnalysis[] = [];
|
|
246
|
+
const conflicts: Conflict[] = [];
|
|
247
|
+
|
|
248
|
+
for (let t = 0; t < prepared.length; t += 1) {
|
|
249
|
+
const track = prepared[t];
|
|
250
|
+
if (!track) {
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
253
|
+
const notes: AnalyzedNote[] = [];
|
|
254
|
+
for (let v = 0; v < track.voices.length; v += 1) {
|
|
255
|
+
const subVoice = track.voices[v];
|
|
256
|
+
if (!subVoice) {
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
notes.push(
|
|
260
|
+
...analyzeVoice(subVoice.voice, timeline.at, key, (beat) =>
|
|
261
|
+
otherVoicesSounding(prepared, t, v, beat),
|
|
262
|
+
),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
for (let noteIndex = 0; noteIndex < subVoice.voice.length; noteIndex += 1) {
|
|
266
|
+
const note = subVoice.voice[noteIndex];
|
|
267
|
+
const preparedNote = subVoice.sounding[noteIndex];
|
|
268
|
+
if (note === undefined || preparedNote === undefined) {
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
for (const beat of evaluationBeats(note, timeline)) {
|
|
272
|
+
const atOnset = Math.abs(beat - note.startBeat) <= EPS;
|
|
273
|
+
const result = evaluateSafety({
|
|
274
|
+
profile,
|
|
275
|
+
candidatePitch: note.pitch,
|
|
276
|
+
prevPitch: atOnset ? preparedNote.prevPitch : note.pitch,
|
|
277
|
+
chord: timeline.at(beat),
|
|
278
|
+
key,
|
|
279
|
+
otherVoices: otherVoicesSounding(prepared, t, v, beat),
|
|
280
|
+
strongBeat: isStrongBeat(beat, ts),
|
|
281
|
+
});
|
|
282
|
+
if (result.safety !== NoteSafety.Safe) {
|
|
283
|
+
const conflict: Conflict = {
|
|
284
|
+
beat,
|
|
285
|
+
trackName: track.name,
|
|
286
|
+
pitch: note.pitch,
|
|
287
|
+
safety: result.safety,
|
|
288
|
+
reasons: result.reasons,
|
|
289
|
+
};
|
|
290
|
+
if (result.rationale !== undefined) {
|
|
291
|
+
conflict.rationale = result.rationale;
|
|
292
|
+
}
|
|
293
|
+
if (result.resolveTo !== undefined) {
|
|
294
|
+
conflict.resolveTo = result.resolveTo;
|
|
295
|
+
}
|
|
296
|
+
if (result.suggestions !== undefined) {
|
|
297
|
+
conflict.suggestions = [...result.suggestions];
|
|
298
|
+
}
|
|
299
|
+
conflicts.push(conflict);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// Sub-voice results interleave; restore the track's onset-then-pitch order.
|
|
305
|
+
notes.sort((a, b) => a.noteId - b.noteId);
|
|
306
|
+
trackAnalyses.push({ name: track.name, role: track.role, notes });
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
|
|
310
|
+
|
|
311
|
+
return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
|
|
312
|
+
}
|
|
@@ -4,8 +4,10 @@
|
|
|
4
4
|
* which only go name -> notes.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import type {
|
|
8
|
-
import {
|
|
7
|
+
import type { KeyScale } from '../../core/types.js';
|
|
8
|
+
import { assertFiniteNumber, assertGenerationBudget } from '../../core/validation/index.js';
|
|
9
|
+
import type { Chord, ChordQuality } from '../../theory/chord/index.js';
|
|
10
|
+
import { chordPitchClasses, chordQualities, makeChord } from '../../theory/chord/index.js';
|
|
9
11
|
import {
|
|
10
12
|
HARMONIC_MINOR_MASK,
|
|
11
13
|
MAJOR_MASK,
|
|
@@ -13,8 +15,7 @@ import {
|
|
|
13
15
|
majorKey,
|
|
14
16
|
minorKey,
|
|
15
17
|
NATURAL_MINOR_MASK,
|
|
16
|
-
} from '
|
|
17
|
-
import type { KeyScale } from '../types.js';
|
|
18
|
+
} from '../../theory/scale/index.js';
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* A candidate chord interpretation of a pitch set.
|
|
@@ -30,12 +31,24 @@ export type ChordMatch = {
|
|
|
30
31
|
extraPcs: number[];
|
|
31
32
|
/** True when the input pitch-class set equals the chord exactly. */
|
|
32
33
|
exact: boolean;
|
|
33
|
-
/** Inversion implied by
|
|
34
|
-
inversion: number;
|
|
34
|
+
/** Inversion implied by a known bass, or null for an unordered pitch-class set. */
|
|
35
|
+
inversion: number | null;
|
|
35
36
|
/** Bass pitch class when inverted (the lowest note is not the root). */
|
|
36
37
|
bassPc?: number;
|
|
37
38
|
};
|
|
38
39
|
|
|
40
|
+
/** Input interpretation for {@link detectChord} and {@link detectChordBest}. */
|
|
41
|
+
export type DetectChordOptions = {
|
|
42
|
+
/**
|
|
43
|
+
* `midi` uses the numerically lowest pitch as bass; `pitchClass` treats the
|
|
44
|
+
* input as unordered. `auto` (default) selects pitch-class mode only when all
|
|
45
|
+
* values lie in 0..11.
|
|
46
|
+
*/
|
|
47
|
+
input?: 'auto' | 'midi' | 'pitchClass';
|
|
48
|
+
/** Explicit bass pitch class, including for an unordered pitch-class set. */
|
|
49
|
+
bassPc?: number;
|
|
50
|
+
};
|
|
51
|
+
|
|
39
52
|
/**
|
|
40
53
|
* A candidate key interpretation of a pitch-class set.
|
|
41
54
|
*
|
|
@@ -58,6 +71,13 @@ function uniquePitchClasses(pitches: number[]): number[] {
|
|
|
58
71
|
return [...new Set(pitches.map(pitchClass))].sort((a, b) => a - b);
|
|
59
72
|
}
|
|
60
73
|
|
|
74
|
+
function assertPitches(pitches: number[]): void {
|
|
75
|
+
assertGenerationBudget(pitches.length, 'detection pitches');
|
|
76
|
+
for (let index = 0; index < pitches.length; index += 1) {
|
|
77
|
+
assertFiniteNumber(pitches[index] ?? Number.NaN, `pitches[${index}]`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
61
81
|
/**
|
|
62
82
|
* Identify chords matching a set of pitches.
|
|
63
83
|
*
|
|
@@ -78,12 +98,25 @@ function uniquePitchClasses(pitches: number[]): number[] {
|
|
|
78
98
|
* ```
|
|
79
99
|
* @category Recognition
|
|
80
100
|
*/
|
|
81
|
-
export function detectChord(pitches: number[]): ChordMatch[] {
|
|
101
|
+
export function detectChord(pitches: number[], opts: DetectChordOptions = {}): ChordMatch[] {
|
|
102
|
+
assertPitches(pitches);
|
|
103
|
+
if (opts.bassPc !== undefined) assertFiniteNumber(opts.bassPc, 'bassPc');
|
|
82
104
|
const input = uniquePitchClasses(pitches);
|
|
83
105
|
if (input.length === 0) {
|
|
84
106
|
return [];
|
|
85
107
|
}
|
|
86
|
-
const
|
|
108
|
+
const inputKind =
|
|
109
|
+
opts.input === undefined || opts.input === 'auto'
|
|
110
|
+
? pitches.every((pitch) => Number.isInteger(pitch) && pitch >= 0 && pitch <= 11)
|
|
111
|
+
? 'pitchClass'
|
|
112
|
+
: 'midi'
|
|
113
|
+
: opts.input;
|
|
114
|
+
const bassPc =
|
|
115
|
+
opts.bassPc !== undefined
|
|
116
|
+
? pitchClass(opts.bassPc)
|
|
117
|
+
: inputKind === 'midi'
|
|
118
|
+
? pitchClass(pitches.reduce((lowest, pitch) => Math.min(lowest, pitch), Infinity))
|
|
119
|
+
: undefined;
|
|
87
120
|
const inputSet = new Set(input);
|
|
88
121
|
const matches: ChordMatch[] = [];
|
|
89
122
|
const qualities = chordQualities();
|
|
@@ -100,8 +133,11 @@ export function detectChord(pitches: number[]): ChordMatch[] {
|
|
|
100
133
|
continue;
|
|
101
134
|
}
|
|
102
135
|
const extraPcs = input.filter((pc) => !toneSet.has(pc));
|
|
103
|
-
const bassIndex =
|
|
104
|
-
|
|
136
|
+
const bassIndex =
|
|
137
|
+
bassPc === undefined
|
|
138
|
+
? -1
|
|
139
|
+
: chord.intervals.findIndex((iv) => pitchClass(rootPc + iv) === bassPc);
|
|
140
|
+
const inversion = bassPc === undefined ? null : bassIndex > 0 ? bassIndex : 0;
|
|
105
141
|
const match: ChordMatch = {
|
|
106
142
|
rootPc,
|
|
107
143
|
quality,
|
|
@@ -110,7 +146,7 @@ export function detectChord(pitches: number[]): ChordMatch[] {
|
|
|
110
146
|
exact: extraPcs.length === 0 && missingPcs.length === 0,
|
|
111
147
|
inversion,
|
|
112
148
|
};
|
|
113
|
-
if (
|
|
149
|
+
if (bassPc !== undefined && bassPc !== rootPc) {
|
|
114
150
|
match.bassPc = bassPc;
|
|
115
151
|
}
|
|
116
152
|
matches.push(match);
|
|
@@ -146,8 +182,8 @@ export function detectChord(pitches: number[]): ChordMatch[] {
|
|
|
146
182
|
* ```
|
|
147
183
|
* @category Recognition
|
|
148
184
|
*/
|
|
149
|
-
export function detectChordBest(pitches: number[]): Chord | null {
|
|
150
|
-
const best = detectChord(pitches)[0];
|
|
185
|
+
export function detectChordBest(pitches: number[], opts: DetectChordOptions = {}): Chord | null {
|
|
186
|
+
const best = detectChord(pitches, opts)[0];
|
|
151
187
|
if (!best) {
|
|
152
188
|
return null;
|
|
153
189
|
}
|
|
@@ -183,6 +219,7 @@ const MINOR_MASK_VARIANTS = [NATURAL_MINOR_MASK, HARMONIC_MINOR_MASK, MELODIC_MI
|
|
|
183
219
|
* @category Recognition
|
|
184
220
|
*/
|
|
185
221
|
export function detectKey(pitches: number[]): KeyMatch[] {
|
|
222
|
+
assertPitches(pitches);
|
|
186
223
|
const input = uniquePitchClasses(pitches);
|
|
187
224
|
if (input.length === 0) {
|
|
188
225
|
return [];
|
|
@@ -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
|
+
}
|