@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.
Files changed (124) hide show
  1. package/CHANGELOG.md +63 -0
  2. package/DAWG.md +592 -49
  3. package/README.md +4 -4
  4. package/core/chords.ts +492 -4
  5. package/core/diff.ts +9 -0
  6. package/core/expression.ts +143 -6
  7. package/core/fx.ts +689 -2
  8. package/core/granular.ts +619 -0
  9. package/core/instruments.ts +442 -0
  10. package/core/keys.ts +955 -0
  11. package/core/resonators.ts +862 -0
  12. package/core/score.ts +529 -8
  13. package/core/sdk/eval-child.ts +2 -0
  14. package/core/sdk/print.ts +247 -16
  15. package/core/sdk/sync-instruments.ts +58 -0
  16. package/core/sdk/v1.ts +2417 -40
  17. package/core/sections.ts +44 -10
  18. package/core/strings.ts +1080 -0
  19. package/core/winds.ts +652 -0
  20. package/guides/automation.md +1 -0
  21. package/guides/chords.md +3 -1
  22. package/guides/effects.md +5 -2
  23. package/guides/media.md +1 -1
  24. package/guides/performance.md +3 -0
  25. package/guides/resample.md +26 -0
  26. package/guides/sounds.md +7 -2
  27. package/guides/tempo.md +1 -0
  28. package/package.json +1 -1
  29. package/src/agent/agent.ts +13 -0
  30. package/src/agent/chord-tools.ts +153 -1
  31. package/src/agent/expression-tools.ts +91 -0
  32. package/src/agent/granular-tools.ts +138 -0
  33. package/src/agent/models.ts +4 -4
  34. package/src/agent/ops.ts +42 -2
  35. package/src/agent/preview-tool.ts +7 -1
  36. package/src/agent/resample-tool.ts +131 -0
  37. package/src/agent/tools.ts +726 -17
  38. package/src/agent/xcb-agent.ts +13 -0
  39. package/src/audio/arrange.ts +42 -3
  40. package/src/audio/dsp/bank.ts +233 -0
  41. package/src/audio/dsp/envelope.ts +161 -0
  42. package/src/audio/dsp/fft.ts +6 -0
  43. package/src/audio/dsp/filters.ts +57 -0
  44. package/src/audio/dsp/interp.ts +112 -0
  45. package/src/audio/dsp/modal.ts +684 -0
  46. package/src/audio/dsp/onset.ts +197 -0
  47. package/src/audio/dsp/oversample.ts +202 -0
  48. package/src/audio/dsp/rng.ts +37 -0
  49. package/src/audio/dsp/shape.ts +81 -0
  50. package/src/audio/dsp/shift.ts +256 -0
  51. package/src/audio/dsp/stft.ts +73 -0
  52. package/src/audio/dsp/window.ts +77 -0
  53. package/src/audio/effects/chain.ts +23 -3
  54. package/src/audio/effects/common.ts +14 -0
  55. package/src/audio/effects/convolution.ts +106 -1
  56. package/src/audio/effects/gaze.ts +354 -0
  57. package/src/audio/effects/rig/cab.ts +99 -0
  58. package/src/audio/effects/rig/filters.ts +152 -0
  59. package/src/audio/effects/rig/gate.ts +39 -0
  60. package/src/audio/effects/rig/head.ts +487 -0
  61. package/src/audio/effects/rig/index.ts +170 -0
  62. package/src/audio/effects/rig/section.ts +78 -0
  63. package/src/audio/effects/rig/stomp.ts +238 -0
  64. package/src/audio/engine.ts +56 -6
  65. package/src/audio/fit.ts +447 -0
  66. package/src/audio/granular.ts +995 -0
  67. package/src/audio/instrument-check.ts +137 -0
  68. package/src/audio/instruments.ts +140 -0
  69. package/src/audio/keys/dsp.ts +323 -0
  70. package/src/audio/keys/electric.ts +420 -0
  71. package/src/audio/keys/engine.ts +485 -0
  72. package/src/audio/keys/organ.ts +1335 -0
  73. package/src/audio/keys/piano.ts +476 -0
  74. package/src/audio/keys/sympathetic.ts +127 -0
  75. package/src/audio/live-worker.ts +56 -0
  76. package/src/audio/live.ts +361 -15
  77. package/src/audio/loudness.ts +65 -20
  78. package/src/audio/preview.ts +17 -1
  79. package/src/audio/resample.ts +285 -0
  80. package/src/audio/resonators.ts +295 -0
  81. package/src/audio/sampler.ts +328 -35
  82. package/src/audio/samples.ts +56 -8
  83. package/src/audio/strings/body.ts +263 -0
  84. package/src/audio/strings/bow.ts +656 -0
  85. package/src/audio/strings/engine.ts +615 -0
  86. package/src/audio/strings/loop.ts +119 -0
  87. package/src/audio/strings/measure.test-helpers.ts +200 -0
  88. package/src/audio/strings/pluck.ts +354 -0
  89. package/src/audio/warp.ts +61 -0
  90. package/src/audio/wav.ts +191 -19
  91. package/src/audio/winds/engine.ts +302 -0
  92. package/src/audio/winds/filters.ts +153 -0
  93. package/src/audio/winds/pitch.ts +104 -0
  94. package/src/audio/winds/trim.ts +80 -0
  95. package/src/audio/winds/trims.ts +917 -0
  96. package/src/audio/winds/voice.ts +479 -0
  97. package/src/commands/expression.ts +111 -23
  98. package/src/commands/fit.ts +135 -0
  99. package/src/commands/fx.ts +35 -1
  100. package/src/commands/granular.ts +432 -0
  101. package/src/commands/help.ts +159 -5
  102. package/src/commands/keys.ts +629 -0
  103. package/src/commands/modal.ts +310 -0
  104. package/src/commands/resample.ts +281 -0
  105. package/src/commands/rig.ts +264 -0
  106. package/src/commands/sample.ts +52 -2
  107. package/src/commands/shift.ts +119 -0
  108. package/src/commands/string.ts +201 -0
  109. package/src/commands/strum.ts +473 -0
  110. package/src/commands/time.ts +4 -1
  111. package/src/commands/wind.ts +244 -0
  112. package/src/main.ts +339 -15
  113. package/src/project/check.ts +17 -0
  114. package/src/render.ts +49 -4
  115. package/src/session/presence.ts +16 -3
  116. package/src/tui/audition.ts +2 -2
  117. package/src/tui/granular-menu.ts +278 -0
  118. package/src/tui/menu.ts +934 -9
  119. package/src/tui/modal-menu.ts +145 -0
  120. package/src/tui/performance-menu.ts +42 -0
  121. package/src/tui/play-chords.ts +104 -3
  122. package/src/tui/play-mode.ts +1 -0
  123. package/src/tui/play-session.ts +70 -2
  124. package/src/tui/wind-menu.ts +144 -0
