@libraz/libcantus 0.9.0

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 (52) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +291 -0
  3. package/dist/index.cjs +6465 -0
  4. package/dist/index.cjs.map +1 -0
  5. package/dist/index.d.cts +2621 -0
  6. package/dist/index.d.ts +2621 -0
  7. package/dist/index.js +6297 -0
  8. package/dist/index.js.map +1 -0
  9. package/package.json +71 -0
  10. package/src/analysis/index.ts +261 -0
  11. package/src/arrange/index.ts +409 -0
  12. package/src/bass/index.ts +301 -0
  13. package/src/chord/index.ts +292 -0
  14. package/src/chordscale/index.ts +316 -0
  15. package/src/countermelody/index.ts +349 -0
  16. package/src/counterpoint/index.ts +209 -0
  17. package/src/detect/index.ts +176 -0
  18. package/src/drums/beat.ts +266 -0
  19. package/src/drums/euclid.ts +58 -0
  20. package/src/drums/fills.ts +345 -0
  21. package/src/drums/ghost.ts +119 -0
  22. package/src/drums/hihat.ts +285 -0
  23. package/src/drums/hit.ts +31 -0
  24. package/src/drums/index.ts +211 -0
  25. package/src/drums/internal.ts +251 -0
  26. package/src/drums/kick.ts +160 -0
  27. package/src/drums/percussion.ts +121 -0
  28. package/src/drums/rng.ts +7 -0
  29. package/src/drums/swing.ts +66 -0
  30. package/src/functional/index.ts +602 -0
  31. package/src/groove/index.ts +230 -0
  32. package/src/harmonize/index.ts +367 -0
  33. package/src/harmony/index.ts +75 -0
  34. package/src/index.ts +233 -0
  35. package/src/interval/index.ts +67 -0
  36. package/src/meter/index.ts +179 -0
  37. package/src/model/index.ts +957 -0
  38. package/src/motif/index.ts +321 -0
  39. package/src/pitch/index.ts +256 -0
  40. package/src/progression/index.ts +336 -0
  41. package/src/random/index.ts +41 -0
  42. package/src/reharmony/index.ts +299 -0
  43. package/src/rhythm/index.ts +131 -0
  44. package/src/safety/index.ts +372 -0
  45. package/src/scale/index.ts +215 -0
  46. package/src/spelling/index.ts +164 -0
  47. package/src/symbol/index.ts +213 -0
  48. package/src/timeline/index.ts +352 -0
  49. package/src/tuning/index.ts +129 -0
  50. package/src/types.ts +29 -0
  51. package/src/voicing/index.ts +523 -0
  52. package/tsconfig.json +23 -0
