@pinkpixel/haku 0.0.0-stage → 0.1.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.
- package/README.md +147 -2
- package/dist/LICENSE +202 -0
- package/dist/cli.mjs +134 -0
- package/dist/fonts/Figtree-Regular.ttf +0 -0
- package/dist/fonts/Figtree-SemiBold.ttf +0 -0
- package/dist/fonts/OFL.txt +93 -0
- package/dist/mcp-DDuNECfs.mjs +71 -0
- package/dist/node-CVxAljK8.mjs +24 -0
- package/dist/samples/ambience/rain_roof.ogg +0 -0
- package/dist/samples/ambience/rain_window.ogg +0 -0
- package/dist/samples/drums/brush_v0_r1.ogg +0 -0
- package/dist/samples/drums/brush_v0_r2.ogg +0 -0
- package/dist/samples/drums/brush_v1_r1.ogg +0 -0
- package/dist/samples/drums/brush_v1_r2.ogg +0 -0
- package/dist/samples/drums/brushhat_v0_r1.ogg +0 -0
- package/dist/samples/drums/brushhat_v0_r2.ogg +0 -0
- package/dist/samples/drums/brushhat_v1_r1.ogg +0 -0
- package/dist/samples/drums/brushhat_v1_r2.ogg +0 -0
- package/dist/samples/drums/hat_v0_r1.ogg +0 -0
- package/dist/samples/drums/hat_v0_r2.ogg +0 -0
- package/dist/samples/drums/hat_v1_r1.ogg +0 -0
- package/dist/samples/drums/hat_v1_r2.ogg +0 -0
- package/dist/samples/drums/kick_v0_r1.ogg +0 -0
- package/dist/samples/drums/kick_v0_r2.ogg +0 -0
- package/dist/samples/drums/kick_v1_r1.ogg +0 -0
- package/dist/samples/drums/kick_v1_r2.ogg +0 -0
- package/dist/samples/drums/openhat_v0_r1.ogg +0 -0
- package/dist/samples/drums/openhat_v0_r2.ogg +0 -0
- package/dist/samples/drums/openhat_v1_r1.ogg +0 -0
- package/dist/samples/drums/openhat_v1_r2.ogg +0 -0
- package/dist/samples/drums/ride_v0_r1.ogg +0 -0
- package/dist/samples/drums/ride_v0_r2.ogg +0 -0
- package/dist/samples/drums/ride_v1_r1.ogg +0 -0
- package/dist/samples/drums/ride_v1_r2.ogg +0 -0
- package/dist/samples/drums/rim_v0_r1.ogg +0 -0
- package/dist/samples/drums/rim_v0_r2.ogg +0 -0
- package/dist/samples/drums/rim_v1_r1.ogg +0 -0
- package/dist/samples/drums/rim_v1_r2.ogg +0 -0
- package/dist/samples/drums/snare_v0_r1.ogg +0 -0
- package/dist/samples/drums/snare_v0_r2.ogg +0 -0
- package/dist/samples/drums/snare_v1_r1.ogg +0 -0
- package/dist/samples/drums/snare_v1_r2.ogg +0 -0
- package/dist/samples/drums/tom_v0_r1.ogg +0 -0
- package/dist/samples/drums/tom_v0_r2.ogg +0 -0
- package/dist/samples/drums/tom_v1_r1.ogg +0 -0
- package/dist/samples/drums/tom_v1_r2.ogg +0 -0
- package/dist/samples/glock/v0_103.ogg +0 -0
- package/dist/samples/glock/v0_108.ogg +0 -0
- package/dist/samples/glock/v0_79.ogg +0 -0
- package/dist/samples/glock/v0_84.ogg +0 -0
- package/dist/samples/glock/v0_91.ogg +0 -0
- package/dist/samples/glock/v0_96.ogg +0 -0
- package/dist/samples/harp/v0_101.ogg +0 -0
- package/dist/samples/harp/v0_28.ogg +0 -0
- package/dist/samples/harp/v0_31.ogg +0 -0
- package/dist/samples/harp/v0_35.ogg +0 -0
- package/dist/samples/harp/v0_38.ogg +0 -0
- package/dist/samples/harp/v0_41.ogg +0 -0
- package/dist/samples/harp/v0_45.ogg +0 -0
- package/dist/samples/harp/v0_48.ogg +0 -0
- package/dist/samples/harp/v0_52.ogg +0 -0
- package/dist/samples/harp/v0_55.ogg +0 -0
- package/dist/samples/harp/v0_59.ogg +0 -0
- package/dist/samples/harp/v0_62.ogg +0 -0
- package/dist/samples/harp/v0_65.ogg +0 -0
- package/dist/samples/harp/v0_69.ogg +0 -0
- package/dist/samples/harp/v0_72.ogg +0 -0
- package/dist/samples/harp/v0_76.ogg +0 -0
- package/dist/samples/harp/v0_79.ogg +0 -0
- package/dist/samples/harp/v0_83.ogg +0 -0
- package/dist/samples/harp/v0_86.ogg +0 -0
- package/dist/samples/harp/v0_89.ogg +0 -0
- package/dist/samples/harp/v0_93.ogg +0 -0
- package/dist/samples/harp/v0_95.ogg +0 -0
- package/dist/samples/harp/v0_98.ogg +0 -0
- package/dist/samples/manifest.json +368 -0
- package/dist/samples/marimba/v0_41.ogg +0 -0
- package/dist/samples/marimba/v0_48.ogg +0 -0
- package/dist/samples/marimba/v0_55.ogg +0 -0
- package/dist/samples/marimba/v0_59.ogg +0 -0
- package/dist/samples/marimba/v0_65.ogg +0 -0
- package/dist/samples/marimba/v0_72.ogg +0 -0
- package/dist/samples/marimba/v0_79.ogg +0 -0
- package/dist/samples/marimba/v0_83.ogg +0 -0
- package/dist/samples/marimba/v0_89.ogg +0 -0
- package/dist/samples/marimba/v0_96.ogg +0 -0
- package/dist/samples/piano/v0_29.ogg +0 -0
- package/dist/samples/piano/v0_33.ogg +0 -0
- package/dist/samples/piano/v0_37.ogg +0 -0
- package/dist/samples/piano/v0_41.ogg +0 -0
- package/dist/samples/piano/v0_45.ogg +0 -0
- package/dist/samples/piano/v0_49.ogg +0 -0
- package/dist/samples/piano/v0_53.ogg +0 -0
- package/dist/samples/piano/v0_57.ogg +0 -0
- package/dist/samples/piano/v0_61.ogg +0 -0
- package/dist/samples/piano/v0_65.ogg +0 -0
- package/dist/samples/piano/v0_69.ogg +0 -0
- package/dist/samples/piano/v0_73.ogg +0 -0
- package/dist/samples/piano/v0_77.ogg +0 -0
- package/dist/samples/piano/v0_81.ogg +0 -0
- package/dist/samples/piano/v0_85.ogg +0 -0
- package/dist/samples/piano/v0_89.ogg +0 -0
- package/dist/samples/piano/v0_93.ogg +0 -0
- package/dist/samples/piano/v0_97.ogg +0 -0
- package/dist/samples/piano/v1_29.ogg +0 -0
- package/dist/samples/piano/v1_33.ogg +0 -0
- package/dist/samples/piano/v1_37.ogg +0 -0
- package/dist/samples/piano/v1_41.ogg +0 -0
- package/dist/samples/piano/v1_45.ogg +0 -0
- package/dist/samples/piano/v1_49.ogg +0 -0
- package/dist/samples/piano/v1_53.ogg +0 -0
- package/dist/samples/piano/v1_57.ogg +0 -0
- package/dist/samples/piano/v1_61.ogg +0 -0
- package/dist/samples/piano/v1_65.ogg +0 -0
- package/dist/samples/piano/v1_69.ogg +0 -0
- package/dist/samples/piano/v1_73.ogg +0 -0
- package/dist/samples/piano/v1_77.ogg +0 -0
- package/dist/samples/piano/v1_81.ogg +0 -0
- package/dist/samples/piano/v1_85.ogg +0 -0
- package/dist/samples/piano/v1_89.ogg +0 -0
- package/dist/samples/piano/v1_93.ogg +0 -0
- package/dist/samples/piano/v1_97.ogg +0 -0
- package/dist/samples/wurli/v0_33.ogg +0 -0
- package/dist/samples/wurli/v0_40.ogg +0 -0
- package/dist/samples/wurli/v0_45.ogg +0 -0
- package/dist/samples/wurli/v0_51.ogg +0 -0
- package/dist/samples/wurli/v0_56.ogg +0 -0
- package/dist/samples/wurli/v0_61.ogg +0 -0
- package/dist/samples/wurli/v0_66.ogg +0 -0
- package/dist/samples/wurli/v0_71.ogg +0 -0
- package/dist/samples/wurli/v0_73.ogg +0 -0
- package/dist/samples/wurli/v0_79.ogg +0 -0
- package/dist/samples/wurli/v0_85.ogg +0 -0
- package/dist/samples/wurli/v0_92.ogg +0 -0
- package/dist/samples/wurli/v1_33.ogg +0 -0
- package/dist/samples/wurli/v1_36.ogg +0 -0
- package/dist/samples/wurli/v1_41.ogg +0 -0
- package/dist/samples/wurli/v1_47.ogg +0 -0
- package/dist/samples/wurli/v1_52.ogg +0 -0
- package/dist/samples/wurli/v1_56.ogg +0 -0
- package/dist/samples/wurli/v1_61.ogg +0 -0
- package/dist/samples/wurli/v1_68.ogg +0 -0
- package/dist/samples/wurli/v1_73.ogg +0 -0
- package/dist/samples/wurli/v1_79.ogg +0 -0
- package/dist/samples/wurli/v1_85.ogg +0 -0
- package/dist/samples/wurli/v1_92.ogg +0 -0
- package/dist/src-D5fW7AC1.mjs +2313 -0
- package/dist/src-JrRbTOJC.mjs +701 -0
- package/dist/version-DoZ2etXG.mjs +1742 -0
- package/package.json +59 -3
|
@@ -0,0 +1,2313 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import * as midiModule from "@tonejs/midi";
|
|
3
|
+
// ../../packages/core/src/theory/notes.ts
|
|
4
|
+
const SHARP_NAMES = [
|
|
5
|
+
"C",
|
|
6
|
+
"C#",
|
|
7
|
+
"D",
|
|
8
|
+
"D#",
|
|
9
|
+
"E",
|
|
10
|
+
"F",
|
|
11
|
+
"F#",
|
|
12
|
+
"G",
|
|
13
|
+
"G#",
|
|
14
|
+
"A",
|
|
15
|
+
"A#",
|
|
16
|
+
"B"
|
|
17
|
+
];
|
|
18
|
+
const FLAT_NAMES = [
|
|
19
|
+
"C",
|
|
20
|
+
"Db",
|
|
21
|
+
"D",
|
|
22
|
+
"Eb",
|
|
23
|
+
"E",
|
|
24
|
+
"F",
|
|
25
|
+
"Gb",
|
|
26
|
+
"G",
|
|
27
|
+
"Ab",
|
|
28
|
+
"A",
|
|
29
|
+
"Bb",
|
|
30
|
+
"B"
|
|
31
|
+
];
|
|
32
|
+
const LETTERS = {
|
|
33
|
+
C: 0,
|
|
34
|
+
D: 2,
|
|
35
|
+
E: 4,
|
|
36
|
+
F: 5,
|
|
37
|
+
G: 7,
|
|
38
|
+
A: 9,
|
|
39
|
+
B: 11
|
|
40
|
+
};
|
|
41
|
+
const mod12 = (n) => (n % 12 + 12) % 12;
|
|
42
|
+
/** Parses a pitch class like "C", "f#", "Bb", "Ebb". Returns null if it isn't one. */
|
|
43
|
+
function parsePitchClass(name) {
|
|
44
|
+
const m = /^([A-Ga-g])([#b]*)$/.exec(name.trim());
|
|
45
|
+
if (!m) return null;
|
|
46
|
+
const base = LETTERS[m[1].toUpperCase()];
|
|
47
|
+
let shift = 0;
|
|
48
|
+
for (const ch of m[2]) shift += ch === "#" ? 1 : -1;
|
|
49
|
+
return mod12(base + shift);
|
|
50
|
+
}
|
|
51
|
+
/** Parses a note like "C4", "f#3", "Bb5" (C4 = 60) or a MIDI number string. */
|
|
52
|
+
function parseNote(name) {
|
|
53
|
+
const trimmed = name.trim();
|
|
54
|
+
if (/^\d+$/.test(trimmed)) {
|
|
55
|
+
const n = Number(trimmed);
|
|
56
|
+
return n >= 0 && n <= 127 ? n : null;
|
|
57
|
+
}
|
|
58
|
+
const m = /^([A-Ga-g][#b]*)(-?\d)$/.exec(trimmed);
|
|
59
|
+
if (!m) return null;
|
|
60
|
+
const pc = parsePitchClass(m[1]);
|
|
61
|
+
if (pc === null) return null;
|
|
62
|
+
const letter = LETTERS[m[1][0].toUpperCase()];
|
|
63
|
+
const accidental = pc - letter > 6 ? pc - letter - 12 : pc - letter < -6 ? pc - letter + 12 : pc - letter;
|
|
64
|
+
const midi = (Number(m[2]) + 1) * 12 + letter + accidental;
|
|
65
|
+
return midi >= 0 && midi <= 127 ? midi : null;
|
|
66
|
+
}
|
|
67
|
+
function pcName(pc, flats = false) {
|
|
68
|
+
return (flats ? FLAT_NAMES : SHARP_NAMES)[mod12(pc)];
|
|
69
|
+
}
|
|
70
|
+
function noteName(midi, flats = false) {
|
|
71
|
+
return `${pcName(midi, flats)}${Math.floor(midi / 12) - 1}`;
|
|
72
|
+
}
|
|
73
|
+
// ../../packages/core/src/theory/scales.ts
|
|
74
|
+
const MODES = [
|
|
75
|
+
"major",
|
|
76
|
+
"minor",
|
|
77
|
+
"dorian",
|
|
78
|
+
"mixolydian",
|
|
79
|
+
"lydian",
|
|
80
|
+
"phrygian"
|
|
81
|
+
];
|
|
82
|
+
const MODE_INTERVALS = {
|
|
83
|
+
major: [
|
|
84
|
+
0,
|
|
85
|
+
2,
|
|
86
|
+
4,
|
|
87
|
+
5,
|
|
88
|
+
7,
|
|
89
|
+
9,
|
|
90
|
+
11
|
|
91
|
+
],
|
|
92
|
+
minor: [
|
|
93
|
+
0,
|
|
94
|
+
2,
|
|
95
|
+
3,
|
|
96
|
+
5,
|
|
97
|
+
7,
|
|
98
|
+
8,
|
|
99
|
+
10
|
|
100
|
+
],
|
|
101
|
+
dorian: [
|
|
102
|
+
0,
|
|
103
|
+
2,
|
|
104
|
+
3,
|
|
105
|
+
5,
|
|
106
|
+
7,
|
|
107
|
+
9,
|
|
108
|
+
10
|
|
109
|
+
],
|
|
110
|
+
mixolydian: [
|
|
111
|
+
0,
|
|
112
|
+
2,
|
|
113
|
+
4,
|
|
114
|
+
5,
|
|
115
|
+
7,
|
|
116
|
+
9,
|
|
117
|
+
10
|
|
118
|
+
],
|
|
119
|
+
lydian: [
|
|
120
|
+
0,
|
|
121
|
+
2,
|
|
122
|
+
4,
|
|
123
|
+
6,
|
|
124
|
+
7,
|
|
125
|
+
9,
|
|
126
|
+
11
|
|
127
|
+
],
|
|
128
|
+
phrygian: [
|
|
129
|
+
0,
|
|
130
|
+
1,
|
|
131
|
+
3,
|
|
132
|
+
5,
|
|
133
|
+
7,
|
|
134
|
+
8,
|
|
135
|
+
10
|
|
136
|
+
]
|
|
137
|
+
};
|
|
138
|
+
/** Modes with a minor third use the minor pentatonic, the rest use the major one. */
|
|
139
|
+
function isMinorMode(mode) {
|
|
140
|
+
return MODE_INTERVALS[mode][2] === 3;
|
|
141
|
+
}
|
|
142
|
+
function tonicPc(key) {
|
|
143
|
+
const pc = parsePitchClass(key.tonic);
|
|
144
|
+
if (pc === null) throw new Error(`Unknown tonic "${key.tonic}"`);
|
|
145
|
+
return pc;
|
|
146
|
+
}
|
|
147
|
+
/** Absolute pitch classes of the key's scale. */
|
|
148
|
+
function scalePcs(key) {
|
|
149
|
+
const t = tonicPc(key);
|
|
150
|
+
return MODE_INTERVALS[key.mode].map((i) => mod12(t + i));
|
|
151
|
+
}
|
|
152
|
+
function pentatonicPcs(key) {
|
|
153
|
+
const t = tonicPc(key);
|
|
154
|
+
return (isMinorMode(key.mode) ? [
|
|
155
|
+
0,
|
|
156
|
+
3,
|
|
157
|
+
5,
|
|
158
|
+
7,
|
|
159
|
+
10
|
|
160
|
+
] : [
|
|
161
|
+
0,
|
|
162
|
+
2,
|
|
163
|
+
4,
|
|
164
|
+
7,
|
|
165
|
+
9
|
|
166
|
+
]).map((i) => mod12(t + i));
|
|
167
|
+
}
|
|
168
|
+
/** Whether to spell this key with flats. Based on the relative major's position on the circle of fifths. */
|
|
169
|
+
function prefersFlats(key) {
|
|
170
|
+
if (key.tonic.includes("b")) return true;
|
|
171
|
+
if (key.tonic.includes("#")) return false;
|
|
172
|
+
const t = tonicPc(key);
|
|
173
|
+
const major = mod12(t + {
|
|
174
|
+
major: 0,
|
|
175
|
+
minor: 3,
|
|
176
|
+
dorian: -2,
|
|
177
|
+
mixolydian: 5,
|
|
178
|
+
lydian: -5,
|
|
179
|
+
phrygian: -4
|
|
180
|
+
}[key.mode]);
|
|
181
|
+
return [
|
|
182
|
+
5,
|
|
183
|
+
10,
|
|
184
|
+
3,
|
|
185
|
+
8,
|
|
186
|
+
1,
|
|
187
|
+
6
|
|
188
|
+
].includes(major);
|
|
189
|
+
}
|
|
190
|
+
function keyLabel(key) {
|
|
191
|
+
const pc = tonicPc(key);
|
|
192
|
+
return `${key.tonic.length > 1 ? key.tonic[0].toUpperCase() + key.tonic.slice(1) : FLAT_NAMES[pc]} ${key.mode}`;
|
|
193
|
+
}
|
|
194
|
+
// ../../packages/core/src/theory/chords.ts
|
|
195
|
+
const QUALITIES = {
|
|
196
|
+
maj: {
|
|
197
|
+
tones: [
|
|
198
|
+
0,
|
|
199
|
+
4,
|
|
200
|
+
7
|
|
201
|
+
],
|
|
202
|
+
voicing: [
|
|
203
|
+
0,
|
|
204
|
+
4,
|
|
205
|
+
7
|
|
206
|
+
],
|
|
207
|
+
minor: false
|
|
208
|
+
},
|
|
209
|
+
m: {
|
|
210
|
+
tones: [
|
|
211
|
+
0,
|
|
212
|
+
3,
|
|
213
|
+
7
|
|
214
|
+
],
|
|
215
|
+
voicing: [
|
|
216
|
+
0,
|
|
217
|
+
3,
|
|
218
|
+
7
|
|
219
|
+
],
|
|
220
|
+
minor: true
|
|
221
|
+
},
|
|
222
|
+
dim: {
|
|
223
|
+
tones: [
|
|
224
|
+
0,
|
|
225
|
+
3,
|
|
226
|
+
6
|
|
227
|
+
],
|
|
228
|
+
voicing: [
|
|
229
|
+
0,
|
|
230
|
+
3,
|
|
231
|
+
6
|
|
232
|
+
],
|
|
233
|
+
minor: true
|
|
234
|
+
},
|
|
235
|
+
aug: {
|
|
236
|
+
tones: [
|
|
237
|
+
0,
|
|
238
|
+
4,
|
|
239
|
+
8
|
|
240
|
+
],
|
|
241
|
+
voicing: [
|
|
242
|
+
0,
|
|
243
|
+
4,
|
|
244
|
+
8
|
|
245
|
+
],
|
|
246
|
+
minor: false
|
|
247
|
+
},
|
|
248
|
+
sus2: {
|
|
249
|
+
tones: [
|
|
250
|
+
0,
|
|
251
|
+
2,
|
|
252
|
+
7
|
|
253
|
+
],
|
|
254
|
+
voicing: [
|
|
255
|
+
0,
|
|
256
|
+
2,
|
|
257
|
+
7
|
|
258
|
+
],
|
|
259
|
+
minor: false
|
|
260
|
+
},
|
|
261
|
+
sus4: {
|
|
262
|
+
tones: [
|
|
263
|
+
0,
|
|
264
|
+
5,
|
|
265
|
+
7
|
|
266
|
+
],
|
|
267
|
+
voicing: [
|
|
268
|
+
0,
|
|
269
|
+
5,
|
|
270
|
+
7
|
|
271
|
+
],
|
|
272
|
+
minor: false
|
|
273
|
+
},
|
|
274
|
+
add9: {
|
|
275
|
+
tones: [
|
|
276
|
+
0,
|
|
277
|
+
4,
|
|
278
|
+
7,
|
|
279
|
+
14
|
|
280
|
+
],
|
|
281
|
+
voicing: [
|
|
282
|
+
4,
|
|
283
|
+
14,
|
|
284
|
+
7,
|
|
285
|
+
0
|
|
286
|
+
],
|
|
287
|
+
minor: false
|
|
288
|
+
},
|
|
289
|
+
"6": {
|
|
290
|
+
tones: [
|
|
291
|
+
0,
|
|
292
|
+
4,
|
|
293
|
+
7,
|
|
294
|
+
9
|
|
295
|
+
],
|
|
296
|
+
voicing: [
|
|
297
|
+
4,
|
|
298
|
+
9,
|
|
299
|
+
7,
|
|
300
|
+
0
|
|
301
|
+
],
|
|
302
|
+
minor: false
|
|
303
|
+
},
|
|
304
|
+
m6: {
|
|
305
|
+
tones: [
|
|
306
|
+
0,
|
|
307
|
+
3,
|
|
308
|
+
7,
|
|
309
|
+
9
|
|
310
|
+
],
|
|
311
|
+
voicing: [
|
|
312
|
+
3,
|
|
313
|
+
9,
|
|
314
|
+
7,
|
|
315
|
+
0
|
|
316
|
+
],
|
|
317
|
+
minor: true
|
|
318
|
+
},
|
|
319
|
+
"69": {
|
|
320
|
+
tones: [
|
|
321
|
+
0,
|
|
322
|
+
4,
|
|
323
|
+
7,
|
|
324
|
+
9,
|
|
325
|
+
14
|
|
326
|
+
],
|
|
327
|
+
voicing: [
|
|
328
|
+
4,
|
|
329
|
+
9,
|
|
330
|
+
14,
|
|
331
|
+
7
|
|
332
|
+
],
|
|
333
|
+
minor: false
|
|
334
|
+
},
|
|
335
|
+
maj7: {
|
|
336
|
+
tones: [
|
|
337
|
+
0,
|
|
338
|
+
4,
|
|
339
|
+
7,
|
|
340
|
+
11
|
|
341
|
+
],
|
|
342
|
+
voicing: [
|
|
343
|
+
4,
|
|
344
|
+
11,
|
|
345
|
+
7,
|
|
346
|
+
0
|
|
347
|
+
],
|
|
348
|
+
minor: false
|
|
349
|
+
},
|
|
350
|
+
maj9: {
|
|
351
|
+
tones: [
|
|
352
|
+
0,
|
|
353
|
+
4,
|
|
354
|
+
7,
|
|
355
|
+
11,
|
|
356
|
+
14
|
|
357
|
+
],
|
|
358
|
+
voicing: [
|
|
359
|
+
4,
|
|
360
|
+
11,
|
|
361
|
+
14,
|
|
362
|
+
7
|
|
363
|
+
],
|
|
364
|
+
minor: false
|
|
365
|
+
},
|
|
366
|
+
m7: {
|
|
367
|
+
tones: [
|
|
368
|
+
0,
|
|
369
|
+
3,
|
|
370
|
+
7,
|
|
371
|
+
10
|
|
372
|
+
],
|
|
373
|
+
voicing: [
|
|
374
|
+
3,
|
|
375
|
+
10,
|
|
376
|
+
7,
|
|
377
|
+
0
|
|
378
|
+
],
|
|
379
|
+
minor: true
|
|
380
|
+
},
|
|
381
|
+
m9: {
|
|
382
|
+
tones: [
|
|
383
|
+
0,
|
|
384
|
+
3,
|
|
385
|
+
7,
|
|
386
|
+
10,
|
|
387
|
+
14
|
|
388
|
+
],
|
|
389
|
+
voicing: [
|
|
390
|
+
3,
|
|
391
|
+
10,
|
|
392
|
+
14,
|
|
393
|
+
7
|
|
394
|
+
],
|
|
395
|
+
minor: true
|
|
396
|
+
},
|
|
397
|
+
m11: {
|
|
398
|
+
tones: [
|
|
399
|
+
0,
|
|
400
|
+
3,
|
|
401
|
+
7,
|
|
402
|
+
10,
|
|
403
|
+
14,
|
|
404
|
+
17
|
|
405
|
+
],
|
|
406
|
+
voicing: [
|
|
407
|
+
3,
|
|
408
|
+
10,
|
|
409
|
+
14,
|
|
410
|
+
17
|
|
411
|
+
],
|
|
412
|
+
minor: true
|
|
413
|
+
},
|
|
414
|
+
"7": {
|
|
415
|
+
tones: [
|
|
416
|
+
0,
|
|
417
|
+
4,
|
|
418
|
+
7,
|
|
419
|
+
10
|
|
420
|
+
],
|
|
421
|
+
voicing: [
|
|
422
|
+
4,
|
|
423
|
+
10,
|
|
424
|
+
7,
|
|
425
|
+
0
|
|
426
|
+
],
|
|
427
|
+
minor: false
|
|
428
|
+
},
|
|
429
|
+
"9": {
|
|
430
|
+
tones: [
|
|
431
|
+
0,
|
|
432
|
+
4,
|
|
433
|
+
7,
|
|
434
|
+
10,
|
|
435
|
+
14
|
|
436
|
+
],
|
|
437
|
+
voicing: [
|
|
438
|
+
4,
|
|
439
|
+
10,
|
|
440
|
+
14,
|
|
441
|
+
7
|
|
442
|
+
],
|
|
443
|
+
minor: false
|
|
444
|
+
},
|
|
445
|
+
"13": {
|
|
446
|
+
tones: [
|
|
447
|
+
0,
|
|
448
|
+
4,
|
|
449
|
+
7,
|
|
450
|
+
10,
|
|
451
|
+
14,
|
|
452
|
+
21
|
|
453
|
+
],
|
|
454
|
+
voicing: [
|
|
455
|
+
4,
|
|
456
|
+
10,
|
|
457
|
+
14,
|
|
458
|
+
21
|
|
459
|
+
],
|
|
460
|
+
minor: false
|
|
461
|
+
},
|
|
462
|
+
"7b9": {
|
|
463
|
+
tones: [
|
|
464
|
+
0,
|
|
465
|
+
4,
|
|
466
|
+
7,
|
|
467
|
+
10,
|
|
468
|
+
13
|
|
469
|
+
],
|
|
470
|
+
voicing: [
|
|
471
|
+
4,
|
|
472
|
+
10,
|
|
473
|
+
13,
|
|
474
|
+
7
|
|
475
|
+
],
|
|
476
|
+
minor: false
|
|
477
|
+
},
|
|
478
|
+
"7sus4": {
|
|
479
|
+
tones: [
|
|
480
|
+
0,
|
|
481
|
+
5,
|
|
482
|
+
7,
|
|
483
|
+
10
|
|
484
|
+
],
|
|
485
|
+
voicing: [
|
|
486
|
+
5,
|
|
487
|
+
10,
|
|
488
|
+
7,
|
|
489
|
+
0
|
|
490
|
+
],
|
|
491
|
+
minor: false
|
|
492
|
+
},
|
|
493
|
+
"9sus4": {
|
|
494
|
+
tones: [
|
|
495
|
+
0,
|
|
496
|
+
5,
|
|
497
|
+
7,
|
|
498
|
+
10,
|
|
499
|
+
14
|
|
500
|
+
],
|
|
501
|
+
voicing: [
|
|
502
|
+
5,
|
|
503
|
+
10,
|
|
504
|
+
14,
|
|
505
|
+
7
|
|
506
|
+
],
|
|
507
|
+
minor: false
|
|
508
|
+
},
|
|
509
|
+
m7b5: {
|
|
510
|
+
tones: [
|
|
511
|
+
0,
|
|
512
|
+
3,
|
|
513
|
+
6,
|
|
514
|
+
10
|
|
515
|
+
],
|
|
516
|
+
voicing: [
|
|
517
|
+
3,
|
|
518
|
+
10,
|
|
519
|
+
6,
|
|
520
|
+
0
|
|
521
|
+
],
|
|
522
|
+
minor: true
|
|
523
|
+
},
|
|
524
|
+
dim7: {
|
|
525
|
+
tones: [
|
|
526
|
+
0,
|
|
527
|
+
3,
|
|
528
|
+
6,
|
|
529
|
+
9
|
|
530
|
+
],
|
|
531
|
+
voicing: [
|
|
532
|
+
3,
|
|
533
|
+
9,
|
|
534
|
+
6,
|
|
535
|
+
0
|
|
536
|
+
],
|
|
537
|
+
minor: true
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
const CHORD_QUALITIES = Object.keys(QUALITIES);
|
|
541
|
+
/** Alternate spellings people (and agents) type, mapped to our canonical names. */
|
|
542
|
+
const ALIASES = {
|
|
543
|
+
"": "maj",
|
|
544
|
+
M: "maj",
|
|
545
|
+
major: "maj",
|
|
546
|
+
min: "m",
|
|
547
|
+
minor: "m",
|
|
548
|
+
"-": "m",
|
|
549
|
+
mi: "m",
|
|
550
|
+
M7: "maj7",
|
|
551
|
+
Δ: "maj7",
|
|
552
|
+
Δ7: "maj7",
|
|
553
|
+
ma7: "maj7",
|
|
554
|
+
M9: "maj9",
|
|
555
|
+
Δ9: "maj9",
|
|
556
|
+
ma9: "maj9",
|
|
557
|
+
min7: "m7",
|
|
558
|
+
"-7": "m7",
|
|
559
|
+
mi7: "m7",
|
|
560
|
+
min9: "m9",
|
|
561
|
+
"-9": "m9",
|
|
562
|
+
min11: "m11",
|
|
563
|
+
"-11": "m11",
|
|
564
|
+
dom7: "7",
|
|
565
|
+
ø: "m7b5",
|
|
566
|
+
ø7: "m7b5",
|
|
567
|
+
"m7-5": "m7b5",
|
|
568
|
+
"°": "dim",
|
|
569
|
+
"°7": "dim7",
|
|
570
|
+
o: "dim",
|
|
571
|
+
o7: "dim7",
|
|
572
|
+
sus: "sus4",
|
|
573
|
+
"7sus": "7sus4",
|
|
574
|
+
"9sus": "9sus4",
|
|
575
|
+
"6/9": "69",
|
|
576
|
+
add2: "add9",
|
|
577
|
+
"+": "aug"
|
|
578
|
+
};
|
|
579
|
+
function normalizeQuality(q) {
|
|
580
|
+
if (q in QUALITIES) return q;
|
|
581
|
+
return ALIASES[q] ?? null;
|
|
582
|
+
}
|
|
583
|
+
function chordPcs(root, quality) {
|
|
584
|
+
return [...new Set(QUALITIES[quality].tones.map((t) => mod12(root + t)))];
|
|
585
|
+
}
|
|
586
|
+
/** Absolute root pitch class of a chord in a key. */
|
|
587
|
+
function chordRootPc(chord, key) {
|
|
588
|
+
return mod12(tonicPc(key) + chord.root);
|
|
589
|
+
}
|
|
590
|
+
function chordSymbol(chord, key, flats) {
|
|
591
|
+
const root = pcName(chordRootPc(chord, key), flats);
|
|
592
|
+
return chord.quality === "maj" ? root : `${root}${chord.quality}`;
|
|
593
|
+
}
|
|
594
|
+
/** Parses an absolute chord symbol like "Em9", "Bbmaj7", "F#m7b5" into a root pitch class and quality. */
|
|
595
|
+
function parseChordSymbol(symbol) {
|
|
596
|
+
const m = /^([A-Ga-g][#b]?)(.*)$/.exec(symbol.trim());
|
|
597
|
+
if (!m) return null;
|
|
598
|
+
const rootPc = parsePitchClass(m[1]);
|
|
599
|
+
const quality = normalizeQuality(m[2]);
|
|
600
|
+
if (rootPc === null || quality === null) return null;
|
|
601
|
+
return {
|
|
602
|
+
rootPc,
|
|
603
|
+
quality
|
|
604
|
+
};
|
|
605
|
+
}
|
|
606
|
+
// ../../packages/core/src/theory/progressions.ts
|
|
607
|
+
const NUMERALS = [
|
|
608
|
+
"i",
|
|
609
|
+
"ii",
|
|
610
|
+
"iii",
|
|
611
|
+
"iv",
|
|
612
|
+
"v",
|
|
613
|
+
"vi",
|
|
614
|
+
"vii"
|
|
615
|
+
];
|
|
616
|
+
const NUMERAL_SEMITONES = [
|
|
617
|
+
0,
|
|
618
|
+
2,
|
|
619
|
+
4,
|
|
620
|
+
5,
|
|
621
|
+
7,
|
|
622
|
+
9,
|
|
623
|
+
11
|
|
624
|
+
];
|
|
625
|
+
/** Lowercase numerals turn bare "7"/"9"/"11"/"6" into their minor versions, the way people write them. */
|
|
626
|
+
function qualityFor(suffix, lower) {
|
|
627
|
+
if (lower) {
|
|
628
|
+
const minorMap = {
|
|
629
|
+
"": "m",
|
|
630
|
+
"6": "m6",
|
|
631
|
+
"7": "m7",
|
|
632
|
+
"9": "m9",
|
|
633
|
+
"11": "m11"
|
|
634
|
+
};
|
|
635
|
+
if (suffix in minorMap) return normalizeQuality(minorMap[suffix]);
|
|
636
|
+
}
|
|
637
|
+
return normalizeQuality(suffix);
|
|
638
|
+
}
|
|
639
|
+
/** Parses one roman numeral chord like "ii9", "V13", "bVIImaj7", "viiø7". */
|
|
640
|
+
function parseRoman(token) {
|
|
641
|
+
const m = /^([b#]?)(vii|iii|iv|vi|ii|v|i|VII|III|IV|VI|II|V|I)(.*)$/.exec(token.trim());
|
|
642
|
+
if (!m) return null;
|
|
643
|
+
const idx = NUMERALS.indexOf(m[2].toLowerCase());
|
|
644
|
+
const lower = m[2] === m[2].toLowerCase();
|
|
645
|
+
const quality = qualityFor(m[3], lower);
|
|
646
|
+
if (quality === null) return null;
|
|
647
|
+
const shift = m[1] === "b" ? -1 : m[1] === "#" ? 1 : 0;
|
|
648
|
+
return {
|
|
649
|
+
root: mod12(NUMERAL_SEMITONES[idx] + shift),
|
|
650
|
+
quality
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Parses a progression string. Tokens are roman numerals or chord symbols, separated by spaces or "|".
|
|
655
|
+
* A ":N" suffix sets the length in beats (default 4), so "ii9:2 V13:2 Imaj9" packs two chords into one bar.
|
|
656
|
+
*/
|
|
657
|
+
function parseProgression(text, key) {
|
|
658
|
+
const tokens = text.split(/[\s|,]+/).filter(Boolean);
|
|
659
|
+
if (tokens.length === 0) throw new Error("Progression is empty");
|
|
660
|
+
return tokens.map((token) => {
|
|
661
|
+
const [body, beatsText] = token.split(":");
|
|
662
|
+
const beats = beatsText === void 0 ? 4 : Number(beatsText);
|
|
663
|
+
if (!Number.isFinite(beats) || beats <= 0 || beats > 32) throw new Error(`Bad chord length in "${token}"`);
|
|
664
|
+
const roman = parseRoman(body);
|
|
665
|
+
if (roman) return {
|
|
666
|
+
...roman,
|
|
667
|
+
beats
|
|
668
|
+
};
|
|
669
|
+
const symbol = parseChordSymbol(body);
|
|
670
|
+
if (symbol) return {
|
|
671
|
+
root: mod12(symbol.rootPc - tonicPc(key)),
|
|
672
|
+
quality: symbol.quality,
|
|
673
|
+
beats
|
|
674
|
+
};
|
|
675
|
+
throw new Error(`Can't read chord "${body}". Try a roman numeral like ii9 or a symbol like Em9.`);
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
function progressionBeats(progression) {
|
|
679
|
+
return progression.reduce((sum, c) => sum + c.beats, 0);
|
|
680
|
+
}
|
|
681
|
+
/** Finds the chord sounding at a beat. Progressions shorter than the scene loop around. */
|
|
682
|
+
function chordAt(progression, beat) {
|
|
683
|
+
const total = progressionBeats(progression);
|
|
684
|
+
const cycle = Math.floor(beat / total) * total;
|
|
685
|
+
let pos = beat - cycle;
|
|
686
|
+
let start = cycle;
|
|
687
|
+
for (const chord of progression) {
|
|
688
|
+
if (pos < chord.beats) return {
|
|
689
|
+
chord,
|
|
690
|
+
start
|
|
691
|
+
};
|
|
692
|
+
pos -= chord.beats;
|
|
693
|
+
start += chord.beats;
|
|
694
|
+
}
|
|
695
|
+
return {
|
|
696
|
+
chord: progression[progression.length - 1],
|
|
697
|
+
start: cycle + total - progression[progression.length - 1].beats
|
|
698
|
+
};
|
|
699
|
+
}
|
|
700
|
+
function progressionText(progression, key) {
|
|
701
|
+
const flats = prefersFlats(key);
|
|
702
|
+
return progression.map((c) => chordSymbol(c, key, flats) + (c.beats === 4 ? "" : `:${c.beats}`)).join(" ");
|
|
703
|
+
}
|
|
704
|
+
const PROGRESSIONS = {
|
|
705
|
+
"jazzy-251": {
|
|
706
|
+
label: "Jazzy 2-5-1",
|
|
707
|
+
text: "ii9 V13 Imaj9 vi9"
|
|
708
|
+
},
|
|
709
|
+
"soft-turnaround": {
|
|
710
|
+
label: "Soft turnaround",
|
|
711
|
+
text: "Imaj9 vi9 ii9 V9sus4"
|
|
712
|
+
},
|
|
713
|
+
"rainy-minor": {
|
|
714
|
+
label: "Rainy minor",
|
|
715
|
+
text: "i9 iv9 bVIImaj7 bIIImaj7"
|
|
716
|
+
},
|
|
717
|
+
"neo-soul": {
|
|
718
|
+
label: "Neo soul",
|
|
719
|
+
text: "IVmaj9 iii7 vi9 Imaj9"
|
|
720
|
+
},
|
|
721
|
+
"late-night": {
|
|
722
|
+
label: "Late night",
|
|
723
|
+
text: "i9 bVImaj7 iv9 V7b9"
|
|
724
|
+
},
|
|
725
|
+
"dreamy-two": {
|
|
726
|
+
label: "Dreamy two-chord",
|
|
727
|
+
text: "Imaj9 IVmaj9"
|
|
728
|
+
},
|
|
729
|
+
"dorian-float": {
|
|
730
|
+
label: "Dorian float",
|
|
731
|
+
text: "i9 IV9 i9 IV9"
|
|
732
|
+
},
|
|
733
|
+
"sunday-walk": {
|
|
734
|
+
label: "Sunday walk",
|
|
735
|
+
text: "Imaj7 iii7 IVmaj7 iv6"
|
|
736
|
+
},
|
|
737
|
+
"city-lights": {
|
|
738
|
+
label: "City lights",
|
|
739
|
+
text: "vi9 ii9 V13 Imaj9"
|
|
740
|
+
},
|
|
741
|
+
"snowfall": {
|
|
742
|
+
label: "Snowfall",
|
|
743
|
+
text: "Imaj9 bVIImaj9 IVmaj9 iv6"
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
Object.keys(PROGRESSIONS);
|
|
747
|
+
/** Accepts a preset id or a progression string. */
|
|
748
|
+
function resolveProgression(textOrPreset, key) {
|
|
749
|
+
const preset = PROGRESSIONS[textOrPreset];
|
|
750
|
+
return parseProgression(preset ? preset.text : textOrPreset, key);
|
|
751
|
+
}
|
|
752
|
+
// ../../packages/core/src/model/constants.ts
|
|
753
|
+
const INSTRUMENTS = {
|
|
754
|
+
piano: {
|
|
755
|
+
label: "Upright piano",
|
|
756
|
+
range: [55, 84],
|
|
757
|
+
kind: "tonal",
|
|
758
|
+
gm: 0
|
|
759
|
+
},
|
|
760
|
+
wurli: {
|
|
761
|
+
label: "Wurlitzer",
|
|
762
|
+
range: [55, 81],
|
|
763
|
+
kind: "tonal",
|
|
764
|
+
gm: 4
|
|
765
|
+
},
|
|
766
|
+
"fm-ep": {
|
|
767
|
+
label: "FM keys",
|
|
768
|
+
range: [55, 81],
|
|
769
|
+
kind: "tonal",
|
|
770
|
+
gm: 5
|
|
771
|
+
},
|
|
772
|
+
glock: {
|
|
773
|
+
label: "Glockenspiel",
|
|
774
|
+
range: [72, 96],
|
|
775
|
+
kind: "tonal",
|
|
776
|
+
gm: 9
|
|
777
|
+
},
|
|
778
|
+
marimba: {
|
|
779
|
+
label: "Marimba",
|
|
780
|
+
range: [55, 84],
|
|
781
|
+
kind: "tonal",
|
|
782
|
+
gm: 12
|
|
783
|
+
},
|
|
784
|
+
harp: {
|
|
785
|
+
label: "Harp",
|
|
786
|
+
range: [48, 84],
|
|
787
|
+
kind: "tonal",
|
|
788
|
+
gm: 46
|
|
789
|
+
},
|
|
790
|
+
pad: {
|
|
791
|
+
label: "Warm pad",
|
|
792
|
+
range: [55, 79],
|
|
793
|
+
kind: "tonal",
|
|
794
|
+
gm: 89
|
|
795
|
+
},
|
|
796
|
+
pluck: {
|
|
797
|
+
label: "Soft pluck",
|
|
798
|
+
range: [60, 84],
|
|
799
|
+
kind: "tonal",
|
|
800
|
+
gm: 24
|
|
801
|
+
},
|
|
802
|
+
bass: {
|
|
803
|
+
label: "Round bass",
|
|
804
|
+
range: [31, 55],
|
|
805
|
+
kind: "tonal",
|
|
806
|
+
gm: 33
|
|
807
|
+
},
|
|
808
|
+
drums: {
|
|
809
|
+
label: "Drums",
|
|
810
|
+
range: [0, 0],
|
|
811
|
+
kind: "drums",
|
|
812
|
+
gm: 0
|
|
813
|
+
}
|
|
814
|
+
};
|
|
815
|
+
const INSTRUMENT_IDS = Object.keys(INSTRUMENTS);
|
|
816
|
+
/** Brushes decide the rhythmic grid a stroke is read on and how its notes are shaped. */
|
|
817
|
+
const BRUSHES = {
|
|
818
|
+
pencil: {
|
|
819
|
+
label: "Pencil",
|
|
820
|
+
stepsPerBeat: 2,
|
|
821
|
+
velocity: .7,
|
|
822
|
+
note: "Melodies in eighth notes, held notes merge"
|
|
823
|
+
},
|
|
824
|
+
brush: {
|
|
825
|
+
label: "Brush",
|
|
826
|
+
stepsPerBeat: 1,
|
|
827
|
+
velocity: .55,
|
|
828
|
+
note: "Long soft notes on the beat"
|
|
829
|
+
},
|
|
830
|
+
chalk: {
|
|
831
|
+
label: "Chalk",
|
|
832
|
+
stepsPerBeat: 2,
|
|
833
|
+
velocity: .6,
|
|
834
|
+
note: "Two-note harmonies under the line"
|
|
835
|
+
},
|
|
836
|
+
spray: {
|
|
837
|
+
label: "Spray",
|
|
838
|
+
stepsPerBeat: 4,
|
|
839
|
+
velocity: .45,
|
|
840
|
+
note: "Scattered sparkly sixteenths"
|
|
841
|
+
},
|
|
842
|
+
stamp: {
|
|
843
|
+
label: "Stamp",
|
|
844
|
+
stepsPerBeat: 4,
|
|
845
|
+
velocity: .75,
|
|
846
|
+
note: "One hit per step, great for drums"
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
const BRUSH_IDS = Object.keys(BRUSHES);
|
|
850
|
+
const HARMONY_MODES = [
|
|
851
|
+
"smart",
|
|
852
|
+
"scale",
|
|
853
|
+
"pentatonic",
|
|
854
|
+
"chord",
|
|
855
|
+
"chromatic"
|
|
856
|
+
];
|
|
857
|
+
const DRUM_PIECES = {
|
|
858
|
+
kick: {
|
|
859
|
+
label: "Kick",
|
|
860
|
+
gm: 36
|
|
861
|
+
},
|
|
862
|
+
snare: {
|
|
863
|
+
label: "Snare",
|
|
864
|
+
gm: 38
|
|
865
|
+
},
|
|
866
|
+
brush: {
|
|
867
|
+
label: "Brush snare",
|
|
868
|
+
gm: 40
|
|
869
|
+
},
|
|
870
|
+
rim: {
|
|
871
|
+
label: "Side stick",
|
|
872
|
+
gm: 37
|
|
873
|
+
},
|
|
874
|
+
hat: {
|
|
875
|
+
label: "Hi-hat",
|
|
876
|
+
gm: 42
|
|
877
|
+
},
|
|
878
|
+
brushhat: {
|
|
879
|
+
label: "Brush hat",
|
|
880
|
+
gm: 44
|
|
881
|
+
},
|
|
882
|
+
openhat: {
|
|
883
|
+
label: "Open hat",
|
|
884
|
+
gm: 46
|
|
885
|
+
},
|
|
886
|
+
ride: {
|
|
887
|
+
label: "Ride",
|
|
888
|
+
gm: 51
|
|
889
|
+
},
|
|
890
|
+
tom: {
|
|
891
|
+
label: "Tom",
|
|
892
|
+
gm: 45
|
|
893
|
+
}
|
|
894
|
+
};
|
|
895
|
+
const DRUM_PIECE_IDS = Object.keys(DRUM_PIECES);
|
|
896
|
+
const DRUM_KITS = ["sticks", "brushes"];
|
|
897
|
+
/** Rows of a drum layer from the bottom of the canvas to the top. */
|
|
898
|
+
function drumRows(kit) {
|
|
899
|
+
return kit === "brushes" ? [
|
|
900
|
+
"kick",
|
|
901
|
+
"tom",
|
|
902
|
+
"brush",
|
|
903
|
+
"rim",
|
|
904
|
+
"brushhat",
|
|
905
|
+
"openhat",
|
|
906
|
+
"ride"
|
|
907
|
+
] : [
|
|
908
|
+
"kick",
|
|
909
|
+
"tom",
|
|
910
|
+
"snare",
|
|
911
|
+
"rim",
|
|
912
|
+
"hat",
|
|
913
|
+
"openhat",
|
|
914
|
+
"ride"
|
|
915
|
+
];
|
|
916
|
+
}
|
|
917
|
+
const COMP_STYLES = [
|
|
918
|
+
"sustain",
|
|
919
|
+
"lazy",
|
|
920
|
+
"stabs",
|
|
921
|
+
"arp"
|
|
922
|
+
];
|
|
923
|
+
const BASS_STYLES = [
|
|
924
|
+
"roots",
|
|
925
|
+
"walking",
|
|
926
|
+
"dub"
|
|
927
|
+
];
|
|
928
|
+
const CHORD_INSTRUMENTS = [
|
|
929
|
+
"piano",
|
|
930
|
+
"wurli",
|
|
931
|
+
"fm-ep",
|
|
932
|
+
"pad",
|
|
933
|
+
"harp"
|
|
934
|
+
];
|
|
935
|
+
const GROOVE_IDS = [
|
|
936
|
+
"dusty",
|
|
937
|
+
"brushes",
|
|
938
|
+
"halftime",
|
|
939
|
+
"bossa",
|
|
940
|
+
"sparse"
|
|
941
|
+
];
|
|
942
|
+
const WINDOW_SCENES = [
|
|
943
|
+
"rain",
|
|
944
|
+
"dusk",
|
|
945
|
+
"night-city",
|
|
946
|
+
"sunny",
|
|
947
|
+
"snow"
|
|
948
|
+
];
|
|
949
|
+
const RAIN_KINDS = ["roof", "window"];
|
|
950
|
+
/** Layer colors, drawn from the Haku icon. */
|
|
951
|
+
const LAYER_COLORS = [
|
|
952
|
+
"#e8897a",
|
|
953
|
+
"#8fae8b",
|
|
954
|
+
"#f2c48d",
|
|
955
|
+
"#a39bd1",
|
|
956
|
+
"#7fb7c9",
|
|
957
|
+
"#d98fb0",
|
|
958
|
+
"#c9a27e"
|
|
959
|
+
];
|
|
960
|
+
// ../../packages/core/src/model/schema.ts
|
|
961
|
+
const unit = z.number().min(0).max(1);
|
|
962
|
+
const KeySchema = z.object({
|
|
963
|
+
tonic: z.string().regex(/^[A-Ga-g][#b]?$/, "Tonic should look like C, F#, or Bb"),
|
|
964
|
+
mode: z.enum(MODES)
|
|
965
|
+
});
|
|
966
|
+
const ChordRefSchema = z.object({
|
|
967
|
+
root: z.number().int().min(0).max(11),
|
|
968
|
+
quality: z.enum(CHORD_QUALITIES),
|
|
969
|
+
beats: z.number().positive().max(32)
|
|
970
|
+
});
|
|
971
|
+
const StrokePointSchema = z.object({
|
|
972
|
+
x: unit,
|
|
973
|
+
y: unit,
|
|
974
|
+
/** Milliseconds since the stroke started. Drawing speed sets velocity when there's no pressure. */
|
|
975
|
+
t: z.number().min(0),
|
|
976
|
+
/** Pen pressure, 0 to 1. Leave it out for mouse input. */
|
|
977
|
+
p: unit.optional()
|
|
978
|
+
});
|
|
979
|
+
const StrokeSchema = z.object({
|
|
980
|
+
id: z.string(),
|
|
981
|
+
brush: z.enum(BRUSH_IDS),
|
|
982
|
+
/** Brush thickness, 0 to 1. Thicker strokes play softer and darker. */
|
|
983
|
+
size: unit,
|
|
984
|
+
points: z.array(StrokePointSchema).min(1),
|
|
985
|
+
/** Exact strokes skip harmony snapping and land on the nearest semitone. Used by draw_melody. */
|
|
986
|
+
exact: z.boolean().optional()
|
|
987
|
+
});
|
|
988
|
+
const LayerSchema = z.object({
|
|
989
|
+
id: z.string(),
|
|
990
|
+
name: z.string().min(1).max(40),
|
|
991
|
+
instrument: z.enum(INSTRUMENT_IDS),
|
|
992
|
+
color: z.string(),
|
|
993
|
+
volume: unit,
|
|
994
|
+
pan: z.number().min(-1).max(1),
|
|
995
|
+
muted: z.boolean(),
|
|
996
|
+
solo: z.boolean(),
|
|
997
|
+
harmony: z.enum(HARMONY_MODES),
|
|
998
|
+
octave: z.number().int().min(-2).max(2),
|
|
999
|
+
kit: z.enum(DRUM_KITS),
|
|
1000
|
+
strokes: z.array(StrokeSchema)
|
|
1001
|
+
});
|
|
1002
|
+
const BackingSchema = z.object({
|
|
1003
|
+
chords: z.object({
|
|
1004
|
+
enabled: z.boolean(),
|
|
1005
|
+
instrument: z.enum(CHORD_INSTRUMENTS),
|
|
1006
|
+
style: z.enum(COMP_STYLES),
|
|
1007
|
+
volume: unit
|
|
1008
|
+
}),
|
|
1009
|
+
bass: z.object({
|
|
1010
|
+
enabled: z.boolean(),
|
|
1011
|
+
style: z.enum(BASS_STYLES),
|
|
1012
|
+
volume: unit
|
|
1013
|
+
}),
|
|
1014
|
+
drums: z.object({
|
|
1015
|
+
enabled: z.boolean(),
|
|
1016
|
+
groove: z.enum(GROOVE_IDS),
|
|
1017
|
+
volume: unit
|
|
1018
|
+
})
|
|
1019
|
+
});
|
|
1020
|
+
const SceneSchema = z.object({
|
|
1021
|
+
id: z.string(),
|
|
1022
|
+
name: z.string().min(1).max(40),
|
|
1023
|
+
bars: z.number().int().min(1).max(16),
|
|
1024
|
+
progression: z.array(ChordRefSchema).min(1),
|
|
1025
|
+
layers: z.array(LayerSchema),
|
|
1026
|
+
backing: BackingSchema
|
|
1027
|
+
});
|
|
1028
|
+
const MixSchema = z.object({
|
|
1029
|
+
master: unit,
|
|
1030
|
+
/** Low-pass and tape saturation. */
|
|
1031
|
+
warmth: unit,
|
|
1032
|
+
crackle: unit,
|
|
1033
|
+
reverb: unit,
|
|
1034
|
+
/** Tape wow and flutter. */
|
|
1035
|
+
wobble: unit,
|
|
1036
|
+
rain: z.object({
|
|
1037
|
+
enabled: z.boolean(),
|
|
1038
|
+
kind: z.enum(RAIN_KINDS),
|
|
1039
|
+
level: unit
|
|
1040
|
+
})
|
|
1041
|
+
});
|
|
1042
|
+
const ProjectSchema = z.object({
|
|
1043
|
+
format: z.literal("haku"),
|
|
1044
|
+
version: z.literal(1),
|
|
1045
|
+
id: z.string(),
|
|
1046
|
+
name: z.string().min(1).max(80),
|
|
1047
|
+
createdAt: z.string(),
|
|
1048
|
+
updatedAt: z.string(),
|
|
1049
|
+
/** Seeds humanize and spray so the same project always plays the same way. */
|
|
1050
|
+
seed: z.number().int(),
|
|
1051
|
+
mood: z.string().nullable(),
|
|
1052
|
+
window: z.enum(WINDOW_SCENES),
|
|
1053
|
+
key: KeySchema,
|
|
1054
|
+
bpm: z.number().min(50).max(140),
|
|
1055
|
+
swing: unit,
|
|
1056
|
+
humanize: unit,
|
|
1057
|
+
mix: MixSchema,
|
|
1058
|
+
scenes: z.array(SceneSchema).min(1),
|
|
1059
|
+
arrangement: z.array(z.object({
|
|
1060
|
+
sceneId: z.string(),
|
|
1061
|
+
repeat: z.number().int().min(1).max(16)
|
|
1062
|
+
})).min(1),
|
|
1063
|
+
activeSceneId: z.string()
|
|
1064
|
+
});
|
|
1065
|
+
// ../../packages/core/src/model/moods.ts
|
|
1066
|
+
const MOODS = {
|
|
1067
|
+
"rainy-study": {
|
|
1068
|
+
label: "Rainy study",
|
|
1069
|
+
bpm: 72,
|
|
1070
|
+
key: {
|
|
1071
|
+
tonic: "F",
|
|
1072
|
+
mode: "major"
|
|
1073
|
+
},
|
|
1074
|
+
progression: "jazzy-251",
|
|
1075
|
+
lead: "piano",
|
|
1076
|
+
swing: .35,
|
|
1077
|
+
window: "rain",
|
|
1078
|
+
backing: {
|
|
1079
|
+
chords: {
|
|
1080
|
+
enabled: true,
|
|
1081
|
+
instrument: "wurli",
|
|
1082
|
+
style: "lazy",
|
|
1083
|
+
volume: .6
|
|
1084
|
+
},
|
|
1085
|
+
bass: {
|
|
1086
|
+
enabled: true,
|
|
1087
|
+
style: "roots",
|
|
1088
|
+
volume: .7
|
|
1089
|
+
},
|
|
1090
|
+
drums: {
|
|
1091
|
+
enabled: true,
|
|
1092
|
+
groove: "brushes",
|
|
1093
|
+
volume: .65
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
mix: {
|
|
1097
|
+
master: .8,
|
|
1098
|
+
warmth: .55,
|
|
1099
|
+
crackle: .35,
|
|
1100
|
+
reverb: .4,
|
|
1101
|
+
wobble: .25,
|
|
1102
|
+
rain: {
|
|
1103
|
+
enabled: true,
|
|
1104
|
+
kind: "roof",
|
|
1105
|
+
level: .45
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
},
|
|
1109
|
+
"late-night-train": {
|
|
1110
|
+
label: "Late-night train",
|
|
1111
|
+
bpm: 68,
|
|
1112
|
+
key: {
|
|
1113
|
+
tonic: "A",
|
|
1114
|
+
mode: "minor"
|
|
1115
|
+
},
|
|
1116
|
+
progression: "late-night",
|
|
1117
|
+
lead: "wurli",
|
|
1118
|
+
swing: .4,
|
|
1119
|
+
window: "night-city",
|
|
1120
|
+
backing: {
|
|
1121
|
+
chords: {
|
|
1122
|
+
enabled: true,
|
|
1123
|
+
instrument: "piano",
|
|
1124
|
+
style: "sustain",
|
|
1125
|
+
volume: .55
|
|
1126
|
+
},
|
|
1127
|
+
bass: {
|
|
1128
|
+
enabled: true,
|
|
1129
|
+
style: "dub",
|
|
1130
|
+
volume: .75
|
|
1131
|
+
},
|
|
1132
|
+
drums: {
|
|
1133
|
+
enabled: true,
|
|
1134
|
+
groove: "dusty",
|
|
1135
|
+
volume: .7
|
|
1136
|
+
}
|
|
1137
|
+
},
|
|
1138
|
+
mix: {
|
|
1139
|
+
master: .8,
|
|
1140
|
+
warmth: .65,
|
|
1141
|
+
crackle: .45,
|
|
1142
|
+
reverb: .45,
|
|
1143
|
+
wobble: .4,
|
|
1144
|
+
rain: {
|
|
1145
|
+
enabled: true,
|
|
1146
|
+
kind: "window",
|
|
1147
|
+
level: .2
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
"sunny-cafe": {
|
|
1152
|
+
label: "Sunny café",
|
|
1153
|
+
bpm: 86,
|
|
1154
|
+
key: {
|
|
1155
|
+
tonic: "G",
|
|
1156
|
+
mode: "major"
|
|
1157
|
+
},
|
|
1158
|
+
progression: "sunday-walk",
|
|
1159
|
+
lead: "marimba",
|
|
1160
|
+
swing: .2,
|
|
1161
|
+
window: "sunny",
|
|
1162
|
+
backing: {
|
|
1163
|
+
chords: {
|
|
1164
|
+
enabled: true,
|
|
1165
|
+
instrument: "piano",
|
|
1166
|
+
style: "stabs",
|
|
1167
|
+
volume: .55
|
|
1168
|
+
},
|
|
1169
|
+
bass: {
|
|
1170
|
+
enabled: true,
|
|
1171
|
+
style: "walking",
|
|
1172
|
+
volume: .65
|
|
1173
|
+
},
|
|
1174
|
+
drums: {
|
|
1175
|
+
enabled: true,
|
|
1176
|
+
groove: "bossa",
|
|
1177
|
+
volume: .6
|
|
1178
|
+
}
|
|
1179
|
+
},
|
|
1180
|
+
mix: {
|
|
1181
|
+
master: .8,
|
|
1182
|
+
warmth: .35,
|
|
1183
|
+
crackle: .2,
|
|
1184
|
+
reverb: .3,
|
|
1185
|
+
wobble: .15,
|
|
1186
|
+
rain: {
|
|
1187
|
+
enabled: false,
|
|
1188
|
+
kind: "roof",
|
|
1189
|
+
level: .3
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
},
|
|
1193
|
+
"dusk-walk": {
|
|
1194
|
+
label: "Dusk walk",
|
|
1195
|
+
bpm: 78,
|
|
1196
|
+
key: {
|
|
1197
|
+
tonic: "D",
|
|
1198
|
+
mode: "dorian"
|
|
1199
|
+
},
|
|
1200
|
+
progression: "dorian-float",
|
|
1201
|
+
lead: "harp",
|
|
1202
|
+
swing: .3,
|
|
1203
|
+
window: "dusk",
|
|
1204
|
+
backing: {
|
|
1205
|
+
chords: {
|
|
1206
|
+
enabled: true,
|
|
1207
|
+
instrument: "wurli",
|
|
1208
|
+
style: "lazy",
|
|
1209
|
+
volume: .55
|
|
1210
|
+
},
|
|
1211
|
+
bass: {
|
|
1212
|
+
enabled: true,
|
|
1213
|
+
style: "roots",
|
|
1214
|
+
volume: .7
|
|
1215
|
+
},
|
|
1216
|
+
drums: {
|
|
1217
|
+
enabled: true,
|
|
1218
|
+
groove: "halftime",
|
|
1219
|
+
volume: .65
|
|
1220
|
+
}
|
|
1221
|
+
},
|
|
1222
|
+
mix: {
|
|
1223
|
+
master: .8,
|
|
1224
|
+
warmth: .5,
|
|
1225
|
+
crackle: .3,
|
|
1226
|
+
reverb: .45,
|
|
1227
|
+
wobble: .3,
|
|
1228
|
+
rain: {
|
|
1229
|
+
enabled: false,
|
|
1230
|
+
kind: "roof",
|
|
1231
|
+
level: .3
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
},
|
|
1235
|
+
"snowy-window": {
|
|
1236
|
+
label: "Snowy window",
|
|
1237
|
+
bpm: 64,
|
|
1238
|
+
key: {
|
|
1239
|
+
tonic: "Eb",
|
|
1240
|
+
mode: "major"
|
|
1241
|
+
},
|
|
1242
|
+
progression: "snowfall",
|
|
1243
|
+
lead: "glock",
|
|
1244
|
+
swing: .25,
|
|
1245
|
+
window: "snow",
|
|
1246
|
+
backing: {
|
|
1247
|
+
chords: {
|
|
1248
|
+
enabled: true,
|
|
1249
|
+
instrument: "pad",
|
|
1250
|
+
style: "sustain",
|
|
1251
|
+
volume: .5
|
|
1252
|
+
},
|
|
1253
|
+
bass: {
|
|
1254
|
+
enabled: true,
|
|
1255
|
+
style: "roots",
|
|
1256
|
+
volume: .6
|
|
1257
|
+
},
|
|
1258
|
+
drums: {
|
|
1259
|
+
enabled: true,
|
|
1260
|
+
groove: "sparse",
|
|
1261
|
+
volume: .5
|
|
1262
|
+
}
|
|
1263
|
+
},
|
|
1264
|
+
mix: {
|
|
1265
|
+
master: .8,
|
|
1266
|
+
warmth: .6,
|
|
1267
|
+
crackle: .3,
|
|
1268
|
+
reverb: .6,
|
|
1269
|
+
wobble: .2,
|
|
1270
|
+
rain: {
|
|
1271
|
+
enabled: false,
|
|
1272
|
+
kind: "window",
|
|
1273
|
+
level: .3
|
|
1274
|
+
}
|
|
1275
|
+
}
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
const MOOD_IDS = Object.keys(MOODS);
|
|
1279
|
+
function getMood(id) {
|
|
1280
|
+
const mood = MOODS[id];
|
|
1281
|
+
if (!mood) throw new Error(`Unknown mood "${id}". Try one of: ${MOOD_IDS.join(", ")}`);
|
|
1282
|
+
return mood;
|
|
1283
|
+
}
|
|
1284
|
+
// ../../packages/core/src/rng.ts
|
|
1285
|
+
/** mulberry32: fast, tiny, and plenty random for musical jitter. */
|
|
1286
|
+
function createRng(seed) {
|
|
1287
|
+
let a = seed >>> 0;
|
|
1288
|
+
return () => {
|
|
1289
|
+
a = a + 1831565813 >>> 0;
|
|
1290
|
+
let t = a;
|
|
1291
|
+
t = Math.imul(t ^ t >>> 15, t | 1);
|
|
1292
|
+
t ^= t + Math.imul(t ^ t >>> 7, t | 61);
|
|
1293
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
1294
|
+
};
|
|
1295
|
+
}
|
|
1296
|
+
/** Mixes strings and numbers into one 32-bit seed (FNV-1a). */
|
|
1297
|
+
function hashSeed(...parts) {
|
|
1298
|
+
let h = 2166136261;
|
|
1299
|
+
for (const part of parts) {
|
|
1300
|
+
for (const ch of String(part)) {
|
|
1301
|
+
h ^= ch.charCodeAt(0);
|
|
1302
|
+
h = Math.imul(h, 16777619);
|
|
1303
|
+
}
|
|
1304
|
+
h ^= 255;
|
|
1305
|
+
h = Math.imul(h, 16777619);
|
|
1306
|
+
}
|
|
1307
|
+
return h >>> 0;
|
|
1308
|
+
}
|
|
1309
|
+
/** Symmetric jitter in [-1, 1], a bit more likely near zero (sum of two uniforms). */
|
|
1310
|
+
function jitter(rng) {
|
|
1311
|
+
return rng() + rng() - 1;
|
|
1312
|
+
}
|
|
1313
|
+
const ID_CHARS = "0123456789abcdefghijklmnopqrstuvwxyz";
|
|
1314
|
+
/** Short readable ids like "l_k3x9q2", easy for agents to type back. */
|
|
1315
|
+
function makeId(prefix) {
|
|
1316
|
+
const bytes = crypto.getRandomValues(/* @__PURE__ */ new Uint8Array(6));
|
|
1317
|
+
let id = "";
|
|
1318
|
+
for (const b of bytes) id += ID_CHARS[b % 36];
|
|
1319
|
+
return `${prefix}_${id}`;
|
|
1320
|
+
}
|
|
1321
|
+
// ../../packages/core/src/model/project.ts
|
|
1322
|
+
function createLayer({ name, instrument, color, index = 0 }) {
|
|
1323
|
+
return {
|
|
1324
|
+
id: makeId("l"),
|
|
1325
|
+
name: name ?? INSTRUMENTS[instrument].label,
|
|
1326
|
+
instrument,
|
|
1327
|
+
color: color ?? LAYER_COLORS[index % LAYER_COLORS.length],
|
|
1328
|
+
volume: .8,
|
|
1329
|
+
pan: 0,
|
|
1330
|
+
muted: false,
|
|
1331
|
+
solo: false,
|
|
1332
|
+
harmony: "smart",
|
|
1333
|
+
octave: 0,
|
|
1334
|
+
kit: "sticks",
|
|
1335
|
+
strokes: []
|
|
1336
|
+
};
|
|
1337
|
+
}
|
|
1338
|
+
function createScene(project, name, bars = 4) {
|
|
1339
|
+
const mood = getMood(project.mood ?? "rainy-study");
|
|
1340
|
+
return {
|
|
1341
|
+
id: makeId("s"),
|
|
1342
|
+
name,
|
|
1343
|
+
bars,
|
|
1344
|
+
progression: resolveProgression(mood.progression, project.key),
|
|
1345
|
+
layers: [createLayer({
|
|
1346
|
+
instrument: mood.lead,
|
|
1347
|
+
name: "Melody"
|
|
1348
|
+
})],
|
|
1349
|
+
backing: structuredClone(mood.backing)
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
function createProject({ name = "Untitled loop", mood = "rainy-study", seed, bars = 4 } = {}) {
|
|
1353
|
+
const preset = getMood(mood);
|
|
1354
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1355
|
+
const base = {
|
|
1356
|
+
key: { ...preset.key },
|
|
1357
|
+
mood
|
|
1358
|
+
};
|
|
1359
|
+
const scene = createScene(base, "A", bars);
|
|
1360
|
+
return {
|
|
1361
|
+
format: "haku",
|
|
1362
|
+
version: 1,
|
|
1363
|
+
id: makeId("p"),
|
|
1364
|
+
name,
|
|
1365
|
+
createdAt: now,
|
|
1366
|
+
updatedAt: now,
|
|
1367
|
+
seed: seed ?? Math.floor(createRng(Date.now())() * 2 ** 31),
|
|
1368
|
+
mood,
|
|
1369
|
+
window: preset.window,
|
|
1370
|
+
key: base.key,
|
|
1371
|
+
bpm: preset.bpm,
|
|
1372
|
+
swing: preset.swing,
|
|
1373
|
+
humanize: .5,
|
|
1374
|
+
mix: structuredClone(preset.mix),
|
|
1375
|
+
scenes: [scene],
|
|
1376
|
+
arrangement: [{
|
|
1377
|
+
sceneId: scene.id,
|
|
1378
|
+
repeat: 1
|
|
1379
|
+
}],
|
|
1380
|
+
activeSceneId: scene.id
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/** Validates unknown data (a file, a bridge message) as a project. Throws with a readable message. */
|
|
1384
|
+
function parseProject(data) {
|
|
1385
|
+
const result = ProjectSchema.safeParse(data);
|
|
1386
|
+
if (!result.success) {
|
|
1387
|
+
const issue = result.error.issues[0];
|
|
1388
|
+
throw new Error(`Not a valid Haku project: ${issue.path.join(".") || "root"}: ${issue.message}`);
|
|
1389
|
+
}
|
|
1390
|
+
return result.data;
|
|
1391
|
+
}
|
|
1392
|
+
function findScene(project, sceneId) {
|
|
1393
|
+
const id = sceneId ?? project.activeSceneId;
|
|
1394
|
+
const scene = project.scenes.find((s) => s.id === id || s.name === id);
|
|
1395
|
+
if (!scene) throw new Error(`No scene "${id}"`);
|
|
1396
|
+
return scene;
|
|
1397
|
+
}
|
|
1398
|
+
/** Finds a layer by id or name. Searches the given scene, or the active one. */
|
|
1399
|
+
function findLayer(project, layerId, sceneId) {
|
|
1400
|
+
const scene = findScene(project, sceneId);
|
|
1401
|
+
const layer = layerId ? scene.layers.find((l) => l.id === layerId || l.name.toLowerCase() === layerId.toLowerCase()) : scene.layers.find((l) => INSTRUMENTS[l.instrument].kind === "tonal") ?? scene.layers[0];
|
|
1402
|
+
if (!layer) throw new Error(layerId ? `No layer "${layerId}" in scene ${scene.name}` : `Scene ${scene.name} has no layers`);
|
|
1403
|
+
return {
|
|
1404
|
+
scene,
|
|
1405
|
+
layer
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
// ../../packages/core/src/engine/snap.ts
|
|
1409
|
+
/** Beats 1 and 3 are strong, 2 and 4 medium, everything between weak. Assumes 4/4. */
|
|
1410
|
+
function beatStrength(beat) {
|
|
1411
|
+
const inBar = (beat % 4 + 4) % 4;
|
|
1412
|
+
if (Math.abs(inBar - Math.round(inBar)) > 1e-6) return "weak";
|
|
1413
|
+
return Math.round(inBar) % 2 === 0 ? "strong" : "medium";
|
|
1414
|
+
}
|
|
1415
|
+
/** The pitch classes a note is allowed to land on in this context. */
|
|
1416
|
+
function allowedPcs(ctx) {
|
|
1417
|
+
const tonic = tonicPc(ctx.key);
|
|
1418
|
+
const scale = scalePcs(ctx.key);
|
|
1419
|
+
const chord = chordPcs(mod12(tonic + ctx.chord.root), ctx.chord.quality);
|
|
1420
|
+
switch (ctx.mode) {
|
|
1421
|
+
case "chromatic": return [
|
|
1422
|
+
0,
|
|
1423
|
+
1,
|
|
1424
|
+
2,
|
|
1425
|
+
3,
|
|
1426
|
+
4,
|
|
1427
|
+
5,
|
|
1428
|
+
6,
|
|
1429
|
+
7,
|
|
1430
|
+
8,
|
|
1431
|
+
9,
|
|
1432
|
+
10,
|
|
1433
|
+
11
|
|
1434
|
+
];
|
|
1435
|
+
case "scale": return scale;
|
|
1436
|
+
case "pentatonic": return pentatonicPcs(ctx.key);
|
|
1437
|
+
case "chord": return chord;
|
|
1438
|
+
case "smart": {
|
|
1439
|
+
if (ctx.strength === "strong" || ctx.long) return chord;
|
|
1440
|
+
if (ctx.strength === "weak") return [.../* @__PURE__ */ new Set([...scale, ...chord])];
|
|
1441
|
+
const safe = scale.filter((pc) => chord.includes(pc) || !chord.includes(mod12(pc - 1)));
|
|
1442
|
+
return [.../* @__PURE__ */ new Set([...safe, ...chord])];
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
/** Snaps a continuous MIDI pitch (like 63.4) to the best allowed note inside the range. */
|
|
1447
|
+
function snapPitch(raw, ctx) {
|
|
1448
|
+
const allowed = new Set(allowedPcs(ctx));
|
|
1449
|
+
const [lo, hi] = ctx.range;
|
|
1450
|
+
let best = Math.round(Math.min(hi, Math.max(lo, raw)));
|
|
1451
|
+
let bestCost = Infinity;
|
|
1452
|
+
for (let n = lo - 2; n <= hi + 2; n++) {
|
|
1453
|
+
if (!allowed.has(mod12(n))) continue;
|
|
1454
|
+
const outside = n < lo ? lo - n : n > hi ? n - hi : 0;
|
|
1455
|
+
const cost = Math.abs(n - raw) + (ctx.prev === void 0 ? 0 : .08 * Math.abs(n - ctx.prev)) + outside * 2;
|
|
1456
|
+
if (cost < bestCost) {
|
|
1457
|
+
bestCost = cost;
|
|
1458
|
+
best = n;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
return best;
|
|
1462
|
+
}
|
|
1463
|
+
/** Moves a pitch by scale steps (positive is up), staying in the key. */
|
|
1464
|
+
function scaleStep(midi, steps, key) {
|
|
1465
|
+
const scale = new Set(scalePcs(key));
|
|
1466
|
+
let n = midi;
|
|
1467
|
+
let remaining = Math.abs(steps);
|
|
1468
|
+
const dir = Math.sign(steps);
|
|
1469
|
+
while (remaining > 0) {
|
|
1470
|
+
n += dir;
|
|
1471
|
+
if (scale.has(mod12(n))) remaining--;
|
|
1472
|
+
}
|
|
1473
|
+
return n;
|
|
1474
|
+
}
|
|
1475
|
+
// ../../packages/core/src/engine/gestures.ts
|
|
1476
|
+
function measureStroke(stroke) {
|
|
1477
|
+
const pts = stroke.points;
|
|
1478
|
+
let minX = 1, maxX = 0, minY = 1, maxY = 0, length = 0;
|
|
1479
|
+
for (const p of pts) {
|
|
1480
|
+
minX = Math.min(minX, p.x);
|
|
1481
|
+
maxX = Math.max(maxX, p.x);
|
|
1482
|
+
minY = Math.min(minY, p.y);
|
|
1483
|
+
maxY = Math.max(maxY, p.y);
|
|
1484
|
+
}
|
|
1485
|
+
for (let i = 1; i < pts.length; i++) length += Math.hypot(pts[i].x - pts[i - 1].x, pts[i].y - pts[i - 1].y);
|
|
1486
|
+
const threshold = .012;
|
|
1487
|
+
let reversals = 0;
|
|
1488
|
+
let dir = 0;
|
|
1489
|
+
let anchor = pts[0].y;
|
|
1490
|
+
for (const p of pts) {
|
|
1491
|
+
const d = p.y - anchor;
|
|
1492
|
+
if (Math.abs(d) < threshold) continue;
|
|
1493
|
+
const nd = Math.sign(d);
|
|
1494
|
+
if (dir !== 0 && nd !== dir) reversals++;
|
|
1495
|
+
dir = nd;
|
|
1496
|
+
anchor = p.y;
|
|
1497
|
+
}
|
|
1498
|
+
return {
|
|
1499
|
+
minX,
|
|
1500
|
+
maxX,
|
|
1501
|
+
minY,
|
|
1502
|
+
maxY,
|
|
1503
|
+
width: maxX - minX,
|
|
1504
|
+
height: maxY - minY,
|
|
1505
|
+
length,
|
|
1506
|
+
durationMs: pts[pts.length - 1].t - pts[0].t,
|
|
1507
|
+
reversals
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
function classifyStroke(stroke) {
|
|
1511
|
+
const s = measureStroke(stroke);
|
|
1512
|
+
if (stroke.points.length === 1 || s.width < .012 && s.height < .02) return "dot";
|
|
1513
|
+
if (stroke.exact || stroke.brush === "stamp" || stroke.brush === "spray") return "line";
|
|
1514
|
+
if (s.height > .06 && s.width < Math.max(.015, s.height * .15)) return "strum";
|
|
1515
|
+
if (s.reversals >= 4 && s.width / s.reversals < .04 && s.height < .15) return "trill";
|
|
1516
|
+
if (s.durationMs > 0 && s.durationMs < 180 && s.length > .03 && s.length < .12) return "flick";
|
|
1517
|
+
return "line";
|
|
1518
|
+
}
|
|
1519
|
+
// ../../packages/core/src/engine/strokes.ts
|
|
1520
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
1521
|
+
function speedBetween(a, b) {
|
|
1522
|
+
const dt = (b.t - a.t) / 1e3;
|
|
1523
|
+
return dt > 0 ? Math.hypot(b.x - a.x, b.y - a.y) / dt : null;
|
|
1524
|
+
}
|
|
1525
|
+
/** Walks the stroke and records the y value at the center of every grid column it crosses. */
|
|
1526
|
+
function sampleColumns(points, columns) {
|
|
1527
|
+
const colOf = (x) => clamp(Math.floor(x * columns), 0, columns - 1);
|
|
1528
|
+
const samples = [];
|
|
1529
|
+
const push = (s) => {
|
|
1530
|
+
if (samples.length === 0 || samples[samples.length - 1].col !== s.col) samples.push(s);
|
|
1531
|
+
};
|
|
1532
|
+
const first = points[0];
|
|
1533
|
+
push({
|
|
1534
|
+
col: colOf(first.x),
|
|
1535
|
+
y: first.y,
|
|
1536
|
+
speed: points[1] ? speedBetween(first, points[1]) : null,
|
|
1537
|
+
pressure: first.p
|
|
1538
|
+
});
|
|
1539
|
+
for (let i = 1; i < points.length; i++) {
|
|
1540
|
+
const a = points[i - 1];
|
|
1541
|
+
const b = points[i];
|
|
1542
|
+
const dx = b.x - a.x;
|
|
1543
|
+
if (Math.abs(dx) < 1e-9) continue;
|
|
1544
|
+
const speed = speedBetween(a, b);
|
|
1545
|
+
const from = colOf(a.x);
|
|
1546
|
+
const to = colOf(b.x);
|
|
1547
|
+
const step = to >= from ? 1 : -1;
|
|
1548
|
+
for (let c = from; c !== to + step; c += step) {
|
|
1549
|
+
const f = ((c + .5) / columns - a.x) / dx;
|
|
1550
|
+
if (f < 0 || f > 1) continue;
|
|
1551
|
+
const pressure = a.p === void 0 || b.p === void 0 ? void 0 : a.p + (b.p - a.p) * f;
|
|
1552
|
+
push({
|
|
1553
|
+
col: c,
|
|
1554
|
+
y: a.y + (b.y - a.y) * f,
|
|
1555
|
+
speed,
|
|
1556
|
+
pressure
|
|
1557
|
+
});
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
const last = points[points.length - 1];
|
|
1561
|
+
if (points.length > 1) push({
|
|
1562
|
+
col: colOf(last.x),
|
|
1563
|
+
y: last.y,
|
|
1564
|
+
speed: null,
|
|
1565
|
+
pressure: last.p
|
|
1566
|
+
});
|
|
1567
|
+
return samples;
|
|
1568
|
+
}
|
|
1569
|
+
function velocityFor(sample, base, size) {
|
|
1570
|
+
let v;
|
|
1571
|
+
if (sample.pressure !== void 0) v = .25 + .7 * sample.pressure;
|
|
1572
|
+
else if (sample.speed === null) v = base;
|
|
1573
|
+
else v = base * (.75 + .5 * clamp(sample.speed / 1.2, 0, 1));
|
|
1574
|
+
return clamp(v * (1 - .25 * size), .15, 1);
|
|
1575
|
+
}
|
|
1576
|
+
function layerRange(layer) {
|
|
1577
|
+
const [lo, hi] = INSTRUMENTS[layer.instrument].range;
|
|
1578
|
+
return [lo + layer.octave * 12, hi + layer.octave * 12];
|
|
1579
|
+
}
|
|
1580
|
+
/** The continuous pitch a y position points at, before snapping. */
|
|
1581
|
+
function yToPitch(y, range) {
|
|
1582
|
+
return range[1] - clamp(y, 0, 1) * (range[1] - range[0]);
|
|
1583
|
+
}
|
|
1584
|
+
/** The y position that reads back as this exact pitch. */
|
|
1585
|
+
function pitchToY(midi, range) {
|
|
1586
|
+
return clamp((range[1] - midi) / (range[1] - range[0]), 0, 1);
|
|
1587
|
+
}
|
|
1588
|
+
function strokeToNotes(stroke, ctx) {
|
|
1589
|
+
if (INSTRUMENTS[ctx.layer.instrument].kind === "drums") return drumStrokeToNotes(stroke, ctx);
|
|
1590
|
+
const brush = BRUSHES[stroke.brush];
|
|
1591
|
+
const spb = stroke.exact ? 4 : brush.stepsPerBeat;
|
|
1592
|
+
const totalBeats = ctx.bars * 4;
|
|
1593
|
+
const columns = totalBeats * spb;
|
|
1594
|
+
const stepLen = 1 / spb;
|
|
1595
|
+
const range = layerRange(ctx.layer);
|
|
1596
|
+
const brightness = clamp(1 - stroke.size * .7, 0, 1);
|
|
1597
|
+
const rng = createRng(hashSeed(ctx.seed, stroke.id));
|
|
1598
|
+
const base = {
|
|
1599
|
+
track: ctx.layer.id,
|
|
1600
|
+
instrument: ctx.layer.instrument,
|
|
1601
|
+
source: "stroke",
|
|
1602
|
+
brightness,
|
|
1603
|
+
strokeId: stroke.id
|
|
1604
|
+
};
|
|
1605
|
+
const note = (time, duration, midi, velocity) => ({
|
|
1606
|
+
...base,
|
|
1607
|
+
time,
|
|
1608
|
+
duration,
|
|
1609
|
+
midi,
|
|
1610
|
+
velocity
|
|
1611
|
+
});
|
|
1612
|
+
const snapAt = (beat, raw, prev, long = false) => {
|
|
1613
|
+
if (stroke.exact) return clamp(Math.round(raw), 0, 127);
|
|
1614
|
+
return snapPitch(raw, {
|
|
1615
|
+
key: ctx.key,
|
|
1616
|
+
chord: chordAt(ctx.progression, beat).chord,
|
|
1617
|
+
mode: ctx.layer.harmony,
|
|
1618
|
+
strength: beatStrength(beat),
|
|
1619
|
+
long,
|
|
1620
|
+
range,
|
|
1621
|
+
prev
|
|
1622
|
+
});
|
|
1623
|
+
};
|
|
1624
|
+
const gesture = classifyStroke(stroke);
|
|
1625
|
+
const shape = measureStroke(stroke);
|
|
1626
|
+
const firstVel = velocityFor({
|
|
1627
|
+
speed: null,
|
|
1628
|
+
pressure: stroke.points[0].p
|
|
1629
|
+
}, brush.velocity, stroke.size);
|
|
1630
|
+
if (gesture === "dot") {
|
|
1631
|
+
const p = stroke.points[0];
|
|
1632
|
+
const beat = Math.floor(clamp(p.x, 0, .9999) * columns) / spb;
|
|
1633
|
+
const len = stroke.brush === "brush" ? 1 : stepLen;
|
|
1634
|
+
return [note(beat, len, snapAt(beat, yToPitch(p.y, range), void 0, len >= 1), firstVel)];
|
|
1635
|
+
}
|
|
1636
|
+
if (gesture === "strum") {
|
|
1637
|
+
const beat = Math.floor(clamp((shape.minX + shape.maxX) / 2, 0, .9999) * columns) / spb;
|
|
1638
|
+
const { chord, start } = chordAt(ctx.progression, beat);
|
|
1639
|
+
const pcs = new Set(chordPcs(mod12(tonicPc(ctx.key) + chord.root), chord.quality));
|
|
1640
|
+
const top = Math.round(yToPitch(shape.minY, range));
|
|
1641
|
+
const bottom = Math.round(yToPitch(shape.maxY, range));
|
|
1642
|
+
const pitches = [];
|
|
1643
|
+
for (let n = bottom; n <= top; n++) if (pcs.has(mod12(n))) pitches.push(n);
|
|
1644
|
+
if (!(stroke.points[stroke.points.length - 1].y < stroke.points[0].y)) pitches.reverse();
|
|
1645
|
+
const hold = Math.max(stepLen, Math.min(2, start + chord.beats - beat));
|
|
1646
|
+
return pitches.slice(0, 6).map((m, i) => note(beat + i * .04, hold - i * .04, m, firstVel * (1 - i * .04)));
|
|
1647
|
+
}
|
|
1648
|
+
if (gesture === "trill") {
|
|
1649
|
+
const startCol = Math.floor(clamp(shape.minX, 0, .9999) * totalBeats * 4);
|
|
1650
|
+
const endCol = Math.max(startCol + 1, Math.ceil(clamp(shape.maxX, 0, 1) * totalBeats * 4));
|
|
1651
|
+
const low = snapAt(startCol / 4, yToPitch((shape.minY + shape.maxY) / 2, range));
|
|
1652
|
+
const high = scaleStep(low, 1, ctx.key);
|
|
1653
|
+
const out = [];
|
|
1654
|
+
for (let c = startCol; c < endCol; c++) out.push(note(c / 4, .25, (c - startCol) % 2 === 0 ? low : high, firstVel * .85));
|
|
1655
|
+
return out;
|
|
1656
|
+
}
|
|
1657
|
+
if (gesture === "flick") {
|
|
1658
|
+
const end = stroke.points[stroke.points.length - 1];
|
|
1659
|
+
const beat = Math.floor(clamp(end.x, 0, .9999) * columns) / spb;
|
|
1660
|
+
const main = snapAt(beat, yToPitch(end.y, range), void 0, true);
|
|
1661
|
+
const grace = scaleStep(main, end.y < stroke.points[0].y ? -1 : 1, ctx.key);
|
|
1662
|
+
const v = clamp(firstVel * 1.15, 0, 1);
|
|
1663
|
+
const graceTime = Math.max(0, beat - .125);
|
|
1664
|
+
return [note(graceTime, beat - graceTime || .125, grace, v * .7), note(beat, stepLen * 2, main, v)];
|
|
1665
|
+
}
|
|
1666
|
+
const samples = sampleColumns(stroke.points, columns);
|
|
1667
|
+
const out = [];
|
|
1668
|
+
let prev;
|
|
1669
|
+
let lastCol = -2;
|
|
1670
|
+
let melody;
|
|
1671
|
+
let harmony;
|
|
1672
|
+
for (const s of samples) {
|
|
1673
|
+
const beat = s.col / spb;
|
|
1674
|
+
const v = velocityFor(s, brush.velocity, stroke.size);
|
|
1675
|
+
if (stroke.brush === "spray") {
|
|
1676
|
+
if (rng() > .6) continue;
|
|
1677
|
+
const raw = yToPitch(s.y, range) + jitter(rng) * 4;
|
|
1678
|
+
out.push(note(beat, stepLen, snapAt(beat, raw), v * (.7 + rng() * .4)));
|
|
1679
|
+
continue;
|
|
1680
|
+
}
|
|
1681
|
+
const midi = snapAt(beat, yToPitch(s.y, range), prev, stroke.brush === "brush");
|
|
1682
|
+
if (melody && stroke.brush !== "stamp" && s.col === lastCol + 1 && melody.midi === midi) {
|
|
1683
|
+
melody.duration += stepLen;
|
|
1684
|
+
if (harmony) harmony.duration = melody.duration;
|
|
1685
|
+
} else {
|
|
1686
|
+
melody = note(beat, stepLen, midi, v);
|
|
1687
|
+
out.push(melody);
|
|
1688
|
+
harmony = void 0;
|
|
1689
|
+
if (stroke.brush === "chalk") {
|
|
1690
|
+
const below = chordToneBelow(midi, beat, ctx);
|
|
1691
|
+
if (below !== null) out.push(harmony = note(beat, stepLen, below, v * .8));
|
|
1692
|
+
}
|
|
1693
|
+
}
|
|
1694
|
+
prev = midi;
|
|
1695
|
+
lastCol = s.col;
|
|
1696
|
+
}
|
|
1697
|
+
return out;
|
|
1698
|
+
}
|
|
1699
|
+
/** The closest chord tone at least a minor third below, for chalk harmonies. */
|
|
1700
|
+
function chordToneBelow(midi, beat, ctx) {
|
|
1701
|
+
const { chord } = chordAt(ctx.progression, beat);
|
|
1702
|
+
const pcs = new Set(chordPcs(mod12(tonicPc(ctx.key) + chord.root), chord.quality));
|
|
1703
|
+
for (let n = midi - 3; n >= midi - 9; n--) if (pcs.has(mod12(n))) return n;
|
|
1704
|
+
return null;
|
|
1705
|
+
}
|
|
1706
|
+
/** Drum layers split the canvas into rows, one per drum, bottom to top. */
|
|
1707
|
+
function drumStrokeToNotes(stroke, ctx) {
|
|
1708
|
+
const brush = BRUSHES[stroke.brush];
|
|
1709
|
+
const columns = ctx.bars * 4 * brush.stepsPerBeat;
|
|
1710
|
+
const rows = drumRows(ctx.layer.kit);
|
|
1711
|
+
const rng = createRng(hashSeed(ctx.seed, stroke.id));
|
|
1712
|
+
const brightness = clamp(1 - stroke.size * .7, 0, 1);
|
|
1713
|
+
const samples = classifyStroke(stroke) === "dot" ? sampleColumns(stroke.points.slice(0, 1), columns) : sampleColumns(stroke.points, columns);
|
|
1714
|
+
const out = [];
|
|
1715
|
+
for (const s of samples) {
|
|
1716
|
+
if (stroke.brush === "spray" && rng() > .55) continue;
|
|
1717
|
+
const piece = rows[clamp(Math.floor((1 - s.y) * rows.length), 0, rows.length - 1)];
|
|
1718
|
+
const v = velocityFor(s, brush.velocity, stroke.size) * (stroke.brush === "spray" ? .55 : 1);
|
|
1719
|
+
out.push({
|
|
1720
|
+
time: s.col / brush.stepsPerBeat,
|
|
1721
|
+
duration: .25,
|
|
1722
|
+
midi: DRUM_PIECES[piece].gm,
|
|
1723
|
+
velocity: v,
|
|
1724
|
+
track: ctx.layer.id,
|
|
1725
|
+
instrument: "drums",
|
|
1726
|
+
source: "stroke",
|
|
1727
|
+
drum: piece,
|
|
1728
|
+
brightness,
|
|
1729
|
+
strokeId: stroke.id
|
|
1730
|
+
});
|
|
1731
|
+
}
|
|
1732
|
+
return out;
|
|
1733
|
+
}
|
|
1734
|
+
// ../../packages/core/src/engine/voicing.ts
|
|
1735
|
+
const VOICING_RANGE = [50, 76];
|
|
1736
|
+
function closeVoicing(order, bottom) {
|
|
1737
|
+
const notes = [bottom];
|
|
1738
|
+
for (let i = 1; i < order.length; i++) {
|
|
1739
|
+
let n = notes[i - 1] + 1;
|
|
1740
|
+
while (mod12(n) !== order[i]) n++;
|
|
1741
|
+
notes.push(n);
|
|
1742
|
+
}
|
|
1743
|
+
return notes;
|
|
1744
|
+
}
|
|
1745
|
+
function candidateVoicings(rootPc, quality, range = VOICING_RANGE) {
|
|
1746
|
+
const pcs = [...new Set(QUALITIES[quality].voicing.map((i) => mod12(rootPc + i)))];
|
|
1747
|
+
const out = [];
|
|
1748
|
+
for (let r = 0; r < pcs.length; r++) {
|
|
1749
|
+
const order = [...pcs.slice(r), ...pcs.slice(0, r)];
|
|
1750
|
+
let bottom = range[0];
|
|
1751
|
+
while (mod12(bottom) !== order[0]) bottom++;
|
|
1752
|
+
for (let b = bottom; b <= range[1]; b += 12) {
|
|
1753
|
+
const close = closeVoicing(order, b);
|
|
1754
|
+
out.push(close);
|
|
1755
|
+
if (close.length >= 4) {
|
|
1756
|
+
const drop = [...close];
|
|
1757
|
+
drop[drop.length - 2] -= 12;
|
|
1758
|
+
out.push(drop.sort((x, y) => x - y));
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
}
|
|
1762
|
+
return out.filter((v) => v[0] >= range[0] - 2 && v[v.length - 1] <= range[1]);
|
|
1763
|
+
}
|
|
1764
|
+
/** How far the voices move, matching each note to its nearest neighbor in the other chord. */
|
|
1765
|
+
function movement(a, b) {
|
|
1766
|
+
const nearest = (n, set) => Math.min(...set.map((m) => Math.abs(m - n)));
|
|
1767
|
+
return (a.reduce((s, n) => s + nearest(n, b), 0) + b.reduce((s, n) => s + nearest(n, a), 0)) / 2;
|
|
1768
|
+
}
|
|
1769
|
+
function roughness(v) {
|
|
1770
|
+
let penalty = 0;
|
|
1771
|
+
for (let i = 1; i < v.length; i++) if (v[i - 1] < 53 && v[i] - v[i - 1] < 5) penalty += 3;
|
|
1772
|
+
if (v[v.length - 1] > 74) penalty += 1.5;
|
|
1773
|
+
return penalty;
|
|
1774
|
+
}
|
|
1775
|
+
function voiceChord(rootPc, quality, prev, range = VOICING_RANGE) {
|
|
1776
|
+
const candidates = candidateVoicings(rootPc, quality, range);
|
|
1777
|
+
let best = candidates[0] ?? [];
|
|
1778
|
+
let bestCost = Infinity;
|
|
1779
|
+
for (const v of candidates) {
|
|
1780
|
+
const center = v.reduce((s, n) => s + n, 0) / v.length;
|
|
1781
|
+
const cost = (prev ? movement(v, prev) : Math.abs(center - 62)) + roughness(v);
|
|
1782
|
+
if (cost < bestCost) {
|
|
1783
|
+
bestCost = cost;
|
|
1784
|
+
best = v;
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
return best;
|
|
1788
|
+
}
|
|
1789
|
+
// ../../packages/core/src/engine/grooves.ts
|
|
1790
|
+
const GROOVES = {
|
|
1791
|
+
dusty: {
|
|
1792
|
+
label: "Dusty boom bap",
|
|
1793
|
+
kit: "sticks",
|
|
1794
|
+
bar: {
|
|
1795
|
+
kick: "X......x..X.....",
|
|
1796
|
+
snare: "....X.......X...",
|
|
1797
|
+
hat: "x.o.x.o.x.ogx.o."
|
|
1798
|
+
},
|
|
1799
|
+
turnaround: {
|
|
1800
|
+
kick: "X......x..X..x..",
|
|
1801
|
+
snare: "....X.......X.g.",
|
|
1802
|
+
hat: "x.o.x.o.x.ogx...",
|
|
1803
|
+
openhat: "..............o."
|
|
1804
|
+
}
|
|
1805
|
+
},
|
|
1806
|
+
brushes: {
|
|
1807
|
+
label: "Brushes",
|
|
1808
|
+
kit: "brushes",
|
|
1809
|
+
bar: {
|
|
1810
|
+
kick: "o.......o.......",
|
|
1811
|
+
brush: "g...X..g....X.g.",
|
|
1812
|
+
brushhat: "x...x.o.x...x.o."
|
|
1813
|
+
}
|
|
1814
|
+
},
|
|
1815
|
+
halftime: {
|
|
1816
|
+
label: "Half-time",
|
|
1817
|
+
kit: "sticks",
|
|
1818
|
+
bar: {
|
|
1819
|
+
kick: "X.........x.....",
|
|
1820
|
+
snare: "........X.......",
|
|
1821
|
+
hat: "x.o.x.o.x.o.x.o."
|
|
1822
|
+
},
|
|
1823
|
+
turnaround: {
|
|
1824
|
+
kick: "X.........x..o..",
|
|
1825
|
+
snare: "........X.....g.",
|
|
1826
|
+
hat: "x.o.x.o.x.o.x...",
|
|
1827
|
+
openhat: "..............o."
|
|
1828
|
+
}
|
|
1829
|
+
},
|
|
1830
|
+
bossa: {
|
|
1831
|
+
label: "Lazy bossa",
|
|
1832
|
+
kit: "sticks",
|
|
1833
|
+
bar: {
|
|
1834
|
+
kick: "x..ox..ox..ox..o",
|
|
1835
|
+
rim: "x..x..x...x..x..",
|
|
1836
|
+
hat: "o.g.o.g.o.g.o.g."
|
|
1837
|
+
}
|
|
1838
|
+
},
|
|
1839
|
+
sparse: {
|
|
1840
|
+
label: "Sparse",
|
|
1841
|
+
kit: "sticks",
|
|
1842
|
+
bar: {
|
|
1843
|
+
kick: "x.......o.......",
|
|
1844
|
+
rim: "........o.......",
|
|
1845
|
+
ride: "o...o...o...o..."
|
|
1846
|
+
}
|
|
1847
|
+
}
|
|
1848
|
+
};
|
|
1849
|
+
const LEVELS = {
|
|
1850
|
+
X: .95,
|
|
1851
|
+
x: .72,
|
|
1852
|
+
o: .5,
|
|
1853
|
+
g: .28
|
|
1854
|
+
};
|
|
1855
|
+
function grooveBar(id, turnaround) {
|
|
1856
|
+
const groove = GROOVES[id];
|
|
1857
|
+
const pattern = turnaround && groove.turnaround ? groove.turnaround : groove.bar;
|
|
1858
|
+
const hits = [];
|
|
1859
|
+
for (const [piece, steps] of Object.entries(pattern)) for (let i = 0; i < steps.length; i++) {
|
|
1860
|
+
const v = LEVELS[steps[i]];
|
|
1861
|
+
if (v !== void 0) hits.push({
|
|
1862
|
+
beat: i / 4,
|
|
1863
|
+
piece,
|
|
1864
|
+
velocity: v
|
|
1865
|
+
});
|
|
1866
|
+
}
|
|
1867
|
+
return hits.sort((a, b) => a.beat - b.beat);
|
|
1868
|
+
}
|
|
1869
|
+
// ../../packages/core/src/engine/backing.ts
|
|
1870
|
+
/** Lays the progression across the scene, looping it if it's shorter than the scene. */
|
|
1871
|
+
function placeChords(progression, key, totalBeats) {
|
|
1872
|
+
const out = [];
|
|
1873
|
+
const tonic = tonicPc(key);
|
|
1874
|
+
let t = 0;
|
|
1875
|
+
for (let i = 0; t < totalBeats; i = (i + 1) % progression.length) {
|
|
1876
|
+
const chord = progression[i];
|
|
1877
|
+
const beats = Math.min(chord.beats, totalBeats - t);
|
|
1878
|
+
out.push({
|
|
1879
|
+
chord,
|
|
1880
|
+
start: t,
|
|
1881
|
+
beats,
|
|
1882
|
+
rootPc: mod12(tonic + chord.root)
|
|
1883
|
+
});
|
|
1884
|
+
t += chord.beats;
|
|
1885
|
+
}
|
|
1886
|
+
return out;
|
|
1887
|
+
}
|
|
1888
|
+
/** Hit times (beat offset, length) for one chord, by comping style. */
|
|
1889
|
+
function compRhythm(style, beats) {
|
|
1890
|
+
switch (style) {
|
|
1891
|
+
case "sustain": return [{
|
|
1892
|
+
at: 0,
|
|
1893
|
+
len: beats,
|
|
1894
|
+
strum: .01
|
|
1895
|
+
}];
|
|
1896
|
+
case "lazy": return [{
|
|
1897
|
+
at: .06,
|
|
1898
|
+
len: beats - .3,
|
|
1899
|
+
strum: .035
|
|
1900
|
+
}];
|
|
1901
|
+
case "stabs": {
|
|
1902
|
+
const hits = [];
|
|
1903
|
+
for (let bar = 0; bar < beats; bar += 4) for (const [at, len] of [
|
|
1904
|
+
[0, .9],
|
|
1905
|
+
[1.5, .4],
|
|
1906
|
+
[3, .4]
|
|
1907
|
+
]) if (bar + at < beats) hits.push({
|
|
1908
|
+
at: bar + at,
|
|
1909
|
+
len: Math.min(len, beats - bar - at),
|
|
1910
|
+
strum: .01
|
|
1911
|
+
});
|
|
1912
|
+
return hits;
|
|
1913
|
+
}
|
|
1914
|
+
case "arp": return [];
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
function compChords(placed, backing, carry) {
|
|
1918
|
+
const out = [];
|
|
1919
|
+
const base = {
|
|
1920
|
+
track: "backing:chords",
|
|
1921
|
+
instrument: backing.instrument,
|
|
1922
|
+
source: "chords",
|
|
1923
|
+
brightness: .6
|
|
1924
|
+
};
|
|
1925
|
+
for (const p of placed) {
|
|
1926
|
+
const voicing = voiceChord(p.rootPc, p.chord.quality, carry.voicing);
|
|
1927
|
+
carry.voicing = voicing;
|
|
1928
|
+
if (backing.style === "arp") {
|
|
1929
|
+
const order = [...voicing, ...voicing.slice(1, -1).reverse()];
|
|
1930
|
+
for (let i = 0; i * .5 < p.beats; i++) out.push({
|
|
1931
|
+
...base,
|
|
1932
|
+
time: p.start + i * .5,
|
|
1933
|
+
duration: Math.min(.9, p.beats - i * .5),
|
|
1934
|
+
midi: order[i % order.length],
|
|
1935
|
+
velocity: i % 4 === 0 ? .55 : .42
|
|
1936
|
+
});
|
|
1937
|
+
continue;
|
|
1938
|
+
}
|
|
1939
|
+
for (const hit of compRhythm(backing.style, p.beats)) voicing.forEach((midi, i) => {
|
|
1940
|
+
const at = hit.at + i * hit.strum;
|
|
1941
|
+
out.push({
|
|
1942
|
+
...base,
|
|
1943
|
+
time: p.start + at,
|
|
1944
|
+
duration: Math.max(.1, hit.len - i * hit.strum),
|
|
1945
|
+
midi,
|
|
1946
|
+
velocity: .5 - i * .03
|
|
1947
|
+
});
|
|
1948
|
+
});
|
|
1949
|
+
}
|
|
1950
|
+
return out;
|
|
1951
|
+
}
|
|
1952
|
+
const BASS_RANGE = [33, 52];
|
|
1953
|
+
function nearestInRange(pc, target, range = BASS_RANGE) {
|
|
1954
|
+
let best = -1;
|
|
1955
|
+
for (let n = range[0]; n <= range[1]; n++) if (mod12(n) === pc && (best < 0 || Math.abs(n - target) < Math.abs(best - target))) best = n;
|
|
1956
|
+
return best;
|
|
1957
|
+
}
|
|
1958
|
+
function chordTone(p, kind) {
|
|
1959
|
+
const tones = QUALITIES[p.chord.quality].tones.map((t) => t % 12);
|
|
1960
|
+
const pick = (options, fallback) => options.find((o) => tones.includes(o)) ?? fallback;
|
|
1961
|
+
const interval = kind === "third" ? pick([
|
|
1962
|
+
4,
|
|
1963
|
+
3,
|
|
1964
|
+
5,
|
|
1965
|
+
2
|
|
1966
|
+
], 4) : kind === "fifth" ? pick([
|
|
1967
|
+
7,
|
|
1968
|
+
6,
|
|
1969
|
+
8
|
|
1970
|
+
], 7) : pick([
|
|
1971
|
+
10,
|
|
1972
|
+
11,
|
|
1973
|
+
9
|
|
1974
|
+
], 10);
|
|
1975
|
+
return mod12(p.rootPc + interval);
|
|
1976
|
+
}
|
|
1977
|
+
function bassLine(placed, style, carry) {
|
|
1978
|
+
const out = [];
|
|
1979
|
+
const base = {
|
|
1980
|
+
track: "backing:bass",
|
|
1981
|
+
instrument: "bass",
|
|
1982
|
+
source: "bass",
|
|
1983
|
+
brightness: .5
|
|
1984
|
+
};
|
|
1985
|
+
const add = (time, duration, midi, velocity) => {
|
|
1986
|
+
out.push({
|
|
1987
|
+
...base,
|
|
1988
|
+
time,
|
|
1989
|
+
duration,
|
|
1990
|
+
midi,
|
|
1991
|
+
velocity
|
|
1992
|
+
});
|
|
1993
|
+
carry.last = midi;
|
|
1994
|
+
};
|
|
1995
|
+
placed.forEach((p, idx) => {
|
|
1996
|
+
const root = nearestInRange(p.rootPc, carry.last);
|
|
1997
|
+
const fifth = nearestInRange(chordTone(p, "fifth"), root + 5);
|
|
1998
|
+
const next = placed[idx + 1] ?? placed[0];
|
|
1999
|
+
if (style === "walking") {
|
|
2000
|
+
const tones = [
|
|
2001
|
+
root,
|
|
2002
|
+
nearestInRange(chordTone(p, "third"), root + 4),
|
|
2003
|
+
fifth,
|
|
2004
|
+
nearestInRange(chordTone(p, "seventh"), root + 9)
|
|
2005
|
+
];
|
|
2006
|
+
for (let b = 0; b < p.beats; b++) {
|
|
2007
|
+
const last = b === Math.ceil(p.beats) - 1 && p.beats > 1;
|
|
2008
|
+
const nextRoot = nearestInRange(next.rootPc, carry.last);
|
|
2009
|
+
const midi = b === 0 ? root : last ? nextRoot + (nextRoot > carry.last ? -1 : 1) : tones[b % tones.length];
|
|
2010
|
+
add(p.start + b, .9, midi, b === 0 ? .78 : .62);
|
|
2011
|
+
}
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
for (let bar = 0; bar < p.beats; bar += 4) {
|
|
2015
|
+
const left = p.beats - bar;
|
|
2016
|
+
if (style === "roots") {
|
|
2017
|
+
add(p.start + bar, Math.min(2.75, left - .1), root, .75);
|
|
2018
|
+
if (left > 3) add(p.start + bar + 3, .75, bar % 8 === 0 ? fifth : root + 12 <= BASS_RANGE[1] ? root + 12 : root, .6);
|
|
2019
|
+
} else {
|
|
2020
|
+
add(p.start + bar, Math.min(1.5, left - .1), root, .8);
|
|
2021
|
+
if (left > 2.5) add(p.start + bar + 2.5, .4, root, .55);
|
|
2022
|
+
if (left > 3.5) add(p.start + bar + 3.5, .4, fifth, .5);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
});
|
|
2026
|
+
return out;
|
|
2027
|
+
}
|
|
2028
|
+
function drumPart(groove, totalBeats) {
|
|
2029
|
+
const out = [];
|
|
2030
|
+
const bars = Math.ceil(totalBeats / 4);
|
|
2031
|
+
for (let bar = 0; bar < bars; bar++) {
|
|
2032
|
+
const turnaround = bar === bars - 1 || bar % 4 === 3;
|
|
2033
|
+
for (const hit of grooveBar(groove, turnaround)) {
|
|
2034
|
+
const time = bar * 4 + hit.beat;
|
|
2035
|
+
if (time >= totalBeats) continue;
|
|
2036
|
+
out.push({
|
|
2037
|
+
time,
|
|
2038
|
+
duration: .25,
|
|
2039
|
+
midi: DRUM_PIECES[hit.piece].gm,
|
|
2040
|
+
velocity: hit.velocity,
|
|
2041
|
+
track: "backing:drums",
|
|
2042
|
+
instrument: "drums",
|
|
2043
|
+
source: "drums",
|
|
2044
|
+
drum: hit.piece,
|
|
2045
|
+
brightness: .6
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
return out;
|
|
2050
|
+
}
|
|
2051
|
+
// ../../packages/core/src/engine/humanize.ts
|
|
2052
|
+
const SIXTEENTH = .25;
|
|
2053
|
+
/**
|
|
2054
|
+
* Pushes offbeat sixteenths late. At 1.0 they land on the triplet (a third of the beat after the
|
|
2055
|
+
* eighth), which is about as swung as lofi gets. Notes that aren't on an offbeat sixteenth are untouched.
|
|
2056
|
+
*/
|
|
2057
|
+
function swingTime(time, amount) {
|
|
2058
|
+
const inPair = time % (SIXTEENTH * 2);
|
|
2059
|
+
if (Math.abs(inPair - SIXTEENTH) > .01) return time;
|
|
2060
|
+
return time + amount * (1 / 3 - SIXTEENTH);
|
|
2061
|
+
}
|
|
2062
|
+
function applySwing(events, amount) {
|
|
2063
|
+
if (amount <= 0) return;
|
|
2064
|
+
for (const e of events) {
|
|
2065
|
+
const swung = swingTime(e.time, amount);
|
|
2066
|
+
e.duration = Math.max(.05, e.duration - (swung - e.time));
|
|
2067
|
+
e.time = swung;
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
/** Backbeats drag a little behind the grid, kicks stay tight. That lazy push-pull is half the genre. */
|
|
2071
|
+
const DRUM_LAG = {
|
|
2072
|
+
snare: .012,
|
|
2073
|
+
brush: .016,
|
|
2074
|
+
rim: .01
|
|
2075
|
+
};
|
|
2076
|
+
/**
|
|
2077
|
+
* Nudges timing and velocity by small seeded amounts. The same seed and pass always give the same
|
|
2078
|
+
* result, so repeats of a scene vary from each other but a project plays the same everywhere.
|
|
2079
|
+
*/
|
|
2080
|
+
function applyHumanize(events, amount, seed, pass = 0) {
|
|
2081
|
+
if (amount <= 0) return;
|
|
2082
|
+
const rngs = /* @__PURE__ */ new Map();
|
|
2083
|
+
for (const e of events) {
|
|
2084
|
+
let rng = rngs.get(e.track);
|
|
2085
|
+
if (!rng) rngs.set(e.track, rng = createRng(hashSeed(seed, e.track, pass)));
|
|
2086
|
+
const tight = e.drum === "kick" ? .5 : 1;
|
|
2087
|
+
const lag = e.drum ? (DRUM_LAG[e.drum] ?? 0) * amount : 0;
|
|
2088
|
+
e.time = Math.max(0, e.time + jitter(rng) * .022 * amount * tight + lag);
|
|
2089
|
+
e.velocity = Math.min(1, Math.max(.05, e.velocity * (1 + jitter(rng) * .14 * amount)));
|
|
2090
|
+
}
|
|
2091
|
+
}
|
|
2092
|
+
// ../../packages/core/src/engine/score.ts
|
|
2093
|
+
const newCarry = () => ({
|
|
2094
|
+
voicing: null,
|
|
2095
|
+
bassLast: 40
|
|
2096
|
+
});
|
|
2097
|
+
/** Which layers are audible: if anything is soloed, only soloed layers play (and the backing sits out). */
|
|
2098
|
+
function audibleLayers(scene) {
|
|
2099
|
+
const anySolo = scene.layers.some((l) => l.solo);
|
|
2100
|
+
return {
|
|
2101
|
+
layers: scene.layers.filter((l) => anySolo ? l.solo : !l.muted),
|
|
2102
|
+
backing: !anySolo
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
function sceneEvents(project, scene, { offset = 0, pass = 0, carry = newCarry() } = {}) {
|
|
2106
|
+
const totalBeats = scene.bars * 4;
|
|
2107
|
+
const events = [];
|
|
2108
|
+
const { layers, backing } = audibleLayers(scene);
|
|
2109
|
+
for (const layer of layers) for (const stroke of layer.strokes) events.push(...strokeToNotes(stroke, {
|
|
2110
|
+
key: project.key,
|
|
2111
|
+
progression: scene.progression,
|
|
2112
|
+
bars: scene.bars,
|
|
2113
|
+
layer,
|
|
2114
|
+
seed: project.seed
|
|
2115
|
+
}));
|
|
2116
|
+
const placed = placeChords(scene.progression, project.key, totalBeats);
|
|
2117
|
+
if (backing) {
|
|
2118
|
+
const b = scene.backing;
|
|
2119
|
+
if (b.chords.enabled) {
|
|
2120
|
+
const v = { voicing: carry.voicing };
|
|
2121
|
+
events.push(...compChords(placed, b.chords, v));
|
|
2122
|
+
carry.voicing = v.voicing;
|
|
2123
|
+
}
|
|
2124
|
+
if (b.bass.enabled) {
|
|
2125
|
+
const c = { last: carry.bassLast };
|
|
2126
|
+
events.push(...bassLine(placed, b.bass.style, c));
|
|
2127
|
+
carry.bassLast = c.last;
|
|
2128
|
+
}
|
|
2129
|
+
if (b.drums.enabled) events.push(...drumPart(b.drums.groove, totalBeats));
|
|
2130
|
+
}
|
|
2131
|
+
const kept = events.filter((e) => e.time < totalBeats);
|
|
2132
|
+
for (const e of kept) e.duration = Math.min(e.duration, totalBeats - e.time);
|
|
2133
|
+
applySwing(kept, project.swing);
|
|
2134
|
+
applyHumanize(kept, project.humanize, hashSeed(project.seed, scene.id), pass);
|
|
2135
|
+
for (const e of kept) e.time += offset;
|
|
2136
|
+
const flats = prefersFlats(project.key);
|
|
2137
|
+
const chords = placed.map((p) => ({
|
|
2138
|
+
time: p.start + offset,
|
|
2139
|
+
duration: p.beats,
|
|
2140
|
+
symbol: chordSymbol(p.chord, project.key, flats),
|
|
2141
|
+
rootPc: p.rootPc,
|
|
2142
|
+
pcs: chordPcs(p.rootPc, p.chord.quality)
|
|
2143
|
+
}));
|
|
2144
|
+
return {
|
|
2145
|
+
events: kept.sort((a, b) => a.time - b.time || a.midi - b.midi),
|
|
2146
|
+
chords
|
|
2147
|
+
};
|
|
2148
|
+
}
|
|
2149
|
+
/** One scene on its own, the way the canvas loops it. */
|
|
2150
|
+
function buildSceneScore(project, sceneId) {
|
|
2151
|
+
const scene = findScene(project, sceneId);
|
|
2152
|
+
const { events, chords } = sceneEvents(project, scene);
|
|
2153
|
+
return {
|
|
2154
|
+
bpm: project.bpm,
|
|
2155
|
+
beats: scene.bars * 4,
|
|
2156
|
+
events,
|
|
2157
|
+
chords,
|
|
2158
|
+
sections: [{
|
|
2159
|
+
sceneId: scene.id,
|
|
2160
|
+
name: scene.name,
|
|
2161
|
+
start: 0,
|
|
2162
|
+
beats: scene.bars * 4
|
|
2163
|
+
}]
|
|
2164
|
+
};
|
|
2165
|
+
}
|
|
2166
|
+
/** The whole song: every arrangement entry, repeats included. */
|
|
2167
|
+
function buildArrangementScore(project) {
|
|
2168
|
+
const carry = newCarry();
|
|
2169
|
+
const score = {
|
|
2170
|
+
bpm: project.bpm,
|
|
2171
|
+
beats: 0,
|
|
2172
|
+
events: [],
|
|
2173
|
+
chords: [],
|
|
2174
|
+
sections: []
|
|
2175
|
+
};
|
|
2176
|
+
for (const item of project.arrangement) {
|
|
2177
|
+
const scene = findScene(project, item.sceneId);
|
|
2178
|
+
for (let pass = 0; pass < item.repeat; pass++) {
|
|
2179
|
+
const { events, chords } = sceneEvents(project, scene, {
|
|
2180
|
+
offset: score.beats,
|
|
2181
|
+
pass,
|
|
2182
|
+
carry
|
|
2183
|
+
});
|
|
2184
|
+
score.events.push(...events);
|
|
2185
|
+
score.chords.push(...chords);
|
|
2186
|
+
score.sections.push({
|
|
2187
|
+
sceneId: scene.id,
|
|
2188
|
+
name: scene.name,
|
|
2189
|
+
start: score.beats,
|
|
2190
|
+
beats: scene.bars * 4
|
|
2191
|
+
});
|
|
2192
|
+
score.beats += scene.bars * 4;
|
|
2193
|
+
}
|
|
2194
|
+
}
|
|
2195
|
+
return score;
|
|
2196
|
+
}
|
|
2197
|
+
// ../../packages/core/src/describe.ts
|
|
2198
|
+
const pct = (v) => `${Math.round(v * 100)}%`;
|
|
2199
|
+
/** A compact piano roll: one row per pitch used, one column per eighth note, "|" between bars. */
|
|
2200
|
+
function pianoRoll(events, beats, flats, maxRows = 14) {
|
|
2201
|
+
if (events.length === 0) return ["(empty)"];
|
|
2202
|
+
const cols = Math.ceil(beats * 2);
|
|
2203
|
+
const pitches = [...new Set(events.map((e) => e.midi))].sort((a, b) => b - a);
|
|
2204
|
+
const shown = pitches.length > maxRows ? pitches.filter((_, i) => i % Math.ceil(pitches.length / maxRows) === 0) : pitches;
|
|
2205
|
+
const width = Math.max(...shown.map((p) => noteName(p, flats).length));
|
|
2206
|
+
return shown.map((p) => {
|
|
2207
|
+
const row = Array.from({ length: cols }, () => ".");
|
|
2208
|
+
for (const e of events) {
|
|
2209
|
+
if (e.midi !== p) continue;
|
|
2210
|
+
const start = Math.min(cols - 1, Math.round(e.time * 2));
|
|
2211
|
+
const end = Math.min(cols, Math.max(start + 1, Math.round((e.time + e.duration) * 2)));
|
|
2212
|
+
row[start] = "o";
|
|
2213
|
+
for (let c = start + 1; c < end; c++) if (row[c] === ".") row[c] = "-";
|
|
2214
|
+
}
|
|
2215
|
+
let line = "";
|
|
2216
|
+
for (let c = 0; c < cols; c++) line += (c % 8 === 0 ? "|" : "") + row[c];
|
|
2217
|
+
return `${noteName(p, flats).padStart(width)} ${line}|`;
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
function describeProject(project, sceneId) {
|
|
2221
|
+
const flats = prefersFlats(project.key);
|
|
2222
|
+
const scene = findScene(project, sceneId);
|
|
2223
|
+
const score = buildSceneScore(project, scene.id);
|
|
2224
|
+
const lines = [];
|
|
2225
|
+
lines.push(`${project.name} · ${keyLabel(project.key)} · ${project.bpm} BPM · swing ${pct(project.swing)} · humanize ${pct(project.humanize)}`);
|
|
2226
|
+
lines.push(`Mood: ${project.mood ?? "custom"} · window: ${project.window}`);
|
|
2227
|
+
const arrangement = project.arrangement.map((a) => `${project.scenes.find((s) => s.id === a.sceneId)?.name ?? a.sceneId}${a.repeat > 1 ? `×${a.repeat}` : ""}`).join(" → ");
|
|
2228
|
+
lines.push(`Scenes: ${project.scenes.map((s) => `${s.name} [${s.id}] ${s.bars} bars`).join(", ")}`);
|
|
2229
|
+
lines.push(`Arrangement: ${arrangement}`);
|
|
2230
|
+
const mix = project.mix;
|
|
2231
|
+
lines.push(`Mix: master ${pct(mix.master)}, warmth ${pct(mix.warmth)}, crackle ${pct(mix.crackle)}, reverb ${pct(mix.reverb)}, wobble ${pct(mix.wobble)}, rain ${mix.rain.enabled ? `${mix.rain.kind} ${pct(mix.rain.level)}` : "off"}`);
|
|
2232
|
+
lines.push("");
|
|
2233
|
+
lines.push(`Scene ${scene.name} (${scene.bars} bars, ${scene.bars * 4} beats). Canvas x 0→1 spans the whole scene, y 0 is the top (high notes).`);
|
|
2234
|
+
lines.push(`Chords: ${progressionText(scene.progression, project.key)}`);
|
|
2235
|
+
const b = scene.backing;
|
|
2236
|
+
lines.push(`Backing: chords ${b.chords.enabled ? `${b.chords.instrument} ${b.chords.style}` : "off"} · bass ${b.bass.enabled ? b.bass.style : "off"} · drums ${b.drums.enabled ? b.drums.groove : "off"}`);
|
|
2237
|
+
for (const layer of scene.layers) {
|
|
2238
|
+
const notes = score.events.filter((e) => e.track === layer.id);
|
|
2239
|
+
const flags = [layer.muted && "muted", layer.solo && "solo"].filter(Boolean).join(", ");
|
|
2240
|
+
const brushes = [...new Set(layer.strokes.map((s) => BRUSHES[s.brush].label.toLowerCase()))].join("/");
|
|
2241
|
+
lines.push("");
|
|
2242
|
+
lines.push(`Layer "${layer.name}" [${layer.id}] ${INSTRUMENTS[layer.instrument].label}, harmony ${layer.harmony}, octave ${layer.octave >= 0 ? "+" : ""}${layer.octave}, volume ${pct(layer.volume)}${flags ? ` (${flags})` : ""}`);
|
|
2243
|
+
if (layer.strokes.length === 0) {
|
|
2244
|
+
lines.push(" No strokes yet.");
|
|
2245
|
+
continue;
|
|
2246
|
+
}
|
|
2247
|
+
const range = notes.length ? `${noteName(Math.min(...notes.map((n) => n.midi)), flats)} to ${noteName(Math.max(...notes.map((n) => n.midi)), flats)}` : "none";
|
|
2248
|
+
lines.push(` ${layer.strokes.length} strokes (${brushes}), ${notes.length} notes, range ${range}`);
|
|
2249
|
+
if (INSTRUMENTS[layer.instrument].kind === "drums") {
|
|
2250
|
+
const counts = /* @__PURE__ */ new Map();
|
|
2251
|
+
for (const n of notes) counts.set(n.drum ?? "?", (counts.get(n.drum ?? "?") ?? 0) + 1);
|
|
2252
|
+
lines.push(` Hits: ${[...counts].map(([k, v]) => `${k} ${v}`).join(", ")}`);
|
|
2253
|
+
} else {
|
|
2254
|
+
const gridded = notes.map((n) => ({
|
|
2255
|
+
...n,
|
|
2256
|
+
time: Math.round(n.time * 4) / 4
|
|
2257
|
+
}));
|
|
2258
|
+
for (const row of pianoRoll(gridded, scene.bars * 4, flats)) lines.push(` ${row}`);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
return lines.join("\n");
|
|
2262
|
+
}
|
|
2263
|
+
// ../../packages/core/src/midi.ts
|
|
2264
|
+
const Midi = midiModule.Midi ?? midiModule.default.Midi;
|
|
2265
|
+
function trackName(project, track) {
|
|
2266
|
+
if (track === "backing:chords") return "Chords";
|
|
2267
|
+
if (track === "backing:bass") return "Bass";
|
|
2268
|
+
if (track === "backing:drums") return "Drums";
|
|
2269
|
+
for (const scene of project.scenes) {
|
|
2270
|
+
const layer = scene.layers.find((l) => l.id === track);
|
|
2271
|
+
if (layer) return layer.name;
|
|
2272
|
+
}
|
|
2273
|
+
return track;
|
|
2274
|
+
}
|
|
2275
|
+
/** Volume per track, so the MIDI file roughly matches the mix. */
|
|
2276
|
+
function trackVolume(project, track) {
|
|
2277
|
+
const scene = project.scenes.find((s) => s.layers.some((l) => l.id === track)) ?? project.scenes[0];
|
|
2278
|
+
if (track === "backing:chords") return scene.backing.chords.volume;
|
|
2279
|
+
if (track === "backing:bass") return scene.backing.bass.volume;
|
|
2280
|
+
if (track === "backing:drums") return scene.backing.drums.volume;
|
|
2281
|
+
return scene.layers.find((l) => l.id === track)?.volume ?? .8;
|
|
2282
|
+
}
|
|
2283
|
+
function scoreToMidi(project, score) {
|
|
2284
|
+
const midi = new Midi();
|
|
2285
|
+
midi.name = project.name;
|
|
2286
|
+
midi.header.setTempo(score.bpm);
|
|
2287
|
+
const ppq = midi.header.ppq;
|
|
2288
|
+
const tracks = /* @__PURE__ */ new Map();
|
|
2289
|
+
let nextChannel = 0;
|
|
2290
|
+
for (const e of score.events) {
|
|
2291
|
+
let track = tracks.get(e.track);
|
|
2292
|
+
if (!track) {
|
|
2293
|
+
track = midi.addTrack();
|
|
2294
|
+
track.name = trackName(project, e.track);
|
|
2295
|
+
if (e.instrument === "drums") track.channel = 9;
|
|
2296
|
+
else {
|
|
2297
|
+
track.channel = nextChannel === 9 ? ++nextChannel : nextChannel;
|
|
2298
|
+
nextChannel = (track.channel + 1) % 16;
|
|
2299
|
+
track.instrument.number = INSTRUMENTS[e.instrument].gm;
|
|
2300
|
+
}
|
|
2301
|
+
tracks.set(e.track, track);
|
|
2302
|
+
}
|
|
2303
|
+
const velocity = Math.max(.04, Math.min(1, e.velocity * (.4 + .6 * trackVolume(project, e.track))));
|
|
2304
|
+
track.addNote({
|
|
2305
|
+
midi: e.midi,
|
|
2306
|
+
ticks: Math.round(e.time * ppq),
|
|
2307
|
+
durationTicks: Math.max(1, Math.round(e.duration * ppq)),
|
|
2308
|
+
velocity
|
|
2309
|
+
});
|
|
2310
|
+
}
|
|
2311
|
+
return midi.toArray();
|
|
2312
|
+
}
|
|
2313
|
+
export { PROGRESSIONS as A, GROOVE_IDS as C, RAIN_KINDS as D, INSTRUMENT_IDS as E, prefersFlats as F, scalePcs as I, mod12 as L, resolveProgression as M, MODES as N, WINDOW_SCENES as O, keyLabel as P, noteName as R, DRUM_PIECE_IDS as S, INSTRUMENTS as T, BASS_STYLES as _, layerRange as a, COMP_STYLES as b, createProject as c, findScene as d, parseProject as f, getMood as g, MOOD_IDS as h, buildSceneScore as i, progressionText as j, drumRows as k, createScene as l, makeId as m, describeProject as n, pitchToY as o, createRng as p, buildArrangementScore as r, createLayer as s, scoreToMidi as t, findLayer as u, BRUSH_IDS as v, HARMONY_MODES as w, DRUM_KITS as x, CHORD_INSTRUMENTS as y, parseNote as z };
|