-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathChromaticComposer.js
More file actions
165 lines (151 loc) · 6.89 KB
/
ChromaticComposer.js
File metadata and controls
165 lines (151 loc) · 6.89 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
// ChromaticComposer.js - Chromatic passing tone / enclosure / neighbor-note composition
// Generates chromatic approach patterns relative to a target scale context.
// Not the same as "playing the chromatic scale" - this composer uses a target
// scale to distinguish chord/scale tones from chromatic passing tones and builds
// idiomatic enclosure, neighbor, and approach figures.
ChromaticComposer = class ChromaticComposer extends MeasureComposer {
/**
* @param {string} targetScaleName - Scale to orbit around (e.g., 'major', 'minor')
* @param {string} root - Root note (e.g., 'C', 'D')
* @param {number} [chromaticDensity=0.4] - 0-1, how much chromatic content vs diatonic
*/
constructor(targetScaleName = 'major', root = 'C', chromaticDensity = 0.4) {
super();
this.root = root;
this.chromaticDensity = clamp(chromaticDensity, 0, 1);
/** @type {Set<number>} */
this.ChromaticComposerTargetPCs = new Set();
/** @type {string[]} */
this.ChromaticComposerTargetNotes = [];
this.enableVoiceLeading(new VoiceLeadingScore());
this.noteSet(targetScaleName, root);
}
/**
* Set target scale context.
* @param {string} targetScaleName
* @param {string} root
*/
noteSet(targetScaleName, root) {
if (typeof targetScaleName !== 'string' || !targetScaleName) {
throw new Error(`ChromaticComposer.noteSet: targetScaleName must be non-empty string`);
}
if (typeof root !== 'string' || !root) {
throw new Error(`ChromaticComposer.noteSet: root must be non-empty string`);
}
this.root = root;
const targetScale = t.Scale.get(`${root} ${targetScaleName}`);
if (!targetScale || !Array.isArray(targetScale.notes) || targetScale.notes.length === 0) {
throw new Error(`ChromaticComposer.noteSet: scale "${root} ${targetScaleName}" not found`);
}
this.ChromaticComposerTargetNotes = targetScale.notes;
// Build the target pitch-class set for enclosure detection
this.ChromaticComposerTargetPCs = new Set(targetScale.notes.map(n => t.Note.chroma(n)).filter(c => Number.isFinite(c)));
// Use full chromatic as the available note pool
const chromatic = t.Scale.get(`${root} chromatic`);
if (!chromatic || !Array.isArray(chromatic.notes) || chromatic.notes.length === 0) {
throw new Error(`ChromaticComposer.noteSet: chromatic scale failed for root=${root}`);
}
this.notes = chromatic.notes;
this.intervalOptions = {
style: 'rising',
density: 0.7,
minNotes: 4,
maxNotes: 12,
jitter: true,
};
this.voicingOptions = {
minSemitones: 1,
};
}
/**
* Generate notes with chromatic approach patterns.
* For each selected note, probabilistically wraps it in an enclosure,
* neighbor, or approach figure relative to the target scale.
* @param {number[]|null} [octaveRange]
* @returns {{note: number}[]}
*/
getNotes(octaveRange = null) {
const baseNotes = super.getNotes(octaveRange);
if (!Array.isArray(baseNotes) || baseNotes.length === 0) {
throw new Error('ChromaticComposer.getNotes: super.getNotes() returned empty');
}
const result = [];
for (const n of baseNotes) {
const midiRaw = typeof n === 'number' ? n : (n && typeof n.note === 'number' ? n.note : null);
if (!Number.isFinite(midiRaw)) throw new Error('ChromaticComposer.getNotes: invalid note in base pool');
/** @type {number} */
const midi = /** @type {number} */ (midiRaw);
const isTargetTone = this.ChromaticComposerTargetPCs.has(midi % 12);
const wrapped = typeof n === 'number' ? { note: n } : n;
if (rf() < this.chromaticDensity) {
if (isTargetTone) {
// Note is diatonic - ornament it with chromatic approaches
const pattern = rf();
if (pattern < 0.35) {
// Enclosure: chromatic above + below - target
result.push({ note: clamp(midi + 1, 0, 127), ChromaticComposerApproach: 'enclosure-upper' });
result.push({ note: clamp(midi - 1, 0, 127), ChromaticComposerApproach: 'enclosure-lower' });
result.push(wrapped);
} else if (pattern < 0.6) {
// Upper neighbor: target - step up - back
result.push(wrapped);
result.push({ note: clamp(midi + 1, 0, 127), ChromaticComposerApproach: 'upper-neighbor' });
result.push(wrapped);
} else if (pattern < 0.8) {
// Lower approach: chromatic step from below
result.push({ note: clamp(midi - 1, 0, 127), ChromaticComposerApproach: 'lower-approach' });
result.push(wrapped);
} else {
// Double chromatic approach from above
result.push({ note: clamp(midi + 2, 0, 127), ChromaticComposerApproach: 'double-upper' });
result.push({ note: clamp(midi + 1, 0, 127), ChromaticComposerApproach: 'upper-approach' });
result.push(wrapped);
}
} else {
// Note is already chromatic - resolve toward nearest scale tone
const below = this.ChromaticComposerTargetPCs.has((midi - 1) % 12) ? midi - 1 : null;
const above = this.ChromaticComposerTargetPCs.has((midi + 1) % 12) ? midi + 1 : null;
if (below !== null && above !== null) {
// Both neighbors are scale tones - chromatic passing tone between them
result.push({ note: clamp(below, 0, 127), ChromaticComposerApproach: 'resolve-below' });
result.push(wrapped);
result.push({ note: clamp(above, 0, 127), ChromaticComposerApproach: 'resolve-above' });
} else if (below !== null) {
// Approach from below, land on chromatic, resolve down
result.push(wrapped);
result.push({ note: clamp(below, 0, 127), ChromaticComposerApproach: 'resolve-down' });
} else if (above !== null) {
// Chromatic leads up into scale tone
result.push(wrapped);
result.push({ note: clamp(above, 0, 127), ChromaticComposerApproach: 'resolve-up' });
} else {
// Isolated chromatic - pass through as color
result.push(wrapped);
}
}
} else {
// Below density threshold - pass through unadorned
result.push(wrapped);
}
}
return result;
}
x = () => this.getNotes();
}
RandomChromaticComposer = class RandomChromaticComposer extends ChromaticComposer {
constructor() {
super();
this.noteSet();
}
noteSet() {
if (!Array.isArray(allNotes) || allNotes.length === 0) throw new Error('RandomChromaticComposer.noteSet: allNotes not available');
if (!Array.isArray(allScales) || allScales.length === 0) throw new Error('RandomChromaticComposer.noteSet: allScales not available');
const randomRoot = allNotes[ri(allNotes.length - 1)];
const randomScale = allScales[ri(allScales.length - 1)];
super.noteSet(randomScale, randomRoot);
}
x() {
this.noteSet();
return super.x();
}
}