@hraness/dawg 0.3.0 → 0.4.1
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/CHANGELOG.md +138 -0
- package/DAWG.md +608 -39
- package/README.md +4 -4
- package/core/chords.ts +1724 -0
- package/core/diff.ts +13 -1
- package/core/euclid.ts +670 -0
- package/core/fx.ts +1065 -0
- package/core/kits.ts +320 -0
- package/core/params.ts +111 -0
- package/core/rhythm.ts +287 -0
- package/core/score.ts +735 -18
- package/core/sdk/eval-child.ts +34 -22
- package/core/sdk/eval.ts +4 -1
- package/core/sdk/print.ts +304 -22
- package/core/sdk/sync-chords.ts +54 -0
- package/core/sdk/v1.ts +3226 -21
- package/core/synth.ts +1001 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +38 -3
- package/src/agent/brief.ts +29 -4
- package/src/agent/chord-tools.ts +357 -0
- package/src/agent/drum-tools.ts +135 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/pack-tools.ts +369 -0
- package/src/agent/planner.ts +17 -2
- package/src/agent/preview-tool.ts +270 -0
- package/src/agent/rhythm-tools.ts +145 -0
- package/src/agent/tools.ts +308 -14
- package/src/agent/xcb-agent.ts +8 -2
- package/src/audio/audition.ts +93 -0
- package/src/audio/cache.ts +160 -0
- package/src/audio/effects/bus.ts +147 -0
- package/src/audio/effects/chain.ts +109 -0
- package/src/audio/effects/common.ts +251 -0
- package/src/audio/effects/convolution.ts +339 -0
- package/src/audio/effects/drive.ts +142 -0
- package/src/audio/effects/duck.ts +122 -0
- package/src/audio/effects/dynamics.ts +121 -0
- package/src/audio/effects/filter.ts +319 -0
- package/src/audio/effects/modulation.ts +174 -0
- package/src/audio/effects/space.ts +394 -0
- package/src/audio/engine.ts +112 -4
- package/src/audio/kits.ts +200 -0
- package/src/audio/packs.ts +1787 -0
- package/src/audio/preview.ts +481 -0
- package/src/audio/random.ts +15 -0
- package/src/audio/sampler.ts +157 -29
- package/src/audio/samples.ts +386 -44
- package/src/audio/synth/oscillators.ts +268 -0
- package/src/audio/synth/voice.ts +555 -0
- package/src/audio/synth/zzfx.ts +137 -0
- package/src/audio/wav.ts +411 -295
- package/src/audio/wavetable-maker.ts +717 -0
- package/src/audio/wavetable.ts +624 -0
- package/src/commands/drums.ts +299 -0
- package/src/commands/edit.ts +26 -4
- package/src/commands/fx.ts +360 -0
- package/src/commands/help.ts +298 -10
- package/src/commands/music.ts +10 -8
- package/src/commands/pack.ts +423 -0
- package/src/commands/rhythm.ts +230 -0
- package/src/commands/sample.ts +162 -2
- package/src/commands/synth.ts +229 -0
- package/src/commands/wavetable.ts +370 -0
- package/src/main.ts +1222 -30
- package/src/media/cli.ts +15 -2
- package/src/media/tools.ts +106 -1
- package/src/media/wavetable.ts +202 -0
- package/src/render.ts +22 -1
- package/src/session/naming.ts +3 -1
- package/src/tui/audition.ts +521 -0
- package/src/tui/euclid.ts +516 -0
- package/src/tui/menu.ts +1305 -244
- package/src/tui/play-chords.ts +630 -0
- package/src/tui/play-session.ts +364 -16
- package/src/tui/sketch.ts +108 -0
- package/src/web/search.ts +4 -1
- package/tui/app.ts +161 -39
- package/tui/grammar.ts +286 -0
- package/tui/highway.ts +7 -1
- package/tui/play-strip.ts +59 -14
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chord mode for play mode: the Orchid's left hand on the number row.
|
|
3
|
+
*
|
|
4
|
+
* auto every note key plays the chord that fits the song key (Orchid
|
|
5
|
+
* Key mode: in C major, D plays Dm). The default on chord-capable
|
|
6
|
+
* tracks.
|
|
7
|
+
* manual note keys play single notes, as before; latch a chord type or an
|
|
8
|
+
* extension and they play that chord on the pressed root (Orchid
|
|
9
|
+
* without Key mode).
|
|
10
|
+
* off plain play mode; the chord keys below are free again.
|
|
11
|
+
*
|
|
12
|
+
* Terminals send key-down only, so the Orchid's held buttons become latches:
|
|
13
|
+
*
|
|
14
|
+
* 1 2 3 4 dim min maj sus toggle a chord type (two make a combined
|
|
15
|
+
* 5 6 7 8 6 m7 M7 9 chord); toggle extensions (any number)
|
|
16
|
+
* 0 clear the latches - / = voicing dial down / up
|
|
17
|
+
* 9 next perform mode b next bass mode (off, chords,
|
|
18
|
+
* unison, single, solo)
|
|
19
|
+
* n play the suggested next chord (dawg's progression engine;
|
|
20
|
+
* the session routes it through the root's note key)
|
|
21
|
+
* q auto ⇄ manual
|
|
22
|
+
*
|
|
23
|
+
* Pure: the session asks for a chord per note key and owns sound and time.
|
|
24
|
+
*/
|
|
25
|
+
import {
|
|
26
|
+
BASS_MODES,
|
|
27
|
+
CHORD_PATTERNS,
|
|
28
|
+
DEFAULT_ARP_RATE,
|
|
29
|
+
DEFAULT_STRUM,
|
|
30
|
+
generateProgression,
|
|
31
|
+
presetChords,
|
|
32
|
+
renderProgression,
|
|
33
|
+
CHORD_TYPES,
|
|
34
|
+
EXTENSIONS,
|
|
35
|
+
MAX_VOICING_STEP,
|
|
36
|
+
PERFORM_MODES,
|
|
37
|
+
PROGRESSION_PRESETS,
|
|
38
|
+
PROGRESSION_STYLES,
|
|
39
|
+
SPREADS,
|
|
40
|
+
chordName,
|
|
41
|
+
findChordPattern,
|
|
42
|
+
makeChord,
|
|
43
|
+
noteName,
|
|
44
|
+
parseBassMode,
|
|
45
|
+
routeBass,
|
|
46
|
+
type BassMode,
|
|
47
|
+
findPreset,
|
|
48
|
+
keyModeChord,
|
|
49
|
+
keyName,
|
|
50
|
+
keyUsesFlats,
|
|
51
|
+
manualChord,
|
|
52
|
+
parseKey,
|
|
53
|
+
romanOf,
|
|
54
|
+
suggestNext,
|
|
55
|
+
voiceChord,
|
|
56
|
+
type Chord,
|
|
57
|
+
type ChordType,
|
|
58
|
+
type Extension,
|
|
59
|
+
type Key,
|
|
60
|
+
type PerformMode,
|
|
61
|
+
type ProgressionStyle,
|
|
62
|
+
type Spread,
|
|
63
|
+
} from "../../core/chords.ts";
|
|
64
|
+
import {
|
|
65
|
+
isSamplerInstrument,
|
|
66
|
+
type NoteInput,
|
|
67
|
+
type Track,
|
|
68
|
+
type TrackScore,
|
|
69
|
+
} from "../../core/score.ts";
|
|
70
|
+
|
|
71
|
+
export const CHORD_MODES = ["auto", "manual", "off"] as const;
|
|
72
|
+
export type ChordMode = (typeof CHORD_MODES)[number];
|
|
73
|
+
|
|
74
|
+
/** Arp rates: `grid` follows play mode's grid; the rest are beats. */
|
|
75
|
+
export const ARP_RATES = ["grid", "1/4", "1/8", "1/16", "1/32"] as const;
|
|
76
|
+
export type ArpRate = (typeof ARP_RATES)[number];
|
|
77
|
+
const RATE_BEATS: Readonly<Record<Exclude<ArpRate, "grid">, number>> = {
|
|
78
|
+
"1/4": 1,
|
|
79
|
+
"1/8": 0.5,
|
|
80
|
+
"1/16": 0.25,
|
|
81
|
+
"1/32": 0.125,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Hand-editable chord settings (`/chords …`, `/menu chords`). */
|
|
85
|
+
export type ChordSettings = {
|
|
86
|
+
mode: ChordMode;
|
|
87
|
+
/** True once the mode was chosen, so a track switch keeps it. */
|
|
88
|
+
explicit: boolean;
|
|
89
|
+
inversion: number;
|
|
90
|
+
spread: Spread;
|
|
91
|
+
/** Orchid bass behaviour (core/chords.ts BASS_MODES). */
|
|
92
|
+
bass: BassMode;
|
|
93
|
+
perform: PerformMode;
|
|
94
|
+
/** CHORD_PATTERNS name for the `pattern` perform mode. */
|
|
95
|
+
pattern: string;
|
|
96
|
+
rate: ArpRate;
|
|
97
|
+
octaves: number;
|
|
98
|
+
sevenths: boolean;
|
|
99
|
+
/** `none` or a PROGRESSION_PRESETS name; drives the "next" suggestion. */
|
|
100
|
+
preset: string;
|
|
101
|
+
style: ProgressionStyle;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function defaultChordSettings(): ChordSettings {
|
|
105
|
+
return {
|
|
106
|
+
mode: "manual",
|
|
107
|
+
explicit: false,
|
|
108
|
+
inversion: 0,
|
|
109
|
+
spread: "close",
|
|
110
|
+
bass: "off",
|
|
111
|
+
perform: "block",
|
|
112
|
+
pattern: CHORD_PATTERNS[0]!.name,
|
|
113
|
+
rate: "grid",
|
|
114
|
+
octaves: 1,
|
|
115
|
+
sevenths: false,
|
|
116
|
+
preset: "none",
|
|
117
|
+
style: "pop",
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Tracks where auto chords are the default: anything pitched that is not a
|
|
123
|
+
* bass, a drum kit or a one-shot sampler.
|
|
124
|
+
*/
|
|
125
|
+
export function chordCapable(track: Track | undefined): boolean {
|
|
126
|
+
if (!track) return false;
|
|
127
|
+
if (isSamplerInstrument(track.instrument))
|
|
128
|
+
return track.sampler?.mode === "keyed";
|
|
129
|
+
const names = `${track.instrument} ${track.name} ${track.id}`.toLowerCase();
|
|
130
|
+
return !/bass|kit|drum|perc|808|kick|snare|hat/.test(names);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The song key, or C major when the score has none. */
|
|
134
|
+
export function songKey(text: string | null | undefined): {
|
|
135
|
+
key: Key;
|
|
136
|
+
set: boolean;
|
|
137
|
+
} {
|
|
138
|
+
const key = parseKey(text ?? undefined);
|
|
139
|
+
return key
|
|
140
|
+
? { key, set: true }
|
|
141
|
+
: { key: { tonic: 0, mode: "major" }, set: false };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const TYPE_KEYS: Readonly<Record<string, ChordType>> = {
|
|
145
|
+
"1": "dim",
|
|
146
|
+
"2": "min",
|
|
147
|
+
"3": "maj",
|
|
148
|
+
"4": "sus",
|
|
149
|
+
};
|
|
150
|
+
const EXTENSION_KEYS: Readonly<Record<string, Extension>> = {
|
|
151
|
+
"5": "6",
|
|
152
|
+
"6": "m7",
|
|
153
|
+
"7": "M7",
|
|
154
|
+
"8": "9",
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
/** Keys chord mode claims (all unused by the note layout). */
|
|
158
|
+
export const CHORD_KEYS: readonly string[] = Object.freeze([
|
|
159
|
+
..."1234567890",
|
|
160
|
+
"-",
|
|
161
|
+
"=",
|
|
162
|
+
"b",
|
|
163
|
+
"n",
|
|
164
|
+
"q",
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
export type PadResult =
|
|
168
|
+
/** A setting changed; show `status`. */
|
|
169
|
+
{ type: "status"; status: string } | { type: "pass" };
|
|
170
|
+
|
|
171
|
+
/** A chord as play mode sounds it. */
|
|
172
|
+
export type PlayedChord = Readonly<{
|
|
173
|
+
chord: Chord;
|
|
174
|
+
name: string;
|
|
175
|
+
/** Voiced treble pitches, low to high. */
|
|
176
|
+
pitches: readonly number[];
|
|
177
|
+
bass: number | undefined;
|
|
178
|
+
}>;
|
|
179
|
+
|
|
180
|
+
export class ChordPad {
|
|
181
|
+
public readonly types = new Set<ChordType>();
|
|
182
|
+
public readonly extensions = new Set<Extension>();
|
|
183
|
+
public last: PlayedChord | undefined;
|
|
184
|
+
/** Forced chord for the next note (from `n`). */
|
|
185
|
+
private forced: Chord | undefined;
|
|
186
|
+
|
|
187
|
+
public constructor(
|
|
188
|
+
public settings: ChordSettings,
|
|
189
|
+
private readonly keyText: () => string | null | undefined,
|
|
190
|
+
) {}
|
|
191
|
+
|
|
192
|
+
public get key(): Key {
|
|
193
|
+
return songKey(this.keyText()).key;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
public get on(): boolean {
|
|
197
|
+
return this.settings.mode !== "off";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Handle a chord key; `pass` for anything else. */
|
|
201
|
+
public press(value: string): PadResult {
|
|
202
|
+
const s = this.settings;
|
|
203
|
+
if (value === "q" && s.mode !== "off") {
|
|
204
|
+
s.mode = s.mode === "auto" ? "manual" : "auto";
|
|
205
|
+
s.explicit = true;
|
|
206
|
+
return { type: "status", status: `chords ${s.mode}` };
|
|
207
|
+
}
|
|
208
|
+
if (!this.on) return { type: "pass" };
|
|
209
|
+
const type = TYPE_KEYS[value];
|
|
210
|
+
if (type) {
|
|
211
|
+
if (this.types.has(type)) this.types.delete(type);
|
|
212
|
+
else this.types.add(type);
|
|
213
|
+
return { type: "status", status: `chord ${this.latchText() || "clear"}` };
|
|
214
|
+
}
|
|
215
|
+
const extension = EXTENSION_KEYS[value];
|
|
216
|
+
if (extension) {
|
|
217
|
+
if (this.extensions.has(extension)) this.extensions.delete(extension);
|
|
218
|
+
else this.extensions.add(extension);
|
|
219
|
+
return { type: "status", status: `chord ${this.latchText() || "clear"}` };
|
|
220
|
+
}
|
|
221
|
+
if (value === "0") {
|
|
222
|
+
this.types.clear();
|
|
223
|
+
this.extensions.clear();
|
|
224
|
+
return { type: "status", status: "chord latches clear" };
|
|
225
|
+
}
|
|
226
|
+
if (value === "-" || value === "=") {
|
|
227
|
+
s.inversion = Math.max(
|
|
228
|
+
-MAX_VOICING_STEP,
|
|
229
|
+
Math.min(MAX_VOICING_STEP, s.inversion + (value === "=" ? 1 : -1)),
|
|
230
|
+
);
|
|
231
|
+
return { type: "status", status: `voicing ${signed(s.inversion)}` };
|
|
232
|
+
}
|
|
233
|
+
if (value === "9") {
|
|
234
|
+
const index = PERFORM_MODES.indexOf(s.perform);
|
|
235
|
+
s.perform = PERFORM_MODES[(index + 1) % PERFORM_MODES.length]!;
|
|
236
|
+
return { type: "status", status: `perform ${s.perform}` };
|
|
237
|
+
}
|
|
238
|
+
if (value === "b") {
|
|
239
|
+
const index = BASS_MODES.indexOf(s.bass);
|
|
240
|
+
s.bass = BASS_MODES[(index + 1) % BASS_MODES.length]!;
|
|
241
|
+
return { type: "status", status: `bass ${s.bass}` };
|
|
242
|
+
}
|
|
243
|
+
return { type: "pass" };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** `min+m7`: the latched buttons. */
|
|
247
|
+
public latchText(): string {
|
|
248
|
+
return [
|
|
249
|
+
...CHORD_TYPES.filter((type) => this.types.has(type)),
|
|
250
|
+
...EXTENSIONS.filter((extension) => this.extensions.has(extension)),
|
|
251
|
+
].join("+");
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Strip label for a note key: the chord it plays in auto mode with no
|
|
256
|
+
* latches (Orchid's display names the chord), else undefined.
|
|
257
|
+
*/
|
|
258
|
+
public keyLabel(pitch: number): string | undefined {
|
|
259
|
+
if (this.settings.mode !== "auto") return undefined;
|
|
260
|
+
if (this.types.size > 0 || this.extensions.size > 0) return undefined;
|
|
261
|
+
const chord = keyModeChord(this.key, pitch, {
|
|
262
|
+
sevenths: this.settings.sevenths,
|
|
263
|
+
});
|
|
264
|
+
return chordName(chord, keyUsesFlats(this.key));
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Make the next note key play `chord` (the `n` key). */
|
|
268
|
+
public force(chord: Chord): void {
|
|
269
|
+
this.forced = chord;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The chord a note key plays, or undefined for a single note. */
|
|
273
|
+
public chordFor(pitch: number): Chord | undefined {
|
|
274
|
+
const forced = this.forced;
|
|
275
|
+
this.forced = undefined;
|
|
276
|
+
if (forced) return forced;
|
|
277
|
+
if (this.settings.mode === "auto")
|
|
278
|
+
return keyModeChord(this.key, pitch, {
|
|
279
|
+
types: this.types,
|
|
280
|
+
extensions: this.extensions,
|
|
281
|
+
sevenths: this.settings.sevenths,
|
|
282
|
+
});
|
|
283
|
+
if (this.settings.mode === "manual")
|
|
284
|
+
return manualChord(pitch, this.types, this.extensions);
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Voice a chord near the pressed key: voice-led from the last chord when
|
|
290
|
+
* there is one, otherwise root position from the pressed octave, rotated
|
|
291
|
+
* by the dial. Becomes the new "last" chord.
|
|
292
|
+
*/
|
|
293
|
+
public voice(chord: Chord, pitch: number): PlayedChord {
|
|
294
|
+
const anchor = Math.max(24, Math.min(96, pitch - mod12(pitch)));
|
|
295
|
+
const pitches = voiceChord(chord, {
|
|
296
|
+
inversion: this.settings.inversion,
|
|
297
|
+
spread: this.settings.spread,
|
|
298
|
+
previous: this.last?.pitches,
|
|
299
|
+
anchor,
|
|
300
|
+
low: anchor - 12,
|
|
301
|
+
high: anchor + 19,
|
|
302
|
+
});
|
|
303
|
+
// Bass two octaves under the pressed octave (C2–B2 from C4).
|
|
304
|
+
const route = routeBass(this.settings.bass, chord, pitch, anchor - 24);
|
|
305
|
+
const voiced: PlayedChord = {
|
|
306
|
+
chord,
|
|
307
|
+
name: chordName(chord, keyUsesFlats(this.key)),
|
|
308
|
+
pitches,
|
|
309
|
+
bass: route.bass,
|
|
310
|
+
};
|
|
311
|
+
// Solo mutes the treble but keeps voice leading on the full voicing.
|
|
312
|
+
this.last = voiced;
|
|
313
|
+
return route.treble ? voiced : { ...voiced, pitches: [] };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A single note (manual mode, no latch) routed through the bass mode:
|
|
318
|
+
* undefined when it plays as a plain note (bass off or chords-only),
|
|
319
|
+
* else the treble note (unison) or none (single, solo) plus its bass.
|
|
320
|
+
*/
|
|
321
|
+
public single(pitch: number): PlayedChord | undefined {
|
|
322
|
+
if (this.settings.mode === "off") return undefined;
|
|
323
|
+
const anchor = Math.max(24, Math.min(96, pitch - mod12(pitch)));
|
|
324
|
+
const route = routeBass(this.settings.bass, undefined, pitch, anchor - 24);
|
|
325
|
+
if (route.bass === undefined) return undefined;
|
|
326
|
+
return {
|
|
327
|
+
chord: makeChord(mod12(pitch), "maj"),
|
|
328
|
+
name: noteName(mod12(pitch), keyUsesFlats(this.key)),
|
|
329
|
+
pitches: route.treble ? [pitch] : [],
|
|
330
|
+
bass: route.bass,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** The chord the progression engine suggests after the last one. */
|
|
335
|
+
public next(): Chord {
|
|
336
|
+
const preset = this.settings.preset;
|
|
337
|
+
return suggestNext(this.key, this.last?.chord, {
|
|
338
|
+
...(preset !== "none" && findPreset(preset) ? { preset } : {}),
|
|
339
|
+
style: this.settings.style,
|
|
340
|
+
sevenths: this.settings.sevenths,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Arp step in beats for `gridBeats`. */
|
|
345
|
+
public rateBeats(gridBeats: number): number {
|
|
346
|
+
const rate = this.settings.rate;
|
|
347
|
+
return rate === "grid" ? gridBeats : RATE_BEATS[rate];
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** The header at a glance: mode, key, last chord, next, latches. */
|
|
351
|
+
public glance(keySet: boolean): string {
|
|
352
|
+
const s = this.settings;
|
|
353
|
+
if (s.mode === "off") return "";
|
|
354
|
+
const key = this.key;
|
|
355
|
+
const flats = keyUsesFlats(key);
|
|
356
|
+
const parts = [
|
|
357
|
+
`${s.mode.toUpperCase()} ${keyName(key)}${keySet ? "" : " (assumed)"}`,
|
|
358
|
+
];
|
|
359
|
+
if (this.last)
|
|
360
|
+
parts.push(`${this.last.name} (${romanOf(key, this.last.chord)})`);
|
|
361
|
+
parts.push(`next ${chordName(this.next(), flats)}`);
|
|
362
|
+
return parts.join(" · ");
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The number-row legend, in key order, with latches marked. */
|
|
366
|
+
public legend(): {
|
|
367
|
+
key: string;
|
|
368
|
+
label: string;
|
|
369
|
+
on: boolean;
|
|
370
|
+
}[] {
|
|
371
|
+
const s = this.settings;
|
|
372
|
+
const types = Object.entries(TYPE_KEYS).map(([key, type]) => ({
|
|
373
|
+
key,
|
|
374
|
+
label: type,
|
|
375
|
+
on: this.types.has(type),
|
|
376
|
+
}));
|
|
377
|
+
const extensions = Object.entries(EXTENSION_KEYS).map(
|
|
378
|
+
([key, extension]) => ({
|
|
379
|
+
key,
|
|
380
|
+
label: extension,
|
|
381
|
+
on: this.extensions.has(extension),
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
const perform =
|
|
385
|
+
s.perform === "pattern"
|
|
386
|
+
? `pattern ${patternLabel(s.pattern)}`
|
|
387
|
+
: s.perform;
|
|
388
|
+
return [
|
|
389
|
+
...types,
|
|
390
|
+
...extensions,
|
|
391
|
+
{ key: "0", label: "clear", on: false },
|
|
392
|
+
{ key: "-=", label: `voicing ${signed(s.inversion)}`, on: false },
|
|
393
|
+
{ key: "9", label: perform, on: false },
|
|
394
|
+
{ key: "b", label: `bass ${s.bass}`, on: false },
|
|
395
|
+
{ key: "n", label: "next", on: false },
|
|
396
|
+
{ key: "q", label: s.mode === "auto" ? "auto" : "manual", on: false },
|
|
397
|
+
];
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* The full chord state for the `?` panel, in the header's words:
|
|
402
|
+
* `AUTO C major · Dm (ii) · next G · min+m7 · voicing +1 · arp-up · bass chords`.
|
|
403
|
+
*/
|
|
404
|
+
public headerText(keySet: boolean): string {
|
|
405
|
+
const s = this.settings;
|
|
406
|
+
if (s.mode === "off") return "";
|
|
407
|
+
const parts = [this.glance(keySet)];
|
|
408
|
+
const latches = this.latchText();
|
|
409
|
+
if (latches) parts.push(latches);
|
|
410
|
+
if (s.inversion !== 0) parts.push(`voicing ${signed(s.inversion)}`);
|
|
411
|
+
if (s.spread !== "close") parts.push(s.spread);
|
|
412
|
+
if (s.perform === "pattern")
|
|
413
|
+
parts.push(`pattern ${patternLabel(s.pattern)}`);
|
|
414
|
+
else if (s.perform !== "block") parts.push(s.perform);
|
|
415
|
+
if (s.bass !== "off") parts.push(`bass ${s.bass}`);
|
|
416
|
+
return parts.join(" · ");
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function mod12(value: number): number {
|
|
421
|
+
return ((value % 12) + 12) % 12;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** `3 offbeat`: a pattern's 1-based number and name. */
|
|
425
|
+
export function patternLabel(name: string): string {
|
|
426
|
+
const pattern = findChordPattern(name) ?? CHORD_PATTERNS[0]!;
|
|
427
|
+
return `${CHORD_PATTERNS.indexOf(pattern) + 1} ${pattern.name}`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function signed(value: number): string {
|
|
431
|
+
return value > 0 ? `+${value}` : String(value);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* `/chords …`: `auto|manual|off`, `voicing <-12..12>`, `spread <close|open|
|
|
436
|
+
* wide>`, `bass off|chords|unison|single|solo` (`on` = chords), `perform
|
|
437
|
+
* <mode>`, `pattern <1..13|name>`, `rate <grid|1/8…>`, `octaves
|
|
438
|
+
* <1..4>`, `sevenths on|off`, `preset <name|none>`, `style <pop|jazz|…>`.
|
|
439
|
+
* Mutates `settings`; returns the receipt or an error.
|
|
440
|
+
*/
|
|
441
|
+
export function applyChordsCommand(
|
|
442
|
+
settings: ChordSettings,
|
|
443
|
+
argument: string,
|
|
444
|
+
): { ok: boolean; message: string } {
|
|
445
|
+
const words = argument.trim().toLowerCase().split(/\s+/).filter(Boolean);
|
|
446
|
+
const [head, value] = words;
|
|
447
|
+
const done = (message: string) => ({ ok: true, message });
|
|
448
|
+
const bad = (message: string) => ({ ok: false, message });
|
|
449
|
+
if (words.length === 0) return done(chordsSummary(settings));
|
|
450
|
+
if ((CHORD_MODES as readonly string[]).includes(head!) && !value) {
|
|
451
|
+
settings.mode = head as ChordMode;
|
|
452
|
+
settings.explicit = true;
|
|
453
|
+
return done(`chords ${settings.mode}`);
|
|
454
|
+
}
|
|
455
|
+
const onOff = (text: string | undefined) =>
|
|
456
|
+
text === "on" || text === "true"
|
|
457
|
+
? true
|
|
458
|
+
: text === "off" || text === "false"
|
|
459
|
+
? false
|
|
460
|
+
: undefined;
|
|
461
|
+
switch (head) {
|
|
462
|
+
case "voicing":
|
|
463
|
+
case "inversion": {
|
|
464
|
+
const n = Number(value);
|
|
465
|
+
if (!Number.isInteger(n) || Math.abs(n) > MAX_VOICING_STEP)
|
|
466
|
+
return bad(`voicing takes -${MAX_VOICING_STEP}..${MAX_VOICING_STEP}`);
|
|
467
|
+
settings.inversion = n;
|
|
468
|
+
return done(`chords voicing ${signed(n)}`);
|
|
469
|
+
}
|
|
470
|
+
case "spread":
|
|
471
|
+
if (!(SPREADS as readonly string[]).includes(value ?? ""))
|
|
472
|
+
return bad(`spread takes ${SPREADS.join("|")}`);
|
|
473
|
+
settings.spread = value as Spread;
|
|
474
|
+
return done(`chords spread ${value}`);
|
|
475
|
+
case "bass": {
|
|
476
|
+
const mode = parseBassMode(value);
|
|
477
|
+
if (!mode) return bad(`bass takes ${BASS_MODES.join("|")}`);
|
|
478
|
+
settings.bass = mode;
|
|
479
|
+
return done(`chords bass ${mode}`);
|
|
480
|
+
}
|
|
481
|
+
case "pattern": {
|
|
482
|
+
const pattern = findChordPattern(value);
|
|
483
|
+
if (!pattern)
|
|
484
|
+
return bad(
|
|
485
|
+
`pattern takes 1..${CHORD_PATTERNS.length} or ${CHORD_PATTERNS.map((p) => p.name).join("|")}`,
|
|
486
|
+
);
|
|
487
|
+
settings.pattern = pattern.name;
|
|
488
|
+
settings.perform = "pattern";
|
|
489
|
+
return done(`chords pattern ${patternLabel(pattern.name)}`);
|
|
490
|
+
}
|
|
491
|
+
case "sevenths": {
|
|
492
|
+
const on = onOff(value);
|
|
493
|
+
if (on === undefined) return bad(`${head} takes on|off`);
|
|
494
|
+
settings[head] = on;
|
|
495
|
+
return done(`chords ${head} ${on ? "on" : "off"}`);
|
|
496
|
+
}
|
|
497
|
+
case "perform":
|
|
498
|
+
if (!(PERFORM_MODES as readonly string[]).includes(value ?? ""))
|
|
499
|
+
return bad(`perform takes ${PERFORM_MODES.join("|")}`);
|
|
500
|
+
settings.perform = value as PerformMode;
|
|
501
|
+
return done(`chords perform ${value}`);
|
|
502
|
+
case "rate":
|
|
503
|
+
if (!(ARP_RATES as readonly string[]).includes(value ?? ""))
|
|
504
|
+
return bad(`rate takes ${ARP_RATES.join("|")}`);
|
|
505
|
+
settings.rate = value as ArpRate;
|
|
506
|
+
return done(`chords rate ${value}`);
|
|
507
|
+
case "octaves": {
|
|
508
|
+
const n = Number(value);
|
|
509
|
+
if (!Number.isInteger(n) || n < 1 || n > 4)
|
|
510
|
+
return bad("octaves takes 1..4");
|
|
511
|
+
settings.octaves = n;
|
|
512
|
+
return done(`chords octaves ${n}`);
|
|
513
|
+
}
|
|
514
|
+
case "preset":
|
|
515
|
+
if (value !== "none" && !findPreset(value ?? ""))
|
|
516
|
+
return bad(
|
|
517
|
+
`preset takes none|${PROGRESSION_PRESETS.map((p) => p.name).join("|")}`,
|
|
518
|
+
);
|
|
519
|
+
settings.preset = value!;
|
|
520
|
+
return done(`chords preset ${value}`);
|
|
521
|
+
case "style":
|
|
522
|
+
if (!(PROGRESSION_STYLES as readonly string[]).includes(value ?? ""))
|
|
523
|
+
return bad(`style takes ${PROGRESSION_STYLES.join("|")}`);
|
|
524
|
+
settings.style = value as ProgressionStyle;
|
|
525
|
+
return done(`chords style ${value}`);
|
|
526
|
+
default:
|
|
527
|
+
return bad(
|
|
528
|
+
"/chords auto|manual|off · voicing · spread · bass · perform · pattern · rate · octaves · sevenths · preset · style",
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
export function chordsSummary(settings: ChordSettings): string {
|
|
534
|
+
const s = settings;
|
|
535
|
+
return [
|
|
536
|
+
`chords ${s.mode}`,
|
|
537
|
+
`voicing ${signed(s.inversion)}`,
|
|
538
|
+
s.spread,
|
|
539
|
+
s.perform,
|
|
540
|
+
`pattern ${patternLabel(s.pattern)}`,
|
|
541
|
+
`rate ${s.rate}`,
|
|
542
|
+
`octaves ${s.octaves}`,
|
|
543
|
+
`bass ${s.bass}`,
|
|
544
|
+
`sevenths ${s.sevenths ? "on" : "off"}`,
|
|
545
|
+
`preset ${s.preset}`,
|
|
546
|
+
`style ${s.style}`,
|
|
547
|
+
].join(" · ");
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/** Chords in the chord settings screen's audition phrase. */
|
|
551
|
+
const PHRASE_CHORDS = 4;
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* What the chord settings sound like, for the audition loop: the chosen
|
|
555
|
+
* progression (the preset, or four chords of the style's seeded walk) in the
|
|
556
|
+
* song key, voiced and performed with `settings` (voicing, spread, sevenths,
|
|
557
|
+
* perform mode, pattern, arp rate and octaves, bass mode), one chord per bar
|
|
558
|
+
* or two per bar in a one-bar loop. With chords `off` it plays each chord's
|
|
559
|
+
* root alone, as play mode would. Deterministic: the same settings and key
|
|
560
|
+
* always give the same notes.
|
|
561
|
+
*/
|
|
562
|
+
export function chordPhrase(
|
|
563
|
+
settings: ChordSettings,
|
|
564
|
+
score: TrackScore,
|
|
565
|
+
track: Track,
|
|
566
|
+
bars: number,
|
|
567
|
+
): NoteInput[] {
|
|
568
|
+
const key = parseKey(score.key) ?? { tonic: 0, mode: "major" as const };
|
|
569
|
+
const preset =
|
|
570
|
+
settings.preset === "none" ? undefined : findPreset(settings.preset);
|
|
571
|
+
const progression = preset
|
|
572
|
+
? presetChords(key, preset)
|
|
573
|
+
: generateProgression({
|
|
574
|
+
key,
|
|
575
|
+
length: PHRASE_CHORDS,
|
|
576
|
+
style: settings.style,
|
|
577
|
+
seed: 1,
|
|
578
|
+
sevenths: settings.sevenths,
|
|
579
|
+
});
|
|
580
|
+
const tpb = score.ticksPerBeat;
|
|
581
|
+
const totalBeats = Math.max(1, bars) * score.beatsPerBar;
|
|
582
|
+
const count = Math.max(2, Math.max(1, bars));
|
|
583
|
+
const span = totalBeats / count;
|
|
584
|
+
const chords = Array.from(
|
|
585
|
+
{ length: count },
|
|
586
|
+
(_, index) => progression[index % progression.length]!,
|
|
587
|
+
);
|
|
588
|
+
const mode = settings.perform;
|
|
589
|
+
const rendered = renderProgression({
|
|
590
|
+
key,
|
|
591
|
+
chords,
|
|
592
|
+
beatsPerChord: span,
|
|
593
|
+
inversion: settings.inversion,
|
|
594
|
+
spread: settings.spread,
|
|
595
|
+
bassMode: settings.mode === "off" ? "solo" : settings.bass,
|
|
596
|
+
perform: {
|
|
597
|
+
mode,
|
|
598
|
+
rate:
|
|
599
|
+
settings.rate === "grid" ? DEFAULT_ARP_RATE : RATE_BEATS[settings.rate],
|
|
600
|
+
octaves: settings.octaves,
|
|
601
|
+
strum: mode === "harp" ? DEFAULT_STRUM * 2 : DEFAULT_STRUM,
|
|
602
|
+
seed: 1,
|
|
603
|
+
velocity: 0.8,
|
|
604
|
+
...(mode === "pattern" ? { pattern: settings.pattern } : {}),
|
|
605
|
+
},
|
|
606
|
+
});
|
|
607
|
+
// With chords off a key plays one note: the root, in the chord's octave.
|
|
608
|
+
const performed =
|
|
609
|
+
settings.mode === "off"
|
|
610
|
+
? rendered.bass.map((note) => ({ ...note, pitch: note.pitch + 24 }))
|
|
611
|
+
: [...rendered.notes, ...rendered.bass];
|
|
612
|
+
const end = totalBeats * tpb;
|
|
613
|
+
const notes: NoteInput[] = [];
|
|
614
|
+
for (const note of performed) {
|
|
615
|
+
const startTick = Math.round(note.start * tpb);
|
|
616
|
+
if (startTick >= end) continue;
|
|
617
|
+
notes.push({
|
|
618
|
+
id: `chords-${notes.length + 1}`,
|
|
619
|
+
trackId: track.id,
|
|
620
|
+
startTick,
|
|
621
|
+
durationTicks: Math.max(
|
|
622
|
+
1,
|
|
623
|
+
Math.min(Math.round(note.length * tpb), end - startTick),
|
|
624
|
+
),
|
|
625
|
+
pitch: note.pitch,
|
|
626
|
+
velocity: note.velocity,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
return notes;
|
|
630
|
+
}
|