@@ -0,0 +1,432 @@
1
+ /**
2
+ * The `grain` prompt command: the focused track's granular instrument
3
+ * (core/granular.ts, `Track.granular`).
4
+ *
5
+ * grain this track's preset, source and overrides
6
+ * grain presets the granular presets
7
+ * grain <preset> | preset <name> play a preset (instrument "granular")
8
+ * grain on [voice V] grain this track's own sound
9
+ * grain src synth:<name>[@note] a built-in synth source
10
+ * grain src voice <V> a sampler voice of this track as source
11
+ * grain <param> <value> [...] override parameters (`off` unsets)
12
+ * grain reset drop the overrides, keep preset and source
13
+ * grain off back to the track's previous voice
14
+ *
15
+ * On a synth track the source is that synth (`synth:<instrument>`), on a
16
+ * sampler the first (or named) voice, pinned like the sampler voice. Each
17
+ * command is one `updateTrack` revision and one undo step.
18
+ */
19
+ import {
20
+ DEFAULT_GRANULAR_SOURCE,
21
+ GRANULAR_INSTRUMENT,
22
+ GRANULAR_PARAMS,
23
+ GRANULAR_PRESET_NAMES,
24
+ GRANULAR_PRESETS,
25
+ granularSourceLabel,
26
+ isGranularInstrument,
27
+ isGranularPreset,
28
+ normalizeGranular,
29
+ resolveGranular,
30
+ parseSynthSource,
31
+ synthSourceNames,
32
+ SYNTH_SOURCE_PREFIX,
33
+ type GranularPresetName,
34
+ type GranularSource,
35
+ type TrackGranular,
36
+ } from "../../core/granular.ts";
37
+ import { isDrumInstrument } from "../../core/drums.ts";
38
+ import { FxValidationError } from "../../core/params.ts";
39
+ import {
40
+ ScoreValidationError,
41
+ updateTrack,
42
+ type Track,
43
+ type TrackScore,
44
+ } from "../../core/score.ts";
45
+ import { pitchToMidi } from "../../core/pitch.ts";
46
+ import { normalizeSynth, SYNTH_PRESETS } from "../../core/synth.ts";
47
+ import { parseParamValue } from "./fx.ts";
48
+
49
+ /** `grain 0.12s · overlap 6 · scan 0.25x …`; overrides marked `*`. */
50
+ function basicsLine(settings: TrackGranular | undefined): string {
51
+ const resolved = resolveGranular(settings) as Record<string, unknown>;
52
+ return GRANULAR_SIMPLE_PARAMS.map((name) => {
53
+ const spec = GRANULAR_PARAMS[name]!;
54
+ const value = resolved[name];
55
+ const unit =
56
+ spec.kind === "number" && spec.unit
57
+ ? spec.unit
58
+ : name === "scan"
59
+ ? "x"
60
+ : "";
61
+ const shown =
62
+ typeof value === "number"
63
+ ? `${Number(value.toFixed(3))}${unit}`
64
+ : String(value);
65
+ const mark =
66
+ settings && Object.prototype.hasOwnProperty.call(settings, name)
67
+ ? "*"
68
+ : "";
69
+ return `${name} ${shown}${mark}`;
70
+ }).join(" · ");
71
+ }
72
+
73
+ /** The basics shown first in the menu and the `grain` listing. */
74
+ export const GRANULAR_SIMPLE_PARAMS = Object.freeze([
75
+ "grain",
76
+ "overlap",
77
+ "scan",
78
+ "pos",
79
+ "spray",
80
+ "pitch",
81
+ "shimmer",
82
+ "spread",
83
+ "freeze",
84
+ ]);
85
+
86
+ export type GranularCommand =
87
+ | { type: "grain-list" }
88
+ | { type: "grain-presets" }
89
+ | { type: "grain-reset" }
90
+ | { type: "grain-off" }
91
+ | { type: "grain-on"; voice?: string }
92
+ | { type: "grain-preset"; preset: GranularPresetName; voice?: string }
93
+ | { type: "grain-src"; synth?: string; voice?: string }
94
+ | {
95
+ type: "grain-set";
96
+ /** `null` unsets a parameter (the preset's value again). */
97
+ values: Readonly<Record<string, number | string | boolean | null>>;
98
+ };
99
+
100
+ const VOICE_NAME = /^[a-z0-9._-]{1,64}$/;
101
+
102
+ export function parseGranularCommand(
103
+ prompt: string,
104
+ ): GranularCommand | undefined {
105
+ const words = prompt.trim().split(/\s+/);
106
+ const head = words[0]?.toLowerCase();
107
+ if (head !== "grain" && head !== "granular") return undefined;
108
+ if (words.length === 1) return { type: "grain-list" };
109
+ if (prompt.length > 1_024) return undefined;
110
+ const rest = words.slice(1).map((word) => word.toLowerCase());
111
+ const voiceAt = (index: number): string | undefined | null => {
112
+ if (rest.length === index) return undefined;
113
+ if (
114
+ rest.length === index + 2 &&
115
+ rest[index] === "voice" &&
116
+ VOICE_NAME.test(rest[index + 1]!)
117
+ )
118
+ return rest[index + 1]!;
119
+ return null;
120
+ };
121
+ if (rest[0] === "reset" && rest.length === 1) return { type: "grain-reset" };
122
+ if (rest[0] === "off" && rest.length === 1) return { type: "grain-off" };
123
+ if ((rest[0] === "presets" || rest[0] === "list") && rest.length === 1)
124
+ return { type: "grain-presets" };
125
+ if (rest[0] === "on") {
126
+ const voice = voiceAt(1);
127
+ return voice === null ? undefined : { type: "grain-on", voice };
128
+ }
129
+ // `hold` is a preset and a parameter: `grain hold 4` sets the parameter.
130
+ const presetWord =
131
+ rest[0] === "preset" ||
132
+ (isGranularPreset(rest[0]!) &&
133
+ (rest.length === 1 ||
134
+ rest[1] === "voice" ||
135
+ !GRANULAR_PARAMS[rest[0]!] ||
136
+ rest.length % 2 !== 0));
137
+ if (presetWord) {
138
+ const at = rest[0] === "preset" ? 1 : 0;
139
+ const name = rest[at];
140
+ if (!name || !isGranularPreset(name)) return undefined;
141
+ const voice = voiceAt(at + 1);
142
+ return voice === null
143
+ ? undefined
144
+ : { type: "grain-preset", preset: name, voice };
145
+ }
146
+ if (rest[0] === "src" || rest[0] === "source") {
147
+ if (rest.length === 3 && rest[1] === "voice" && VOICE_NAME.test(rest[2]!))
148
+ return { type: "grain-src", voice: rest[2]! };
149
+ if (rest.length !== 2) return undefined;
150
+ const text = rest[1]!.startsWith(SYNTH_SOURCE_PREFIX)
151
+ ? rest[1]!
152
+ : `${SYNTH_SOURCE_PREFIX}${rest[1]!}`;
153
+ return parseSynthSource(text)
154
+ ? { type: "grain-src", synth: text }
155
+ : undefined;
156
+ }
157
+ if (rest.length % 2 !== 0) return undefined;
158
+ const values: Record<string, number | string | boolean | null> = {};
159
+ for (let index = 0; index < rest.length; index += 2) {
160
+ const name = rest[index]!;
161
+ const spec = GRANULAR_PARAMS[name];
162
+ if (!spec) return undefined;
163
+ // Dotted note values as the rest of dawg writes them: `1/8.` is `1/8d`.
164
+ const word =
165
+ name === "sync" ? rest[index + 1]!.replace(/\.$/, "d") : rest[index + 1]!;
166
+ if ((word === "off" || word === "unset") && spec.kind !== "boolean") {
167
+ values[name] = null;
168
+ continue;
169
+ }
170
+ const value =
171
+ name === "root" && !/^\d/.test(word)
172
+ ? noteNumber(word)
173
+ : parseParamValue(spec, word);
174
+ if (value === undefined) return undefined;
175
+ // 0.6.1 switches: off removes the field, so on-then-off leaves the
176
+ // score (and its sha256) as it was.
177
+ values[name] =
178
+ value === false && (name === "mono" || name === "pedal") ? null : value;
179
+ }
180
+ return { type: "grain-set", values };
181
+ }
182
+
183
+ /** A note name (`c4`, `f#3`) as its MIDI number. */
184
+ function noteNumber(word: string): number | undefined {
185
+ const midi = pitchToMidi(word);
186
+ return Number.isFinite(midi) ? midi : undefined;
187
+ }
188
+
189
+ /** `cloud · synth:pad · scan 0.1`, preset first, then source and overrides. */
190
+ export function describeGranular(settings: TrackGranular | undefined): string {
191
+ const parts = [
192
+ settings?.preset ?? "default",
193
+ granularSourceLabel(settings?.src),
194
+ ];
195
+ for (const [key, value] of Object.entries(settings ?? {}))
196
+ if (
197
+ key !== "preset" &&
198
+ key !== "src" &&
199
+ key !== "from" &&
200
+ value !== undefined
201
+ )
202
+ parts.push(`${key} ${String(value)}`);
203
+ return parts.join(" · ");
204
+ }
205
+
206
+ /**
207
+ * The source a track's own sound gives: a sampler voice (the named one or
208
+ * the first), the track's synth when it is a known synth source, else the
209
+ * built-in pad.
210
+ */
211
+ export function ownGranularSource(
212
+ track: Track,
213
+ voice?: string,
214
+ ): { src: GranularSource } | { error: string } {
215
+ if (track.sampler) {
216
+ const names = Object.keys(track.sampler.voices);
217
+ const name = voice ?? names[0];
218
+ const ref = name === undefined ? undefined : track.sampler.voices[name];
219
+ if (!ref)
220
+ return {
221
+ error: `no sampler voice ${voice ?? ""} on ${track.id} (${names.join(" ") || "none"})`,
222
+ };
223
+ return { src: ref };
224
+ }
225
+ if (voice !== undefined)
226
+ return { error: `${track.id} has no sampler voices` };
227
+ if (track.granular?.src !== undefined) return { src: track.granular.src };
228
+ // `synth preset pad` stores supersaw plus pad's params: grain the preset.
229
+ const preset = synthPresetOf(track);
230
+ if (preset) return { src: `${SYNTH_SOURCE_PREFIX}${preset}` };
231
+ // A sound plays with the track's own synth params (src/audio/granular.ts).
232
+ if (synthSourceNames().includes(track.instrument))
233
+ return { src: `${SYNTH_SOURCE_PREFIX}${track.instrument}` };
234
+ return { src: DEFAULT_GRANULAR_SOURCE };
235
+ }
236
+
237
+ /** The synth preset a track's instrument and synth params are, if any. */
238
+ export function synthPresetOf(track: Track): string | undefined {
239
+ if (!track.synth) return undefined;
240
+ const own = JSON.stringify(track.synth);
241
+ for (const [name, preset] of Object.entries(SYNTH_PRESETS))
242
+ if (
243
+ preset.instrument === track.instrument &&
244
+ JSON.stringify(normalizeSynth({ ...preset.synth })) === own
245
+ )
246
+ return name;
247
+ return undefined;
248
+ }
249
+
250
+ export type GranularResult = Readonly<{
251
+ ok: boolean;
252
+ message: string;
253
+ next?: TrackScore;
254
+ kind?: string;
255
+ payload?: Record<string, unknown>;
256
+ }>;
257
+
258
+ /**
259
+ * The patch a granular change writes: instrument granular plus the
260
+ * validated settings, or `null` settings to turn it off.
261
+ */
262
+ export function applyGranularCommand(
263
+ score: TrackScore,
264
+ trackId: string,
265
+ command: GranularCommand,
266
+ ): GranularResult {
267
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
268
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
269
+ if (command.type === "grain-presets")
270
+ return {
271
+ ok: true,
272
+ message: `grain presets · ${GRANULAR_PRESET_NAMES.map((name) => `${name} (${GRANULAR_PRESETS[name].doc})`).join(" · ")}`,
273
+ };
274
+ if (track.kit || isDrumInstrument(track.instrument))
275
+ return {
276
+ ok: false,
277
+ message: `grain · ${trackId} is a drum track; granular plays pitched notes`,
278
+ };
279
+ const active = isGranularInstrument(track.instrument);
280
+ if (command.type === "grain-list")
281
+ return {
282
+ ok: true,
283
+ message: active
284
+ ? `grain · ${track.granular?.preset ?? "default"} · ${granularSourceLabel(track.granular?.src)} · ${basicsLine(track.granular)}`
285
+ : `grain · off (${track.instrument}) · grain cloud turns it on · grain presets`,
286
+ };
287
+ let instrument = track.instrument;
288
+ let granular: Record<string, unknown> | null;
289
+ let synthPatch: Record<string, unknown> | undefined;
290
+ const keepSrc = (): Record<string, unknown> | { error: string } => {
291
+ const own = ownGranularSource(track);
292
+ if ("error" in own) return own;
293
+ return own.src === DEFAULT_GRANULAR_SOURCE ? {} : { src: own.src };
294
+ };
295
+ if (command.type === "grain-off") {
296
+ if (!active) return { ok: true, message: "grain · already off" };
297
+ // A sampler keeps its voices; anything else returns to its synth
298
+ // source's voice (or sine), and the settings stay for `grain on`.
299
+ // The voice granular turned on from (`from`), else a wavetable, else
300
+ // the synth source's own instrument (a preset maps to its instrument
301
+ // and params), else sine.
302
+ const src = track.granular?.src;
303
+ const parsed = typeof src === "string" ? parseSynthSource(src) : undefined;
304
+ if (track.sampler) instrument = "sampler";
305
+ else if (track.granular?.from) instrument = track.granular.from;
306
+ else if (track.wavetable) instrument = "wavetable";
307
+ else if (parsed && SYNTH_PRESETS[parsed.name]) {
308
+ instrument = SYNTH_PRESETS[parsed.name]!.instrument;
309
+ if (!track.synth) synthPatch = { ...SYNTH_PRESETS[parsed.name]!.synth };
310
+ } else if (parsed) instrument = parsed.name;
311
+ else instrument = "sine";
312
+ granular = track.granular ? { ...track.granular } : null;
313
+ } else if (command.type === "grain-reset") {
314
+ if (!active) return { ok: true, message: "grain · already off" };
315
+ granular = {};
316
+ if (track.granular?.preset) granular.preset = track.granular.preset;
317
+ if (track.granular?.src !== undefined) granular.src = track.granular.src;
318
+ } else if (command.type === "grain-on" || command.type === "grain-preset") {
319
+ instrument = GRANULAR_INSTRUMENT;
320
+ const base: Record<string, unknown> =
321
+ active || (track.granular && command.voice === undefined)
322
+ ? { ...track.granular }
323
+ : {};
324
+ if (command.voice !== undefined || base.src === undefined) {
325
+ const own =
326
+ command.voice !== undefined
327
+ ? ownGranularSource(track, command.voice)
328
+ : ((): { src: GranularSource } | { error: string } => {
329
+ const kept = keepSrc();
330
+ if ("error" in kept) return kept as { error: string };
331
+ return {
332
+ src: (kept.src as GranularSource) ?? DEFAULT_GRANULAR_SOURCE,
333
+ };
334
+ })();
335
+ if ("error" in own) return { ok: false, message: `grain · ${own.error}` };
336
+ if (own.src === DEFAULT_GRANULAR_SOURCE) delete base.src;
337
+ else base.src = own.src;
338
+ }
339
+ if (command.type === "grain-preset") {
340
+ // A preset replaces the overrides; the source stays.
341
+ granular = { preset: command.preset };
342
+ if (base.src !== undefined) granular.src = base.src;
343
+ } else granular = base;
344
+ } else if (command.type === "grain-src") {
345
+ instrument = GRANULAR_INSTRUMENT;
346
+ granular = active || track.granular ? { ...track.granular } : {};
347
+ if (command.voice !== undefined) {
348
+ const own = ownGranularSource(track, command.voice);
349
+ if ("error" in own) return { ok: false, message: `grain · ${own.error}` };
350
+ granular.src = own.src;
351
+ } else if (command.synth === DEFAULT_GRANULAR_SOURCE) delete granular.src;
352
+ else granular.src = command.synth;
353
+ } else {
354
+ instrument = GRANULAR_INSTRUMENT;
355
+ granular = active && track.granular ? { ...track.granular } : {};
356
+ if (!active) {
357
+ const kept = keepSrc();
358
+ if ("error" in kept)
359
+ return { ok: false, message: `grain · ${kept.error}` };
360
+ Object.assign(granular, track.granular ?? {}, kept);
361
+ }
362
+ for (const [key, value] of Object.entries(command.values)) {
363
+ if (value === null) delete granular[key];
364
+ else granular[key] = value;
365
+ }
366
+ }
367
+ // Remember the voice to go back to when granular turns on.
368
+ if (granular !== null && command.type !== "grain-off") {
369
+ const from = active
370
+ ? track.granular?.from
371
+ : track.sampler || isGranularInstrument(track.instrument)
372
+ ? undefined
373
+ : track.instrument;
374
+ if (from) granular.from = from;
375
+ else delete granular.from;
376
+ }
377
+ let next: TrackScore;
378
+ try {
379
+ next = updateTrack(score, trackId, {
380
+ instrument,
381
+ granular: granular === null ? null : granularOrEmpty(granular),
382
+ ...(synthPatch ? { synth: synthPatch as never } : {}),
383
+ });
384
+ } catch (error) {
385
+ if (
386
+ error instanceof ScoreValidationError ||
387
+ error instanceof FxValidationError
388
+ )
389
+ return { ok: false, message: `grain · ${error.message}` };
390
+ throw error;
391
+ }
392
+ const stored = next.tracks.find((candidate) => candidate.id === trackId);
393
+ return {
394
+ ok: true,
395
+ message:
396
+ command.type === "grain-off"
397
+ ? `grain · off · ${instrument}`
398
+ : `grain · ${describeGranular(stored?.granular)}`,
399
+ next,
400
+ kind: "score.granular",
401
+ payload: {
402
+ trackId,
403
+ instrument: stored?.instrument ?? instrument,
404
+ granular: stored?.granular ?? null,
405
+ },
406
+ };
407
+ }
408
+
409
+ /** Validated settings; the score's normaliser re-checks the SampleRef. */
410
+ function granularOrEmpty(value: Record<string, unknown>): TrackGranular {
411
+ return normalizeGranular(value, (ref) => ref as never) ?? Object.freeze({});
412
+ }
413
+
414
+ /** Track names that create a granular track: `cloud`, `hold-2`, … */
415
+ export function granularTrackPreset(
416
+ trackId: string,
417
+ ): GranularPresetName | undefined {
418
+ const match = /^([a-z]+)(?:-\d{1,3})?$/.exec(trackId);
419
+ return match && isGranularPreset(match[1]!) ? match[1] : undefined;
420
+ }
421
+
422
+ /**
423
+ * The local answer for `grain src <something else>` (`grain src bus:guitars`),
424
+ * so a source dawg cannot read yet never goes to the agent.
425
+ */
426
+ export function grainSrcHint(prompt: string): string | undefined {
427
+ const words = prompt.trim().toLowerCase().split(/\s+/);
428
+ if (words[0] !== "grain" || (words[1] !== "src" && words[1] !== "source"))
429
+ return undefined;
430
+ if (parseGranularCommand(prompt)) return undefined;
431
+ return "grain src synth:<preset>[@note] | voice <name> · a bus or another track is not a source yet: render it and load the file as a sampler voice";
432
+ }
@@ -4,6 +4,7 @@
4
4
  * app commands take a slash (bare aliases keep working but are listed once,
5
5
  * in their canonical form).
