@libraz/libcantus 0.9.2 → 0.9.3

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 (126) hide show
  1. package/README.md +4 -3
  2. package/dist/analyze/index.cjs +315 -42
  3. package/dist/analyze/index.cjs.map +1 -1
  4. package/dist/analyze/index.d.cts +11 -99
  5. package/dist/analyze/index.d.ts +11 -99
  6. package/dist/analyze/index.js +6 -6
  7. package/dist/chunk-2ZWMSJH6.js +30 -0
  8. package/dist/chunk-2ZWMSJH6.js.map +1 -0
  9. package/dist/{chunk-R3S2LUBH.js → chunk-4MHA432G.js} +316 -184
  10. package/dist/chunk-4MHA432G.js.map +1 -0
  11. package/dist/chunk-62QZKCJQ.js +49 -0
  12. package/dist/chunk-62QZKCJQ.js.map +1 -0
  13. package/dist/chunk-AKLYPWIN.js +83 -0
  14. package/dist/chunk-AKLYPWIN.js.map +1 -0
  15. package/dist/{chunk-XTC3X6MS.js → chunk-C5XYKUVG.js} +100 -12
  16. package/dist/chunk-C5XYKUVG.js.map +1 -0
  17. package/dist/{chunk-LN7ZZMD2.js → chunk-CHMXELDS.js} +33 -14
  18. package/dist/chunk-CHMXELDS.js.map +1 -0
  19. package/dist/{chunk-A4532ZFR.js → chunk-EH6EM37U.js} +60 -4
  20. package/dist/chunk-EH6EM37U.js.map +1 -0
  21. package/dist/chunk-FBQK3ZQN.js +131 -0
  22. package/dist/chunk-FBQK3ZQN.js.map +1 -0
  23. package/dist/{chunk-5HTNMQW4.js → chunk-GOAW4VYL.js} +87 -21
  24. package/dist/chunk-GOAW4VYL.js.map +1 -0
  25. package/dist/{chunk-MSWFZF7J.js → chunk-I5R7ZBJS.js} +90 -6
  26. package/dist/chunk-I5R7ZBJS.js.map +1 -0
  27. package/dist/{chunk-GQKN5KDT.js → chunk-IT2O4XFE.js} +4 -9
  28. package/dist/chunk-IT2O4XFE.js.map +1 -0
  29. package/dist/{chunk-7Y7WF64D.js → chunk-MIWO425M.js} +6 -4
  30. package/dist/chunk-MIWO425M.js.map +1 -0
  31. package/dist/chunk-N6IHQUOX.js +1 -0
  32. package/dist/chunk-N6IHQUOX.js.map +1 -0
  33. package/dist/{chunk-COKTX2G5.js → chunk-SGU7SPLK.js} +29 -12
  34. package/dist/chunk-SGU7SPLK.js.map +1 -0
  35. package/dist/core/index.cjs +230 -14
  36. package/dist/core/index.cjs.map +1 -1
  37. package/dist/core/index.d.cts +56 -4
  38. package/dist/core/index.d.ts +56 -4
  39. package/dist/core/index.js +24 -4
  40. package/dist/{function-2bJW1Zp_.d.ts → function-DVH4Eusr.d.ts} +1 -1
  41. package/dist/{function-r2sh-jOO.d.cts → function-DYt4iKsn.d.cts} +1 -1
  42. package/dist/generate/index.cjs +830 -494
  43. package/dist/generate/index.cjs.map +1 -1
  44. package/dist/generate/index.d.cts +11 -5
  45. package/dist/generate/index.d.ts +11 -5
  46. package/dist/generate/index.js +9 -9
  47. package/dist/{index-BOBXBNwP.d.cts → index-BNlGP86A.d.cts} +1 -1
  48. package/dist/{index-CP3wAfEH.d.ts → index-Bg5dYMmZ.d.ts} +1 -1
  49. package/dist/{index-CM5W4rcm.d.ts → index-Bt4tOUD9.d.ts} +1 -1
  50. package/dist/index-CnZCo29u.d.cts +108 -0
  51. package/dist/{index-D_ZTUNKp.d.cts → index-CpOsP1y9.d.cts} +2 -2
  52. package/dist/{index-DZ1M21do.d.cts → index-CxqBQtH1.d.cts} +2 -1
  53. package/dist/{index-BdeQ1r_p.d.cts → index-Dikst44v.d.cts} +1 -1
  54. package/dist/{index-BMPxwjwV.d.ts → index-DxvARIP2.d.ts} +2 -2
  55. package/dist/{index-BreoJRw0.d.ts → index-Jc0Ryi79.d.ts} +2 -1
  56. package/dist/index-JnF4v9bx.d.ts +108 -0
  57. package/dist/index.cjs +818 -283
  58. package/dist/index.cjs.map +1 -1
  59. package/dist/index.d.cts +9 -8
  60. package/dist/index.d.ts +9 -8
  61. package/dist/index.js +34 -13
  62. package/dist/model/index.cjs +170 -31
  63. package/dist/model/index.cjs.map +1 -1
  64. package/dist/model/index.d.cts +6 -5
  65. package/dist/model/index.d.ts +6 -5
  66. package/dist/model/index.js +6 -6
  67. package/dist/{styled-D7BAx3S3.d.ts → styled-DZ7GjShb.d.ts} +1 -1
  68. package/dist/{styled-BjXtOFNk.d.cts → styled-ZTbdaVFM.d.cts} +1 -1
  69. package/dist/theory/index.cjs +180 -34
  70. package/dist/theory/index.cjs.map +1 -1
  71. package/dist/theory/index.d.cts +6 -6
  72. package/dist/theory/index.d.ts +6 -6
  73. package/dist/theory/index.js +6 -5
  74. package/package.json +50 -19
  75. package/src/analyze/arrange/internal.ts +3 -1
  76. package/src/analyze/arrange/tension.ts +15 -3
  77. package/src/analyze/arrange/tracks.ts +36 -3
  78. package/src/analyze/detect/index.ts +46 -9
  79. package/src/analyze/functional/roman.ts +14 -5
  80. package/src/analyze/index.ts +1 -1
  81. package/src/analyze/timeline/index.ts +43 -7
  82. package/src/analyze/voice/index.ts +11 -2
  83. package/src/core/event-index/index.ts +104 -0
  84. package/src/core/index.ts +14 -0
  85. package/src/core/meter/index.ts +24 -4
  86. package/src/core/random/index.ts +26 -3
  87. package/src/core/tuning/index.ts +36 -6
  88. package/src/core/validation/index.ts +119 -0
  89. package/src/generate/bass/index.ts +23 -0
  90. package/src/generate/countermelody/index.ts +167 -76
  91. package/src/generate/drums/euclid.ts +10 -3
  92. package/src/generate/drums/index.ts +56 -24
  93. package/src/generate/drums/percussion.ts +1 -1
  94. package/src/generate/groove/index.ts +25 -0
  95. package/src/generate/harmonize/index.ts +55 -44
  96. package/src/generate/index.ts +0 -1
  97. package/src/generate/motif/index.ts +45 -21
  98. package/src/generate/progression/index.ts +8 -16
  99. package/src/generate/reharmony/index.ts +1 -8
  100. package/src/generate/rhythm/index.ts +11 -1
  101. package/src/index.ts +13 -247
  102. package/src/model/chord.ts +29 -10
  103. package/src/theory/chord/index.ts +4 -2
  104. package/src/theory/harmony/index.ts +6 -21
  105. package/src/theory/index.ts +6 -1
  106. package/src/theory/safety/index.ts +71 -2
  107. package/src/theory/spelling/index.ts +70 -3
  108. package/src/theory/voicing/satb.ts +25 -4
  109. package/src/theory/voicing/styled.ts +26 -3
  110. package/dist/chunk-5HTNMQW4.js.map +0 -1
  111. package/dist/chunk-7Y7WF64D.js.map +0 -1
  112. package/dist/chunk-A4532ZFR.js.map +0 -1
  113. package/dist/chunk-BHUT2S5G.js +0 -22
  114. package/dist/chunk-BHUT2S5G.js.map +0 -1
  115. package/dist/chunk-CCBLVAPK.js +0 -42
  116. package/dist/chunk-CCBLVAPK.js.map +0 -1
  117. package/dist/chunk-COKTX2G5.js.map +0 -1
  118. package/dist/chunk-GQKN5KDT.js.map +0 -1
  119. package/dist/chunk-LN7ZZMD2.js.map +0 -1
  120. package/dist/chunk-MSWFZF7J.js.map +0 -1
  121. package/dist/chunk-R3S2LUBH.js.map +0 -1
  122. package/dist/chunk-SDM3VJJ5.js +0 -41
  123. package/dist/chunk-SDM3VJJ5.js.map +0 -1
  124. package/dist/chunk-XTC3X6MS.js.map +0 -1
  125. package/dist/chunk-ZXW5YIWK.js +0 -55
  126. package/dist/chunk-ZXW5YIWK.js.map +0 -1
