@libraz/libcantus 0.9.2 → 0.9.4

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 (200) hide show
  1. package/README.md +6 -5
  2. package/dist/analyze/index.cjs +41 -1676
  3. package/dist/analyze/index.cjs.map +1 -1
  4. package/dist/analyze/index.d.cts +194 -122
  5. package/dist/analyze/index.d.ts +194 -122
  6. package/dist/analyze/index.js +19 -8
  7. package/dist/chunk-36N73KZ3.cjs +246 -0
  8. package/dist/chunk-36N73KZ3.cjs.map +1 -0
  9. package/dist/chunk-4HLLZPXT.cjs +3077 -0
  10. package/dist/chunk-4HLLZPXT.cjs.map +1 -0
  11. package/dist/chunk-7MGZ7D62.cjs +498 -0
  12. package/dist/chunk-7MGZ7D62.cjs.map +1 -0
  13. package/dist/{chunk-R3S2LUBH.js → chunk-7NKDPF74.js} +660 -305
  14. package/dist/chunk-7NKDPF74.js.map +1 -0
  15. package/dist/chunk-CAN76NCU.cjs +679 -0
  16. package/dist/chunk-CAN76NCU.cjs.map +1 -0
  17. package/dist/{chunk-7Y7WF64D.js → chunk-D2FAJBYL.js} +104 -35
  18. package/dist/chunk-D2FAJBYL.js.map +1 -0
  19. package/dist/{chunk-COKTX2G5.js → chunk-EHVEBWM5.js} +1052 -510
  20. package/dist/chunk-EHVEBWM5.js.map +1 -0
  21. package/dist/chunk-FCANXBAT.cjs +30 -0
  22. package/dist/chunk-FCANXBAT.cjs.map +1 -0
  23. package/dist/chunk-FNDWXRRI.cjs +479 -0
  24. package/dist/chunk-FNDWXRRI.cjs.map +1 -0
  25. package/dist/{chunk-GQKN5KDT.js → chunk-GQSGS2N4.js} +43 -29
  26. package/dist/chunk-GQSGS2N4.js.map +1 -0
  27. package/dist/chunk-HKGYCQ34.js +53 -0
  28. package/dist/chunk-HKGYCQ34.js.map +1 -0
  29. package/dist/chunk-I24MV65U.js +106 -0
  30. package/dist/chunk-I24MV65U.js.map +1 -0
  31. package/dist/{chunk-LN7ZZMD2.js → chunk-KMHOICIY.js} +322 -173
  32. package/dist/chunk-KMHOICIY.js.map +1 -0
  33. package/dist/chunk-LW53AHQU.cjs +1117 -0
  34. package/dist/chunk-LW53AHQU.cjs.map +1 -0
  35. package/dist/chunk-N6IHQUOX.js +1 -0
  36. package/dist/chunk-N6IHQUOX.js.map +1 -0
  37. package/dist/chunk-NKIBQ6PT.js +30 -0
  38. package/dist/chunk-NKIBQ6PT.js.map +1 -0
  39. package/dist/chunk-NP4NA2FB.cjs +106 -0
  40. package/dist/chunk-NP4NA2FB.cjs.map +1 -0
  41. package/dist/chunk-NVE34YGZ.cjs +1449 -0
  42. package/dist/chunk-NVE34YGZ.cjs.map +1 -0
  43. package/dist/{chunk-A4532ZFR.js → chunk-NYYWMV2C.js} +115 -47
  44. package/dist/chunk-NYYWMV2C.js.map +1 -0
  45. package/dist/{chunk-5HTNMQW4.js → chunk-Q2XUP227.js} +310 -118
  46. package/dist/chunk-Q2XUP227.js.map +1 -0
  47. package/dist/chunk-Q6OP4U43.cjs +327 -0
  48. package/dist/chunk-Q6OP4U43.cjs.map +1 -0
  49. package/dist/chunk-RC6GBMCD.cjs +845 -0
  50. package/dist/chunk-RC6GBMCD.cjs.map +1 -0
  51. package/dist/{chunk-XTC3X6MS.js → chunk-RK35RIN7.js} +453 -152
  52. package/dist/chunk-RK35RIN7.js.map +1 -0
  53. package/dist/chunk-RUMU3UA6.cjs +53 -0
  54. package/dist/chunk-RUMU3UA6.cjs.map +1 -0
  55. package/dist/chunk-T64C7ZD7.cjs +1 -0
  56. package/dist/chunk-T64C7ZD7.cjs.map +1 -0
  57. package/dist/chunk-XEBG3G53.js +498 -0
  58. package/dist/chunk-XEBG3G53.js.map +1 -0
  59. package/dist/chunk-YLEMGEFH.js +246 -0
  60. package/dist/chunk-YLEMGEFH.js.map +1 -0
  61. package/dist/chunk-ZEJR4TKL.cjs +173 -0
  62. package/dist/chunk-ZEJR4TKL.cjs.map +1 -0
  63. package/dist/core/index.cjs +130 -431
  64. package/dist/core/index.cjs.map +1 -1
  65. package/dist/core/index.d.cts +331 -17
  66. package/dist/core/index.d.ts +331 -17
  67. package/dist/core/index.js +71 -13
  68. package/dist/{function-2bJW1Zp_.d.ts → function-BtiZb9i9.d.ts} +28 -7
  69. package/dist/{function-r2sh-jOO.d.cts → function-DNugAOrt.d.cts} +28 -7
  70. package/dist/generate/index.cjs +30 -3751
  71. package/dist/generate/index.cjs.map +1 -1
  72. package/dist/generate/index.d.cts +373 -59
  73. package/dist/generate/index.d.ts +373 -59
  74. package/dist/generate/index.js +23 -9
  75. package/dist/{index-DZ1M21do.d.cts → index-6VR8-kit.d.ts} +20 -4
  76. package/dist/index-70p168VM.d.ts +192 -0
  77. package/dist/{index-D_ZTUNKp.d.cts → index-Be74mcon.d.cts} +18 -17
  78. package/dist/index-BtXT6UQ0.d.cts +192 -0
  79. package/dist/{index-BMPxwjwV.d.ts → index-C5UBbQ4G.d.ts} +18 -17
  80. package/dist/{index-BOBXBNwP.d.cts → index-C83mZI1P.d.ts} +93 -8
  81. package/dist/{index-BdeQ1r_p.d.cts → index-D6b6na69.d.cts} +14 -11
  82. package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
  83. package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
  84. package/dist/index-OG65ocOr.d.cts +272 -0
  85. package/dist/index-OG65ocOr.d.ts +272 -0
  86. package/dist/{index-BreoJRw0.d.ts → index-tJhkmQgE.d.cts} +20 -4
  87. package/dist/{index-CP3wAfEH.d.ts → index-tSQh-xwY.d.ts} +14 -11
  88. package/dist/{index-CM5W4rcm.d.ts → index-y_ak9_Hi.d.cts} +93 -8
  89. package/dist/index.cjs +419 -6848
  90. package/dist/index.cjs.map +1 -1
  91. package/dist/index.d.cts +14 -13
  92. package/dist/index.d.ts +14 -13
  93. package/dist/index.js +132 -27
  94. package/dist/model/index.cjs +8 -2423
  95. package/dist/model/index.cjs.map +1 -1
  96. package/dist/model/index.d.cts +433 -74
  97. package/dist/model/index.d.ts +433 -74
  98. package/dist/model/index.js +6 -7
  99. package/dist/styled-DIRkk9Uz.d.ts +506 -0
  100. package/dist/styled-DJbkJQfj.d.cts +506 -0
  101. package/dist/theory/index.cjs +172 -1676
  102. package/dist/theory/index.cjs.map +1 -1
  103. package/dist/theory/index.d.cts +151 -233
  104. package/dist/theory/index.d.ts +151 -233
  105. package/dist/theory/index.js +31 -7
  106. package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
  107. package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
  108. package/package.json +73 -24
  109. package/dist/chunk-5HTNMQW4.js.map +0 -1
  110. package/dist/chunk-7Y7WF64D.js.map +0 -1
  111. package/dist/chunk-A4532ZFR.js.map +0 -1
  112. package/dist/chunk-BHUT2S5G.js +0 -22
  113. package/dist/chunk-BHUT2S5G.js.map +0 -1
  114. package/dist/chunk-CCBLVAPK.js +0 -42
  115. package/dist/chunk-CCBLVAPK.js.map +0 -1
  116. package/dist/chunk-COKTX2G5.js.map +0 -1
  117. package/dist/chunk-DYWFNC4A.js +0 -152
  118. package/dist/chunk-DYWFNC4A.js.map +0 -1
  119. package/dist/chunk-GQKN5KDT.js.map +0 -1
  120. package/dist/chunk-LN7ZZMD2.js.map +0 -1
  121. package/dist/chunk-MSWFZF7J.js +0 -118
  122. package/dist/chunk-MSWFZF7J.js.map +0 -1
  123. package/dist/chunk-R3S2LUBH.js.map +0 -1
  124. package/dist/chunk-SDM3VJJ5.js +0 -41
  125. package/dist/chunk-SDM3VJJ5.js.map +0 -1
  126. package/dist/chunk-XTC3X6MS.js.map +0 -1
  127. package/dist/chunk-ZXW5YIWK.js +0 -55
  128. package/dist/chunk-ZXW5YIWK.js.map +0 -1
  129. package/dist/index-CGMZNCx6.d.cts +0 -140
  130. package/dist/index-CGMZNCx6.d.ts +0 -140
  131. package/dist/styled-BjXtOFNk.d.cts +0 -282
  132. package/dist/styled-D7BAx3S3.d.ts +0 -282
  133. package/src/analyze/arrange/index.ts +0 -21
  134. package/src/analyze/arrange/internal.ts +0 -125
  135. package/src/analyze/arrange/tension.ts +0 -187
  136. package/src/analyze/arrange/tracks.ts +0 -279
  137. package/src/analyze/detect/index.ts +0 -235
  138. package/src/analyze/functional/borrowed.ts +0 -84
  139. package/src/analyze/functional/cadence.ts +0 -66
  140. package/src/analyze/functional/function.ts +0 -184
  141. package/src/analyze/functional/index.ts +0 -23
  142. package/src/analyze/functional/internal.ts +0 -29
  143. package/src/analyze/functional/roman.ts +0 -393
  144. package/src/analyze/index.ts +0 -49
  145. package/src/analyze/timeline/index.ts +0 -415
  146. package/src/analyze/voice/index.ts +0 -330
  147. package/src/core/index.ts +0 -47
  148. package/src/core/interval/index.ts +0 -81
  149. package/src/core/meter/index.ts +0 -282
  150. package/src/core/pitch/index.ts +0 -311
  151. package/src/core/random/index.ts +0 -53
  152. package/src/core/tuning/index.ts +0 -156
  153. package/src/core/types.ts +0 -29
  154. package/src/generate/bass/index.ts +0 -350
  155. package/src/generate/countermelody/index.ts +0 -379
  156. package/src/generate/drums/beat.ts +0 -266
  157. package/src/generate/drums/euclid.ts +0 -58
  158. package/src/generate/drums/fills.ts +0 -356
  159. package/src/generate/drums/ghost.ts +0 -119
  160. package/src/generate/drums/hihat.ts +0 -285
  161. package/src/generate/drums/hit.ts +0 -31
  162. package/src/generate/drums/index.ts +0 -329
  163. package/src/generate/drums/internal.ts +0 -257
  164. package/src/generate/drums/kick.ts +0 -160
  165. package/src/generate/drums/percussion.ts +0 -121
  166. package/src/generate/drums/rng.ts +0 -7
  167. package/src/generate/drums/swing.ts +0 -71
  168. package/src/generate/groove/index.ts +0 -326
  169. package/src/generate/harmonize/index.ts +0 -440
  170. package/src/generate/index.ts +0 -59
  171. package/src/generate/motif/index.ts +0 -398
  172. package/src/generate/progression/index.ts +0 -370
  173. package/src/generate/reharmony/index.ts +0 -330
  174. package/src/generate/rhythm/index.ts +0 -165
  175. package/src/index.ts +0 -247
  176. package/src/model/chord.ts +0 -423
  177. package/src/model/index.ts +0 -15
  178. package/src/model/interval.ts +0 -82
  179. package/src/model/key.ts +0 -266
  180. package/src/model/note.ts +0 -176
  181. package/src/model/progression.ts +0 -161
  182. package/src/model/shared.ts +0 -12
  183. package/src/theory/chord/index.ts +0 -373
  184. package/src/theory/chordscale/index.ts +0 -374
  185. package/src/theory/counterpoint/index.ts +0 -230
  186. package/src/theory/harmony/index.ts +0 -92
  187. package/src/theory/index.ts +0 -107
  188. package/src/theory/safety/index.ts +0 -419
  189. package/src/theory/scale/degrees.ts +0 -123
  190. package/src/theory/scale/index.ts +0 -28
  191. package/src/theory/scale/key.ts +0 -56
  192. package/src/theory/scale/masks.ts +0 -153
  193. package/src/theory/spelling/index.ts +0 -182
  194. package/src/theory/symbol/index.ts +0 -268
  195. package/src/theory/voicing/index.ts +0 -5
  196. package/src/theory/voicing/internal.ts +0 -168
  197. package/src/theory/voicing/leading.ts +0 -112
  198. package/src/theory/voicing/satb.ts +0 -195
  199. package/src/theory/voicing/styled.ts +0 -176
  200. package/tsconfig.json +0 -23
