@hraness/dawg 0.4.1 → 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.
Files changed (166) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/DAWG.md +551 -33
  3. package/README.md +4 -4
  4. package/core/chords.ts +288 -7
  5. package/core/diff.ts +41 -12
  6. package/core/expression.ts +1241 -0
  7. package/core/fx.ts +437 -2
  8. package/core/granular.ts +528 -0
  9. package/core/instruments.ts +281 -0
  10. package/core/keys.ts +386 -0
  11. package/core/loop.ts +23 -0
  12. package/core/master.ts +455 -0
  13. package/core/midi.ts +452 -0
  14. package/core/resonators.ts +574 -0
  15. package/core/rhythm.ts +7 -2
  16. package/core/score.ts +717 -62
  17. package/core/sdk/eval-child.ts +40 -3
  18. package/core/sdk/print.ts +614 -27
  19. package/core/sdk/sync-instruments.ts +58 -0
  20. package/core/sdk/v1.ts +2824 -45
  21. package/core/sections.ts +2072 -0
  22. package/core/strings.ts +845 -0
  23. package/core/synth.ts +11 -1
  24. package/core/tempo.ts +1318 -0
  25. package/core/tuning.ts +1180 -0
  26. package/guides/audition.md +26 -0
  27. package/guides/automation.md +26 -0
  28. package/guides/chords.md +28 -0
  29. package/guides/effects.md +29 -0
  30. package/guides/faders.md +29 -0
  31. package/guides/files.md +28 -0
  32. package/guides/getting-started.md +26 -0
  33. package/guides/index.ts +75 -0
  34. package/guides/keys.md +27 -0
  35. package/guides/media.md +27 -0
  36. package/guides/mix.md +23 -0
  37. package/guides/music.md +14 -0
  38. package/guides/notes.md +26 -0
  39. package/guides/performance.md +26 -0
  40. package/guides/play.md +24 -0
  41. package/guides/project.md +13 -0
  42. package/guides/providers.md +25 -0
  43. package/guides/rhythm.md +26 -0
  44. package/guides/sessions.md +21 -0
  45. package/guides/sound.md +15 -0
  46. package/guides/sounds.md +29 -0
  47. package/guides/tempo.md +27 -0
  48. package/guides/tracks.md +25 -0
  49. package/guides/web-search.md +21 -0
  50. package/package.json +3 -1
  51. package/src/agent/agent.ts +9 -0
  52. package/src/agent/brief.ts +77 -3
  53. package/src/agent/chord-tools.ts +9 -1
  54. package/src/agent/expression-tools.ts +336 -0
  55. package/src/agent/granular-tools.ts +138 -0
  56. package/src/agent/master-tools.ts +299 -0
  57. package/src/agent/models.ts +4 -4
  58. package/src/agent/ops.ts +68 -6
  59. package/src/agent/planner.ts +45 -2
  60. package/src/agent/preview-tool.ts +27 -4
  61. package/src/agent/section-tools.ts +411 -0
  62. package/src/agent/time-tools.ts +290 -0
  63. package/src/agent/tools.ts +574 -16
  64. package/src/agent/tuning-tools.ts +301 -0
  65. package/src/agent/xcb-agent.ts +9 -0
  66. package/src/audio/arrange.ts +489 -0
  67. package/src/audio/audition.ts +16 -3
  68. package/src/audio/click.ts +113 -1
  69. package/src/audio/clock.ts +71 -5
  70. package/src/audio/dsp/bank.ts +233 -0
  71. package/src/audio/dsp/fft.ts +6 -0
  72. package/src/audio/dsp/filters.ts +57 -0
  73. package/src/audio/dsp/interp.ts +112 -0
  74. package/src/audio/dsp/modal.ts +684 -0
  75. package/src/audio/dsp/onset.ts +197 -0
  76. package/src/audio/dsp/oversample.ts +202 -0
  77. package/src/audio/dsp/rng.ts +37 -0
  78. package/src/audio/dsp/shape.ts +81 -0
  79. package/src/audio/dsp/stft.ts +73 -0
  80. package/src/audio/dsp/window.ts +77 -0
  81. package/src/audio/effects/bus.ts +5 -4
  82. package/src/audio/effects/chain.ts +6 -1
  83. package/src/audio/effects/common.ts +30 -1
  84. package/src/audio/effects/dynamics.ts +2 -1
  85. package/src/audio/effects/filter.ts +5 -3
  86. package/src/audio/effects/modulation.ts +5 -1
  87. package/src/audio/effects/rig/cab.ts +99 -0
  88. package/src/audio/effects/rig/filters.ts +152 -0
  89. package/src/audio/effects/rig/gate.ts +39 -0
  90. package/src/audio/effects/rig/head.ts +487 -0
  91. package/src/audio/effects/rig/index.ts +170 -0
  92. package/src/audio/effects/rig/section.ts +78 -0
  93. package/src/audio/effects/rig/stomp.ts +238 -0
  94. package/src/audio/effects/space.ts +92 -2
  95. package/src/audio/engine.ts +100 -21
  96. package/src/audio/fit.ts +402 -0
  97. package/src/audio/granular.ts +664 -0
  98. package/src/audio/instrument-check.ts +133 -0
  99. package/src/audio/instruments.ts +111 -0
  100. package/src/audio/keys/dsp.ts +323 -0
  101. package/src/audio/keys/engine.ts +361 -0
  102. package/src/audio/keys/piano.ts +432 -0
  103. package/src/audio/live-worker.ts +54 -0
  104. package/src/audio/live.ts +263 -17
  105. package/src/audio/loudness.ts +596 -0
  106. package/src/audio/master.ts +660 -0
  107. package/src/audio/measure-worker.ts +45 -0
  108. package/src/audio/measure.ts +110 -0
  109. package/src/audio/player.ts +11 -6
  110. package/src/audio/preview.ts +76 -14
  111. package/src/audio/render-worker.ts +6 -1
  112. package/src/audio/renderer.ts +7 -1
  113. package/src/audio/resonators.ts +287 -0
  114. package/src/audio/sampler.ts +269 -34
  115. package/src/audio/samples.ts +30 -7
  116. package/src/audio/strings/body.ts +250 -0
  117. package/src/audio/strings/engine.ts +369 -0
  118. package/src/audio/strings/loop.ts +119 -0
  119. package/src/audio/strings/measure.test-helpers.ts +198 -0
  120. package/src/audio/strings/pluck.ts +354 -0
  121. package/src/audio/synth/voice.ts +60 -13
  122. package/src/audio/synth/zzfx.ts +10 -4
  123. package/src/audio/warp.ts +147 -0
  124. package/src/audio/wav.ts +377 -30
  125. package/src/commands/arrange.ts +949 -0
  126. package/src/commands/expression.ts +941 -0
  127. package/src/commands/fit.ts +135 -0
  128. package/src/commands/fx.ts +31 -0
  129. package/src/commands/granular.ts +427 -0
  130. package/src/commands/help.ts +355 -27
  131. package/src/commands/keys.ts +353 -0
  132. package/src/commands/master.ts +361 -0
  133. package/src/commands/modal.ts +247 -0
  134. package/src/commands/music.ts +6 -1
  135. package/src/commands/rig.ts +260 -0
  136. package/src/commands/sample.ts +3 -0
  137. package/src/commands/string.ts +175 -0
  138. package/src/commands/synth.ts +11 -1
  139. package/src/commands/time.ts +967 -0
  140. package/src/commands/tuning.ts +490 -0
  141. package/src/main.ts +919 -41
  142. package/src/project/check.ts +13 -0
  143. package/src/render.ts +139 -8
  144. package/src/session/daemon.ts +18 -8
  145. package/src/session/naming.ts +8 -1
  146. package/src/session/rebase.ts +21 -0
  147. package/src/tui/arrange-menu.ts +390 -0
  148. package/src/tui/audition.ts +38 -5
  149. package/src/tui/fader.ts +409 -0
  150. package/src/tui/granular-menu.ts +278 -0
  151. package/src/tui/menu-time.ts +401 -0
  152. package/src/tui/menu.ts +967 -23
  153. package/src/tui/modal-menu.ts +118 -0
  154. package/src/tui/performance-menu.ts +235 -0
  155. package/src/tui/play-chords.ts +3 -1
  156. package/src/tui/play-mode.ts +150 -6
  157. package/src/tui/play-session.ts +459 -43
  158. package/tui/app.ts +262 -13
  159. package/tui/arrange-strip.ts +174 -0
  160. package/tui/drawer.ts +478 -0
  161. package/tui/grammar.ts +63 -1
  162. package/tui/guide.ts +351 -0
  163. package/tui/highway.ts +87 -4
  164. package/tui/hits.ts +68 -0
  165. package/tui/input.ts +7 -0
  166. package/tui/keys.ts +72 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * `/fitmode <mode>`, `/bpm <n>` and `/len <beats>` (0.6): fit a sampler
