@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.
Files changed (88) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/DAWG.md +269 -13
  3. package/README.md +4 -4
  4. package/core/diff.ts +4 -0
  5. package/core/fx.ts +437 -2
  6. package/core/granular.ts +528 -0
  7. package/core/instruments.ts +281 -0
  8. package/core/keys.ts +386 -0
  9. package/core/resonators.ts +574 -0
  10. package/core/score.ts +181 -6
  11. package/core/sdk/eval-child.ts +2 -0
  12. package/core/sdk/print.ts +169 -13
  13. package/core/sdk/sync-instruments.ts +58 -0
  14. package/core/sdk/v1.ts +1114 -21
  15. package/core/sections.ts +34 -8
  16. package/core/strings.ts +845 -0
  17. package/guides/effects.md +3 -1
  18. package/guides/sounds.md +4 -0
  19. package/guides/tempo.md +1 -0
  20. package/package.json +1 -1
  21. package/src/agent/agent.ts +7 -0
  22. package/src/agent/granular-tools.ts +138 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/ops.ts +39 -2
  25. package/src/agent/preview-tool.ts +6 -1
  26. package/src/agent/tools.ts +538 -14
  27. package/src/agent/xcb-agent.ts +7 -0
  28. package/src/audio/arrange.ts +21 -3
  29. package/src/audio/dsp/bank.ts +233 -0
  30. package/src/audio/dsp/fft.ts +6 -0
  31. package/src/audio/dsp/filters.ts +57 -0
  32. package/src/audio/dsp/interp.ts +112 -0
  33. package/src/audio/dsp/modal.ts +684 -0
  34. package/src/audio/dsp/onset.ts +197 -0
  35. package/src/audio/dsp/oversample.ts +202 -0
  36. package/src/audio/dsp/rng.ts +37 -0
  37. package/src/audio/dsp/shape.ts +81 -0
  38. package/src/audio/dsp/stft.ts +73 -0
  39. package/src/audio/dsp/window.ts +77 -0
  40. package/src/audio/effects/chain.ts +6 -1
  41. package/src/audio/effects/rig/cab.ts +99 -0
  42. package/src/audio/effects/rig/filters.ts +152 -0
  43. package/src/audio/effects/rig/gate.ts +39 -0
  44. package/src/audio/effects/rig/head.ts +487 -0
  45. package/src/audio/effects/rig/index.ts +170 -0
  46. package/src/audio/effects/rig/section.ts +78 -0
  47. package/src/audio/effects/rig/stomp.ts +238 -0
  48. package/src/audio/engine.ts +10 -1
  49. package/src/audio/fit.ts +402 -0
  50. package/src/audio/granular.ts +664 -0
  51. package/src/audio/instrument-check.ts +133 -0
  52. package/src/audio/instruments.ts +111 -0
  53. package/src/audio/keys/dsp.ts +323 -0
  54. package/src/audio/keys/engine.ts +361 -0
  55. package/src/audio/keys/piano.ts +432 -0
  56. package/src/audio/live-worker.ts +54 -0
  57. package/src/audio/live.ts +231 -12
  58. package/src/audio/loudness.ts +65 -20
  59. package/src/audio/preview.ts +2 -0
  60. package/src/audio/resonators.ts +287 -0
  61. package/src/audio/sampler.ts +163 -17
  62. package/src/audio/samples.ts +30 -7
  63. package/src/audio/strings/body.ts +250 -0
  64. package/src/audio/strings/engine.ts +369 -0
  65. package/src/audio/strings/loop.ts +119 -0
  66. package/src/audio/strings/measure.test-helpers.ts +198 -0
  67. package/src/audio/strings/pluck.ts +354 -0
  68. package/src/audio/warp.ts +61 -0
  69. package/src/audio/wav.ts +78 -6
  70. package/src/commands/expression.ts +9 -2
  71. package/src/commands/fit.ts +135 -0
  72. package/src/commands/fx.ts +31 -0
  73. package/src/commands/granular.ts +427 -0
  74. package/src/commands/help.ts +74 -3
  75. package/src/commands/keys.ts +353 -0
  76. package/src/commands/modal.ts +247 -0
  77. package/src/commands/rig.ts +260 -0
  78. package/src/commands/sample.ts +3 -0
  79. package/src/commands/string.ts +175 -0
  80. package/src/commands/time.ts +4 -1
  81. package/src/main.ts +210 -8
  82. package/src/project/check.ts +13 -0
  83. package/src/render.ts +33 -4
  84. package/src/tui/audition.ts +2 -2
  85. package/src/tui/granular-menu.ts +278 -0
  86. package/src/tui/menu.ts +332 -9
  87. package/src/tui/modal-menu.ts +118 -0
  88. 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
+ }
@@ -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
+ }
@@ -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 { ok: false, message: `tempo · ${where} is past the song end` };
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",