@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.
Files changed (155) hide show
  1. package/README.md +25 -2
  2. package/dist/analyze/index.cjs +1697 -0
  3. package/dist/analyze/index.cjs.map +1 -0
  4. package/dist/analyze/index.d.cts +429 -0
  5. package/dist/analyze/index.d.ts +429 -0
  6. package/dist/analyze/index.js +51 -0
  7. package/dist/analyze/index.js.map +1 -0
  8. package/dist/chunk-5HTNMQW4.js +653 -0
  9. package/dist/chunk-5HTNMQW4.js.map +1 -0
  10. package/dist/chunk-7Y7WF64D.js +410 -0
  11. package/dist/chunk-7Y7WF64D.js.map +1 -0
  12. package/dist/chunk-A4532ZFR.js +259 -0
  13. package/dist/chunk-A4532ZFR.js.map +1 -0
  14. package/dist/chunk-BHUT2S5G.js +22 -0
  15. package/dist/chunk-BHUT2S5G.js.map +1 -0
  16. package/dist/chunk-CCBLVAPK.js +42 -0
  17. package/dist/chunk-CCBLVAPK.js.map +1 -0
  18. package/dist/chunk-COKTX2G5.js +907 -0
  19. package/dist/chunk-COKTX2G5.js.map +1 -0
  20. package/dist/chunk-DYWFNC4A.js +152 -0
  21. package/dist/chunk-DYWFNC4A.js.map +1 -0
  22. package/dist/chunk-GQKN5KDT.js +159 -0
  23. package/dist/chunk-GQKN5KDT.js.map +1 -0
  24. package/dist/chunk-LN7ZZMD2.js +530 -0
  25. package/dist/chunk-LN7ZZMD2.js.map +1 -0
  26. package/dist/chunk-MSWFZF7J.js +118 -0
  27. package/dist/chunk-MSWFZF7J.js.map +1 -0
  28. package/dist/chunk-R3S2LUBH.js +2722 -0
  29. package/dist/chunk-R3S2LUBH.js.map +1 -0
  30. package/dist/chunk-SDM3VJJ5.js +41 -0
  31. package/dist/chunk-SDM3VJJ5.js.map +1 -0
  32. package/dist/chunk-XTC3X6MS.js +816 -0
  33. package/dist/chunk-XTC3X6MS.js.map +1 -0
  34. package/dist/chunk-ZXW5YIWK.js +55 -0
  35. package/dist/chunk-ZXW5YIWK.js.map +1 -0
  36. package/dist/core/index.cjs +432 -0
  37. package/dist/core/index.cjs.map +1 -0
  38. package/dist/core/index.d.cts +210 -0
  39. package/dist/core/index.d.ts +210 -0
  40. package/dist/core/index.js +73 -0
  41. package/dist/core/index.js.map +1 -0
  42. package/dist/function-2bJW1Zp_.d.ts +187 -0
  43. package/dist/function-r2sh-jOO.d.cts +187 -0
  44. package/dist/generate/index.cjs +3787 -0
  45. package/dist/generate/index.cjs.map +1 -0
  46. package/dist/generate/index.d.cts +980 -0
  47. package/dist/generate/index.d.ts +980 -0
  48. package/dist/generate/index.js +52 -0
  49. package/dist/generate/index.js.map +1 -0
  50. package/dist/index-BMPxwjwV.d.ts +151 -0
  51. package/dist/index-BOBXBNwP.d.cts +157 -0
  52. package/dist/index-BdeQ1r_p.d.cts +56 -0
  53. package/dist/index-BreoJRw0.d.ts +127 -0
  54. package/dist/index-CGMZNCx6.d.cts +140 -0
  55. package/dist/index-CGMZNCx6.d.ts +140 -0
  56. package/dist/index-CM5W4rcm.d.ts +157 -0
  57. package/dist/index-CP3wAfEH.d.ts +56 -0
  58. package/dist/index-Cl7mhOFM.d.cts +164 -0
  59. package/dist/index-Cl7mhOFM.d.ts +164 -0
  60. package/dist/index-DZ1M21do.d.cts +127 -0
  61. package/dist/index-D_ZTUNKp.d.cts +151 -0
  62. package/dist/index.cjs +5277 -4893
  63. package/dist/index.cjs.map +1 -1
  64. package/dist/index.d.cts +14 -2621
  65. package/dist/index.d.ts +14 -2621
  66. package/dist/index.js +170 -6152
  67. package/dist/index.js.map +1 -1
  68. package/dist/model/index.cjs +2435 -0
  69. package/dist/model/index.cjs.map +1 -0
  70. package/dist/model/index.d.cts +630 -0
  71. package/dist/model/index.d.ts +630 -0
  72. package/dist/model/index.js +21 -0
  73. package/dist/model/index.js.map +1 -0
  74. package/dist/styled-BjXtOFNk.d.cts +282 -0
  75. package/dist/styled-D7BAx3S3.d.ts +282 -0
  76. package/dist/theory/index.cjs +1677 -0
  77. package/dist/theory/index.cjs.map +1 -0
  78. package/dist/theory/index.d.cts +535 -0
  79. package/dist/theory/index.d.ts +535 -0
  80. package/dist/theory/index.js +149 -0
  81. package/dist/theory/index.js.map +1 -0
  82. package/dist/types-MGP29r3t.d.cts +30 -0
  83. package/dist/types-MGP29r3t.d.ts +30 -0
  84. package/package.json +31 -1
  85. package/src/analyze/arrange/index.ts +21 -0
  86. package/src/analyze/arrange/internal.ts +125 -0
  87. package/src/analyze/arrange/tension.ts +187 -0
  88. package/src/analyze/arrange/tracks.ts +279 -0
  89. package/src/{detect → analyze/detect}/index.ts +82 -23
  90. package/src/analyze/functional/borrowed.ts +84 -0
  91. package/src/analyze/functional/cadence.ts +66 -0
  92. package/src/analyze/functional/function.ts +184 -0
  93. package/src/analyze/functional/index.ts +23 -0
  94. package/src/analyze/functional/internal.ts +29 -0
  95. package/src/analyze/functional/roman.ts +393 -0
  96. package/src/analyze/index.ts +49 -0
  97. package/src/{timeline → analyze/timeline}/index.ts +87 -24
  98. package/src/{analysis → analyze/voice}/index.ts +81 -12
  99. package/src/core/index.ts +47 -0
  100. package/src/{interval → core/interval}/index.ts +15 -1
  101. package/src/{meter → core/meter}/index.ts +108 -5
  102. package/src/{pitch → core/pitch}/index.ts +58 -3
  103. package/src/{random → core/random}/index.ts +13 -1
  104. package/src/{tuning → core/tuning}/index.ts +28 -1
  105. package/src/{bass → generate/bass}/index.ts +66 -17
  106. package/src/{countermelody → generate/countermelody}/index.ts +54 -24
  107. package/src/{drums → generate/drums}/fills.ts +31 -20
  108. package/src/{drums → generate/drums}/index.ts +127 -9
  109. package/src/{drums → generate/drums}/internal.ts +7 -1
  110. package/src/{drums → generate/drums}/rng.ts +2 -2
  111. package/src/{drums → generate/drums}/swing.ts +7 -2
  112. package/src/{groove → generate/groove}/index.ts +118 -22
  113. package/src/{harmonize → generate/harmonize}/index.ts +96 -23
  114. package/src/generate/index.ts +59 -0
  115. package/src/{motif → generate/motif}/index.ts +94 -17
  116. package/src/{progression → generate/progression}/index.ts +61 -27
  117. package/src/{reharmony → generate/reharmony}/index.ts +46 -15
  118. package/src/{rhythm → generate/rhythm}/index.ts +47 -13
  119. package/src/index.ts +130 -116
  120. package/src/model/chord.ts +423 -0
  121. package/src/model/index.ts +5 -947
  122. package/src/model/interval.ts +82 -0
  123. package/src/model/key.ts +266 -0
  124. package/src/model/note.ts +176 -0
  125. package/src/model/progression.ts +161 -0
  126. package/src/model/shared.ts +12 -0
  127. package/src/{chord → theory/chord}/index.ts +85 -4
  128. package/src/{chordscale → theory/chordscale}/index.ts +67 -9
  129. package/src/{counterpoint → theory/counterpoint}/index.ts +23 -2
  130. package/src/{harmony → theory/harmony}/index.ts +19 -2
  131. package/src/theory/index.ts +107 -0
  132. package/src/{safety → theory/safety}/index.ts +59 -12
  133. package/src/theory/scale/degrees.ts +123 -0
  134. package/src/theory/scale/index.ts +28 -0
  135. package/src/theory/scale/key.ts +56 -0
  136. package/src/theory/scale/masks.ts +153 -0
  137. package/src/{spelling → theory/spelling}/index.ts +21 -3
  138. package/src/{symbol → theory/symbol}/index.ts +91 -36
  139. package/src/theory/voicing/index.ts +5 -0
  140. package/src/theory/voicing/internal.ts +168 -0
  141. package/src/theory/voicing/leading.ts +112 -0
  142. package/src/theory/voicing/satb.ts +195 -0
  143. package/src/theory/voicing/styled.ts +176 -0
  144. package/src/arrange/index.ts +0 -409
  145. package/src/functional/index.ts +0 -602
  146. package/src/scale/index.ts +0 -215
  147. package/src/voicing/index.ts +0 -523
  148. /package/src/{types.ts → core/types.ts} +0 -0
  149. /package/src/{drums → generate/drums}/beat.ts +0 -0
  150. /package/src/{drums → generate/drums}/euclid.ts +0 -0
  151. /package/src/{drums → generate/drums}/ghost.ts +0 -0
  152. /package/src/{drums → generate/drums}/hihat.ts +0 -0
  153. /package/src/{drums → generate/drums}/hit.ts +0 -0
  154. /package/src/{drums → generate/drums}/kick.ts +0 -0
  155. /package/src/{drums → generate/drums}/percussion.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.0",
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": [
@@ -58,13 +83,18 @@
58
83
  "test": "vitest run",
59
84
  "test:watch": "vitest",
60
85
  "test:coverage": "vitest run --coverage",
86
+ "docs": "typedoc",
61
87
  "prepare": "[ -d node_modules/typescript ] && tsup || true",
62
88
  "prepublishOnly": "yarn build"
63
89
  },
64
90
  "devDependencies": {
65
91
  "@biomejs/biome": "2.5.2",
92
+ "@libraz/typedoc-theme": "^0.1.1",
66
93
  "@vitest/coverage-v8": "4.1.9",
67
94
  "tsup": "8.5.1",
95
+ "typedoc": "0.28.20",
96
+ "typedoc-plugin-extras": "4.0.1",
97
+ "typedoc-plugin-mdn-links": "5.1.1",
68
98
  "typescript": "6.0.3",
69
99
  "vitest": "4.1.9"
70
100
  }
@@ -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
+ }