@@ -1,125 +0,0 @@
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
- }
@@ -1,187 +0,0 @@
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
- }
@@ -1,279 +0,0 @@
1
- /**
2
- * Arrangement track-role analysis: a single harmony is inferred from every
3
- * track's notes pooled together, each track is then analysed against that shared
4
- * harmony, and notes that clash with the sounding chord are collected as
5
- * conflicts.
6
- */
7
-
8
- import { isStrongBeat, parseTimeSignature, type TimeSignature } from '../../core/meter/index.js';
9
- import type { KeyScale, NoteEvent } from '../../core/types.js';
10
- import {
11
- evaluateSafety,
12
- NoteSafety,
13
- type SafetyProfile,
14
- type VoiceSnapshot,
15
- } from '../../theory/safety/index.js';
16
- import {
17
- type CadenceHit,
18
- type ChordTimeline,
19
- chordTimelineFromNotes,
20
- detectCadences,
21
- } from '../timeline/index.js';
22
- import { type AnalyzedNote, analyzeVoice, type VoiceNote } from '../voice/index.js';
23
- import { covers, EPS, type PreparedTrack, poolNotes, prepareTracks } from './internal.js';
24
-
25
- /**
26
- * The musical role a track plays in the arrangement.
27
- *
28
- * @category Arrangement & Analysis
29
- */
30
- export type TrackRole = 'melody' | 'harmony' | 'bass' | 'other';
31
-
32
- /**
33
- * One input track: its notes plus optional name and role.
34
- *
35
- * @category Arrangement & Analysis
36
- */
37
- export type ArrangementTrack = {
38
- name?: string;
39
- role?: TrackRole;
40
- notes: NoteEvent[];
41
- };
42
-
43
- /**
44
- * A track after analysis: its resolved name, role, and per-note annotations.
45
- *
46
- * @category Arrangement & Analysis
47
- */
48
- export type TrackAnalysis = {
49
- name: string;
50
- role: TrackRole;
51
- /** One {@link AnalyzedNote} per note, labelled against the inferred chords. */
52
- notes: AnalyzedNote[];
53
- };
54
-
55
- /**
56
- * A note that clashes with the harmony sounding beneath it.
57
- *
58
- * @category Arrangement & Analysis
59
- */
60
- export type Conflict = {
61
- beat: number;
62
- trackName: string;
63
- pitch: number;
64
- /** The clash severity: {@link NoteSafety.Warning} or {@link NoteSafety.Dissonant}. */
65
- safety: NoteSafety;
66
- /** {@link import('../safety/index.js').SafetyResult.reasons} bitmask. */
67
- reasons: number;
68
- rationale?: string;
69
- };
70
-
71
- /**
72
- * The full result of {@link analyzeArrangement}.
73
- *
74
- * @category Arrangement & Analysis
75
- */
76
- export type ArrangementAnalysis = {
77
- key: KeyScale;
78
- timeline: ChordTimeline;
79
- segmentConfidence: number[];
80
- cadences: CadenceHit[];
81
- tracks: TrackAnalysis[];
82
- /** Notes clashing with the sounding harmony, worst severity first. */
83
- conflicts: Conflict[];
84
- };
85
-
86
- /**
87
- * Options controlling {@link analyzeArrangement} and {@link tensionCurve}.
88
- *
89
- * @category Arrangement & Analysis
90
- */
91
- export type ArrangementOptions = {
92
- /** Key context; inferred from the pooled notes when omitted. */
93
- key?: KeyScale;
94
- /**
95
- * Time signature; defaults to 4/4.
96
- *
97
- * @defaultValue `4/4`
98
- */
99
- ts?: TimeSignature;
100
- /**
101
- * Chord-slot length in beats; defaults to one bar of `ts`.
102
- *
103
- * @defaultValue one bar of `ts`
104
- */
105
- harmonicRhythm?: number;
106
- /**
107
- * Safety profile used for conflict detection; defaults to `pop`.
108
- *
109
- * @defaultValue `'pop'`
110
- */
111
- profile?: SafetyProfile;
112
- };
113
-
114
- /**
115
- * Sounding pitches of every sub-voice at a beat, excluding one sub-voice.
116
- *
117
- * Only the sub-voice under analysis is excluded — sibling sub-voices of the
118
- * same track are included, so dissonant clusters inside a single polyphonic
119
- * track are still detected.
120
- */
121
- function otherVoicesSounding(
122
- prepared: PreparedTrack[],
123
- excludeTrack: number,
124
- excludeVoice: number,
125
- beat: number,
126
- ): VoiceSnapshot[] {
127
- const out: VoiceSnapshot[] = [];
128
- for (let t = 0; t < prepared.length; t += 1) {
129
- const track = prepared[t];
130
- if (!track) {
131
- continue;
132
- }
133
- for (let v = 0; v < track.voices.length; v += 1) {
134
- if (t === excludeTrack && v === excludeVoice) {
135
- continue;
136
- }
137
- const subVoice = track.voices[v];
138
- if (!subVoice) {
139
- continue;
140
- }
141
- for (const note of subVoice.sounding) {
142
- if (!covers(note, beat)) {
143
- continue;
144
- }
145
- const snap: VoiceSnapshot = { pitch: note.pitch };
146
- if (note.prevPitch !== undefined) {
147
- snap.prevPitch = note.prevPitch;
148
- }
149
- out.push(snap);
150
- }
151
- }
152
- }
153
- return out;
154
- }
155
-
156
- /**
157
- * The beats at which a note must be checked against the harmony: its onset,
158
- * plus the onset of every later chord segment the note sustains across. Each
159
- * crossed segment is visited exactly once (at its own start), so a boundary
160
- * beat is never evaluated twice for the same note.
161
- */
162
- function evaluationBeats(note: VoiceNote, timeline: ChordTimeline): number[] {
163
- const noteEnd = note.startBeat + note.durationBeat;
164
- const beats = [note.startBeat];
165
- for (const segment of timeline.segments) {
166
- if (segment.startBeat > note.startBeat + EPS && segment.startBeat < noteEnd - EPS) {
167
- beats.push(segment.startBeat);
168
- }
169
- }
170
- return beats;
171
- }
172
-
173
- /**
174
- * Analyse a whole arrangement against a single inferred harmony.
175
- *
176
- * The chord timeline and key are inferred from the pooled notes of every track
177
- * (see {@link chordTimelineFromNotes}); pooling all voices is robust even when
178
- * roles are absent or a track doubles the harmony, so it is preferred over
179
- * deriving the harmony from a subset. Zero- and negative-length notes never
180
- * sound, so they are dropped at ingest and appear in neither the annotations
181
- * nor the conflicts.
182
- *
183
- * Voice analysis assumes one monophonic voice at a time, so each track is
184
- * first partitioned into monophonic sub-voices (a polyphonic block-chord track
185
- * fans out into one sub-voice per simultaneous note; a monophonic track is
186
- * unaffected). Each sub-voice is labelled note-by-note against the harmony
187
- * with {@link analyzeVoice}, seeing every other sub-voice — including siblings
188
- * within the same track — as its accompaniment. Every note is then re-checked
189
- * with {@link evaluateSafety} at its onset and again at each chord change it
190
- * sustains across, so a held note that clashes with a later chord is caught:
191
- * evaluations that are not {@link NoteSafety.Safe} become conflicts, one per
192
- * clashing beat, sorted worst severity first and then by beat.
193
- *
194
- * @param tracks The tracks to analyse.
195
- * @param opts Analysis options; see {@link ArrangementOptions}.
196
- * @returns The inferred harmony, per-track annotations, cadences, and conflicts.
197
- * @example
198
- * ```ts
199
- * import { analyzeArrangement } from '@libraz/libcantus';
200
- * const melody = [
201
- * { pitch: 60, startBeat: 0, durationBeat: 2 },
202
- * { pitch: 67, startBeat: 2, durationBeat: 2 },
203
- * ];
204
- * const { key, conflicts } = analyzeArrangement([{ role: 'melody', notes: melody }]);
205
- * conflicts; // notes clashing with the inferred harmony, worst severity first
206
- * ```
207
- * @category Arrangement & Analysis
208
- */
209
- export function analyzeArrangement(
210
- tracks: ArrangementTrack[],
211
- opts: ArrangementOptions = {},
212
- ): ArrangementAnalysis {
213
- const ts = opts.ts ?? parseTimeSignature('4/4');
214
- const profile: SafetyProfile = opts.profile ?? 'pop';
215
- const pooled = poolNotes(tracks);
216
-
217
- const { timeline, key, segmentConfidence } = chordTimelineFromNotes(pooled, {
218
- key: opts.key,
219
- ts,
220
- harmonicRhythm: opts.harmonicRhythm,
221
- });
222
- const cadences = detectCadences(timeline, key);
223
- const prepared = prepareTracks(tracks);
224
-
225
- const trackAnalyses: TrackAnalysis[] = [];
226
- const conflicts: Conflict[] = [];
227
-
228
- for (let t = 0; t < prepared.length; t += 1) {
229
- const track = prepared[t];
230
- if (!track) {
231
- continue;
232
- }
233
- const notes: AnalyzedNote[] = [];
234
- for (let v = 0; v < track.voices.length; v += 1) {
235
- const subVoice = track.voices[v];
236
- if (!subVoice) {
237
- continue;
238
- }
239
- notes.push(
240
- ...analyzeVoice(subVoice.voice, timeline.at, key, (beat) =>
241
- otherVoicesSounding(prepared, t, v, beat),
242
- ),
243
- );
244
-
245
- for (const note of subVoice.voice) {
246
- for (const beat of evaluationBeats(note, timeline)) {
247
- const result = evaluateSafety({
248
- profile,
249
- candidatePitch: note.pitch,
250
- chord: timeline.at(beat),
251
- key,
252
- otherVoices: otherVoicesSounding(prepared, t, v, beat),
253
- strongBeat: isStrongBeat(beat, ts),
254
- });
255
- if (result.safety !== NoteSafety.Safe) {
256
- const conflict: Conflict = {
257
- beat,
258
- trackName: track.name,
259
- pitch: note.pitch,
260
- safety: result.safety,
261
- reasons: result.reasons,
262
- };
263
- if (result.rationale !== undefined) {
264
- conflict.rationale = result.rationale;
265
- }
266
- conflicts.push(conflict);
267
- }
268
- }
269
- }
270
- }
271
- // Sub-voice results interleave; restore the track's onset-then-pitch order.
272
- notes.sort((a, b) => a.noteId - b.noteId);
273
- trackAnalyses.push({ name: track.name, role: track.role, notes });
274
- }
275
-
276
- conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
277
-
278
- return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
279
- }