@@ -1,10 +1,10 @@
1
- import { C as Chord } from '../index-BOBXBNwP.cjs';
2
- export { a as ChordQuality, c as chordFromDegree, d as chordPitchClasses, e as chordQualities, f as chordToneRole, g as diatonicSeventh, h as diatonicTriad, m as makeChord } from '../index-BOBXBNwP.cjs';
3
- import { d as VoicingOptions } from '../styled-BjXtOFNk.cjs';
4
- export { C as ChordScaleMatch, a as ChordScaleReportEntry, S as SATB_RANGES, b as ScaleChoice, c as StyledVoicingOptions, V as VoiceRange, e as VoicingStyle, f as availableTensions, g as avoidNotes, h as chordScaleReport, i as chordScales, s as scaleMatchesChord, j as scalesForChanges, v as voiceChord, k as voiceChordStyled, l as voiceProgression } from '../styled-BjXtOFNk.cjs';
1
+ import { C as Chord } from '../index-BNlGP86A.cjs';
2
+ export { a as ChordQuality, b as ChordSpan, P as PitchSpelling, c as chordFromDegree, d as chordPitchClasses, e as chordQualities, f as chordToneRole, g as diatonicSeventh, h as diatonicTriad, m as makeChord } from '../index-BNlGP86A.cjs';
3
+ import { d as VoicingOptions } from '../styled-ZTbdaVFM.cjs';
4
+ export { C as ChordScaleMatch, a as ChordScaleReportEntry, S as SATB_RANGES, b as ScaleChoice, c as StyledVoicingOptions, V as VoiceRange, e as VoicingStyle, f as availableTensions, g as avoidNotes, h as chordScaleReport, i as chordScales, s as scaleMatchesChord, j as scalesForChanges, v as voiceChord, k as voiceChordStyled, l as voiceProgression } from '../styled-ZTbdaVFM.cjs';
5
5
  import { K as KeyScale } from '../types-MGP29r3t.cjs';
