@libraz/libcantus 0.9.0 → 0.9.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/dist/analyze/index.cjs +1697 -0
- package/dist/analyze/index.cjs.map +1 -0
- package/dist/analyze/index.d.cts +429 -0
- package/dist/analyze/index.d.ts +429 -0
- package/dist/analyze/index.js +51 -0
- package/dist/analyze/index.js.map +1 -0
- package/dist/chunk-5HTNMQW4.js +653 -0
- package/dist/chunk-5HTNMQW4.js.map +1 -0
- package/dist/chunk-7Y7WF64D.js +410 -0
- package/dist/chunk-7Y7WF64D.js.map +1 -0
- package/dist/chunk-A4532ZFR.js +259 -0
- package/dist/chunk-A4532ZFR.js.map +1 -0
- package/dist/chunk-BHUT2S5G.js +22 -0
- package/dist/chunk-BHUT2S5G.js.map +1 -0
- package/dist/chunk-CCBLVAPK.js +42 -0
- package/dist/chunk-CCBLVAPK.js.map +1 -0
- package/dist/chunk-COKTX2G5.js +907 -0
- package/dist/chunk-COKTX2G5.js.map +1 -0
- package/dist/chunk-DYWFNC4A.js +152 -0
- package/dist/chunk-DYWFNC4A.js.map +1 -0
- package/dist/chunk-GQKN5KDT.js +159 -0
- package/dist/chunk-GQKN5KDT.js.map +1 -0
- package/dist/chunk-LN7ZZMD2.js +530 -0
- package/dist/chunk-LN7ZZMD2.js.map +1 -0
- package/dist/chunk-MSWFZF7J.js +118 -0
- package/dist/chunk-MSWFZF7J.js.map +1 -0
- package/dist/chunk-R3S2LUBH.js +2722 -0
- package/dist/chunk-R3S2LUBH.js.map +1 -0
- package/dist/chunk-SDM3VJJ5.js +41 -0
- package/dist/chunk-SDM3VJJ5.js.map +1 -0
- package/dist/chunk-XTC3X6MS.js +816 -0
- package/dist/chunk-XTC3X6MS.js.map +1 -0
- package/dist/chunk-ZXW5YIWK.js +55 -0
- package/dist/chunk-ZXW5YIWK.js.map +1 -0
- package/dist/core/index.cjs +432 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +210 -0
- package/dist/core/index.d.ts +210 -0
- package/dist/core/index.js +73 -0
- package/dist/core/index.js.map +1 -0
- package/dist/function-2bJW1Zp_.d.ts +187 -0
- package/dist/function-r2sh-jOO.d.cts +187 -0
- package/dist/generate/index.cjs +3787 -0
- package/dist/generate/index.cjs.map +1 -0
- package/dist/generate/index.d.cts +980 -0
- package/dist/generate/index.d.ts +980 -0
- package/dist/generate/index.js +52 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/index-BMPxwjwV.d.ts +151 -0
- package/dist/index-BOBXBNwP.d.cts +157 -0
- package/dist/index-BdeQ1r_p.d.cts +56 -0
- package/dist/index-BreoJRw0.d.ts +127 -0
- package/dist/index-CGMZNCx6.d.cts +140 -0
- package/dist/index-CGMZNCx6.d.ts +140 -0
- package/dist/index-CM5W4rcm.d.ts +157 -0
- package/dist/index-CP3wAfEH.d.ts +56 -0
- package/dist/index-Cl7mhOFM.d.cts +164 -0
- package/dist/index-Cl7mhOFM.d.ts +164 -0
- package/dist/index-DZ1M21do.d.cts +127 -0
- package/dist/index-D_ZTUNKp.d.cts +151 -0
- package/dist/index.cjs +5277 -4893
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -2621
- package/dist/index.d.ts +14 -2621
- package/dist/index.js +170 -6152
- package/dist/index.js.map +1 -1
- package/dist/model/index.cjs +2435 -0
- package/dist/model/index.cjs.map +1 -0
- package/dist/model/index.d.cts +630 -0
- package/dist/model/index.d.ts +630 -0
- package/dist/model/index.js +21 -0
- package/dist/model/index.js.map +1 -0
- package/dist/styled-BjXtOFNk.d.cts +282 -0
- package/dist/styled-D7BAx3S3.d.ts +282 -0
- package/dist/theory/index.cjs +1677 -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 +149 -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 +31 -1
- package/src/analyze/arrange/index.ts +21 -0
- package/src/analyze/arrange/internal.ts +125 -0
- package/src/analyze/arrange/tension.ts +187 -0
- package/src/analyze/arrange/tracks.ts +279 -0
- package/src/{detect → analyze/detect}/index.ts +82 -23
- 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/analyze/functional/roman.ts +393 -0
- package/src/analyze/index.ts +49 -0
- package/src/{timeline → analyze/timeline}/index.ts +87 -24
- package/src/{analysis → analyze/voice}/index.ts +81 -12
- package/src/core/index.ts +47 -0
- package/src/{interval → core/interval}/index.ts +15 -1
- package/src/{meter → core/meter}/index.ts +108 -5
- package/src/{pitch → core/pitch}/index.ts +58 -3
- package/src/{random → core/random}/index.ts +13 -1
- package/src/{tuning → core/tuning}/index.ts +28 -1
- package/src/{bass → generate/bass}/index.ts +66 -17
- package/src/{countermelody → generate/countermelody}/index.ts +54 -24
- package/src/{drums → generate/drums}/fills.ts +31 -20
- package/src/{drums → generate/drums}/index.ts +127 -9
- package/src/{drums → generate/drums}/internal.ts +7 -1
- package/src/{drums → generate/drums}/rng.ts +2 -2
- package/src/{drums → generate/drums}/swing.ts +7 -2
- package/src/{groove → generate/groove}/index.ts +118 -22
- package/src/{harmonize → generate/harmonize}/index.ts +96 -23
- package/src/generate/index.ts +59 -0
- package/src/{motif → generate/motif}/index.ts +94 -17
- package/src/{progression → generate/progression}/index.ts +61 -27
- package/src/{reharmony → generate/reharmony}/index.ts +46 -15
- package/src/{rhythm → generate/rhythm}/index.ts +47 -13
- package/src/index.ts +130 -116
- package/src/model/chord.ts +423 -0
- package/src/model/index.ts +5 -947
- 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 +85 -4
- package/src/{chordscale → theory/chordscale}/index.ts +67 -9
- package/src/{counterpoint → theory/counterpoint}/index.ts +23 -2
- package/src/{harmony → theory/harmony}/index.ts +19 -2
- package/src/theory/index.ts +107 -0
- package/src/{safety → theory/safety}/index.ts +59 -12
- 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 +21 -3
- package/src/{symbol → theory/symbol}/index.ts +91 -36
- 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 +195 -0
- package/src/theory/voicing/styled.ts +176 -0
- package/src/arrange/index.ts +0 -409
- package/src/functional/index.ts +0 -602
- package/src/scale/index.ts +0 -215
- package/src/voicing/index.ts +0 -523
- /package/src/{types.ts → core/types.ts} +0 -0
- /package/src/{drums → generate/drums}/beat.ts +0 -0
- /package/src/{drums → generate/drums}/euclid.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}/kick.ts +0 -0
- /package/src/{drums → generate/drums}/percussion.ts +0 -0
package/src/arrange/index.ts
DELETED
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Arrangement analysis: the capstone that ties the timeline, per-voice analysis,
|
|
3
|
-
* functional harmony, and safety modules together for a whole multi-track piece.
|
|
4
|
-
*
|
|
5
|
-
* A chord timeline and key are inferred once from every track's notes pooled
|
|
6
|
-
* together; each track is then analysed against that shared harmony, notes that
|
|
7
|
-
* clash with the sounding chord are collected as conflicts, and a coarse tension
|
|
8
|
-
* curve is sampled across the piece.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import { type AnalyzedNote, analyzeVoice, type VoiceNote } from '../analysis/index.js';
|
|
12
|
-
import { chordPitchClasses } from '../chord/index.js';
|
|
13
|
-
import { functionOf } from '../functional/index.js';
|
|
14
|
-
import { isStrongBeat, parseTimeSignature, type TimeSignature } from '../meter/index.js';
|
|
15
|
-
import {
|
|
16
|
-
evaluateSafety,
|
|
17
|
-
NoteSafety,
|
|
18
|
-
type SafetyProfile,
|
|
19
|
-
type VoiceSnapshot,
|
|
20
|
-
} from '../safety/index.js';
|
|
21
|
-
import {
|
|
22
|
-
type CadenceHit,
|
|
23
|
-
type ChordTimeline,
|
|
24
|
-
chordTimelineFromNotes,
|
|
25
|
-
detectCadences,
|
|
26
|
-
} from '../timeline/index.js';
|
|
27
|
-
import type { KeyScale, NoteEvent } from '../types.js';
|
|
28
|
-
|
|
29
|
-
/** Float tolerance for beat boundary comparisons. */
|
|
30
|
-
const EPS = 1e-9;
|
|
31
|
-
|
|
32
|
-
/** The musical role a track plays in the arrangement. */
|
|
33
|
-
export type TrackRole = 'melody' | 'harmony' | 'bass' | 'other';
|
|
34
|
-
|
|
35
|
-
/** One input track: its notes plus optional name and role. */
|
|
36
|
-
export type ArrangementTrack = {
|
|
37
|
-
name?: string;
|
|
38
|
-
role?: TrackRole;
|
|
39
|
-
notes: NoteEvent[];
|
|
40
|
-
};
|
|
41
|
-
|
|
42
|
-
/** A track after analysis: its resolved name, role, and per-note annotations. */
|
|
43
|
-
export type TrackAnalysis = {
|
|
44
|
-
name: string;
|
|
45
|
-
role: TrackRole;
|
|
46
|
-
/** One {@link AnalyzedNote} per note, labelled against the inferred chords. */
|
|
47
|
-
notes: AnalyzedNote[];
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
/** A note that clashes with the harmony sounding beneath it. */
|
|
51
|
-
export type Conflict = {
|
|
52
|
-
beat: number;
|
|
53
|
-
trackName: string;
|
|
54
|
-
pitch: number;
|
|
55
|
-
/** The clash severity: {@link NoteSafety.Warning} or {@link NoteSafety.Dissonant}. */
|
|
56
|
-
safety: NoteSafety;
|
|
57
|
-
/** {@link import('../safety/index.js').SafetyResult.reasons} bitmask. */
|
|
58
|
-
reasons: number;
|
|
59
|
-
rationale?: string;
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
/** The full result of {@link analyzeArrangement}. */
|
|
63
|
-
export type ArrangementAnalysis = {
|
|
64
|
-
key: KeyScale;
|
|
65
|
-
timeline: ChordTimeline;
|
|
66
|
-
segmentConfidence: number[];
|
|
67
|
-
cadences: CadenceHit[];
|
|
68
|
-
tracks: TrackAnalysis[];
|
|
69
|
-
/** Notes clashing with the sounding harmony, worst severity first. */
|
|
70
|
-
conflicts: Conflict[];
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
/** Options controlling {@link analyzeArrangement} and {@link tensionCurve}. */
|
|
74
|
-
export type ArrangementOptions = {
|
|
75
|
-
/** Key context; inferred from the pooled notes when omitted. */
|
|
76
|
-
key?: KeyScale;
|
|
77
|
-
/** Time signature; defaults to 4/4. */
|
|
78
|
-
ts?: TimeSignature;
|
|
79
|
-
/** Chord-slot length in beats; defaults to one bar of `ts`. */
|
|
80
|
-
harmonicRhythm?: number;
|
|
81
|
-
/** Safety profile used for conflict detection; defaults to `pop`. */
|
|
82
|
-
profile?: SafetyProfile;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
/** A note with the pitch that immediately preceded it in the same track. */
|
|
86
|
-
type PreparedNote = {
|
|
87
|
-
pitch: number;
|
|
88
|
-
prevPitch?: number;
|
|
89
|
-
startBeat: number;
|
|
90
|
-
endBeat: number;
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
/** A track prepared for analysis: resolved metadata plus derived note views. */
|
|
94
|
-
type PreparedTrack = {
|
|
95
|
-
name: string;
|
|
96
|
-
role: TrackRole;
|
|
97
|
-
/** The track's notes in time order, each with a stable id. */
|
|
98
|
-
voice: VoiceNote[];
|
|
99
|
-
/** The same notes as sounding spans, each carrying its predecessor's pitch. */
|
|
100
|
-
sounding: PreparedNote[];
|
|
101
|
-
};
|
|
102
|
-
|
|
103
|
-
/** Reduce a pitch to a pitch class in [0, 11]. */
|
|
104
|
-
function pitchClass(pitch: number): number {
|
|
105
|
-
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Whether a sounding span `[start, end)` covers a beat. */
|
|
109
|
-
function covers(note: PreparedNote, beat: number): boolean {
|
|
110
|
-
return beat >= note.startBeat - EPS && beat < note.endBeat - EPS;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
/** Every note of every track pooled into one flat list. */
|
|
114
|
-
function poolNotes(tracks: ArrangementTrack[]): NoteEvent[] {
|
|
115
|
-
const notes: NoteEvent[] = [];
|
|
116
|
-
for (const track of tracks) {
|
|
117
|
-
for (const note of track.notes) {
|
|
118
|
-
notes.push(note);
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
return notes;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Resolve each track's metadata and derive its ordered voice and sounding spans.
|
|
126
|
-
*
|
|
127
|
-
* Notes are sorted by onset, then pitch, so ordering is deterministic; the
|
|
128
|
-
* predecessor pitch used for suspension and parallel detection is simply the
|
|
129
|
-
* previous note in that order.
|
|
130
|
-
*/
|
|
131
|
-
function prepareTracks(tracks: ArrangementTrack[]): PreparedTrack[] {
|
|
132
|
-
let nextId = 0;
|
|
133
|
-
return tracks.map((track, index) => {
|
|
134
|
-
const ordered = [...track.notes].sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch);
|
|
135
|
-
const voice: VoiceNote[] = ordered.map((note) => ({ ...note, id: nextId++ }));
|
|
136
|
-
const sounding: PreparedNote[] = ordered.map((note, i) => {
|
|
137
|
-
const prev = i > 0 ? ordered[i - 1] : undefined;
|
|
138
|
-
return {
|
|
139
|
-
pitch: note.pitch,
|
|
140
|
-
prevPitch: prev?.pitch,
|
|
141
|
-
startBeat: note.startBeat,
|
|
142
|
-
endBeat: note.startBeat + note.durationBeat,
|
|
143
|
-
};
|
|
144
|
-
});
|
|
145
|
-
return {
|
|
146
|
-
name: track.name ?? `track ${index + 1}`,
|
|
147
|
-
role: track.role ?? 'other',
|
|
148
|
-
voice,
|
|
149
|
-
sounding,
|
|
150
|
-
};
|
|
151
|
-
});
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
/** Sounding pitches of every track except `excludeIndex` at a beat. */
|
|
155
|
-
function otherVoicesSounding(
|
|
156
|
-
prepared: PreparedTrack[],
|
|
157
|
-
excludeIndex: number,
|
|
158
|
-
beat: number,
|
|
159
|
-
): VoiceSnapshot[] {
|
|
160
|
-
const out: VoiceSnapshot[] = [];
|
|
161
|
-
for (let t = 0; t < prepared.length; t += 1) {
|
|
162
|
-
if (t === excludeIndex) {
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
|
-
const track = prepared[t];
|
|
166
|
-
if (!track) {
|
|
167
|
-
continue;
|
|
168
|
-
}
|
|
169
|
-
for (const note of track.sounding) {
|
|
170
|
-
if (!covers(note, beat)) {
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
const snap: VoiceSnapshot = { pitch: note.pitch };
|
|
174
|
-
if (note.prevPitch !== undefined) {
|
|
175
|
-
snap.prevPitch = note.prevPitch;
|
|
176
|
-
}
|
|
177
|
-
out.push(snap);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
return out;
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
/**
|
|
184
|
-
* Analyse a whole arrangement against a single inferred harmony.
|
|
185
|
-
*
|
|
186
|
-
* The chord timeline and key are inferred from the pooled notes of every track
|
|
187
|
-
* (see {@link chordTimelineFromNotes}); pooling all voices is robust even when
|
|
188
|
-
* roles are absent or a track doubles the harmony, so it is preferred over
|
|
189
|
-
* deriving the harmony from a subset. Each track is then labelled note-by-note
|
|
190
|
-
* against that harmony with {@link analyzeVoice}, seeing the other tracks as its
|
|
191
|
-
* accompaniment, and every note is re-checked with {@link evaluateSafety}: those
|
|
192
|
-
* that are not {@link NoteSafety.Safe} against the sounding chord become
|
|
193
|
-
* conflicts, sorted worst severity first and then by beat.
|
|
194
|
-
*
|
|
195
|
-
* @param tracks The tracks to analyse.
|
|
196
|
-
* @param opts Analysis options; see {@link ArrangementOptions}.
|
|
197
|
-
* @returns The inferred harmony, per-track annotations, cadences, and conflicts.
|
|
198
|
-
*/
|
|
199
|
-
export function analyzeArrangement(
|
|
200
|
-
tracks: ArrangementTrack[],
|
|
201
|
-
opts: ArrangementOptions = {},
|
|
202
|
-
): ArrangementAnalysis {
|
|
203
|
-
const ts = opts.ts ?? parseTimeSignature('4/4');
|
|
204
|
-
const profile: SafetyProfile = opts.profile ?? 'pop';
|
|
205
|
-
const pooled = poolNotes(tracks);
|
|
206
|
-
|
|
207
|
-
const { timeline, key, segmentConfidence } = chordTimelineFromNotes(pooled, {
|
|
208
|
-
key: opts.key,
|
|
209
|
-
ts,
|
|
210
|
-
harmonicRhythm: opts.harmonicRhythm,
|
|
211
|
-
});
|
|
212
|
-
const cadences = detectCadences(timeline, key);
|
|
213
|
-
const prepared = prepareTracks(tracks);
|
|
214
|
-
|
|
215
|
-
const trackAnalyses: TrackAnalysis[] = [];
|
|
216
|
-
const conflicts: Conflict[] = [];
|
|
217
|
-
|
|
218
|
-
for (let t = 0; t < prepared.length; t += 1) {
|
|
219
|
-
const track = prepared[t];
|
|
220
|
-
if (!track) {
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
const notes = analyzeVoice(track.voice, timeline.at, key, (beat) =>
|
|
224
|
-
otherVoicesSounding(prepared, t, beat),
|
|
225
|
-
);
|
|
226
|
-
trackAnalyses.push({ name: track.name, role: track.role, notes });
|
|
227
|
-
|
|
228
|
-
for (const note of track.voice) {
|
|
229
|
-
const result = evaluateSafety({
|
|
230
|
-
profile,
|
|
231
|
-
candidatePitch: note.pitch,
|
|
232
|
-
chord: timeline.at(note.startBeat),
|
|
233
|
-
key,
|
|
234
|
-
otherVoices: otherVoicesSounding(prepared, t, note.startBeat),
|
|
235
|
-
strongBeat: isStrongBeat(note.startBeat, ts),
|
|
236
|
-
});
|
|
237
|
-
if (result.safety !== NoteSafety.Safe) {
|
|
238
|
-
const conflict: Conflict = {
|
|
239
|
-
beat: note.startBeat,
|
|
240
|
-
trackName: track.name,
|
|
241
|
-
pitch: note.pitch,
|
|
242
|
-
safety: result.safety,
|
|
243
|
-
reasons: result.reasons,
|
|
244
|
-
};
|
|
245
|
-
if (result.rationale !== undefined) {
|
|
246
|
-
conflict.rationale = result.rationale;
|
|
247
|
-
}
|
|
248
|
-
conflicts.push(conflict);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
|
|
254
|
-
|
|
255
|
-
return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
|
|
256
|
-
}
|
|
257
|
-
|
|
258
|
-
/** A tension reading sampled at a beat. */
|
|
259
|
-
export type TensionPoint = {
|
|
260
|
-
beat: number;
|
|
261
|
-
/** Combined tension in [0, 1]. */
|
|
262
|
-
tension: number;
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
/** Tension contributed by the sounding chord's harmonic function. */
|
|
266
|
-
const FUNCTION_TENSION: Record<'tonic' | 'subdominant' | 'dominant', number> = {
|
|
267
|
-
tonic: 0,
|
|
268
|
-
subdominant: 0.5,
|
|
269
|
-
dominant: 1,
|
|
270
|
-
};
|
|
271
|
-
|
|
272
|
-
/** Weight of the harmonic-function term in the combined tension score. */
|
|
273
|
-
const FUNCTION_WEIGHT = 0.5;
|
|
274
|
-
/** Weight of the non-chord-tone / dissonance term. */
|
|
275
|
-
const DISSONANCE_WEIGHT = 0.35;
|
|
276
|
-
/** Weight of the registral-span term. */
|
|
277
|
-
const SPAN_WEIGHT = 0.15;
|
|
278
|
-
/** Pitch span, in semitones, that saturates the span term. */
|
|
279
|
-
const SPAN_SATURATION = 24;
|
|
280
|
-
|
|
281
|
-
/** Clamp a value into [0, 1]. */
|
|
282
|
-
function clamp01(value: number): number {
|
|
283
|
-
return Math.min(1, Math.max(0, value));
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/**
|
|
287
|
-
* Sample the harmonic tension of an arrangement at regular beats.
|
|
288
|
-
*
|
|
289
|
-
* At each sample beat the tension combines three normalized terms:
|
|
290
|
-
* - the sounding chord's harmonic function (dominant `1`, subdominant `0.5`,
|
|
291
|
-
* tonic or no chord `0`), weighted {@link FUNCTION_WEIGHT};
|
|
292
|
-
* - the dissonance of the sounding notes — the larger of the share of sounding
|
|
293
|
-
* pitches that are not chord tones and the share that {@link evaluateSafety}
|
|
294
|
-
* rates {@link NoteSafety.Dissonant} — weighted {@link DISSONANCE_WEIGHT};
|
|
295
|
-
* - the registral span of the sounding notes, saturating at
|
|
296
|
-
* {@link SPAN_SATURATION} semitones, weighted {@link SPAN_WEIGHT}.
|
|
297
|
-
*
|
|
298
|
-
* The weighted sum is clamped to [0, 1]. The harmony is inferred from all tracks
|
|
299
|
-
* pooled together, so the result is deterministic and self-contained.
|
|
300
|
-
*
|
|
301
|
-
* @param tracks The tracks to sample.
|
|
302
|
-
* @param opts Analysis options plus an optional `step` (default one beat).
|
|
303
|
-
* @returns One {@link TensionPoint} per sampled beat, in beat order.
|
|
304
|
-
* @throws If `step` is not positive.
|
|
305
|
-
*/
|
|
306
|
-
export function tensionCurve(
|
|
307
|
-
tracks: ArrangementTrack[],
|
|
308
|
-
opts: ArrangementOptions & { step?: number } = {},
|
|
309
|
-
): TensionPoint[] {
|
|
310
|
-
const ts = opts.ts ?? parseTimeSignature('4/4');
|
|
311
|
-
const profile: SafetyProfile = opts.profile ?? 'pop';
|
|
312
|
-
const step = opts.step ?? 1;
|
|
313
|
-
if (!(step > 0)) {
|
|
314
|
-
throw new Error(`Invalid tension sampling step: ${step}`);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
const pooled = poolNotes(tracks);
|
|
318
|
-
const totalBeats = pooled.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
|
|
319
|
-
const { timeline, key } = chordTimelineFromNotes(pooled, {
|
|
320
|
-
key: opts.key,
|
|
321
|
-
ts,
|
|
322
|
-
harmonicRhythm: opts.harmonicRhythm,
|
|
323
|
-
});
|
|
324
|
-
|
|
325
|
-
const prepared = prepareTracks(tracks);
|
|
326
|
-
const points: TensionPoint[] = [];
|
|
327
|
-
const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS));
|
|
328
|
-
for (let i = 0; i < sampleCount; i += 1) {
|
|
329
|
-
const beat = i * step;
|
|
330
|
-
points.push({ beat, tension: sampleTension(prepared, timeline, key, ts, profile, beat) });
|
|
331
|
-
}
|
|
332
|
-
return points;
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** All sounding notes across every track at a beat, tagged with their track. */
|
|
336
|
-
function allSounding(prepared: PreparedTrack[], beat: number): { pitch: number; track: number }[] {
|
|
337
|
-
const out: { pitch: number; track: number }[] = [];
|
|
338
|
-
for (let t = 0; t < prepared.length; t += 1) {
|
|
339
|
-
const track = prepared[t];
|
|
340
|
-
if (!track) {
|
|
341
|
-
continue;
|
|
342
|
-
}
|
|
343
|
-
for (const note of track.sounding) {
|
|
344
|
-
if (covers(note, beat)) {
|
|
345
|
-
out.push({ pitch: note.pitch, track: t });
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
return out;
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
/** Combine the harmonic, dissonance, and span terms at one sample beat. */
|
|
353
|
-
function sampleTension(
|
|
354
|
-
prepared: PreparedTrack[],
|
|
355
|
-
timeline: ChordTimeline,
|
|
356
|
-
key: KeyScale,
|
|
357
|
-
ts: TimeSignature,
|
|
358
|
-
profile: SafetyProfile,
|
|
359
|
-
beat: number,
|
|
360
|
-
): number {
|
|
361
|
-
const sounding = allSounding(prepared, beat);
|
|
362
|
-
if (sounding.length === 0) {
|
|
363
|
-
return 0;
|
|
364
|
-
}
|
|
365
|
-
const chord = timeline.at(beat);
|
|
366
|
-
const functionScore = chord ? FUNCTION_TENSION[functionOf(chord, key)] : 0;
|
|
367
|
-
|
|
368
|
-
const chordPcs = chord ? new Set(chordPitchClasses(chord)) : null;
|
|
369
|
-
const strongBeat = isStrongBeat(beat, ts);
|
|
370
|
-
let nonChord = 0;
|
|
371
|
-
let dissonant = 0;
|
|
372
|
-
for (let i = 0; i < sounding.length; i += 1) {
|
|
373
|
-
const voice = sounding[i];
|
|
374
|
-
if (!voice) {
|
|
375
|
-
continue;
|
|
376
|
-
}
|
|
377
|
-
// Non-chord-tone share only applies when a chord is sounding; at a timeline
|
|
378
|
-
// gap there is no reference harmony, so vertical dissonance alone drives the
|
|
379
|
-
// dissonance term (the chord function term is already 0 there).
|
|
380
|
-
if (chordPcs && !chordPcs.has(pitchClass(voice.pitch))) {
|
|
381
|
-
nonChord += 1;
|
|
382
|
-
}
|
|
383
|
-
const others = sounding.filter((_, j) => j !== i).map((s) => ({ pitch: s.pitch }));
|
|
384
|
-
const result = evaluateSafety({
|
|
385
|
-
profile,
|
|
386
|
-
candidatePitch: voice.pitch,
|
|
387
|
-
chord,
|
|
388
|
-
key,
|
|
389
|
-
otherVoices: others,
|
|
390
|
-
strongBeat,
|
|
391
|
-
});
|
|
392
|
-
if (result.safety === NoteSafety.Dissonant) {
|
|
393
|
-
dissonant += 1;
|
|
394
|
-
}
|
|
395
|
-
}
|
|
396
|
-
const dissonanceScore = Math.max(nonChord, dissonant) / sounding.length;
|
|
397
|
-
|
|
398
|
-
let low = Number.POSITIVE_INFINITY;
|
|
399
|
-
let high = Number.NEGATIVE_INFINITY;
|
|
400
|
-
for (const voice of sounding) {
|
|
401
|
-
low = Math.min(low, voice.pitch);
|
|
402
|
-
high = Math.max(high, voice.pitch);
|
|
403
|
-
}
|
|
404
|
-
const spanScore = Math.min(1, (high - low) / SPAN_SATURATION);
|
|
405
|
-
|
|
406
|
-
return clamp01(
|
|
407
|
-
FUNCTION_WEIGHT * functionScore + DISSONANCE_WEIGHT * dissonanceScore + SPAN_WEIGHT * spanScore,
|
|
408
|
-
);
|
|
409
|
-
}
|