6
6
  */
7
+ import { GUITAR_TUNING_NAMES } from "../../core/chords.ts";
7
8
  import { EXPRESSION_USAGE } from "./expression.ts";
8
9
 
9
10
  export type HelpGroup = "music" | "session" | "window" | "keys";
@@ -53,6 +54,12 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
53
54
  command: "wtenv|wtattack|wtdecay|wtrate|wtdepth|warp <n>",
54
55
  summary: "scan the table · warpmode bendp",
55
56
  },
57
+ {
58
+ command:
59
+ "grain <preset> | <param> <value> | on [voice V] | src synth:<name>|voice V | reset | off",
60
+ summary:
61
+ "granular cloud · grain cloud · grain scan 0.2 · track swarm-2 · grain presets",
62
+ },
56
63
  {
57
64
  command: "fx <effect> <param> <value> | on | off | preset <name>",
58
65
  summary: "effects · fx delay mix 0.3 · fx reverb on · fx lists them",
@@ -61,6 +68,80 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
61
68
  command: "synth <param> <value> | preset <name>",
62
69
  summary: "synth voice · synth lpf 1200 · synth lists every param",
63
70
  },
71
+ {
72
+ command: "string <preset> | <param> <value> | presets | reset | off",
73
+ summary:
74
+ "plucked strings · string sitar · string buzz 0.8 · string ring 6",
75
+ },
76
+ {
77
+ command: "bowed [<preset>] | <param> <value> | presets",
78
+ summary:
79
+ "bowed strings · bowed violin · bowed cellos · bowed pressure 0.7 · bowed sord 1",
80
+ },
81
+ {
82
+ command: "rig <preset> | reset",
83
+ summary:
84
+ "guitar rig · rig crunch · rig metal · rig shoegaze · rig lists them",
85
+ },
86
+ {
87
+ command:
88
+ "guitar tune <name|notes> | capo | hand | ring | position | reset",
89
+ summary: "guitar fretting · guitar tune dadgad · guitar capo 2",
90
+ },
91
+ {
92
+ command: "strum [chords] [pattern] [strokes D-DU-UDU] [speed 22ms]",
93
+ summary:
94
+ "strummed guitar chords · strum G D Em C folk · strum I V vi IV · strum alone strums the track's chords",
95
+ },
96
+ {
97
+ command: "fx wobble|bloom|swell|double [param value]",
98
+ summary:
99
+ "shoegaze · fx wobble depth 30 · fx double · fx reverb ir builtin:reverse",
100
+ },
101
+ {
102
+ command: "track jangle|punk|funk|ragged|gtr-lead|gtr-metal|bachata",
103
+ summary: "new guitar track with that rig",
104
+ },
105
+ {
106
+ command: "stomp|head|cab <type> | <param> <value>",
107
+ summary:
108
+ "pedal, amp, cabinet · stomp rat · head gain 7 gate -55 · cab 4x12",
109
+ },
110
+ {
111
+ command: "piano [<preset>] | grand | upright | felt | honkytonk",
112
+ summary: "modelled piano · piano ballad · piano lofi · piano prepared",
113
+ },
114
+ {
115
+ command: "keys <param> <value> | preset <name> | reset",
116
+ summary: "piano params · keys hardness 0.3 decay 1.5 · keys lists them",
117
+ },
118
+ {
119
+ command: "epiano|wurli|clav [preset <name>] | <param> <value>",
120
+ summary:
121
+ "electric keys · suitcase dyno funkclav · epiano vibe 0.6 · clav pickup bridge",
122
+ },
123
+ {
124
+ command:
125
+ "tonewheel [<drawbars>] | combo [<registers>] [<voice>] | pipe [<stops>] [<row> <value> …]",
126
+ summary:
127
+ "organs · tonewheel 888800008 perc 3rd · gospel · combo 08880 flute · pipe plenum · keys perc 3rd",
128
+ },
129
+ {
130
+ command: "rotary slow|fast|stop [at <beat>]",
131
+ summary:
132
+ "organ rotary speaker speed · rotary fast at 16 writes the keys-rotary lane",
133
+ },
134
+ {
135
+ command:
136
+ "modal <preset> | <param> <value> | mallet <name> | reset | off",
137
+ summary:
138
+ "mallets and bells · modal vibes · modal hardness 0.8 · modal presets",
139
+ },
140
+ {
141
+ command: "wind <preset> | <param> <value> | mute <name> | reset | off",
142
+ summary:
143
+ "winds and brass · wind flute · wind sax · wind players 4 · wind presets",
144
+ },
64
145
  {
65
146
  command: "art <articulation>|off [target]",
66
147
  summary:
@@ -83,6 +164,11 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
83
164
  command: "pedal <beat>-<beat>... | bars | down|half|up <beat> | off",
84
165
  summary: "sustain pedal · pedal 0-3.5 4-7.5 · pedal bars",
85
166
  },
