@hraness/dawg 0.5.0 → 0.6.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/CHANGELOG.md +29 -0
- package/DAWG.md +269 -13
- package/README.md +4 -4
- package/core/diff.ts +4 -0
- package/core/fx.ts +437 -2
- package/core/granular.ts +528 -0
- package/core/instruments.ts +281 -0
- package/core/keys.ts +386 -0
- package/core/resonators.ts +574 -0
- package/core/score.ts +181 -6
- package/core/sdk/eval-child.ts +2 -0
- package/core/sdk/print.ts +169 -13
- package/core/sdk/sync-instruments.ts +58 -0
- package/core/sdk/v1.ts +1114 -21
- package/core/sections.ts +34 -8
- package/core/strings.ts +845 -0
- package/guides/effects.md +3 -1
- package/guides/sounds.md +4 -0
- package/guides/tempo.md +1 -0
- package/package.json +1 -1
- package/src/agent/agent.ts +7 -0
- package/src/agent/granular-tools.ts +138 -0
- package/src/agent/models.ts +4 -4
- package/src/agent/ops.ts +39 -2
- package/src/agent/preview-tool.ts +6 -1
- package/src/agent/tools.ts +538 -14
- package/src/agent/xcb-agent.ts +7 -0
- package/src/audio/arrange.ts +21 -3
- package/src/audio/dsp/bank.ts +233 -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/stft.ts +73 -0
- package/src/audio/dsp/window.ts +77 -0
- package/src/audio/effects/chain.ts +6 -1
- 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 +10 -1
- package/src/audio/fit.ts +402 -0
- package/src/audio/granular.ts +664 -0
- package/src/audio/instrument-check.ts +133 -0
- package/src/audio/instruments.ts +111 -0
- package/src/audio/keys/dsp.ts +323 -0
- package/src/audio/keys/engine.ts +361 -0
- package/src/audio/keys/piano.ts +432 -0
- package/src/audio/live-worker.ts +54 -0
- package/src/audio/live.ts +231 -12
- package/src/audio/loudness.ts +65 -20
- package/src/audio/preview.ts +2 -0
- package/src/audio/resonators.ts +287 -0
- package/src/audio/sampler.ts +163 -17
- package/src/audio/samples.ts +30 -7
- package/src/audio/strings/body.ts +250 -0
- package/src/audio/strings/engine.ts +369 -0
- package/src/audio/strings/loop.ts +119 -0
- package/src/audio/strings/measure.test-helpers.ts +198 -0
- package/src/audio/strings/pluck.ts +354 -0
- package/src/audio/warp.ts +61 -0
- package/src/audio/wav.ts +78 -6
- package/src/commands/expression.ts +9 -2
- package/src/commands/fit.ts +135 -0
- package/src/commands/fx.ts +31 -0
- package/src/commands/granular.ts +427 -0
- package/src/commands/help.ts +74 -3
- package/src/commands/keys.ts +353 -0
- package/src/commands/modal.ts +247 -0
- package/src/commands/rig.ts +260 -0
- package/src/commands/sample.ts +3 -0
- package/src/commands/string.ts +175 -0
- package/src/commands/time.ts +4 -1
- package/src/main.ts +210 -8
- package/src/project/check.ts +13 -0
- package/src/render.ts +33 -4
- package/src/tui/audition.ts +2 -2
- package/src/tui/granular-menu.ts +278 -0
- package/src/tui/menu.ts +332 -9
- package/src/tui/modal-menu.ts +118 -0
- package/src/tui/play-session.ts +45 -2
|
@@ -0,0 +1,260 @@
|
|
|
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
|
+
} from "../../core/fx.ts";
|
|
31
|
+
import {
|
|
32
|
+
instrumentForWord,
|
|
33
|
+
resolveInstrumentWord,
|
|
34
|
+
} from "../../core/instruments.ts";
|
|
35
|
+
import {
|
|
36
|
+
ScoreValidationError,
|
|
37
|
+
updateTrack,
|
|
38
|
+
type TrackPatch,
|
|
39
|
+
type TrackScore,
|
|
40
|
+
} from "../../core/score.ts";
|
|
41
|
+
import {
|
|
42
|
+
applyFxCommand,
|
|
43
|
+
parseFxCommand,
|
|
44
|
+
type FxCommand,
|
|
45
|
+
type FxResult,
|
|
46
|
+
} from "./fx.ts";
|
|
47
|
+
|
|
48
|
+
export type RigCommand =
|
|
49
|
+
| { type: "rig-show" }
|
|
50
|
+
| { type: "rig-preset"; preset: string }
|
|
51
|
+
| { type: "rig-reset" }
|
|
52
|
+
| { type: "rig-stage"; stage: RigStage; fx: FxCommand }
|
|
53
|
+
/** A typed word that is close to a rig command: answer with a hint. */
|
|
54
|
+
| { type: "rig-hint"; message: string };
|
|
55
|
+
|
|
56
|
+
export const RIG_PRESET_NAMES: readonly string[] = Object.freeze(
|
|
57
|
+
Object.keys(RIG_PRESETS),
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
const AMP_HINT =
|
|
61
|
+
"did you mean head (guitar amp)? · `head crunch`, `rig crunch` · amp stays Strudel linear gain (`fx gain`)";
|
|
62
|
+
|
|
63
|
+
function isStage(word: string): word is RigStage {
|
|
64
|
+
return (RIG_STAGES as readonly string[]).includes(word);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `unknown rig crunh · did you mean crunch? · rigs clean crunch …` */
|
|
68
|
+
function unknownRigMessage(name: string): string {
|
|
69
|
+
let best: { name: string; distance: number } | undefined;
|
|
70
|
+
for (const candidate of RIG_PRESET_NAMES) {
|
|
71
|
+
const distance = editDistance(name, candidate);
|
|
72
|
+
if (!best || distance < best.distance) best = { name: candidate, distance };
|
|
73
|
+
}
|
|
74
|
+
const near =
|
|
75
|
+
best && best.distance <= Math.max(2, Math.floor(name.length / 3))
|
|
76
|
+
? ` · did you mean ${best.name}?`
|
|
77
|
+
: "";
|
|
78
|
+
return `unknown rig ${name}${near} · rigs ${RIG_PRESET_NAMES.join(" ")} reset`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function editDistance(a: string, b: string): number {
|
|
82
|
+
let row = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
83
|
+
for (let i = 1; i <= a.length; i++) {
|
|
84
|
+
const next = [i];
|
|
85
|
+
for (let j = 1; j <= b.length; j++)
|
|
86
|
+
next[j] = Math.min(
|
|
87
|
+
row[j]! + 1,
|
|
88
|
+
next[j - 1]! + 1,
|
|
89
|
+
row[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
|
|
90
|
+
);
|
|
91
|
+
row = next;
|
|
92
|
+
}
|
|
93
|
+
return row[b.length]!;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function parseRigCommand(prompt: string): RigCommand | undefined {
|
|
97
|
+
if (prompt.length > 1_024) return undefined;
|
|
98
|
+
const words = prompt.trim().replace(/^\//, "").toLowerCase().split(/\s+/);
|
|
99
|
+
const head = words[0];
|
|
100
|
+
if (head === "amp" || (head === "fx" && words[1] === "amp"))
|
|
101
|
+
return { type: "rig-hint", message: AMP_HINT };
|
|
102
|
+
if (head === "rig") {
|
|
103
|
+
if (words.length === 1) return { type: "rig-show" };
|
|
104
|
+
const name = words.slice(1).join(" ");
|
|
105
|
+
if (name === "reset" || name === "off" || name === "none")
|
|
106
|
+
return { type: "rig-reset" };
|
|
107
|
+
if (Object.prototype.hasOwnProperty.call(RIG_PRESETS, name))
|
|
108
|
+
return { type: "rig-preset", preset: name };
|
|
109
|
+
return { type: "rig-hint", message: unknownRigMessage(name) };
|
|
110
|
+
}
|
|
111
|
+
if (!head || !isStage(head)) return undefined;
|
|
112
|
+
const stage = head;
|
|
113
|
+
const rest = words.slice(1);
|
|
114
|
+
if (rest.length === 0)
|
|
115
|
+
return { type: "rig-stage", stage, fx: { type: "fx-on", effect: stage } };
|
|
116
|
+
// `stomp fuzz`, `head crunch`, `cab 4x12`: the stage's type.
|
|
117
|
+
const types = effectSpec(stage).params.type;
|
|
118
|
+
if (
|
|
119
|
+
rest.length === 1 &&
|
|
120
|
+
types?.kind === "enum" &&
|
|
121
|
+
(types.values as readonly string[]).includes(rest[0]!)
|
|
122
|
+
)
|
|
123
|
+
return {
|
|
124
|
+
type: "rig-stage",
|
|
125
|
+
stage,
|
|
126
|
+
fx: { type: "fx-set", effect: stage, values: { type: rest[0]! } },
|
|
127
|
+
};
|
|
128
|
+
// `stomp muff`: a stage preset.
|
|
129
|
+
if (
|
|
130
|
+
rest.length === 1 &&
|
|
131
|
+
Object.prototype.hasOwnProperty.call(FX_PRESETS[stage] ?? {}, rest[0]!)
|
|
132
|
+
)
|
|
133
|
+
return {
|
|
134
|
+
type: "rig-stage",
|
|
135
|
+
stage,
|
|
136
|
+
fx: { type: "fx-preset", effect: stage, preset: rest[0]! },
|
|
137
|
+
};
|
|
138
|
+
const fx = parseFxCommand(`fx ${stage} ${rest.join(" ")}`);
|
|
139
|
+
return fx ? { type: "rig-stage", stage, fx } : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function describeRig(values: Readonly<Record<string, FxValues | undefined>>) {
|
|
143
|
+
return RIG_STAGES.filter((stage) => values[stage])
|
|
144
|
+
.map((stage) => {
|
|
145
|
+
const v = values[stage]!;
|
|
146
|
+
const type = String(v.type);
|
|
147
|
+
const gain = v.gain === undefined ? "" : ` ${String(v.gain)}`;
|
|
148
|
+
return `${stage} ${type}${gain}`;
|
|
149
|
+
})
|
|
150
|
+
.join(" → ");
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function applyRigCommand(
|
|
154
|
+
score: TrackScore,
|
|
155
|
+
trackId: string,
|
|
156
|
+
command: RigCommand,
|
|
157
|
+
): FxResult {
|
|
158
|
+
if (command.type === "rig-hint")
|
|
159
|
+
return { ok: false, message: command.message };
|
|
160
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
161
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
162
|
+
if (command.type === "rig-stage")
|
|
163
|
+
return applyFxCommand(score, trackId, command.fx);
|
|
164
|
+
if (command.type === "rig-show") {
|
|
165
|
+
const fx = track.fx ?? {};
|
|
166
|
+
if (!RIG_STAGES.some((stage) => fx[stage]))
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
message: `rig · none · rigs ${RIG_PRESET_NAMES.join(" ")}`,
|
|
170
|
+
};
|
|
171
|
+
const name = rigPresetOf(track.fx);
|
|
172
|
+
return {
|
|
173
|
+
ok: true,
|
|
174
|
+
message: `rig${name ? ` ${name}` : ""} · ${describeRig(fx)}`,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
const name = command.type === "rig-reset" ? "reset" : command.preset;
|
|
178
|
+
if (name === "reset" && !RIG_STAGES.some((stage) => track.fx?.[stage]))
|
|
179
|
+
return { ok: true, message: "rig · already off" };
|
|
180
|
+
let next: TrackScore;
|
|
181
|
+
try {
|
|
182
|
+
next = updateTrack(score, trackId, rigPatch(track, name));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (
|
|
185
|
+
error instanceof ScoreValidationError ||
|
|
186
|
+
error instanceof FxValidationError
|
|
187
|
+
)
|
|
188
|
+
return { ok: false, message: `rig · ${error.message}` };
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
const stored = next.tracks.find((candidate) => candidate.id === trackId)?.fx;
|
|
192
|
+
return {
|
|
193
|
+
ok: true,
|
|
194
|
+
message:
|
|
195
|
+
name === "reset"
|
|
196
|
+
? "rig · off"
|
|
197
|
+
: `rig ${name} · ${describeRig(stored ?? {})}`,
|
|
198
|
+
next,
|
|
199
|
+
kind: "score.effect",
|
|
200
|
+
payload: { trackId, rig: name, fx: stored ?? null },
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Track fields an instrument word sets beyond `instrument`: a rig alias
|
|
206
|
+
* (`jangle`, `punk`, `gtr-metal`…) also loads its rig over `fx`, keeping
|
|
207
|
+
* the track's other effects. Empty for every other word.
|
|
208
|
+
*/
|
|
209
|
+
export function rigWordPatch(
|
|
210
|
+
word: string,
|
|
211
|
+
fx: TrackFx | undefined,
|
|
212
|
+
): Readonly<{ fx?: TrackFx | null; reverb?: TrackPatch["reverb"] }> {
|
|
213
|
+
const rig = resolveInstrumentWord(word)?.fx;
|
|
214
|
+
if (!rig || !Object.prototype.hasOwnProperty.call(RIG_PRESETS, rig))
|
|
215
|
+
return {};
|
|
216
|
+
return rigPatch({ fx }, rig);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The track patch for rig `name` (or `"reset"`): the stages and companion
|
|
221
|
+
* effects, plus `rig spring`'s short room. A reverb a previous rig set (and
|
|
222
|
+
* nobody changed since) is removed when the new rig has none.
|
|
223
|
+
*/
|
|
224
|
+
function rigPatch(
|
|
225
|
+
track: Readonly<{ fx?: TrackFx; reverb?: unknown }>,
|
|
226
|
+
name: string,
|
|
227
|
+
): Readonly<{ fx: TrackFx | null; reverb?: TrackPatch["reverb"] }> {
|
|
228
|
+
const reverb = name === "reset" ? undefined : rigReverb(name);
|
|
229
|
+
return {
|
|
230
|
+
fx: applyRigPreset(track.fx, name) ?? null,
|
|
231
|
+
...(reverb
|
|
232
|
+
? { reverb: reverb as unknown as TrackPatch["reverb"] }
|
|
233
|
+
: isRigReverb(track.reverb)
|
|
234
|
+
? { reverb: null }
|
|
235
|
+
: {}),
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* A new track's fields for instrument word `word`: a guitar alias gets its
|
|
241
|
+
* guitar voice and rig, any other word `{}` (callers pick the instrument).
|
|
242
|
+
*/
|
|
243
|
+
export function rigTrackFields(word: string): Readonly<{
|
|
244
|
+
instrument?: string;
|
|
245
|
+
string?: { preset: string };
|
|
246
|
+
fx?: TrackFx;
|
|
247
|
+
reverb?: TrackPatch["reverb"];
|
|
248
|
+
}> {
|
|
249
|
+
const meaning = resolveInstrumentWord(word);
|
|
250
|
+
if (!meaning?.fx) return {};
|
|
251
|
+
const { fx, reverb } = rigWordPatch(word, undefined);
|
|
252
|
+
return {
|
|
253
|
+
instrument: meaning.instrument,
|
|
254
|
+
...(meaning.field === "string" && meaning.preset
|
|
255
|
+
? { string: { preset: meaning.preset } }
|
|
256
|
+
: {}),
|
|
257
|
+
...(fx ? { fx } : {}),
|
|
258
|
+
...(reverb ? { reverb } : {}),
|
|
259
|
+
};
|
|
260
|
+
}
|
package/src/commands/sample.ts
CHANGED
|
@@ -59,6 +59,9 @@ export const SAMPLE_CONTROLS = Object.freeze({
|
|
|
59
59
|
accelerate: "rate ramps by this × over the voice (−8..8)",
|
|
60
60
|
squiz: "pitch-raise ratio per zero-crossing cycle (1..32)",
|
|
61
61
|
cut: "choke group name: a new hit stops the previous one",
|
|
62
|
+
bpm: "the sample's own tempo: it follows the song's tempo map",
|
|
63
|
+
fitmode: "how a fitted sample changes time: repitch, beats or tones",
|
|
64
|
+
len: "the window lasts n beats of the song's tempo map",
|
|
62
65
|
});
|
|
63
66
|
export type SampleControl = keyof typeof SAMPLE_CONTROLS;
|
|
64
67
|
|
|
@@ -0,0 +1,175 @@
|
|
|
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
|
+
*
|
|
13
|
+
* `string sitar`, `string buzz 0.8 sym 0.5`, `string ring 6`. Each command
|
|
14
|
+
* is one `updateTrack` revision and one undo step.
|
|
15
|
+
*/
|
|
16
|
+
import { FxValidationError } from "../../core/params.ts";
|
|
17
|
+
import {
|
|
18
|
+
ScoreValidationError,
|
|
19
|
+
updateTrack,
|
|
20
|
+
type TrackScore,
|
|
21
|
+
} from "../../core/score.ts";
|
|
22
|
+
import {
|
|
23
|
+
DEFAULT_STRING_PRESET,
|
|
24
|
+
isStringTrack,
|
|
25
|
+
normalizeString,
|
|
26
|
+
STRING_INSTRUMENT,
|
|
27
|
+
STRING_PARAMS,
|
|
28
|
+
STRING_PRESET_NAMES,
|
|
29
|
+
STRING_PRESETS,
|
|
30
|
+
STRING_SIMPLE_PARAMS,
|
|
31
|
+
stringParamName,
|
|
32
|
+
stringPresetName,
|
|
33
|
+
stringPresetOf,
|
|
34
|
+
type TrackString,
|
|
35
|
+
} from "../../core/strings.ts";
|
|
36
|
+
import { parseParamValue } from "./fx.ts";
|
|
37
|
+
|
|
38
|
+
export type StringCommand =
|
|
39
|
+
| { type: "string-list" }
|
|
40
|
+
| { type: "string-presets" }
|
|
41
|
+
| { type: "string-reset" }
|
|
42
|
+
| { type: "string-off" }
|
|
43
|
+
| { type: "string-preset"; preset: string }
|
|
44
|
+
| {
|
|
45
|
+
type: "string-set";
|
|
46
|
+
/** `null` unsets a parameter (the preset's value again). */
|
|
47
|
+
values: Readonly<Record<string, number | string | null>>;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function parseStringCommand(prompt: string): StringCommand | undefined {
|
|
51
|
+
const words = prompt.trim().split(/\s+/);
|
|
52
|
+
if (words[0]?.toLowerCase() !== "string") return undefined;
|
|
53
|
+
if (words.length === 1) return { type: "string-list" };
|
|
54
|
+
if (prompt.length > 1_024) return undefined;
|
|
55
|
+
const rest = words.slice(1).map((word) => word.toLowerCase());
|
|
56
|
+
if (rest.length === 1) {
|
|
57
|
+
if (rest[0] === "reset") return { type: "string-reset" };
|
|
58
|
+
if (rest[0] === "off") return { type: "string-off" };
|
|
59
|
+
if (rest[0] === "presets" || rest[0] === "list")
|
|
60
|
+
return { type: "string-presets" };
|
|
61
|
+
const preset = stringPresetName(rest[0]!);
|
|
62
|
+
return preset ? { type: "string-preset", preset } : undefined;
|
|
63
|
+
}
|
|
64
|
+
if (rest[0] === "preset") {
|
|
65
|
+
const preset = rest.length === 2 ? stringPresetName(rest[1]!) : undefined;
|
|
66
|
+
return preset ? { type: "string-preset", preset } : undefined;
|
|
67
|
+
}
|
|
68
|
+
if (rest.length % 2 !== 0) return undefined;
|
|
69
|
+
const values: Record<string, number | string | null> = {};
|
|
70
|
+
for (let index = 0; index < rest.length; index += 2) {
|
|
71
|
+
const name = stringParamName(rest[index]!);
|
|
72
|
+
if (!name) return undefined;
|
|
73
|
+
const word = rest[index + 1]!;
|
|
74
|
+
if (word === "off" || word === "unset") {
|
|
75
|
+
values[name] = null;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const value = parseParamValue(STRING_PARAMS[name]!, word);
|
|
79
|
+
if (value === undefined || typeof value === "boolean") return undefined;
|
|
80
|
+
values[name] = value;
|
|
81
|
+
}
|
|
82
|
+
return { type: "string-set", values };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `sitar · buzz 0.8 · sym 0.5`, or `nylon` for a bare preset. */
|
|
86
|
+
export function describeString(settings: TrackString | undefined): string {
|
|
87
|
+
const parts = [stringPresetOf(settings)];
|
|
88
|
+
for (const [key, value] of Object.entries(settings ?? {}))
|
|
89
|
+
if (key !== "preset" && value !== undefined) parts.push(`${key} ${value}`);
|
|
90
|
+
return parts.join(" · ");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export type StringResult = Readonly<{
|
|
94
|
+
ok: boolean;
|
|
95
|
+
message: string;
|
|
96
|
+
next?: TrackScore;
|
|
97
|
+
kind?: string;
|
|
98
|
+
payload?: Record<string, unknown>;
|
|
99
|
+
}>;
|
|
100
|
+
|
|
101
|
+
export function applyStringCommand(
|
|
102
|
+
score: TrackScore,
|
|
103
|
+
trackId: string,
|
|
104
|
+
command: StringCommand,
|
|
105
|
+
): StringResult {
|
|
106
|
+
const track = score.tracks.find((candidate) => candidate.id === trackId);
|
|
107
|
+
if (!track) return { ok: false, message: `no track · ${trackId}` };
|
|
108
|
+
if (command.type === "string-presets")
|
|
109
|
+
return {
|
|
110
|
+
ok: true,
|
|
111
|
+
message: `string presets · ${STRING_PRESET_NAMES.map((name) => `${name} (${STRING_PRESETS[name]!.doc})`).join(" · ")}`,
|
|
112
|
+
};
|
|
113
|
+
if (track.sampler || track.kit)
|
|
114
|
+
return {
|
|
115
|
+
ok: false,
|
|
116
|
+
message: `string · ${trackId} is a ${track.sampler ? "sampler" : "drum"} track; the string engine plays pitched notes`,
|
|
117
|
+
};
|
|
118
|
+
const active = isStringTrack(track);
|
|
119
|
+
if (command.type === "string-list")
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
message: active
|
|
123
|
+
? `string · ${describeString(track.string)} · basics ${STRING_SIMPLE_PARAMS.join(" ")}`
|
|
124
|
+
: `string · off (${track.instrument}) · string ${DEFAULT_STRING_PRESET} turns it on · string presets`,
|
|
125
|
+
};
|
|
126
|
+
let instrument = track.instrument;
|
|
127
|
+
let string: Record<string, unknown> | null;
|
|
128
|
+
if (command.type === "string-off") {
|
|
129
|
+
if (!active) return { ok: true, message: "string · already off" };
|
|
130
|
+
instrument = "pluck";
|
|
131
|
+
string = null;
|
|
132
|
+
} else if (command.type === "string-reset") {
|
|
133
|
+
if (!active) return { ok: true, message: "string · already off" };
|
|
134
|
+
string = { preset: stringPresetOf(track.string) };
|
|
135
|
+
} else if (command.type === "string-preset") {
|
|
136
|
+
instrument = STRING_INSTRUMENT;
|
|
137
|
+
string = { preset: command.preset };
|
|
138
|
+
} else {
|
|
139
|
+
instrument = STRING_INSTRUMENT;
|
|
140
|
+
string = active ? { ...track.string } : { preset: DEFAULT_STRING_PRESET };
|
|
141
|
+
for (const [key, value] of Object.entries(command.values)) {
|
|
142
|
+
if (value === null) delete string[key];
|
|
143
|
+
else string[key] = value;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
let next: TrackScore;
|
|
147
|
+
try {
|
|
148
|
+
next = updateTrack(score, trackId, {
|
|
149
|
+
instrument,
|
|
150
|
+
string: string === null ? null : (normalizeString(string) ?? null),
|
|
151
|
+
});
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (
|
|
154
|
+
error instanceof ScoreValidationError ||
|
|
155
|
+
error instanceof FxValidationError
|
|
156
|
+
)
|
|
157
|
+
return { ok: false, message: `string · ${error.message}` };
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
const stored = next.tracks.find((candidate) => candidate.id === trackId);
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
message:
|
|
164
|
+
command.type === "string-off"
|
|
165
|
+
? `string · off · ${instrument}`
|
|
166
|
+
: `string · ${describeString(stored?.string)}`,
|
|
167
|
+
next,
|
|
168
|
+
kind: "score.string",
|
|
169
|
+
payload: {
|
|
170
|
+
trackId,
|
|
171
|
+
instrument: stored?.instrument ?? instrument,
|
|
172
|
+
string: stored?.string ?? null,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
package/src/commands/time.ts
CHANGED
|
@@ -446,7 +446,10 @@ function applyOrThrow(
|
|
|
446
446
|
const tick = tickOf(score, command.at);
|
|
447
447
|
const where = placeLabel(command.at);
|
|
448
448
|
if (tick >= loopTicksOf(score))
|
|
449
|
-
return {
|
|
449
|
+
return {
|
|
450
|
+
ok: false,
|
|
451
|
+
message: `tempo · ${where} is past the song end (${score.bars} bars) · bars <n> extends it`,
|
|
452
|
+
};
|
|
450
453
|
if (tick === 0) {
|
|
451
454
|
const next = applyScoreOperation(score, {
|
|
452
455
|
type: "setTempo",
|