@hraness/dawg 0.3.0 → 0.4.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 (81) hide show
  1. package/CHANGELOG.md +138 -0
  2. package/DAWG.md +608 -39
  3. package/README.md +4 -4
  4. package/core/chords.ts +1724 -0
  5. package/core/diff.ts +13 -1
  6. package/core/euclid.ts +670 -0
  7. package/core/fx.ts +1065 -0
  8. package/core/kits.ts +320 -0
  9. package/core/params.ts +111 -0
  10. package/core/rhythm.ts +287 -0
  11. package/core/score.ts +735 -18
  12. package/core/sdk/eval-child.ts +34 -22
  13. package/core/sdk/eval.ts +4 -1
  14. package/core/sdk/print.ts +304 -22
  15. package/core/sdk/sync-chords.ts +54 -0
  16. package/core/sdk/v1.ts +3226 -21
  17. package/core/synth.ts +1001 -0
  18. package/package.json +1 -1
  19. package/src/agent/agent.ts +38 -3
  20. package/src/agent/brief.ts +29 -4
  21. package/src/agent/chord-tools.ts +357 -0
  22. package/src/agent/drum-tools.ts +135 -0
  23. package/src/agent/models.ts +4 -4
  24. package/src/agent/pack-tools.ts +369 -0
  25. package/src/agent/planner.ts +17 -2
  26. package/src/agent/preview-tool.ts +270 -0
  27. package/src/agent/rhythm-tools.ts +145 -0
  28. package/src/agent/tools.ts +308 -14
  29. package/src/agent/xcb-agent.ts +8 -2
  30. package/src/audio/audition.ts +93 -0
  31. package/src/audio/cache.ts +160 -0
  32. package/src/audio/effects/bus.ts +147 -0
  33. package/src/audio/effects/chain.ts +109 -0
  34. package/src/audio/effects/common.ts +251 -0
  35. package/src/audio/effects/convolution.ts +339 -0
  36. package/src/audio/effects/drive.ts +142 -0
  37. package/src/audio/effects/duck.ts +122 -0
  38. package/src/audio/effects/dynamics.ts +121 -0
  39. package/src/audio/effects/filter.ts +319 -0
  40. package/src/audio/effects/modulation.ts +174 -0
  41. package/src/audio/effects/space.ts +394 -0
  42. package/src/audio/engine.ts +112 -4
  43. package/src/audio/kits.ts +200 -0
  44. package/src/audio/packs.ts +1787 -0
  45. package/src/audio/preview.ts +481 -0
  46. package/src/audio/random.ts +15 -0
  47. package/src/audio/sampler.ts +157 -29
  48. package/src/audio/samples.ts +386 -44
  49. package/src/audio/synth/oscillators.ts +268 -0
  50. package/src/audio/synth/voice.ts +555 -0
  51. package/src/audio/synth/zzfx.ts +137 -0
  52. package/src/audio/wav.ts +411 -295
  53. package/src/audio/wavetable-maker.ts +717 -0
  54. package/src/audio/wavetable.ts +624 -0
  55. package/src/commands/drums.ts +299 -0
  56. package/src/commands/edit.ts +26 -4
  57. package/src/commands/fx.ts +360 -0
  58. package/src/commands/help.ts +298 -10
  59. package/src/commands/music.ts +10 -8
  60. package/src/commands/pack.ts +423 -0
  61. package/src/commands/rhythm.ts +230 -0
  62. package/src/commands/sample.ts +162 -2
  63. package/src/commands/synth.ts +229 -0
  64. package/src/commands/wavetable.ts +370 -0
  65. package/src/main.ts +1222 -30
  66. package/src/media/cli.ts +15 -2
  67. package/src/media/tools.ts +106 -1
  68. package/src/media/wavetable.ts +202 -0
  69. package/src/render.ts +22 -1
  70. package/src/session/naming.ts +3 -1
  71. package/src/tui/audition.ts +521 -0
  72. package/src/tui/euclid.ts +516 -0
  73. package/src/tui/menu.ts +1305 -244
  74. package/src/tui/play-chords.ts +630 -0
  75. package/src/tui/play-session.ts +364 -16
  76. package/src/tui/sketch.ts +108 -0
  77. package/src/web/search.ts +4 -1
  78. package/tui/app.ts +161 -39
  79. package/tui/grammar.ts +286 -0
  80. package/tui/highway.ts +7 -1
  81. package/tui/play-strip.ts +59 -14