167
+ {
168
+ command: "pedal soft|sost <beat>-<beat>... | bars | off",
169
+ summary:
170
+ "piano una corda and sostenuto · pedal soft 0-8 · pedal sost 0-4",
171
+ },
86
172
  {
87
173
  command: "velcurve linear|soft|hard|fixed [<v>]",
88
174
  summary: "how velocity maps to level · velcurve fixed 0.6 (0..1)",
@@ -203,6 +289,20 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
203
289
  summary: "repeats pace accent prob swing …",
204
290
  },
205
291
  { command: "grid <voice> <x.X.>", summary: "explicit steps · X accent" },
292
+ {
293
+ command: "shift <semitones> [formant keep|follow|<n>] [<voice>]",
294
+ summary:
295
+ "a sample's pitch without changing its length · shift 0 clears",
296
+ },
297
+ {
298
+ command: "fade [in|out] <seconds> [<voice>]",
299
+ summary: "a sample's fade in and out (Strudel fadeInTime/fadeTime)",
300
+ },
301
+ {
302
+ command:
303
+ "resample <track>|orbit <n>|master [section <name>|bars a-b] [grain]",
304
+ summary: "render to a pinned WAV on a new sampler (or granular) track",
305
+ },
206
306
  { command: "undo", summary: "step back · Ctrl-Z" },