6
- export { H as HarmonyRole, L as LockLevel, V as VoicedRole, r as roleOf } from '../index-BdeQ1r_p.cjs';
7
- export { N as NoteSafety, R as ReasonFlag, S as SafetyProfile, a as SafetyQuery, b as SafetyResult, V as VoiceSnapshot, e as enumerateSafePitches, c as evaluateSafety } from '../index-DZ1M21do.cjs';
6
+ export { H as HarmonyRole, L as LockLevel, V as VoicedRole, r as roleOf } from '../index-Dikst44v.cjs';
7
+ export { N as NoteSafety, R as ReasonFlag, S as SafetyProfile, a as SafetyQuery, b as SafetyResult, V as VoiceSnapshot, e as enumerateSafePitches, c as evaluateSafety } from '../index-CxqBQtH1.cjs';
8
8
  import { N as Note } from '../index-CGMZNCx6.cjs';
9
9
 
10
10
  /**
@@ -1,10 +1,10 @@
1
- import { C as Chord } from '../index-CM5W4rcm.js';
2
- export { a as ChordQuality, c as chordFromDegree, d as chordPitchClasses, e as chordQualities, f as chordToneRole, g as diatonicSeventh, h as diatonicTriad, m as makeChord } from '../index-CM5W4rcm.js';
3
- import { d as VoicingOptions } from '../styled-D7BAx3S3.js';
4
- export { C as ChordScaleMatch, a as ChordScaleReportEntry, S as SATB_RANGES, b as ScaleChoice, c as StyledVoicingOptions, V as VoiceRange, e as VoicingStyle, f as availableTensions, g as avoidNotes, h as chordScaleReport, i as chordScales, s as scaleMatchesChord, j as scalesForChanges, v as voiceChord, k as voiceChordStyled, l as voiceProgression } from '../styled-D7BAx3S3.js';
1
+ import { C as Chord } from '../index-Bt4tOUD9.js';
2
+ export { a as ChordQuality, b as ChordSpan, P as PitchSpelling, c as chordFromDegree, d as chordPitchClasses, e as chordQualities, f as chordToneRole, g as diatonicSeventh, h as diatonicTriad, m as makeChord } from '../index-Bt4tOUD9.js';
3
+ import { d as VoicingOptions } from '../styled-DZ7GjShb.js';
4
+ export { C as ChordScaleMatch, a as ChordScaleReportEntry, S as SATB_RANGES, b as ScaleChoice, c as StyledVoicingOptions, V as VoiceRange, e as VoicingStyle, f as availableTensions, g as avoidNotes, h as chordScaleReport, i as chordScales, s as scaleMatchesChord, j as scalesForChanges, v as voiceChord, k as voiceChordStyled, l as voiceProgression } from '../styled-DZ7GjShb.js';
5
5
  import { K as KeyScale } from '../types-MGP29r3t.js';
6
- export { H as HarmonyRole, L as LockLevel, V as VoicedRole, r as roleOf } from '../index-CP3wAfEH.js';
7
- export { N as NoteSafety, R as ReasonFlag, S as SafetyProfile, a as SafetyQuery, b as SafetyResult, V as VoiceSnapshot, e as enumerateSafePitches, c as evaluateSafety } from '../index-BreoJRw0.js';
6
+ export { H as HarmonyRole, L as LockLevel, V as VoicedRole, r as roleOf } from '../index-Bg5dYMmZ.js';
7
+ export { N as NoteSafety, R as ReasonFlag, S as SafetyProfile, a as SafetyQuery, b as SafetyResult, V as VoiceSnapshot, e as enumerateSafePitches, c as evaluateSafety } from '../index-Jc0Ryi79.js';
8
8
  import { N as Note } from '../index-CGMZNCx6.js';
9
9
 
10
10
  /**
@@ -1,12 +1,13 @@
1
+ import "../chunk-N6IHQUOX.js";
1
2
  import {
2
3
  roleOf
3
- } from "../chunk-SDM3VJJ5.js";
4
+ } from "../chunk-2ZWMSJH6.js";
4
5
  import {
5
6
  NoteSafety,
6
7
  ReasonFlag,
7
8
  enumerateSafePitches,
8
9
  evaluateSafety
9
- } from "../chunk-A4532ZFR.js";
10
+ } from "../chunk-EH6EM37U.js";
10
11
  import {
11
12
  SATB_RANGES,
12
13
  availableTensions,
@@ -28,7 +29,7 @@ import {
28
29
  voiceChordStyled,
29
30
  voiceLeadingCost,
30
31
  voiceProgression
31
- } from "../chunk-XTC3X6MS.js";
32
+ } from "../chunk-C5XYKUVG.js";
32
33
  import "../chunk-DYWFNC4A.js";
33
34
  import {
34
35
  BLUES_MASK,
@@ -74,8 +75,8 @@ import {
74
75
  pitchToScaleDegree,
75
76
  scaleByName,
76
77
  scaleTonesInDegreeOrder
77
- } from "../chunk-7Y7WF64D.js";
78
- import "../chunk-CCBLVAPK.js";
78
+ } from "../chunk-MIWO425M.js";
79
+ import "../chunk-FBQK3ZQN.js";
79
80
  export {
80
81
  BLUES_MASK,
81
82
  CHROMATIC_MASK,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@libraz/libcantus",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Pure-TypeScript music-theory and composition primitives (intervals, scales, chords).",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -35,34 +35,64 @@
35
35
  "types": "./dist/index.d.ts",
36
36
  "exports": {
37
37
  ".": {
38
- "types": "./dist/index.d.ts",
39
- "import": "./dist/index.js",
40
- "require": "./dist/index.cjs"
38
+ "import": {
39
+ "types": "./dist/index.d.ts",
40
+ "default": "./dist/index.js"
41
+ },
42
+ "require": {
43
+ "types": "./dist/index.d.cts",
44
+ "default": "./dist/index.cjs"
45
+ }
41
46
  },
42
47
  "./core": {
43
- "types": "./dist/core/index.d.ts",
44
- "import": "./dist/core/index.js",
45
- "require": "./dist/core/index.cjs"
48
+ "import": {
49
+ "types": "./dist/core/index.d.ts",
50
+ "default": "./dist/core/index.js"
51
+ },
52
+ "require": {
53
+ "types": "./dist/core/index.d.cts",
54
+ "default": "./dist/core/index.cjs"
55
+ }
46
56
  },
47
57
  "./theory": {
48
- "types": "./dist/theory/index.d.ts",
49
- "import": "./dist/theory/index.js",
50
- "require": "./dist/theory/index.cjs"
58
+ "import": {
59
+ "types": "./dist/theory/index.d.ts",
60
+ "default": "./dist/theory/index.js"
61
+ },
62
+ "require": {
63
+ "types": "./dist/theory/index.d.cts",
64
+ "default": "./dist/theory/index.cjs"
65
+ }
51
66
  },
52
67
  "./analyze": {
53
- "types": "./dist/analyze/index.d.ts",
54
- "import": "./dist/analyze/index.js",
55
- "require": "./dist/analyze/index.cjs"
68
+ "import": {
69
+ "types": "./dist/analyze/index.d.ts",
70
+ "default": "./dist/analyze/index.js"
71
+ },
72
+ "require": {
73
+ "types": "./dist/analyze/index.d.cts",
74
+ "default": "./dist/analyze/index.cjs"
75
+ }
56
76
  },
57
77
  "./generate": {
58
- "types": "./dist/generate/index.d.ts",
59
- "import": "./dist/generate/index.js",
60
- "require": "./dist/generate/index.cjs"
78
+ "import": {
79
+ "types": "./dist/generate/index.d.ts",
80
+ "default": "./dist/generate/index.js"
81
+ },
82
+ "require": {
83
+ "types": "./dist/generate/index.d.cts",
84
+ "default": "./dist/generate/index.cjs"
85
+ }
61
86
  },
62
87
  "./model": {
63
- "types": "./dist/model/index.d.ts",
64
- "import": "./dist/model/index.js",
65
- "require": "./dist/model/index.cjs"
88
+ "import": {
89
+ "types": "./dist/model/index.d.ts",
90
+ "default": "./dist/model/index.js"
91
+ },
92
+ "require": {
93
+ "types": "./dist/model/index.d.cts",
94
+ "default": "./dist/model/index.cjs"
95
+ }
66
96
  }
67
97
  },
68
98
  "files": [
@@ -83,6 +113,7 @@
83
113
  "test": "vitest run",
84
114
  "test:watch": "vitest",
85
115
  "test:coverage": "vitest run --coverage",
116
+ "bench:timeline": "yarn build && node --expose-gc benchmark/timeline-index.mjs",
86
117
  "docs": "typedoc",
87
118
  "prepare": "[ -d node_modules/typescript ] && tsup || true",
88
119
  "prepublishOnly": "yarn build"
@@ -108,9 +108,11 @@ export function prepareTracks(tracks: ArrangementTrack[]): PreparedTrack[] {
108
108
  voice,
109
109
  sounding: voice.map((note, i) => {
110
110
  const prev = i > 0 ? voice[i - 1] : undefined;
111
+ const prevEnd = prev === undefined ? undefined : prev.startBeat + prev.durationBeat;
112
+ const contiguous = prevEnd !== undefined && Math.abs(prevEnd - note.startBeat) <= EPS;
111
113
  return {
112
114
  pitch: note.pitch,
113
- prevPitch: prev?.pitch,
115
+ prevPitch: contiguous ? prev?.pitch : undefined,
114
116
  startBeat: note.startBeat,
115
117
  endBeat: note.startBeat + note.durationBeat,
116
118
  };
@@ -7,6 +7,12 @@
7
7
 
8
8
  import { isStrongBeat, parseTimeSignature, type TimeSignature } from '../../core/meter/index.js';
9
9
  import type { KeyScale } from '../../core/types.js';
10
+ import {
11
+ assertGenerationBudget,
12
+ assertNoteEvents,
13
+ assertRange,
14
+ assertTimeSignature,
15
+ } from '../../core/validation/index.js';
10
16
  import { chordPitchClasses } from '../../theory/chord/index.js';
11
17
  import { evaluateSafety, NoteSafety, type SafetyProfile } from '../../theory/safety/index.js';
12
18
  import { functionOf } from '../functional/index.js';
@@ -84,11 +90,16 @@ export function tensionCurve(
84
90
  opts: ArrangementOptions & { step?: number } = {},
85
91
  ): TensionPoint[] {
86
92
  const ts = opts.ts ?? parseTimeSignature('4/4');
93
+ assertTimeSignature(ts);
94
+ assertGenerationBudget(tracks.length, 'arrangement tracks');
95
+ for (let index = 0; index < tracks.length; index += 1) {
96
+ assertNoteEvents(tracks[index]?.notes ?? [], `tracks[${index}].notes`, {
97
+ allowNonPositiveDuration: true,
98
+ });
99
+ }
87
100
  const profile: SafetyProfile = opts.profile ?? 'pop';
88
101
  const step = opts.step ?? 1;
89
- if (!(step > 0)) {
90
- throw new Error(`Invalid tension sampling step: ${step}`);
91
- }
102
+ assertRange(step, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, 'tension sampling step');
92
103
 
93
104
  const pooled = poolNotes(tracks);
94
105
  const totalBeats = pooled.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
@@ -101,6 +112,7 @@ export function tensionCurve(
101
112
  const prepared = prepareTracks(tracks);
102
113
  const points: TensionPoint[] = [];
103
114
  const sampleCount = Math.max(0, Math.ceil(totalBeats / step - EPS));
115
+ assertGenerationBudget(sampleCount, 'tension samples');
104
116
  for (let i = 0; i < sampleCount; i += 1) {
105
117
  const beat = i * step;
106
118
  points.push({ beat, tension: sampleTension(prepared, timeline, key, ts, profile, beat) });
@@ -7,6 +7,11 @@
7
7
 
8
8
  import { isStrongBeat, parseTimeSignature, type TimeSignature } from '../../core/meter/index.js';
9
9
  import type { KeyScale, NoteEvent } from '../../core/types.js';
10
+ import {
11
+ assertGenerationBudget,
12
+ assertNoteEvents,
13
+ assertTimeSignature,
14
+ } from '../../core/validation/index.js';
10
15
  import {
11
16
  evaluateSafety,
12
17
  NoteSafety,
@@ -65,6 +70,10 @@ export type Conflict = {
65
70
  safety: NoteSafety;
66
71
  /** {@link import('../safety/index.js').SafetyResult.reasons} bitmask. */
