@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
package/core/granular.ts
ADDED
|
@@ -0,0 +1,528 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Granular instrument (0.6): the `Track.granular` field, its parameter
|
|
3
|
+
* table, presets and validation. Pure, no audio imports; the voice lives in
|
|
4
|
+
* `src/audio/granular.ts` and reads `resolveGranular`.
|
|
5
|
+
*
|
|
6
|
+
* Names follow common granular UIs (Mutable Instruments Clouds, Ableton
|
|
7
|
+
* Granulator II) where Strudel has no word: `pos`, `grain`, `overlap`,
|
|
8
|
+
* `spray`, `jitter`, `freeze`; `begin`/`end`/`attack`/`release` are the
|
|
9
|
+
* Strudel names.
|
|
10
|
+
*/
|
|
11
|
+
import type { ParamSpec } from "./params.ts";
|
|
12
|
+
import { FxValidationError, isRecord, normalizeParam } from "./params.ts";
|
|
13
|
+
import { SYNTH_PRESETS, SYNTH_SOUNDS } from "./synth.ts";
|
|
14
|
+
import type { SampleRef } from "./score.ts";
|
|
15
|
+
import { pitchToMidi } from "./pitch.ts";
|
|
16
|
+
|
|
17
|
+
/** Instrument name that plays a track's `granular` field. */
|
|
18
|
+
export const GRANULAR_INSTRUMENT = "granular" as const;
|
|
19
|
+
|
|
20
|
+
export function isGranularInstrument(instrument: string | undefined): boolean {
|
|
21
|
+
return instrument === GRANULAR_INSTRUMENT;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const GRAIN_WINDOWS = Object.freeze([
|
|
25
|
+
"hann",
|
|
26
|
+
"tukey",
|
|
27
|
+
"gauss",
|
|
28
|
+
"tri",
|
|
29
|
+
"perc",
|
|
30
|
+
"rperc",
|
|
31
|
+
] as const);
|
|
32
|
+
export type GrainWindow = (typeof GRAIN_WINDOWS)[number];
|
|
33
|
+
|
|
34
|
+
/** Prefix of a built-in source: a held render of a synth preset or sound. */
|
|
35
|
+
export const SYNTH_SOURCE_PREFIX = "synth:";
|
|
36
|
+
/** The source a granular track plays when `src` is absent. */
|
|
37
|
+
export const DEFAULT_GRANULAR_SOURCE = "synth:pad";
|
|
38
|
+
/** Note and length of a built-in synth source render. */
|
|
39
|
+
/** The sample-bank voice name a granular track's sample source loads under. */
|
|
40
|
+
export const GRANULAR_SOURCE_VOICE = "granular:src";
|
|
41
|
+
export const SYNTH_SOURCE_NOTE = 60;
|
|
42
|
+
export const SYNTH_SOURCE_SECONDS = 4;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A granular source: a sample (same shape and pinning as a sampler voice)
|
|
46
|
+
* or `synth:<preset|sound>[@<note>]`, a deterministic offline render.
|
|
47
|
+
*/
|
|
48
|
+
export type GranularSource = SampleRef | string;
|
|
49
|
+
|
|
50
|
+
/** Every stored field is optional; absent means the default or the preset. */
|
|
51
|
+
export type TrackGranular = Readonly<{
|
|
52
|
+
src?: GranularSource;
|
|
53
|
+
preset?: GranularPresetName;
|
|
54
|
+
seed?: number;
|
|
55
|
+
root?: number;
|
|
56
|
+
begin?: number;
|
|
57
|
+
end?: number;
|
|
58
|
+
pos?: number;
|
|
59
|
+
scan?: number;
|
|
60
|
+
grain?: number;
|
|
61
|
+
overlap?: number;
|
|
62
|
+
jitter?: number;
|
|
63
|
+
spray?: number;
|
|
64
|
+
pitch?: number;
|
|
65
|
+
detune?: number;
|
|
66
|
+
shimmer?: number;
|
|
67
|
+
shimint?: number;
|
|
68
|
+
spread?: number;
|
|
69
|
+
window?: GrainWindow;
|
|
70
|
+
reverse?: number;
|
|
71
|
+
freeze?: boolean;
|
|
72
|
+
repeat?: number;
|
|
73
|
+
hold?: number;
|
|
74
|
+
drift?: number;
|
|
75
|
+
drate?: number;
|
|
76
|
+
attack?: number;
|
|
77
|
+
release?: number;
|
|
78
|
+
veltone?: number;
|
|
79
|
+
gain?: number;
|
|
80
|
+
/**
|
|
81
|
+
* The instrument `grain off` returns to (set when granular turns on;
|
|
82
|
+
* absent: a sampler, a wavetable, or the source's synth).
|
|
83
|
+
*/
|
|
84
|
+
from?: string;
|
|
85
|
+
}>;
|
|
86
|
+
|
|
87
|
+
const n = (
|
|
88
|
+
min: number,
|
|
89
|
+
max: number,
|
|
90
|
+
value: number,
|
|
91
|
+
step: number | "log",
|
|
92
|
+
doc: string,
|
|
93
|
+
extra: Partial<{ unit: string; integer: boolean; automate: boolean }> = {},
|
|
94
|
+
): ParamSpec =>
|
|
95
|
+
Object.freeze({
|
|
96
|
+
kind: "number",
|
|
97
|
+
min,
|
|
98
|
+
max,
|
|
99
|
+
default: value,
|
|
100
|
+
step,
|
|
101
|
+
doc,
|
|
102
|
+
...extra,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The granular parameter table: one row per stored number, enum or flag,
|
|
107
|
+
* in stored key order. It drives validation, the printer, the menu, the
|
|
108
|
+
* `grain` command and the `set_granular` tool. `automate` marks the rows a
|
|
109
|
+
* later lane exposes as `grain-<name>` automation lanes.
|
|
110
|
+
*/
|
|
111
|
+
export const GRANULAR_PARAMS: Readonly<Record<string, ParamSpec>> =
|
|
112
|
+
Object.freeze({
|
|
113
|
+
seed: n(
|
|
114
|
+
0,
|
|
115
|
+
2_147_483_647,
|
|
116
|
+
0,
|
|
117
|
+
1,
|
|
118
|
+
"texture variation; same seed, same grains",
|
|
119
|
+
{
|
|
120
|
+
integer: true,
|
|
121
|
+
},
|
|
122
|
+
),
|
|
123
|
+
root: n(0, 127, 60, 1, "the note that plays the source at its own pitch", {
|
|
124
|
+
integer: true,
|
|
125
|
+
}),
|
|
126
|
+
begin: n(
|
|
127
|
+
0,
|
|
128
|
+
1,
|
|
129
|
+
0,
|
|
130
|
+
0.01,
|
|
131
|
+
"start of the region the head reads, 0..1 of the file",
|
|
132
|
+
),
|
|
133
|
+
end: n(0, 1, 1, 0.01, "end of the region the head reads, 0..1 of the file"),
|
|
134
|
+
pos: n(0, 1, 0, 0.01, "where the head starts inside the region", {
|
|
135
|
+
automate: true,
|
|
136
|
+
}),
|
|
137
|
+
scan: n(
|
|
138
|
+
-4,
|
|
139
|
+
4,
|
|
140
|
+
1,
|
|
141
|
+
0.05,
|
|
142
|
+
"head speed: 1 the source's own speed, 0 held, negative backwards",
|
|
143
|
+
{
|
|
144
|
+
unit: "x",
|
|
145
|
+
automate: true,
|
|
146
|
+
},
|
|
147
|
+
),
|
|
148
|
+
grain: n(0.005, 2, 0.08, "log", "grain length", {
|
|
149
|
+
unit: "s",
|
|
150
|
+
automate: true,
|
|
151
|
+
}),
|
|
152
|
+
overlap: n(
|
|
153
|
+
0.05,
|
|
154
|
+
32,
|
|
155
|
+
4,
|
|
156
|
+
0.5,
|
|
157
|
+
"grains sounding at once (density = overlap / grain)",
|
|
158
|
+
{
|
|
159
|
+
automate: true,
|
|
160
|
+
},
|
|
161
|
+
),
|
|
162
|
+
jitter: n(
|
|
163
|
+
0,
|
|
164
|
+
1,
|
|
165
|
+
0.25,
|
|
166
|
+
0.05,
|
|
167
|
+
"onset randomness, fraction of the grain period",
|
|
168
|
+
{
|
|
169
|
+
automate: true,
|
|
170
|
+
},
|
|
171
|
+
),
|
|
172
|
+
spray: n(0, 2, 0.01, 0.01, "random offset of each grain's read position", {
|
|
173
|
+
unit: "s",
|
|
174
|
+
automate: true,
|
|
175
|
+
}),
|
|
176
|
+
pitch: n(-48, 48, 0, 1, "semitones on top of the note", {
|
|
177
|
+
unit: "st",
|
|
178
|
+
automate: true,
|
|
179
|
+
}),
|
|
180
|
+
detune: n(0, 24, 0, 0.05, "random per-grain pitch spread (± half)", {
|
|
181
|
+
unit: "st",
|
|
182
|
+
automate: true,
|
|
183
|
+
}),
|
|
184
|
+
shimmer: n(0, 1, 0, 0.05, "chance a grain plays shimint semitones up", {
|
|
185
|
+
automate: true,
|
|
186
|
+
}),
|
|
187
|
+
shimint: n(-24, 24, 12, 1, "the shimmer interval", { unit: "st" }),
|
|
188
|
+
spread: n(0, 1, 0.3, 0.05, "stereo spread of the grains", {
|
|
189
|
+
automate: true,
|
|
190
|
+
}),
|
|
191
|
+
window: Object.freeze({
|
|
192
|
+
kind: "enum",
|
|
193
|
+
values: GRAIN_WINDOWS,
|
|
194
|
+
default: "hann",
|
|
195
|
+
doc: "grain envelope: hann tukey gauss tri perc rperc",
|
|
196
|
+
}),
|
|
197
|
+
reverse: n(0, 1, 0, 0.05, "chance a grain plays backwards", {
|
|
198
|
+
automate: true,
|
|
199
|
+
}),
|
|
200
|
+
freeze: Object.freeze({
|
|
201
|
+
kind: "boolean",
|
|
202
|
+
default: false,
|
|
203
|
+
doc: "hold the read head (the cloud stops moving)",
|
|
204
|
+
}),
|
|
205
|
+
repeat: n(
|
|
206
|
+
0,
|
|
207
|
+
1,
|
|
208
|
+
0,
|
|
209
|
+
0.05,
|
|
210
|
+
"chance a grain step latches the head (beat repeat)",
|
|
211
|
+
{
|
|
212
|
+
automate: true,
|
|
213
|
+
},
|
|
214
|
+
),
|
|
215
|
+
hold: n(1, 16, 1, 1, "grain steps a latch lasts", { integer: true }),
|
|
216
|
+
drift: n(0, 1, 0, 0.05, "slow random walk of the head (depth)", {
|
|
217
|
+
automate: true,
|
|
218
|
+
}),
|
|
219
|
+
drate: n(0.01, 10, 0.2, "log", "drift rate", { unit: "Hz" }),
|
|
220
|
+
attack: n(0, 10, 0.01, "log", "voice fade in", { unit: "s" }),
|
|
221
|
+
release: n(0, 20, 0.3, "log", "voice fade out after the note ends", {
|
|
222
|
+
unit: "s",
|
|
223
|
+
}),
|
|
224
|
+
veltone: n(0, 1, 0, 0.05, "soft notes are darker"),
|
|
225
|
+
gain: n(0, 4, 1, 0.05, "voice level"),
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
export type GranularParamName = keyof typeof GRANULAR_PARAMS & string;
|
|
229
|
+
|
|
230
|
+
/** Resolved settings the voice reads (presets and defaults applied). */
|
|
231
|
+
export type GranularSettings = Readonly<{
|
|
232
|
+
src: GranularSource;
|
|
233
|
+
seed: number;
|
|
234
|
+
root: number;
|
|
235
|
+
begin: number;
|
|
236
|
+
end: number;
|
|
237
|
+
pos: number;
|
|
238
|
+
scan: number;
|
|
239
|
+
grain: number;
|
|
240
|
+
overlap: number;
|
|
241
|
+
jitter: number;
|
|
242
|
+
spray: number;
|
|
243
|
+
pitch: number;
|
|
244
|
+
detune: number;
|
|
245
|
+
shimmer: number;
|
|
246
|
+
shimint: number;
|
|
247
|
+
spread: number;
|
|
248
|
+
window: GrainWindow;
|
|
249
|
+
reverse: number;
|
|
250
|
+
freeze: boolean;
|
|
251
|
+
repeat: number;
|
|
252
|
+
hold: number;
|
|
253
|
+
drift: number;
|
|
254
|
+
drate: number;
|
|
255
|
+
attack: number;
|
|
256
|
+
release: number;
|
|
257
|
+
veltone: number;
|
|
258
|
+
gain: number;
|
|
259
|
+
}>;
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Presets: partial settings over the defaults (the prototype's values).
|
|
263
|
+
* `microloop`, `sparkle` and `backwards` come into their own on a resampled
|
|
264
|
+
* phrase; on the built-in pad they are textures.
|
|
265
|
+
*/
|
|
266
|
+
export const GRANULAR_PRESETS = Object.freeze({
|
|
267
|
+
cloud: {
|
|
268
|
+
doc: "slow-scanning soft cloud, wide",
|
|
269
|
+
params: {
|
|
270
|
+
grain: 0.12,
|
|
271
|
+
overlap: 6,
|
|
272
|
+
jitter: 0.5,
|
|
273
|
+
spray: 0.06,
|
|
274
|
+
scan: 0.25,
|
|
275
|
+
spread: 0.6,
|
|
276
|
+
window: "hann",
|
|
277
|
+
attack: 0.4,
|
|
278
|
+
release: 1.2,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
hold: {
|
|
282
|
+
doc: "held, shimmering sustain of one moment",
|
|
283
|
+
params: {
|
|
284
|
+
grain: 0.2,
|
|
285
|
+
overlap: 8,
|
|
286
|
+
jitter: 0.6,
|
|
287
|
+
spray: 0.03,
|
|
288
|
+
freeze: true,
|
|
289
|
+
spread: 0.7,
|
|
290
|
+
window: "gauss",
|
|
291
|
+
attack: 0.6,
|
|
292
|
+
release: 2,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
sparkle: {
|
|
296
|
+
doc: "octave-and-fifth sparkle over a slow scan (best on a resample)",
|
|
297
|
+
params: {
|
|
298
|
+
grain: 0.15,
|
|
299
|
+
overlap: 8,
|
|
300
|
+
jitter: 0.5,
|
|
301
|
+
spray: 0.05,
|
|
302
|
+
scan: 0.2,
|
|
303
|
+
shimmer: 0.35,
|
|
304
|
+
shimint: 19,
|
|
305
|
+
spread: 0.8,
|
|
306
|
+
attack: 0.5,
|
|
307
|
+
release: 2.5,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
swarm: {
|
|
311
|
+
doc: "dense, detuned, fully wide",
|
|
312
|
+
params: {
|
|
313
|
+
grain: 0.09,
|
|
314
|
+
overlap: 14,
|
|
315
|
+
jitter: 0.8,
|
|
316
|
+
spray: 0.08,
|
|
317
|
+
detune: 0.35,
|
|
318
|
+
spread: 1,
|
|
319
|
+
scan: 0.5,
|
|
320
|
+
attack: 0.2,
|
|
321
|
+
release: 0.8,
|
|
322
|
+
},
|
|
323
|
+
},
|
|
324
|
+
stutter: {
|
|
325
|
+
doc: "dry 45 ms repeats that latch and follow the music",
|
|
326
|
+
params: {
|
|
327
|
+
grain: 0.045,
|
|
328
|
+
overlap: 1,
|
|
329
|
+
jitter: 0,
|
|
330
|
+
spray: 0,
|
|
331
|
+
scan: 1,
|
|
332
|
+
repeat: 0.5,
|
|
333
|
+
hold: 4,
|
|
334
|
+
window: "perc",
|
|
335
|
+
reverse: 0.25,
|
|
336
|
+
spread: 0.2,
|
|
337
|
+
attack: 0.002,
|
|
338
|
+
release: 0.08,
|
|
339
|
+
},
|
|
340
|
+
},
|
|
341
|
+
microloop: {
|
|
342
|
+
doc: "tight looping grains that slowly advance (best on a resample)",
|
|
343
|
+
params: {
|
|
344
|
+
grain: 0.06,
|
|
345
|
+
overlap: 2,
|
|
346
|
+
jitter: 0,
|
|
347
|
+
spray: 0,
|
|
348
|
+
scan: 0.5,
|
|
349
|
+
window: "tukey",
|
|
350
|
+
spread: 0.1,
|
|
351
|
+
attack: 0.005,
|
|
352
|
+
release: 0.15,
|
|
353
|
+
},
|
|
354
|
+
},
|
|
355
|
+
backwards: {
|
|
356
|
+
doc: "backwards swells (best on a resample)",
|
|
357
|
+
params: {
|
|
358
|
+
grain: 0.25,
|
|
359
|
+
overlap: 4,
|
|
360
|
+
jitter: 0.3,
|
|
361
|
+
spray: 0.02,
|
|
362
|
+
reverse: 1,
|
|
363
|
+
window: "rperc",
|
|
364
|
+
spread: 0.5,
|
|
365
|
+
attack: 0.05,
|
|
366
|
+
release: 0.6,
|
|
367
|
+
},
|
|
368
|
+
},
|
|
369
|
+
dust: {
|
|
370
|
+
doc: "sparse random crackles across the source",
|
|
371
|
+
params: {
|
|
372
|
+
grain: 0.02,
|
|
373
|
+
overlap: 0.4,
|
|
374
|
+
jitter: 1,
|
|
375
|
+
spray: 0.4,
|
|
376
|
+
detune: 0.2,
|
|
377
|
+
spread: 1,
|
|
378
|
+
window: "perc",
|
|
379
|
+
attack: 0.01,
|
|
380
|
+
release: 0.5,
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
} as const satisfies Record<
|
|
384
|
+
string,
|
|
385
|
+
{ doc: string; params: Partial<Record<string, number | string | boolean>> }
|
|
386
|
+
>);
|
|
387
|
+
|
|
388
|
+
export type GranularPresetName = keyof typeof GRANULAR_PRESETS;
|
|
389
|
+
export const GRANULAR_PRESET_NAMES: readonly GranularPresetName[] =
|
|
390
|
+
Object.freeze(Object.keys(GRANULAR_PRESETS) as GranularPresetName[]);
|
|
391
|
+
|
|
392
|
+
export function isGranularPreset(name: string): name is GranularPresetName {
|
|
393
|
+
return Object.prototype.hasOwnProperty.call(GRANULAR_PRESETS, name);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** Synth preset and sound names a `synth:` source accepts. */
|
|
397
|
+
export function synthSourceNames(): readonly string[] {
|
|
398
|
+
return [...Object.keys(SYNTH_PRESETS), ...SYNTH_SOUNDS];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/** `synth:<name>[@<note>]` parsed, or undefined when malformed or unknown. */
|
|
402
|
+
export function parseSynthSource(
|
|
403
|
+
src: string,
|
|
404
|
+
): Readonly<{ name: string; note: number }> | undefined {
|
|
405
|
+
if (!src.startsWith(SYNTH_SOURCE_PREFIX)) return undefined;
|
|
406
|
+
const match =
|
|
407
|
+
/^([a-z][a-z0-9_]{0,31})(?:@(\d{1,3}|[a-gA-G][#b]?-?\d))?$/.exec(
|
|
408
|
+
src.slice(SYNTH_SOURCE_PREFIX.length),
|
|
409
|
+
);
|
|
410
|
+
if (!match) return undefined;
|
|
411
|
+
const name = match[1]!;
|
|
412
|
+
const at = match[2];
|
|
413
|
+
const note =
|
|
414
|
+
at === undefined
|
|
415
|
+
? SYNTH_SOURCE_NOTE
|
|
416
|
+
: /^\d+$/.test(at)
|
|
417
|
+
? Number(at)
|
|
418
|
+
: pitchToMidi(at);
|
|
419
|
+
if (!(note <= 127) || !synthSourceNames().includes(name)) return undefined;
|
|
420
|
+
return { name, note };
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Validates `Track.granular`. Fields are kept as given (a value equal to
|
|
425
|
+
* the default still overrides the preset), in table order after `src` and
|
|
426
|
+
* `preset`. `normalizeRef` is the score's sample-ref validator.
|
|
427
|
+
*/
|
|
428
|
+
export function normalizeGranular(
|
|
429
|
+
input: unknown,
|
|
430
|
+
normalizeRef: (value: unknown) => SampleRef,
|
|
431
|
+
): TrackGranular | undefined {
|
|
432
|
+
if (input === undefined || input === null) return undefined;
|
|
433
|
+
if (!isRecord(input))
|
|
434
|
+
throw new FxValidationError("track granular must be an object or null");
|
|
435
|
+
const out: Record<string, unknown> = {};
|
|
436
|
+
if (input.src !== undefined) {
|
|
437
|
+
if (typeof input.src === "string") {
|
|
438
|
+
if (!parseSynthSource(input.src))
|
|
439
|
+
throw new FxValidationError(
|
|
440
|
+
`granular src "${input.src.slice(0, 40)}" must be synth:<preset>[@note] (${Object.keys(SYNTH_PRESETS).join(" ")}) or a sample`,
|
|
441
|
+
);
|
|
442
|
+
// A note name (`synth:pad@C3`) is stored as its MIDI number.
|
|
443
|
+
out.src = /@[a-gA-G]/.test(input.src)
|
|
444
|
+
? `${SYNTH_SOURCE_PREFIX}${parseSynthSource(input.src)!.name}@${parseSynthSource(input.src)!.note}`
|
|
445
|
+
: input.src;
|
|
446
|
+
} else out.src = normalizeRef(input.src);
|
|
447
|
+
}
|
|
448
|
+
if (input.preset !== undefined) {
|
|
449
|
+
if (typeof input.preset !== "string" || !isGranularPreset(input.preset))
|
|
450
|
+
throw new FxValidationError(
|
|
451
|
+
`granular preset must be one of ${GRANULAR_PRESET_NAMES.join(", ")}`,
|
|
452
|
+
);
|
|
453
|
+
out.preset = input.preset;
|
|
454
|
+
}
|
|
455
|
+
for (const [name, spec] of Object.entries(GRANULAR_PARAMS)) {
|
|
456
|
+
const value = input[name];
|
|
457
|
+
if (value === undefined) continue;
|
|
458
|
+
out[name] = normalizeParam(spec, value, `granular ${name}`);
|
|
459
|
+
}
|
|
460
|
+
if (input.from !== undefined) {
|
|
461
|
+
if (
|
|
462
|
+
typeof input.from !== "string" ||
|
|
463
|
+
!/^[a-z][a-z0-9_-]{0,31}$/.test(input.from) ||
|
|
464
|
+
isGranularInstrument(input.from)
|
|
465
|
+
)
|
|
466
|
+
throw new FxValidationError(
|
|
467
|
+
"granular from must be the instrument name grain off returns to",
|
|
468
|
+
);
|
|
469
|
+
out.from = input.from;
|
|
470
|
+
}
|
|
471
|
+
for (const key of Object.keys(input))
|
|
472
|
+
if (
|
|
473
|
+
key !== "src" &&
|
|
474
|
+
key !== "preset" &&
|
|
475
|
+
key !== "from" &&
|
|
476
|
+
!Object.prototype.hasOwnProperty.call(GRANULAR_PARAMS, key)
|
|
477
|
+
)
|
|
478
|
+
throw new FxValidationError(
|
|
479
|
+
`granular has no parameter "${key.slice(0, 32)}"`,
|
|
480
|
+
);
|
|
481
|
+
const begin = (out.begin as number | undefined) ?? 0;
|
|
482
|
+
const end = (out.end as number | undefined) ?? 1;
|
|
483
|
+
if (end <= begin)
|
|
484
|
+
throw new FxValidationError("granular end must be greater than begin");
|
|
485
|
+
return Object.freeze(out) as TrackGranular;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Defaults, then the preset, then the stored fields. */
|
|
489
|
+
export function resolveGranular(
|
|
490
|
+
granular: TrackGranular | undefined,
|
|
491
|
+
): GranularSettings {
|
|
492
|
+
const out: Record<string, unknown> = { src: DEFAULT_GRANULAR_SOURCE };
|
|
493
|
+
for (const [name, spec] of Object.entries(GRANULAR_PARAMS))
|
|
494
|
+
out[name] = spec.default;
|
|
495
|
+
if (granular?.preset)
|
|
496
|
+
Object.assign(out, GRANULAR_PRESETS[granular.preset].params);
|
|
497
|
+
if (granular)
|
|
498
|
+
for (const [key, value] of Object.entries(granular))
|
|
499
|
+
if (key !== "preset" && key !== "from" && value !== undefined)
|
|
500
|
+
out[key] = value;
|
|
501
|
+
return Object.freeze(out) as GranularSettings;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** A settings value as stored, or the preset's, or the default. */
|
|
505
|
+
export function granularValue(
|
|
506
|
+
granular: TrackGranular | undefined,
|
|
507
|
+
name: GranularParamName,
|
|
508
|
+
): number | string | boolean {
|
|
509
|
+
return resolveGranular(granular)[name as keyof GranularSettings] as
|
|
510
|
+
number | string | boolean;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** Ring-out after a note: release plus 1.5 grains, capped at 10 s. */
|
|
514
|
+
export const GRANULAR_TAIL_SECONDS_MAX = 10;
|
|
515
|
+
export function granularTailSeconds(
|
|
516
|
+
granular: TrackGranular | undefined,
|
|
517
|
+
): number {
|
|
518
|
+
const s = resolveGranular(granular);
|
|
519
|
+
return Math.min(GRANULAR_TAIL_SECONDS_MAX, s.release + 1.5 * s.grain);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** A short label for the source (`synth:pad`, a file name). */
|
|
523
|
+
export function granularSourceLabel(src: GranularSource | undefined): string {
|
|
524
|
+
const value = src ?? DEFAULT_GRANULAR_SOURCE;
|
|
525
|
+
if (typeof value === "string") return value;
|
|
526
|
+
const base = value.src.split("/").pop() ?? value.src;
|
|
527
|
+
return base;
|
|
528
|
+
}
|