@hraness/dawg 0.5.0 → 0.6.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 +63 -0
- package/DAWG.md +592 -49
- package/README.md +4 -4
- package/core/chords.ts +492 -4
- package/core/diff.ts +9 -0
- package/core/expression.ts +143 -6
- package/core/fx.ts +689 -2
- package/core/granular.ts +619 -0
- package/core/instruments.ts +442 -0
- package/core/keys.ts +955 -0
- package/core/resonators.ts +862 -0
- package/core/score.ts +529 -8
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +247 -16
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +2417 -40
- package/core/sections.ts +44 -10
- package/core/strings.ts +1080 -0
- package/core/winds.ts +652 -0
- package/guides/automation.md +1 -0
- package/guides/chords.md +3 -1
- package/guides/effects.md +5 -2
- package/guides/media.md +1 -1
- package/guides/performance.md +3 -0
- package/guides/resample.md +26 -0
- package/guides/sounds.md +7 -2
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +13 -0
- package/src/agent/chord-tools.ts +153 -1
- package/src/agent/expression-tools.ts +91 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +42 -2
- package/src/agent/preview-tool.ts +7 -1
- package/src/agent/resample-tool.ts +131 -0
- package/src/agent/tools.ts +726 -17
- package/src/agent/xcb-agent.ts +13 -0
- package/src/audio/arrange.ts +42 -3
- package/src/audio/dsp/bank.ts +233 -0
- package/src/audio/dsp/envelope.ts +161 -0
- package/src/audio/dsp/fft.ts +6 -0
- package/src/audio/dsp/filters.ts +57 -0
- package/src/audio/dsp/interp.ts +112 -0
- package/src/audio/dsp/modal.ts +684 -0
- package/src/audio/dsp/onset.ts +197 -0
- package/src/audio/dsp/oversample.ts +202 -0
- package/src/audio/dsp/rng.ts +37 -0
- package/src/audio/dsp/shape.ts +81 -0
- package/src/audio/dsp/shift.ts +256 -0
- package/src/audio/dsp/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +23 -3
- package/src/audio/effects/common.ts +14 -0
- package/src/audio/effects/convolution.ts +106 -1
- package/src/audio/effects/gaze.ts +354 -0
- package/src/audio/effects/rig/cab.ts +99 -0
- package/src/audio/effects/rig/filters.ts +152 -0
- package/src/audio/effects/rig/gate.ts +39 -0
- package/src/audio/effects/rig/head.ts +487 -0
- package/src/audio/effects/rig/index.ts +170 -0
- package/src/audio/effects/rig/section.ts +78 -0
- package/src/audio/effects/rig/stomp.ts +238 -0
- package/src/audio/engine.ts +56 -6
- package/src/audio/fit.ts +447 -0
- package/src/audio/granular.ts +995 -0
- package/src/audio/instrument-check.ts +137 -0
- package/src/audio/instruments.ts +140 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/electric.ts +420 -0
- package/src/audio/keys/engine.ts +485 -0
- package/src/audio/keys/organ.ts +1335 -0
- package/src/audio/keys/piano.ts +476 -0
- package/src/audio/keys/sympathetic.ts +127 -0
- package/src/audio/live-worker.ts +56 -0
- package/src/audio/live.ts +361 -15
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +17 -1
- package/src/audio/resample.ts +285 -0
- package/src/audio/resonators.ts +295 -0
- package/src/audio/sampler.ts +328 -35
- package/src/audio/samples.ts +56 -8
- package/src/audio/strings/body.ts +263 -0
- package/src/audio/strings/bow.ts +656 -0
- package/src/audio/strings/engine.ts +615 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +200 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +191 -19
- package/src/audio/winds/engine.ts +302 -0
- package/src/audio/winds/filters.ts +153 -0
- package/src/audio/winds/pitch.ts +104 -0
- package/src/audio/winds/trim.ts +80 -0
- package/src/audio/winds/trims.ts +917 -0
- package/src/audio/winds/voice.ts +479 -0
- package/src/commands/expression.ts +111 -23
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +35 -1
- package/src/commands/granular.ts +432 -0
- package/src/commands/help.ts +159 -5
- package/src/commands/keys.ts +629 -0
- package/src/commands/modal.ts +310 -0
- package/src/commands/resample.ts +281 -0
- package/src/commands/rig.ts +264 -0
- package/src/commands/sample.ts +52 -2
- package/src/commands/shift.ts +119 -0
- package/src/commands/string.ts +201 -0
- package/src/commands/strum.ts +473 -0
- package/src/commands/time.ts +4 -1
- package/src/commands/wind.ts +244 -0
- package/src/main.ts +339 -15
- package/src/project/check.ts +17 -0
- package/src/render.ts +49 -4
- package/src/session/presence.ts +16 -3
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +934 -9
- package/src/tui/modal-menu.ts +145 -0
- package/src/tui/performance-menu.ts +42 -0
- package/src/tui/play-chords.ts +104 -3
- package/src/tui/play-mode.ts +1 -0
- package/src/tui/play-session.ts +70 -2
- package/src/tui/wind-menu.ts +144 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guitar rig prompt commands (0.6):
|
|
3
|
+
*
|
|
4
|
+
* rig show the focused track's rig
|
|
5
|
+
* rig <preset> load a whole rig (RIG_PRESETS): clean crunch
|
|
6
|
+
* punk ragged lead metal fuzz octave funk wah
|
|
7
|
+
* bachata spring bassdrive reese jangle alt
|
|
8
|
+
* rig reset | rig off remove the stomp, head and cab
|
|
9
|
+
* stomp|head|cab <type> pick a pedal, amp or cabinet (`head crunch`)
|
|
10
|
+
* stomp|head|cab <param> <v>… set parameters (`head gain 7 gate -55`)
|
|
11
|
+
* stomp|head|cab on|off|reset as `fx <stage> …`
|
|
12
|
+
*
|
|
13
|
+
* Stage commands are `fx stomp|head|cab …` without the `fx`. `fx amp` (and
|
|
14
|
+
* `amp`) answer with a hint: `amp` stays Strudel's linear gain, the guitar
|
|
15
|
+
* amplifier is `head`. One command is one `updateTrack` revision.
|
|
16
|
+
*/
|
|
17
|
+
import {
|
|
18
|
+
FX_PRESETS,
|
|
19
|
+
FxValidationError,
|
|
20
|
+
RIG_PRESETS,
|
|
21
|
+
RIG_STAGES,
|
|
22
|
+
applyRigPreset,
|
|
23
|
+
effectSpec,
|
|
24
|
+
isRigReverb,
|
|
25
|
+
rigPresetOf,
|
|
26
|
+
rigReverb,
|
|
27
|
+
type FxValues,
|
|
28
|
+
type RigStage,
|
|
29
|
+
type TrackFx,
|
|
30
|
+
SHOEGAZE_EFFECTS,
|
|
31
|
+
} from "../../core/fx.ts";
|
|
32
|
+
import {
|
|
33
|
+
instrumentForWord,
|
|
34
|
+
resolveInstrumentWord,
|
|
35
|
+
} from "../../core/instruments.ts";
|
|
36
|
+
import {
|
|
37
|
+
ScoreValidationError,
|
|
38
|
+
updateTrack,
|
|
39
|
+
type TrackPatch,
|
|
40
|
+
type TrackScore,
|
|
41
|
+
} from "../../core/score.ts";
|
|
42
|
+
import {
|
|
43
|
+
applyFxCommand,
|
|
44
|
+
parseFxCommand,
|
|
45
|
+
type FxCommand,
|
|
46
|
+
type FxResult,
|
|
47
|
+
} from "./fx.ts";
|
|
48
|
+
|
|
49
|
+
export type RigCommand =
|
|
50
|
+
| { type: "rig-show" }
|
|
51
|
+
| { type: "rig-preset"; preset: string }
|
|
52
|
+
| { type: "rig-reset" }
|
|
53
|
+
| { type: "rig-stage"; stage: RigStage; fx: FxCommand }
|
|
54
|
+
/** A typed word that is close to a rig command: answer with a hint. */
|
|
55
|
+
| { type: "rig-hint"; message: string };
|
|
56
|
+
|
|
57
|
+
export const RIG_PRESET_NAMES: readonly string[] = Object.freeze(
|
|
58
|
+
Object.keys(RIG_PRESETS),
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const AMP_HINT =
|
|
62
|
+
"did you mean head (guitar amp)? · `head crunch`, `rig crunch` · amp stays Strudel linear gain (`fx gain`)";
|
|
63
|
+
|
|
64
|
+
function isStage(word: string): word is RigStage {
|
|
65
|
+
return (RIG_STAGES as readonly string[]).includes(word);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** `unknown rig crunh · did you mean crunch? · rigs clean crunch …` */
|
|
69
|
+
function unknownRigMessage(name: string): string {
|
|
70
|
+
let best: { name: string; distance: number } | undefined;
|
|
71
|
+
for (const candidate of RIG_PRESET_NAMES) {
|
|
72
|
+
const distance = editDistance(name, candidate);
|
|
73
|
+
if (!best || distance < best.distance) best = { name: candidate, distance };
|
|
74
|
+
}
|
|
75
|
+
const near =
|
|
76
|
+
best && best.distance <= Math.max(2, Math.floor(name.length / 3))
|
|
77
|
+
? ` · did you mean ${best.name}?`
|
|
78
|
+
: "";
|
|
79
|
+
return `unknown rig ${name}${near} · rigs ${RIG_PRESET_NAMES.join(" ")} reset`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function editDistance(a: string, b: string): number {
|
|
83
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
84
|
+
for (let i = 1; i <= a.length; i++) {
|
|
85
|
+
const next = [i];
|
|
86
|
+
for (let j = 1; j <= b.length; j++)
|
|
87
|
+
next[j] = Math.min(
|
|
88
|
+
row[j]! + 1,
|
|
89
|
+
next[j - 1]! + 1,
|
|
90
|
+
row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
91
|
+
);
|
|
92
|
+
row = next;
|
|
93
|
+
}
|
|
94
|
+
return row[b.length]!;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseRigCommand(prompt: string): RigCommand | undefined {
|
|
98
|
+
if (prompt.length > 1_024) return undefined;
|
|
99
|
+
const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
|
|
100
|
+
const head = words[0];
|
|
101
|
+
if (head === "amp" || (head === "fx" && words[1] === "amp"))
|
|
102
|
+
return { type: "rig-hint", message: AMP_HINT };
|
|
103
|
+
if (head === "rig") {
|
|
104
|
+
if (words.length === 1) return { type: "rig-show" };
|
|
105
|
+
const name = words.slice(1).join(" ");
|
|
106
|
+
if (name === "reset" || name === "off" || name === "none")
|
|
107
|
+
return { type: "rig-reset" };
|
|
108
|
+
if (Object.prototype.hasOwnProperty.call(RIG_PRESETS, name))
|
|
109
|
+
return { type: "rig-preset", preset: name };
|
|
110
|
+
return { type: "rig-hint", message: unknownRigMessage(name) };
|
|
111
|
+
}
|
|
112
|
+
if (!head || !isStage(head)) return undefined;
|
|
113
|
+
const stage = head;
|
|
114
|
+
const rest = words.slice(1);
|
|
115
|
+
if (rest.length === 0)
|
|
116
|
+
return { type: "rig-stage", stage, fx: { type: "fx-on", effect: stage } };
|
|
117
|
+
// `stomp fuzz`, `head crunch`, `cab 4x12`: the stage's type.
|
|
118
|
+
const types = effectSpec(stage).params.type;
|
|
119
|
+
if (
|
|
120
|
+
rest.length === 1 &&
|
|
121
|
+
types?.kind === "enum" &&
|
|
122
|
+
(types.values as readonly string[]).includes(rest[0]!)
|
|
123
|
+
)
|
|
124
|
+
return {
|
|
125
|
+
type: "rig-stage",
|
|
126
|
+
stage,
|
|
127
|
+
fx: { type: "fx-set", effect: stage, values: { type: rest[0]! } },
|
|
128
|
+
};
|
|
129
|
+
// `stomp muff`: a stage preset.
|
|
130
|
+
if (
|
|
131
|
+
rest.length === 1 &&
|
|
132
|
+
Object.prototype.hasOwnProperty.call(FX_PRESETS[stage] ?? {}, rest[0]!)
|
|
133
|
+
)
|
|
134
|
+
return {
|
|
135
|
+
type: "rig-stage",
|
|
136
|
+
stage,
|
|
137
|
+
fx: { type: "fx-preset", effect: stage, preset: rest[0]! },
|
|
138
|
+
};
|
|
139
|
+
const fx = parseFxCommand(`fx ${stage} ${rest.join(" ")}`);
|
|
140
|
+
return fx ? { type: "rig-stage", stage, fx } : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function describeRig(values: Readonly<Record<string, FxValues | undefined>>) {
|
|
144
|
+
const stages = RIG_STAGES.filter((stage) => values[stage])
|
|
145
|
+
.map((stage) => {
|
|
146
|
+
const v = values[stage]!;
|
|
147
|
+
const type = String(v.type);
|
|
148
|
+
const gain = v.gain === undefined ? "" : ` ${String(v.gain)}`;
|
|
149
|
+
return `${stage} ${type}${gain}`;
|
|
150
|
+
})
|
|
151
|
+
.join(" → ");
|
|
152
|
+
// 0.6.1 shoegaze stages follow the cab in the chain.
|
|
153
|
+
const gaze = SHOEGAZE_EFFECTS.filter((effect) => values[effect]);
|
|
154
|
+
return gaze.length ? `${stages} + ${gaze.join(" ")}` : stages;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function applyRigCommand(
|
|
158
|
+
score: TrackScore,
|
|
159
|
+
trackId: string,
|
|
160
|
+
command: RigCommand,
|
|
161
|
+
): FxResult {
|
|
162
|
+
if (command.type === "rig-hint")
|
|
163
|
+
return { ok: false, message: command.message };
|
|
164
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
165
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
166
|
+
if (command.type === "rig-stage")
|
|
167
|
+
return applyFxCommand(score, trackId, command.fx);
|
|
168
|
+
if (command.type === "rig-show") {
|
|
169
|
+
const fx = track.fx ?? {};
|
|
170
|
+
if (!RIG_STAGES.some((stage) => fx[stage]))
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
message: `rig · none · rigs ${RIG_PRESET_NAMES.join(" ")}`,
|
|
174
|
+
};
|
|
175
|
+
const name = rigPresetOf(track.fx);
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
message: `rig${name ? ` ${name}` : ""} · ${describeRig(fx)}`,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const name = command.type === "rig-reset" ? "reset" : command.preset;
|
|
182
|
+
if (name === "reset" && !RIG_STAGES.some((stage) => track.fx?.[stage]))
|
|
183
|
+
return { ok: true, message: "rig · already off" };
|
|
184
|
+
let next: TrackScore;
|
|
185
|
+
try {
|
|
186
|
+
next = updateTrack(score, trackId, rigPatch(track, name));
|
|
187
|
+
} catch (error) {
|
|
188
|
+
if (
|
|
189
|
+
error instanceof ScoreValidationError ||
|
|
190
|
+
error instanceof FxValidationError
|
|
191
|
+
)
|
|
192
|
+
return { ok: false, message: `rig · ${error.message}` };
|
|
193
|
+
throw error;
|
|
194
|
+
}
|
|
195
|
+
const stored = next.tracks.find((candidate) => candidate.id === trackId)?.fx;
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
message:
|
|
199
|
+
name === "reset"
|
|
200
|
+
? "rig · off"
|
|
201
|
+
: `rig ${name} · ${describeRig(stored ?? {})}`,
|
|
202
|
+
next,
|
|
203
|
+
kind: "score.effect",
|
|
204
|
+
payload: { trackId, rig: name, fx: stored ?? null },
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Track fields an instrument word sets beyond `instrument`: a rig alias
|
|
210
|
+
* (`jangle`, `punk`, `gtr-metal`…) also loads its rig over `fx`, keeping
|
|
211
|
+
* the track's other effects. Empty for every other word.
|
|
212
|
+
*/
|
|
213
|
+
export function rigWordPatch(
|
|
214
|
+
word: string,
|
|
215
|
+
fx: TrackFx | undefined,
|
|
216
|
+
): Readonly<{ fx?: TrackFx | null; reverb?: TrackPatch["reverb"] }> {
|
|
217
|
+
const rig = resolveInstrumentWord(word)?.fx;
|
|
218
|
+
if (!rig || !Object.prototype.hasOwnProperty.call(RIG_PRESETS, rig))
|
|
219
|
+
return {};
|
|
220
|
+
return rigPatch({ fx }, rig);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The track patch for rig `name` (or `"reset"`): the stages and companion
|
|
225
|
+
* effects, plus `rig spring`'s short room. A reverb a previous rig set (and
|
|
226
|
+
* nobody changed since) is removed when the new rig has none.
|
|
227
|
+
*/
|
|
228
|
+
function rigPatch(
|
|
229
|
+
track: Readonly<{ fx?: TrackFx; reverb?: unknown }>,
|
|
230
|
+
name: string,
|
|
231
|
+
): Readonly<{ fx: TrackFx | null; reverb?: TrackPatch["reverb"] }> {
|
|
232
|
+
const reverb = name === "reset" ? undefined : rigReverb(name);
|
|
233
|
+
return {
|
|
234
|
+
fx: applyRigPreset(track.fx, name) ?? null,
|
|
235
|
+
...(reverb
|
|
236
|
+
? { reverb: reverb as unknown as TrackPatch["reverb"] }
|
|
237
|
+
: isRigReverb(track.reverb)
|
|
238
|
+
? { reverb: null }
|
|
239
|
+
: {}),
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* A new track's fields for instrument word `word`: a guitar alias gets its
|
|
245
|
+
* guitar voice and rig, any other word `{}` (callers pick the instrument).
|
|
246
|
+
*/
|
|
247
|
+
export function rigTrackFields(word: string): Readonly<{
|
|
248
|
+
instrument?: string;
|
|
249
|
+
string?: { preset: string };
|
|
250
|
+
fx?: TrackFx;
|
|
251
|
+
reverb?: TrackPatch["reverb"];
|
|
252
|
+
}> {
|
|
253
|
+
const meaning = resolveInstrumentWord(word);
|
|
254
|
+
if (!meaning?.fx) return {};
|
|
255
|
+
const { fx, reverb } = rigWordPatch(word, undefined);
|
|
256
|
+
return {
|
|
257
|
+
instrument: meaning.instrument,
|
|
258
|
+
...(meaning.field === "string" && meaning.preset
|
|
259
|
+
? { string: { preset: meaning.preset } }
|
|
260
|
+
: {}),
|
|
261
|
+
...(fx ? { fx } : {}),
|
|
262
|
+
...(reverb ? { reverb } : {}),
|
|
263
|
+
};
|
|
264
|
+
}
|
package/src/commands/sample.ts
CHANGED
|
@@ -38,7 +38,8 @@ export type SampleCommand =
|
|
|
38
38
|
}>;
|
|
39
39
|
|
|
40
40
|
/** A sample control value: a number, a switch, a unit, or null to unset. */
|
|
41
|
-
export type SampleControlValue =
|
|
41
|
+
export type SampleControlValue =
|
|
42
|
+
number | string | boolean | null | readonly number[];
|
|
42
43
|
|
|
43
44
|
/**
|
|
44
45
|
* Per-voice sample controls `/sample set` and `set_sample` change, by
|
|
@@ -59,6 +60,15 @@ export const SAMPLE_CONTROLS = Object.freeze({
|
|
|
59
60
|
accelerate: "rate ramps by this × over the voice (−8..8)",
|
|
60
61
|
squiz: "pitch-raise ratio per zero-crossing cycle (1..32)",
|
|
61
62
|
cut: "choke group name: a new hit stops the previous one",
|
|
63
|
+
bpm: "the sample's own tempo: it follows the song's tempo map",
|
|
64
|
+
fitmode: "how a fitted sample changes time: repitch, beats or tones",
|
|
65
|
+
len: "the window lasts n beats of the song's tempo map",
|
|
66
|
+
shift: "pitch in semitones (−24..24), length unchanged",
|
|
67
|
+
formant: "formants in semitones with shift: 0 keeps the voice, off follows",
|
|
68
|
+
fadeTime: "release fade in seconds (0..2); alias fadeout",
|
|
69
|
+
fadeInTime: "attack fade in seconds (0..2); alias fadein",
|
|
70
|
+
vel: "velocity layer lo-hi (MIDI 0-127): plays only for those velocities",
|
|
71
|
+
rr: "round-robin group name: voices in a group take turns",
|
|
62
72
|
});
|
|
63
73
|
export type SampleControl = keyof typeof SAMPLE_CONTROLS;
|
|
64
74
|
|
|
@@ -67,6 +77,8 @@ const SAMPLE_ALIASES: Readonly<Record<string, string>> = {
|
|
|
67
77
|
loope: "loopEnd",
|
|
68
78
|
legato: "clip",
|
|
69
79
|
choke: "cut",
|
|
80
|
+
fadeout: "fadeTime",
|
|
81
|
+
fadein: "fadeInTime",
|
|
70
82
|
};
|
|
71
83
|
|
|
72
84
|
function controlName(raw: string): SampleControl | undefined {
|
|
@@ -173,6 +185,18 @@ export function setSampleControls(
|
|
|
173
185
|
continue;
|
|
174
186
|
}
|
|
175
187
|
if (name === "cut" && typeof value === "number") value = `cut${value}`;
|
|
188
|
+
if (name === "rr" && typeof value === "number") value = `rr${value}`;
|
|
189
|
+
if (name === "vel" && value !== null) {
|
|
190
|
+
const range = parseVelocityRange(value);
|
|
191
|
+
if (!range)
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
message: "sample · vel needs lo-hi MIDI velocities, e.g. vel 0-63",
|
|
195
|
+
};
|
|
196
|
+
next.vel = range;
|
|
197
|
+
changed.push(`vel ${range[0]}-${range[1]}`);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
176
200
|
if (value === null) delete next[field];
|
|
177
201
|
else next[field] = value;
|
|
178
202
|
changed.push(`${raw} ${value === null ? "off" : String(value)}`);
|
|
@@ -200,10 +224,24 @@ export function setSampleControls(
|
|
|
200
224
|
return {
|
|
201
225
|
ok: true,
|
|
202
226
|
next: updated,
|
|
203
|
-
message: `sample · ${trackId}/${voice} · ${changed.join(" · ")}`,
|
|
227
|
+
message: `sample · ${trackId}/${voice} · ${changed.join(" · ")}${layerHint(updated, trackId, voice)}`,
|
|
204
228
|
};
|
|
205
229
|
}
|
|
206
230
|
|
|
231
|
+
/** `0-63`, `0..63`, `64` (one velocity) → [lo, hi]; else undefined. */
|
|
232
|
+
export function parseVelocityRange(
|
|
233
|
+
value: SampleControlValue | readonly number[],
|
|
234
|
+
): [number, number] | undefined {
|
|
235
|
+
if (Array.isArray(value))
|
|
236
|
+
return value.length === 2 && value.every((v) => typeof v === "number")
|
|
237
|
+
? [value[0] as number, value[1] as number]
|
|
238
|
+
: undefined;
|
|
239
|
+
if (typeof value === "number") return [value, value];
|
|
240
|
+
if (typeof value !== "string") return undefined;
|
|
241
|
+
const match = value.trim().match(/^(\d{1,3})\s*(?:-|\.\.|,)\s*(\d{1,3})$/);
|
|
242
|
+
return match ? [Number(match[1]), Number(match[2])] : undefined;
|
|
243
|
+
}
|
|
244
|
+
|
|
207
245
|
function unquote(value: string): string {
|
|
208
246
|
const quoted = value.match(/^(["'])(.*)\1$/);
|
|
209
247
|
return quoted ? quoted[2]! : value;
|
|
@@ -369,6 +407,18 @@ export function freeVoiceName(
|
|
|
369
407
|
}
|
|
370
408
|
}
|
|
371
409
|
|
|
410
|
+
/**
|
|
411
|
+
* One-shot velocity layers swap only within a round-robin group: a kit
|
|
412
|
+
* voice with `vel` and no `rr` keeps its own pad, so say how to join them.
|
|
413
|
+
*/
|
|
414
|
+
function layerHint(score: TrackScore, trackId: string, voice: string): string {
|
|
415
|
+
const sampler = score.tracks.find((item) => item.id === trackId)?.sampler;
|
|
416
|
+
const ref = sampler?.voices[voice];
|
|
417
|
+
if (sampler?.mode !== "oneshot" || !ref?.vel || ref.rr !== undefined)
|
|
418
|
+
return "";
|
|
419
|
+
return ` · one-shot layers share a pad only within an rr group: sample ${voice} rr <group> on each layer`;
|
|
420
|
+
}
|
|
421
|
+
|
|
372
422
|
export class SamplePlacementError extends Error {}
|
|
373
423
|
|
|
374
424
|
/**
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `shift <semitones> [formant keep|follow|<n>] [<voice>]` and
|
|
3
|
+
* `fade [in|out] <seconds> [<voice>]` (0.6.1): pitch a sampler voice
|
|
4
|
+
* without changing its length, and set its fade in and out. They act on
|
|
5
|
+
* the focused sampler track's voice (named, or its only one) through
|
|
6
|
+
* `setSampleControls`, so validation and printing stay the sampler's.
|
|
7
|
+
*/
|
|
8
|
+
import type { TrackScore } from "../../core/score.ts";
|
|
9
|
+
import { fitVoice } from "./fit.ts";
|
|
10
|
+
import {
|
|
11
|
+
setSampleControls,
|
|
12
|
+
type SampleControlValue,
|
|
13
|
+
type SetSampleResult,
|
|
14
|
+
} from "./sample.ts";
|
|
15
|
+
|
|
16
|
+
export type ShiftCommand =
|
|
17
|
+
| Readonly<{
|
|
18
|
+
kind: "shift";
|
|
19
|
+
/** Semitones; null clears shift and formant. */
|
|
20
|
+
semitones: number | null;
|
|
21
|
+
/** 0 keeps the formants, null follows the pitch, undefined leaves it. */
|
|
22
|
+
formant?: number | null;
|
|
23
|
+
voice?: string;
|
|
24
|
+
}>
|
|
25
|
+
| Readonly<{
|
|
26
|
+
kind: "fade";
|
|
27
|
+
/** `in` is fadeInTime, `out` fadeTime, `both` sets each. */
|
|
28
|
+
edge: "in" | "out" | "both";
|
|
29
|
+
seconds: number | null;
|
|
30
|
+
voice?: string;
|
|
31
|
+
}>;
|
|
32
|
+
|
|
33
|
+
const VOICE = "([a-z][a-z0-9_]{0,31})";
|
|
34
|
+
const NUMBER = "([+-]?\\d+(?:\\.\\d+)?|off|none)";
|
|
35
|
+
|
|
36
|
+
/** Parse `shift ...` and `fade ...` (with or without the slash). */
|
|
37
|
+
export function parseShiftCommand(command: string): ShiftCommand | undefined {
|
|
38
|
+
const text = command.trim();
|
|
39
|
+
const shift = text.match(
|
|
40
|
+
new RegExp(
|
|
41
|
+
`^/?shift\\s+${NUMBER}(?:\\s+formant\\s+(keep|follow|[+-]?\\d+(?:\\.\\d+)?))?(?:\\s+(?:voice\\s+)?${VOICE})?$`,
|
|
42
|
+
"i",
|
|
43
|
+
),
|
|
44
|
+
);
|
|
45
|
+
if (shift) {
|
|
46
|
+
const raw = shift[1]!.toLowerCase();
|
|
47
|
+
const formant = shift[2]?.toLowerCase();
|
|
48
|
+
return {
|
|
49
|
+
kind: "shift",
|
|
50
|
+
semitones: raw === "off" || raw === "none" ? null : Number(raw),
|
|
51
|
+
...(formant === undefined
|
|
52
|
+
? {}
|
|
53
|
+
: {
|
|
54
|
+
formant:
|
|
55
|
+
formant === "keep"
|
|
56
|
+
? 0
|
|
57
|
+
: formant === "follow"
|
|
58
|
+
? null
|
|
59
|
+
: Number(formant),
|
|
60
|
+
}),
|
|
61
|
+
...(shift[3] ? { voice: shift[3].toLowerCase() } : {}),
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const fade = text.match(
|
|
65
|
+
new RegExp(
|
|
66
|
+
`^/?fade(?:\\s+(in|out))?\\s+${NUMBER}(?:\\s+(?:voice\\s+)?${VOICE})?$`,
|
|
67
|
+
"i",
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
if (!fade) return undefined;
|
|
71
|
+
const raw = fade[2]!.toLowerCase();
|
|
72
|
+
return {
|
|
73
|
+
kind: "fade",
|
|
74
|
+
edge: (fade[1]?.toLowerCase() as "in" | "out" | undefined) ?? "both",
|
|
75
|
+
seconds: raw === "off" || raw === "none" ? null : Number(raw),
|
|
76
|
+
...(fade[3] ? { voice: fade[3].toLowerCase() } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Apply a parsed `shift` or `fade` to the focused track's voice. */
|
|
81
|
+
export function applyShiftCommand(
|
|
82
|
+
score: TrackScore,
|
|
83
|
+
trackId: string,
|
|
84
|
+
command: ShiftCommand,
|
|
85
|
+
): SetSampleResult {
|
|
86
|
+
const target = fitVoice(score, trackId, command.voice);
|
|
87
|
+
if ("error" in target)
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
message: target.error.replace(/^fit · /, `${command.kind} · `),
|
|
91
|
+
};
|
|
92
|
+
const values: Record<string, SampleControlValue> = {};
|
|
93
|
+
if (command.kind === "shift") {
|
|
94
|
+
// `shift off` clears the shift and its formant setting. `shift 0`
|
|
95
|
+
// clears the shift and a kept formant (0, a no-op without a shift) but
|
|
96
|
+
// keeps a formant move, so `shift 0 formant 3` is a formant-only move
|
|
97
|
+
// and stepping the shift through 0 keeps a stored formant.
|
|
98
|
+
if (command.semitones === null) {
|
|
99
|
+
values.shift = null;
|
|
100
|
+
values.formant = null;
|
|
101
|
+
} else {
|
|
102
|
+
values.shift = command.semitones === 0 ? null : command.semitones;
|
|
103
|
+
const formant =
|
|
104
|
+
command.formant !== undefined
|
|
105
|
+
? command.formant
|
|
106
|
+
: score.tracks.find((track) => track.id === trackId)?.sampler?.voices[
|
|
107
|
+
target.voice
|
|
108
|
+
]?.formant;
|
|
109
|
+
if (command.semitones === 0 && (formant === 0 || formant === null))
|
|
110
|
+
values.formant = null;
|
|
111
|
+
else if (command.formant !== undefined) values.formant = command.formant;
|
|
112
|
+
}
|
|
113
|
+
} else {
|
|
114
|
+
const seconds = command.seconds === 0 ? null : command.seconds;
|
|
115
|
+
if (command.edge !== "out") values.fadeInTime = seconds;
|
|
116
|
+
if (command.edge !== "in") values.fadeTime = seconds;
|
|
117
|
+
}
|
|
118
|
+
return setSampleControls(score, trackId, target.voice, values);
|
|
119
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `string` prompt command: the focused track's plucked-string engine
|
|
3
|
+
* (core/strings.ts, `Track.string`).
|
|
4
|
+
*
|
|
5
|
+
* string list this track's preset and overrides
|
|
6
|
+
* string presets the plucked presets
|
|
7
|
+
* string <preset> | preset <name> play a preset (instrument "string")
|
|
8
|
+
* string <param> <value> [<param> <value>…] override parameters
|
|
9
|
+
* string <param> off back to the preset's value
|
|
10
|
+
* string reset drop the overrides, keep the preset
|
|
11
|
+
* string off back to the track's synth voice
|
|
12
|
+
* bowed play the cello preset (bowed strings)
|
|
13
|
+
* bowed <preset> a bowed preset (violin viola cello …)
|
|
14
|
+
* bowed presets the bowed presets
|
|
15
|
+
*
|
|
16
|
+
* `string sitar`, `string buzz 0.8 sym 0.5`, `string ring 6`. Each command
|
|
17
|
+
* is one `updateTrack` revision and one undo step.
|
|
18
|
+
*/
|
|
19
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
20
|
+
import {
|
|
21
|
+
ScoreValidationError,
|
|
22
|
+
updateTrack,
|
|
23
|
+
type TrackScore,
|
|
24
|
+
} from "../../core/score.ts";
|
|
25
|
+
import {
|
|
26
|
+
BOWED_PRESET_NAMES,
|
|
27
|
+
DEFAULT_BOWED_PRESET,
|
|
28
|
+
DEFAULT_STRING_PRESET,
|
|
29
|
+
isStringTrack,
|
|
30
|
+
normalizeString,
|
|
31
|
+
STRING_INSTRUMENT,
|
|
32
|
+
STRING_PARAMS,
|
|
33
|
+
STRING_PRESET_NAMES,
|
|
34
|
+
STRING_PRESETS,
|
|
35
|
+
STRING_SIMPLE_PARAMS,
|
|
36
|
+
stringParamName,
|
|
37
|
+
stringPresetName,
|
|
38
|
+
stringPresetOf,
|
|
39
|
+
type TrackString,
|
|
40
|
+
} from "../../core/strings.ts";
|
|
41
|
+
import { parseParamValue } from "./fx.ts";
|
|
42
|
+
|
|
43
|
+
export type StringCommand =
|
|
44
|
+
| { type: "string-list" }
|
|
45
|
+
| { type: "string-presets"; bowed?: boolean }
|
|
46
|
+
| { type: "string-reset" }
|
|
47
|
+
| { type: "string-off" }
|
|
48
|
+
| { type: "string-preset"; preset: string }
|
|
49
|
+
| {
|
|
50
|
+
type: "string-set";
|
|
51
|
+
/** `null` unsets a parameter (the preset's value again). */
|
|
52
|
+
values: Readonly<Record<string, number | string | null>>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export function parseStringCommand(prompt: string): StringCommand | undefined {
|
|
56
|
+
const words = prompt.trim().split(/\s+/);
|
|
57
|
+
if (words[0]?.toLowerCase() === "bowed") return parseBowed(words.slice(1));
|
|
58
|
+
if (words[0]?.toLowerCase() !== "string") return undefined;
|
|
59
|
+
if (words.length === 1) return { type: "string-list" };
|
|
60
|
+
if (prompt.length > 1_024) return undefined;
|
|
61
|
+
const rest = words.slice(1).map((word) => word.toLowerCase());
|
|
62
|
+
if (rest.length === 1) {
|
|
63
|
+
if (rest[0] === "reset") return { type: "string-reset" };
|
|
64
|
+
if (rest[0] === "off") return { type: "string-off" };
|
|
65
|
+
if (rest[0] === "presets" || rest[0] === "list")
|
|
66
|
+
return { type: "string-presets" };
|
|
67
|
+
const preset = stringPresetName(rest[0]!);
|
|
68
|
+
return preset ? { type: "string-preset", preset } : undefined;
|
|
69
|
+
}
|
|
70
|
+
if (rest[0] === "preset") {
|
|
71
|
+
const preset = rest.length === 2 ? stringPresetName(rest[1]!) : undefined;
|
|
72
|
+
return preset ? { type: "string-preset", preset } : undefined;
|
|
73
|
+
}
|
|
74
|
+
if (rest.length % 2 !== 0) return undefined;
|
|
75
|
+
const values: Record<string, number | string | null> = {};
|
|
76
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
77
|
+
const name = stringParamName(rest[index]!);
|
|
78
|
+
if (!name) return undefined;
|
|
79
|
+
const word = rest[index + 1]!;
|
|
80
|
+
if (word === "off" || word === "unset") {
|
|
81
|
+
values[name] = null;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const value = parseParamValue(STRING_PARAMS[name]!, word);
|
|
85
|
+
if (value === undefined || typeof value === "boolean") return undefined;
|
|
86
|
+
values[name] = value;
|
|
87
|
+
}
|
|
88
|
+
return { type: "string-set", values };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* `bowed` (0.6.1): the string engine's bowed family. `bowed` plays cello,
|
|
93
|
+
* `bowed <preset>` a bowed preset, `bowed presets` lists them, and
|
|
94
|
+
* `bowed <param> <value>…` is `string <param> <value>…`.
|
|
95
|
+
*/
|
|
96
|
+
function parseBowed(words: string[]): StringCommand | undefined {
|
|
97
|
+
if (words.length === 0)
|
|
98
|
+
return { type: "string-preset", preset: DEFAULT_BOWED_PRESET };
|
|
99
|
+
const rest = words.map((word) => word.toLowerCase());
|
|
100
|
+
if (rest.length === 1 && (rest[0] === "presets" || rest[0] === "list"))
|
|
101
|
+
return { type: "string-presets", bowed: true };
|
|
102
|
+
if (rest.length === 1) {
|
|
103
|
+
const preset = stringPresetName(rest[0]!);
|
|
104
|
+
return preset && BOWED_PRESET_NAMES.includes(preset)
|
|
105
|
+
? { type: "string-preset", preset }
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
return parseStringCommand(`string ${rest.join(" ")}`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** `sitar · buzz 0.8 · sym 0.5`, or `nylon` for a bare preset. */
|
|
112
|
+
export function describeString(settings: TrackString | undefined): string {
|
|
113
|
+
const parts = [stringPresetOf(settings)];
|
|
114
|
+
for (const [key, value] of Object.entries(settings ?? {}))
|
|
115
|
+
if (key !== "preset" && value !== undefined) parts.push(`${key} ${value}`);
|
|
116
|
+
return parts.join(" · ");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export type StringResult = Readonly<{
|
|
120
|
+
ok: boolean;
|
|
121
|
+
message: string;
|
|
122
|
+
next?: TrackScore;
|
|
123
|
+
kind?: string;
|
|
124
|
+
payload?: Record<string, unknown>;
|
|
125
|
+
}>;
|
|
126
|
+
|
|
127
|
+
export function applyStringCommand(
|
|
128
|
+
score: TrackScore,
|
|
129
|
+
trackId: string,
|
|
130
|
+
command: StringCommand,
|
|
131
|
+
): StringResult {
|
|
132
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
133
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
134
|
+
if (command.type === "string-presets")
|
|
135
|
+
return {
|
|
136
|
+
ok: true,
|
|
137
|
+
message: `${command.bowed ? "bowed" : "string"} presets · ${(command.bowed ? BOWED_PRESET_NAMES : STRING_PRESET_NAMES).map((name) => `${name} (${STRING_PRESETS[name]!.doc})`).join(" · ")}`,
|
|
138
|
+
};
|
|
139
|
+
if (track.sampler || track.kit)
|
|
140
|
+
return {
|
|
141
|
+
ok: false,
|
|
142
|
+
message: `string · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track; the string engine plays pitched notes`,
|
|
143
|
+
};
|
|
144
|
+
const active = isStringTrack(track);
|
|
145
|
+
if (command.type === "string-list")
|
|
146
|
+
return {
|
|
147
|
+
ok: true,
|
|
148
|
+
message: active
|
|
149
|
+
? `string · ${describeString(track.string)} · basics ${STRING_SIMPLE_PARAMS.join(" ")}`
|
|
150
|
+
: `string · off (${track.instrument}) · string ${DEFAULT_STRING_PRESET} turns it on · string presets`,
|
|
151
|
+
};
|
|
152
|
+
let instrument = track.instrument;
|
|
153
|
+
let string: Record<string, unknown> | null;
|
|
154
|
+
if (command.type === "string-off") {
|
|
155
|
+
if (!active) return { ok: true, message: "string · already off" };
|
|
156
|
+
instrument = "pluck";
|
|
157
|
+
string = null;
|
|
158
|
+
} else if (command.type === "string-reset") {
|
|
159
|
+
if (!active) return { ok: true, message: "string · already off" };
|
|
160
|
+
string = { preset: stringPresetOf(track.string) };
|
|
161
|
+
} else if (command.type === "string-preset") {
|
|
162
|
+
instrument = STRING_INSTRUMENT;
|
|
163
|
+
string = { preset: command.preset };
|
|
164
|
+
} else {
|
|
165
|
+
instrument = STRING_INSTRUMENT;
|
|
166
|
+
string = active ? { ...track.string } : { preset: DEFAULT_STRING_PRESET };
|
|
167
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
168
|
+
if (value === null) delete string[key];
|
|
169
|
+
else string[key] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
let next: TrackScore;
|
|
173
|
+
try {
|
|
174
|
+
next = updateTrack(score, trackId, {
|
|
175
|
+
instrument,
|
|
176
|
+
string: string === null ? null : (normalizeString(string) ?? null),
|
|
177
|
+
});
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (
|
|
180
|
+
error instanceof ScoreValidationError ||
|
|
181
|
+
error instanceof FxValidationError
|
|
182
|
+
)
|
|
183
|
+
return { ok: false, message: `string · ${error.message}` };
|
|
184
|
+
throw error;
|
|
185
|
+
}
|
|
186
|
+
const stored = next.tracks.find((candidate) => candidate.id === trackId);
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
message:
|
|
190
|
+
command.type === "string-off"
|
|
191
|
+
? `string · off · ${instrument}`
|
|
192
|
+
: `string · ${describeString(stored?.string)}`,
|
|
193
|
+
next,
|
|
194
|
+
kind: "score.string",
|
|
195
|
+
payload: {
|
|
196
|
+
trackId,
|
|
197
|
+
instrument: stored?.instrument ?? instrument,
|
|
198
|
+
string: stored?.string ?? null,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|