67
72
  reasons: number;
73
+ /** Preferred stepwise resolution, when the safety evaluator provides one. */
74
+ resolveTo?: number;
75
+ /** Nearby fully safe replacement pitches, nearest first. */
76
+ suggestions?: number[];
68
77
  rationale?: string;
69
78
  };
70
79
 
@@ -143,8 +152,12 @@ function otherVoicesSounding(
143
152
  continue;
144
153
  }
145
154
  const snap: VoiceSnapshot = { pitch: note.pitch };
146
- if (note.prevPitch !== undefined) {
147
- snap.prevPitch = note.prevPitch;
155
+ // Motion reasons compare one real transition shared by both voices.
156
+ // A voice attacking exactly here contributes its adjacent predecessor;
157
+ // a sustained voice contributes the same pitch (oblique motion).
158
+ const previous = Math.abs(note.startBeat - beat) <= EPS ? note.prevPitch : note.pitch;
159
+ if (previous !== undefined) {
160
+ snap.prevPitch = previous;
148
161
  }
149
162
  out.push(snap);
150
163
  }
@@ -211,6 +224,13 @@ export function analyzeArrangement(
211
224
  opts: ArrangementOptions = {},
212
225
  ): ArrangementAnalysis {
213
226
  const ts = opts.ts ?? parseTimeSignature('4/4');
227
+ assertTimeSignature(ts);
228
+ assertGenerationBudget(tracks.length, 'arrangement tracks');
229
+ for (let index = 0; index < tracks.length; index += 1) {
230
+ assertNoteEvents(tracks[index]?.notes ?? [], `tracks[${index}].notes`, {
231
+ allowNonPositiveDuration: true,
232
+ });
233
+ }
214
234
  const profile: SafetyProfile = opts.profile ?? 'pop';
215
235
  const pooled = poolNotes(tracks);
216
236
 
@@ -242,11 +262,18 @@ export function analyzeArrangement(
242
262
  ),
243
263
  );