@@ -20,6 +20,7 @@ import {
20
20
  TrackScore,
21
21
  addTrack,
22
22
  isSamplerInstrument,
23
+ normalizeSampleRef,
23
24
  samplerVoiceSlots,
24
25
  type Sampler,
25
26
  type SampleRef,
@@ -29,14 +30,67 @@ import { trackDirectories } from "../../core/sdk/print.ts";
29
30
 
30
31
  export type SampleCommand =
31
32
  | Readonly<{ kind: "list" }>
32
- | Readonly<{ kind: "add"; path: string; voice?: string }>;
33
+ | Readonly<{ kind: "add"; path: string; voice?: string }>
34
+ | Readonly<{
35
+ kind: "set";
36
+ voice: string;
37
+ values: Readonly<Record<string, SampleControlValue>>;
38
+ }>;
39
+
40
+ /** A sample control value: a number, a switch, a unit, or null to unset. */
41
+ export type SampleControlValue = number | string | boolean | null;
42
+
43
+ /**
44
+ * Per-voice sample controls `/sample set` and `set_sample` change, by
45
+ * Strudel name. Aliases resolve to the stored field.
46
+ */
47
+ export const SAMPLE_CONTROLS = Object.freeze({
48
+ begin: "fraction 0..1 of the file where playback starts",
49
+ end: "fraction 0..1 where it stops",
50
+ gain: "linear 0..2",
51
+ speed: "rate (negative reverses); with unit c/s a duration",
52
+ unit: "r (rate), c (speed in cycles = bars), s (speed in seconds)",
53
+ loop: "on/off: sustain by looping",
54
+ loopBegin: "fraction where the loop starts (≥ begin); alias loopb",
55
+ loopEnd: "fraction where the loop ends (≤ end); alias loope",
56
+ clip: "voice lasts note length × clip, cutting the sample; alias legato",
57
+ fit: "on/off: the window lasts the note's length",
58
+ loopAt: "the window lasts n bars (sets speed 1/n, unit c)",
59
+ accelerate: "rate ramps by this × over the voice (−8..8)",
60
+ squiz: "pitch-raise ratio per zero-crossing cycle (1..32)",
61
+ cut: "choke group name: a new hit stops the previous one",
62
+ });
63
+ export type SampleControl = keyof typeof SAMPLE_CONTROLS;
33
64
 
34
- /** `/sample`, `/sample <path>`, `/sample <path> as <voice>`; else undefined. */
65
+ const SAMPLE_ALIASES: Readonly<Record<string, string>> = {
66
+ loopb: "loopBegin",
67
+ loope: "loopEnd",
68
+ legato: "clip",
69
+ choke: "cut",
70
+ };
71
+
72
+ function controlName(raw: string): SampleControl | undefined {
73
+ const lower = raw.toLowerCase();
74
+ const name =
75
+ Object.keys(SAMPLE_CONTROLS).find((key) => key.toLowerCase() === lower) ??
76
+ SAMPLE_ALIASES[lower];
77
+ return name as SampleControl | undefined;
78
+ }
79
+
80
+ /** `/sample`, `/sample <path>`, `/sample <path> as <voice>`, `/sample set <voice> <control> <value>…`; else undefined. */
35
81
  export function parseSampleCommand(command: string): SampleCommand | undefined {
36
82
  const match = command.trim().match(/^\/samples?(?:\s+(.+?))?\s*$/i);
37
83
  if (!match) return undefined;
38
84
  const rest = match[1];
39
85
  if (!rest) return { kind: "list" };
86
+ const set = rest.match(/^set\s+([A-Za-z][A-Za-z0-9_]*)\s+(.+)$/i);
87
+ if (set) {
88
+ const tokens = set[2]!.split(/\s+/);
89
+ const values: Record<string, SampleControlValue> = {};
90
+ for (let i = 0; i < tokens.length; i += 2)
91
+ values[tokens[i]!] = parseControlToken(tokens[i + 1]);
92
+ return { kind: "set", voice: set[1]!, values };
93
+ }
40
94
  const named = rest.match(/^(.+?)\s+as\s+(\S+)$/i);
41
95
  const path = unquote((named ? named[1]! : rest).trim());
42
96
  return named
@@ -44,6 +98,112 @@ export function parseSampleCommand(command: string): SampleCommand | undefined {
44
98
  : { kind: "add", path };
45
99
  }
46
100
 
101
+ function parseControlToken(token: string | undefined): SampleControlValue {
102
+ if (token === undefined) return "";
103
+ const lower = token.toLowerCase();
104
+ if (lower === "on" || lower === "true") return true;
105
+ if (lower === "off" || lower === "false") return false;
106
+ if (lower === "none" || lower === "unset" || lower === "default") return null;
107
+ const value = Number(token);
108
+ return token.trim() !== "" && Number.isFinite(value) ? value : token;
109
+ }
110
+
111
+ export type SetSampleResult =
112
+ | Readonly<{ ok: true; next: TrackScore; message: string }>
113
+ | Readonly<{ ok: false; message: string }>;
114
+
115
+ /**
116
+ * Set or unset (`null`, or `off` for numbers) per-voice sample controls on
117
+ * `trackId`'s voice. Validation is the score's: limits and window rules.
118
+ */
119
+ export function setSampleControls(
120
+ score: TrackScore,
121
+ trackId: string,
122
+ voice: string,
123
+ values: Readonly<Record<string, SampleControlValue>>,
124
+ ): SetSampleResult {
125
+ const track = score.tracks.find((item) => item.id === trackId);
126
+ const sampler = track?.sampler;
127
+ if (!track || !sampler || !isSamplerInstrument(track.instrument))
128
+ return {
129
+ ok: false,
130
+ message: `sample · ${trackId} is not a sampler track · /sample <path> first`,
131
+ };
132
+ const ref = sampler.voices[voice];
133
+ if (!ref)
134
+ return {
135
+ ok: false,
136
+ message: `sample · ${trackId} has no voice "${voice}" · /sample lists them`,
137
+ };
138
+ const keys = Object.keys(values);
139
+ if (keys.length === 0)
140
+ return {
141
+ ok: false,
142
+ message: `sample · set needs a control · ${Object.keys(SAMPLE_CONTROLS).join(" ")}`,
143
+ };
144
+ const next: Record<string, unknown> = { ...ref };
145
+ const changed: string[] = [];
146
+ for (const raw of keys) {
147
+ const name = controlName(raw);
148
+ if (!name)
149
+ return {
150
+ ok: false,
151
+ message: `sample · unknown control "${raw.slice(0, 40)}" · ${Object.keys(SAMPLE_CONTROLS).join(" ")}`,
152
+ };
153
+ let value: SampleControlValue = values[raw] ?? null;
154
+ if (value === "")
155
+ return { ok: false, message: `sample · ${raw} needs a value` };
156
+ const field = name === "cut" ? "choke" : name;
157
+ // `off` unsets a control (back to its default): numbers, switches, cut.
158
+ if (value === false && name !== "unit") value = null;
159
+ if (name === "loopAt") {
160
+ if (value === null) {
161
+ delete next.speed;
162
+ delete next.unit;
163
+ } else if (typeof value !== "number" || value <= 0)
164
+ return {
165
+ ok: false,
166
+ message: "sample · loopAt needs a positive number of bars",
167
+ };
168
+ else {
169
+ next.speed = 1 / value;
170
+ next.unit = "c";
171
+ }
172
+ changed.push(value === null ? "loopAt off" : `loopAt ${value}`);
173
+ continue;
174
+ }
175
+ if (name === "cut" && typeof value === "number") value = `cut${value}`;
176
+ if (value === null) delete next[field];
177
+ else next[field] = value;
178
+ changed.push(`${raw} ${value === null ? "off" : String(value)}`);
179
+ }
180
+ let updated: TrackScore;
181
+ try {
182
+ const voices = {
183
+ ...sampler.voices,
184
+ [voice]: normalizeSampleRef(next, voice),
185
+ };
186
+ updated = new TrackScore({
187
+ ...score.toJSON(),
188
+ tracks: score.tracks.map((item) =>
189
+ item.id === trackId
190
+ ? { ...item, sampler: { ...sampler, voices } }
191
+ : item,
192
+ ),
193
+ });
194
+ } catch (error) {
195
+ return {
196
+ ok: false,
197
+ message: `sample · ${error instanceof Error ? error.message : String(error)}`,
198
+ };
199
+ }
200
+ return {
201
+ ok: true,
202
+ next: updated,
203
+ message: `sample · ${trackId}/${voice} · ${changed.join(" · ")}`,
204
+ };
205
+ }
206
+
47
207
  function unquote(value: string): string {
48
208
  const quoted = value.match(/^(["'])(.*)\1$/);
49
209
  return quoted ? quoted[2]! : value;
@@ -0,0 +1,229 @@
1
+ /**
2
+ * The `synth` prompt command: the focused track's synth voice parameters
3
+ * (core/synth.ts), named as in Strudel.
4
+ *
5
+ * synth list the parameters this track sets
6
+ * synth preset <name> load a preset (instrument + params)
7
+ * synth <param> <value> [<param> <value>…] set (Strudel aliases accepted)
8
+ * synth <param> off unset one parameter (back to default)
9
+ * synth partials 1 0.5 0.33 additive harmonics (also `phases`)
10
+ * synth reset unset every parameter
11
+ * synth zzfx 1,.05,220,,,.1,2 a raw ZzFX array (z_* sound + params)
12
+ *
13
+ * `synth lpf 800 lpenv 3 lpdecay 0.2`, `synth fm 4 fmh 1.5`, `synth adsr
14
+ * 0.01 0.2 0.5 0.3` (Strudel's `adsr` shorthand). Each command is one
15
+ * `updateTrack` revision and one undo step.
16
+ */
17
+ import { FxValidationError } from "../../core/params.ts";
18
+ import {
19
+ ScoreValidationError,
20
+ updateTrack,
21
+ type TrackScore,
22
+ } from "../../core/score.ts";
23
+ import {
24
+ SYNTH_PARAMS,
25
+ SYNTH_PRESETS,
26
+ SYNTH_SIMPLE,
27
+ isSynthList,
28
+ isSynthPreset,
29
+ normalizeSynth,
30
+ synthParamName,
31
+ ZZFX_ARRAY_LAYOUT,
32
+ zzfxArraySynth,
33
+ type TrackSynth,
34
+ } from "../../core/synth.ts";
35
+ import { parseParamValue } from "./fx.ts";
36
+
37
+ export type SynthCommand =
38
+ | { type: "synth-list" }
39
+ | { type: "synth-reset" }
40
+ | { type: "synth-preset"; preset: string }
41
+ /** A raw ZzFX parameter array (Strudel `zzfx([...])`); empty slots null. */
42
+ | { type: "synth-zzfx"; values: readonly (number | null)[] }
43
+ | {
44
+ type: "synth-set";
45
+ /** `null` unsets a parameter. */
46
+ values: Readonly<
47
+ Record<string, number | string | boolean | readonly number[] | null>
48
+ >;
49
+ };
50
+
51
+ const ADSR = ["attack", "decay", "sustain", "release"] as const;
52
+
53
+ export function parseSynthCommand(prompt: string): SynthCommand | undefined {
54
+ const words = prompt.trim().split(/\s+/);
55
+ if (words[0]?.toLowerCase() !== "synth") return undefined;
56
+ if (words.length === 1) return { type: "synth-list" };
57
+ if (prompt.length > 1_024) return undefined;
58
+ const rest = words.slice(1).map((word) => word.toLowerCase());
59
+ if (rest.length === 1 && (rest[0] === "reset" || rest[0] === "off"))
60
+ return { type: "synth-reset" };
61
+ if (rest[0] === "preset")
62
+ return rest.length === 2 && isSynthPreset(rest[1]!)
63
+ ? { type: "synth-preset", preset: rest[1]! }
64
+ : undefined;
65
+ const list = synthParamName(rest[0]!);
66
+ if (list && isSynthList(list)) {
67
+ if (rest.length === 2 && rest[1] === "off")
68
+ return { type: "synth-set", values: { [list]: null } };
69
+ const numbers = rest.slice(1).map(Number);
70
+ if (numbers.length === 0 || numbers.some((n) => !Number.isFinite(n)))
71
+ return undefined;
72
+ return { type: "synth-set", values: { [list]: numbers } };
73
+ }
74
+ if (rest[0] === "zzfx") return zzfxCommand(words.slice(2).join(" "));
75
+ if (rest[0] === "adsr") {
76
+ // Strudel's `adsr("a:d:s:r")`, as four words or one colon string.
77
+ const parts = rest.length === 2 ? rest[1]!.split(":") : rest.slice(1);
78
+ if (parts.length !== 4) return undefined;
79
+ const values: Record<string, number> = {};
80
+ for (const [index, name] of ADSR.entries()) {
81
+ const value = parseParamValue(SYNTH_PARAMS[name]!, parts[index]!);
82
+ if (typeof value !== "number") return undefined;
83
+ values[name] = value;
84
+ }
85
+ return { type: "synth-set", values };
86
+ }
87
+ if (rest.length % 2 !== 0) return undefined;
88
+ const values: Record<string, number | string | boolean | null> = {};
89
+ for (let index = 0; index < rest.length; index += 2) {
90
+ const name = synthParamName(rest[index]!);
91
+ if (!name || isSynthList(name)) return undefined;
92
+ const word = rest[index + 1]!;
93
+ if (word === "off" || word === "unset") {
94
+ values[name] = null;
95
+ continue;
96
+ }
97
+ const value = parseParamValue(SYNTH_PARAMS[name]!, word);
98
+ if (value === undefined) return undefined;
99
+ values[name] = value;
100
+ }
101
+ return { type: "synth-set", values };
102
+ }
103
+
104
+ /**
105
+ * `synth zzfx 1,.05,220,,,.1` or the pasted `zzfx(...[,,129,.01])` form:
106
+ * commas keep empty slots; without commas, spaces separate the values.
107
+ */
108
+ function zzfxCommand(text: string): SynthCommand | undefined {
109
+ const body = text
110
+ .trim()
111
+ .replace(/^zzfx\s*\(/i, "")
112
+ .replace(/\)\s*;?$/, "")
113
+ .replace(/^\.\.\./, "")
114
+ .replace(/^\[/, "")
115
+ .replace(/\]$/, "")
116
+ .trim();
117
+ if (body.length === 0) return undefined;
118
+ const parts = body.includes(",") ? body.split(",") : body.split(/\s+/);
119
+ if (parts.length > ZZFX_ARRAY_LAYOUT.length) return undefined;
120
+ const values: (number | null)[] = [];
121
+ for (const part of parts) {
122
+ const word = part.trim();
123
+ if (word === "") {
124
+ values.push(null);
125
+ continue;
126
+ }
127
+ const value = Number(
128
+ word.startsWith(".") ? `0${word}` : word.replace(/^-\./, "-0."),
129
+ );
130
+ if (!Number.isFinite(value)) return undefined;
131
+ values.push(value);
132
+ }
133
+ return { type: "synth-zzfx", values };
134
+ }
135
+
136
+ /** `attack 0.01 · lpf 800 …` in spec order, or `defaults`. */
137
+ export function describeSynth(synth: TrackSynth | undefined): string {
138
+ if (!synth) return "defaults";
139
+ return Object.entries(synth)
140
+ .map(([key, value]) =>
141
+ Array.isArray(value)
142
+ ? `${key} [${value.join(" ")}]`
143
+ : `${key} ${String(value)}`,
144
+ )
145
+ .join(" · ");
146
+ }
147
+
148
+ export type SynthResult = Readonly<{
149
+ ok: boolean;
150
+ message: string;
151
+ next?: TrackScore;
152
+ kind?: string;
153
+ payload?: Record<string, unknown>;
154
+ }>;
155
+
156
+ export function applySynthCommand(
157
+ score: TrackScore,
158
+ trackId: string,
159
+ command: SynthCommand,
160
+ ): SynthResult {
161
+ const track = score.tracks.find((candidate) => candidate.id === trackId);
162
+ if (!track) return { ok: false, message: `no track · ${trackId}` };
163
+ if (track.sampler)
164
+ return {
165
+ ok: false,
166
+ message: `synth · ${trackId} is a sampler track; synth parameters shape synth voices`,
167
+ };
168
+ if (command.type === "synth-list")
169
+ return {
170
+ ok: true,
171
+ message: `synth · ${track.instrument} · ${describeSynth(track.synth)} · basics ${SYNTH_SIMPLE.join(" ")}`,
172
+ };
173
+ let instrument = track.instrument;
174
+ let synth: Record<string, unknown> | null;
175
+ if (command.type === "synth-reset") {
176
+ if (!track.synth) return { ok: true, message: "synth · already defaults" };
177
+ synth = null;
178
+ } else if (command.type === "synth-zzfx") {
179
+ try {
180
+ const sound = zzfxArraySynth(command.values);
181
+ instrument = sound.instrument;
182
+ synth = { ...sound.synth };
183
+ } catch (error) {
184
+ if (error instanceof FxValidationError)
185
+ return { ok: false, message: `synth · ${error.message}` };
186
+ throw error;
187
+ }
188
+ } else if (command.type === "synth-preset") {
189
+ const preset = SYNTH_PRESETS[command.preset]!;
190
+ instrument = preset.instrument;
191
+ synth = { ...preset.synth };
192
+ } else {
193
+ synth = { ...track.synth };
194
+ for (const [key, value] of Object.entries(command.values)) {
195
+ if (value === null) delete synth[key];
196
+ else synth[key] = value;
197
+ }
198
+ }
199
+ let next: TrackScore;
200
+ try {
201
+ const normalized = normalizeSynth(synth);
202
+ next = updateTrack(score, trackId, {
203
+ instrument,
204
+ synth: normalized ?? null,
205
+ });
206
+ } catch (error) {
207
+ if (
208
+ error instanceof ScoreValidationError ||
209
+ error instanceof FxValidationError
210
+ )
211
+ return { ok: false, message: `synth · ${error.message}` };
212
+ throw error;
213
+ }
214
+ const stored = next.tracks.find((candidate) => candidate.id === trackId);
215
+ return {
216
+ ok: true,
217
+ message:
218
+ command.type === "synth-preset"
219
+ ? `synth · preset ${command.preset} · ${stored?.instrument} · ${describeSynth(stored?.synth)}`
220
+ : `synth · ${describeSynth(stored?.synth)}`,
221
+ next,
222
+ kind: "score.synth",
223
+ payload: {
224
+ trackId,
225
+ instrument: stored?.instrument ?? instrument,
226
+ synth: stored?.synth ?? null,
227
+ },
228
+ };
229
+ }