@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.
@@ -81,7 +81,14 @@ function copyNote(data: NoteData): NoteData {
81
81
  return copy;
82
82
  }
83
83
 
84
- /** Defensive copy of a plain chord. */
84
+ /**
85
+ * Defensive copy of a plain chord.
86
+ *
87
+ * Any enharmonic spelling hints (`rootSpelling`/`bassSpelling`, populated by
88
+ * `parseChordSymbol`) are carried through so a flat-named chord round-trips
89
+ * through the class API; a hint that no longer matches its pitch class is
90
+ * simply ignored by the formatter.
91
+ */
85
92
  function copyChord(data: ChordData): ChordData {
86
93
  const copy: ChordData = {
87
94
  rootPc: data.rootPc,
@@ -91,6 +98,12 @@ function copyChord(data: ChordData): ChordData {
91
98
  if (data.bassPc !== undefined) {
92
99
  copy.bassPc = data.bassPc;
93
100
  }
101
+ if (data.rootSpelling !== undefined) {
102
+ copy.rootSpelling = { letter: data.rootSpelling.letter, alter: data.rootSpelling.alter };
103
+ }
104
+ if (data.bassSpelling !== undefined) {
105
+ copy.bassSpelling = { letter: data.bassSpelling.letter, alter: data.bassSpelling.alter };
106
+ }
94
107
  return copy;
95
108
  }
96
109
 
@@ -104,6 +117,13 @@ function spellPitchClassBare(pc: number, spelling: 'sharp' | 'flat'): NoteData {
104
117
  * An immutable spelled note: a diatonic letter plus a chromatic alteration and
105
118
  * an optional octave. Wraps the plain note object and delegates to the pitch
106
119
  * module; every transformation returns a new instance.
120
+ *
121
+ * @category Class API
122
+ * @example
123
+ * ```ts
124
+ * import { Note } from '@libraz/libcantus';
125
+ * Note.of('C4').transpose(7).name; // 'G4'
126
+ * ```
107
127
  */
108
128
  export class Note {
109
129
  readonly #data: NoteData;
@@ -196,12 +216,16 @@ export class Note {
196
216
  *
197
217
  * When the note carries an octave the transposition happens in MIDI space and
198
218
  * the result is spelled with a sharp preference. An octave-less note stays
199
- * octave-less: only its pitch class is moved.
219
+ * octave-less: only its pitch class is moved. Transposing by zero is the
220
+ * identity: the original spelling is preserved (no enharmonic respelling).
200
221
  *
201
222
  * @param semitones The signed semitone offset.
202
223
  * @returns The transposed note.
203
224
  */
204
225
  transpose(semitones: number): Note {
226
+ if (semitones === 0) {
227
+ return new Note(this.#data);
228
+ }
205
229
  if (this.#data.octave !== undefined) {
206
230
  return Note.fromMidi(this.midi + semitones);
207
231
  }
@@ -231,12 +255,31 @@ export class Note {
231
255
  this.#data.octave === other.#data.octave
232
256
  );
233
257
  }
258
+
259
+ /**
260
+ * The plain note data, for JSON serialization.
261
+ *
262
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
263
+ * `JSON.stringify(note)` from collapsing to `{}`.
264
+ *
265
+ * @returns A copy of the underlying plain note object.
266
+ */
267
+ toJSON(): NoteData {
268
+ return this.data;
269
+ }
234
270
  }
235
271
 
236
272
  /**
237
273
  * An immutable spelled interval value: a diatonic number, a quality label, and
238
274
  * a signed semitone span. A thin convenience wrapper over the pitch module's
239
275
  * plain interval result.
276
+ *
277
+ * @category Class API
278
+ * @example
279
+ * ```ts
280
+ * import { Interval, Note } from '@libraz/libcantus';
281
+ * Interval.between(Note.of('C4'), Note.of('G4')).name; // 'P5'
282
+ * ```
240
283
  */
241
284
  export class Interval {
242
285
  readonly #number: number;
@@ -292,6 +335,18 @@ export class Interval {
292
335
  get name(): string {
293
336
  return `${this.#quality}${this.#number}`;
294
337
  }
338
+
339
+ /**
340
+ * The plain interval data, for JSON serialization.
341
+ *
342
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
343
+ * `JSON.stringify(interval)` from collapsing to `{}`.
344
+ *
345
+ * @returns The diatonic number, quality, and semitone span.
346
+ */
347
+ toJSON(): SpelledInterval {
348
+ return { number: this.#number, quality: this.#quality, semitones: this.#semitones };
349
+ }
295
350
  }
296
351
 
297
352
  /** Resolve a string or numeric key root into a spelled tonic note. */
@@ -299,10 +354,38 @@ function resolveTonic(root: string | number, spelling: 'sharp' | 'flat'): Note {
299
354
  return typeof root === 'string' ? Note.of(root) : new Note(spellPitchClassBare(root, spelling));
300
355
  }
301
356
 
357
+ /** Total accidentals a spelled tonic produces across a key's whole scale. */
358
+ function accidentalLoad(tonic: NoteData, scale: KeyScale): number {
359
+ return spellScale(tonic, scale).reduce((sum, note) => sum + Math.abs(note.alter), 0);
360
+ }
361
+
362
+ /**
363
+ * Choose the tonic spelling (sharp- or flat-side) that spells `scale` with the
364
+ * fewest accidentals, so a numeric root never yields a double-flat/double-sharp
365
+ * scale (e.g. pitch class 6 minor spells as F# minor, not Gb minor with Bbb).
366
+ */
367
+ function bestTonicForScale(rootPc: number, scale: KeyScale): Note {
368
+ const sharp = spellPitchClassBare(rootPc, 'sharp');
369
+ const flat = spellPitchClassBare(rootPc, 'flat');
370
+ if (sharp.letter === flat.letter && sharp.alter === flat.alter) {
371
+ return new Note(sharp);
372
+ }
373
+ return accidentalLoad(flat, scale) <= accidentalLoad(sharp, scale)
374
+ ? new Note(flat)
375
+ : new Note(sharp);
376
+ }
377
+
302
378
  /**
303
379
  * An immutable key/scale: a `KeyScale` (root pitch class plus mode mask) paired
304
380
  * with a spelled tonic that anchors letter-name spelling. Acts as the factory
305
381
  * for key-aware chords.
382
+ *
383
+ * @category Class API
384
+ * @example
385
+ * ```ts
386
+ * import { Key } from '@libraz/libcantus';
387
+ * Key.major('C').chord(4).symbol(); // 'G' (the diatonic triad on scale degree 4)
388
+ * ```
306
389
  */
307
390
  export class Key {
308
391
  readonly #scale: KeyScale;
@@ -322,25 +405,35 @@ export class Key {
322
405
  /**
323
406
  * A major key.
324
407
  *
325
- * @param root Tonic as a note name (e.g. `'Eb'`) or a pitch class; numeric
326
- * roots are spelled with a sharp preference.
408
+ * @param root Tonic as a note name (e.g. `'Eb'`) or a pitch class; a numeric
409
+ * root is spelled with whichever accidental side yields the fewest
410
+ * accidentals across the scale.
327
411
  * @returns The major key.
328
412
  */
329
413
  static major(root: string | number): Key {
330
- const tonic = resolveTonic(root, 'sharp');
331
- return new Key(majorKey(tonic.pitchClass), tonic);
414
+ if (typeof root === 'string') {
415
+ const tonic = Note.of(root);
416
+ return new Key(majorKey(tonic.pitchClass), tonic);
417
+ }
418
+ const scale = majorKey(root);
419
+ return new Key(scale, bestTonicForScale(root, scale));
332
420
  }
333
421
 
334
422
  /**
335
423
  * A natural-minor key.
336
424
  *
337
- * @param root Tonic as a note name or a pitch class; numeric roots are
338
- * spelled with a flat preference.
425
+ * @param root Tonic as a note name or a pitch class; a numeric root is
426
+ * spelled with whichever accidental side yields the fewest accidentals
427
+ * across the scale.
339
428
  * @returns The minor key.
340
429
  */
341
430
  static minor(root: string | number): Key {
342
- const tonic = resolveTonic(root, 'flat');
343
- return new Key(minorKey(tonic.pitchClass), tonic);
431
+ if (typeof root === 'string') {
432
+ const tonic = Note.of(root);
433
+ return new Key(minorKey(tonic.pitchClass), tonic);
434
+ }
435
+ const scale = minorKey(root);
436
+ return new Key(scale, bestTonicForScale(root, scale));
344
437
  }
345
438
 
346
439
  /**
@@ -486,6 +579,20 @@ export class Key {
486
579
  contains(x: number | Note): boolean {
487
580
  return isScaleTone(typeof x === 'number' ? x : x.pitchClass, this.#scale);
488
581
  }
582
+
583
+ /**
584
+ * The plain key data, for JSON serialization.
585
+ *
586
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
587
+ * `JSON.stringify(key)` from collapsing to `{}`. The result pairs the
588
+ * `KeyScale` with the spelled tonic, enough to reconstruct the key via
589
+ * {@link Key.of}.
590
+ *
591
+ * @returns The key/scale and its spelled tonic.
592
+ */
593
+ toJSON(): { scale: KeyScale; tonic: NoteData } {
594
+ return { scale: this.scale, tonic: this.#tonic.data };
595
+ }
489
596
  }
490
597
 
491
598
  /**
@@ -493,6 +600,13 @@ export class Key {
493
600
  * optional slash bass, optionally carrying a {@link Key} context. Analysis
494
601
  * methods (`roman`, `function`, `analyze`, ...) use an explicitly passed key
495
602
  * first and fall back to the carried context.
603
+ *
604
+ * @category Class API
605
+ * @example
606
+ * ```ts
607
+ * import { Chord } from '@libraz/libcantus';
608
+ * Chord.parse('Cmaj7').invert(1).symbol(); // 'Cmaj7/E' (third in the bass)
609
+ * ```
496
610
  */
497
611
  export class Chord {
498
612
  readonly #data: ChordData;
@@ -712,7 +826,10 @@ export class Chord {
712
826
  * @throws If no key is given and none is carried.
713
827
  */
714
828
  negativeHarmony(key?: Key): Chord {
715
- return new Chord(negativeHarmonyMirror(this.#data, this.#resolveKey(key).scale), this.#key);
829
+ const resolved = this.#resolveKey(key);
830
+ // Retain the key that anchored the reflection (explicit first, then carried)
831
+ // so a later no-arg analysis method still has a key context.
832
+ return new Chord(negativeHarmonyMirror(this.#data, resolved.scale), key ?? this.#key);
716
833
  }
717
834
 
718
835
  /**
@@ -720,6 +837,9 @@ export class Chord {
720
837
  * root in the interval template (`invert(1)` puts the third in the bass).
721
838
  * `n` wraps around the template length; negative values count backwards.
722
839
  *
840
+ * `invert(0)` (and any `n` that wraps to it) is root position, so it carries
841
+ * no slash bass and equals the original chord.
842
+ *
723
843
  * @param n The inversion number.
724
844
  * @returns The inverted chord, keeping any key context.
725
845
  * @throws If the chord has no intervals.
@@ -732,7 +852,11 @@ export class Chord {
732
852
  }
733
853
  const index = ((n % length) + length) % length;
734
854
  const data = copyChord(this.#data);
735
- data.bassPc = mod12(this.#data.rootPc + (intervals[index] ?? 0));
855
+ if (index === 0) {
856
+ delete data.bassPc;
857
+ } else {
858
+ data.bassPc = mod12(this.#data.rootPc + (intervals[index] ?? 0));
859
+ }
736
860
  return new Chord(data, this.#key);
737
861
  }
738
862
 
@@ -835,6 +959,15 @@ export class Chord {
835
959
  /**
836
960
  * An immutable ordered sequence of chords, optionally carrying a {@link Key}
837
961
  * context shared by its analysis methods.
962
+ *
963
+ * @category Class API
964
+ * @example
965
+ * ```ts
966
+ * import { Key } from '@libraz/libcantus';
967
+ * const key = Key.major('C');
968
+ * key.chord(1).progressionTo(key.chord(4), key.chord(0)).roman();
969
+ * // ['ii', 'V', 'I']
970
+ * ```
838
971
  */
839
972
  export class Progression {
840
973
  readonly #chords: readonly Chord[];
@@ -946,6 +1079,22 @@ export class Progression {
946
1079
  return { chords, cadence };
947
1080
  }
948
1081
 
1082
+ /**
1083
+ * The plain progression data, for JSON serialization.
1084
+ *
1085
+ * Private class fields do not serialize, so an explicit `toJSON` keeps
1086
+ * `JSON.stringify(progression)` from collapsing to `{}`. The chords are
1087
+ * emitted as plain data and the carried key, when present, as its own data.
1088
+ *
1089
+ * @returns The chord data sequence and the carried key, if any.
1090
+ */
1091
+ toJSON(): { chords: ChordData[]; key: { scale: KeyScale; tonic: NoteData } | undefined } {
1092
+ return {
1093
+ chords: this.#chords.map((chord) => chord.data),
1094
+ key: this.#key?.toJSON(),
1095
+ };
1096
+ }
1097
+
949
1098
  /** Resolve the key for an analysis method: explicit first, then carried. */
950
1099
  #resolveKey(key?: Key): Key {
951
1100
  const resolved = key ?? this.#key;
@@ -4,7 +4,11 @@ import { isScaleTone, nearestScaleTone } from '../scale/index.js';
4
4
  import type { ChordTimeline } from '../timeline/index.js';
5
5
  import type { KeyScale } from '../types.js';
6
6
 
7
- /** A transformation applicable to a motif cell. */
7
+ /**
8
+ * A transformation applicable to a motif cell.
9
+ *
10
+ * @category Composition
11
+ */
8
12
  export type MotifTransform =
9
13
  | 'transposeDiatonic'
10
14
  | 'transposeChromatic'
@@ -14,27 +18,62 @@ export type MotifTransform =
14
18
  | 'diminish'
15
19
  | 'sequence';
16
20
 
17
- /** A single note within a motif cell. */
21
+ /**
22
+ * A single note within a motif cell.
23
+ *
24
+ * @category Composition
25
+ */
18
26
  export type MotifNote = {
19
27
  pitch: number;
20
28
  startBeat: number;
21
29
  durationBeat: number;
22
30
  };
23
31
 
24
- /** A short melodic cell. */
32
+ /**
33
+ * A short melodic cell.
34
+ *
35
+ * @category Composition
36
+ */
25
37
  export type MotifCell = {
26
38
  notes: MotifNote[];
27
39
  };
28
40
 
29
- /** Melodic contour shape for {@link generateMotif}. */
41
+ /**
42
+ * Melodic contour shape for {@link generateMotif}.
43
+ *
44
+ * @category Composition
45
+ */
30
46
  export type MotifContour = 'arch' | 'ascending' | 'descending' | 'wave';
31
47
 
32
- /** Options controlling {@link generateMotif}. */
48
+ /**
49
+ * Options controlling {@link generateMotif}.
50
+ *
51
+ * @category Composition
52
+ */
33
53
  export type GenerateMotifOptions = {
34
54
  key: KeyScale;
35
55
  chord?: Chord | null;
36
56
  bars: number;
57
+ /**
58
+ * Melodic contour shape the line follows.
59
+ *
60
+ * @defaultValue `'arch'`
61
+ */
37
62
  contour?: MotifContour;
63
+ /**
64
+ * Probability in [0, 1] that a note is nudged by a single diatonic step. The
65
+ * nudge direction is balanced (up or down with equal odds), so it adds
66
+ * variety without biasing the line off its contour. Default 0, which
67
+ * reproduces the requested contour exactly (no drift, tails return to tonic).
68
+ *
69
+ * @defaultValue 0
70
+ */
71
+ jitter?: number;
72
+ /**
73
+ * Seed for the deterministic PRNG.
74
+ *
75
+ * @defaultValue 0
76
+ */
38
77
  seed?: number;
39
78
  };
40
79
 
@@ -126,7 +165,6 @@ function mulberry32(seed: number): () => number {
126
165
  /** Diatonic scale-degree offsets shaping a contour of `count` notes. */
127
166
  function contourOffsets(contour: MotifContour, count: number): number[] {
128
167
  const offsets: number[] = [];
129
- const half = Math.floor(count / 2);
130
168
  for (let i = 0; i < count; i += 1) {
131
169
  switch (contour) {
132
170
  case 'ascending':
@@ -136,7 +174,9 @@ function contourOffsets(contour: MotifContour, count: number): number[] {
136
174
  offsets.push(-i);
137
175
  break;
138
176
  case 'arch':
139
- offsets.push(i <= half ? i : count - 1 - i);
177
+ // Symmetric rise-and-fall: a palindrome that starts and ends on the
178
+ // tonic (offset 0) for both odd and even lengths.
179
+ offsets.push(Math.min(i, count - 1 - i));
140
180
  break;
141
181
  case 'wave':
142
182
  offsets.push([0, 1, 0, -1][i % 4] ?? 0);
@@ -151,10 +191,21 @@ function contourOffsets(contour: MotifContour, count: number): number[] {
151
191
  *
152
192
  * The melody follows the requested contour in diatonic steps from the tonic,
153
193
  * snapped to the key. When a chord is supplied, notes landing on bar downbeats
154
- * are pulled to the nearest chord tone. Output is deterministic for a given seed.
194
+ * are pulled to the nearest chord tone. An optional `jitter` adds balanced
195
+ * per-note variation without biasing the contour; it is off by default, so the
196
+ * contour is reproduced exactly. Output is deterministic for a given seed.
155
197
  *
156
- * @param opts Key, optional chord, length, contour, and seed.
198
+ * @param opts Key, optional chord, length, contour, jitter, and seed.
157
199
  * @returns The generated motif cell.
200
+ *
201
+ * @example
202
+ * ```ts
203
+ * import { generateMotif, majorKey } from '@libraz/libcantus';
204
+ * const cell = generateMotif({ key: majorKey(0), bars: 2, contour: 'arch' });
205
+ * // cell.notes is a deterministic MotifCell (seed defaults to 0)
206
+ * ```
207
+ *
208
+ * @category Composition
158
209
  */
159
210
  export function generateMotif(opts: GenerateMotifOptions): MotifCell {
160
211
  const contour = opts.contour ?? 'arch';
@@ -163,12 +214,19 @@ export function generateMotif(opts: GenerateMotifOptions): MotifCell {
163
214
  const noteCount = Math.max(3, bars * 2);
164
215
  const beatsPerNote = totalBeats / noteCount;
165
216
  const rng = mulberry32(opts.seed ?? 0);
217
+ const jitterProb = Math.min(1, Math.max(0, opts.jitter ?? 0));
166
218
  const tonic = pitchClass(opts.key.rootPc) + 60;
167
219
  const offsets = contourOffsets(contour, noteCount);
168
220
 
169
221
  const notes: MotifNote[] = [];
170
222
  for (let i = 0; i < noteCount; i += 1) {
171
- const jitter = rng() < 0.25 ? 1 : 0;
223
+ // Opt-in, direction-balanced jitter: disabled by default so the contour is
224
+ // preserved exactly. When enabled it nudges up or down with equal odds,
225
+ // avoiding the upward bias that used to drift arch/wave tails off the tonic.
226
+ let jitter = 0;
227
+ if (jitterProb > 0 && rng() < jitterProb) {
228
+ jitter = rng() < 0.5 ? 1 : -1;
229
+ }
172
230
  let pitch = stepDiatonic(tonic, (offsets[i] ?? 0) + jitter, opts.key);
173
231
  const startBeat = i * beatsPerNote;
174
232
  if (opts.chord && startBeat % 4 === 0) {
@@ -197,6 +255,15 @@ export function generateMotif(opts: GenerateMotifOptions): MotifCell {
197
255
  * @param key Key context for the diatonic transforms; without it,
198
256
  * `transposeDiatonic` and `sequence` shift chromatically by semitones.
199
257
  * @returns The transformed cell.
258
+ *
259
+ * @example
260
+ * ```ts
261
+ * import { generateMotif, transformMotif, majorKey } from '@libraz/libcantus';
262
+ * const cell = generateMotif({ key: majorKey(0), bars: 1 });
263
+ * const inverted = transformMotif(cell, 'invert');
264
+ * ```
265
+ *
266
+ * @category Composition
200
267
  */
201
268
  export function transformMotif(
202
269
  cell: MotifCell,
@@ -279,6 +346,16 @@ function scaleTime(cell: MotifCell, factor: number): MotifCell {
279
346
  * @param key Key context (used to keep snapped pitches sensible).
280
347
  * @param bars Number of four-beat bars to fill.
281
348
  * @returns The developed, harmony-aware cell.
349
+ *
350
+ * @example
351
+ * ```ts
352
+ * import { chordTimelineFromChords, developMotif, generateMotif, majorKey } from '@libraz/libcantus';
353
+ * const key = majorKey(0);
354
+ * const timeline = chordTimelineFromChords([{ rootPc: 0, quality: 'maj', startBeat: 0 }], 8);
355
+ * const developed = developMotif(generateMotif({ key, bars: 1 }), timeline, key, 2);
356
+ * ```
357
+ *
358
+ * @category Composition
282
359
  */
283
360
  export function developMotif(
284
361
  cell: MotifCell,
@@ -24,6 +24,8 @@ const LETTER_NAMES = ['C', 'D', 'E', 'F', 'G', 'A', 'B'] as const;
24
24
  * 0 natural, +1 sharp, +2 double-sharp.
25
25
  * - `octave` follows scientific pitch notation (middle C = C4 = MIDI 60). When
26
26
  * absent, the note denotes a bare pitch class.
27
+ *
28
+ * @category Pitch & Intervals
27
29
  */
28
30
  export type Note = {
29
31
  letter: number;
@@ -31,7 +33,11 @@ export type Note = {
31
33
  octave?: number;
32
34
  };
33
35
 
34
- /** A spelled interval: a diatonic number, a quality label, and its semitone span. */
36
+ /**
37
+ * A spelled interval: a diatonic number, a quality label, and its semitone span.
38
+ *
39
+ * @category Pitch & Intervals
40
+ */
35
41
  export type SpelledInterval = {
36
42
  /** Diatonic size: 1 = unison, 2 = second, ... 8 = octave, and beyond. */
37
43
  number: number;
@@ -62,6 +68,13 @@ function mod7(n: number): number {
62
68
  * @param text The note text.
63
69
  * @returns The parsed note.
64
70
  * @throws If the text is not a valid note.
71
+ * @example
72
+ * ```ts
73
+ * import { parseNote, noteToMidi } from '@libraz/libcantus';
74
+ * const n = parseNote('C#4');
75
+ * noteToMidi(n); // 61
76
+ * ```
77
+ * @category Pitch & Intervals
65
78
  */
66
79
  export function parseNote(text: string): Note {
67
80
  const match = /^([A-Ga-g])([#x]*|b*)(-?\d+)?$/.exec(text.trim());
@@ -103,6 +116,12 @@ function formatAlter(alter: number): string {
103
116
  *
104
117
  * @param note The note to format.
105
118
  * @returns The note text, including the octave when present.
119
+ * @example
120
+ * ```ts
121
+ * import { formatNote, parseNote } from '@libraz/libcantus';
122
+ * formatNote(parseNote('C#4')); // 'C#4'
123
+ * ```
124
+ * @category Pitch & Intervals
106
125
  */
107
126
  export function formatNote(note: Note): string {
108
127
  const glyph = LETTER_NAMES[mod7(note.letter)] ?? 'C';
@@ -115,6 +134,12 @@ export function formatNote(note: Note): string {
115
134
  *
116
135
  * @param note The note.
117
136
  * @returns The pitch class.
137
+ * @example
138
+ * ```ts
139
+ * import { noteToPitchClass, parseNote } from '@libraz/libcantus';
140
+ * noteToPitchClass(parseNote('Db')); // 1
141
+ * ```
142
+ * @category Pitch & Intervals
118
143
  */
119
144
  export function noteToPitchClass(note: Note): number {
120
145
  const natural = LETTER_SEMITONES[mod7(note.letter)] ?? 0;
@@ -127,6 +152,12 @@ export function noteToPitchClass(note: Note): number {
127
152
  * @param note The note; must carry an octave.
128
153
  * @returns The MIDI number.
129
154
  * @throws If the note has no octave.
155
+ * @example
156
+ * ```ts
157
+ * import { noteToMidi, parseNote } from '@libraz/libcantus';
158
+ * noteToMidi(parseNote('A4')); // 69
159
+ * ```
160
+ * @category Pitch & Intervals
130
161
  */
131
162
  export function noteToMidi(note: Note): number {
132
163
  if (note.octave === undefined) {
@@ -170,9 +201,20 @@ const FLAT_SPELLING: readonly [number, number][] = [
170
201
  /**
171
202
  * Name a MIDI number as a {@link Note}, choosing sharp or flat spelling.
172
203
  *
204
+ * The input is rounded to the nearest integer but is not clamped to the MIDI
205
+ * range 0..127: out-of-range values extrapolate linearly (e.g. `-1` -> `B-2`,
206
+ * `128` -> `G#9`) and remain an exact inverse of {@link noteToMidi}.
207
+ *
173
208
  * @param midi The MIDI number.
174
209
  * @param spelling Whether to prefer sharps or flats for black keys.
175
210
  * @returns The spelled note, with octave.
211
+ * @example
212
+ * ```ts
213
+ * import { midiToNote, formatNote } from '@libraz/libcantus';
214
+ * formatNote(midiToNote(61)); // 'C#4'
215
+ * formatNote(midiToNote(61, 'flat')); // 'Db4'
216
+ * ```
217
+ * @category Pitch & Intervals
176
218
  */
177
219
  export function midiToNote(midi: number, spelling: 'sharp' | 'flat' = 'sharp'): Note {
178
220
  const rounded = Math.round(midi);
@@ -227,6 +269,13 @@ function diatonicIndex(note: Note): number {
227
269
  * @param a The lower/first note.
228
270
  * @param b The upper/second note.
229
271
  * @returns The diatonic number, quality, and semitone span.
272
+ * @example
273
+ * ```ts
274
+ * import { spelledInterval, parseNote } from '@libraz/libcantus';
275
+ * spelledInterval(parseNote('C4'), parseNote('G4'));
276
+ * // { number: 5, quality: 'P', semitones: 7 }
277
+ * ```
278
+ * @category Pitch & Intervals
230
279
  */
231
280
  export function spelledInterval(a: Note, b: Note): SpelledInterval {
232
281
  const octaved = a.octave !== undefined && b.octave !== undefined;
@@ -240,11 +289,17 @@ export function spelledInterval(a: Note, b: Note): SpelledInterval {
240
289
  letterSteps = mod7(b.letter - a.letter);
241
290
  let rawSemis = mod12(noteToPitchClass(b) - noteToPitchClass(a));
242
291
  // Lift the chromatic span into the octave nearest the diatonic reference so
243
- // wraparound intervals (e.g. Ab -> G# = augmented seventh) stay consistent
244
- // with the ascending diatonic number instead of collapsing modulo 12.
292
+ // wraparound intervals stay consistent with the diatonic number instead of
293
+ // collapsing modulo 12. The correction is bidirectional: an upward wrap
294
+ // (e.g. Ab -> G# = augmented seventh) adds an octave, while a same-letter or
295
+ // downward chromatic step (e.g. E -> Eb, F# -> F, C -> Cb = descending
296
+ // diminished/augmented unison) subtracts one so the sign follows pitch
297
+ // direction instead of returning a garbage stack of augmentations.
245
298
  const reference = SIMPLE_REFERENCE[letterSteps + 1] ?? 0;
246
299
  if (reference - rawSemis > 6) {
247
300
  rawSemis += 12;
301
+ } else if (rawSemis - reference > 6) {
302
+ rawSemis -= 12;
248
303
  }
249
304
  semitones = rawSemis;
250
305
  }