@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.
@@ -5,6 +5,8 @@ import type { KeyScale } from '../types.js';
5
5
  *
6
6
  * Bit 0 (the root) is always set, enforcing the `KeyScale` invariant that the
7
7
  * root is a scale tone even when the offset list omits 0.
8
+ *
9
+ * @category Scales
8
10
  */
9
11
  export function maskFromOffsets(offsets: readonly number[]): number {
10
12
  let mask = 1;
@@ -14,55 +16,123 @@ export function maskFromOffsets(offsets: readonly number[]): number {
14
16
  return mask;
15
17
  }
16
18
 
17
- /** Mode mask for the major (Ionian) scale: offsets {0, 2, 4, 5, 7, 9, 11}. */
19
+ /**
20
+ * Mode mask for the major (Ionian) scale: offsets {0, 2, 4, 5, 7, 9, 11}.
21
+ *
22
+ * @category Scales
23
+ */
18
24
  export const MAJOR_MASK = 0b101010110101;
19
25
 
20
- /** Mode mask for the natural minor (Aeolian) scale: offsets {0, 2, 3, 5, 7, 8, 10}. */
26
+ /**
27
+ * Mode mask for the natural minor (Aeolian) scale: offsets {0, 2, 3, 5, 7, 8, 10}.
28
+ *
29
+ * @category Scales
30
+ */
21
31
  export const NATURAL_MINOR_MASK = 0b010110101101;
22
32
 
23
- /** Harmonic minor: natural minor with a raised seventh — offsets {0,2,3,5,7,8,11}. */
33
+ /**
34
+ * Harmonic minor: natural minor with a raised seventh — offsets {0,2,3,5,7,8,11}.
35
+ *
36
+ * @category Scales
37
+ */
24
38
  export const HARMONIC_MINOR_MASK = maskFromOffsets([0, 2, 3, 5, 7, 8, 11]);
25
39
 
26
- /** Ascending melodic minor: offsets {0,2,3,5,7,9,11}. */
40
+ /**
41
+ * Ascending melodic minor: offsets {0,2,3,5,7,9,11}.
42
+ *
43
+ * @category Scales
44
+ */
27
45
  export const MELODIC_MINOR_MASK = maskFromOffsets([0, 2, 3, 5, 7, 9, 11]);
28
46
 
29
- /** Dorian mode: offsets {0,2,3,5,7,9,10}. */
47
+ /**
48
+ * Dorian mode: offsets {0,2,3,5,7,9,10}.
49
+ *
50
+ * @category Scales
51
+ */
30
52
  export const DORIAN_MASK = maskFromOffsets([0, 2, 3, 5, 7, 9, 10]);
31
53
 
32
- /** Phrygian mode: offsets {0,1,3,5,7,8,10}. */
54
+ /**
55
+ * Phrygian mode: offsets {0,1,3,5,7,8,10}.
56
+ *
57
+ * @category Scales
58
+ */
33
59
  export const PHRYGIAN_MASK = maskFromOffsets([0, 1, 3, 5, 7, 8, 10]);
34
60
 
35
- /** Lydian mode: offsets {0,2,4,6,7,9,11}. */
61
+ /**
62
+ * Lydian mode: offsets {0,2,4,6,7,9,11}.
63
+ *
64
+ * @category Scales
65
+ */
36
66
  export const LYDIAN_MASK = maskFromOffsets([0, 2, 4, 6, 7, 9, 11]);
37
67
 
38
- /** Mixolydian mode: offsets {0,2,4,5,7,9,10}. */
68
+ /**
69
+ * Mixolydian mode: offsets {0,2,4,5,7,9,10}.
70
+ *
71
+ * @category Scales
72
+ */
39
73
  export const MIXOLYDIAN_MASK = maskFromOffsets([0, 2, 4, 5, 7, 9, 10]);
40
74
 
41
- /** Locrian mode: offsets {0,1,3,5,6,8,10}. */
75
+ /**
76
+ * Locrian mode: offsets {0,1,3,5,6,8,10}.
77
+ *
78
+ * @category Scales
79
+ */
42
80
  export const LOCRIAN_MASK = maskFromOffsets([0, 1, 3, 5, 6, 8, 10]);
43
81
 
44
- /** Major pentatonic: offsets {0,2,4,7,9}. */
82
+ /**
83
+ * Major pentatonic: offsets {0,2,4,7,9}.
84
+ *
85
+ * @category Scales
86
+ */
45
87
  export const MAJOR_PENTATONIC_MASK = maskFromOffsets([0, 2, 4, 7, 9]);
46
88
 
47
- /** Minor pentatonic: offsets {0,3,5,7,10}. */
89
+ /**
90
+ * Minor pentatonic: offsets {0,3,5,7,10}.
91
+ *
92
+ * @category Scales
93
+ */
48
94
  export const MINOR_PENTATONIC_MASK = maskFromOffsets([0, 3, 5, 7, 10]);
49
95
 
50
- /** Hexatonic blues scale: minor pentatonic plus the flat-fifth — {0,3,5,6,7,10}. */
96
+ /**
97
+ * Hexatonic blues scale: minor pentatonic plus the flat-fifth — {0,3,5,6,7,10}.
98
+ *
99
+ * @category Scales
100
+ */
51
101
  export const BLUES_MASK = maskFromOffsets([0, 3, 5, 6, 7, 10]);
52
102
 
53
- /** Whole-tone scale: offsets {0,2,4,6,8,10}. */
103
+ /**
104
+ * Whole-tone scale: offsets {0,2,4,6,8,10}.
105
+ *
106
+ * @category Scales
107
+ */
54
108
  export const WHOLE_TONE_MASK = maskFromOffsets([0, 2, 4, 6, 8, 10]);
55
109
 
56
- /** Octatonic (half-step first): offsets {0,1,3,4,6,7,9,10}. */
110
+ /**
111
+ * Octatonic (half-step first): offsets {0,1,3,4,6,7,9,10}.
112
+ *
113
+ * @category Scales
114
+ */
57
115
  export const OCTATONIC_HALF_WHOLE_MASK = maskFromOffsets([0, 1, 3, 4, 6, 7, 9, 10]);
58
116
 
59
- /** Octatonic (whole-step first): offsets {0,2,3,5,6,8,9,11}. */
117
+ /**
118
+ * Octatonic (whole-step first): offsets {0,2,3,5,6,8,9,11}.
119
+ *
120
+ * @category Scales
121
+ */
60
122
  export const OCTATONIC_WHOLE_HALF_MASK = maskFromOffsets([0, 2, 3, 5, 6, 8, 9, 11]);
61
123
 
62
- /** Chromatic scale: all twelve pitch classes. */
124
+ /**
125
+ * Chromatic scale: all twelve pitch classes.
126
+ *
127
+ * @category Scales
128
+ */
63
129
  export const CHROMATIC_MASK = 0b111111111111;
64
130
 
65
- /** Named scale masks addressable by {@link scaleByName}. */
131
+ /**
132
+ * Named scale masks addressable by {@link scaleByName}.
133
+ *
134
+ * @category Scales
135
+ */
66
136
  export const NAMED_SCALES: Record<string, number> = {
67
137
  major: MAJOR_MASK,
68
138
  ionian: MAJOR_MASK,
@@ -84,12 +154,32 @@ export const NAMED_SCALES: Record<string, number> = {
84
154
  chromatic: CHROMATIC_MASK,
85
155
  };
86
156
 
87
- /** Build a `KeyScale` for a major key on the given root pitch class. */
157
+ /**
158
+ * Build a `KeyScale` for a major key on the given root pitch class.
159
+ *
160
+ * @example
161
+ * ```ts
162
+ * import { majorKey } from '@libraz/libcantus';
163
+ * const cMajor = majorKey(0); // C major: { rootPc: 0, modeMask12: MAJOR_MASK }
164
+ * ```
165
+ *
166
+ * @category Scales
167
+ */
88
168
  export function majorKey(rootPc: number): KeyScale {
89
169
  return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: MAJOR_MASK };
90
170
  }
91
171
 
92
- /** Build a `KeyScale` for a natural-minor key on the given root pitch class. */
172
+ /**
173
+ * Build a `KeyScale` for a natural-minor key on the given root pitch class.
174
+ *
175
+ * @example
176
+ * ```ts
177
+ * import { minorKey } from '@libraz/libcantus';
178
+ * const aMinor = minorKey(9); // A natural minor: { rootPc: 9, modeMask12: NATURAL_MINOR_MASK }
179
+ * ```
180
+ *
181
+ * @category Scales
182
+ */
93
183
  export function minorKey(rootPc: number): KeyScale {
94
184
  return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: NATURAL_MINOR_MASK };
95
185
  }