207
307
  { command: "redo", summary: "step forward · Ctrl-Y" },
208
308
  ],
@@ -224,6 +324,19 @@ export const HELP_SECTIONS: readonly HelpSection[] = [
224
324
  command: "/sample [<path> [as <voice>]]",
225
325
  summary: "add a sample voice · list voices",
226
326
  },
327
+ {
328
+ command: "/bpm <n> [<voice>]",
329
+ summary:
330
+ "the sample's own tempo (slash needed: bare bpm is song tempo)",
331
+ },
332
+ {
333
+ command: "/fitmode [repitch|beats|tones|auto] [<voice>]",
334
+ summary: "how it fits · alone suggests one from the sound",
335
+ },
336
+ {
337
+ command: "/len <beats> [<voice>]",
338
+ summary: "the sample lasts n beats of the song",
339
+ },
227
340
  ],
228
341
  },
229
342
  {
@@ -366,7 +479,7 @@ export const HELP_GUIDE: readonly HelpSection[] = [
366
479
  entries: [
367
480
  {
368
481
  command: "ctrl-k › Sound",
369
- summary: "instrument, envelope, filter, wavetable",
482
+ summary: "instrument, strings, envelope, filter, wavetable",
370
483
  },
371
484
  { command: "ctrl-k › Effects", summary: "delay, reverb, distortion …" },
372
485
  {
@@ -551,7 +664,7 @@ export const USAGE: Readonly<Record<string, string>> = {
551
664
  click: "/click on|off|<volume> · /click 50%",
552
665
  "count-in": "/count-in 0|1|2",
553
666
  chords:
554
- "/chords auto|manual|off · voicing <n> · spread · bass · perform · rate · octaves · sevenths · preset · style",
667
+ "/chords auto|manual|off · voicing <n> · spread · bass · perform · rate · octaves · sevenths · preset · style · strokes · speed",
555
668
  key: "key <tonic> <mode> | none · key A minor",
556
669
  scale: "scale [<tonic>] <name> | list · scale D hijaz",
557
670
  tuning:
@@ -561,7 +674,7 @@ export const USAGE: Readonly<Record<string, string>> = {
561
674
  grid: "/grid 1/4|1/8|1/8T|1/16|1/16T|1/32",
562
675
  tempo:
563
676
  "tempo takes 20…300 · tempo 120 · tempo 90 at bar 9 [ramp|exp] · tempo remove bar 9 · tempo clear · tempo map",
564
- bpm: "tempo takes 20…300 · tempo 120",
677
+ bpm: "tempo takes 20…300 · tempo 120 · a sample's own tempo: /bpm 174 [<voice>] · /bpm off",
565
678
  rit: "rit [<n> bars|beats] [to <bpm>] [at bar <n>|<beat>] [exp] · rit 4 bars to 80",
566
679
  ritardando:
567
680
  "rit [<n> bars|beats] [to <bpm>] [at bar <n>|<beat>] [exp] · rit 4 bars to 80",
@@ -583,12 +696,16 @@ export const USAGE: Readonly<Record<string, string>> = {
583
696
  bars: "bars takes 1…256 · bars 8",
584
697
  extend: "extend <count> bars · extend 4 bars",
585
698
  instrument:
586
- "instrument <name> · sine piano pluck bass saw square triangle wavetable kit · synth: sawtooth supersaw pulse white pink z_square…",
699
+ "instrument <name> · sine piano pluck bass saw square triangle wavetable kit · pianos: grand upright felt honkytonk prepared · electric: epiano suitcase dyno wurli clav funkclav · synth: sawtooth supersaw pulse white pink z_square…",
587
700
  volume: "volume takes 0…1 · volume 0.8",
588
701
  vol: "volume takes 0…1 · volume 0.8",
589
702
  pan: "pan takes -1…1 · pan -0.5",
590
703
  wt: "wt <table> | wt <0..1> | wt list · wt basic · wt wt_digital:2",
591
704
  wavetable: "wt <table> | wt <0..1> | wt list · wt basic · wt wt_digital:2",
705
+ grain:
706
+ "grain <preset> | <param> <value> | on [voice V] | src synth:<name>|voice V | reset | off | presets · sync 1/64…1/1 (t triplet, d or . dotted) · grain cloud · grain pitch 12 · grain sync 1/8.",
707
+ granular:
708
+ "grain <preset> | <param> <value> | on [voice V] | src synth:<name>|voice V | reset | off | presets · grain cloud",
592
709
  warpmode: "warpmode none|asym|bendp|bendm|bendmp|sync|quant",
593
710
  filter: "filter <hz> [res] · filter 800 0.3 · filter off",
594
711
  delay: "delay <beats> [fb] [mix] · delay 0.75 0.4 0.3 · delay off",
@@ -609,8 +726,17 @@ export const USAGE: Readonly<Record<string, string>> = {
609
726
  export: "/export <file> · /export loop.track.json",
610
727
  import: "/import <file> · /import loop.track.json",
611
728
  sample:
612
- "/sample <path> [as <voice>] · /sample set <voice> <control> <value>… · /sample set brk fit on clip 1",
729
+ "/sample <path> [as <voice>] · /sample set <voice> <control> <value>… · /sample set brk fit on clip 1 · /sample set soft vel 0-63 rr a",
613
730
  samples: "/sample · lists the focused track's voices",
731
+ fitmode:
732
+ "/fitmode [repitch|beats|tones|auto|off] [<voice>] · /fitmode beats · /fitmode auto brk",
733
+ len: "/len <beats> [<voice>] · /len 16 · /len off",
734
+ shift:
735
+ "shift <semitones> [formant keep|follow|<n>] [<voice>] · shift 7 formant keep · shift 0",
736
+ fade: "fade [in|out] <seconds> [<voice>] · fade out 0.5 · fade in 0.05 · fade off",
737
+ resample:
738
+ "resample <track>|orbit <n>|master [section <name>|bars a-b] [post] [grain] [as <id>] · resample lead · resample drums bars 1-2 grain · resample master section chorus",
739
+ bounce: "resample <track>|orbit <n>|master [section <name>|bars a-b] [grain]",
614
740
  view: "/view focus | all",
615
741
  transcript: "/transcript",
616
742
  log: "/transcript",
@@ -624,6 +750,34 @@ export const USAGE: Readonly<Record<string, string>> = {
624
750
  guide: "/guide [topic] · /guide chords · F1",
625
751
  fx: "fx <effect> <param> <value> | on | off | preset <name> · fx delay mix 0.3",
626
752
  synth: "synth <param> <value> | preset <name> · synth lpf 1200",
753
+ string:
754
+ "string <preset> | preset <name> | <param> <value> | presets | reset | off · string koto",
755
+ bowed:
756
+ "bowed [violin|viola|cello|contrabass|fiddle|erhu|kamancheh|violins|violas|cellos|contrabasses|pizz|trem] | <param> <value> | presets · bowed violin · bowed pressure 0.7",
757
+ rig: "rig clean|crunch|punk|ragged|lead|metal|fuzz|octave|funk|wah|bachata|spring|bassdrive|reese|jangle|alt|shoegaze|glide|dreampop|swell|ebow | reset",
758
+ guitar: `guitar tune ${GUITAR_TUNING_NAMES.join("|")} | E A D G B E · capo 0..12 · hand 3..6 · ring 0..1 · position · reset`,
759
+ strum:
760
+ "strum G D Em C [folk|pop|punk|…] [strokes D-DU-UDU] [speed 22ms] [each 4] [at 0] · strum alone strums the track's chords",
761
+ stomp:
762
+ "stomp fuzz|face|od|rat|octave | gain <0-10> tone <0-1> level <dB> | off",
763
+ head: "head clean|chime|crunch|lead|high|solid|bass | gain bass mid treble presence master <0-10> | gate <dB> | off",
764
+ cab: "cab 1x12|2x12|4x12|1x10|open|8x10|1x15|di | mic <0-1> | off",
765
+ keys: "keys <param> <value> | preset <name> | reset | presets · keys hardness 0.3 · keys stretch 0 · keys sym 0.5 (sympathetic bloom under the sustain pedal)",
766
+ piano:
767
+ "piano [grand|ballad|upright|felt|lofi|honkytonk|prepared] · piano ballad",
768
+ epiano:
769
+ "epiano [preset epiano|suitcase|dyno] | bark bell tone vibe vibehz decay release <value> · epiano vibe 0.6 · rhodes",
770
+ wurli:
771
+ "wurli [preset wurli] | bark bell tone trem decay release <value> · wurli trem 0.5",
772
+ clav: "clav [preset clav|funkclav] | pickup neck|bridge|both|out | mute tone decay release <value> · clav pickup bridge",
773
+ tonewheel:
774
+ "tonewheel [<9 drawbar digits>] | hammond | b3 | gospel | jazzorgan · tonewheel 888800008",
775
+ combo: "combo [<5 register digits>] | farfisa | vox · combo 08880",
776
+ pipe: "pipe [plenum|flutes|cornet|reeds|strings|full | <stop> …] | church · pipe principal8,octave4",
777
+ rotary: "rotary slow|fast|stop [at <beat>] · rotary fast · rotary fast at 16",
778
+ modal:
779
+ "modal <preset> | <body> | <param> <value> | mallet <name> | pair <track> | gamelan | reset | off | presets · modal vibes · modal gangsa · modal ring 3",
780
+ wind: "wind <preset> | <param> <value> | mute <name> | reset | off | presets · wind flute · wind trumpet mute harmon · wind players 4",
627
781
  pack: "/pack list | info <name> | use <pack>/<sound> | add <url>",
628
782
  kit: "/kit [name] · /kit syn909",
629
783
  euclid: "/euclid [voice] · euclid hat 7 16",