@libraz/libcantus 0.9.0 → 0.9.1
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 +10 -2
- package/dist/index.cjs +577 -225
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1533 -172
- package/dist/index.d.ts +1533 -172
- package/dist/index.js +577 -225
- package/dist/index.js.map +1 -1
- package/package.json +6 -1
- package/src/analysis/index.ts +73 -7
- package/src/arrange/index.ts +241 -79
- package/src/bass/index.ts +61 -12
- package/src/chord/index.ts +66 -3
- package/src/chordscale/index.ts +67 -9
- package/src/countermelody/index.ts +44 -14
- package/src/counterpoint/index.ts +21 -0
- package/src/detect/index.ts +79 -20
- package/src/drums/fills.ts +31 -20
- package/src/drums/index.ts +125 -7
- package/src/drums/internal.ts +7 -1
- package/src/drums/swing.ts +7 -2
- package/src/functional/index.ts +160 -51
- package/src/groove/index.ts +116 -20
- package/src/harmonize/index.ts +85 -12
- package/src/harmony/index.ts +19 -2
- package/src/index.ts +1 -0
- package/src/interval/index.ts +15 -1
- package/src/meter/index.ts +108 -5
- package/src/model/index.ts +161 -12
- package/src/motif/index.ts +87 -10
- package/src/pitch/index.ts +58 -3
- package/src/progression/index.ts +56 -8
- package/src/random/index.ts +13 -1
- package/src/reharmony/index.ts +35 -4
- package/src/rhythm/index.ts +45 -11
- package/src/safety/index.ts +58 -11
- package/src/scale/index.ts +135 -19
- package/src/spelling/index.ts +18 -0
- package/src/symbol/index.ts +84 -36
- package/src/timeline/index.ts +80 -13
- package/src/tuning/index.ts +28 -1
- package/src/voicing/index.ts +121 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@libraz/libcantus",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Pure-TypeScript music-theory and composition primitives (intervals, scales, chords).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -58,6 +58,7 @@
|
|
|
58
58
|
"test": "vitest run",
|
|
59
59
|
"test:watch": "vitest",
|
|
60
60
|
"test:coverage": "vitest run --coverage",
|
|
61
|
+
"docs": "typedoc",
|
|
61
62
|
"prepare": "[ -d node_modules/typescript ] && tsup || true",
|
|
62
63
|
"prepublishOnly": "yarn build"
|
|
63
64
|
},
|
|
@@ -65,6 +66,10 @@
|
|
|
65
66
|
"@biomejs/biome": "2.5.2",
|
|
66
67
|
"@vitest/coverage-v8": "4.1.9",
|
|
67
68
|
"tsup": "8.5.1",
|
|
69
|
+
"typedoc": "0.28.20",
|
|
70
|
+
"typedoc-material-theme": "1.4.1",
|
|
71
|
+
"typedoc-plugin-extras": "4.0.1",
|
|
72
|
+
"typedoc-plugin-mdn-links": "5.1.1",
|
|
68
73
|
"typescript": "6.0.3",
|
|
69
74
|
"vitest": "4.1.9"
|
|
70
75
|
}
|
package/src/analysis/index.ts
CHANGED
|
@@ -4,7 +4,11 @@ import { createsVerticalDissonance, isLeadingToneResolution } from '../counterpo
|
|
|
4
4
|
import type { VoiceSnapshot } from '../safety/index.js';
|
|
5
5
|
import type { KeyScale, NoteEvent } from '../types.js';
|
|
6
6
|
|
|
7
|
-
/**
|
|
7
|
+
/**
|
|
8
|
+
* A theory annotation attached to a note.
|
|
9
|
+
*
|
|
10
|
+
* @category Arrangement & Analysis
|
|
11
|
+
*/
|
|
8
12
|
export type TheoryLabel =
|
|
9
13
|
| { kind: 'chordTone'; role: 'root' | 'third' | 'fifth' | 'sixth' | 'seventh' }
|
|
10
14
|
| { kind: 'tension'; degree: 9 | 11 | 13 }
|
|
@@ -17,16 +21,27 @@ export type TheoryLabel =
|
|
|
17
21
|
| { kind: 'needsResolution'; resolveTo: number }
|
|
18
22
|
| { kind: 'leadingTone'; resolveTo: number };
|
|
19
23
|
|
|
20
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* A note with its theory labels and a short rationale.
|
|
26
|
+
*
|
|
27
|
+
* @category Arrangement & Analysis
|
|
28
|
+
*/
|
|
21
29
|
export type AnalyzedNote = {
|
|
22
30
|
noteId: number;
|
|
23
31
|
labels: TheoryLabel[];
|
|
24
32
|
rationale?: string;
|
|
25
33
|
};
|
|
26
34
|
|
|
27
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* A single note in a monophonic voice: a {@link NoteEvent} with a stable id.
|
|
37
|
+
*
|
|
38
|
+
* @category Arrangement & Analysis
|
|
39
|
+
*/
|
|
28
40
|
export type VoiceNote = NoteEvent & { id: number };
|
|
29
41
|
|
|
42
|
+
/** Float tolerance for beat comparisons. */
|
|
43
|
+
const EPS = 1e-9;
|
|
44
|
+
|
|
30
45
|
function pitchClass(pitch: number): number {
|
|
31
46
|
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
32
47
|
}
|
|
@@ -35,6 +50,26 @@ function intervalAboveRoot(pitch: number, chord: Chord): number {
|
|
|
35
50
|
return (((pitchClass(pitch) - pitchClass(chord.rootPc)) % 12) + 12) % 12;
|
|
36
51
|
}
|
|
37
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Interval class of a pitch above the actual sounding bass.
|
|
55
|
+
*
|
|
56
|
+
* Suspension figures (4-3, 7-6, 9-8) are named for the interval above the bass,
|
|
57
|
+
* not the chord root, so this prefers the lowest pitch among the other sounding
|
|
58
|
+
* voices when one lies below the note. Without a lower sounding voice it falls
|
|
59
|
+
* back to the chord's bass pitch class (or root in root position).
|
|
60
|
+
*/
|
|
61
|
+
function intervalAboveBass(pitch: number, others: VoiceSnapshot[], chord: Chord): number {
|
|
62
|
+
let bass = Number.POSITIVE_INFINITY;
|
|
63
|
+
for (const ov of others) {
|
|
64
|
+
bass = Math.min(bass, ov.pitch);
|
|
65
|
+
}
|
|
66
|
+
if (Number.isFinite(bass) && bass < pitch) {
|
|
67
|
+
return (((pitch - bass) % 12) + 12) % 12;
|
|
68
|
+
}
|
|
69
|
+
const ref = chord.bassPc ?? chord.rootPc;
|
|
70
|
+
return (((pitchClass(pitch) - pitchClass(ref)) % 12) + 12) % 12;
|
|
71
|
+
}
|
|
72
|
+
|
|
38
73
|
/**
|
|
39
74
|
* Map an interval class above the root to its extended-tension scale degree:
|
|
40
75
|
* a flat/natural ninth (1 or 2) to 9, an eleventh (5 or 6) to 11, and a
|
|
@@ -59,6 +94,10 @@ function isStep(a: number, b: number): boolean {
|
|
|
59
94
|
return d === 1 || d === 2;
|
|
60
95
|
}
|
|
61
96
|
|
|
97
|
+
/**
|
|
98
|
+
* Classify a suspension figure from the interval class above the sounding bass
|
|
99
|
+
* (`ic`) and the resolution direction (`delta`, positive when resolving upward).
|
|
100
|
+
*/
|
|
62
101
|
function suspensionType(ic: number, delta: number): TheoryLabel & { kind: 'suspension' } {
|
|
63
102
|
let type: 'sus4-3' | 'sus7-6' | 'sus9-8' | 'sus2-3';
|
|
64
103
|
if (delta > 0) {
|
|
@@ -92,11 +131,30 @@ function stepResolution(pitch: number, chord: Chord): number | undefined {
|
|
|
92
131
|
* avoid, or an unresolved-dissonance label. Leading-tone resolutions are noted
|
|
93
132
|
* additionally.
|
|
94
133
|
*
|
|
134
|
+
* The voice is expected to be monophonic (one note at a time). Notes sharing an
|
|
135
|
+
* onset are treated as simultaneous cluster members, not melodic neighbors, so
|
|
136
|
+
* they receive no melodic labels (suspension, passing, neighbor, anticipation,
|
|
137
|
+
* escape) — only harmonic ones. Callers with truly polyphonic material should
|
|
138
|
+
* split it into monophonic sub-voices first, as `analyzeArrangement` does.
|
|
139
|
+
*
|
|
95
140
|
* @param voice The monophonic voice, in time order.
|
|
96
141
|
* @param chordAtBeat Chord sounding at a given beat, or null.
|
|
97
142
|
* @param key Key context for leading-tone detection.
|
|
98
143
|
* @param otherVoicesAtBeat Other sounding voices at a given beat.
|
|
99
144
|
* @returns One annotation per input note.
|
|
145
|
+
* @example
|
|
146
|
+
* ```ts
|
|
147
|
+
* import { analyzeVoice, makeChord, majorKey } from '@libraz/libcantus';
|
|
148
|
+
* const voice = [
|
|
149
|
+
* { id: 0, pitch: 60, startBeat: 0, durationBeat: 1 },
|
|
150
|
+
* { id: 1, pitch: 62, startBeat: 1, durationBeat: 1 },
|
|
151
|
+
* { id: 2, pitch: 64, startBeat: 2, durationBeat: 1 },
|
|
152
|
+
* ];
|
|
153
|
+
* const cMajor = makeChord(0, 'maj');
|
|
154
|
+
* const labels = analyzeVoice(voice, () => cMajor, majorKey(0), () => []);
|
|
155
|
+
* labels; // one AnalyzedNote per input note, in the same order
|
|
156
|
+
* ```
|
|
157
|
+
* @category Arrangement & Analysis
|
|
100
158
|
*/
|
|
101
159
|
export function analyzeVoice(
|
|
102
160
|
voice: VoiceNote[],
|
|
@@ -111,8 +169,15 @@ export function analyzeVoice(
|
|
|
111
169
|
if (!note) {
|
|
112
170
|
continue;
|
|
113
171
|
}
|
|
114
|
-
const
|
|
115
|
-
const
|
|
172
|
+
const prevNote = i > 0 ? voice[i - 1] : undefined;
|
|
173
|
+
const nextNote = i + 1 < voice.length ? voice[i + 1] : undefined;
|
|
174
|
+
// A neighboring note sharing this note's onset is a simultaneous cluster
|
|
175
|
+
// member, not a melodic predecessor/successor; treating it as one would
|
|
176
|
+
// fabricate passing/suspension figures, so it is dropped here.
|
|
177
|
+
const prev =
|
|
178
|
+
prevNote !== undefined && prevNote.startBeat < note.startBeat - EPS ? prevNote : undefined;
|
|
179
|
+
const next =
|
|
180
|
+
nextNote !== undefined && nextNote.startBeat > note.startBeat + EPS ? nextNote : undefined;
|
|
116
181
|
const chord = chordAtBeat(note.startBeat);
|
|
117
182
|
const labels: TheoryLabel[] = [];
|
|
118
183
|
const member = isChordMember(note.pitch, chord);
|
|
@@ -133,11 +198,12 @@ export function analyzeVoice(
|
|
|
133
198
|
const prevChord = chordAtBeat(prev.startBeat);
|
|
134
199
|
const prepared = prev.pitch === note.pitch && isChordMember(prev.pitch, prevChord);
|
|
135
200
|
const resolves = isStep(next.pitch, note.pitch);
|
|
136
|
-
const
|
|
201
|
+
const others = otherVoicesAtBeat(note.startBeat);
|
|
202
|
+
const verticallyDissonant = others.some((ov) =>
|
|
137
203
|
createsVerticalDissonance(note.pitch, ov.pitch, true),
|
|
138
204
|
);
|
|
139
205
|
if (prepared && verticallyDissonant && resolves) {
|
|
140
|
-
const ic =
|
|
206
|
+
const ic = intervalAboveBass(note.pitch, others, chord);
|
|
141
207
|
const sus = suspensionType(ic, next.pitch - note.pitch);
|
|
142
208
|
sus.resolveTo = next.pitch;
|
|
143
209
|
labels.push(sus);
|
package/src/arrange/index.ts
CHANGED
|
@@ -29,17 +29,29 @@ import type { KeyScale, NoteEvent } from '../types.js';
|
|
|
29
29
|
/** Float tolerance for beat boundary comparisons. */
|
|
30
30
|
const EPS = 1e-9;
|
|
31
31
|
|
|
32
|
-
/**
|
|
32
|
+
/**
|
|
33
|
+
* The musical role a track plays in the arrangement.
|
|
34
|
+
*
|
|
35
|
+
* @category Arrangement & Analysis
|
|
36
|
+
*/
|
|
33
37
|
export type TrackRole = 'melody' | 'harmony' | 'bass' | 'other';
|
|
34
38
|
|
|
35
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* One input track: its notes plus optional name and role.
|
|
41
|
+
*
|
|
42
|
+
* @category Arrangement & Analysis
|
|
43
|
+
*/
|
|
36
44
|
export type ArrangementTrack = {
|
|
37
45
|
name?: string;
|
|
38
46
|
role?: TrackRole;
|
|
39
47
|
notes: NoteEvent[];
|
|
40
48
|
};
|
|
41
49
|
|
|
42
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* A track after analysis: its resolved name, role, and per-note annotations.
|
|
52
|
+
*
|
|
53
|
+
* @category Arrangement & Analysis
|
|
54
|
+
*/
|
|
43
55
|
export type TrackAnalysis = {
|
|
44
56
|
name: string;
|
|
45
57
|
role: TrackRole;
|
|
@@ -47,7 +59,11 @@ export type TrackAnalysis = {
|
|
|
47
59
|
notes: AnalyzedNote[];
|
|
48
60
|
};
|
|
49
61
|
|
|
50
|
-
/**
|
|
62
|
+
/**
|
|
63
|
+
* A note that clashes with the harmony sounding beneath it.
|
|
64
|
+
*
|
|
65
|
+
* @category Arrangement & Analysis
|
|
66
|
+
*/
|
|
51
67
|
export type Conflict = {
|
|
52
68
|
beat: number;
|
|
53
69
|
trackName: string;
|
|
@@ -59,7 +75,11 @@ export type Conflict = {
|
|
|
59
75
|
rationale?: string;
|
|
60
76
|
};
|
|
61
77
|
|
|
62
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* The full result of {@link analyzeArrangement}.
|
|
80
|
+
*
|
|
81
|
+
* @category Arrangement & Analysis
|
|
82
|
+
*/
|
|
63
83
|
export type ArrangementAnalysis = {
|
|
64
84
|
key: KeyScale;
|
|
65
85
|
timeline: ChordTimeline;
|
|
@@ -70,19 +90,35 @@ export type ArrangementAnalysis = {
|
|
|
70
90
|
conflicts: Conflict[];
|
|
71
91
|
};
|
|
72
92
|
|
|
73
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Options controlling {@link analyzeArrangement} and {@link tensionCurve}.
|
|
95
|
+
*
|
|
96
|
+
* @category Arrangement & Analysis
|
|
97
|
+
*/
|
|
74
98
|
export type ArrangementOptions = {
|
|
75
99
|
/** Key context; inferred from the pooled notes when omitted. */
|
|
76
100
|
key?: KeyScale;
|
|
77
|
-
/**
|
|
101
|
+
/**
|
|
102
|
+
* Time signature; defaults to 4/4.
|
|
103
|
+
*
|
|
104
|
+
* @defaultValue `4/4`
|
|
105
|
+
*/
|
|
78
106
|
ts?: TimeSignature;
|
|
79
|
-
/**
|
|
107
|
+
/**
|
|
108
|
+
* Chord-slot length in beats; defaults to one bar of `ts`.
|
|
109
|
+
*
|
|
110
|
+
* @defaultValue one bar of `ts`
|
|
111
|
+
*/
|
|
80
112
|
harmonicRhythm?: number;
|
|
81
|
-
/**
|
|
113
|
+
/**
|
|
114
|
+
* Safety profile used for conflict detection; defaults to `pop`.
|
|
115
|
+
*
|
|
116
|
+
* @defaultValue `'pop'`
|
|
117
|
+
*/
|
|
82
118
|
profile?: SafetyProfile;
|
|
83
119
|
};
|
|
84
120
|
|
|
85
|
-
/** A note with the pitch that immediately preceded it in the same
|
|
121
|
+
/** A note with the pitch that immediately preceded it in the same sub-voice. */
|
|
86
122
|
type PreparedNote = {
|
|
87
123
|
pitch: number;
|
|
88
124
|
prevPitch?: number;
|
|
@@ -90,16 +126,27 @@ type PreparedNote = {
|
|
|
90
126
|
endBeat: number;
|
|
91
127
|
};
|
|
92
128
|
|
|
93
|
-
/**
|
|
94
|
-
type
|
|
95
|
-
|
|
96
|
-
role: TrackRole;
|
|
97
|
-
/** The track's notes in time order, each with a stable id. */
|
|
129
|
+
/** One monophonic sub-voice of a track: ordered notes plus sounding spans. */
|
|
130
|
+
type PreparedVoice = {
|
|
131
|
+
/** The sub-voice's notes in time order, each with a stable id. */
|
|
98
132
|
voice: VoiceNote[];
|
|
99
133
|
/** The same notes as sounding spans, each carrying its predecessor's pitch. */
|
|
100
134
|
sounding: PreparedNote[];
|
|
101
135
|
};
|
|
102
136
|
|
|
137
|
+
/** A track prepared for analysis: resolved metadata plus monophonic sub-voices. */
|
|
138
|
+
type PreparedTrack = {
|
|
139
|
+
name: string;
|
|
140
|
+
role: TrackRole;
|
|
141
|
+
/**
|
|
142
|
+
* The track's notes partitioned into monophonic sub-voices. Voice analysis
|
|
143
|
+
* assumes one note at a time, so a polyphonic track (e.g. a 'harmony' track
|
|
144
|
+
* playing block chords) is split here; a monophonic track yields exactly one
|
|
145
|
+
* sub-voice.
|
|
146
|
+
*/
|
|
147
|
+
voices: PreparedVoice[];
|
|
148
|
+
};
|
|
149
|
+
|
|
103
150
|
/** Reduce a pitch to a pitch class in [0, 11]. */
|
|
104
151
|
function pitchClass(pitch: number): number {
|
|
105
152
|
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
@@ -110,91 +157,179 @@ function covers(note: PreparedNote, beat: number): boolean {
|
|
|
110
157
|
return beat >= note.startBeat - EPS && beat < note.endBeat - EPS;
|
|
111
158
|
}
|
|
112
159
|
|
|
113
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* Every sounding note of every track pooled into one flat list. Zero- and
|
|
162
|
+
* negative-length notes never sound, so they are dropped here (matching the
|
|
163
|
+
* chord-inference ingest in the timeline module).
|
|
164
|
+
*/
|
|
114
165
|
function poolNotes(tracks: ArrangementTrack[]): NoteEvent[] {
|
|
115
166
|
const notes: NoteEvent[] = [];
|
|
116
167
|
for (const track of tracks) {
|
|
117
168
|
for (const note of track.notes) {
|
|
118
|
-
|
|
169
|
+
if (note.durationBeat > 0) {
|
|
170
|
+
notes.push(note);
|
|
171
|
+
}
|
|
119
172
|
}
|
|
120
173
|
}
|
|
121
174
|
return notes;
|
|
122
175
|
}
|
|
123
176
|
|
|
124
177
|
/**
|
|
125
|
-
*
|
|
178
|
+
* Split a track's ordered notes into monophonic sub-voices.
|
|
126
179
|
*
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
180
|
+
* Greedy lane assignment: each note joins the first sub-voice that is free at
|
|
181
|
+
* its onset (its last note has ended), otherwise it opens a new sub-voice. A
|
|
182
|
+
* monophonic track therefore stays a single sub-voice, while block chords fan
|
|
183
|
+
* out into one sub-voice per simultaneous note, keeping every sub-voice's
|
|
184
|
+
* note-to-note adjacency genuinely melodic.
|
|
185
|
+
*/
|
|
186
|
+
function splitIntoSubVoices(ordered: VoiceNote[]): VoiceNote[][] {
|
|
187
|
+
const lanes: VoiceNote[][] = [];
|
|
188
|
+
for (const note of ordered) {
|
|
189
|
+
const lane = lanes.find((candidate) => {
|
|
190
|
+
const last = candidate[candidate.length - 1];
|
|
191
|
+
return last !== undefined && last.startBeat + last.durationBeat <= note.startBeat + EPS;
|
|
192
|
+
});
|
|
193
|
+
if (lane) {
|
|
194
|
+
lane.push(note);
|
|
195
|
+
} else {
|
|
196
|
+
lanes.push([note]);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return lanes;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Resolve each track's metadata and derive its monophonic sub-voices.
|
|
204
|
+
*
|
|
205
|
+
* Notes are sorted by onset, then pitch, so ordering is deterministic, and ids
|
|
206
|
+
* are assigned in that order. Zero- and negative-length notes never sound and
|
|
207
|
+
* are dropped at ingest. Each track is then partitioned into monophonic
|
|
208
|
+
* sub-voices (see {@link splitIntoSubVoices}); the predecessor pitch used for
|
|
209
|
+
* suspension and parallel detection is the previous note of the same sub-voice,
|
|
210
|
+
* never a simultaneous chord member.
|
|
130
211
|
*/
|
|
131
212
|
function prepareTracks(tracks: ArrangementTrack[]): PreparedTrack[] {
|
|
132
213
|
let nextId = 0;
|
|
133
214
|
return tracks.map((track, index) => {
|
|
134
|
-
const ordered =
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
215
|
+
const ordered: VoiceNote[] = track.notes
|
|
216
|
+
.filter((note) => note.durationBeat > 0)
|
|
217
|
+
.sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch)
|
|
218
|
+
.map((note) => ({ ...note, id: nextId++ }));
|
|
219
|
+
const voices: PreparedVoice[] = splitIntoSubVoices(ordered).map((voice) => ({
|
|
220
|
+
voice,
|
|
221
|
+
sounding: voice.map((note, i) => {
|
|
222
|
+
const prev = i > 0 ? voice[i - 1] : undefined;
|
|
223
|
+
return {
|
|
224
|
+
pitch: note.pitch,
|
|
225
|
+
prevPitch: prev?.pitch,
|
|
226
|
+
startBeat: note.startBeat,
|
|
227
|
+
endBeat: note.startBeat + note.durationBeat,
|
|
228
|
+
};
|
|
229
|
+
}),
|
|
230
|
+
}));
|
|
145
231
|
return {
|
|
146
232
|
name: track.name ?? `track ${index + 1}`,
|
|
147
233
|
role: track.role ?? 'other',
|
|
148
|
-
|
|
149
|
-
sounding,
|
|
234
|
+
voices,
|
|
150
235
|
};
|
|
151
236
|
});
|
|
152
237
|
}
|
|
153
238
|
|
|
154
|
-
/**
|
|
239
|
+
/**
|
|
240
|
+
* Sounding pitches of every sub-voice at a beat, excluding one sub-voice.
|
|
241
|
+
*
|
|
242
|
+
* Only the sub-voice under analysis is excluded — sibling sub-voices of the
|
|
243
|
+
* same track are included, so dissonant clusters inside a single polyphonic
|
|
244
|
+
* track are still detected.
|
|
245
|
+
*/
|
|
155
246
|
function otherVoicesSounding(
|
|
156
247
|
prepared: PreparedTrack[],
|
|
157
|
-
|
|
248
|
+
excludeTrack: number,
|
|
249
|
+
excludeVoice: number,
|
|
158
250
|
beat: number,
|
|
159
251
|
): VoiceSnapshot[] {
|
|
160
252
|
const out: VoiceSnapshot[] = [];
|
|
161
253
|
for (let t = 0; t < prepared.length; t += 1) {
|
|
162
|
-
if (t === excludeIndex) {
|
|
163
|
-
continue;
|
|
164
|
-
}
|
|
165
254
|
const track = prepared[t];
|
|
166
255
|
if (!track) {
|
|
167
256
|
continue;
|
|
168
257
|
}
|
|
169
|
-
for (
|
|
170
|
-
if (
|
|
258
|
+
for (let v = 0; v < track.voices.length; v += 1) {
|
|
259
|
+
if (t === excludeTrack && v === excludeVoice) {
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const subVoice = track.voices[v];
|
|
263
|
+
if (!subVoice) {
|
|
171
264
|
continue;
|
|
172
265
|
}
|
|
173
|
-
const
|
|
174
|
-
|
|
175
|
-
|
|
266
|
+
for (const note of subVoice.sounding) {
|
|
267
|
+
if (!covers(note, beat)) {
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const snap: VoiceSnapshot = { pitch: note.pitch };
|
|
271
|
+
if (note.prevPitch !== undefined) {
|
|
272
|
+
snap.prevPitch = note.prevPitch;
|
|
273
|
+
}
|
|
274
|
+
out.push(snap);
|
|
176
275
|
}
|
|
177
|
-
out.push(snap);
|
|
178
276
|
}
|
|
179
277
|
}
|
|
180
278
|
return out;
|
|
181
279
|
}
|
|
182
280
|
|
|
281
|
+
/**
|
|
282
|
+
* The beats at which a note must be checked against the harmony: its onset,
|
|
283
|
+
* plus the onset of every later chord segment the note sustains across. Each
|
|
284
|
+
* crossed segment is visited exactly once (at its own start), so a boundary
|
|
285
|
+
* beat is never evaluated twice for the same note.
|
|
286
|
+
*/
|
|
287
|
+
function evaluationBeats(note: VoiceNote, timeline: ChordTimeline): number[] {
|
|
288
|
+
const noteEnd = note.startBeat + note.durationBeat;
|
|
289
|
+
const beats = [note.startBeat];
|
|
290
|
+
for (const segment of timeline.segments) {
|
|
291
|
+
if (segment.startBeat > note.startBeat + EPS && segment.startBeat < noteEnd - EPS) {
|
|
292
|
+
beats.push(segment.startBeat);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return beats;
|
|
296
|
+
}
|
|
297
|
+
|
|
183
298
|
/**
|
|
184
299
|
* Analyse a whole arrangement against a single inferred harmony.
|
|
185
300
|
*
|
|
186
301
|
* The chord timeline and key are inferred from the pooled notes of every track
|
|
187
302
|
* (see {@link chordTimelineFromNotes}); pooling all voices is robust even when
|
|
188
303
|
* roles are absent or a track doubles the harmony, so it is preferred over
|
|
189
|
-
* deriving the harmony from a subset.
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
304
|
+
* deriving the harmony from a subset. Zero- and negative-length notes never
|
|
305
|
+
* sound, so they are dropped at ingest and appear in neither the annotations
|
|
306
|
+
* nor the conflicts.
|
|
307
|
+
*
|
|
308
|
+
* Voice analysis assumes one monophonic voice at a time, so each track is
|
|
309
|
+
* first partitioned into monophonic sub-voices (a polyphonic block-chord track
|
|
310
|
+
* fans out into one sub-voice per simultaneous note; a monophonic track is
|
|
311
|
+
* unaffected). Each sub-voice is labelled note-by-note against the harmony
|
|
312
|
+
* with {@link analyzeVoice}, seeing every other sub-voice — including siblings
|
|
313
|
+
* within the same track — as its accompaniment. Every note is then re-checked
|
|
314
|
+
* with {@link evaluateSafety} at its onset and again at each chord change it
|
|
315
|
+
* sustains across, so a held note that clashes with a later chord is caught:
|
|
316
|
+
* evaluations that are not {@link NoteSafety.Safe} become conflicts, one per
|
|
317
|
+
* clashing beat, sorted worst severity first and then by beat.
|
|
194
318
|
*
|
|
195
319
|
* @param tracks The tracks to analyse.
|
|
196
320
|
* @param opts Analysis options; see {@link ArrangementOptions}.
|
|
197
321
|
* @returns The inferred harmony, per-track annotations, cadences, and conflicts.
|
|
322
|
+
* @example
|
|
323
|
+
* ```ts
|
|
324
|
+
* import { analyzeArrangement } from '@libraz/libcantus';
|
|
325
|
+
* const melody = [
|
|
326
|
+
* { pitch: 60, startBeat: 0, durationBeat: 2 },
|
|
327
|
+
* { pitch: 67, startBeat: 2, durationBeat: 2 },
|
|
328
|
+
* ];
|
|
329
|
+
* const { key, conflicts } = analyzeArrangement([{ role: 'melody', notes: melody }]);
|
|
330
|
+
* conflicts; // notes clashing with the inferred harmony, worst severity first
|
|
331
|
+
* ```
|
|
332
|
+
* @category Arrangement & Analysis
|
|
198
333
|
*/
|
|
199
334
|
export function analyzeArrangement(
|
|
200
335
|
tracks: ArrangementTrack[],
|
|
@@ -220,34 +355,47 @@ export function analyzeArrangement(
|
|
|
220
355
|
if (!track) {
|
|
221
356
|
continue;
|
|
222
357
|
}
|
|
223
|
-
const notes =
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
358
|
+
const notes: AnalyzedNote[] = [];
|
|
359
|
+
for (let v = 0; v < track.voices.length; v += 1) {
|
|
360
|
+
const subVoice = track.voices[v];
|
|
361
|
+
if (!subVoice) {
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
notes.push(
|
|
365
|
+
...analyzeVoice(subVoice.voice, timeline.at, key, (beat) =>
|
|
366
|
+
otherVoicesSounding(prepared, t, v, beat),
|
|
367
|
+
),
|
|
368
|
+
);
|
|
369
|
+
|
|
370
|
+
for (const note of subVoice.voice) {
|
|
371
|
+
for (const beat of evaluationBeats(note, timeline)) {
|
|
372
|
+
const result = evaluateSafety({
|
|
373
|
+
profile,
|
|
374
|
+
candidatePitch: note.pitch,
|
|
375
|
+
chord: timeline.at(beat),
|
|
376
|
+
key,
|
|
377
|
+
otherVoices: otherVoicesSounding(prepared, t, v, beat),
|
|
378
|
+
strongBeat: isStrongBeat(beat, ts),
|
|
379
|
+
});
|
|
380
|
+
if (result.safety !== NoteSafety.Safe) {
|
|
381
|
+
const conflict: Conflict = {
|
|
382
|
+
beat,
|
|
383
|
+
trackName: track.name,
|
|
384
|
+
pitch: note.pitch,
|
|
385
|
+
safety: result.safety,
|
|
386
|
+
reasons: result.reasons,
|
|
387
|
+
};
|
|
388
|
+
if (result.rationale !== undefined) {
|
|
389
|
+
conflict.rationale = result.rationale;
|
|
390
|
+
}
|
|
391
|
+
conflicts.push(conflict);
|
|
392
|
+
}
|
|
247
393
|
}
|
|
248
|
-
conflicts.push(conflict);
|
|
249
394
|
}
|
|
250
395
|
}
|
|
396
|
+
// Sub-voice results interleave; restore the track's onset-then-pitch order.
|
|
397
|
+
notes.sort((a, b) => a.noteId - b.noteId);
|
|
398
|
+
trackAnalyses.push({ name: track.name, role: track.role, notes });
|
|
251
399
|
}
|
|
252
400
|
|
|
253
401
|
conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
|
|
@@ -255,7 +403,11 @@ export function analyzeArrangement(
|
|
|
255
403
|
return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
|
|
256
404
|
}
|
|
257
405
|
|
|
258
|
-
/**
|
|
406
|
+
/**
|
|
407
|
+
* A tension reading sampled at a beat.
|
|
408
|
+
*
|
|
409
|
+
* @category Arrangement & Analysis
|
|
410
|
+
*/
|
|
259
411
|
export type TensionPoint = {
|
|
260
412
|
beat: number;
|
|
261
413
|
/** Combined tension in [0, 1]. */
|
|
@@ -302,6 +454,14 @@ function clamp01(value: number): number {
|
|
|
302
454
|
* @param opts Analysis options plus an optional `step` (default one beat).
|
|
303
455
|
* @returns One {@link TensionPoint} per sampled beat, in beat order.
|
|
304
456
|
* @throws If `step` is not positive.
|
|
457
|
+
* @example
|
|
458
|
+
* ```ts
|
|
459
|
+
* import { tensionCurve } from '@libraz/libcantus';
|
|
460
|
+
* const notes = [{ pitch: 60, startBeat: 0, durationBeat: 4 }];
|
|
461
|
+
* const curve = tensionCurve([{ notes }], { step: 1 });
|
|
462
|
+
* curve; // one { beat, tension } sample per beat, in beat order
|
|
463
|
+
* ```
|
|
464
|
+
* @category Arrangement & Analysis
|
|
305
465
|
*/
|
|
306
466
|
export function tensionCurve(
|
|
307
467
|
tracks: ArrangementTrack[],
|
|
@@ -340,9 +500,11 @@ function allSounding(prepared: PreparedTrack[], beat: number): { pitch: number;
|
|
|
340
500
|
if (!track) {
|
|
341
501
|
continue;
|
|
342
502
|
}
|
|
343
|
-
for (const
|
|
344
|
-
|
|
345
|
-
|
|
503
|
+
for (const subVoice of track.voices) {
|
|
504
|
+
for (const note of subVoice.sounding) {
|
|
505
|
+
if (covers(note, beat)) {
|
|
506
|
+
out.push({ pitch: note.pitch, track: t });
|
|
507
|
+
}
|
|
346
508
|
}
|
|
347
509
|
}
|
|
348
510
|
}
|