@libraz/libcantus 0.9.3 → 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 (198) hide show
  1. package/README.md +2 -2
  2. package/dist/analyze/index.cjs +39 -1947
  3. package/dist/analyze/index.cjs.map +1 -1
  4. package/dist/analyze/index.d.cts +197 -37
  5. package/dist/analyze/index.d.ts +197 -37
  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-4MHA432G.js → chunk-7NKDPF74.js} +403 -180
  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-MIWO425M.js → chunk-D2FAJBYL.js} +101 -34
  18. package/dist/chunk-D2FAJBYL.js.map +1 -0
  19. package/dist/{chunk-SGU7SPLK.js → chunk-EHVEBWM5.js} +1056 -531
  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-IT2O4XFE.js → chunk-GQSGS2N4.js} +43 -24
  26. package/dist/chunk-GQSGS2N4.js.map +1 -0
  27. package/dist/{chunk-62QZKCJQ.js → chunk-HKGYCQ34.js} +10 -6
  28. package/dist/chunk-HKGYCQ34.js.map +1 -0
  29. package/dist/{chunk-AKLYPWIN.js → chunk-I24MV65U.js} +29 -6
  30. package/dist/chunk-I24MV65U.js.map +1 -0
  31. package/dist/{chunk-CHMXELDS.js → chunk-KMHOICIY.js} +311 -181
  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-2ZWMSJH6.js → chunk-NKIBQ6PT.js} +2 -2
  36. package/dist/chunk-NKIBQ6PT.js.map +1 -0
  37. package/dist/chunk-NP4NA2FB.cjs +106 -0
  38. package/dist/chunk-NP4NA2FB.cjs.map +1 -0
  39. package/dist/chunk-NVE34YGZ.cjs +1449 -0
  40. package/dist/chunk-NVE34YGZ.cjs.map +1 -0
  41. package/dist/{chunk-EH6EM37U.js → chunk-NYYWMV2C.js} +61 -49
  42. package/dist/chunk-NYYWMV2C.js.map +1 -0
  43. package/dist/{chunk-GOAW4VYL.js → chunk-Q2XUP227.js} +243 -117
  44. package/dist/chunk-Q2XUP227.js.map +1 -0
  45. package/dist/chunk-Q6OP4U43.cjs +327 -0
  46. package/dist/chunk-Q6OP4U43.cjs.map +1 -0
  47. package/dist/chunk-RC6GBMCD.cjs +845 -0
  48. package/dist/chunk-RC6GBMCD.cjs.map +1 -0
  49. package/dist/{chunk-C5XYKUVG.js → chunk-RK35RIN7.js} +370 -157
  50. package/dist/chunk-RK35RIN7.js.map +1 -0
  51. package/dist/chunk-RUMU3UA6.cjs +53 -0
  52. package/dist/chunk-RUMU3UA6.cjs.map +1 -0
  53. package/dist/chunk-T64C7ZD7.cjs +1 -0
  54. package/dist/chunk-T64C7ZD7.cjs.map +1 -0
  55. package/dist/chunk-XEBG3G53.js +498 -0
  56. package/dist/chunk-XEBG3G53.js.map +1 -0
  57. package/dist/{chunk-I5R7ZBJS.js → chunk-YLEMGEFH.js} +103 -59
  58. package/dist/chunk-YLEMGEFH.js.map +1 -0
  59. package/dist/chunk-ZEJR4TKL.cjs +173 -0
  60. package/dist/chunk-ZEJR4TKL.cjs.map +1 -0
  61. package/dist/core/index.cjs +130 -647
  62. package/dist/core/index.cjs.map +1 -1
  63. package/dist/core/index.d.cts +301 -39
  64. package/dist/core/index.d.ts +301 -39
  65. package/dist/core/index.js +54 -16
  66. package/dist/{function-DYt4iKsn.d.cts → function-BtiZb9i9.d.ts} +28 -7
  67. package/dist/{function-DVH4Eusr.d.ts → function-DNugAOrt.d.cts} +28 -7
  68. package/dist/generate/index.cjs +28 -4085
  69. package/dist/generate/index.cjs.map +1 -1
  70. package/dist/generate/index.d.cts +370 -62
  71. package/dist/generate/index.d.ts +370 -62
  72. package/dist/generate/index.js +23 -9
  73. package/dist/{index-CxqBQtH1.d.cts → index-6VR8-kit.d.ts} +19 -4
  74. package/dist/index-70p168VM.d.ts +192 -0
  75. package/dist/{index-CpOsP1y9.d.cts → index-Be74mcon.d.cts} +18 -17
  76. package/dist/index-BtXT6UQ0.d.cts +192 -0
  77. package/dist/{index-DxvARIP2.d.ts → index-C5UBbQ4G.d.ts} +18 -17
  78. package/dist/{index-Bt4tOUD9.d.ts → index-C83mZI1P.d.ts} +93 -8
  79. package/dist/{index-Bg5dYMmZ.d.ts → index-D6b6na69.d.cts} +14 -11
  80. package/dist/{index-Cl7mhOFM.d.cts → index-DXd9JikN.d.cts} +92 -10
  81. package/dist/{index-Cl7mhOFM.d.ts → index-DXd9JikN.d.ts} +92 -10
  82. package/dist/index-OG65ocOr.d.cts +272 -0
  83. package/dist/index-OG65ocOr.d.ts +272 -0
  84. package/dist/{index-Jc0Ryi79.d.ts → index-tJhkmQgE.d.cts} +19 -4
  85. package/dist/{index-Dikst44v.d.cts → index-tSQh-xwY.d.ts} +14 -11
  86. package/dist/{index-BNlGP86A.d.cts → index-y_ak9_Hi.d.cts} +93 -8
  87. package/dist/index.cjs +419 -7383
  88. package/dist/index.cjs.map +1 -1
  89. package/dist/index.d.cts +14 -14
  90. package/dist/index.d.ts +14 -14
  91. package/dist/index.js +111 -27
  92. package/dist/model/index.cjs +8 -2562
  93. package/dist/model/index.cjs.map +1 -1
  94. package/dist/model/index.d.cts +433 -75
  95. package/dist/model/index.d.ts +433 -75
  96. package/dist/model/index.js +6 -7
  97. package/dist/styled-DIRkk9Uz.d.ts +506 -0
  98. package/dist/styled-DJbkJQfj.d.cts +506 -0
  99. package/dist/theory/index.cjs +172 -1822
  100. package/dist/theory/index.cjs.map +1 -1
  101. package/dist/theory/index.d.cts +151 -233
  102. package/dist/theory/index.d.ts +151 -233
  103. package/dist/theory/index.js +30 -7
  104. package/dist/{types-MGP29r3t.d.cts → types-BN6_Q9Kl.d.cts} +11 -2
  105. package/dist/{types-MGP29r3t.d.ts → types-BN6_Q9Kl.d.ts} +11 -2
  106. package/package.json +24 -6
  107. package/dist/chunk-2ZWMSJH6.js.map +0 -1
  108. package/dist/chunk-4MHA432G.js.map +0 -1
  109. package/dist/chunk-62QZKCJQ.js.map +0 -1
  110. package/dist/chunk-AKLYPWIN.js.map +0 -1
  111. package/dist/chunk-C5XYKUVG.js.map +0 -1
  112. package/dist/chunk-CHMXELDS.js.map +0 -1
  113. package/dist/chunk-DYWFNC4A.js +0 -152
  114. package/dist/chunk-DYWFNC4A.js.map +0 -1
  115. package/dist/chunk-EH6EM37U.js.map +0 -1
  116. package/dist/chunk-FBQK3ZQN.js +0 -131
  117. package/dist/chunk-FBQK3ZQN.js.map +0 -1
  118. package/dist/chunk-GOAW4VYL.js.map +0 -1
  119. package/dist/chunk-I5R7ZBJS.js.map +0 -1
  120. package/dist/chunk-IT2O4XFE.js.map +0 -1
  121. package/dist/chunk-MIWO425M.js.map +0 -1
  122. package/dist/chunk-SGU7SPLK.js.map +0 -1
  123. package/dist/index-CGMZNCx6.d.cts +0 -140
  124. package/dist/index-CGMZNCx6.d.ts +0 -140
  125. package/dist/index-CnZCo29u.d.cts +0 -108
  126. package/dist/index-JnF4v9bx.d.ts +0 -108
  127. package/dist/styled-DZ7GjShb.d.ts +0 -282
  128. package/dist/styled-ZTbdaVFM.d.cts +0 -282
  129. package/src/analyze/arrange/index.ts +0 -21
  130. package/src/analyze/arrange/internal.ts +0 -127
  131. package/src/analyze/arrange/tension.ts +0 -199
  132. package/src/analyze/arrange/tracks.ts +0 -312
  133. package/src/analyze/detect/index.ts +0 -272
  134. package/src/analyze/functional/borrowed.ts +0 -84
  135. package/src/analyze/functional/cadence.ts +0 -66
  136. package/src/analyze/functional/function.ts +0 -184
  137. package/src/analyze/functional/index.ts +0 -23
  138. package/src/analyze/functional/internal.ts +0 -29
  139. package/src/analyze/functional/roman.ts +0 -402
  140. package/src/analyze/index.ts +0 -49
  141. package/src/analyze/timeline/index.ts +0 -451
  142. package/src/analyze/voice/index.ts +0 -339
  143. package/src/core/event-index/index.ts +0 -104
  144. package/src/core/index.ts +0 -61
  145. package/src/core/interval/index.ts +0 -81
  146. package/src/core/meter/index.ts +0 -302
  147. package/src/core/pitch/index.ts +0 -311
  148. package/src/core/random/index.ts +0 -76
  149. package/src/core/tuning/index.ts +0 -186
  150. package/src/core/types.ts +0 -29
  151. package/src/core/validation/index.ts +0 -119
  152. package/src/generate/bass/index.ts +0 -373
  153. package/src/generate/countermelody/index.ts +0 -470
  154. package/src/generate/drums/beat.ts +0 -266
  155. package/src/generate/drums/euclid.ts +0 -65
  156. package/src/generate/drums/fills.ts +0 -356
  157. package/src/generate/drums/ghost.ts +0 -119
  158. package/src/generate/drums/hihat.ts +0 -285
  159. package/src/generate/drums/hit.ts +0 -31
  160. package/src/generate/drums/index.ts +0 -361
  161. package/src/generate/drums/internal.ts +0 -257
  162. package/src/generate/drums/kick.ts +0 -160
  163. package/src/generate/drums/percussion.ts +0 -121
  164. package/src/generate/drums/rng.ts +0 -7
  165. package/src/generate/drums/swing.ts +0 -71
  166. package/src/generate/groove/index.ts +0 -351
  167. package/src/generate/harmonize/index.ts +0 -451
  168. package/src/generate/index.ts +0 -58
  169. package/src/generate/motif/index.ts +0 -422
  170. package/src/generate/progression/index.ts +0 -362
  171. package/src/generate/reharmony/index.ts +0 -323
  172. package/src/generate/rhythm/index.ts +0 -175
  173. package/src/index.ts +0 -13
  174. package/src/model/chord.ts +0 -442
  175. package/src/model/index.ts +0 -15
  176. package/src/model/interval.ts +0 -82
  177. package/src/model/key.ts +0 -266
  178. package/src/model/note.ts +0 -176
  179. package/src/model/progression.ts +0 -161
  180. package/src/model/shared.ts +0 -12
  181. package/src/theory/chord/index.ts +0 -375
  182. package/src/theory/chordscale/index.ts +0 -374
  183. package/src/theory/counterpoint/index.ts +0 -230
  184. package/src/theory/harmony/index.ts +0 -77
  185. package/src/theory/index.ts +0 -112
  186. package/src/theory/safety/index.ts +0 -488
  187. package/src/theory/scale/degrees.ts +0 -123
  188. package/src/theory/scale/index.ts +0 -28
  189. package/src/theory/scale/key.ts +0 -56
  190. package/src/theory/scale/masks.ts +0 -153
  191. package/src/theory/spelling/index.ts +0 -249
  192. package/src/theory/symbol/index.ts +0 -268
  193. package/src/theory/voicing/index.ts +0 -5
  194. package/src/theory/voicing/internal.ts +0 -168
  195. package/src/theory/voicing/leading.ts +0 -112
  196. package/src/theory/voicing/satb.ts +0 -216
  197. package/src/theory/voicing/styled.ts +0 -199
  198. package/tsconfig.json +0 -23
