@libraz/libcantus 0.9.1 → 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.
- package/README.md +19 -3
- package/dist/analyze/index.cjs +1970 -0
- package/dist/analyze/index.cjs.map +1 -0
- package/dist/analyze/index.d.cts +341 -0
- package/dist/analyze/index.d.ts +341 -0
- package/dist/analyze/index.js +51 -0
- package/dist/analyze/index.js.map +1 -0
- package/dist/chunk-2ZWMSJH6.js +30 -0
- package/dist/chunk-2ZWMSJH6.js.map +1 -0
- package/dist/chunk-4MHA432G.js +2854 -0
- package/dist/chunk-4MHA432G.js.map +1 -0
- package/dist/chunk-62QZKCJQ.js +49 -0
- package/dist/chunk-62QZKCJQ.js.map +1 -0
- package/dist/chunk-AKLYPWIN.js +83 -0
- package/dist/chunk-AKLYPWIN.js.map +1 -0
- package/dist/chunk-C5XYKUVG.js +904 -0
- package/dist/chunk-C5XYKUVG.js.map +1 -0
- package/dist/chunk-CHMXELDS.js +549 -0
- package/dist/chunk-CHMXELDS.js.map +1 -0
- package/dist/chunk-DYWFNC4A.js +152 -0
- package/dist/chunk-DYWFNC4A.js.map +1 -0
- package/dist/chunk-EH6EM37U.js +315 -0
- package/dist/chunk-EH6EM37U.js.map +1 -0
- package/dist/chunk-FBQK3ZQN.js +131 -0
- package/dist/chunk-FBQK3ZQN.js.map +1 -0
- package/dist/chunk-GOAW4VYL.js +719 -0
- package/dist/chunk-GOAW4VYL.js.map +1 -0
- package/dist/chunk-I5R7ZBJS.js +202 -0
- package/dist/chunk-I5R7ZBJS.js.map +1 -0
- package/dist/chunk-IT2O4XFE.js +154 -0
- package/dist/chunk-IT2O4XFE.js.map +1 -0
- package/dist/chunk-MIWO425M.js +412 -0
- package/dist/chunk-MIWO425M.js.map +1 -0
- package/dist/chunk-N6IHQUOX.js +1 -0
- package/dist/chunk-N6IHQUOX.js.map +1 -0
- package/dist/chunk-SGU7SPLK.js +924 -0
- package/dist/chunk-SGU7SPLK.js.map +1 -0
- package/dist/core/index.cjs +648 -0
- package/dist/core/index.cjs.map +1 -0
- package/dist/core/index.d.cts +262 -0
- package/dist/core/index.d.ts +262 -0
- package/dist/core/index.js +93 -0
- package/dist/core/index.js.map +1 -0
- package/dist/function-DVH4Eusr.d.ts +187 -0
- package/dist/function-DYt4iKsn.d.cts +187 -0
- package/dist/generate/index.cjs +4123 -0
- package/dist/generate/index.cjs.map +1 -0
- package/dist/generate/index.d.cts +986 -0
- package/dist/generate/index.d.ts +986 -0
- package/dist/generate/index.js +52 -0
- package/dist/generate/index.js.map +1 -0
- package/dist/index-BNlGP86A.d.cts +157 -0
- package/dist/index-Bg5dYMmZ.d.ts +56 -0
- package/dist/index-Bt4tOUD9.d.ts +157 -0
- package/dist/index-CGMZNCx6.d.cts +140 -0
- package/dist/index-CGMZNCx6.d.ts +140 -0
- package/dist/index-Cl7mhOFM.d.cts +164 -0
- package/dist/index-Cl7mhOFM.d.ts +164 -0
- package/dist/index-CnZCo29u.d.cts +108 -0
- package/dist/index-CpOsP1y9.d.cts +151 -0
- package/dist/index-CxqBQtH1.d.cts +128 -0
- package/dist/index-Dikst44v.d.cts +56 -0
- package/dist/index-DxvARIP2.d.ts +151 -0
- package/dist/index-Jc0Ryi79.d.ts +128 -0
- package/dist/index-JnF4v9bx.d.ts +108 -0
- package/dist/index.cjs +3710 -3143
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +15 -3982
- package/dist/index.d.ts +15 -3982
- package/dist/index.js +191 -6504
- package/dist/index.js.map +1 -1
- package/dist/model/index.cjs +2574 -0
- package/dist/model/index.cjs.map +1 -0
- package/dist/model/index.d.cts +631 -0
- package/dist/model/index.d.ts +631 -0
- package/dist/model/index.js +21 -0
- package/dist/model/index.js.map +1 -0
- package/dist/styled-DZ7GjShb.d.ts +282 -0
- package/dist/styled-ZTbdaVFM.d.cts +282 -0
- package/dist/theory/index.cjs +1823 -0
- package/dist/theory/index.cjs.map +1 -0
- package/dist/theory/index.d.cts +535 -0
- package/dist/theory/index.d.ts +535 -0
- package/dist/theory/index.js +150 -0
- package/dist/theory/index.js.map +1 -0
- package/dist/types-MGP29r3t.d.cts +30 -0
- package/dist/types-MGP29r3t.d.ts +30 -0
- package/package.json +61 -5
- package/src/analyze/arrange/index.ts +21 -0
- package/src/analyze/arrange/internal.ts +127 -0
- package/src/analyze/arrange/tension.ts +199 -0
- package/src/analyze/arrange/tracks.ts +312 -0
- package/src/{detect → analyze/detect}/index.ts +50 -13
- package/src/analyze/functional/borrowed.ts +84 -0
- package/src/analyze/functional/cadence.ts +66 -0
- package/src/analyze/functional/function.ts +184 -0
- package/src/analyze/functional/index.ts +23 -0
- package/src/analyze/functional/internal.ts +29 -0
- package/src/{functional/index.ts → analyze/functional/roman.ts} +23 -332
- package/src/analyze/index.ts +49 -0
- package/src/{timeline → analyze/timeline}/index.ts +50 -18
- package/src/{analysis → analyze/voice}/index.ts +19 -7
- package/src/core/event-index/index.ts +104 -0
- package/src/core/index.ts +61 -0
- package/src/{meter → core/meter}/index.ts +24 -4
- package/src/{random → core/random}/index.ts +26 -3
- package/src/{tuning → core/tuning}/index.ts +36 -6
- package/src/core/validation/index.ts +119 -0
- package/src/{bass → generate/bass}/index.ts +28 -5
- package/src/{countermelody → generate/countermelody}/index.ts +176 -85
- package/src/{drums → generate/drums}/euclid.ts +10 -3
- package/src/{drums → generate/drums}/index.ts +58 -26
- package/src/{drums → generate/drums}/percussion.ts +1 -1
- package/src/{drums → generate/drums}/rng.ts +2 -2
- package/src/{groove → generate/groove}/index.ts +28 -3
- package/src/{harmonize → generate/harmonize}/index.ts +68 -57
- package/src/generate/index.ts +58 -0
- package/src/{motif → generate/motif}/index.ts +52 -28
- package/src/{progression → generate/progression}/index.ts +19 -41
- package/src/{reharmony → generate/reharmony}/index.ts +12 -19
- package/src/{rhythm → generate/rhythm}/index.ts +13 -3
- package/src/index.ts +13 -234
- package/src/model/chord.ts +442 -0
- package/src/model/index.ts +5 -1096
- package/src/model/interval.ts +82 -0
- package/src/model/key.ts +266 -0
- package/src/model/note.ts +176 -0
- package/src/model/progression.ts +161 -0
- package/src/model/shared.ts +12 -0
- package/src/{chord → theory/chord}/index.ts +23 -3
- package/src/{counterpoint → theory/counterpoint}/index.ts +2 -2
- package/src/{harmony → theory/harmony}/index.ts +6 -21
- package/src/theory/index.ts +112 -0
- package/src/{safety → theory/safety}/index.ts +72 -3
- package/src/theory/scale/degrees.ts +123 -0
- package/src/theory/scale/index.ts +28 -0
- package/src/theory/scale/key.ts +56 -0
- package/src/theory/scale/masks.ts +153 -0
- package/src/{spelling → theory/spelling}/index.ts +73 -6
- package/src/{symbol → theory/symbol}/index.ts +8 -1
- package/src/theory/voicing/index.ts +5 -0
- package/src/theory/voicing/internal.ts +168 -0
- package/src/theory/voicing/leading.ts +112 -0
- package/src/theory/voicing/satb.ts +216 -0
- package/src/theory/voicing/styled.ts +199 -0
- package/src/arrange/index.ts +0 -571
- package/src/scale/index.ts +0 -331
- package/src/voicing/index.ts +0 -623
- /package/src/{interval → core/interval}/index.ts +0 -0
- /package/src/{pitch → core/pitch}/index.ts +0 -0
- /package/src/{types.ts → core/types.ts} +0 -0
- /package/src/{drums → generate/drums}/beat.ts +0 -0
- /package/src/{drums → generate/drums}/fills.ts +0 -0
- /package/src/{drums → generate/drums}/ghost.ts +0 -0
- /package/src/{drums → generate/drums}/hihat.ts +0 -0
- /package/src/{drums → generate/drums}/hit.ts +0 -0
- /package/src/{drums → generate/drums}/internal.ts +0 -0
- /package/src/{drums → generate/drums}/kick.ts +0 -0
- /package/src/{drums → generate/drums}/swing.ts +0 -0
- /package/src/{chordscale → theory/chordscale}/index.ts +0 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import type { KeyScale } from '../../core/types.js';
|
|
1
2
|
import { scaleTonesInDegreeOrder } from '../scale/index.js';
|
|
2
|
-
import type { KeyScale } from '../types.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Chord quality identifiers understood by the chord builder.
|
|
@@ -70,6 +70,24 @@ export type Chord = {
|
|
|
70
70
|
bassSpelling?: PitchSpelling;
|
|
71
71
|
};
|
|
72
72
|
|
|
73
|
+
/**
|
|
74
|
+
* A chord positioned in time: a root pitch class and quality placed at a beat,
|
|
75
|
+
* with optional harmonic-analysis hints. Serves as the neutral exchange record
|
|
76
|
+
* between progression generators and timeline analysers.
|
|
77
|
+
*
|
|
78
|
+
* @category Composition
|
|
79
|
+
*/
|
|
80
|
+
export type ChordSpan = {
|
|
81
|
+
rootPc: number;
|
|
82
|
+
quality: ChordQuality;
|
|
83
|
+
startBeat: number;
|
|
84
|
+
bassPc?: number;
|
|
85
|
+
/** 0-based scale degree of the chord root, when known. */
|
|
86
|
+
degree?: number;
|
|
87
|
+
/** True when the chord is a secondary dominant tonicizing another degree. */
|
|
88
|
+
secondaryDominant?: boolean;
|
|
89
|
+
};
|
|
90
|
+
|
|
73
91
|
/** Semitone offsets from the root for each supported chord quality. */
|
|
74
92
|
const QUALITY_INTERVALS: Record<ChordQuality, number[]> = {
|
|
75
93
|
maj: [0, 4, 7],
|
|
@@ -299,8 +317,10 @@ function classifySeventh(thirdIc: number, fifthIc: number, seventhIc: number): C
|
|
|
299
317
|
function stackThirds(degree: number, key: KeyScale, size: 3 | 4): Chord {
|
|
300
318
|
const tones = scaleTonesInDegreeOrder(key);
|
|
301
319
|
const length = tones.length;
|
|
302
|
-
if (length
|
|
303
|
-
|
|
320
|
+
if (length !== 7) {
|
|
321
|
+
throw new Error(
|
|
322
|
+
`diatonic chord stacking requires a heptatonic scale (received ${length} tones)`,
|
|
323
|
+
);
|
|
304
324
|
}
|
|
305
325
|
const idx = (step: number) => (((degree + step) % length) + length) % length;
|
|
306
326
|
const rootPc = tones[idx(0)] ?? pitchClass(key.rootPc);
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { isConsonantInterval } from '
|
|
2
|
-
import type { KeyScale } from '
|
|
1
|
+
import { isConsonantInterval } from '../../core/interval/index.js';
|
|
2
|
+
import type { KeyScale } from '../../core/types.js';
|
|
3
3
|
|
|
4
4
|
/** Reduce a MIDI pitch (or pitch class) to a pitch class in [0, 11]. */
|
|
5
5
|
function pitchClass(pitch: number): number {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type Chord, chordToneRole } from '../chord/index.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Harmonic role a pitch plays within a chord.
|
|
@@ -57,33 +57,18 @@ export type VoicedRole = {
|
|
|
57
57
|
export function roleOf(pitch: number, chord: Chord, chordId = 0): VoicedRole {
|
|
58
58
|
const interval = (((Math.trunc(pitch) - chord.rootPc) % 12) + 12) % 12;
|
|
59
59
|
const tones = new Set(chord.intervals.map((i) => ((i % 12) + 12) % 12));
|
|
60
|
-
const hasHigherSeventh = tones.has(10) || tones.has(11);
|
|
61
60
|
const hasThird = tones.has(3) || tones.has(4);
|
|
62
61
|
const isSuspendedTone =
|
|
63
62
|
!hasThird && ((interval === 5 && tones.has(5)) || (interval === 2 && tones.has(2)));
|
|
63
|
+
const chordRole = chordToneRole(pitch, chord);
|
|
64
64
|
let role: HarmonyRole;
|
|
65
65
|
let lock: LockLevel;
|
|
66
|
-
if (
|
|
67
|
-
role = 'root';
|
|
68
|
-
lock = 'identity';
|
|
69
|
-
} else if (isSuspendedTone) {
|
|
66
|
+
if (isSuspendedTone) {
|
|
70
67
|
role = 'third'; // suspended tone occupies the third's quality-defining slot
|
|
71
68
|
lock = 'quality';
|
|
72
|
-
} else if (
|
|
73
|
-
role =
|
|
74
|
-
lock = 'quality';
|
|
75
|
-
} else if (interval === 6 || interval === 7 || interval === 8) {
|
|
76
|
-
role = 'fifth';
|
|
77
|
-
lock = 'voicing';
|
|
78
|
-
} else if (interval === 9 && tones.has(3) && tones.has(6) && !hasHigherSeventh) {
|
|
79
|
-
role = 'seventh'; // diminished seventh
|
|
80
|
-
lock = 'voicing';
|
|
81
|
-
} else if (interval === 9 && tones.has(9) && !hasHigherSeventh) {
|
|
82
|
-
role = 'sixth';
|
|
83
|
-
lock = 'voicing';
|
|
84
|
-
} else if (interval === 10 || interval === 11) {
|
|
85
|
-
role = 'seventh';
|
|
86
|
-
lock = 'voicing';
|
|
69
|
+
} else if (chordRole !== null) {
|
|
70
|
+
role = chordRole;
|
|
71
|
+
lock = chordRole === 'root' ? 'identity' : chordRole === 'third' ? 'quality' : 'voicing';
|
|
87
72
|
} else {
|
|
88
73
|
role = 'tension';
|
|
89
74
|
lock = 'voicing';
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API of the theory layer. Re-exports every theory-layer module;
|
|
3
|
+
* also available from the package root.
|
|
4
|
+
*/
|
|
5
|
+
export type {
|
|
6
|
+
Chord as ChordData,
|
|
7
|
+
ChordQuality,
|
|
8
|
+
ChordSpan,
|
|
9
|
+
PitchSpelling,
|
|
10
|
+
} from './chord/index.js';
|
|
11
|
+
export {
|
|
12
|
+
chordFromDegree,
|
|
13
|
+
chordPitchClasses,
|
|
14
|
+
chordQualities,
|
|
15
|
+
chordToneRole,
|
|
16
|
+
diatonicSeventh,
|
|
17
|
+
diatonicTriad,
|
|
18
|
+
makeChord,
|
|
19
|
+
} from './chord/index.js';
|
|
20
|
+
export type {
|
|
21
|
+
ChordScaleMatch,
|
|
22
|
+
ChordScaleReportEntry,
|
|
23
|
+
ScaleChoice,
|
|
24
|
+
} from './chordscale/index.js';
|
|
25
|
+
export {
|
|
26
|
+
availableTensions,
|
|
27
|
+
avoidNotes,
|
|
28
|
+
chordScaleReport,
|
|
29
|
+
chordScales,
|
|
30
|
+
scaleMatchesChord,
|
|
31
|
+
scalesForChanges,
|
|
32
|
+
} from './chordscale/index.js';
|
|
33
|
+
export {
|
|
34
|
+
createsHiddenParallelPerfect,
|
|
35
|
+
createsParallelOctave,
|
|
36
|
+
createsParallelPerfect,
|
|
37
|
+
createsParallelUnison,
|
|
38
|
+
createsVerticalDissonance,
|
|
39
|
+
createsVoiceCrossing,
|
|
40
|
+
createsVoiceOverlap,
|
|
41
|
+
exceedsSpacing,
|
|
42
|
+
isForbiddenMelodicLeap,
|
|
43
|
+
isLeadingToneResolution,
|
|
44
|
+
} from './counterpoint/index.js';
|
|
45
|
+
export type { HarmonyRole, LockLevel, VoicedRole } from './harmony/index.js';
|
|
46
|
+
export { roleOf } from './harmony/index.js';
|
|
47
|
+
export type {
|
|
48
|
+
SafetyProfile,
|
|
49
|
+
SafetyQuery,
|
|
50
|
+
SafetyResult,
|
|
51
|
+
VoiceSnapshot,
|
|
52
|
+
} from './safety/index.js';
|
|
53
|
+
export {
|
|
54
|
+
enumerateSafePitches,
|
|
55
|
+
evaluateSafety,
|
|
56
|
+
NoteSafety,
|
|
57
|
+
ReasonFlag,
|
|
58
|
+
} from './safety/index.js';
|
|
59
|
+
export {
|
|
60
|
+
BLUES_MASK,
|
|
61
|
+
CHROMATIC_MASK,
|
|
62
|
+
DORIAN_MASK,
|
|
63
|
+
diatonicPitchClasses,
|
|
64
|
+
HARMONIC_MINOR_MASK,
|
|
65
|
+
isScaleTone,
|
|
66
|
+
LOCRIAN_MASK,
|
|
67
|
+
LYDIAN_MASK,
|
|
68
|
+
MAJOR_MASK,
|
|
69
|
+
MAJOR_PENTATONIC_MASK,
|
|
70
|
+
MELODIC_MINOR_MASK,
|
|
71
|
+
MINOR_PENTATONIC_MASK,
|
|
72
|
+
MIXOLYDIAN_MASK,
|
|
73
|
+
majorKey,
|
|
74
|
+
maskFromOffsets,
|
|
75
|
+
minorKey,
|
|
76
|
+
NAMED_SCALES,
|
|
77
|
+
NATURAL_MINOR_MASK,
|
|
78
|
+
nearestScaleTone,
|
|
79
|
+
OCTATONIC_HALF_WHOLE_MASK,
|
|
80
|
+
OCTATONIC_WHOLE_HALF_MASK,
|
|
81
|
+
PHRYGIAN_MASK,
|
|
82
|
+
pitchToScaleDegree,
|
|
83
|
+
scaleByName,
|
|
84
|
+
scaleTonesInDegreeOrder,
|
|
85
|
+
WHOLE_TONE_MASK,
|
|
86
|
+
} from './scale/index.js';
|
|
87
|
+
export {
|
|
88
|
+
noteNames,
|
|
89
|
+
spellChord,
|
|
90
|
+
spellPitchClass,
|
|
91
|
+
spellPitchClasses,
|
|
92
|
+
spellScale,
|
|
93
|
+
} from './spelling/index.js';
|
|
94
|
+
export {
|
|
95
|
+
formatChordSymbol,
|
|
96
|
+
parseChordSymbol,
|
|
97
|
+
transposeChordSymbol,
|
|
98
|
+
} from './symbol/index.js';
|
|
99
|
+
export type {
|
|
100
|
+
StyledVoicingOptions,
|
|
101
|
+
VoiceRange,
|
|
102
|
+
VoicingOptions,
|
|
103
|
+
VoicingStyle,
|
|
104
|
+
} from './voicing/index.js';
|
|
105
|
+
export {
|
|
106
|
+
nextVoicing,
|
|
107
|
+
SATB_RANGES,
|
|
108
|
+
voiceChord,
|
|
109
|
+
voiceChordStyled,
|
|
110
|
+
voiceLeadingCost,
|
|
111
|
+
voiceProgression,
|
|
112
|
+
} from './voicing/index.js';
|
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
import type { KeyScale } from '../../core/types.js';
|
|
2
|
+
import {
|
|
3
|
+
assertFiniteNumber,
|
|
4
|
+
assertGenerationBudget,
|
|
5
|
+
assertInteger,
|
|
6
|
+
} from '../../core/validation/index.js';
|
|
1
7
|
import type { Chord } from '../chord/index.js';
|
|
2
8
|
import { chordPitchClasses } from '../chord/index.js';
|
|
3
9
|
import {
|
|
@@ -7,7 +13,6 @@ import {
|
|
|
7
13
|
isForbiddenMelodicLeap,
|
|
8
14
|
} from '../counterpoint/index.js';
|
|
9
15
|
import { isScaleTone } from '../scale/index.js';
|
|
10
|
-
import type { KeyScale } from '../types.js';
|
|
11
16
|
|
|
12
17
|
/**
|
|
13
18
|
* Severity policy: `strict` counterpoint vs. lenient `pop` voice-leading.
|
|
@@ -96,6 +101,36 @@ const SUGGESTION_WINDOW = 12;
|
|
|
96
101
|
/** Maximum number of alternative pitches returned in `SafetyResult.suggestions`. */
|
|
97
102
|
const MAX_SUGGESTIONS = 3;
|
|
98
103
|
|
|
104
|
+
/** Canonical minimum verdict contributed by each non-informational reason. */
|
|
105
|
+
const REASON_SEVERITY: ReadonlyArray<
|
|
106
|
+
readonly [flag: ReasonFlag, pop: NoteSafety, strict: NoteSafety]
|
|
107
|
+
> = [
|
|
108
|
+
[ReasonFlag.Tension, NoteSafety.Warning, NoteSafety.Warning],
|
|
109
|
+
[ReasonFlag.AvoidNote, NoteSafety.Warning, NoteSafety.Dissonant],
|
|
110
|
+
[ReasonFlag.ScaleTone, NoteSafety.Warning, NoteSafety.Warning],
|
|
111
|
+
[ReasonFlag.NonScale, NoteSafety.Dissonant, NoteSafety.Dissonant],
|
|
112
|
+
[ReasonFlag.OutOfRange, NoteSafety.Warning, NoteSafety.Warning],
|
|
113
|
+
[ReasonFlag.Tritone, NoteSafety.Dissonant, NoteSafety.Dissonant],
|
|
114
|
+
[ReasonFlag.LargeLeap, NoteSafety.Warning, NoteSafety.Dissonant],
|
|
115
|
+
[ReasonFlag.VerticalDissonance, NoteSafety.Dissonant, NoteSafety.Dissonant],
|
|
116
|
+
[ReasonFlag.ParallelPerfect, NoteSafety.Warning, NoteSafety.Dissonant],
|
|
117
|
+
[ReasonFlag.HiddenParallel, NoteSafety.Warning, NoteSafety.Dissonant],
|
|
118
|
+
[ReasonFlag.VoiceCrossing, NoteSafety.Warning, NoteSafety.Dissonant],
|
|
119
|
+
[ReasonFlag.Suspension, NoteSafety.Dissonant, NoteSafety.Dissonant],
|
|
120
|
+
[ReasonFlag.NeedsResolution, NoteSafety.Warning, NoteSafety.Dissonant],
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
function minimumSafetyForReasons(reasons: number, profile: SafetyProfile): NoteSafety {
|
|
124
|
+
let minimum = NoteSafety.Safe;
|
|
125
|
+
const severityIndex = profile === 'strict' ? 2 : 1;
|
|
126
|
+
for (const entry of REASON_SEVERITY) {
|
|
127
|
+
if (reasons & entry[0]) {
|
|
128
|
+
minimum = Math.max(minimum, entry[severityIndex]) as NoteSafety;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return minimum;
|
|
132
|
+
}
|
|
133
|
+
|
|
99
134
|
function pitchClass(pitch: number): number {
|
|
100
135
|
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
101
136
|
}
|
|
@@ -154,9 +189,30 @@ function stepResolution(pitch: number, chord: Chord): number | undefined {
|
|
|
154
189
|
* @category Arrangement & Analysis
|
|
155
190
|
*/
|
|
156
191
|
export function evaluateSafety(q: SafetyQuery): SafetyResult {
|
|
192
|
+
assertSafetyContext(q);
|
|
193
|
+
assertFiniteNumber(q.candidatePitch, 'candidatePitch');
|
|
157
194
|
return evaluateInternal(q, true);
|
|
158
195
|
}
|
|
159
196
|
|
|
197
|
+
/** Validate all numeric context fields once at a public safety entry point. */
|
|
198
|
+
function assertSafetyContext(q: Omit<SafetyQuery, 'candidatePitch'>): void {
|
|
199
|
+
if (q.prevPitch !== undefined) assertFiniteNumber(q.prevPitch, 'prevPitch');
|
|
200
|
+
if (q.vocalLow !== undefined) assertFiniteNumber(q.vocalLow, 'vocalLow');
|
|
201
|
+
if (q.vocalHigh !== undefined) assertFiniteNumber(q.vocalHigh, 'vocalHigh');
|
|
202
|
+
if (q.vocalLow !== undefined && q.vocalHigh !== undefined && q.vocalLow > q.vocalHigh) {
|
|
203
|
+
throw new RangeError('vocalLow must not exceed vocalHigh');
|
|
204
|
+
}
|
|
205
|
+
assertGenerationBudget(q.otherVoices.length, 'other voices');
|
|
206
|
+
for (let index = 0; index < q.otherVoices.length; index += 1) {
|
|
207
|
+
const voice = q.otherVoices[index];
|
|
208
|
+
if (!voice) continue;
|
|
209
|
+
assertFiniteNumber(voice.pitch, `otherVoices[${index}].pitch`);
|
|
210
|
+
if (voice.prevPitch !== undefined) {
|
|
211
|
+
assertFiniteNumber(voice.prevPitch, `otherVoices[${index}].prevPitch`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
160
216
|
/**
|
|
161
217
|
* Core safety evaluation.
|
|
162
218
|
*
|
|
@@ -239,9 +295,11 @@ function evaluateInternal(q: SafetyQuery, collectSuggestions: boolean): SafetyRe
|
|
|
239
295
|
const pc = Math.abs(pitch - q.prevPitch) % 12;
|
|
240
296
|
if (pc === 6) {
|
|
241
297
|
reasons |= ReasonFlag.Tritone;
|
|
298
|
+
raise(NoteSafety.Dissonant);
|
|
242
299
|
}
|
|
243
300
|
if (isForbiddenMelodicLeap(q.prevPitch, pitch)) {
|
|
244
301
|
reasons |= ReasonFlag.LargeLeap;
|
|
302
|
+
raise(parallelSeverity);
|
|
245
303
|
}
|
|
246
304
|
if (pc === 1) {
|
|
247
305
|
reasons |= ReasonFlag.MinorSecond;
|
|
@@ -298,6 +356,12 @@ function evaluateInternal(q: SafetyQuery, collectSuggestions: boolean): SafetyRe
|
|
|
298
356
|
}
|
|
299
357
|
}
|
|
300
358
|
|
|
359
|
+
// Keep reason production and verdict policy as one invariant even when a new
|
|
360
|
+
// call path skips an earlier local `raise`: informational flags (ChordTone,
|
|
361
|
+
// MinorSecond, MajorSeventh) may coexist with Safe; every policy flag above
|
|
362
|
+
// contributes at least its table severity.
|
|
363
|
+
raise(minimumSafetyForReasons(reasons, q.profile));
|
|
364
|
+
|
|
301
365
|
const result: SafetyResult = { safety: level, reasons, rationale: describe(reasons, q) };
|
|
302
366
|
if (resolveTo !== undefined) {
|
|
303
367
|
result.resolveTo = resolveTo;
|
|
@@ -382,6 +446,7 @@ function describe(reasons: number, q: SafetyQuery): string {
|
|
|
382
446
|
* @param pitchLow Lowest MIDI pitch to consider (inclusive).
|
|
383
447
|
* @param pitchHigh Highest MIDI pitch to consider (inclusive).
|
|
384
448
|
* @returns Placeable pitches (non-dissonant), chord tones before others, each group descending.
|
|
449
|
+
* @throws If either bound is non-finite/non-integral, reversed, or exceeds the generation budget.
|
|
385
450
|
* @example
|
|
386
451
|
* ```ts
|
|
387
452
|
* import { enumerateSafePitches, makeChord, majorKey } from '@libraz/libcantus';
|
|
@@ -399,11 +464,15 @@ export function enumerateSafePitches(
|
|
|
399
464
|
pitchLow: number,
|
|
400
465
|
pitchHigh: number,
|
|
401
466
|
): number[] {
|
|
467
|
+
assertSafetyContext(q);
|
|
402
468
|
const chordTones: number[] = [];
|
|
403
469
|
const others: number[] = [];
|
|
404
|
-
|
|
405
|
-
|
|
470
|
+
assertInteger(pitchLow, 'pitchLow');
|
|
471
|
+
assertInteger(pitchHigh, 'pitchHigh');
|
|
472
|
+
if (pitchLow > pitchHigh) {
|
|
473
|
+
throw new RangeError(`pitchLow must not exceed pitchHigh; received ${pitchLow} > ${pitchHigh}`);
|
|
406
474
|
}
|
|
475
|
+
assertGenerationBudget(pitchHigh - pitchLow + 1, 'safe pitch candidates');
|
|
407
476
|
for (let pitch = pitchHigh; pitch >= pitchLow; pitch -= 1) {
|
|
408
477
|
const result = evaluateInternal({ ...q, candidatePitch: pitch }, false);
|
|
409
478
|
if (result.safety === NoteSafety.Dissonant) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import type { KeyScale } from '../../core/types.js';
|
|
2
|
+
|
|
3
|
+
/** Reduce a pitch (or pitch class) to a pitch class in [0, 11]. */
|
|
4
|
+
function pitchClass(pitch: number): number {
|
|
5
|
+
return ((Math.trunc(pitch) % 12) + 12) % 12;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** Scale offset of a pitch relative to the key root, in [0, 11]. */
|
|
9
|
+
function scaleOffset(pitch: number, key: KeyScale): number {
|
|
10
|
+
return (pitchClass(pitch) - pitchClass(key.rootPc) + 12) % 12;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Test whether a pitch belongs to the scale.
|
|
15
|
+
*
|
|
16
|
+
* @param pitch MIDI pitch or bare pitch class.
|
|
17
|
+
* @param key The key/scale to test against.
|
|
18
|
+
* @returns True if the pitch class is a member of the scale.
|
|
19
|
+
*
|
|
20
|
+
* @example
|
|
21
|
+
* ```ts
|
|
22
|
+
* import { majorKey, isScaleTone } from '@libraz/libcantus';
|
|
23
|
+
* const key = majorKey(0); // C major
|
|
24
|
+
* isScaleTone(64, key); // true — E is in C major
|
|
25
|
+
* isScaleTone(61, key); // false — C# is not
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @category Scales
|
|
29
|
+
*/
|
|
30
|
+
export function isScaleTone(pitch: number, key: KeyScale): boolean {
|
|
31
|
+
return ((key.modeMask12 >> scaleOffset(pitch, key)) & 1) === 1;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Find the nearest MIDI pitch whose pitch class is in the scale.
|
|
36
|
+
*
|
|
37
|
+
* The search expands symmetrically outward from `pitch`. On a tie (equal
|
|
38
|
+
* distance above and below) the lower pitch is returned.
|
|
39
|
+
*
|
|
40
|
+
* @param pitch MIDI pitch to snap.
|
|
41
|
+
* @param key The key/scale to snap to.
|
|
42
|
+
* @returns The nearest in-scale MIDI pitch.
|
|
43
|
+
*
|
|
44
|
+
* @category Scales
|
|
45
|
+
*/
|
|
46
|
+
export function nearestScaleTone(pitch: number, key: KeyScale): number {
|
|
47
|
+
const base = Math.round(pitch);
|
|
48
|
+
for (let distance = 0; distance < 12; distance += 1) {
|
|
49
|
+
const lower = base - distance;
|
|
50
|
+
if (isScaleTone(lower, key)) {
|
|
51
|
+
return lower;
|
|
52
|
+
}
|
|
53
|
+
const higher = base + distance;
|
|
54
|
+
if (isScaleTone(higher, key)) {
|
|
55
|
+
return higher;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return base;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Get the 0-based scale degree of a pitch.
|
|
63
|
+
*
|
|
64
|
+
* @param pitch MIDI pitch or bare pitch class.
|
|
65
|
+
* @param key The key/scale to measure against.
|
|
66
|
+
* @returns The 0-based degree, or -1 if the pitch is not in the scale.
|
|
67
|
+
*
|
|
68
|
+
* @category Scales
|
|
69
|
+
*/
|
|
70
|
+
export function pitchToScaleDegree(pitch: number, key: KeyScale): number {
|
|
71
|
+
const offset = scaleOffset(pitch, key);
|
|
72
|
+
if (((key.modeMask12 >> offset) & 1) === 0) {
|
|
73
|
+
return -1;
|
|
74
|
+
}
|
|
75
|
+
let degree = 0;
|
|
76
|
+
for (let n = 0; n < offset; n += 1) {
|
|
77
|
+
if (((key.modeMask12 >> n) & 1) === 1) {
|
|
78
|
+
degree += 1;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return degree;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* List the pitch classes of a key's scale.
|
|
86
|
+
*
|
|
87
|
+
* @param key The key/scale to enumerate.
|
|
88
|
+
* @returns The member pitch classes, sorted ascending in [0, 11].
|
|
89
|
+
*
|
|
90
|
+
* @category Scales
|
|
91
|
+
*/
|
|
92
|
+
export function diatonicPitchClasses(key: KeyScale): number[] {
|
|
93
|
+
const root = pitchClass(key.rootPc);
|
|
94
|
+
const pcs: number[] = [];
|
|
95
|
+
for (let n = 0; n < 12; n += 1) {
|
|
96
|
+
if (((key.modeMask12 >> n) & 1) === 1) {
|
|
97
|
+
pcs.push((root + n) % 12);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return pcs.sort((a, b) => a - b);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* List the pitch classes of a key's scale in ascending scale-degree order.
|
|
105
|
+
*
|
|
106
|
+
* Degree 0 is the root; degrees follow the mask bits in offset order rather
|
|
107
|
+
* than sorted pitch-class order.
|
|
108
|
+
*
|
|
109
|
+
* @param key The key/scale to enumerate.
|
|
110
|
+
* @returns The member pitch classes ordered by scale degree.
|
|
111
|
+
*
|
|
112
|
+
* @category Scales
|
|
113
|
+
*/
|
|
114
|
+
export function scaleTonesInDegreeOrder(key: KeyScale): number[] {
|
|
115
|
+
const root = pitchClass(key.rootPc);
|
|
116
|
+
const pcs: number[] = [];
|
|
117
|
+
for (let n = 0; n < 12; n += 1) {
|
|
118
|
+
if (((key.modeMask12 >> n) & 1) === 1) {
|
|
119
|
+
pcs.push((root + n) % 12);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return pcs;
|
|
123
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export {
|
|
2
|
+
diatonicPitchClasses,
|
|
3
|
+
isScaleTone,
|
|
4
|
+
nearestScaleTone,
|
|
5
|
+
pitchToScaleDegree,
|
|
6
|
+
scaleTonesInDegreeOrder,
|
|
7
|
+
} from './degrees.js';
|
|
8
|
+
export { majorKey, minorKey, scaleByName } from './key.js';
|
|
9
|
+
export {
|
|
10
|
+
BLUES_MASK,
|
|
11
|
+
CHROMATIC_MASK,
|
|
12
|
+
DORIAN_MASK,
|
|
13
|
+
HARMONIC_MINOR_MASK,
|
|
14
|
+
LOCRIAN_MASK,
|
|
15
|
+
LYDIAN_MASK,
|
|
16
|
+
MAJOR_MASK,
|
|
17
|
+
MAJOR_PENTATONIC_MASK,
|
|
18
|
+
MELODIC_MINOR_MASK,
|
|
19
|
+
MINOR_PENTATONIC_MASK,
|
|
20
|
+
MIXOLYDIAN_MASK,
|
|
21
|
+
maskFromOffsets,
|
|
22
|
+
NAMED_SCALES,
|
|
23
|
+
NATURAL_MINOR_MASK,
|
|
24
|
+
OCTATONIC_HALF_WHOLE_MASK,
|
|
25
|
+
OCTATONIC_WHOLE_HALF_MASK,
|
|
26
|
+
PHRYGIAN_MASK,
|
|
27
|
+
WHOLE_TONE_MASK,
|
|
28
|
+
} from './masks.js';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { KeyScale } from '../../core/types.js';
|
|
2
|
+
import { MAJOR_MASK, NAMED_SCALES, NATURAL_MINOR_MASK } from './masks.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Build a `KeyScale` for a major key on the given root pitch class.
|
|
6
|
+
*
|
|
7
|
+
* @example
|
|
8
|
+
* ```ts
|
|
9
|
+
* import { majorKey } from '@libraz/libcantus';
|
|
10
|
+
* const cMajor = majorKey(0); // C major: { rootPc: 0, modeMask12: MAJOR_MASK }
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* @category Scales
|
|
14
|
+
*/
|
|
15
|
+
export function majorKey(rootPc: number): KeyScale {
|
|
16
|
+
return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: MAJOR_MASK };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build a `KeyScale` for a natural-minor key on the given root pitch class.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* import { minorKey } from '@libraz/libcantus';
|
|
25
|
+
* const aMinor = minorKey(9); // A natural minor: { rootPc: 9, modeMask12: NATURAL_MINOR_MASK }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @category Scales
|
|
29
|
+
*/
|
|
30
|
+
export function minorKey(rootPc: number): KeyScale {
|
|
31
|
+
return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: NATURAL_MINOR_MASK };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Build a `KeyScale` from a named scale (see {@link NAMED_SCALES}).
|
|
36
|
+
*
|
|
37
|
+
* @param name The scale name, e.g. `'dorian'` or `'harmonicMinor'`.
|
|
38
|
+
* @param rootPc The root pitch class.
|
|
39
|
+
* @returns The key/scale.
|
|
40
|
+
* @throws If the name is not a known scale.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { scaleByName } from '@libraz/libcantus';
|
|
45
|
+
* const dDorian = scaleByName('dorian', 2); // D Dorian, rootPc 2
|
|
46
|
+
* ```
|
|
47
|
+
*
|
|
48
|
+
* @category Scales
|
|
49
|
+
*/
|
|
50
|
+
export function scaleByName(name: string, rootPc: number): KeyScale {
|
|
51
|
+
const mask = NAMED_SCALES[name];
|
|
52
|
+
if (mask === undefined) {
|
|
53
|
+
throw new Error(`Unknown scale: ${name}`);
|
|
54
|
+
}
|
|
55
|
+
return { rootPc: ((rootPc % 12) + 12) % 12, modeMask12: mask };
|
|
56
|
+
}
|