@libraz/libcantus 0.9.0 → 0.9.1

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