@@ -0,0 +1,352 @@
1
+ import type { Chord } from '../chord/index.js';
2
+ import { chordPitchClasses, makeChord } from '../chord/index.js';
3
+ import type { ChordMatch } from '../detect/index.js';
4
+ import { detectChord, detectChordBest, detectKey } from '../detect/index.js';
5
+ import type { Cadence } from '../functional/index.js';
6
+ import { detectCadence } from '../functional/index.js';
7
+ import type { TimeSignature } from '../meter/index.js';
8
+ import { beatsPerBar, metricWeight, parseTimeSignature } from '../meter/index.js';
9
+ import type { GeneratedChord } from '../progression/index.js';
10
+ import { isScaleTone, majorKey } from '../scale/index.js';
11
+ import type { KeyScale, NoteEvent } from '../types.js';
12
+
13
+ /** A chord occupying a half-open beat span. */
14
+ export type ChordSegment = {
15
+ startBeat: number;
16
+ endBeat: number;
17
+ chord: Chord;
18
+ };
19
+
20
+ /** A beat-indexed sequence of chord segments. */
21
+ export type ChordTimeline = {
22
+ /** The chord sounding at a beat, or null when no segment covers it. */
23
+ at: (beat: number) => Chord | null;
24
+ segments: ChordSegment[];
25
+ };
26
+
27
+ /**
28
+ * Build a chord timeline from placed chords.
29
+ *
30
+ * Each chord spans from its `startBeat` to the next chord's `startBeat`; the last
31
+ * chord runs to `totalBeats`. Segments with no positive length (from duplicate
32
+ * onsets or a last chord at or past `totalBeats`) are dropped. `at(beat)` returns
33
+ * the covering segment's chord, or null when the beat lies outside every segment.
34
+ *
35
+ * @param chords Placed chords in time order.
36
+ * @param totalBeats End of the timeline in beats.
37
+ * @returns A queryable chord timeline.
38
+ */
39
+ export function chordTimelineFromChords(
40
+ chords: GeneratedChord[],
41
+ totalBeats: number,
42
+ ): ChordTimeline {
43
+ const sorted = [...chords].sort((a, b) => a.startBeat - b.startBeat);
44
+ const segments: ChordSegment[] = sorted
45
+ .map((gc, i) => {
46
+ const next = sorted[i + 1];
47
+ const endBeat = Math.max(gc.startBeat, next ? next.startBeat : totalBeats);
48
+ return {
49
+ startBeat: gc.startBeat,
50
+ endBeat,
51
+ chord: makeChord(gc.rootPc, gc.quality, gc.bassPc),
52
+ };
53
+ })
54
+ .filter((seg) => seg.endBeat > seg.startBeat);
55
+
56
+ return { at: segmentLookup(segments), segments };
57
+ }
58
+
59
+ /** Build the `at(beat)` lookup over a segment list. */
60
+ function segmentLookup(segments: ChordSegment[]): (beat: number) => Chord | null {
61
+ return (beat) => {
62
+ for (const seg of segments) {
63
+ if (beat >= seg.startBeat && beat < seg.endBeat) {
64
+ return seg.chord;
65
+ }
66
+ }
67
+ return null;
68
+ };
69
+ }
70
+
71
+ const EPS = 1e-9;
72
+
73
+ /** Fraction of the strongest pitch-class weight below which a pc is noise. */
74
+ const NOISE_THRESHOLD_RATIO = 0.2;
75
+
76
+ /** Maximum number of pitch classes fed to chord detection per window. */
77
+ const MAX_DETECTION_PCS = 6;
78
+
79
+ /** Number of top-weighted pitch classes tried in the detection fallback. */
80
+ const FALLBACK_PCS = 3;
81
+
82
+ /** Confidence multiplier applied when the chosen chord match is inexact. */
83
+ const INEXACT_CONFIDENCE_FACTOR = 0.85;
84
+
85
+ /** Score bonus for a match whose tones are all in the key. */
86
+ const DIATONIC_BONUS = 0.5;
87
+
88
+ /** Score bonus for an exact match (no extra and no missing tones). */
89
+ const EXACT_BONUS = 0.5;
90
+
91
+ /** Score penalty per extra or missing tone in a match. */
92
+ const MISMATCH_PENALTY = 0.3;
93
+
94
+ /** Reduce a value to a pitch class in [0, 11]. */
95
+ function pitchClass(value: number): number {
96
+ return ((Math.trunc(value) % 12) + 12) % 12;
97
+ }
98
+
99
+ /** Options controlling {@link chordTimelineFromNotes}. */
100
+ export type ChordTimelineOptions = {
101
+ /** Key context; inferred from the notes with {@link detectKey} when omitted. */
102
+ key?: KeyScale;
103
+ /** Time signature used for metric accents; defaults to 4/4. */
104
+ ts?: TimeSignature;
105
+ /** Window length in beats per chord slot; defaults to one bar of `ts`. */
106
+ harmonicRhythm?: number;
107
+ /** End of the analyzed span in beats; defaults to the end of the last note. */
108
+ totalBeats?: number;
109
+ };
110
+
111
+ /** The result of {@link chordTimelineFromNotes}. */
112
+ export type ChordTimelineResult = {
113
+ /** The inferred timeline, with adjacent identical chords merged. */
114
+ timeline: ChordTimeline;
115
+ /** The key used for the analysis (given or inferred). */
116
+ key: KeyScale;
117
+ /** One confidence value in [0, 1] per segment, in segment order. */
118
+ segmentConfidence: number[];
119
+ };
120
+
121
+ /** A window's inferred chord and its confidence, or null for an empty window. */
122
+ type WindowChord = {
123
+ chord: Chord;
124
+ confidence: number;
125
+ };
126
+
127
+ /**
128
+ * Score a chord match against a window's pitch-class weights: a heavily
129
+ * weighted root, in-key tones, and exactness all raise the score; extra or
130
+ * missing tones lower it.
131
+ */
132
+ function scoreMatch(
133
+ match: ChordMatch,
134
+ weights: number[],
135
+ maxWeight: number,
136
+ key: KeyScale,
137
+ ): number {
138
+ const rootWeight = maxWeight > 0 ? (weights[match.rootPc] ?? 0) / maxWeight : 0;
139
+ const tones = chordPitchClasses(makeChord(match.rootPc, match.quality));
140
+ let score = rootWeight;
141
+ if (tones.every((pc) => isScaleTone(pc, key))) {
142
+ score += DIATONIC_BONUS;
143
+ }
144
+ if (match.exact) {
145
+ score += EXACT_BONUS;
146
+ }
147
+ score -= MISMATCH_PENALTY * (match.extraPcs.length + match.missingPcs.length);
148
+ return score;
149
+ }
150
+
151
+ /** Confidence of a chord for a window: chord-tone weight over total weight. */
152
+ function chordConfidence(
153
+ chord: Chord,
154
+ weights: number[],
155
+ totalWeight: number,
156
+ exact: boolean,
157
+ ): number {
158
+ const tones = new Set(chordPitchClasses(chord));
159
+ let chordWeight = 0;
160
+ for (let pc = 0; pc < 12; pc += 1) {
161
+ if (tones.has(pc)) {
162
+ chordWeight += weights[pc] ?? 0;
163
+ }
164
+ }
165
+ const raw = totalWeight > 0 ? chordWeight / totalWeight : 0;
166
+ const confidence = Math.min(1, Math.max(0, raw));
167
+ return exact ? confidence : confidence * INEXACT_CONFIDENCE_FACTOR;
168
+ }
169
+
170
+ /**
171
+ * Infer the chord sounding in one window from the notes overlapping it.
172
+ *
173
+ * Builds a pitch-class weight histogram (overlap duration x velocity x
174
+ * metric-accent bonus for onsets inside the window), keeps the significantly
175
+ * weighted pitch classes, and picks the best {@link detectChord} match by
176
+ * root weight, key membership, and exactness.
177
+ */
178
+ function analyzeWindow(
179
+ notes: NoteEvent[],
180
+ windowStart: number,
181
+ windowEnd: number,
182
+ ts: TimeSignature,
183
+ key: KeyScale,
184
+ ): WindowChord | null {
185
+ const weights = new Array<number>(12).fill(0);
186
+ let lowestPitch = Number.POSITIVE_INFINITY;
187
+ for (const note of notes) {
188
+ const noteEnd = note.startBeat + note.durationBeat;
189
+ const overlap = Math.min(noteEnd, windowEnd) - Math.max(note.startBeat, windowStart);
190
+ if (overlap <= EPS) {
191
+ continue;
192
+ }
193
+ const velocityFactor = note.velocity !== undefined ? note.velocity / 127 : 1;
194
+ const onsetInWindow = note.startBeat >= windowStart - EPS && note.startBeat < windowEnd - EPS;
195
+ const accent = onsetInWindow ? 1 + metricWeight(note.startBeat, ts) / 3 : 1;
196
+ const pc = pitchClass(note.pitch);
197
+ weights[pc] = (weights[pc] ?? 0) + overlap * velocityFactor * accent;
198
+ if (note.pitch < lowestPitch) {
199
+ lowestPitch = note.pitch;
200
+ }
201
+ }
202
+ let totalWeight = 0;
203
+ let maxWeight = 0;
204
+ for (const w of weights) {
205
+ totalWeight += w;
206
+ maxWeight = Math.max(maxWeight, w);
207
+ }
208
+ if (totalWeight <= EPS) {
209
+ return null;
210
+ }
211
+
212
+ // Keep pitch classes with meaningful weight, strongest first.
213
+ const ranked = weights
214
+ .map((weight, pc) => ({ pc, weight }))
215
+ .filter(({ weight }) => weight > 0)
216
+ .sort((a, b) => b.weight - a.weight || a.pc - b.pc);
217
+ const selected = ranked
218
+ .filter(({ weight }) => weight >= maxWeight * NOISE_THRESHOLD_RATIO)
219
+ .slice(0, MAX_DETECTION_PCS)
220
+ .map(({ pc }) => pc);
221
+
222
+ // Feed detection with the window's true bass as the lowest pitch so inversion
223
+ // detection works; when the bass pc was filtered out as noise, anchor on the
224
+ // heaviest pc instead so no spurious inversion is reported.
225
+ const bassPc = pitchClass(lowestPitch);
226
+ const anchorPc = selected.includes(bassPc) ? bassPc : (selected[0] ?? bassPc);
227
+ const detectionPitches = selected.map((pc) => (pc === anchorPc ? pc : pc + 12));
228
+
229
+ const matches = detectChord(detectionPitches);
230
+ let bestMatch: ChordMatch | null = null;
231
+ let bestScore = Number.NEGATIVE_INFINITY;
232
+ for (const match of matches) {
233
+ const score = scoreMatch(match, weights, maxWeight, key);
234
+ if (score > bestScore) {
235
+ bestScore = score;
236
+ bestMatch = match;
237
+ }
238
+ }
239
+ if (bestMatch) {
240
+ const chord = makeChord(bestMatch.rootPc, bestMatch.quality, bestMatch.bassPc);
241
+ return { chord, confidence: chordConfidence(chord, weights, totalWeight, bestMatch.exact) };
242
+ }
243
+
244
+ // Fallback: retry on only the top-weighted pitch classes.
245
+ const fallback = detectChordBest(selected.slice(0, FALLBACK_PCS));
246
+ if (fallback) {
247
+ return { chord: fallback, confidence: chordConfidence(fallback, weights, totalWeight, false) };
248
+ }
249
+ return null;
250
+ }
251
+
252
+ /** Whether two chords are the same root, quality, and bass. */
253
+ function sameChord(a: Chord, b: Chord): boolean {
254
+ return a.rootPc === b.rootPc && a.quality === b.quality && a.bassPc === b.bassPc;
255
+ }
256
+
257
+ /**
258
+ * Infer a chord timeline from raw multi-track notes.
259
+ *
260
+ * The span `[0, totalBeats)` is sliced into `harmonicRhythm`-beat windows; each
261
+ * window's chord is inferred from a pitch-class weight histogram of the notes
262
+ * overlapping it (weight = overlap duration x velocity x metric-accent bonus
263
+ * for onsets in the window). Adjacent windows carrying the identical chord are
264
+ * merged into one segment; windows with no notes produce no segment, so
265
+ * `at(beat)` returns null there. Each segment carries a confidence in [0, 1]:
266
+ * the fraction of the window weight explained by chord tones, reduced when the
267
+ * match is inexact, and duration-weighted across merged windows.
268
+ *
269
+ * @param notes The notes to analyze (any number of tracks, flattened).
270
+ * @param opts Analysis options; see {@link ChordTimelineOptions}.
271
+ * @returns The inferred timeline, the key used, and per-segment confidences.
272
+ * @throws If `harmonicRhythm` is not positive.
273
+ */
274
+ export function chordTimelineFromNotes(
275
+ notes: NoteEvent[],
276
+ opts: ChordTimelineOptions = {},
277
+ ): ChordTimelineResult {
278
+ const ts = opts.ts ?? parseTimeSignature('4/4');
279
+ const harmonicRhythm = opts.harmonicRhythm ?? beatsPerBar(ts);
280
+ if (!(harmonicRhythm > 0)) {
281
+ throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
282
+ }
283
+ const lastNoteEnd = notes.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
284
+ const totalBeats = opts.totalBeats ?? lastNoteEnd;
285
+ const key = opts.key ?? detectKey(notes.map((n) => n.pitch))[0]?.key ?? majorKey(0);
286
+
287
+ const segments: ChordSegment[] = [];
288
+ const segmentConfidence: number[] = [];
289
+ const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS));
290
+ for (let i = 0; i < windowCount; i += 1) {
291
+ const start = i * harmonicRhythm;
292
+ const end = Math.min(start + harmonicRhythm, totalBeats);
293
+ const inferred = analyzeWindow(notes, start, end, ts, key);
294
+ if (!inferred) {
295
+ continue;
296
+ }
297
+ const last = segments[segments.length - 1];
298
+ if (last && sameChord(last.chord, inferred.chord) && Math.abs(last.endBeat - start) < EPS) {
299
+ // Merge into the previous segment, blending confidence by duration.
300
+ const lastLength = last.endBeat - last.startBeat;
301
+ const length = end - start;
302
+ const lastConfidence = segmentConfidence[segmentConfidence.length - 1] ?? 0;
303
+ segmentConfidence[segmentConfidence.length - 1] =
304
+ (lastConfidence * lastLength + inferred.confidence * length) / (lastLength + length);
305
+ last.endBeat = end;
306
+ } else {
307
+ segments.push({ startBeat: start, endBeat: end, chord: inferred.chord });
308
+ segmentConfidence.push(inferred.confidence);
309
+ }
310
+ }
311
+
312
+ return {
313
+ timeline: { at: segmentLookup(segments), segments },
314
+ key,
315
+ segmentConfidence,
316
+ };
317
+ }
318
+
319
+ /** A cadence found between two consecutive timeline segments. */
320
+ export type CadenceHit = {
321
+ /** The beat where the cadence arrives (the second chord's onset). */
322
+ atBeat: number;
323
+ type: Exclude<Cadence, null>;
324
+ from: Chord;
325
+ to: Chord;
326
+ };
327
+
328
+ /**
329
+ * Find cadences between consecutive segments of a chord timeline.
330
+ *
331
+ * Each adjacent segment pair is classified with {@link detectCadence}; pairs
332
+ * forming no cadence are skipped.
333
+ *
334
+ * @param timeline The chord timeline to scan.
335
+ * @param key The prevailing key.
336
+ * @returns The cadences found, in time order.
337
+ */
338
+ export function detectCadences(timeline: ChordTimeline, key: KeyScale): CadenceHit[] {
339
+ const hits: CadenceHit[] = [];
340
+ for (let i = 1; i < timeline.segments.length; i += 1) {
341
+ const prev = timeline.segments[i - 1];
342
+ const cur = timeline.segments[i];
343
+ if (!prev || !cur) {
344
+ continue;
345
+ }
346
+ const type = detectCadence(prev.chord, cur.chord, key);
347
+ if (type !== null) {
348
+ hits.push({ atBeat: cur.startBeat, type, from: prev.chord, to: cur.chord });
349
+ }
350
+ }
351
+ return hits;
352
+ }
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Tuning and microtonality: frequency conversion, cents, arbitrary equal
3
+ * temperaments (EDO), and just-intonation ratios.
4
+ *
5
+ * The rest of the library reasons in twelve-tone pitch classes, which is a
6
+ * deliberate scope choice for tonal theory. This module is the escape hatch for
7
+ * anyone who needs actual frequencies, cents, non-12 equal temperaments, or the
8
+ * acoustic (just) tuning behind the tempered intervals.
9
+ */
10
+
11
+ /**
12
+ * An equal-tempered tuning: a reference pitch and a number of equal divisions of
13
+ * the octave. With `divisions === 12` a step index is an ordinary MIDI number.
14
+ */
15
+ export type Tuning = {
16
+ /** Step index (MIDI number when `divisions` is 12) whose frequency is `refFreq`. */
17
+ refStep: number;
18
+ /** Frequency in Hz of `refStep`. */
19
+ refFreq: number;
20
+ /** Equal divisions of the octave (12 standard; e.g. 19 or 31 for microtonal). */
21
+ divisions: number;
22
+ };
23
+
24
+ /** Standard twelve-tone equal temperament, A4 (MIDI 69) = 440 Hz. */
25
+ export const TWELVE_TET: Tuning = { refStep: 69, refFreq: 440, divisions: 12 };
26
+
27
+ /**
28
+ * Build an equal temperament with `n` divisions of the octave.
29
+ *
30
+ * @param n Divisions of the octave (e.g. 19, 24, 31).
31
+ * @param refFreq Reference frequency in Hz (default 440).
32
+ * @param refStep Step index of the reference (default 69).
33
+ * @returns The tuning.
34
+ */
35
+ export function edo(n: number, refFreq = 440, refStep = 69): Tuning {
36
+ return { refStep, refFreq, divisions: n };
37
+ }
38
+
39
+ /**
40
+ * Frequency in Hz of a step index under a tuning.
41
+ *
42
+ * @param step Step index (a MIDI number under 12-EDO).
43
+ * @param tuning The tuning (default 12-TET).
44
+ * @returns The frequency in Hz.
45
+ */
46
+ export function frequencyOf(step: number, tuning: Tuning = TWELVE_TET): number {
47
+ return tuning.refFreq * 2 ** ((step - tuning.refStep) / tuning.divisions);
48
+ }
49
+
50
+ /**
51
+ * Nearest step index to a frequency under a tuning (the inverse of
52
+ * {@link frequencyOf}, rounded).
53
+ *
54
+ * @param freq Frequency in Hz.
55
+ * @param tuning The tuning (default 12-TET).
56
+ * @returns The nearest step index.
57
+ */
58
+ export function nearestStep(freq: number, tuning: Tuning = TWELVE_TET): number {
59
+ return Math.round(tuning.refStep + tuning.divisions * Math.log2(freq / tuning.refFreq));
60
+ }
61
+
62
+ /**
63
+ * Interval in cents between two frequencies.
64
+ *
65
+ * @param a Lower/first frequency in Hz.
66
+ * @param b Upper/second frequency in Hz.
67
+ * @returns Cents from `a` to `b` (negative if `b` is lower).
68
+ */
69
+ export function centsBetweenFreq(a: number, b: number): number {
70
+ return 1200 * Math.log2(b / a);
71
+ }
72
+
73
+ /**
74
+ * Cents spanned by a number of equal-temperament steps.
75
+ *
76
+ * @param steps Number of steps.
77
+ * @param tuning The tuning (default 12-TET).
78
+ * @returns The cents.
79
+ */
80
+ export function centsOfSteps(steps: number, tuning: Tuning = TWELVE_TET): number {
81
+ return (steps * 1200) / tuning.divisions;
82
+ }
83
+
84
+ /**
85
+ * Cents of a frequency ratio, e.g. `ratioToCents(3, 2)` ≈ 701.955 for the just
86
+ * perfect fifth.
87
+ *
88
+ * @param numerator Ratio numerator.
89
+ * @param denominator Ratio denominator.
90
+ * @returns The interval in cents.
91
+ */
92
+ export function ratioToCents(numerator: number, denominator: number): number {
93
+ return 1200 * Math.log2(numerator / denominator);
94
+ }
95
+
96
+ /**
97
+ * Five-limit just-intonation ratios for the twelve interval classes above a
98
+ * unison, indexed by semitone class (0..12).
99
+ */
100
+ export const JUST_RATIOS: Record<number, [number, number]> = {
101
+ 0: [1, 1],
102
+ 1: [16, 15],
103
+ 2: [9, 8],
104
+ 3: [6, 5],
105
+ 4: [5, 4],
106
+ 5: [4, 3],
107
+ 6: [45, 32],
108
+ 7: [3, 2],
109
+ 8: [8, 5],
110
+ 9: [5, 3],
111
+ 10: [9, 5],
112
+ 11: [15, 8],
113
+ 12: [2, 1],
114
+ };
115
+
116
+ /**
117
+ * Cents by which a five-limit just interval departs from its 12-TET tempering
118
+ * (positive means the just interval is wider).
119
+ *
120
+ * @param semitoneClass Semitone class in [0, 12].
121
+ * @returns The deviation in cents, or NaN if the class has no listed ratio.
122
+ */
123
+ export function justDeviationCents(semitoneClass: number): number {
124
+ const ratio = JUST_RATIOS[semitoneClass];
125
+ if (!ratio) {
126
+ return Number.NaN;
127
+ }
128
+ return ratioToCents(ratio[0], ratio[1]) - semitoneClass * 100;
129
+ }
package/src/types.ts ADDED
@@ -0,0 +1,29 @@
1
+ /**
2
+ * A key/scale definition anchored on a root pitch class.
3
+ *
4
+ * `modeMask12` is a 12-bit mask where bit `n` set means the pitch class
5
+ * `(rootPc + n) % 12` belongs to the scale. Bit 0 (the root) is always set;
6
+ * {@link maskFromOffsets} enforces this so the root is always a scale tone.
7
+ */
8
+ export type KeyScale = {
9
+ rootPc: number;
10
+ modeMask12: number;
11
+ };
12
+
13
+ /**
14
+ * A single sounding note on a beat-indexed timeline: an absolute MIDI pitch, an
15
+ * onset and duration measured in quarter-note beats, and an optional MIDI
16
+ * velocity. This is the library-wide interchange shape for note events read
17
+ * from or written to a DAW/MIDI track; the harmonizer's `MelodyNote` and the
18
+ * analysis `VoiceNote` are specializations of it.
19
+ */
20
+ export type NoteEvent = {
21
+ /** MIDI pitch (middle C = 60). */
22
+ pitch: number;
23
+ /** Onset in quarter-note beats, absolute from the start of the timeline. */
24
+ startBeat: number;
25
+ /** Duration in quarter-note beats. */
26
+ durationBeat: number;
27
+ /** MIDI velocity in [0, 127], when known. */
28
+ velocity?: number;
29
+ };