@@ -101,6 +191,14 @@ export function minorKey(rootPc: number): KeyScale {
101
191
  * @param rootPc The root pitch class.
102
192
  * @returns The key/scale.
103
193
  * @throws If the name is not a known scale.
194
+ *
195
+ * @example
196
+ * ```ts
197
+ * import { scaleByName } from '@libraz/libcantus';
198
+ * const dDorian = scaleByName('dorian', 2); // D Dorian, rootPc 2
199
+ * ```
200
+ *
201
+ * @category Scales
104
202
  */
105
203
  export function scaleByName(name: string, rootPc: number): KeyScale {
106
204
  const mask = NAMED_SCALES[name];
@@ -126,6 +224,16 @@ function scaleOffset(pitch: number, key: KeyScale): number {
126
224
  * @param pitch MIDI pitch or bare pitch class.
127
225
  * @param key The key/scale to test against.
128
226
  * @returns True if the pitch class is a member of the scale.
227
+ *
228
+ * @example
229
+ * ```ts
230
+ * import { majorKey, isScaleTone } from '@libraz/libcantus';
231
+ * const key = majorKey(0); // C major
232
+ * isScaleTone(64, key); // true — E is in C major
233
+ * isScaleTone(61, key); // false — C# is not
234
+ * ```
235
+ *
236
+ * @category Scales
129
237
  */
130
238
  export function isScaleTone(pitch: number, key: KeyScale): boolean {
131
239
  return ((key.modeMask12 >> scaleOffset(pitch, key)) & 1) === 1;
@@ -140,6 +248,8 @@ export function isScaleTone(pitch: number, key: KeyScale): boolean {
140
248
  * @param pitch MIDI pitch to snap.
141
249
  * @param key The key/scale to snap to.
142
250
  * @returns The nearest in-scale MIDI pitch.
251
+ *
252
+ * @category Scales
143
253
  */
144
254
  export function nearestScaleTone(pitch: number, key: KeyScale): number {
145
255
  const base = Math.round(pitch);
@@ -162,6 +272,8 @@ export function nearestScaleTone(pitch: number, key: KeyScale): number {
162
272
  * @param pitch MIDI pitch or bare pitch class.
163
273
  * @param key The key/scale to measure against.
164
274
  * @returns The 0-based degree, or -1 if the pitch is not in the scale.
275
+ *
276
+ * @category Scales
165
277
  */
166
278
  export function pitchToScaleDegree(pitch: number, key: KeyScale): number {
167
279
  const offset = scaleOffset(pitch, key);
@@ -182,6 +294,8 @@ export function pitchToScaleDegree(pitch: number, key: KeyScale): number {
182
294
  *
183
295
  * @param key The key/scale to enumerate.
184
296
  * @returns The member pitch classes, sorted ascending in [0, 11].
297
+ *
298
+ * @category Scales
185
299
  */
186
300
  export function diatonicPitchClasses(key: KeyScale): number[] {
187
301
  const root = pitchClass(key.rootPc);
@@ -202,6 +316,8 @@ export function diatonicPitchClasses(key: KeyScale): number[] {
202
316
  *
203
317
  * @param key The key/scale to enumerate.
204
318
  * @returns The member pitch classes ordered by scale degree.
319
+ *
320
+ * @category Scales
205
321
  */
206
322
  export function scaleTonesInDegreeOrder(key: KeyScale): number[] {
207
323
  const root = pitchClass(key.rootPc);
@@ -79,6 +79,7 @@ function isHeptatonic(key: KeyScale): boolean {
79
79
  * @param tonic The spelled tonic (its letter anchors the spelling).
80
80
  * @param key The key/scale.
81
81
  * @returns The spelled note (without octave).
82
+ * @category Pitch & Intervals
82
83
  */
83
84
  export function spellPitchClass(pc: number, tonic: Note, key: KeyScale): Note {
84
85
  const tonicPc = mod12(naturalPc(tonic.letter) + tonic.alter);
@@ -117,6 +118,13 @@ export function spellPitchClass(pc: number, tonic: Note, key: KeyScale): Note {
117
118
  * @param tonic The spelled tonic.
118
119
  * @param key The key/scale.
119
120
  * @returns Spelled notes, one per scale degree.
121
+ * @example
122
+ * ```ts
123
+ * import { spellScale, noteNames, parseNote, majorKey } from '@libraz/libcantus';
124
+ * noteNames(spellScale(parseNote('C'), majorKey(0)));
125
+ * // ['C', 'D', 'E', 'F', 'G', 'A', 'B']
126
+ * ```
127
+ * @category Pitch & Intervals
120
128
  */
121
129
  export function spellScale(tonic: Note, key: KeyScale): Note[] {
122
130
  return scaleTonesInDegreeOrder(key).map((pc) => spellPitchClass(pc, tonic, key));
@@ -129,6 +137,7 @@ export function spellScale(tonic: Note, key: KeyScale): Note[] {
129
137
  * @param tonic The spelled tonic.
130
138
  * @param key The key/scale.
131
139
  * @returns Spelled notes, in input order.
140
+ * @category Pitch & Intervals
132
141
  */
133
142
  export function spellPitchClasses(pcs: number[], tonic: Note, key: KeyScale): Note[] {
134
143
  return pcs.map((pc) => spellPitchClass(pc, tonic, key));
@@ -146,6 +155,14 @@ export function spellPitchClasses(pcs: number[], tonic: Note, key: KeyScale): No
146
155
  * @param tonic The spelled tonic of the key.
147
156
  * @param key The key/scale.
148
157
  * @returns Spelled chord tones, root first.
158
+ * @example
159
+ * ```ts
160
+ * import { spellChord, noteNames, parseNote, majorKey, makeChord } from '@libraz/libcantus';
161
+ * // G7 in C major
162
+ * noteNames(spellChord(makeChord(7, 'dom7'), parseNote('C'), majorKey(0)));
163
+ * // ['G', 'B', 'D', 'F']
164
+ * ```
165
+ * @category Pitch & Intervals
149
166
  */
150
167
  export function spellChord(chord: Chord, tonic: Note, key: KeyScale): Note[] {
151
168
  return chord.intervals.map((interval) =>
@@ -158,6 +175,7 @@ export function spellChord(chord: Chord, tonic: Note, key: KeyScale): Note[] {
158
175
  *
159
176
  * @param notes The notes.
160
177
  * @returns Their formatted names.
178
+ * @category Pitch & Intervals
161
179
  */
162
180
  export function noteNames(notes: Note[]): string[] {
163
181
  return notes.map(formatNote);
@@ -5,14 +5,17 @@
5
5
  * (`Cmaj7`, `F#m7b5`, `Bb7`, `C/G`) and the library's structured {@link Chord}
6
6
  * type, so callers can accept/emit plain text at the edges of the API.
7
7
  */
8
- import { type Chord, type ChordQuality, makeChord } from '../chord/index.js';
9
- import { formatNote, midiToNote, noteToPitchClass, parseNote } from '../pitch/index.js';
8
+ import { type Chord, type ChordQuality, makeChord, type PitchSpelling } from '../chord/index.js';
9
+ import { formatNote, midiToNote, type Note, noteToPitchClass, parseNote } from '../pitch/index.js';
10
10
 
11
- /** Matches a chord root: a letter A-G with an optional single-direction accidental. */
12
- const ROOT_RE = /^[A-G](?:#{1,2}|b{1,2})?/;
11
+ /**
12
+ * Matches a chord root: a letter A-G (case-insensitive, like {@link parseNote})
13
+ * with an optional single-direction accidental.
14
+ */
15
+ const ROOT_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?/;
13
16
 
14
17
  /** Matches a bare note usable as a slash-chord bass (no octave). */
15
- const BASS_RE = /^[A-G](?:#{1,2}|b{1,2})?$/;
18
+ const BASS_RE = /^[A-Ga-g](?:#{1,2}|b{1,2})?$/;
16
19
 
17
20
  /** Recognized quality suffixes, keyed by their exact lead-sheet spelling. */
18
21
  const QUALITY_MAP: Record<string, ChordQuality> = {
@@ -114,22 +117,44 @@ function pitchClass(value: number): number {
114
117
  return ((Math.trunc(value) % 12) + 12) % 12;
115
118
  }
116
119
 
117
- /** Pitch class of a bare note token (letter plus optional accidental). */
118
- function bassPitchClass(token: string): number {
119
- return noteToPitchClass(parseNote(token));
120
+ /** Build a chord and record the parsed spellings as enharmonic hints. */
121
+ function makeSpelledChord(root: Note, quality: ChordQuality, bass?: Note): Chord {
122
+ const chord = makeChord(
123
+ noteToPitchClass(root),
124
+ quality,
125
+ bass === undefined ? undefined : noteToPitchClass(bass),
126
+ );
127
+ chord.rootSpelling = { letter: root.letter, alter: root.alter };
128
+ if (bass !== undefined) {
129
+ chord.bassSpelling = { letter: bass.letter, alter: bass.alter };
130
+ }
131
+ return chord;
120
132
  }
121
133
 
122
134
  /**
123
135
  * Parse a lead-sheet chord symbol into a {@link Chord}.
124
136
  *
125
137
  * Accepts `<root><quality>[/<bass>]`, e.g. `Cmaj7`, `F#m7b5`, `Bb7`, `C/G`,
126
- * `C6/9`. The `/` after a quality is ambiguous between a slash bass and the
127
- * `6/9` quality; it resolves to a bass only when the token following it is
128
- * itself a valid note.
138
+ * `C6/9`, `C6/9/E`. Root and bass letters are case-insensitive, matching
139
+ * {@link parseNote}. A `/` is ambiguous between a slash bass and the `6/9`
140
+ * quality; the text splits at the last `/` whose right-hand side is a valid
141
+ * bare note preceded by a recognized quality, so `6/9` (with or without a
142
+ * further slash bass) parses as a quality.
143
+ *
144
+ * The parsed chord carries the root/bass spellings as enharmonic hints
145
+ * (`rootSpelling`/`bassSpelling`) so {@link formatChordSymbol} can reproduce
146
+ * flat spellings such as `Bbmaj7` instead of respelling them with sharps.
129
147
  *
130
148
  * @param text The chord symbol text.
131
149
  * @returns The parsed chord.
132
150
  * @throws If the root or quality is not recognized.
151
+ * @example
152
+ * ```ts
153
+ * import { parseChordSymbol, formatChordSymbol } from '@libraz/libcantus';
154
+ * const c = parseChordSymbol('Bbmaj7');
155
+ * formatChordSymbol(c); // 'Bbmaj7' — flat spelling is preserved on round-trip
156
+ * ```
157
+ * @category Chords
133
158
  */
134
159
  export function parseChordSymbol(text: string): Chord {
135
160
  const trimmed = text.trim();
@@ -138,31 +163,41 @@ export function parseChordSymbol(text: string): Chord {
138
163
  throw new Error(`Invalid chord symbol: ${text}`);
139
164
  }
140
165
  const rootToken = rootMatch[0];
141
- const rootPc = noteToPitchClass(parseNote(rootToken));
166
+ const rootNote = parseNote(rootToken);
142
167
  const rest = trimmed.slice(rootToken.length);
143
168
 
144
- const slashIndex = rest.indexOf('/');
145
- if (slashIndex === -1) {
146
- const quality = QUALITY_MAP[rest];
147
- if (quality === undefined) {
148
- throw new Error(`Unrecognized chord quality: ${text}`);
169
+ // Split at the last '/' so the '6/9' quality's own slash never masks a
170
+ // trailing slash bass (e.g. 'C6/9/E').
171
+ const slashIndex = rest.lastIndexOf('/');
172
+ if (slashIndex !== -1) {
173
+ const before = rest.slice(0, slashIndex);
174
+ const after = rest.slice(slashIndex + 1);
175
+ const beforeQuality = QUALITY_MAP[before];
176
+ if (beforeQuality !== undefined && BASS_RE.test(after)) {
177
+ return makeSpelledChord(rootNote, beforeQuality, parseNote(after));
149
178
  }
150
- return makeChord(rootPc, quality);
151
179
  }
152
180
 
153
- const before = rest.slice(0, slashIndex);
154
- const after = rest.slice(slashIndex + 1);
155
- const beforeQuality = QUALITY_MAP[before];
156
- if (beforeQuality !== undefined && BASS_RE.test(after)) {
157
- return makeChord(rootPc, beforeQuality, bassPitchClass(after));
181
+ const quality = QUALITY_MAP[rest];
182
+ if (quality === undefined) {
183
+ throw new Error(`Unrecognized chord quality: ${text}`);
158
184
  }
185
+ return makeSpelledChord(rootNote, quality);
186
+ }
159
187
 
160
- const combinedQuality = QUALITY_MAP[rest];
161
- if (combinedQuality !== undefined) {
162
- return makeChord(rootPc, combinedQuality);
188
+ /**
189
+ * Name a pitch class, preferring a spelling hint when it is still valid.
190
+ *
191
+ * A hint is used only when no explicit sharp/flat preference was given and the
192
+ * hint still resolves to `pc` (a stale hint left over after transposition is
193
+ * ignored). Otherwise the pitch class is respelled from the requested table.
194
+ */
195
+ function pitchClassName(pc: number, hint: PitchSpelling | undefined, flats?: boolean): string {
196
+ if (flats === undefined && hint !== undefined && noteToPitchClass(hint) === pc) {
197
+ return formatNote(hint);
163
198
  }
164
-
165
- throw new Error(`Unrecognized chord quality: ${text}`);
199
+ const note = midiToNote(60 + pc, flats ? 'flat' : 'sharp');
200
+ return formatNote({ letter: note.letter, alter: note.alter });
166
201
  }
167
202
 
168
203
  /**
@@ -170,22 +205,29 @@ export function parseChordSymbol(text: string): Chord {
170
205
  *
171
206
  * The inverse of {@link parseChordSymbol}: each quality maps to one canonical
172
207
  * suffix spelling, and a slash bass is appended only when it differs from the
173
- * root.
208
+ * root. When the chord carries `rootSpelling`/`bassSpelling` hints (as chords
209
+ * from {@link parseChordSymbol} do) and no explicit `flats` preference is
210
+ * given, the hints are reused so flat symbols round-trip unchanged.
174
211
  *
175
212
  * @param chord The chord to format.
176
213
  * @param opts Formatting options.
177
- * @param opts.flats Prefer flat spellings over sharps for altered roots/basses.
214
+ * @param opts.flats Prefer flat spellings over sharps for altered roots/basses;
215
+ * passing either `true` or `false` overrides any spelling hints.
178
216
  * @returns The chord symbol text.
217
+ * @example
218
+ * ```ts
219
+ * import { makeChord, formatChordSymbol } from '@libraz/libcantus';
220
+ * formatChordSymbol(makeChord(0, 'min7')); // 'Cm7'
221
+ * ```
222
+ * @category Chords
179
223
  */
180
224
  export function formatChordSymbol(chord: Chord, opts?: { flats?: boolean }): string {
181
- const spelling = opts?.flats ? 'flat' : 'sharp';
182
- const rootNote = midiToNote(60 + pitchClass(chord.rootPc), spelling);
183
- const rootName = formatNote({ letter: rootNote.letter, alter: rootNote.alter });
225
+ const rootPc = pitchClass(chord.rootPc);
226
+ const rootName = pitchClassName(rootPc, chord.rootSpelling, opts?.flats);
184
227
  const suffix = CANONICAL_SUFFIX[chord.quality];
185
228
  let symbol = `${rootName}${suffix}`;
186
- if (chord.bassPc !== undefined && pitchClass(chord.bassPc) !== pitchClass(chord.rootPc)) {
187
- const bassNote = midiToNote(60 + pitchClass(chord.bassPc), spelling);
188
- symbol += `/${formatNote({ letter: bassNote.letter, alter: bassNote.alter })}`;
229
+ if (chord.bassPc !== undefined && pitchClass(chord.bassPc) !== rootPc) {
230
+ symbol += `/${pitchClassName(pitchClass(chord.bassPc), chord.bassSpelling, opts?.flats)}`;
189
231
  }
190
232
  return symbol;
191
233
  }
@@ -198,6 +240,12 @@ export function formatChordSymbol(chord: Chord, opts?: { flats?: boolean }): str
198
240
  * @param opts Formatting options for the result; see {@link formatChordSymbol}.
199
241
  * @returns The transposed chord symbol text.
200
242
  * @throws If `text` does not parse as a chord symbol.
243
+ * @example
244
+ * ```ts
245
+ * import { transposeChordSymbol } from '@libraz/libcantus';
246
+ * transposeChordSymbol('C/G', 2); // 'D/A'
247
+ * ```
248
+ * @category Chords
201
249
  */
202
250
  export function transposeChordSymbol(
203
251
  text: string,
@@ -10,14 +10,22 @@ import type { GeneratedChord } from '../progression/index.js';
10
10
  import { isScaleTone, majorKey } from '../scale/index.js';
11
11
  import type { KeyScale, NoteEvent } from '../types.js';
12
12
 
13
- /** A chord occupying a half-open beat span. */
13
+ /**
14
+ * A chord occupying a half-open beat span.
15
+ *
16
+ * @category Arrangement & Analysis
17
+ */
14
18
  export type ChordSegment = {
15
19
  startBeat: number;
16
20
  endBeat: number;
17
21
  chord: Chord;
18
22
  };
19
23
 
20
- /** A beat-indexed sequence of chord segments. */
24
+ /**
25
+ * A beat-indexed sequence of chord segments.
26
+ *
27
+ * @category Arrangement & Analysis
28
+ */
21
29
  export type ChordTimeline = {
22
30
  /** The chord sounding at a beat, or null when no segment covers it. */
23
31
  at: (beat: number) => Chord | null;
@@ -35,6 +43,7 @@ export type ChordTimeline = {
35
43
  * @param chords Placed chords in time order.
36
44
  * @param totalBeats End of the timeline in beats.
37
45
  * @returns A queryable chord timeline.
46
+ * @category Arrangement & Analysis
38
47
  */
39
48
  export function chordTimelineFromChords(
40
49
  chords: GeneratedChord[],
@@ -96,19 +105,39 @@ function pitchClass(value: number): number {
96
105
  return ((Math.trunc(value) % 12) + 12) % 12;
97
106
  }
98
107
 
99
- /** Options controlling {@link chordTimelineFromNotes}. */
108
+ /**
109
+ * Options controlling {@link chordTimelineFromNotes}.
110
+ *
111
+ * @category Arrangement & Analysis
112
+ */
100
113
  export type ChordTimelineOptions = {
101
114
  /** Key context; inferred from the notes with {@link detectKey} when omitted. */
102
115
  key?: KeyScale;
103
- /** Time signature used for metric accents; defaults to 4/4. */
116
+ /**
117
+ * Time signature used for metric accents; defaults to 4/4.
118
+ *
119
+ * @defaultValue `4/4`
120
+ */
104
121
  ts?: TimeSignature;
105
- /** Window length in beats per chord slot; defaults to one bar of `ts`. */
122
+ /**
123
+ * Window length in beats per chord slot; defaults to one bar of `ts`.
124
+ *
125
+ * @defaultValue one bar of `ts`
126
+ */
106
127
  harmonicRhythm?: number;
107
- /** End of the analyzed span in beats; defaults to the end of the last note. */
128
+ /**
129
+ * End of the analyzed span in beats; defaults to the end of the last note.
130
+ *
131
+ * @defaultValue the end of the last note
132
+ */
108
133
  totalBeats?: number;
109
134
  };
110
135
 
111
- /** The result of {@link chordTimelineFromNotes}. */
136
+ /**
137
+ * The result of {@link chordTimelineFromNotes}.
138
+ *
139
+ * @category Arrangement & Analysis
140
+ */
112
141
  export type ChordTimelineResult = {
113
142
  /** The inferred timeline, with adjacent identical chords merged. */
114
143
  timeline: ChordTimeline;
@@ -266,10 +295,26 @@ function sameChord(a: Chord, b: Chord): boolean {
266
295
  * the fraction of the window weight explained by chord tones, reduced when the
267
296
  * match is inexact, and duration-weighted across merged windows.
268
297
  *
298
+ * Notes with a zero or negative duration never sound, so they are dropped at
299
+ * ingest: they contribute to neither the key inference, the span, nor any
300
+ * window's histogram.
301
+ *
269
302
  * @param notes The notes to analyze (any number of tracks, flattened).
270
303
  * @param opts Analysis options; see {@link ChordTimelineOptions}.
271
304
  * @returns The inferred timeline, the key used, and per-segment confidences.
272
305
  * @throws If `harmonicRhythm` is not positive.
306
+ * @example
307
+ * ```ts
308
+ * import { chordTimelineFromNotes } from '@libraz/libcantus';
309
+ * const notes = [
310
+ * { pitch: 60, startBeat: 0, durationBeat: 2 }, // C
311
+ * { pitch: 64, startBeat: 0, durationBeat: 2 }, // E
312
+ * { pitch: 67, startBeat: 0, durationBeat: 2 }, // G
313
+ * ];
314
+ * const { timeline, key } = chordTimelineFromNotes(notes);
315
+ * timeline.at(0); // the chord inferred over beat 0, or null
316
+ * ```
317
+ * @category Arrangement & Analysis
273
318
  */
274
319
  export function chordTimelineFromNotes(
275
320
  notes: NoteEvent[],
@@ -280,9 +325,11 @@ export function chordTimelineFromNotes(
280
325
  if (!(harmonicRhythm > 0)) {
281
326
  throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
282
327
  }
283
- const lastNoteEnd = notes.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
328
+ // Zero/negative-length notes never sound; drop them before any inference.
329
+ const sounding = notes.filter((note) => note.durationBeat > 0);
330
+ const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
284
331
  const totalBeats = opts.totalBeats ?? lastNoteEnd;
285
- const key = opts.key ?? detectKey(notes.map((n) => n.pitch))[0]?.key ?? majorKey(0);
332
+ const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
286
333
 
287
334
  const segments: ChordSegment[] = [];
288
335
  const segmentConfidence: number[] = [];
@@ -290,7 +337,7 @@ export function chordTimelineFromNotes(
290
337
  for (let i = 0; i < windowCount; i += 1) {
291
338
  const start = i * harmonicRhythm;
292
339
  const end = Math.min(start + harmonicRhythm, totalBeats);
293
- const inferred = analyzeWindow(notes, start, end, ts, key);
340
+ const inferred = analyzeWindow(sounding, start, end, ts, key);
294
341
  if (!inferred) {
295
342
  continue;
296
343
  }
@@ -316,7 +363,11 @@ export function chordTimelineFromNotes(
316
363
  };
317
364
  }
318
365
 
319
- /** A cadence found between two consecutive timeline segments. */
366
+ /**
367
+ * A cadence found between two consecutive timeline segments.
368
+ *
369
+ * @category Arrangement & Analysis
370
+ */
320
371
  export type CadenceHit = {
321
372
  /** The beat where the cadence arrives (the second chord's onset). */
322
373
  atBeat: number;
@@ -328,12 +379,25 @@ export type CadenceHit = {
328
379
  /**
329
380
  * Find cadences between consecutive segments of a chord timeline.
330
381
  *
331
- * Each adjacent segment pair is classified with {@link detectCadence}; pairs
332
- * forming no cadence are skipped.
382
+ * Each temporally adjacent segment pair is classified with
383
+ * {@link detectCadence}; pairs forming no cadence are skipped. Segments
384
+ * separated by a gap (a rest in the timeline) are not a chord-to-chord
385
+ * progression, so they are never paired.
333
386
  *
334
387
  * @param timeline The chord timeline to scan.
335
388
  * @param key The prevailing key.
336
389
  * @returns The cadences found, in time order.
390
+ * @example
391
+ * ```ts
392
+ * import { chordTimelineFromNotes, detectCadences } from '@libraz/libcantus';
393
+ * const notes = [
394
+ * { pitch: 67, startBeat: 0, durationBeat: 4 }, // G, a dominant
395
+ * { pitch: 60, startBeat: 4, durationBeat: 4 }, // C, the tonic
396
+ * ];
397
+ * const { timeline, key } = chordTimelineFromNotes(notes);
398
+ * const cadences = detectCadences(timeline, key);
399
+ * ```
400
+ * @category Arrangement & Analysis
337
401
  */
338
402
  export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceHit[] {
339
403
  const hits: CadenceHit[] = [];
@@ -343,6 +407,9 @@ export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceH
343
407
  if (!prev || !cur) {
344
408
  continue;
345
409
  }
410
+ if (Math.abs(cur.startBeat - prev.endBeat) > EPS) {
411
+ continue; // A rest separates the chords; no cadential motion across it.
412
+ }
346
413
  const type = detectCadence(prev.chord, cur.chord, key);
347
414
  if (type !== null) {
348
415
  hits.push({ atBeat: cur.startBeat, type, from: prev.chord, to: cur.chord });