244
264
 
245
- for (const note of subVoice.voice) {
265
+ for (let noteIndex = 0; noteIndex < subVoice.voice.length; noteIndex += 1) {
266
+ const note = subVoice.voice[noteIndex];
267
+ const preparedNote = subVoice.sounding[noteIndex];
268
+ if (note === undefined || preparedNote === undefined) {
269
+ continue;
270
+ }
246
271
  for (const beat of evaluationBeats(note, timeline)) {
272
+ const atOnset = Math.abs(beat - note.startBeat) <= EPS;
247
273
  const result = evaluateSafety({
248
274
  profile,
249
275
  candidatePitch: note.pitch,
276
+ prevPitch: atOnset ? preparedNote.prevPitch : note.pitch,
250
277
  chord: timeline.at(beat),
251
278
  key,
252
279
  otherVoices: otherVoicesSounding(prepared, t, v, beat),
@@ -263,6 +290,12 @@ export function analyzeArrangement(
263
290
  if (result.rationale !== undefined) {
264
291
  conflict.rationale = result.rationale;
265
292
  }
293
+ if (result.resolveTo !== undefined) {
294
+ conflict.resolveTo = result.resolveTo;
295
+ }
296
+ if (result.suggestions !== undefined) {
297
+ conflict.suggestions = [...result.suggestions];
298
+ }
266
299
  conflicts.push(conflict);
267
300
  }
268
301
  }
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import type { KeyScale } from '../../core/types.js';
8
+ import { assertFiniteNumber, assertGenerationBudget } from '../../core/validation/index.js';
8
9
  import type { Chord, ChordQuality } from '../../theory/chord/index.js';
9
10
  import { chordPitchClasses, chordQualities, makeChord } from '../../theory/chord/index.js';
10
11
  import {
@@ -30,12 +31,24 @@ export type ChordMatch = {
30
31
  extraPcs: number[];
31
32
  /** True when the input pitch-class set equals the chord exactly. */
32
33
  exact: boolean;
33
- /** Inversion implied by the lowest note: 0 root position, 1 first, ... */
34
- inversion: number;
34
+ /** Inversion implied by a known bass, or null for an unordered pitch-class set. */
35
+ inversion: number | null;
35
36
  /** Bass pitch class when inverted (the lowest note is not the root). */
36
37
  bassPc?: number;
37
38
  };
38
39
 
40
+ /** Input interpretation for {@link detectChord} and {@link detectChordBest}. */
41
+ export type DetectChordOptions = {
42
+ /**
43
+ * `midi` uses the numerically lowest pitch as bass; `pitchClass` treats the
44
+ * input as unordered. `auto` (default) selects pitch-class mode only when all
45
+ * values lie in 0..11.
46
+ */
47
+ input?: 'auto' | 'midi' | 'pitchClass';
48
+ /** Explicit bass pitch class, including for an unordered pitch-class set. */
49
+ bassPc?: number;
50
+ };
51
+
39
52
  /**
40
53
  * A candidate key interpretation of a pitch-class set.
41
54
  *
@@ -58,6 +71,13 @@ function uniquePitchClasses(pitches: number[]): number[] {
58
71
  return [...new Set(pitches.map(pitchClass))].sort((a, b) => a - b);
59
72
  }
60
73
 
74
+ function assertPitches(pitches: number[]): void {
75
+ assertGenerationBudget(pitches.length, 'detection pitches');
76
+ for (let index = 0; index < pitches.length; index += 1) {
77
+ assertFiniteNumber(pitches[index] ?? Number.NaN, `pitches[${index}]`);
78
+ }
79
+ }
80
+
61
81
  /**
62
82
  * Identify chords matching a set of pitches.
63
83
  *
@@ -78,12 +98,25 @@ function uniquePitchClasses(pitches: number[]): number[] {
78
98
  * ```
79
99
  * @category Recognition
80
100
  */
81
- export function detectChord(pitches: number[]): ChordMatch[] {
101
+ export function detectChord(pitches: number[], opts: DetectChordOptions = {}): ChordMatch[] {
102
+ assertPitches(pitches);
103
+ if (opts.bassPc !== undefined) assertFiniteNumber(opts.bassPc, 'bassPc');
82
104
  const input = uniquePitchClasses(pitches);
83
105
  if (input.length === 0) {
84
106
  return [];
85
107
  }
86
- const bassPc = pitchClass(Math.min(...pitches));
108
+ const inputKind =
109
+ opts.input === undefined || opts.input === 'auto'
110
+ ? pitches.every((pitch) => Number.isInteger(pitch) && pitch >= 0 && pitch <= 11)
111
+ ? 'pitchClass'
112
+ : 'midi'
113
+ : opts.input;
114
+ const bassPc =
115
+ opts.bassPc !== undefined
116
+ ? pitchClass(opts.bassPc)
117
+ : inputKind === 'midi'
118
+ ? pitchClass(pitches.reduce((lowest, pitch) => Math.min(lowest, pitch), Infinity))
119
+ : undefined;
87
120
  const inputSet = new Set(input);
88
121
  const matches: ChordMatch[] = [];
89
122
  const qualities = chordQualities();
@@ -100,8 +133,11 @@ export function detectChord(pitches: number[]): ChordMatch[] {
100
133
  continue;
101
134
  }
102
135
  const extraPcs = input.filter((pc) => !toneSet.has(pc));
103
- const bassIndex = chord.intervals.findIndex((iv) => pitchClass(rootPc + iv) === bassPc);
104
- const inversion = bassIndex > 0 ? bassIndex : 0;
136
+ const bassIndex =
137
+ bassPc === undefined
138
+ ? -1
139
+ : chord.intervals.findIndex((iv) => pitchClass(rootPc + iv) === bassPc);
140
+ const inversion = bassPc === undefined ? null : bassIndex > 0 ? bassIndex : 0;
105
141
  const match: ChordMatch = {
106
142
  rootPc,
107
143
  quality,
@@ -110,7 +146,7 @@ export function detectChord(pitches: number[]): ChordMatch[] {
110
146
  exact: extraPcs.length === 0 && missingPcs.length === 0,
111
147
  inversion,
112
148
  };
113
- if (inversion > 0) {
149
+ if (bassPc !== undefined && bassPc !== rootPc) {
114
150
  match.bassPc = bassPc;
115
151
  }
116
152
  matches.push(match);
@@ -146,8 +182,8 @@ export function detectChord(pitches: number[]): ChordMatch[] {
146
182
  * ```
147
183
  * @category Recognition
148
184
  */
149
- export function detectChordBest(pitches: number[]): Chord | null {
150
- const best = detectChord(pitches)[0];
185
+ export function detectChordBest(pitches: number[], opts: DetectChordOptions = {}): Chord | null {
186
+ const best = detectChord(pitches, opts)[0];
151
187
  if (!best) {
152
188
  return null;
153
189
  }
@@ -183,6 +219,7 @@ const MINOR_MASK_VARIANTS = [NATURAL_MINOR_MASK, HARMONIC_MINOR_MASK, MELODIC_MI
183
219
  * @category Recognition
184
220
  */
185
221
  export function detectKey(pitches: number[]): KeyMatch[] {
222
+ assertPitches(pitches);
186
223
  const input = uniquePitchClasses(pitches);
187
224
  if (input.length === 0) {
188
225
  return [];
@@ -161,11 +161,20 @@ function parseSimpleRoman(
161
161
  return { rootPc, quality: canonical, inversion: 0 };
162
162
  }
163
163
 
164
- const isDim = /o|°|dim/.test(suffix);
165
- const isAug = /\+|aug/.test(suffix);
166
- const isHalfDim = /ø/.test(suffix);
167
- const explicitMaj7 = /maj7|M7/.test(suffix);
168
- const figures = suffix.replace(/maj7|M7/g, '').replace(/[^0-9]/g, '');
164
+ // Non-canonical spellings are limited to a complete quality marker followed
165
+ // by an optional major-seventh marker and a recognized figured bass. Never
166
+ // search substrings: `Vfoo` must not become diminished merely because it
167
+ // contains an "o", and unknown trailing text must be rejected in full.
168
+ const parsedSuffix = /^(o|°|dim|ø|\+|aug)?(maj7|M7)?(65|64|43|42|7|6|2)?$/.exec(suffix);
169
+ if (!parsedSuffix) {
170
+ throw new Error(`Unsupported suffix "${suffix}" in Roman numeral: ${text}`);
171
+ }
172
+ const qualityMarker = parsedSuffix[1] ?? '';
173
+ const isDim = qualityMarker === 'o' || qualityMarker === '°' || qualityMarker === 'dim';
174
+ const isAug = qualityMarker === '+' || qualityMarker === 'aug';
175
+ const isHalfDim = qualityMarker === 'ø';
176
+ const explicitMaj7 = parsedSuffix[2] !== undefined;
177
+ const figures = parsedSuffix[3] ?? '';
169
178
 
170
179
  // Reject figure strings that are neither a known figured-bass inversion nor a
171
180
  // canonical quality suffix rather than silently degrading to a root triad.
@@ -12,7 +12,7 @@ export type {
12
12
  TrackRole,
13
13
  } from './arrange/index.js';
14
14
  export { analyzeArrangement, tensionCurve } from './arrange/index.js';
15
- export type { ChordMatch, KeyMatch } from './detect/index.js';
15
+ export type { ChordMatch, DetectChordOptions, KeyMatch } from './detect/index.js';
16
16
  export { detectChord, detectChordBest, detectKey } from './detect/index.js';
17
17
  export type {
18
18
  BorrowedSource,
@@ -1,6 +1,14 @@
1
+ import { createNoteEventIndex } from '../../core/event-index/index.js';
1
2
  import type { TimeSignature } from '../../core/meter/index.js';
2
3
  import { beatsPerBar, metricWeight, parseTimeSignature } from '../../core/meter/index.js';
3
4
  import type { KeyScale, NoteEvent } from '../../core/types.js';
5
+ import {
6
+ assertFiniteNumber,
7
+ assertGenerationBudget,
8
+ assertNoteEvents,
9
+ assertRange,
10
+ assertTimeSignature,
11
+ } from '../../core/validation/index.js';
4
12
  import type { Chord, ChordSpan } from '../../theory/chord/index.js';
5
13
  import { chordPitchClasses, makeChord } from '../../theory/chord/index.js';
6
14
  import { isScaleTone, majorKey } from '../../theory/scale/index.js';
@@ -45,11 +53,24 @@ export type ChordTimeline = {
45
53
  * @category Arrangement & Analysis
46
54
  */
47
55
  export function chordTimelineFromChords(chords: ChordSpan[], totalBeats: number): ChordTimeline {
48
- const sorted = [...chords].sort((a, b) => a.startBeat - b.startBeat);
56
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, 'timeline totalBeats');
57
+ assertGenerationBudget(chords.length, 'timeline chords');
58
+ for (let index = 0; index < chords.length; index += 1) {
59
+ assertRange(
60
+ chords[index]?.startBeat ?? Number.NaN,
61
+ 0,
62
+ Number.MAX_SAFE_INTEGER,
63
+ `chords[${index}].startBeat`,
64
+ );
65
+ }
66
+ const sorted = [...chords]
67
+ .filter((chord) => chord.startBeat < totalBeats)
68
+ .sort((a, b) => a.startBeat - b.startBeat);
49
69
  const segments: ChordSegment[] = sorted
50
70
  .map((gc, i) => {
51
71
  const next = sorted[i + 1];
52
- const endBeat = Math.max(gc.startBeat, next ? next.startBeat : totalBeats);
72
+ const nextBeat = next ? next.startBeat : totalBeats;
73
+ const endBeat = Math.min(totalBeats, Math.max(gc.startBeat, nextBeat));
53
74
  return {
54
75
  startBeat: gc.startBeat,
55
76
  endBeat,
@@ -64,6 +85,7 @@ export function chordTimelineFromChords(chords: ChordSpan[], totalBeats: number)
64
85
  /** Build the `at(beat)` lookup over a segment list. */
65
86
  function segmentLookup(segments: ChordSegment[]): (beat: number) => Chord | null {
66
87
  return (beat) => {
88
+ assertFiniteNumber(beat, 'timeline query beat');
67
89
  for (const seg of segments) {
68
90
  if (beat >= seg.startBeat && beat < seg.endBeat) {
69
91
  return seg.chord;
@@ -317,23 +339,37 @@ export function chordTimelineFromNotes(
317
339
  opts: ChordTimelineOptions = {},
318
340
  ): ChordTimelineResult {
319
341
  const ts = opts.ts ?? parseTimeSignature('4/4');
342
+ assertTimeSignature(ts);
320
343
  const harmonicRhythm = opts.harmonicRhythm ?? beatsPerBar(ts);
321
- if (!(harmonicRhythm > 0)) {
322
- throw new Error(`Invalid harmonic rhythm: ${harmonicRhythm}`);
323
- }
344
+ assertRange(harmonicRhythm, Number.MIN_VALUE, Number.MAX_SAFE_INTEGER, 'harmonic rhythm');
345
+ assertNoteEvents(notes, 'timeline notes', { allowNonPositiveDuration: true });
324
346
  // Zero/negative-length notes never sound; drop them before any inference.
325
- const sounding = notes.filter((note) => note.durationBeat > 0);
347
+ const soundingIndex = createNoteEventIndex(notes.filter((note) => note.durationBeat > 0));
348
+ const sounding = soundingIndex.notes.map(({ note }) => note);
326
349
  const lastNoteEnd = sounding.reduce((end, n) => Math.max(end, n.startBeat + n.durationBeat), 0);
327
350
  const totalBeats = opts.totalBeats ?? lastNoteEnd;
351
+ assertRange(totalBeats, 0, Number.MAX_SAFE_INTEGER, 'timeline totalBeats');
328
352
  const key = opts.key ?? detectKey(sounding.map((n) => n.pitch))[0]?.key ?? majorKey(0);
329
353
 
330
354
  const segments: ChordSegment[] = [];
331
355
  const segmentConfidence: number[] = [];
332
356
  const windowCount = Math.max(0, Math.ceil(totalBeats / harmonicRhythm - EPS));
357
+ assertGenerationBudget(windowCount, 'timeline windows');
358
+ const windowNotes: NoteEvent[][] = Array.from({ length: windowCount }, () => []);
359
+ let memberships = 0;
360
+ for (const indexed of soundingIndex.notes) {
361
+ const first = Math.max(0, Math.floor(indexed.note.startBeat / harmonicRhythm));
362
+ const lastExclusive = Math.min(windowCount, Math.ceil(indexed.endBeat / harmonicRhythm));
363
+ memberships += Math.max(0, lastExclusive - first);
364
+ assertGenerationBudget(memberships, 'timeline note-to-window memberships');
365
+ for (let window = first; window < lastExclusive; window += 1) {
366
+ windowNotes[window]?.push(indexed.note);
367
+ }
368
+ }
333
369
  for (let i = 0; i < windowCount; i += 1) {
334
370
  const start = i * harmonicRhythm;
335
371
  const end = Math.min(start + harmonicRhythm, totalBeats);
336
- const inferred = analyzeWindow(sounding, start, end, ts, key);
372
+ const inferred = analyzeWindow(windowNotes[i] ?? [], start, end, ts, key);
337
373
  if (!inferred) {
338
374
  continue;
339
375
  }
@@ -1,4 +1,5 @@
1
1
  import type { KeyScale, NoteEvent } from '../../core/types.js';
2
+ import { assertNoteEvents } from '../../core/validation/index.js';
2
3
  import type { Chord } from '../../theory/chord/index.js';
3
4
  import { chordPitchClasses, chordToneRole } from '../../theory/chord/index.js';
4
5
  import {
@@ -165,6 +166,7 @@ export function analyzeVoice(
165
166
  key: KeyScale,
166
167
  otherVoicesAtBeat: (beat: number) => VoiceSnapshot[],
167
168
  ): AnalyzedNote[] {
169
+ assertNoteEvents(voice, 'voice notes', { allowNonPositiveDuration: true });
168
170
  const result: AnalyzedNote[] = [];
169
171
 
170
172
  for (let i = 0; i < voice.length; i += 1) {
@@ -177,10 +179,17 @@ export function analyzeVoice(
177
179
  // A neighboring note sharing this note's onset is a simultaneous cluster
178
180
  // member, not a melodic predecessor/successor; treating it as one would
179
181
  // fabricate passing/suspension figures, so it is dropped here.
182
+ const prevEnd =
183
+ prevNote === undefined
184
+ ? Number.NEGATIVE_INFINITY
185
+ : prevNote.startBeat + prevNote.durationBeat;
186
+ const noteEnd = note.startBeat + note.durationBeat;
180
187
  const prev =
181
- prevNote !== undefined && prevNote.startBeat < note.startBeat - EPS ? prevNote : undefined;
188
+ prevNote !== undefined && Math.abs(prevEnd - note.startBeat) <= EPS ? prevNote : undefined;
182
189
  const next =
183
- nextNote !== undefined && nextNote.startBeat > note.startBeat + EPS ? nextNote : undefined;
190
+ nextNote !== undefined && Math.abs(noteEnd - nextNote.startBeat) <= EPS
191
+ ? nextNote
192
+ : undefined;
184
193
  const chord = chordAtBeat(note.startBeat);
185
194
  const labels: TheoryLabel[] = [];
186
195
  const member = isChordMember(note.pitch, chord);