3
+ * voice to the song's time. They act on the focused sampler track's voice
4
+ * (named, or its only voice) through `setSampleControls`, so validation and
5
+ * printing stay the sampler's. `/fitmode` with no mode suggests one from
6
+ * the sample's content (`suggestFitMode`); song tempo stays `tempo <n>`.
7
+ */
8
+ import {
9
+ SAMPLE_FIT_MODES,
10
+ isSamplerInstrument,
11
+ samplerVoiceSlots,
12
+ type SampleFitMode,
13
+ type TrackScore,
14
+ } from "../../core/score.ts";
15
+ import { setSampleControls, type SetSampleResult } from "./sample.ts";
16
+
17
+ export type FitCommand = Readonly<{
18
+ control: "fitmode" | "bpm" | "len";
19
+ /** Undefined: `/fitmode` alone (suggest) or `off`-style unset is null. */
20
+ value: SampleFitMode | number | null | undefined;
21
+ voice?: string;
22
+ }>;
23
+
24
+ const VOICE = "([a-z][a-z0-9_]{0,31})";
25
+
26
+ /**
27
+ * Parse `/fitmode [mode|auto|off [voice]]`, `/bpm <n|off> [voice]`,
28
+ * `/len <beats|off> [voice]`. `fitmode` and `len` also work without the
29
+ * slash; a bare `bpm 120` stays the song's tempo word, so `/bpm` needs it.
30
+ */
31
+ export function parseFitCommand(command: string): FitCommand | undefined {
32
+ const text = command.trim();
33
+ const mode = text.match(
34
+ new RegExp(
35
+ `^/?fitmode(?:\\s+(repitch|beats|tones|auto|off|none)(?:\\s+${VOICE})?)?$`,
36
+ "i",
37
+ ),
38
+ );
39
+ if (mode) {
40
+ const raw = mode[1]?.toLowerCase();
41
+ return {
42
+ control: "fitmode",
43
+ value:
44
+ raw === undefined || raw === "auto"
45
+ ? undefined
46
+ : raw === "off" || raw === "none"
47
+ ? null
48
+ : (raw as SampleFitMode),
49
+ ...(mode[2] ? { voice: mode[2].toLowerCase() } : {}),
50
+ };
51
+ }
52
+ const number = text.match(
53
+ new RegExp(
54
+ `^(/bpm|/?len)\\s+(\\d+(?:\\.\\d+)?|off|none)(?:\\s+${VOICE})?$`,
55
+ "i",
56
+ ),
57
+ );
58
+ if (!number) return undefined;
59
+ const raw = number[2]!.toLowerCase();
60
+ return {
61
+ control: number[1]!.toLowerCase().replace("/", "") as "bpm" | "len",
62
+ value: raw === "off" || raw === "none" ? null : Number(raw),
63
+ ...(number[3] ? { voice: number[3].toLowerCase() } : {}),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * The voice a fit command acts on: the named one, else the track's only
69
+ * voice (lowest slot first when it has several and none is named).
70
+ */
71
+ export function fitVoice(
72
+ score: TrackScore,
73
+ trackId: string,
74
+ voice: string | undefined,
75
+ ): { voice: string } | { error: string } {
76
+ const track = score.tracks.find((item) => item.id === trackId);
77
+ const sampler = track?.sampler;
78
+ if (!track || !sampler || !isSamplerInstrument(track.instrument))
79
+ return {
80
+ error: `fit · ${trackId} is not a sampler track · /sample <path> first · song tempo is tempo <bpm>`,
81
+ };
82
+ if (voice) return { voice };
83
+ const names = Object.keys(sampler.voices);
84
+ if (names.length === 1) return { voice: names[0]! };
85
+ const slots = samplerVoiceSlots(sampler);
86
+ const sorted = names.sort(
87
+ (a, b) => (slots.get(a) ?? 0) - (slots.get(b) ?? 0) || (a < b ? -1 : 1),
88
+ );
89
+ return {
90
+ error: `fit · ${trackId} has ${names.length} voices · name one: ${sorted.join(" ")}`,
91
+ };
92
+ }
93
+
94
+ /** Apply a parsed fit command with a known value (a suggested mode resolved). */
95
+ export function applyFitCommand(
96
+ score: TrackScore,
97
+ trackId: string,
98
+ command: FitCommand,
99
+ mode?: SampleFitMode,
100
+ ): SetSampleResult {
101
+ const target = fitVoice(score, trackId, command.voice);
102
+ if ("error" in target) return { ok: false, message: target.error };
103
+ const value = command.value === undefined ? (mode ?? null) : command.value;
104
+ if (command.control === "fitmode" && value !== null) {
105
+ const ref = score.tracks.find((t) => t.id === trackId)?.sampler?.voices[
106
+ target.voice
107
+ ];
108
+ if (
109
+ ref &&
110
+ value !== "repitch" &&
111
+ ref.bpm === undefined &&
112
+ ref.len === undefined &&
113
+ ref.fit !== true
114
+ )
115
+ return {
116
+ ok: false,
117
+ message: `fit · fitmode ${String(value)} needs the sample's tempo first · /bpm <n> or /len <beats>`,
118
+ };
119
+ }
120
+ const values: Record<string, number | string | null> = {
121
+ [command.control]: value,
122
+ };
123
+ // Unsetting bpm or len leaves a fitmode with nothing to fit: unset both.
124
+ if (value === null && command.control !== "fitmode") {
125
+ const ref = score.tracks.find((t) => t.id === trackId)?.sampler?.voices[
126
+ target.voice
127
+ ];
128
+ const other = command.control === "bpm" ? ref?.len : ref?.bpm;
129
+ if (ref?.fitmode && other === undefined && ref.fit !== true)
130
+ values.fitmode = null;
131
+ }
132
+ return setSampleControls(score, trackId, target.voice, values);
133
+ }
134
+
135
+ export const FIT_MODES = SAMPLE_FIT_MODES;
@@ -18,6 +18,7 @@
18
18
  * on/off/true/false; enums take one of their values. Each command is one
19
19
  * `updateTrack` revision and one undo step.
20
20
  */
21
+ import { nearestWord } from "../audio/instrument-check.ts";
21
22
  import {
22
23
  EFFECT_NAMES,
23
24
  FX_PRESETS,
@@ -190,6 +191,31 @@ export function parseFxCommand(prompt: string): FxCommand | undefined {
190
191
  return { type: "fx-set", effect, values };
191
192
  }
192
193
 
194
+ /**
195
+ * A short `fx <word> …` whose word is no effect (`fx wobble on`, `fx dela
196
+ * mix 0.3`): the local answer, so a typo never goes to the agent. Longer
197
+ * free text after `fx` still does.
198
+ */
199
+ export function unknownFxMessage(prompt: string): string | undefined {
200
+ const words = prompt.trim().split(/\s+/);
201
+ if (words[0]?.toLowerCase() !== "fx" || words.length < 2) return undefined;
202
+ const name = words[1]!.toLowerCase();
203
+ if (parseEffectName(name) || ["ir", "iresponse", "amp"].includes(name))
204
+ return undefined;
205
+ const near = nearestWord(name, [
206
+ ...EFFECT_NAMES,
207
+ ...Object.keys(EFFECT_ALIASES),
208
+ "reverb",
209
+ ]);
210
+ const rest = words.slice(2).map((word) => word.toLowerCase());
211
+ const commandShaped =
212
+ rest.length <= 1 ||
213
+ ["on", "off", "reset", "preset"].includes(rest[0]!) ||
214
+ rest.every((word, index) => index % 2 === 0 || /^-?[\d.]+/.test(word));
215
+ if (!near && !commandShaped) return undefined;
216
+ return `unknown effect ${name.slice(0, 24)}${near ? ` · did you mean ${near}?` : ""} · effects ${EFFECT_NAMES.join(", ")}`;
217
+ }
218
+
193
219
  function irCommand(value: string): FxCommand | undefined {
194
220
  const lower = value.toLowerCase();
195
221
  if (lower === "off" || lower === "none") return { type: "fx-ir", ir: null };
@@ -208,6 +234,11 @@ export function effectDefaults(effect: EffectName): FxValues {
208
234
  if (!(spec.optional && spec.default === false)) out[key] = spec.default;
209
235
  // A zero `time` means "follow beats"; the canonical form omits it.
210
236
  if (effect === "delay") delete out.time;
237
+ // A head's sag follows its type and its noise gate is off until set.
238
+ if (effect === "head") {
239
+ delete out.sag;
240
+ delete out.gate;
241
+ }
211
242
  return out;
212
243
  }
213
244
 
@@ -0,0 +1,427 @@
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
+ const word = rest[index + 1]!;
164
+ if ((word === "off" || word === "unset") && spec.kind !== "boolean") {
165
+ values[name] = null;
166
+ continue;
167
+ }
168
+ const value =
169
+ name === "root" && !/^\d/.test(word)
170
+ ? noteNumber(word)
171
+ : parseParamValue(spec, word);
172
+ if (value === undefined) return undefined;
173
+ values[name] = value;
174
+ }
175
+ return { type: "grain-set", values };
176
+ }
177
+
178
+ /** A note name (`c4`, `f#3`) as its MIDI number. */
179
+ function noteNumber(word: string): number | undefined {
180
+ const midi = pitchToMidi(word);
181
+ return Number.isFinite(midi) ? midi : undefined;
182
+ }
183
+
184
+ /** `cloud · synth:pad · scan 0.1`, preset first, then source and overrides. */
185
+ export function describeGranular(settings: TrackGranular | undefined): string {
186
+ const parts = [
187
+ settings?.preset ?? "default",
188
+ granularSourceLabel(settings?.src),
189
+ ];
190
+ for (const [key, value] of Object.entries(settings ?? {}))
191
+ if (
192
+ key !== "preset" &&
193
+ key !== "src" &&
194
+ key !== "from" &&
195
+ value !== undefined
196
+ )
197
+ parts.push(`${key} ${String(value)}`);
198
+ return parts.join(" · ");
199
+ }
200
+
201
+ /**
202
+ * The source a track's own sound gives: a sampler voice (the named one or
203
+ * the first), the track's synth when it is a known synth source, else the
204
+ * built-in pad.
205
+ */
206
+ export function ownGranularSource(
207
+ track: Track,
208
+ voice?: string,
209
+ ): { src: GranularSource } | { error: string } {
210
+ if (track.sampler) {
211
+ const names = Object.keys(track.sampler.voices);
212
+ const name = voice ?? names[0];
213
+ const ref = name === undefined ? undefined : track.sampler.voices[name];
214
+ if (!ref)
215
+ return {
216
+ error: `no sampler voice ${voice ?? ""} on ${track.id} (${names.join(" ") || "none"})`,
217
+ };
218
+ return { src: ref };
219
+ }
220
+ if (voice !== undefined)
221
+ return { error: `${track.id} has no sampler voices` };
222
+ if (track.granular?.src !== undefined) return { src: track.granular.src };
223
+ // `synth preset pad` stores supersaw plus pad's params: grain the preset.
224
+ const preset = synthPresetOf(track);
225
+ if (preset) return { src: `${SYNTH_SOURCE_PREFIX}${preset}` };
226
+ // A sound plays with the track's own synth params (src/audio/granular.ts).
227
+ if (synthSourceNames().includes(track.instrument))
228
+ return { src: `${SYNTH_SOURCE_PREFIX}${track.instrument}` };
229
+ return { src: DEFAULT_GRANULAR_SOURCE };
230
+ }
231
+
232
+ /** The synth preset a track's instrument and synth params are, if any. */
233
+ export function synthPresetOf(track: Track): string | undefined {
234
+ if (!track.synth) return undefined;
235
+ const own = JSON.stringify(track.synth);
236
+ for (const [name, preset] of Object.entries(SYNTH_PRESETS))
237
+ if (
238
+ preset.instrument === track.instrument &&
239
+ JSON.stringify(normalizeSynth({ ...preset.synth })) === own
240
+ )
241
+ return name;
242
+ return undefined;
243
+ }
244
+
245
+ export type GranularResult = Readonly<{
246
+ ok: boolean;
247
+ message: string;
248
+ next?: TrackScore;
249
+ kind?: string;
250
+ payload?: Record<string, unknown>;
251
+ }>;
252
+
253
+ /**
254
+ * The patch a granular change writes: instrument granular plus the
255
+ * validated settings, or `null` settings to turn it off.
256
+ */
257
+ export function applyGranularCommand(
258
+ score: TrackScore,
259
+ trackId: string,
260
+ command: GranularCommand,
261
+ ): GranularResult {
262
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
263
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
264
+ if (command.type === "grain-presets")
265
+ return {
266
+ ok: true,
267
+ message: `grain presets · ${GRANULAR_PRESET_NAMES.map((name) => `${name} (${GRANULAR_PRESETS[name].doc})`).join(" · ")}`,
268
+ };
269
+ if (track.kit || isDrumInstrument(track.instrument))
270
+ return {
271
+ ok: false,
272
+ message: `grain · ${trackId} is a drum track; granular plays pitched notes`,
273
+ };
274
+ const active = isGranularInstrument(track.instrument);
275
+ if (command.type === "grain-list")
276
+ return {
277
+ ok: true,
278
+ message: active
279
+ ? `grain · ${track.granular?.preset ?? "default"} · ${granularSourceLabel(track.granular?.src)} · ${basicsLine(track.granular)}`
280
+ : `grain · off (${track.instrument}) · grain cloud turns it on · grain presets`,
281
+ };
282
+ let instrument = track.instrument;
283
+ let granular: Record<string, unknown> | null;
284
+ let synthPatch: Record<string, unknown> | undefined;
285
+ const keepSrc = (): Record<string, unknown> | { error: string } => {
286
+ const own = ownGranularSource(track);
287
+ if ("error" in own) return own;
288
+ return own.src === DEFAULT_GRANULAR_SOURCE ? {} : { src: own.src };
289
+ };
290
+ if (command.type === "grain-off") {
291
+ if (!active) return { ok: true, message: "grain · already off" };
292
+ // A sampler keeps its voices; anything else returns to its synth
293
+ // source's voice (or sine), and the settings stay for `grain on`.
294
+ // The voice granular turned on from (`from`), else a wavetable, else
295
+ // the synth source's own instrument (a preset maps to its instrument
296
+ // and params), else sine.
297
+ const src = track.granular?.src;
298
+ const parsed = typeof src === "string" ? parseSynthSource(src) : undefined;
299
+ if (track.sampler) instrument = "sampler";
300
+ else if (track.granular?.from) instrument = track.granular.from;
301
+ else if (track.wavetable) instrument = "wavetable";
302
+ else if (parsed && SYNTH_PRESETS[parsed.name]) {
303
+ instrument = SYNTH_PRESETS[parsed.name]!.instrument;
304
+ if (!track.synth) synthPatch = { ...SYNTH_PRESETS[parsed.name]!.synth };
305
+ } else if (parsed) instrument = parsed.name;
306
+ else instrument = "sine";
307
+ granular = track.granular ? { ...track.granular } : null;
308
+ } else if (command.type === "grain-reset") {
309
+ if (!active) return { ok: true, message: "grain · already off" };
310
+ granular = {};
311
+ if (track.granular?.preset) granular.preset = track.granular.preset;
312
+ if (track.granular?.src !== undefined) granular.src = track.granular.src;
313
+ } else if (command.type === "grain-on" || command.type === "grain-preset") {
314
+ instrument = GRANULAR_INSTRUMENT;
315
+ const base: Record<string, unknown> =
316
+ active || (track.granular && command.voice === undefined)
317
+ ? { ...track.granular }
318
+ : {};
319
+ if (command.voice !== undefined || base.src === undefined) {
320
+ const own =
321
+ command.voice !== undefined
322
+ ? ownGranularSource(track, command.voice)
323
+ : ((): { src: GranularSource } | { error: string } => {
324
+ const kept = keepSrc();
325
+ if ("error" in kept) return kept as { error: string };
326
+ return {
327
+ src: (kept.src as GranularSource) ?? DEFAULT_GRANULAR_SOURCE,
328
+ };
329
+ })();
330
+ if ("error" in own) return { ok: false, message: `grain · ${own.error}` };
331
+ if (own.src === DEFAULT_GRANULAR_SOURCE) delete base.src;
332
+ else base.src = own.src;
333
+ }
334
+ if (command.type === "grain-preset") {
335
+ // A preset replaces the overrides; the source stays.
336
+ granular = { preset: command.preset };
337
+ if (base.src !== undefined) granular.src = base.src;
338
+ } else granular = base;
339
+ } else if (command.type === "grain-src") {
340
+ instrument = GRANULAR_INSTRUMENT;
341
+ granular = active || track.granular ? { ...track.granular } : {};
342
+ if (command.voice !== undefined) {
343
+ const own = ownGranularSource(track, command.voice);
344
+ if ("error" in own) return { ok: false, message: `grain · ${own.error}` };
345
+ granular.src = own.src;
346
+ } else if (command.synth === DEFAULT_GRANULAR_SOURCE) delete granular.src;
347
+ else granular.src = command.synth;
348
+ } else {
349
+ instrument = GRANULAR_INSTRUMENT;
350
+ granular = active && track.granular ? { ...track.granular } : {};
351
+ if (!active) {
352
+ const kept = keepSrc();
353
+ if ("error" in kept)
354
+ return { ok: false, message: `grain · ${kept.error}` };
355
+ Object.assign(granular, track.granular ?? {}, kept);
356
+ }
357
+ for (const [key, value] of Object.entries(command.values)) {
358
+ if (value === null) delete granular[key];
359
+ else granular[key] = value;
360
+ }
361
+ }
362
+ // Remember the voice to go back to when granular turns on.
363
+ if (granular !== null && command.type !== "grain-off") {
364
+ const from = active
365
+ ? track.granular?.from
366
+ : track.sampler || isGranularInstrument(track.instrument)
367
+ ? undefined
368
+ : track.instrument;
369
+ if (from) granular.from = from;
370
+ else delete granular.from;
371
+ }
372
+ let next: TrackScore;
373
+ try {
374
+ next = updateTrack(score, trackId, {
375
+ instrument,
376
+ granular: granular === null ? null : granularOrEmpty(granular),
377
+ ...(synthPatch ? { synth: synthPatch as never } : {}),
378
+ });
379
+ } catch (error) {
380
+ if (
381
+ error instanceof ScoreValidationError ||
382
+ error instanceof FxValidationError
383
+ )
384
+ return { ok: false, message: `grain · ${error.message}` };
385
+ throw error;
386
+ }
387
+ const stored = next.tracks.find((candidate) => candidate.id === trackId);
388
+ return {
389
+ ok: true,
390
+ message:
391
+ command.type === "grain-off"
392
+ ? `grain · off · ${instrument}`
393
+ : `grain · ${describeGranular(stored?.granular)}`,
394
+ next,
395
+ kind: "score.granular",
396
+ payload: {
397
+ trackId,
398
+ instrument: stored?.instrument ?? instrument,
399
+ granular: stored?.granular ?? null,
400
+ },
401
+ };
402
+ }
403
+
404
+ /** Validated settings; the score's normaliser re-checks the SampleRef. */
405
+ function granularOrEmpty(value: Record<string, unknown>): TrackGranular {
406
+ return normalizeGranular(value, (ref) => ref as never) ?? Object.freeze({});
407
+ }
408
+
409
+ /** Track names that create a granular track: `cloud`, `hold-2`, … */
410
+ export function granularTrackPreset(
411
+ trackId: string,
412
+ ): GranularPresetName | undefined {
413
+ const match = /^([a-z]+)(?:-\d{1,3})?$/.exec(trackId);
414
+ return match && isGranularPreset(match[1]!) ? match[1] : undefined;
415
+ }
416
+
417
+ /**
418
+ * The local answer for `grain src <something else>` (`grain src bus:guitars`),
419
+ * so a source dawg cannot read yet never goes to the agent.
420
+ */
421
+ export function grainSrcHint(prompt: string): string | undefined {
422
+ const words = prompt.trim().toLowerCase().split(/\s+/);
423
+ if (words[0] !== "grain" || (words[1] !== "src" && words[1] !== "source"))
424
+ return undefined;
425
+ if (parseGranularCommand(prompt)) return undefined;
426
+ 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";
427
+ }