@libraz/libcantus 0.9.1 → 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 +15 -0
- 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 +3016 -2984
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +14 -3982
- package/dist/index.d.ts +14 -3982
- package/dist/index.js +170 -6504
- 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 +27 -2
- 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 +4 -4
- 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} +9 -327
- package/src/analyze/index.ts +49 -0
- package/src/{timeline → analyze/timeline}/index.ts +7 -11
- package/src/{analysis → analyze/voice}/index.ts +8 -5
- package/src/core/index.ts +47 -0
- package/src/{bass → generate/bass}/index.ts +5 -5
- package/src/{countermelody → generate/countermelody}/index.ts +11 -11
- package/src/{drums → generate/drums}/index.ts +2 -2
- package/src/{drums → generate/drums}/rng.ts +2 -2
- package/src/{groove → generate/groove}/index.ts +3 -3
- package/src/{harmonize → generate/harmonize}/index.ts +13 -13
- package/src/generate/index.ts +59 -0
- package/src/{motif → generate/motif}/index.ts +7 -7
- package/src/{progression → generate/progression}/index.ts +11 -25
- package/src/{reharmony → generate/reharmony}/index.ts +11 -11
- package/src/{rhythm → generate/rhythm}/index.ts +2 -2
- package/src/index.ts +130 -117
- package/src/model/chord.ts +423 -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 +19 -1
- package/src/{counterpoint → theory/counterpoint}/index.ts +2 -2
- package/src/theory/index.ts +107 -0
- package/src/{safety → theory/safety}/index.ts +1 -1
- 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 +3 -3
- 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 +195 -0
- package/src/theory/voicing/styled.ts +176 -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/{meter → core/meter}/index.ts +0 -0
- /package/src/{pitch → core/pitch}/index.ts +0 -0
- /package/src/{random → core/random}/index.ts +0 -0
- /package/src/{tuning → core/tuning}/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}/euclid.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}/percussion.ts +0 -0
- /package/src/{drums → generate/drums}/swing.ts +0 -0
- /package/src/{chordscale → theory/chordscale}/index.ts +0 -0
- /package/src/{harmony → theory/harmony}/index.ts +0 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A key/scale definition anchored on a root pitch class.
|
|
3
|
+
*
|
|
4
|
+
* `modeMask12` is a 12-bit mask where bit `n` set means the pitch class
|
|
5
|
+
* `(rootPc + n) % 12` belongs to the scale. Bit 0 (the root) is always set;
|
|
6
|
+
* {@link maskFromOffsets} enforces this so the root is always a scale tone.
|
|
7
|
+
*/
|
|
8
|
+
type KeyScale = {
|
|
9
|
+
rootPc: number;
|
|
10
|
+
modeMask12: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* A single sounding note on a beat-indexed timeline: an absolute MIDI pitch, an
|
|
14
|
+
* onset and duration measured in quarter-note beats, and an optional MIDI
|
|
15
|
+
* velocity. This is the library-wide interchange shape for note events read
|
|
16
|
+
* from or written to a DAW/MIDI track; the harmonizer's `MelodyNote` and the
|
|
17
|
+
* analysis `VoiceNote` are specializations of it.
|
|
18
|
+
*/
|
|
19
|
+
type NoteEvent = {
|
|
20
|
+
/** MIDI pitch (middle C = 60). */
|
|
21
|
+
pitch: number;
|
|
22
|
+
/** Onset in quarter-note beats, absolute from the start of the timeline. */
|
|
23
|
+
startBeat: number;
|
|
24
|
+
/** Duration in quarter-note beats. */
|
|
25
|
+
durationBeat: number;
|
|
26
|
+
/** MIDI velocity in [0, 127], when known. */
|
|
27
|
+
velocity?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type { KeyScale as K, NoteEvent as N };
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A key/scale definition anchored on a root pitch class.
|
|
3
|
+
*
|
|
4
|
+
* `modeMask12` is a 12-bit mask where bit `n` set means the pitch class
|
|
5
|
+
* `(rootPc + n) % 12` belongs to the scale. Bit 0 (the root) is always set;
|
|
6
|
+
* {@link maskFromOffsets} enforces this so the root is always a scale tone.
|
|
7
|
+
*/
|
|
8
|
+
type KeyScale = {
|
|
9
|
+
rootPc: number;
|
|
10
|
+
modeMask12: number;
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* A single sounding note on a beat-indexed timeline: an absolute MIDI pitch, an
|
|
14
|
+
* onset and duration measured in quarter-note beats, and an optional MIDI
|
|
15
|
+
* velocity. This is the library-wide interchange shape for note events read
|
|
16
|
+
* from or written to a DAW/MIDI track; the harmonizer's `MelodyNote` and the
|
|
17
|
+
* analysis `VoiceNote` are specializations of it.
|
|
18
|
+
*/
|
|
19
|
+
type NoteEvent = {
|
|
20
|
+
/** MIDI pitch (middle C = 60). */
|
|
21
|
+
pitch: number;
|
|
22
|
+
/** Onset in quarter-note beats, absolute from the start of the timeline. */
|
|
23
|
+
startBeat: number;
|
|
24
|
+
/** Duration in quarter-note beats. */
|
|
25
|
+
durationBeat: number;
|
|
26
|
+
/** MIDI velocity in [0, 127], when known. */
|
|
27
|
+
velocity?: number;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type { KeyScale as K, NoteEvent as N };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@libraz/libcantus",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2",
|
|
4
4
|
"description": "Pure-TypeScript music-theory and composition primitives (intervals, scales, chords).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -38,6 +38,31 @@
|
|
|
38
38
|
"types": "./dist/index.d.ts",
|
|
39
39
|
"import": "./dist/index.js",
|
|
40
40
|
"require": "./dist/index.cjs"
|
|
41
|
+
},
|
|
42
|
+
"./core": {
|
|
43
|
+
"types": "./dist/core/index.d.ts",
|
|
44
|
+
"import": "./dist/core/index.js",
|
|
45
|
+
"require": "./dist/core/index.cjs"
|
|
46
|
+
},
|
|
47
|
+
"./theory": {
|
|
48
|
+
"types": "./dist/theory/index.d.ts",
|
|
49
|
+
"import": "./dist/theory/index.js",
|
|
50
|
+
"require": "./dist/theory/index.cjs"
|
|
51
|
+
},
|
|
52
|
+
"./analyze": {
|
|
53
|
+
"types": "./dist/analyze/index.d.ts",
|
|
54
|
+
"import": "./dist/analyze/index.js",
|
|
55
|
+
"require": "./dist/analyze/index.cjs"
|
|
56
|
+
},
|
|
57
|
+
"./generate": {
|
|
58
|
+
"types": "./dist/generate/index.d.ts",
|
|
59
|
+
"import": "./dist/generate/index.js",
|
|
60
|
+
"require": "./dist/generate/index.cjs"
|
|
61
|
+
},
|
|
62
|
+
"./model": {
|
|
63
|
+
"types": "./dist/model/index.d.ts",
|
|
64
|
+
"import": "./dist/model/index.js",
|
|
65
|
+
"require": "./dist/model/index.cjs"
|
|
41
66
|
}
|
|
42
67
|
},
|
|
43
68
|
"files": [
|
|
@@ -64,10 +89,10 @@
|
|
|
64
89
|
},
|
|
65
90
|
"devDependencies": {
|
|
66
91
|
"@biomejs/biome": "2.5.2",
|
|
92
|
+
"@libraz/typedoc-theme": "^0.1.1",
|
|
67
93
|
"@vitest/coverage-v8": "4.1.9",
|
|
68
94
|
"tsup": "8.5.1",
|
|
69
95
|
"typedoc": "0.28.20",
|
|
70
|
-
"typedoc-material-theme": "1.4.1",
|
|
71
96
|
"typedoc-plugin-extras": "4.0.1",
|
|
72
97
|
"typedoc-plugin-mdn-links": "5.1.1",
|
|
73
98
|
"typescript": "6.0.3",
|
|
@@ -0,0 +1,21 @@
|
|
|
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
|
+
export type { TensionPoint } from './tension.js';
|
|
12
|
+
export { tensionCurve } from './tension.js';
|
|
13
|
+
export type {
|
|
14
|
+
ArrangementAnalysis,
|
|
15
|
+
ArrangementOptions,
|
|
16
|
+
ArrangementTrack,
|
|
17
|
+
Conflict,
|
|
18
|
+
TrackAnalysis,
|
|
19
|
+
TrackRole,
|
|
20
|
+
} from './tracks.js';
|
|
21
|
+
export { analyzeArrangement } from './tracks.js';
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared helpers for the arrangement-analysis modules: pooling every track's
|
|
3
|
+
* sounding notes and partitioning a track into monophonic sub-voices, plus the
|
|
4
|
+
* small geometric primitives both the track-role and tension passes rely on.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { NoteEvent } from '../../core/types.js';
|
|
8
|
+
import type { VoiceNote } from '../voice/index.js';
|
|
9
|
+
import type { ArrangementTrack, TrackRole } from './tracks.js';
|
|
10
|
+
|
|
11
|
+
/** Float tolerance for beat boundary comparisons. */
|
|
12
|
+
export const EPS = 1e-9;
|
|
13
|
+
|
|
14
|
+
/** A note with the pitch that immediately preceded it in the same sub-voice. */
|
|
15
|
+
export type PreparedNote = {
|
|
16
|
+
pitch: number;
|
|
17
|
+
prevPitch?: number;
|
|
18
|
+
startBeat: number;
|
|
19
|
+
endBeat: number;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** One monophonic sub-voice of a track: ordered notes plus sounding spans. */
|
|
23
|
+
export type PreparedVoice = {
|
|
24
|
+
/** The sub-voice's notes in time order, each with a stable id. */
|
|
25
|
+
voice: VoiceNote[];
|
|
26
|
+
/** The same notes as sounding spans, each carrying its predecessor's pitch. */
|
|
27
|
+
sounding: PreparedNote[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** A track prepared for analysis: resolved metadata plus monophonic sub-voices. */
|
|
31
|
+
export type PreparedTrack = {
|
|
32
|
+
name: string;
|
|
33
|
+
role: TrackRole;
|
|
34
|
+
/**
|
|
35
|
+
* The track's notes partitioned into monophonic sub-voices. Voice analysis
|
|
36
|
+
* assumes one note at a time, so a polyphonic track (e.g. a 'harmony' track
|
|
37
|
+
* playing block chords) is split here; a monophonic track yields exactly one
|
|
38
|
+
* sub-voice.
|
|
39
|
+
*/
|
|
40
|
+
voices: PreparedVoice[];
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** Whether a sounding span `[start, end)` covers a beat. */
|
|
44
|
+
export function covers(note: PreparedNote, beat: number): boolean {
|
|
45
|
+
return beat >= note.startBeat - EPS && beat < note.endBeat - EPS;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Every sounding note of every track pooled into one flat list. Zero- and
|
|
50
|
+
* negative-length notes never sound, so they are dropped here (matching the
|
|
51
|
+
* chord-inference ingest in the timeline module).
|
|
52
|
+
*/
|
|
53
|
+
export function poolNotes(tracks: ArrangementTrack[]): NoteEvent[] {
|
|
54
|
+
const notes: NoteEvent[] = [];
|
|
55
|
+
for (const track of tracks) {
|
|
56
|
+
for (const note of track.notes) {
|
|
57
|
+
if (note.durationBeat > 0) {
|
|
58
|
+
notes.push(note);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return notes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Split a track's ordered notes into monophonic sub-voices.
|
|
67
|
+
*
|
|
68
|
+
* Greedy lane assignment: each note joins the first sub-voice that is free at
|
|
69
|
+
* its onset (its last note has ended), otherwise it opens a new sub-voice. A
|
|
70
|
+
* monophonic track therefore stays a single sub-voice, while block chords fan
|
|
71
|
+
* out into one sub-voice per simultaneous note, keeping every sub-voice's
|
|
72
|
+
* note-to-note adjacency genuinely melodic.
|
|
73
|
+
*/
|
|
74
|
+
function splitIntoSubVoices(ordered: VoiceNote[]): VoiceNote[][] {
|
|
75
|
+
const lanes: VoiceNote[][] = [];
|
|
76
|
+
for (const note of ordered) {
|
|
77
|
+
const lane = lanes.find((candidate) => {
|
|
78
|
+
const last = candidate[candidate.length - 1];
|
|
79
|
+
return last !== undefined && last.startBeat + last.durationBeat <= note.startBeat + EPS;
|
|
80
|
+
});
|
|
81
|
+
if (lane) {
|
|
82
|
+
lane.push(note);
|
|
83
|
+
} else {
|
|
84
|
+
lanes.push([note]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return lanes;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Resolve each track's metadata and derive its monophonic sub-voices.
|
|
92
|
+
*
|
|
93
|
+
* Notes are sorted by onset, then pitch, so ordering is deterministic, and ids
|
|
94
|
+
* are assigned in that order. Zero- and negative-length notes never sound and
|
|
95
|
+
* are dropped at ingest. Each track is then partitioned into monophonic
|
|
96
|
+
* sub-voices (see {@link splitIntoSubVoices}); the predecessor pitch used for
|
|
97
|
+
* suspension and parallel detection is the previous note of the same sub-voice,
|
|
98
|
+
* never a simultaneous chord member.
|
|
99
|
+
*/
|
|
100
|
+
export function prepareTracks(tracks: ArrangementTrack[]): PreparedTrack[] {
|
|
101
|
+
let nextId = 0;
|
|
102
|
+
return tracks.map((track, index) => {
|
|
103
|
+
const ordered: VoiceNote[] = track.notes
|
|
104
|
+
.filter((note) => note.durationBeat > 0)
|
|
105
|
+
.sort((a, b) => a.startBeat - b.startBeat || a.pitch - b.pitch)
|
|
106
|
+
.map((note) => ({ ...note, id: nextId++ }));
|
|
107
|
+
const voices: PreparedVoice[] = splitIntoSubVoices(ordered).map((voice) => ({
|
|
108
|
+
voice,
|
|
109
|
+
sounding: voice.map((note, i) => {
|
|
110
|
+
const prev = i > 0 ? voice[i - 1] : undefined;
|
|
111
|
+
return {
|
|
112
|
+
pitch: note.pitch,
|
|
113
|
+
prevPitch: prev?.pitch,
|
|
114
|
+
startBeat: note.startBeat,
|
|
115
|
+
endBeat: note.startBeat + note.durationBeat,
|
|
116
|
+
};
|
|
117
|
+
}),
|
|
118
|
+
}));
|
|
119
|
+
return {
|
|
120
|
+
name: track.name ?? `track ${index + 1}`,
|
|
121
|
+
role: track.role ?? 'other',
|
|
122
|
+
voices,
|
|
123
|
+
};
|
|
124
|
+
});
|
|
125
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Arrangement tension analysis: a coarse harmonic-tension curve sampled at
|
|
3
|
+
* regular beats across a whole multi-track piece, combining the sounding
|
|
4
|
+
* chord's harmonic function, the dissonance of the sounding notes, and their
|
|
5
|
+
* registral span into a single normalized reading.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { isStrongBeat, parseTimeSignature, type TimeSignature } from '../../core/meter/index.js';
|
|
9
|
+
import type { KeyScale } from '../../core/types.js';
|
|
10
|
+
import { chordPitchClasses } from '../../theory/chord/index.js';
|
|
11
|
+
import { evaluateSafety, NoteSafety, type SafetyProfile } from '../../theory/safety/index.js';
|
|
12
|
+
import { functionOf } from '../functional/index.js';
|
|
13
|
+
import { type ChordTimeline, chordTimelineFromNotes } from '../timeline/index.js';
|
|
14
|
+
import { covers, EPS, type PreparedTrack, poolNotes, prepareTracks } from './internal.js';
|
|
15
|
+
import type { ArrangementOptions, ArrangementTrack } from './tracks.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A tension reading sampled at a beat.
|
|
19
|
+
*
|
|
20
|
+
* @category Arrangement & Analysis
|
|
21
|
+
*/
|
|
22
|
+
export type TensionPoint = {
|
|
23
|
+
beat: number;
|
|
24
|
+
/** Combined tension in [0, 1]. */
|
|
25
|
+
tension: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** Tension contributed by the sounding chord's harmonic function. */
|
|
29
|
+
const FUNCTION_TENSION: Record<'tonic' | 'subdominant' | 'dominant', number> = {
|
|
30
|
+
tonic: 0,
|
|
31
|
+
subdominant: 0.5,
|
|
32
|
+
dominant: 1,
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/** Weight of the harmonic-function term in the combined tension score. */
|
|
36
|
+
const FUNCTION_WEIGHT = 0.5;
|
|
37
|
+
/** Weight of the non-chord-tone / dissonance term. */
|
|
38
|
+
const DISSONANCE_WEIGHT = 0.35;
|
|
39
|
+
/** Weight of the registral-span term. */
|
|
40
|
+
const SPAN_WEIGHT = 0.15;
|
|
41
|
+
/** Pitch span, in semitones, that saturates the span term. */
|
|
42
|
+
const SPAN_SATURATION = 24;
|
|
43
|
+
|
|
44
|
+
/** Reduce a pitch to a pitch class in [0, 11]. */
|
|
45
|
+
function pitchClass(pitch: number): number {
|
|
46
|
+
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Clamp a value into [0, 1]. */
|
|
50
|
+
function clamp01(value: number): number {
|
|
51
|
+
return Math.min(1, Math.max(0, value));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Sample the harmonic tension of an arrangement at regular beats.
|
|
56
|
+
*
|
|
57
|
+
* At each sample beat the tension combines three normalized terms:
|
|
58
|
+
* - the sounding chord's harmonic function (dominant `1`, subdominant `0.5`,
|
|
59
|
+
* tonic or no chord `0`), weighted {@link FUNCTION_WEIGHT};
|
|
60
|
+
* - the dissonance of the sounding notes — the larger of the share of sounding
|
|
61
|
+
* pitches that are not chord tones and the share that {@link evaluateSafety}
|
|
62
|
+
* rates {@link NoteSafety.Dissonant} — weighted {@link DISSONANCE_WEIGHT};
|
|
63
|
+
* - the registral span of the sounding notes, saturating at
|
|
64
|
+
* {@link SPAN_SATURATION} semitones, weighted {@link SPAN_WEIGHT}.
|
|
65
|
+
*
|
|
66
|
+
* The weighted sum is clamped to [0, 1]. The harmony is inferred from all tracks
|
|
67
|
+
* pooled together, so the result is deterministic and self-contained.
|
|
68
|
+
*
|
|
69
|
+
* @param tracks The tracks to sample.
|
|
70
|
+
* @param opts Analysis options plus an optional `step` (default one beat).
|
|
71
|
+
* @returns One {@link TensionPoint} per sampled beat, in beat order.
|
|
72
|
+
* @throws If `step` is not positive.
|
|
73
|
+
* @example
|
|
74
|
+
* ```ts
|
|
75
|
+
* import { tensionCurve } from '@libraz/libcantus';
|
|
76
|
+
* const notes = [{ pitch: 60, startBeat: 0, durationBeat: 4 }];
|
|
77
|
+
* const curve = tensionCurve([{ notes }], { step: 1 });
|
|
78
|
+
* curve; // one { beat, tension } sample per beat, in beat order
|
|
79
|
+
* ```
|
|
80
|
+
* @category Arrangement & Analysis
|
|
81
|
+
*/
|
|
82
|
+
export function tensionCurve(
|
|
83
|
+
tracks: ArrangementTrack[],
|
|
84
|
+
opts: ArrangementOptions & { step?: number } = {},
|
|
85
|
+
): TensionPoint[] {
|
|
86
|
+
const ts = opts.ts ?? parseTimeSignature('4/4');
|
|
87
|
+
const profile: SafetyProfile = opts.profile ?? 'pop';
|
|
88
|
+
const step = opts.step ?? 1;
|
|
89
|
+
if (!(step > 0)) {
|
|
90
|
+
throw new Error(`Invalid tension sampling step: ${step}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const pooled = poolNotes(tracks);
|
|
94
|
+
const totalBeats = pooled.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
|
|
95
|
+
const { timeline, key } = chordTimelineFromNotes(pooled, {
|
|
96
|
+
key: opts.key,
|
|
97
|
+
ts,
|
|
98
|
+
harmonicRhythm: opts.harmonicRhythm,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const prepared = prepareTracks(tracks);
|
|
102
|
+
const points: TensionPoint[] = [];
|
|
103
|
+
const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS));
|
|
104
|
+
for (let i = 0; i < sampleCount; i += 1) {
|
|
105
|
+
const beat = i * step;
|
|
106
|
+
points.push({ beat, tension: sampleTension(prepared, timeline, key, ts, profile, beat) });
|
|
107
|
+
}
|
|
108
|
+
return points;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** All sounding notes across every track at a beat, tagged with their track. */
|
|
112
|
+
function allSounding(prepared: PreparedTrack[], beat: number): { pitch: number; track: number }[] {
|
|
113
|
+
const out: { pitch: number; track: number }[] = [];
|
|
114
|
+
for (let t = 0; t < prepared.length; t += 1) {
|
|
115
|
+
const track = prepared[t];
|
|
116
|
+
if (!track) {
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
for (const subVoice of track.voices) {
|
|
120
|
+
for (const note of subVoice.sounding) {
|
|
121
|
+
if (covers(note, beat)) {
|
|
122
|
+
out.push({ pitch: note.pitch, track: t });
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return out;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Combine the harmonic, dissonance, and span terms at one sample beat. */
|
|
131
|
+
function sampleTension(
|
|
132
|
+
prepared: PreparedTrack[],
|
|
133
|
+
timeline: ChordTimeline,
|
|
134
|
+
key: KeyScale,
|
|
135
|
+
ts: TimeSignature,
|
|
136
|
+
profile: SafetyProfile,
|
|
137
|
+
beat: number,
|
|
138
|
+
): number {
|
|
139
|
+
const sounding = allSounding(prepared, beat);
|
|
140
|
+
if (sounding.length === 0) {
|
|
141
|
+
return 0;
|
|
142
|
+
}
|
|
143
|
+
const chord = timeline.at(beat);
|
|
144
|
+
const functionScore = chord ? FUNCTION_TENSION[functionOf(chord, key)] : 0;
|
|
145
|
+
|
|
146
|
+
const chordPcs = chord ? new Set(chordPitchClasses(chord)) : null;
|
|
147
|
+
const strongBeat = isStrongBeat(beat, ts);
|
|
148
|
+
let nonChord = 0;
|
|
149
|
+
let dissonant = 0;
|
|
150
|
+
for (let i = 0; i < sounding.length; i += 1) {
|
|
151
|
+
const voice = sounding[i];
|
|
152
|
+
if (!voice) {
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
// Non-chord-tone share only applies when a chord is sounding; at a timeline
|
|
156
|
+
// gap there is no reference harmony, so vertical dissonance alone drives the
|
|
157
|
+
// dissonance term (the chord function term is already 0 there).
|
|
158
|
+
if (chordPcs && !chordPcs.has(pitchClass(voice.pitch))) {
|
|
159
|
+
nonChord += 1;
|
|
160
|
+
}
|
|
161
|
+
const others = sounding.filter((_, j) => j !== i).map((s) => ({ pitch: s.pitch }));
|
|
162
|
+
const result = evaluateSafety({
|
|
163
|
+
profile,
|
|
164
|
+
candidatePitch: voice.pitch,
|
|
165
|
+
chord,
|
|
166
|
+
key,
|
|
167
|
+
otherVoices: others,
|
|
168
|
+
strongBeat,
|
|
169
|
+
});
|
|
170
|
+
if (result.safety === NoteSafety.Dissonant) {
|
|
171
|
+
dissonant += 1;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
const dissonanceScore = Math.max(nonChord, dissonant) / sounding.length;
|
|
175
|
+
|
|
176
|
+
let low = Number.POSITIVE_INFINITY;
|
|
177
|
+
let high = Number.NEGATIVE_INFINITY;
|
|
178
|
+
for (const voice of sounding) {
|
|
179
|
+
low = Math.min(low, voice.pitch);
|
|
180
|
+
high = Math.max(high, voice.pitch);
|
|
181
|
+
}
|
|
182
|
+
const spanScore = Math.min(1, (high - low) / SPAN_SATURATION);
|
|
183
|
+
|
|
184
|
+
return clamp01(
|
|
185
|
+
FUNCTION_WEIGHT * functionScore + DISSONANCE_WEIGHT * dissonanceScore + SPAN_WEIGHT * spanScore,
|
|
186
|
+
);
|
|
187
|
+
}
|