@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,409 @@
1
+ /**
2
+ * Arrangement analysis: the capstone that ties the timeline, per-voice analysis,
3
+ * functional harmony, and safety modules together for a whole multi-track piece.
4
+ *
5
+ * A chord timeline and key are inferred once from every track's notes pooled
6
+ * together; each track is then analysed against that shared harmony, notes that
7
+ * clash with the sounding chord are collected as conflicts, and a coarse tension
8
+ * curve is sampled across the piece.
9
+ */
10
+
11
+ import { type AnalyzedNote, analyzeVoice, type VoiceNote } from '../analysis/index.js';
12
+ import { chordPitchClasses } from '../chord/index.js';
13
+ import { functionOf } from '../functional/index.js';
14
+ import { isStrongBeat, parseTimeSignature, type TimeSignature } from '../meter/index.js';
15
+ import {
16
+ evaluateSafety,
17
+ NoteSafety,
18
+ type SafetyProfile,
19
+ type VoiceSnapshot,
20
+ } from '../safety/index.js';
21
+ import {
22
+ type CadenceHit,
23
+ type ChordTimeline,
24
+ chordTimelineFromNotes,
25
+ detectCadences,
26
+ } from '../timeline/index.js';
27
+ import type { KeyScale, NoteEvent } from '../types.js';
28
+
29
+ /** Float tolerance for beat boundary comparisons. */
30
+ const EPS = 1e-9;
31
+
32
+ /** The musical role a track plays in the arrangement. */
33
+ export type TrackRole = 'melody' | 'harmony' | 'bass' | 'other';
34
+
35
+ /** One input track: its notes plus optional name and role. */
36
+ export type ArrangementTrack = {
37
+ name?: string;
38
+ role?: TrackRole;
39
+ notes: NoteEvent[];
40
+ };
41
+
42
+ /** A track after analysis: its resolved name, role, and per-note annotations. */
43
+ export type TrackAnalysis = {
44
+ name: string;
45
+ role: TrackRole;
46
+ /** One {@link AnalyzedNote} per note, labelled against the inferred chords. */
47
+ notes: AnalyzedNote[];
48
+ };
49
+
50
+ /** A note that clashes with the harmony sounding beneath it. */
51
+ export type Conflict = {
52
+ beat: number;
53
+ trackName: string;
54
+ pitch: number;
55
+ /** The clash severity: {@link NoteSafety.Warning} or {@link NoteSafety.Dissonant}. */
56
+ safety: NoteSafety;
57
+ /** {@link import('../safety/index.js').SafetyResult.reasons} bitmask. */
58
+ reasons: number;
59
+ rationale?: string;
60
+ };
61
+
62
+ /** The full result of {@link analyzeArrangement}. */
63
+ export type ArrangementAnalysis = {
64
+ key: KeyScale;
65
+ timeline: ChordTimeline;
66
+ segmentConfidence: number[];
67
+ cadences: CadenceHit[];
68
+ tracks: TrackAnalysis[];
69
+ /** Notes clashing with the sounding harmony, worst severity first. */
70
+ conflicts: Conflict[];
71
+ };
72
+
73
+ /** Options controlling {@link analyzeArrangement} and {@link tensionCurve}. */
74
+ export type ArrangementOptions = {
75
+ /** Key context; inferred from the pooled notes when omitted. */
76
+ key?: KeyScale;
77
+ /** Time signature; defaults to 4/4. */
78
+ ts?: TimeSignature;
79
+ /** Chord-slot length in beats; defaults to one bar of `ts`. */
80
+ harmonicRhythm?: number;
81
+ /** Safety profile used for conflict detection; defaults to `pop`. */
82
+ profile?: SafetyProfile;
83
+ };
84
+
85
+ /** A note with the pitch that immediately preceded it in the same track. */
86
+ type PreparedNote = {
87
+ pitch: number;
88
+ prevPitch?: number;
89
+ startBeat: number;
90
+ endBeat: number;
91
+ };
92
+
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. */
98
+ voice: VoiceNote[];
99
+ /** The same notes as sounding spans, each carrying its predecessor's pitch. */
100
+ sounding: PreparedNote[];
101
+ };
102
+
103
+ /** Reduce a pitch to a pitch class in [0, 11]. */
104
+ function pitchClass(pitch: number): number {
105
+ return ((Math.trunc(pitch) % 12) + 12) % 12;
106
+ }
107
+
108
+ /** Whether a sounding span `[start, end)` covers a beat. */
109
+ function covers(note: PreparedNote, beat: number): boolean {
110
+ return beat >= note.startBeat - EPS && beat < note.endBeat - EPS;
111
+ }
112
+
113
+ /** Every note of every track pooled into one flat list. */
114
+ function poolNotes(tracks: ArrangementTrack[]): NoteEvent[] {
115
+ const notes: NoteEvent[] = [];
116
+ for (const track of tracks) {
117
+ for (const note of track.notes) {
118
+ notes.push(note);
119
+ }
120
+ }
121
+ return notes;
122
+ }
123
+
124
+ /**
125
+ * Resolve each track's metadata and derive its ordered voice and sounding spans.
126
+ *
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.
130
+ */
131
+ function prepareTracks(tracks: ArrangementTrack[]): PreparedTrack[] {
132
+ let nextId = 0;
133
+ 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
+ });
145
+ return {
146
+ name: track.name ?? `track ${index + 1}`,
147
+ role: track.role ?? 'other',
148
+ voice,
149
+ sounding,
150
+ };
151
+ });
152
+ }
153
+
154
+ /** Sounding pitches of every track except `excludeIndex` at a beat. */
155
+ function otherVoicesSounding(
156
+ prepared: PreparedTrack[],
157
+ excludeIndex: number,
158
+ beat: number,
159
+ ): VoiceSnapshot[] {
160
+ const out: VoiceSnapshot[] = [];
161
+ for (let t = 0; t < prepared.length; t += 1) {
162
+ if (t === excludeIndex) {
163
+ continue;
164
+ }
165
+ const track = prepared[t];
166
+ if (!track) {
167
+ continue;
168
+ }
169
+ for (const note of track.sounding) {
170
+ if (!covers(note, beat)) {
171
+ continue;
172
+ }
173
+ const snap: VoiceSnapshot = { pitch: note.pitch };
174
+ if (note.prevPitch !== undefined) {
175
+ snap.prevPitch = note.prevPitch;
176
+ }
177
+ out.push(snap);
178
+ }
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * Analyse a whole arrangement against a single inferred harmony.
185
+ *
186
+ * The chord timeline and key are inferred from the pooled notes of every track
187
+ * (see {@link chordTimelineFromNotes}); pooling all voices is robust even when
188
+ * 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.
194
+ *
195
+ * @param tracks The tracks to analyse.
196
+ * @param opts Analysis options; see {@link ArrangementOptions}.
197
+ * @returns The inferred harmony, per-track annotations, cadences, and conflicts.
198
+ */
199
+ export function analyzeArrangement(
200
+ tracks: ArrangementTrack[],
201
+ opts: ArrangementOptions = {},
202
+ ): ArrangementAnalysis {
203
+ const ts = opts.ts ?? parseTimeSignature('4/4');
204
+ const profile: SafetyProfile = opts.profile ?? 'pop';
205
+ const pooled = poolNotes(tracks);
206
+
207
+ const { timeline, key, segmentConfidence } = chordTimelineFromNotes(pooled, {
208
+ key: opts.key,
209
+ ts,
210
+ harmonicRhythm: opts.harmonicRhythm,
211
+ });
212
+ const cadences = detectCadences(timeline, key);
213
+ const prepared = prepareTracks(tracks);
214
+
215
+ const trackAnalyses: TrackAnalysis[] = [];
216
+ const conflicts: Conflict[] = [];
217
+
218
+ for (let t = 0; t < prepared.length; t += 1) {
219
+ const track = prepared[t];
220
+ if (!track) {
221
+ continue;
222
+ }
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;
247
+ }
248
+ conflicts.push(conflict);
249
+ }
250
+ }
251
+ }
252
+
253
+ conflicts.sort((a, b) => b.safety - a.safety || a.beat - b.beat);
254
+
255
+ return { key, timeline, segmentConfidence, cadences, tracks: trackAnalyses, conflicts };
256
+ }
257
+
258
+ /** A tension reading sampled at a beat. */
259
+ export type TensionPoint = {
260
+ beat: number;
261
+ /** Combined tension in [0, 1]. */
262
+ tension: number;
263
+ };
264
+
265
+ /** Tension contributed by the sounding chord's harmonic function. */
266
+ const FUNCTION_TENSION: Record<'tonic' | 'subdominant' | 'dominant', number> = {
267
+ tonic: 0,
268
+ subdominant: 0.5,
269
+ dominant: 1,
270
+ };
271
+
272
+ /** Weight of the harmonic-function term in the combined tension score. */
273
+ const FUNCTION_WEIGHT = 0.5;
274
+ /** Weight of the non-chord-tone / dissonance term. */
275
+ const DISSONANCE_WEIGHT = 0.35;
276
+ /** Weight of the registral-span term. */
277
+ const SPAN_WEIGHT = 0.15;
278
+ /** Pitch span, in semitones, that saturates the span term. */
279
+ const SPAN_SATURATION = 24;
280
+
281
+ /** Clamp a value into [0, 1]. */
282
+ function clamp01(value: number): number {
283
+ return Math.min(1, Math.max(0, value));
284
+ }
285
+
286
+ /**
287
+ * Sample the harmonic tension of an arrangement at regular beats.
288
+ *
289
+ * At each sample beat the tension combines three normalized terms:
290
+ * - the sounding chord's harmonic function (dominant `1`, subdominant `0.5`,
291
+ * tonic or no chord `0`), weighted {@link FUNCTION_WEIGHT};
292
+ * - the dissonance of the sounding notes — the larger of the share of sounding
293
+ * pitches that are not chord tones and the share that {@link evaluateSafety}
294
+ * rates {@link NoteSafety.Dissonant} — weighted {@link DISSONANCE_WEIGHT};
295
+ * - the registral span of the sounding notes, saturating at
296
+ * {@link SPAN_SATURATION} semitones, weighted {@link SPAN_WEIGHT}.
297
+ *
298
+ * The weighted sum is clamped to [0, 1]. The harmony is inferred from all tracks
299
+ * pooled together, so the result is deterministic and self-contained.
300
+ *
301
+ * @param tracks The tracks to sample.
302
+ * @param opts Analysis options plus an optional `step` (default one beat).
303
+ * @returns One {@link TensionPoint} per sampled beat, in beat order.
304
+ * @throws If `step` is not positive.
305
+ */
306
+ export function tensionCurve(
307
+ tracks: ArrangementTrack[],
308
+ opts: ArrangementOptions & { step?: number } = {},
309
+ ): TensionPoint[] {
310
+ const ts = opts.ts ?? parseTimeSignature('4/4');
311
+ const profile: SafetyProfile = opts.profile ?? 'pop';
312
+ const step = opts.step ?? 1;
313
+ if (!(step > 0)) {
314
+ throw new Error(`Invalid tension sampling step: ${step}`);
315
+ }
316
+
317
+ const pooled = poolNotes(tracks);
318
+ const totalBeats = pooled.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
319
+ const { timeline, key } = chordTimelineFromNotes(pooled, {
320
+ key: opts.key,
321
+ ts,
322
+ harmonicRhythm: opts.harmonicRhythm,
323
+ });
324
+
325
+ const prepared = prepareTracks(tracks);
326
+ const points: TensionPoint[] = [];
327
+ const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS));
328
+ for (let i = 0; i < sampleCount; i += 1) {
329
+ const beat = i * step;
330
+ points.push({ beat, tension: sampleTension(prepared, timeline, key, ts, profile, beat) });
331
+ }
332
+ return points;
333
+ }
334
+
335
+ /** All sounding notes across every track at a beat, tagged with their track. */
336
+ function allSounding(prepared: PreparedTrack[], beat: number): { pitch: number; track: number }[] {
337
+ const out: { pitch: number; track: number }[] = [];
338
+ for (let t = 0; t < prepared.length; t += 1) {
339
+ const track = prepared[t];
340
+ if (!track) {
341
+ continue;
342
+ }
343
+ for (const note of track.sounding) {
344
+ if (covers(note, beat)) {
345
+ out.push({ pitch: note.pitch, track: t });
346
+ }
347
+ }
348
+ }
349
+ return out;
350
+ }
351
+
352
+ /** Combine the harmonic, dissonance, and span terms at one sample beat. */
353
+ function sampleTension(
354
+ prepared: PreparedTrack[],
355
+ timeline: ChordTimeline,
356
+ key: KeyScale,
357
+ ts: TimeSignature,
358
+ profile: SafetyProfile,
359
+ beat: number,
360
+ ): number {
361
+ const sounding = allSounding(prepared, beat);
362
+ if (sounding.length === 0) {
363
+ return 0;
364
+ }
365
+ const chord = timeline.at(beat);
366
+ const functionScore = chord ? FUNCTION_TENSION[functionOf(chord, key)] : 0;
367
+
368
+ const chordPcs = chord ? new Set(chordPitchClasses(chord)) : null;
369
+ const strongBeat = isStrongBeat(beat, ts);
370
+ let nonChord = 0;
371
+ let dissonant = 0;
372
+ for (let i = 0; i < sounding.length; i += 1) {
373
+ const voice = sounding[i];
374
+ if (!voice) {
375
+ continue;
376
+ }
377
+ // Non-chord-tone share only applies when a chord is sounding; at a timeline
378
+ // gap there is no reference harmony, so vertical dissonance alone drives the
379
+ // dissonance term (the chord function term is already 0 there).
380
+ if (chordPcs && !chordPcs.has(pitchClass(voice.pitch))) {
381
+ nonChord += 1;
382
+ }
383
+ const others = sounding.filter((_, j) => j !== i).map((s) => ({ pitch: s.pitch }));
384
+ const result = evaluateSafety({
385
+ profile,
386
+ candidatePitch: voice.pitch,
387
+ chord,
388
+ key,
389
+ otherVoices: others,
390
+ strongBeat,
391
+ });
392
+ if (result.safety === NoteSafety.Dissonant) {
393
+ dissonant += 1;
394
+ }
395
+ }
396
+ const dissonanceScore = Math.max(nonChord, dissonant) / sounding.length;
397
+
398
+ let low = Number.POSITIVE_INFINITY;
399
+ let high = Number.NEGATIVE_INFINITY;
400
+ for (const voice of sounding) {
401
+ low = Math.min(low, voice.pitch);
402
+ high = Math.max(high, voice.pitch);
403
+ }
404
+ const spanScore = Math.min(1, (high - low) / SPAN_SATURATION);
405
+
406
+ return clamp01(
407
+ FUNCTION_WEIGHT * functionScore + DISSONANCE_WEIGHT * dissonanceScore + SPAN_WEIGHT * spanScore,
408
+ );
409
+ }
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Deterministic bass-line generation over a chord placement.
3
+ *
4
+ * Given a sequence of chord segments and a key, this builds a monophonic bass
5
+ * line in a chosen low register. Several idiomatic styles are supported, from a
6
+ * single held root per segment to a quarter-note walking line that leads by
7
+ * step into each chord change. All pitches are kept in a narrow bass band and
8
+ * consecutive notes move by small intervals; given a seed the output is fully
9
+ * reproducible.
10
+ *
11
+ * Positions and durations are measured in quarter-note beats, matching the
12
+ * library-wide convention.
13
+ */
14
+
15
+ import type { Chord } from '../chord/index.js';
16
+ import { isStrongBeat, type TimeSignature } from '../meter/index.js';
17
+ import { createRng } from '../random/index.js';
18
+ import { nearestScaleTone } from '../scale/index.js';
19
+ import type { KeyScale, NoteEvent } from '../types.js';
20
+
21
+ /** A chord sounding over a half-open beat span `[startBeat, endBeat)`. */
22
+ export type BassSegment = {
23
+ startBeat: number;
24
+ endBeat: number;
25
+ chord: Chord;
26
+ };
27
+
28
+ /** The bass-line idiom to generate. */
29
+ export type BassStyle = 'root' | 'rootFifth' | 'pop' | 'walking' | 'arpeggio';
30
+
31
+ /** Options controlling {@link generateBassLine}. */
32
+ export type BassLineOptions = {
33
+ /** Chord placement to follow; need not be pre-sorted. */
34
+ segments: BassSegment[];
35
+ /** Key/scale context, used for diatonic approach tones in `walking`. */
36
+ key: KeyScale;
37
+ /** Time signature; used for metric accents. Default 4/4. */
38
+ ts?: TimeSignature;
39
+ /** Bass-line idiom. Default `'root'`. */
40
+ style?: BassStyle;
41
+ /** Target register as a base MIDI octave; roots land around `octave*12+12`. Default 2. */
42
+ octave?: number;
43
+ /** Seed for the deterministic PRNG. Default 0. */
44
+ seed?: number;
45
+ };
46
+
47
+ const DEFAULT_TS: TimeSignature = { numerator: 4, denominator: 4 };
48
+ const DEFAULT_STYLE: BassStyle = 'root';
49
+ const DEFAULT_OCTAVE = 2;
50
+
51
+ /** Velocity for notes on metrically strong positions. */
52
+ const STRONG_VELOCITY = 100;
53
+ /** Velocity for notes on weak positions. */
54
+ const WEAK_VELOCITY = 80;
55
+ /** Probability of a weak-beat pickup in the `pop` style. */
56
+ const PICKUP_PROB = 0.35;
57
+
58
+ const EPS = 1e-9;
59
+
60
+ /** Reduce a value to a pitch class in [0, 11]. */
61
+ function pitchClass(value: number): number {
62
+ return ((Math.trunc(value) % 12) + 12) % 12;
63
+ }
64
+
65
+ /**
66
+ * Place a pitch class as a MIDI note near an anchor, clamped to the bass band.
67
+ *
68
+ * The octave nearest the anchor is chosen first (so motion from the previous
69
+ * note is minimal), then the result is shifted by whole octaves into the band
70
+ * `[low, low + 12]` if it falls outside.
71
+ */
72
+ function placePc(pc: number, anchor: number, low: number): number {
73
+ const high = low + 12;
74
+ let midi = pc + 12 * Math.round((anchor - pc) / 12);
75
+ while (midi < low) {
76
+ midi += 12;
77
+ }
78
+ while (midi > high) {
79
+ midi -= 12;
80
+ }
81
+ return midi;
82
+ }
83
+
84
+ /** Chord-tone pitch classes in stacked-thirds order, deduplicated. */
85
+ function chordTonePcs(chord: Chord): number[] {
86
+ const seen = new Set<number>();
87
+ const pcs: number[] = [];
88
+ for (const interval of chord.intervals) {
89
+ const pc = pitchClass(chord.rootPc + interval);
90
+ if (!seen.has(pc)) {
91
+ seen.add(pc);
92
+ pcs.push(pc);
93
+ }
94
+ }
95
+ if (pcs.length === 0) {
96
+ pcs.push(pitchClass(chord.rootPc));
97
+ }
98
+ return pcs;
99
+ }
100
+
101
+ /** The chord's perfect-fifth pitch class, falling back to the root when absent. */
102
+ function fifthPcOf(chord: Chord): number {
103
+ const hasFifth = chord.intervals.some((i) => pitchClass(i) === 7);
104
+ return hasFifth ? pitchClass(chord.rootPc + 7) : pitchClass(chord.rootPc);
105
+ }
106
+
107
+ /** The sounding bass pitch class of a chord: its slash bass, else its root. */
108
+ function bassPcOf(chord: Chord): number {
109
+ return pitchClass(chord.bassPc ?? chord.rootPc);
110
+ }
111
+
112
+ /** Integer quarter-note beat positions within `[start, end)`, at least one. */
113
+ function beatPositions(start: number, end: number): number[] {
114
+ const positions: number[] = [];
115
+ for (let p = start; p < end - EPS; p += 1) {
116
+ positions.push(p);
117
+ }
118
+ if (positions.length === 0) {
119
+ positions.push(start);
120
+ }
121
+ return positions;
122
+ }
123
+
124
+ type RawNote = { startBeat: number; pitch: number; velocity: number };
125
+
126
+ /** Working state threaded through the per-segment builders. */
127
+ type BuildContext = {
128
+ ts: TimeSignature;
129
+ low: number;
130
+ key: KeyScale;
131
+ rng: ReturnType<typeof createRng>;
132
+ notes: RawNote[];
133
+ prevMidi: number;
134
+ };
135
+
136
+ /** Append a note for pitch class `pc` at `pos`, placed near the running anchor. */
137
+ function emit(ctx: BuildContext, pos: number, pc: number, midiOverride?: number): number {
138
+ const midi = midiOverride ?? placePc(pc, ctx.prevMidi, ctx.low);
139
+ const velocity = isStrongBeat(pos, ctx.ts) ? STRONG_VELOCITY : WEAK_VELOCITY;
140
+ ctx.notes.push({ startBeat: pos, pitch: midi, velocity });
141
+ return midi;
142
+ }
143
+
144
+ /** One root note per segment. */
145
+ function buildRoot(ctx: BuildContext, seg: BassSegment): void {
146
+ ctx.prevMidi = emit(ctx, seg.startBeat, bassPcOf(seg.chord));
147
+ }
148
+
149
+ /** Root on the downbeat, fifth on the segment's midpoint. */
150
+ function buildRootFifth(ctx: BuildContext, seg: BassSegment): void {
151
+ ctx.prevMidi = emit(ctx, seg.startBeat, bassPcOf(seg.chord));
152
+ const mid = (seg.startBeat + seg.endBeat) / 2;
153
+ if (mid > seg.startBeat + EPS && mid < seg.endBeat - EPS) {
154
+ ctx.prevMidi = emit(ctx, mid, fifthPcOf(seg.chord));
155
+ }
156
+ }
157
+
158
+ /** Root on every strong beat, with occasional octave/fifth pickups on weak beats. */
159
+ function buildPop(ctx: BuildContext, seg: BassSegment): void {
160
+ const positions = beatPositions(seg.startBeat, seg.endBeat);
161
+ const rootPc = bassPcOf(seg.chord);
162
+ let emitted = false;
163
+ for (const pos of positions) {
164
+ if (isStrongBeat(pos, ctx.ts)) {
165
+ ctx.prevMidi = emit(ctx, pos, rootPc);
166
+ emitted = true;
167
+ } else if (ctx.rng.prob(PICKUP_PROB)) {
168
+ if (ctx.rng.prob(0.5)) {
169
+ // Octave pickup: the root an octave below the band anchor.
170
+ const base = placePc(rootPc, ctx.prevMidi, ctx.low);
171
+ emit(ctx, pos, rootPc, base - 12);
172
+ } else {
173
+ ctx.prevMidi = emit(ctx, pos, fifthPcOf(seg.chord));
174
+ }
175
+ }
176
+ }
177
+ if (!emitted) {
178
+ ctx.prevMidi = emit(ctx, seg.startBeat, rootPc);
179
+ }
180
+ }
181
+
182
+ /** Cycle root -> third -> fifth (-> seventh) across the segment's beats. */
183
+ function buildArpeggio(ctx: BuildContext, seg: BassSegment): void {
184
+ const tones = chordTonePcs(seg.chord);
185
+ const positions = beatPositions(seg.startBeat, seg.endBeat);
186
+ positions.forEach((pos, i) => {
187
+ const pc = tones[i % tones.length] ?? bassPcOf(seg.chord);
188
+ ctx.prevMidi = emit(ctx, pos, pc);
189
+ });
190
+ }
191
+
192
+ /** A diatonic or chromatic neighbor of `target`, a step toward `from`. */
193
+ function approachNote(ctx: BuildContext, target: number, from: number): number {
194
+ const dir = from <= target ? -1 : 1;
195
+ if (ctx.rng.prob(0.5)) {
196
+ return target + dir; // chromatic semitone
197
+ }
198
+ const cand = nearestScaleTone(target + dir * 2, ctx.key);
199
+ const step = Math.abs(cand - target);
200
+ return step >= 1 && step <= 2 ? cand : target + dir;
201
+ }
202
+
203
+ /** A quarter-note line of chord tones that leads by step into each chord change. */
204
+ function buildWalking(ctx: BuildContext, seg: BassSegment, next: BassSegment | undefined): void {
205
+ const tones = chordTonePcs(seg.chord);
206
+ const positions = beatPositions(seg.startBeat, seg.endBeat);
207
+ const count = positions.length;
208
+ positions.forEach((pos, i) => {
209
+ if (i === 0) {
210
+ ctx.prevMidi = emit(ctx, pos, bassPcOf(seg.chord));
211
+ return;
212
+ }
213
+ if (next && count > 1 && i === count - 1) {
214
+ const nextBass = placePc(bassPcOf(next.chord), ctx.prevMidi, ctx.low);
215
+ const midi = approachNote(ctx, nextBass, ctx.prevMidi);
216
+ ctx.prevMidi = emit(ctx, pos, pitchClass(midi), midi);
217
+ return;
218
+ }
219
+ const pc = tones[i % tones.length] ?? bassPcOf(seg.chord);
220
+ ctx.prevMidi = emit(ctx, pos, pc);
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Generate a bass line following a chord placement.
226
+ *
227
+ * Each segment contributes notes in the chosen bass register per the selected
228
+ * style; consecutive notes are kept within roughly a fifth. Every note's
229
+ * duration extends to the next onset (the final note extends to the last
230
+ * segment's end). Given a seed the output is fully reproducible.
231
+ *
232
+ * @param opts Segments, key, and generation options.
233
+ * @returns Bass notes sorted by onset, non-overlapping.
234
+ */
235
+ export function generateBassLine(opts: BassLineOptions): NoteEvent[] {
236
+ const segments = [...opts.segments].sort((a, b) => a.startBeat - b.startBeat);
237
+ if (segments.length === 0) {
238
+ return [];
239
+ }
240
+
241
+ const ts = opts.ts ?? DEFAULT_TS;
242
+ const style = opts.style ?? DEFAULT_STYLE;
243
+ const octave = opts.octave ?? DEFAULT_OCTAVE;
244
+ const low = octave * 12 + 12;
245
+
246
+ const ctx: BuildContext = {
247
+ ts,
248
+ low,
249
+ key: opts.key,
250
+ rng: createRng(opts.seed ?? 0),
251
+ notes: [],
252
+ prevMidi: low,
253
+ };
254
+
255
+ for (let s = 0; s < segments.length; s += 1) {
256
+ const seg = segments[s];
257
+ if (!seg) {
258
+ continue;
259
+ }
260
+ switch (style) {
261
+ case 'rootFifth':
262
+ buildRootFifth(ctx, seg);
263
+ break;
264
+ case 'pop':
265
+ buildPop(ctx, seg);
266
+ break;
267
+ case 'walking':
268
+ buildWalking(ctx, seg, segments[s + 1]);
269
+ break;
270
+ case 'arpeggio':
271
+ buildArpeggio(ctx, seg);
272
+ break;
273
+ default:
274
+ buildRoot(ctx, seg);
275
+ break;
276
+ }
277
+ }
278
+
279
+ ctx.notes.sort((a, b) => a.startBeat - b.startBeat);
280
+ const lastEnd = segments.reduce((m, seg) => Math.max(m, seg.endBeat), Number.NEGATIVE_INFINITY);
281
+
282
+ const out: NoteEvent[] = [];
283
+ for (let i = 0; i < ctx.notes.length; i += 1) {
284
+ const note = ctx.notes[i];
285
+ if (!note) {
286
+ continue;
287
+ }
288
+ const nextStart = ctx.notes[i + 1]?.startBeat ?? lastEnd;
289
+ const durationBeat = nextStart - note.startBeat;
290
+ if (durationBeat <= EPS) {
291
+ continue;
292
+ }
293
+ out.push({
294
+ pitch: note.pitch,
295
+ startBeat: note.startBeat,
296
+ durationBeat,
297
+ velocity: note.velocity,
298
+ });
299
+ }
300
+ return out;
301
+ }