@@ -1,127 +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
- const prevEnd = prev === undefined ? undefined : prev.startBeat + prev.durationBeat;
112
- const contiguous = prevEnd !== undefined && Math.abs(prevEnd - note.startBeat) <= EPS;
113
- return {
114
- pitch: note.pitch,
115
- prevPitch: contiguous ? prev?.pitch : undefined,
116
- startBeat: note.startBeat,
117
- endBeat: note.startBeat + note.durationBeat,
118
- };
119
- }),
120
- }));
121
- return {
122
- name: track.name ?? `track ${index + 1}`,
123
- role: track.role ?? 'other',
124
- voices,
125
- };
126
- });
127
- }
@@ -1,199 +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 {
11
- assertGenerationBudget,
12
- assertNoteEvents,
13
- assertRange,
14
- assertTimeSignature,
15
- } from '../../core/validation/index.js';
16
- import { chordPitchClasses } from '../../theory/chord/index.js';
17
- import { evaluateSafety, NoteSafety, type SafetyProfile } from '../../theory/safety/index.js';
18
- import { functionOf } from '../functional/index.js';
19
- import { type ChordTimeline, chordTimelineFromNotes } from '../timeline/index.js';
20
- import { covers, EPS, type PreparedTrack, poolNotes, prepareTracks } from './internal.js';
21
- import type { ArrangementOptions, ArrangementTrack } from './tracks.js';
22
-
23
- /**
24
- * A tension reading sampled at a beat.
25
- *
26
- * @category Arrangement & Analysis
27
- */
28
- export type TensionPoint = {
29
- beat: number;
30
- /** Combined tension in [0, 1]. */
31
- tension: number;
32
- };
33
-
34
- /** Tension contributed by the sounding chord's harmonic function. */
35
- const FUNCTION_TENSION: Record<'tonic' | 'subdominant' | 'dominant', number> = {
36
- tonic: 0,
37
- subdominant: 0.5,
38
- dominant: 1,
39
- };
40
-
41
- /** Weight of the harmonic-function term in the combined tension score. */
42
- const FUNCTION_WEIGHT = 0.5;
43
- /** Weight of the non-chord-tone / dissonance term. */
44
- const DISSONANCE_WEIGHT = 0.35;
45
- /** Weight of the registral-span term. */
46
- const SPAN_WEIGHT = 0.15;
47
- /** Pitch span, in semitones, that saturates the span term. */
48
- const SPAN_SATURATION = 24;
49
-
50
- /** Reduce a pitch to a pitch class in [0, 11]. */
51
- function pitchClass(pitch: number): number {
52
- return ((Math.trunc(pitch) % 12) + 12) % 12;
53
- }
54
-
55
- /** Clamp a value into [0, 1]. */
56
- function clamp01(value: number): number {
57
- return Math.min(1, Math.max(0, value));
58
- }
59
-
60
- /**
61
- * Sample the harmonic tension of an arrangement at regular beats.
62
- *
63
- * At each sample beat the tension combines three normalized terms:
64
- * - the sounding chord's harmonic function (dominant `1`, subdominant `0.5`,
65
- * tonic or no chord `0`), weighted {@link FUNCTION_WEIGHT};
66
- * - the dissonance of the sounding notes — the larger of the share of sounding
67
- * pitches that are not chord tones and the share that {@link evaluateSafety}
68
- * rates {@link NoteSafety.Dissonant} — weighted {@link DISSONANCE_WEIGHT};
69
- * - the registral span of the sounding notes, saturating at
70
- * {@link SPAN_SATURATION} semitones, weighted {@link SPAN_WEIGHT}.
71
- *
72
- * The weighted sum is clamped to [0, 1]. The harmony is inferred from all tracks
73
- * pooled together, so the result is deterministic and self-contained.
74
- *
75
- * @param tracks The tracks to sample.
76
- * @param opts Analysis options plus an optional `step` (default one beat).
77
- * @returns One {@link TensionPoint} per sampled beat, in beat order.
78
- * @throws If `step` is not positive.
79
- * @example
80
- * ```ts
81
- * import { tensionCurve } from '@libraz/libcantus';
82
- * const notes = [{ pitch: 60, startBeat: 0, durationBeat: 4 }];
83
- * const curve = tensionCurve([{ notes }], { step: 1 });
84
- * curve; // one { beat, tension } sample per beat, in beat order
85
- * ```
86
- * @category Arrangement & Analysis
87
- */
88
- export function tensionCurve(
89
- tracks: ArrangementTrack[],
90
- opts: ArrangementOptions & { step?: number } = {},
91
- ): TensionPoint[] {
92
- const ts = opts.ts ?? parseTimeSignature('4/4');
93
- assertTimeSignature(ts);
94
- assertGenerationBudget(tracks.length, 'arrangement tracks');
95
- for (let index = 0; index < tracks.length; index += 1) {
96
- assertNoteEvents(tracks[index]?.notes ?? [], `tracks[${index}].notes`, {
97
- allowNonPositiveDuration: true,
98
- });
99
- }
100
- const profile: SafetyProfile = opts.profile ?? 'pop';
101
- const step = opts.step ?? 1;
102
- assertRange(step, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, 'tension sampling step');
103
-
104
- const pooled = poolNotes(tracks);
105
- const totalBeats = pooled.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
106
- const { timeline, key } = chordTimelineFromNotes(pooled, {
107
- key: opts.key,
108
- ts,
109
- harmonicRhythm: opts.harmonicRhythm,
110
- });
111
-
112
- const prepared = prepareTracks(tracks);
113
- const points: TensionPoint[] = [];
114
- const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS));
115
- assertGenerationBudget(sampleCount, 'tension samples');
116
- for (let i = 0; i < sampleCount; i += 1) {
117
- const beat = i * step;
118
- points.push({ beat, tension: sampleTension(prepared, timeline, key, ts, profile, beat) });
119
- }
120
- return points;
121
- }
122
-
123
- /** All sounding notes across every track at a beat, tagged with their track. */
124
- function allSounding(prepared: PreparedTrack[], beat: number): { pitch: number; track: number }[] {
125
- const out: { pitch: number; track: number }[] = [];
126
- for (let t = 0; t < prepared.length; t += 1) {
127
- const track = prepared[t];
128
- if (!track) {
129
- continue;
130
- }
131
- for (const subVoice of track.voices) {
132
- for (const note of subVoice.sounding) {
133
- if (covers(note, beat)) {
134
- out.push({ pitch: note.pitch, track: t });
135
- }
136
- }
137
- }
138
- }
139
- return out;
140
- }
141
-
142
- /** Combine the harmonic, dissonance, and span terms at one sample beat. */
143
- function sampleTension(
144
- prepared: PreparedTrack[],
145
- timeline: ChordTimeline,
146
- key: KeyScale,
147
- ts: TimeSignature,
148
- profile: SafetyProfile,
149
- beat: number,
150
- ): number {
151
- const sounding = allSounding(prepared, beat);
152
- if (sounding.length === 0) {
153
- return 0;
154
- }
155
- const chord = timeline.at(beat);
156
- const functionScore = chord ? FUNCTION_TENSION[functionOf(chord, key)] : 0;
157
-
158
- const chordPcs = chord ? new Set(chordPitchClasses(chord)) : null;
159
- const strongBeat = isStrongBeat(beat, ts);
160
- let nonChord = 0;
161
- let dissonant = 0;
162
- for (let i = 0; i < sounding.length; i += 1) {
163
- const voice = sounding[i];
164
- if (!voice) {
165
- continue;
166
- }
167
- // Non-chord-tone share only applies when a chord is sounding; at a timeline
168
- // gap there is no reference harmony, so vertical dissonance alone drives the
169
- // dissonance term (the chord function term is already 0 there).
170
- if (chordPcs && !chordPcs.has(pitchClass(voice.pitch))) {
171
- nonChord += 1;
172
- }
173
- const others = sounding.filter((_, j) => j !== i).map((s) => ({ pitch: s.pitch }));
174
- const result = evaluateSafety({
175
- profile,
176
- candidatePitch: voice.pitch,
177
- chord,
178
- key,
179
- otherVoices: others,
180
- strongBeat,
181
- });
182
- if (result.safety === NoteSafety.Dissonant) {
183
- dissonant += 1;
184
- }
185
- }
186
- const dissonanceScore = Math.max(nonChord, dissonant) / sounding.length;
187
-
188
- let low = Number.POSITIVE_INFINITY;
189
- let high = Number.NEGATIVE_INFINITY;
190
- for (const voice of sounding) {
191
- low = Math.min(low, voice.pitch);
192
- high = Math.max(high, voice.pitch);
193
- }
194
- const spanScore = Math.min(1, (high - low) / SPAN_SATURATION);
195
-
196
- return clamp01(
197
- FUNCTION_WEIGHT * functionScore + DISSONANCE_WEIGHT * dissonanceScore + SPAN_WEIGHT * spanScore,
198
- );
199
- }
@@ -1,312 +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
- assertGenerationBudget,
12
- assertNoteEvents,
13
- assertTimeSignature,
14
- } from '../../core/validation/index.js';
15
- import {
16
- evaluateSafety,
17
- NoteSafety,
18
- type SafetyProfile,
19
- type VoiceSnapshot,
20
- } from '../../theory/safety/index.js';
21
- import {
22
- type CadenceHit,
23
- type ChordTimeline,
24
- chordTimelineFromNotes,
25
- detectCadences,
26
- } from '../timeline/index.js';
27
- import { type AnalyzedNote, analyzeVoice, type VoiceNote } from '../voice/index.js';
28
- import { covers, EPS, type PreparedTrack, poolNotes, prepareTracks } from './internal.js';
29
-
30
- /**
31
- * The musical role a track plays in the arrangement.
32
- *
33
- * @category Arrangement & Analysis
34
- */
35
- export type TrackRole = 'melody' | 'harmony' | 'bass' | 'other';
36
-
37
- /**
38
- * One input track: its notes plus optional name and role.
39
- *
40
- * @category Arrangement & Analysis
41
- */
42
- export type ArrangementTrack = {
43
- name?: string;
44
- role?: TrackRole;
45
- notes: NoteEvent[];
46
- };
47
-
48
- /**
49
- * A track after analysis: its resolved name, role, and per-note annotations.
50
- *
51
- * @category Arrangement & Analysis
52
- */
53
- export type TrackAnalysis = {
54
- name: string;
55
- role: TrackRole;
56
- /** One {@link AnalyzedNote} per note, labelled against the inferred chords. */
57
- notes: AnalyzedNote[];
58
- };
59
-
60
- /**
61
- * A note that clashes with the harmony sounding beneath it.
62
- *
63
- * @category Arrangement & Analysis
64
- */
65
- export type Conflict = {
66
- beat: number;
67
- trackName: string;
68
- pitch: number;
69
- /** The clash severity: {@link NoteSafety.Warning} or {@link NoteSafety.Dissonant}. */
70
- safety: NoteSafety;
71
- /** {@link import('../safety/index.js').SafetyResult.reasons} bitmask. */
72
- reasons: number;
73
- /** Preferred stepwise resolution, when the safety evaluator provides one. */
74
- resolveTo?: number;
75
- /** Nearby fully safe replacement pitches, nearest first. */
76
- suggestions?: number[];
77
- rationale?: string;
78
- };
79
-
80
- /**
81
- * The full result of {@link analyzeArrangement}.
82
- *
83
- * @category Arrangement & Analysis
84
- */
85
- export type ArrangementAnalysis = {
86
- key: KeyScale;
87
- timeline: ChordTimeline;
88
- segmentConfidence: number[];
89
- cadences: CadenceHit[];
90
- tracks: TrackAnalysis[];
91
- /** Notes clashing with the sounding harmony, worst severity first. */
92
- conflicts: Conflict[];
93
- };
94
-
95
- /**
96
- * Options controlling {@link analyzeArrangement} and {@link tensionCurve}.
97
- *
98
- * @category Arrangement & Analysis
99
- */
100
- export type ArrangementOptions = {
101
- /** Key context; inferred from the pooled notes when omitted. */
102
- key?: KeyScale;
103
- /**
104
- * Time signature; defaults to 4/4.
105
- *
106
- * @defaultValue `4/4`
107
- */
108
- ts?: TimeSignature;
109
- /**
110
- * Chord-slot length in beats; defaults to one bar of `ts`.
111
- *
112
- * @defaultValue one bar of `ts`
113
- */
114
- harmonicRhythm?: number;
115
- /**
116
- * Safety profile used for conflict detection; defaults to `pop`.
117
- *
118
- * @defaultValue `'pop'`
119
- */
120
- profile?: SafetyProfile;
121
- };
122
-
123
- /**
124
- * Sounding pitches of every sub-voice at a beat, excluding one sub-voice.
125
- *
126
- * Only the sub-voice under analysis is excluded — sibling sub-voices of the
127
- * same track are included, so dissonant clusters inside a single polyphonic
128
- * track are still detected.
129
- */
130
- function otherVoicesSounding(
131
- prepared: PreparedTrack[],
132
- excludeTrack: number,
133
- excludeVoice: number,
134
- beat: number,
135
- ): VoiceSnapshot[] {
136
- const out: VoiceSnapshot[] = [];
137
- for (let t = 0; t < prepared.length; t += 1) {
138
- const track = prepared[t];
139
- if (!track) {
140
- continue;
141
- }
142
- for (let v = 0; v < track.voices.length; v += 1) {
143
- if (t === excludeTrack && v === excludeVoice) {
144
- continue;
145
- }
146
- const subVoice = track.voices[v];
147
- if (!subVoice) {
148
- continue;
149
- }
150
- for (const note of subVoice.sounding) {
151
- if (!covers(note, beat)) {
152
- continue;
153
- }
154
- const snap: VoiceSnapshot = { pitch: note.pitch };
155
- // Motion reasons compare one real transition shared by both voices.
156
- // A voice attacking exactly here contributes its adjacent predecessor;
157
- // a sustained voice contributes the same pitch (oblique motion).
158
- const previous = Math.abs(note.startBeat - beat) <= EPS ? note.prevPitch : note.pitch;
159
- if (previous !== undefined) {
160
- snap.prevPitch = previous;
161
- }
162
- out.push(snap);
163
- }
164
- }
165
- }
166
- return out;
167
- }
168
-
169
- /**
170
- * The beats at which a note must be checked against the harmony: its onset,
171
- * plus the onset of every later chord segment the note sustains across. Each
172
- * crossed segment is visited exactly once (at its own start), so a boundary
173
- * beat is never evaluated twice for the same note.
174
- */
175
- function evaluationBeats(note: VoiceNote, timeline: ChordTimeline): number[] {
176
- const noteEnd = note.startBeat + note.durationBeat;
177
- const beats = [note.startBeat];
178
- for (const segment of timeline.segments) {
179
- if (segment.startBeat > note.startBeat + EPS && segment.startBeat < noteEnd - EPS) {
180
- beats.push(segment.startBeat);
181
- }
182
- }
183
- return beats;
184
- }
185
-
186
- /**
187
- * Analyse a whole arrangement against a single inferred harmony.
188
- *
189
- * The chord timeline and key are inferred from the pooled notes of every track
190
- * (see {@link chordTimelineFromNotes}); pooling all voices is robust even when
191
- * roles are absent or a track doubles the harmony, so it is preferred over
192
- * deriving the harmony from a subset. Zero- and negative-length notes never
193
- * sound, so they are dropped at ingest and appear in neither the annotations
194
- * nor the conflicts.
195
- *
196
- * Voice analysis assumes one monophonic voice at a time, so each track is
197
- * first partitioned into monophonic sub-voices (a polyphonic block-chord track
198
- * fans out into one sub-voice per simultaneous note; a monophonic track is
199
- * unaffected). Each sub-voice is labelled note-by-note against the harmony
200
- * with {@link analyzeVoice}, seeing every other sub-voice — including siblings
201
- * within the same track — as its accompaniment. Every note is then re-checked
202
- * with {@link evaluateSafety} at its onset and again at each chord change it
203
- * sustains across, so a held note that clashes with a later chord is caught:
204
- * evaluations that are not {@link NoteSafety.Safe} become conflicts, one per
205
- * clashing beat, sorted worst severity first and then by beat.
206
- *
207
- * @param tracks The tracks to analyse.
208
- * @param opts Analysis options; see {@link ArrangementOptions}.
209
- * @returns The inferred harmony, per-track annotations, cadences, and conflicts.
210
- * @example
211
- * ```ts
212
- * import { analyzeArrangement } from '@libraz/libcantus';
213
- * const melody = [
214
- * { pitch: 60, startBeat: 0, durationBeat: 2 },
215
- * { pitch: 67, startBeat: 2, durationBeat: 2 },
216
- * ];
217
- * const { key, conflicts } = analyzeArrangement([{ role: 'melody', notes: melody }]);
218
- * conflicts; // notes clashing with the inferred harmony, worst severity first
219
- * ```
220
- * @category Arrangement & Analysis
221
- */
222
- export function analyzeArrangement(
223
- tracks: ArrangementTrack[],
224
- opts: ArrangementOptions = {},
225
- ): ArrangementAnalysis {
226
- const ts = opts.ts ?? parseTimeSignature('4/4');
227
- assertTimeSignature(ts);
228
- assertGenerationBudget(tracks.length, 'arrangement tracks');
229
- for (let index = 0; index < tracks.length; index += 1) {
230
- assertNoteEvents(tracks[index]?.notes ?? [], `tracks[${index}].notes`, {
231
- allowNonPositiveDuration: true,
232
- });
233
- }
234
- const profile: SafetyProfile = opts.profile ?? 'pop';
235
- const pooled = poolNotes(tracks);
236
-
237
- const { timeline, key, segmentConfidence } = chordTimelineFromNotes(pooled, {
238
- key: opts.key,
239
- ts,
240
- harmonicRhythm: opts.harmonicRhythm,
241
- });
242
- const cadences = detectCadences(timeline, key);
243
- const prepared = prepareTracks(tracks);
244
-
245
- const trackAnalyses: TrackAnalysis[] = [];
246
- const conflicts: Conflict[] = [];
247
-
248
- for (let t = 0; t < prepared.length; t += 1) {
249
- const track = prepared[t];
250
- if (!track) {
251
- continue;
252
- }
253
- const notes: AnalyzedNote[] = [];
254
- for (let v = 0; v < track.voices.length; v += 1) {
255
- const subVoice = track.voices[v];
256
- if (!subVoice) {
257
- continue;
258
- }
259
- notes.push(
260
- ...analyzeVoice(subVoice.voice, timeline.at, key, (beat) =>
261
- otherVoicesSounding(prepared, t, v, beat),
262
- ),
263
- );
264
-
265
- for (let noteIndex = 0; noteIndex < subVoice.voice.length; noteIndex += 1) {
266
- const note = subVoice.voice[noteIndex];
267
- const preparedNote = subVoice.sounding[noteIndex];
268
- if (note === undefined || preparedNote === undefined) {
269
- continue;
270
- }
271
- for (const beat of evaluationBeats(note, timeline)) {
272
- const atOnset = Math.abs(beat - note.startBeat) <= EPS;
273
- const result = evaluateSafety({
274
- profile,
275
- candidatePitch: note.pitch,
276
- prevPitch: atOnset ? preparedNote.prevPitch : note.pitch,
277
- chord: timeline.at(beat),
278
- key,
279
- otherVoices: otherVoicesSounding(prepared, t, v, beat),
280
- strongBeat: isStrongBeat(beat, ts),
281
- });
282
- if (result.safety !== NoteSafety.Safe) {
283
- const conflict: Conflict = {
284
- beat,
285
- trackName: track.name,
286
- pitch: note.pitch,
287
- safety: result.safety,
288
- reasons: result.reasons,
289
- };
290
- if (result.rationale !== undefined) {
291
- conflict.rationale = result.rationale;
292
- }
293
- if (result.resolveTo !== undefined) {
294
- conflict.resolveTo = result.resolveTo;
295
- }
296
- if (result.suggestions !== undefined) {
297
- conflict.suggestions = [...result.suggestions];
298
- }
299
- conflicts.push(conflict);
300
- }
301
- }
302
- }
303
- }
304
- // Sub-voice results interleave; restore the track's onset-then-pitch order.
305
- notes.sort((a, b) => a.noteId - b.noteId);
306
- trackAnalyses.push({ name: track.name, role: track.role, notes });
307
- }
308
-
309
- conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
310
-
311
- return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
312
- }